--[[
============================================================================
ETB - Init.lua
============================================================================
Core initialization.
Includes debug hooks and enhanced logging.
============================================================================
]]--

local ADDON_NAME, NS = ...

-- Create global namespace
NS.ETB = NS.ETB or {}
local ETB = NS.ETB

-- Single source of truth: read the version straight from the .toc metadata so
-- /etb version, reports, and UI labels can never drift from the packaged build.
local function ReadAddonVersion()
    local getMeta = (C_AddOns and C_AddOns.GetAddOnMetadata) or GetAddOnMetadata
    local v = getMeta and getMeta(ADDON_NAME, "Version")
    return v or "Unknown"
end
ETB.version = ReadAddonVersion()

-- Data version for the precomputed BossLoot index (EnhancedTransmogBrowser_Loot).
-- Bump ONLY when the underlying compact/JLoot data changes and the loot module is
-- regenerated. Decoupled from ETB.version so code-only releases keep the precompute
-- valid. If a shipped loot module's dataVersion != this, BossLoot falls back to the
-- runtime build (Index:TryAttachPrecomputed).
ETB.LOOT_DATA_VERSION = 1
ETB.debug = false  -- Set true for development, false for clean chat
ETB.initialized = false

-- v12.43.0: removed _G.ETB_Addon and _G.ETB_NS globals. These were published
-- to bridge to the ETB_ERB sibling LoadOnDemand addon (v12.39.0–v12.40.2).
-- Since v12.41.0 merged ERB into this addon as a subfolder, all sibling-addon
-- bridging code is dead. Submodules access the namespace via the standard
-- `local ADDON_NAME, NS = ...` pattern.

-- ============================================================================
-- LAZY LOADERS FOR SIBLING ADDONS
-- ============================================================================
--
-- ERB (Enhanced Raid Browser) was merged back into this addon in v12.40.3
-- (previously shipped as the LoadOnDemand sibling ETB_ERB in v12.39.0-v12.40.2).
-- The folder `ERB/` inside this addon contains all ERB files; they load in
-- TOC order after the main Core, so ETB.ERB is populated at addon load time.
--
-- Callers can use either `ETB.ERB` directly or `ETB:GetERB()` for consistency
-- with the previous lazy-load API. Both return the same handle.
-- ============================================================================

function ETB:GetERB()
    -- ERB is now always loaded. The function is preserved for call-site
    -- compatibility with pre-v12.40.3 code that used it as a lazy loader.
    return ETB.ERB
end

-- Binding localization strings (required for keybinding UI)
BINDING_HEADER_ETBBINDING = "Enhanced Transmog Browser"
BINDING_NAME_ETB_TOGGLE_BROWSER = "Toggle ETB Browser"
BINDING_NAME_ETB_TOGGLE_TRANSMOGUI = "Toggle TransmogUI"

-- ============================================================================
-- C_TIMER FALLBACK FOR MOP CLASSIC
-- ============================================================================

if not C_Timer then
    C_Timer = {
        After = function(delay, callback)
            local frame = CreateFrame("Frame")
            frame.elapsed = 0
            frame:SetScript("OnUpdate", function(self, elapsed)
                self.elapsed = self.elapsed + elapsed
                if self.elapsed >= delay then
                    self:SetScript("OnUpdate", nil)
                    callback()
                end
            end)
        end,
        NewTicker = function(interval, callback, iterations)
            local ticker = { cancelled = false, iterations = iterations or -1, count = 0 }
            local frame = CreateFrame("Frame")
            frame.elapsed = 0
            frame:SetScript("OnUpdate", function(self, elapsed)
                if ticker.cancelled then
                    self:SetScript("OnUpdate", nil)
                    return
                end
                self.elapsed = self.elapsed + elapsed
                if self.elapsed >= interval then
                    self.elapsed = 0
                    ticker.count = ticker.count + 1
                    callback(ticker)
                    if ticker.iterations > 0 and ticker.count >= ticker.iterations then
                        ticker.cancelled = true
                        self:SetScript("OnUpdate", nil)
                    end
                end
            end)
            function ticker:Cancel() self.cancelled = true end
            return ticker
        end,
    }
end

-- ============================================================================
-- SHARED LOW-BUDGET WORK QUEUE
-- ============================================================================

ETB._workQueue = ETB._workQueue or {}
ETB._workByKey = ETB._workByKey or {}
ETB._itemInfoQueue = ETB._itemInfoQueue or {}
ETB._itemInfoQueued = ETB._itemInfoQueued or {}

local function nowMs()
    return (debugprofilestop and debugprofilestop()) or ((GetTime and GetTime() * 1000) or 0)
end

-- ============================================================================
-- Shared chunking primitives (v15.200.0)
-- ----------------------------------------------------------------------------
-- A single time-slicing mechanism that any heavy builder can cooperate with.
-- The driver (RunChunked) sets a per-slice deadline; a builder calls YieldSlice()
-- inside its hot loops. On the main thread YieldSlice() is a no-op, so the exact
-- same builder code runs straight through when called synchronously — only the
-- timing differs (proven byte-identical for the BossLoot index build).
--
-- This lets Ensure(): the compact-lookup build, the BossLoot index, and the ERB
-- NPC index all yield into the same frame budget instead of each blocking ~100ms.
-- ============================================================================
ETB._chunkDeadline = ETB._chunkDeadline or 0
local _profclock = _G.debugprofilestop

function ETB:YieldSlice()
    -- Only yields when running inside a coroutine driven by RunChunked. On the
    -- main thread coroutine.running() is nil → no-op → synchronous build.
    if not (coroutine and coroutine.running and coroutine.running()) then return end
    if _profclock and _profclock() >= (ETB._chunkDeadline or 0) then coroutine.yield() end
end

-- Drive bodyFn inside a coroutine, time-sliced across frames via the work pump.
-- bodyFn must call ETB:YieldSlice() in its heavy loops to cooperate. onDone(ok,err)
-- fires once when the build finishes (or errors). Falls back to a synchronous call
-- if coroutines / the work pump / the profiling clock aren't available.
function ETB:RunChunked(key, bodyFn, onDone)
    local SLICE_MS = 6
    if not (self.QueueWork and coroutine and coroutine.create and _profclock) then
        local ok, err = pcall(bodyFn)
        if onDone then onDone(ok, err) end
        return
    end
    local co = coroutine.create(bodyFn)
    local function pump()
        ETB._chunkDeadline = _profclock() + SLICE_MS
        local ok, err = coroutine.resume(co)
        if not ok then
            ETB:Debug("Chunk", tostring(key) .. " error: " .. tostring(err))
            if onDone then onDone(false, err) end
            return
        end
        if coroutine.status(co) == "dead" then
            if onDone then onDone(true) end
        else
            ETB:QueueWork(key, pump, { pauseInCombat = true })
        end
    end
    ETB:QueueWork(key, pump, { pauseInCombat = true })
end

-- ---------------------------------------------------------------------------
-- Appearance-data warm (v15.203.0)
-- ----------------------------------------------------------------------------
-- The 6.8MB compact appearance dataset is browser/ZoneScan/Planner-only and
-- lazy-loaded. The first feature to touch it pays LoadAddOn (~69ms) + the
-- compact-lookup build (~104ms) synchronously. WarmAppearanceData runs that build
-- inside RunChunked so EnsureLoaded() yields (the build spreads across frames),
-- turning a ~170ms freeze into background work. Features call IsAppearanceDataReady()
-- and, if false, warm + show a loading state instead of blocking.
function ETB:IsAppearanceDataReady(expKeys)
    local SDA = self.SourceData_ATT
    return (SDA and SDA.IsLoaded and SDA:IsLoaded(expKeys)) and true or false
end

function ETB:WarmAppearanceData(expKeys, onReady)
    -- Back-compat: WarmAppearanceData(onReadyFn) — a function as the first arg means
    -- "warm everything" (the pre-A2 signature). WarmAppearanceData("MoP", fn) or
    -- WarmAppearanceData({"Classic","Cata"}, fn) warms only those expansions.
    if type(expKeys) == "function" then onReady = expKeys; expKeys = nil end
    if self:IsAppearanceDataReady(expKeys) then
        if onReady then onReady() end
        return
    end
    -- Serialize warm requests. RunChunked keys its coroutine pump on the string
    -- "appearance.warm"; a SECOND concurrent warm calls QueueWork with that same key,
    -- which marks the first coroutine's queued continuation cancelled (the work pump
    -- skips cancelled jobs). That stranded coroutine is the ONLY path that clears
    -- MapperBridge's _ensureBuilding flag at its final publish — so a cancel leaves
    -- the flag stuck true, and every later warm hits `if _ensureBuilding then return`,
    -- fires its onReady, but never actually loads data (stuck until reload). Multiple
    -- independent callers (SourceMapper, TooltipEnhancer, SourceLookup, the hydrate
    -- warm) can easily race here. So we run exactly one warm at a time and queue the
    -- rest; a queued request already satisfied by the completed build fires at once.
    if self._appearanceWarmRunning then
        self._appearanceWarmQueue = self._appearanceWarmQueue or {}
        self._appearanceWarmQueue[#self._appearanceWarmQueue + 1] = { expKeys = expKeys, onReady = onReady }
        return
    end
    self:_RunAppearanceWarm(expKeys, onReady)
end

function ETB:_RunAppearanceWarm(expKeys, onReady)
    self._appearanceWarmRunning = true
    self:RunChunked("appearance.warm", function()
        local SDA = ETB.SourceData_ATT
        if not SDA then return end
        if expKeys and SDA.EnsureExpansion then SDA:EnsureExpansion(expKeys)
        elseif SDA.EnsureLoaded then SDA:EnsureLoaded() end
    end, function(ok)
        self._appearanceWarmRunning = false
        -- If the build didn't finish cleanly (coroutine errored before MapperBridge's
        -- atomic publish), clear the build guard so the next warm retries instead of
        -- being wedged by a stranded _ensureBuilding=true.
        if not ok then
            local SDA = ETB.SourceData_ATT
            if SDA and SDA.AbortBuild then pcall(function() SDA:AbortBuild() end) end
        end
        if onReady then pcall(onReady) end
        self:_DrainAppearanceWarmQueue()
    end)
end

function ETB:_DrainAppearanceWarmQueue()
    local q = self._appearanceWarmQueue
    if not q then return end
    while #q > 0 do
        local req = table.remove(q, 1)
        if self:IsAppearanceDataReady(req.expKeys) then
            if req.onReady then pcall(req.onReady) end   -- already covered by the build that just finished
        else
            self:_RunAppearanceWarm(req.expKeys, req.onReady)  -- start the next build serially
            return
        end
    end
end

function ETB:GetFrameWorkBudget()
    local v = self.Settings and self.Settings.Get and self.Settings:Get("performance.frameWorkBudgetMs")
    return tonumber(v) or 1.5
end

function ETB:IsBackgroundWorkPaused(opts)
    opts = opts or {}
    if opts.pauseInCombat ~= false and InCombatLockdown and InCombatLockdown() then
        return true
    end
    if opts.pauseForStartup ~= false and self.IsStartupIdle and not self:IsStartupIdle(opts.startupIdleOptions or {}) then
        return true
    end
    return false
end

function ETB:QueueWork(key, fn, opts)
    if type(fn) ~= "function" then return end
    opts = opts or {}
    local job = { key = key or tostring(fn), fn = fn, opts = opts }
    if job.key and self._workByKey[job.key] then
        self._workByKey[job.key].cancelled = true
    end
    self._workQueue[#self._workQueue + 1] = job
    if job.key then self._workByKey[job.key] = job end
    self:EnsureWorkPump()
    return job
end

function ETB:CancelWork(key)
    local job = key and self._workByKey and self._workByKey[key]
    if job then
        job.cancelled = true
        self._workByKey[key] = nil
    end
end

local function WorkPumpOnUpdate(frame)
    local q = ETB._workQueue
    if not q or #q == 0 then
        if frame then frame:SetScript("OnUpdate", nil) end
        ETB._workPumpActive = false
        return
    end

    local budgetMs = ETB:GetFrameWorkBudget()
    local deadline = nowMs() + budgetMs
    local safety = #q + 4
    while #q > 0 and nowMs() < deadline and safety > 0 do
        safety = safety - 1
        local job = table.remove(q, 1)
        if job and not job.cancelled then
            if ETB:IsBackgroundWorkPaused(job.opts) then
                q[#q + 1] = job
            else
                if job.key and ETB._workByKey[job.key] == job then
                    ETB._workByKey[job.key] = nil
                end
                local _p0 = ETB.Perf and debugprofilestop and debugprofilestop()
                local ok, done = pcall(job.fn, deadline, job)
                if _p0 then ETB.Perf:Record("work:" .. tostring(job.key or "anon"), debugprofilestop() - _p0) end
                if not ok then
                    ETB:Debug("WorkQueue", tostring(done))
                    if job.opts and type(job.opts.onError) == "function" then
                        pcall(job.opts.onError, done, job)
                    end
                elseif done == false and not job.cancelled then
                    q[#q + 1] = job
                    if job.key then ETB._workByKey[job.key] = job end
                end
            end
        end
    end

    if not q or #q == 0 then
        if frame then frame:SetScript("OnUpdate", nil) end
        ETB._workPumpActive = false
    end
end

function ETB:EnsureWorkPump()
    if not self._workFrame then
        self._workFrame = CreateFrame("Frame")
    end
    if not self._workPumpActive then
        self._workFrame:SetScript("OnUpdate", WorkPumpOnUpdate)
        self._workPumpActive = true
    end
end

function ETB:RequestItemInfoThrottled(itemID)
    itemID = tonumber(itemID)
    if not itemID or itemID <= 0 then return end
    if self._itemInfoQueued[itemID] then return end
    self._itemInfoQueued[itemID] = true
    self._itemInfoQueue[#self._itemInfoQueue + 1] = itemID

    self:QueueWork("core.itemInfoPump", function(deadline)
        local maxPerFrame = self.Settings and self.Settings.Get and self.Settings:Get("performance.itemInfoPerFrame") or 3
        maxPerFrame = tonumber(maxPerFrame) or 3
        -- Cache-cold window: for a few seconds after each loading screen the
        -- client is busy fetching item/appearance/unit data for the new area and
        -- for nearby players as they stream in. Back ETB off to a single request
        -- per frame during that window so we don't compete with the client's own
        -- cache fetches — this is exactly the "hitches when I first start moving
        -- after entering the world" window. Restores to the configured rate after.
        --
        -- BUT only when the browser is closed (pure background warming). With the
        -- grid OPEN the user is actively waiting on the visible page's names, so the
        -- cold backoff would just make items "take a second to show up" right after
        -- login. When open, resolve at a brisk rate so the visible page fills fast;
        -- the deadline guard below still keeps each frame within budget.
        local browserOpen = ETB.MainFrame and ETB.MainFrame.frame
            and ETB.MainFrame.frame.IsShown and ETB.MainFrame.frame:IsShown()
        if browserOpen then
            if maxPerFrame < 4 then maxPerFrame = 4 end
        elseif self._cacheColdUntil and nowMs() < self._cacheColdUntil then
            maxPerFrame = 1
        end
        local n = 0
        while #self._itemInfoQueue > 0 and n < maxPerFrame and nowMs() < deadline do
            local id = table.remove(self._itemInfoQueue, 1)
            self._itemInfoQueued[id] = nil
            GetItemInfo(id)
            n = n + 1
        end
        return #self._itemInfoQueue == 0
    end, { pauseInCombat = true, pauseForStartup = false })
end

-- Arm the cache-cold window on the events that follow a loading screen: login,
-- /reload, teleport, instance/continent transitions (PLAYER_ENTERING_WORLD) and
-- zone changes (ZONE_CHANGED_NEW_AREA). For CACHE_COLD_MS afterward the item-info
-- pump above backs off to 1/frame so ETB yields cache/server-fetch bandwidth to
-- the client while it streams in the new area and nearby players. The throttle
-- only paces ETB's own background name warming, so being conservative here costs
-- nothing functional — names simply resolve a little later.
do
    local CACHE_COLD_MS = 10000
    local cw = CreateFrame("Frame")
    cw:RegisterEvent("PLAYER_ENTERING_WORLD")
    cw:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    cw:SetScript("OnEvent", function()
        ETB._cacheColdUntil = nowMs() + CACHE_COLD_MS
    end)
end

-- ============================================================================
-- SAFE TOOLTIP HELPER
-- Prevents errors from third-party tooltip hooks
-- that crash on uncached items. Only calls SetHyperlink when the item
-- data is already cached by the client.
-- ============================================================================

function ETB:SafeSetTooltipItem(tooltip, itemID)
    if not tooltip or not itemID or type(itemID) ~= "number" or itemID <= 0 then
        return false
    end
    -- Only use SetHyperlink if item is fully cached.
    -- GetItemInfo returns nil for uncached items, which causes third-party
    -- tooltip hooks to crash on nil itemID.
    local name, link = GetItemInfo(itemID)
    if name and link then
        -- Item is fully cached — safe to use hyperlink (triggers tooltip hooks safely)
        local ok = pcall(tooltip.SetHyperlink, tooltip, link)
        return ok
    else
        -- Item NOT fully cached — build a manual tooltip from instant data.
        -- On MoP Classic 5.4.8, GetItemInfoInstant returns
        --   (itemID, itemType, itemSubType, equipLoc, icon, classID, subclassID)
        -- The first return is the NUMERIC itemID, not a name. Earlier code
        -- displayed that number as the tooltip "name" (so the tooltip would
        -- show e.g. "12345" instead of an item title). There's no synchronous
        -- way to get the real string name when GetItemInfo hasn't cached it
        -- yet, so we fall back to "Item #N" cleanly. Triggering GetItemInfo
        -- below populates the cache for next hover.
        tooltip:SetText("Item #" .. itemID)
        tooltip:AddLine("|cff888888Hover again for full tooltip|r")
        -- GetItemInfo triggers a background cache request for next hover
        GetItemInfo(itemID)
        return false
    end
end

-- Available via NS.ETB:SafeSetTooltipItem(tooltip, itemID)
-- NOTE: No _G global — writing to _G taints secure action paths

-- ============================================================================
-- CATEGORY DEFINITIONS
-- MoP Classic uses categories 1-11 for armor slots, 12-27 for weapon types
-- Discovered via /etb categories command
-- ============================================================================

ETB.CATEGORY_INFO = {
    -- Armor slots (1-11) - confirmed
    [1]  = { name = "Head",        slot = "INVTYPE_HEAD",           icon = "Interface\\Icons\\INV_Helmet_44",           isArmor = true },
    [2]  = { name = "Shoulder",    slot = "INVTYPE_SHOULDER",       icon = "Interface\\Icons\\INV_Shoulder_22",         isArmor = true },
    [3]  = { name = "Back",        slot = "INVTYPE_CLOAK",          icon = "Interface\\Icons\\INV_Misc_Cape_18",        isArmor = true },
    [4]  = { name = "Chest",       slot = "INVTYPE_CHEST",          icon = "Interface\\Icons\\INV_Chest_Chain_05",      isArmor = true },
    [5]  = { name = "Shirt",       slot = "INVTYPE_BODY",           icon = "Interface\\Icons\\INV_Shirt_01",            isArmor = true },
    [6]  = { name = "Tabard",      slot = "INVTYPE_TABARD",         icon = "Interface\\Icons\\INV_Shirt_GuildTabard_01",isArmor = true },
    [7]  = { name = "Wrist",       slot = "INVTYPE_WRIST",          icon = "Interface\\Icons\\INV_Bracer_07",           isArmor = true },
    [8]  = { name = "Hands",       slot = "INVTYPE_HAND",           icon = "Interface\\Icons\\INV_Gauntlets_24",        isArmor = true },
    [9]  = { name = "Waist",       slot = "INVTYPE_WAIST",          icon = "Interface\\Icons\\INV_Belt_13",             isArmor = true },
    [10] = { name = "Legs",        slot = "INVTYPE_LEGS",           icon = "Interface\\Icons\\INV_Pants_06",            isArmor = true },
    [11] = { name = "Feet",        slot = "INVTYPE_FEET",           icon = "Interface\\Icons\\INV_Boots_05",            isArmor = true },
    
    -- Weapon types (12-27) - discovered via /etb categories
    -- One-Handed Melee
    [12] = { name = "Wands",       slot = "INVTYPE_RANGEDRIGHT",    icon = "Interface\\Icons\\INV_Wand_01",             isWeapon = true, weaponType = "WAND", count = 241 },
    [13] = { name = "1H Axes",     slot = "INVTYPE_WEAPON",         icon = "Interface\\Icons\\INV_Axe_01",              isWeapon = true, weaponType = "AXE", count = 256 },
    [14] = { name = "1H Swords",   slot = "INVTYPE_WEAPON",         icon = "Interface\\Icons\\INV_Sword_04",            isWeapon = true, weaponType = "SWORD", count = 399 },
    [15] = { name = "1H Maces",    slot = "INVTYPE_WEAPON",         icon = "Interface\\Icons\\INV_Mace_01",             isWeapon = true, weaponType = "MACE", count = 390 },
    [16] = { name = "Daggers",     slot = "INVTYPE_WEAPON",         icon = "Interface\\Icons\\INV_Weapon_ShortBlade_05",isWeapon = true, weaponType = "DAGGER", count = 452 },
    [17] = { name = "Fist Weapons",slot = "INVTYPE_WEAPON",         icon = "Interface\\Icons\\INV_Gauntlets_04",        isWeapon = true, weaponType = "FIST", count = 148 },
    -- Off-Hand
    [18] = { name = "Shields",     slot = "INVTYPE_SHIELD",         icon = "Interface\\Icons\\INV_Shield_04",           isWeapon = true, weaponType = "SHIELD", count = 368 },
    [19] = { name = "Off-Hands",   slot = "INVTYPE_HOLDABLE",       icon = "Interface\\Icons\\INV_Misc_Orb_01",         isWeapon = true, weaponType = "HOLDABLE", count = 263 },
    -- Two-Handed Melee
    [20] = { name = "2H Axes",     slot = "INVTYPE_2HWEAPON",       icon = "Interface\\Icons\\INV_Axe_09",              isWeapon = true, weaponType = "AXE", isTwoHanded = true, count = 210 },
    [21] = { name = "2H Swords",   slot = "INVTYPE_2HWEAPON",       icon = "Interface\\Icons\\INV_Sword_27",            isWeapon = true, weaponType = "SWORD", isTwoHanded = true, count = 212 },
    [22] = { name = "2H Maces",    slot = "INVTYPE_2HWEAPON",       icon = "Interface\\Icons\\INV_Mace_15",             isWeapon = true, weaponType = "MACE", isTwoHanded = true, count = 200 },
    [23] = { name = "Staves",      slot = "INVTYPE_2HWEAPON",       icon = "Interface\\Icons\\INV_Staff_08",            isWeapon = true, weaponType = "STAFF", isTwoHanded = true, count = 428 },
    [24] = { name = "Polearms",    slot = "INVTYPE_2HWEAPON",       icon = "Interface\\Icons\\INV_Spear_04",            isWeapon = true, weaponType = "POLEARM", isTwoHanded = true, count = 152 },
    -- Ranged
    [25] = { name = "Bows",        slot = "INVTYPE_RANGED",         icon = "Interface\\Icons\\INV_Weapon_Bow_05",       isWeapon = true, weaponType = "BOW", count = 165 },
    [26] = { name = "Guns",        slot = "INVTYPE_RANGEDRIGHT",    icon = "Interface\\Icons\\INV_Weapon_Rifle_01",     isWeapon = true, weaponType = "GUN", count = 150 },
    [27] = { name = "Crossbows",   slot = "INVTYPE_RANGEDRIGHT",    icon = "Interface\\Icons\\INV_Weapon_Crossbow_01",  isWeapon = true, weaponType = "CROSSBOW", count = 87 },
}

ETB.SLOT_TO_CATEGORY = {}
for categoryID, info in pairs(ETB.CATEGORY_INFO) do
    ETB.SLOT_TO_CATEGORY[info.slot] = categoryID
end

-- ============================================================================
-- SOURCE TYPES
-- ============================================================================

ETB.SOURCE_TYPES = {
    { id = "all",        name = "All Sources",   color = "|cffffffff" },
    { id = "boss",       name = "Boss (any)",    color = "|cffff7f3f" },
    { id = "raid",       name = "Raid",          color = "|cffa335ee" },
    { id = "dungeon",    name = "Dungeon",       color = "|cff0070dd" },
    { id = "quest",      name = "Quest",         color = "|cffffff00" },
    { id = "vendor",     name = "Vendor",        color = "|cff1eff00" },
    { id = "crafted",    name = "Crafted",       color = "|cffff8000" },
    { id = "pvp",        name = "PvP",           color = "|cffff0000" },
    { id = "world_drop", name = "World Drop",    color = "|cff9d9d9d" },
}

-- ============================================================================
-- EXPANSION DATA
-- ============================================================================

ETB.EXPANSIONS = {
    { id = "all",     name = "All Expansions" },
    { id = "classic", name = "Classic" },
    { id = "tbc",     name = "Burning Crusade" },
    { id = "wrath",   name = "Wrath of the Lich King" },
    { id = "cata",    name = "Cataclysm" },
    { id = "mop",     name = "Mists of Pandaria" },
}

-- ============================================================================
-- QUALITY DATA
-- ============================================================================

ETB.QUALITY_COLORS = {
    [0] = { r = 0.62, g = 0.62, b = 0.62, hex = "|cff9d9d9d", name = "Poor" },
    [1] = { r = 1.00, g = 1.00, b = 1.00, hex = "|cffffffff", name = "Common" },
    [2] = { r = 0.12, g = 1.00, b = 0.00, hex = "|cff1eff00", name = "Uncommon" },
    [3] = { r = 0.00, g = 0.44, b = 0.87, hex = "|cff0070dd", name = "Rare" },
    [4] = { r = 0.64, g = 0.21, b = 0.93, hex = "|cffa335ee", name = "Epic" },
    [5] = { r = 1.00, g = 0.50, b = 0.00, hex = "|cffff8000", name = "Legendary" },
    [6] = { r = 0.90, g = 0.80, b = 0.50, hex = "|cffe6cc80", name = "Artifact" },
    [7] = { r = 0.00, g = 0.80, b = 1.00, hex = "|cff00ccff", name = "Heirloom" },
}

-- ============================================================================
-- UTILITY FUNCTIONS
-- ============================================================================

function ETB:Print(msg)
    -- User-facing output — ALWAYS prints
    DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[ETB]|r " .. tostring(msg))
end

function ETB:PrintError(msg)
    DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[ETB Error]|r " .. tostring(msg))
end

function ETB:PrintWarning(msg)
    DEFAULT_CHAT_FRAME:AddMessage("|cffffff00[ETB Warning]|r " .. tostring(msg))
end

function ETB:Debug(module, msg)
    if self.debug then
        print("|cff00ffff[ETB:" .. tostring(module) .. "]|r " .. tostring(msg))
    end
end

function ETB:Debugf(module, fmt, ...)
    if not self.debug then return end
    self:Debug(module, string.format(fmt, ...))
end

function ETB:IsLowMemoryMode()
    if self.Settings and self.Settings.Get then
        local ok, value = pcall(self.Settings.Get, self.Settings, "performance.lowMemory")
        if ok and value ~= nil then
            return value == true
        end
    end
    return false
end

function ETB:HydrateItemSource(item)
    if not item or not item.itemID or item._sourceHydrated then return item end

    local itemID = item.itemID
    local sourceDataReady = true
    if ETB.SourceData_ATT then
        if ETB.SourceData_ATT.IsAllLoaded then
            sourceDataReady = ETB.SourceData_ATT:IsAllLoaded()
        elseif ETB.IsAppearanceDataReady then
            sourceDataReady = ETB:IsAppearanceDataReady()
        elseif ETB.SourceData_ATT.IsLoaded then
            sourceDataReady = ETB.SourceData_ATT:IsLoaded()
        end
    end
    if not sourceDataReady and not self._appearanceHydrateWarmQueued then
        self._appearanceHydrateWarmQueued = true
        if ETB.WarmAppearanceData then
            ETB:WarmAppearanceData(nil, function() ETB._appearanceHydrateWarmQueued = false end)
        else
            self._appearanceHydrateWarmQueued = false
        end
    end

    -- BossLoot DB2 is the lightest/highest-quality path for boss drops.
    if ETB.SourceLookup then
        local okText, srcText = pcall(ETB.SourceLookup.GetSourceText, ETB.SourceLookup, itemID)
        if okText and srcText then
            item.dropSource = srcText
            local okSources, sources = pcall(ETB.SourceLookup.GetItemSources, ETB.SourceLookup, itemID)
            if okSources and sources and #sources > 0 then
                local s = sources[1]
                item.sourceType = item.sourceType or "BOSS"
                item.expansion = item.expansion or s.expansion
                item.isRaid = item.isRaid or s.isRaid
            end
        end
    end

    if sourceDataReady and not item.dropSource and ETB.SourceData then
        local ok, sourceText = pcall(ETB.SourceData.GetSourceFormatted, ETB.SourceData, itemID)
        if ok and sourceText then
            item.dropSource = sourceText
        end
    end

    if sourceDataReady and ETB.SourceData_ATT and ETB.SourceData_ATT.GetPrimarySource then
        local ok, src = pcall(ETB.SourceData_ATT.GetPrimarySource, ETB.SourceData_ATT, itemID)
        if ok and src then
            item.sourceType = item.sourceType or src.type
            item.expansion = item.expansion or src.expansion
            if item.isRaid == nil then item.isRaid = src.isRaid end
            if not item.dropSource and src.name then
                item.dropSource = src.name .. (src.location and src.location ~= "" and (" - " .. src.location) or "")
            end
        end
    end

    item._sourceHydrated = sourceDataReady or item.dropSource ~= nil
    return item
end

function ETB:GetQualityColor(quality)
    -- Ensure quality is a number
    if quality and type(quality) ~= "number" then
        quality = 1  -- Default to Common
    end
    local color = self.QUALITY_COLORS[quality or 1]
    if not color then
        color = self.QUALITY_COLORS[1]  -- Fallback to Common
    end
    return color and color.hex or "|cffffffff"
end

function ETB:GetQualityRGB(quality)
    -- Ensure quality is a number
    if quality and type(quality) ~= "number" then
        quality = 1  -- Default to Common
    end
    local color = self.QUALITY_COLORS[quality or 1]
    if not color then
        color = self.QUALITY_COLORS[1]  -- Fallback to Common
    end
    return color.r, color.g, color.b
end

function ETB:GetQualityName(quality)
    local color = self.QUALITY_COLORS[quality or 1]
    return color and color.name or "Unknown"
end

function ETB:GetMemoryUsage()
    UpdateAddOnMemoryUsage()
    return GetAddOnMemoryUsage(ADDON_NAME)
end

function ETB:FormatMemory(kb)
    if kb >= 1024 then
        return string.format("%.1f MB", kb / 1024)
    else
        return string.format("%.0f KB", kb)
    end
end

function ETB:FormatNumber(num)
    if not num then return "0" end
    local formatted = tostring(math.floor(num))
    while true do
        formatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", '%1,%2')
        if k == 0 then break end
    end
    return formatted
end

function ETB:FormatPercent(value, decimals)
    decimals = decimals or 1
    return string.format("%." .. decimals .. "f%%", value or 0)
end

-- Get current player info
function ETB:GetPlayerInfo()
    local _, class = UnitClass("player")
    local race = select(2, UnitRace("player"))
    local level = UnitLevel("player")
    local name = UnitName("player")
    local realm = GetRealmName()
    
    return {
        name = name,
        realm = realm,
        class = class,
        race = race,
        level = level,
        key = name .. "-" .. realm,
    }
end

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

function ETB:OnAddonLoaded()
    -- Initialize SavedVariables
    ETB_Settings = ETB_Settings or {}
    ETB_Favorites = ETB_Favorites or {}
    ETB_MinimapSettings = ETB_MinimapSettings or { show = true, position = 195 }
    ETB_CharDB = ETB_CharDB or {}
    ETB_Debug = ETB_Debug or { enabled = true, logLevel = 3 }
    ETB_RuntimeNPCNames = ETB_RuntimeNPCNames or {}
    
    -- Apply settings
    if ETB.Settings then
        ETB.Settings:Load()
    end
    
    -- Version banner
    -- Startup banner stripped for clean chat
end

-- ============================================================================
-- DEFERRED-WORK HELPERS  (login stutter mitigation)
-- ============================================================================
-- Login work is staggered across timers, but each timer still ran ALL of its
-- work in a single frame — a visible hitch once the player has loaded in and
-- started moving. RunSpread spreads a batch of tasks across consecutive
-- frames (one per frame); SteppedGC reclaims login garbage incrementally
-- instead of as one stop-the-world collect.

-- Run `tasks` (array of functions) one per frame; `onDone` fires after the
-- last. Each task is pcall-guarded so one failure can't abort the rest.
function ETB:RunSpread(tasks, onDone, opts)
    opts = opts or {}
    local i = 0
    local function step()
        if opts.pauseWhenBusy and not ETB:IsStartupIdle({ ignoreQuietWindow = true }) then
            C_Timer.After(opts.interval or 1, step)
            return
        end
        i = i + 1
        local task = tasks[i]
        if task then
            -- Time each spread task. These run in bare C_Timer.After(0) callbacks
            -- that were invisible to /etb perf; the worst spread task now surfaces
            -- as startup:spread so first-run init work can't hide here.
            local _spT0 = ETB.Perf and debugprofilestop and debugprofilestop()
            local ok, err = pcall(task)
            if _spT0 then ETB.Perf:Record("startup:spread", debugprofilestop() - _spT0) end
            if not ok then ETB:Debug("Init", "spread task error: " .. tostring(err)) end
            C_Timer.After(opts.stepDelay or 0, step)
        elseif onDone then
            pcall(onDone)
        end
    end
    C_Timer.After(0, step)
end

-- Reclaim parser/login garbage with a series of small incremental GC steps
-- across consecutive frames — no single stop-the-world freeze. Steps until a
-- collection cycle completes or a frame cap is reached.
function ETB:SteppedGC(maxFrames, stepKB)
    maxFrames = maxFrames or 24
    stepKB    = stepKB or 700
    local n = 0
    local function step()
        n = n + 1
        local done = collectgarbage("step", stepKB)
        if not done and n < maxFrames then
            C_Timer.After(0, step)
        end
    end
    C_Timer.After(0, step)
end

-- Startup throttle: keep optional/heavy ETB work out of the fragile "loaded
-- and moving" window after login/reload.
function ETB:GetStartupQuietSeconds()
    local value
    if self.Settings and self.Settings.Get then
        local ok, setting = pcall(self.Settings.Get, self.Settings, "performance.startupQuietSeconds")
        if ok then value = tonumber(setting) end
    end
    if not value then
        value = (self.IsLowMemoryMode and self:IsLowMemoryMode()) and 25 or 12
    end
    if value < 0 then value = 0 end
    if value > 120 then value = 120 end
    return value
end

function ETB:PauseStartupWhileMoving()
    if self.Settings and self.Settings.Get then
        local ok, value = pcall(self.Settings.Get, self.Settings, "performance.pauseStartupWhileMoving")
        if ok and value ~= nil then return value == true end
    end
    return true
end

function ETB:BeginStartupThrottle()
    local now = (GetTime and GetTime()) or 0
    local quietSeconds = self:GetStartupQuietSeconds()
    self._startupQuietUntil = now + quietSeconds
    self._startupMoving = (GetUnitSpeed and (GetUnitSpeed("player") or 0) > 0) or false

    if self._startupThrottleFrame then return end
    local frame = CreateFrame("Frame")
    self._startupThrottleFrame = frame
    frame:RegisterEvent("PLAYER_STARTED_MOVING")
    frame:RegisterEvent("PLAYER_STOPPED_MOVING")
    frame:RegisterEvent("PLAYER_REGEN_DISABLED")
    frame:RegisterEvent("PLAYER_REGEN_ENABLED")
    frame:SetScript("OnEvent", function(_, event)
        if event == "PLAYER_STARTED_MOVING" then
            ETB._startupMoving = true
        elseif event == "PLAYER_STOPPED_MOVING" then
            ETB._startupMoving = false
        end
    end)

    ETB:Debugf("Init", "Startup quiet window: %ds", quietSeconds)
end

function ETB:IsStartupIdle(opts)
    opts = opts or {}
    if InCombatLockdown and InCombatLockdown() then return false end
    if UnitAffectingCombat and UnitAffectingCombat("player") then return false end

    local now = (GetTime and GetTime()) or 0
    if opts.ignoreQuietWindow ~= true and self._startupQuietUntil and now < self._startupQuietUntil then
        return false
    end

    if opts.pauseWhileMoving ~= false and self:PauseStartupWhileMoving() then
        -- Use the LIVE player speed, not just the event-driven _startupMoving flag.
        -- PLAYER_STOPPED_MOVING is unreliable (missed on dismount, terrain collision,
        -- zone transitions), which can strand the flag at `true` and freeze all
        -- gated work — notably the item-name pump — even while standing still.
        local sp = GetUnitSpeed and GetUnitSpeed("player")
        local moving = (sp ~= nil) and (sp > 0) or (sp == nil and self._startupMoving)
        if moving then return false end
    end

    return true
end

function ETB:RunWhenStartupIdle(key, fn, opts)
    if type(fn) ~= "function" then return end
    opts = opts or {}
    self._startupDeferredWork = self._startupDeferredWork or {}

    local job = {
        fn = fn,
        started = (GetTime and GetTime()) or 0,
        opts = opts,
    }
    self._startupDeferredWork[key or tostring(fn)] = job

    local function attempt()
        local mapKey = key or tostring(fn)
        if ETB._startupDeferredWork[mapKey] ~= job then return end

        local now = (GetTime and GetTime()) or 0
        local maxWait = opts.maxWait or 180
        local expired = maxWait and maxWait > 0 and (now - job.started) >= maxWait
        local inCombat = (InCombatLockdown and InCombatLockdown())
            or (UnitAffectingCombat and UnitAffectingCombat("player"))
        if ETB:IsStartupIdle(opts) or (expired and not inCombat) then
            -- Serialize startup work to ~one job per frame. A cluster of same-delay
            -- jobs (the ~13 module inits at delay=2) otherwise all come due in the
            -- same frame and run back-to-back — a single ~100ms+ hitch on reload.
            -- GetTime() is constant within a frame, so if another startup job already
            -- ran this frame, yield to the next one. Drains the cluster over a handful
            -- of frames (imperceptible) instead of one burst.
            if ETB._lastStartupRunFrame == now and not expired then
                C_Timer.After(0.03, attempt)
                return
            end
            ETB._lastStartupRunFrame = now
            ETB._startupDeferredWork[mapKey] = nil
            local _p0 = ETB.Perf and debugprofilestop and debugprofilestop()
            local ok, err = pcall(fn)
            if _p0 then ETB.Perf:Record("startup:" .. mapKey, debugprofilestop() - _p0) end
            if not ok then ETB:Debug("Init", "deferred startup job error: " .. tostring(err)) end
        else
            C_Timer.After(opts.interval or 2, attempt)
        end
    end

    C_Timer.After(opts.delay or 0, attempt)
end

function ETB:OnPlayerLogin()
    self:BeginStartupThrottle()

    -- Delayed initialization
    -- STAGGERED initialization to prevent login freeze
    -- Phase 1 (login+1s): lightweight modules only
    ETB:RunWhenStartupIdle("init.phase1", function()
        if ETB.BlizzardData then
            ETB.BlizzardData:Initialize()
        end
        
        if ETB.MinimapButton then
            ETB.MinimapButton:Initialize()
        end
        
        if ETB.TooltipEnhancer then
            ETB.TooltipEnhancer:Initialize()
        end
        
        -- v15.6.0: B2 bag overlay markers
        if ETB.BagOverlay then
            ETB.BagOverlay:Initialize()
        end
        
        if ETB.Wishlist then
            ETB.Wishlist:Initialize()
        end
        
        if ETB.CollectionTooltip then
            ETB.CollectionTooltip:Initialize()
        end
        
        if ETB.ContainerTooltip then
            ETB.ContainerTooltip:Initialize()
        end
        
        if ETB.RuntimeNPCNames then
            ETB.RuntimeNPCNames:Initialize()
        end

        if ETB.CollectionHistory then
            ETB.CollectionHistory:Initialize()
        end

        -- v14.6.0 Tier 2 modules
        if ETB.HiddenAppearances then
            ETB.HiddenAppearances:Initialize()
        end
        if ETB.SavedSearches then
            ETB.SavedSearches:Initialize()
        end
        if ETB.CollectionToast then
            ETB.CollectionToast:Initialize()
        end

        if ETB.ChatAutocomplete then
            ETB.ChatAutocomplete:Initialize()
        end

        -- v15.8.17: warn if known ETB dev-tool addons are loaded alongside
        -- the main addon. These extractors and validators are not intended
        -- for normal gameplay — they add memory pressure and slow login.
        -- The warning fires once per session and is silenced via
        -- /etb devwarn off.
        local DEV_TOOL_ADDONS = {
            "ETBBossExtractor", "ETBEJExtract", "ETBLootAudit",
            "ETBSetExtractor", "ETBSetValidator",
        }
        local devWarnEnabled = true
        if ETB.Settings and ETB.Settings.Get then
            local v = ETB.Settings:Get("dev.warnOnDevTools")
            if v ~= nil then devWarnEnabled = v end
        end
        if devWarnEnabled then
            local loaded = {}
            local checkLoaded = (C_AddOns and C_AddOns.IsAddOnLoaded) or IsAddOnLoaded
            for _, name in ipairs(DEV_TOOL_ADDONS) do
                local ok, isLoaded = pcall(checkLoaded, name)
                if ok and isLoaded then table.insert(loaded, name) end
            end
            if #loaded > 0 then
                ETB:Print(string.format(
                    "|cffff8800Heads up:|r %d ETB dev tool(s) loaded: |cffaaaaaa%s|r",
                    #loaded, table.concat(loaded, ", ")))
                ETB:Print("|cffaaaaaaThese are extraction/audit tools — consider disabling for normal play. Hide this with |cffffd100/etb devwarn off|r|r")
            end
        end

        ETB:Debug("Init", "Phase 1 complete (lightweight modules)")
    end, { delay = 1, interval = 1, maxWait = 45, ignoreQuietWindow = true })
    
    -- Phase 2 (login+3s): heavy data loading.
    -- v15.15.0: each module's Initialize() now runs on its OWN frame via
    -- RunSpread, instead of all of them inside one frame. Doing the whole
    -- batch at once produced a visible hitch a few seconds post-login, once
    -- the player had loaded in and started moving. Spreading keeps every
    -- frame cheap.
    ETB:RunWhenStartupIdle("init.phase2", function()
        -- v12.42.0: SourceData_ATT is not eagerly initialized; its
        -- EnsureLoaded() runs lazily on first access, so Compact data stays
        -- deferred until the first tooltip / browser query / boss mouseover.
        local tasks = {}

        tasks[#tasks + 1] = function()
            if ETB.SourceMapper then ETB.SourceMapper:Initialize() end
        end
        tasks[#tasks + 1] = function()
            if ETB.Sets then ETB.Sets:Initialize() end
        end
        tasks[#tasks + 1] = function()
            -- SetFixer bridge — narrow, stable API for the SetFixer companion
            -- addon (ETB removed _G.ETB in v12.43.0; this is the sanctioned
            -- third-party entry point, versioned so SetFixer can detect a
            -- mismatch).
            _G.ETB_SetFixerBridge = {
                version = 1,
                GetSourceFormatted = function(itemID)
                    if not itemID or not ETB.SourceData then return nil end
                    local ok, str = pcall(ETB.SourceData.GetSourceFormatted,
                                          ETB.SourceData, itemID)
                    if ok then return str end
                    return nil
                end,
                GetJLoot = function(itemID)
                    if not itemID or not ETB.JLoot then return nil end
                    local ok, ld = pcall(function() return ETB.JLoot[itemID] end)
                    if ok then return ld end
                    return nil
                end,
            }
        end
        tasks[#tasks + 1] = function()
            -- SetCompletion runs after CommunitySetData (loaded +2s) exists.
            if ETB.SetCompletion then ETB.SetCompletion:Initialize() end
        end
        tasks[#tasks + 1] = function()
            -- CollectionPlanner:Initialize is cheap; its heavy index build is
            -- inside a deferred warm-up timer and never touches login.
            if ETB.CollectionPlanner then ETB.CollectionPlanner:Initialize() end
        end

        ETB:RunSpread(tasks, function()
            ETB.initialized = true
            ETB.EventBus:Fire("ETB_INITIALIZED")
            ETB:Debug("Init", "Phase 2 complete (data modules)")
        end, { pauseWhenBusy = true, interval = 1 })
    end, { delay = 3, interval = 2, maxWait = 180 })

    -- DressUpModel pre-warm — moved out of Phase 2 to its own early, isolated
    -- timer. The first DressUpModel:SetUnit("player") after a /reload triggers
    -- a one-time client-wide texture re-stream; firing it at +0.5s lands that
    -- cost during (or right after) the loading screen — where the player
    -- already expects load lag — instead of mid-movement at +3s.
    ETB:RunWhenStartupIdle("init.modelPrewarm", function()
        if ETB.IsLowMemoryMode and ETB:IsLowMemoryMode() then
            ETB:Debug("Init", "Low-memory mode: DressUpModel pre-warm skipped")
            return
        end
        if not ETB._dressUpPrewarmModel then
            local m = CreateFrame("DressUpModel", nil, UIParent)
            m:SetSize(1, 1)
            m:SetPoint("CENTER", UIParent, "CENTER", -10000, 0)  -- offscreen
            m:Hide()
            pcall(m.SetUnit, m, "player")
            ETB._dressUpPrewarmModel = m
            ETB:Debug("Init", "DressUpModel pre-warm done")
        end
    end, { delay = 0.5, interval = 2, maxWait = 180 })

    -- Phase 3 (login+5s): reclaim login/parser garbage incrementally, so the
    -- collection never produces a single stop-the-world freeze mid-movement.
    ETB:RunWhenStartupIdle("init.gc", function()
        ETB:SteppedGC()
        ETB:Debug("Init", "Phase 3 GC scheduled (incremental)")
    end, { delay = 5, interval = 2, maxWait = 180 })
    -- BossLootTooltip index builds lazily on first NPC mouseover (not at login).
end

function ETB:OnCollectionUpdated()
    if ETB.BlizzardData then
        ETB.BlizzardData:InvalidateCollectionCache()
    end
    
    -- Clear visualID cache so newly collected items resolve fresh
    ETB:ClearVisualIDCache()
    
    if ETB.MainFrame and ETB.MainFrame:IsShown() then
        ETB.MainFrame._filterSignature = nil
        ETB.MainFrame._filteredItems = nil
        ETB.MainFrame._overallStats = nil
        ETB.MainFrame._overallStatsPending = nil
        ETB.MainFrame:RefreshItems()
    end
    
    ETB.EventBus:Fire("ETB_COLLECTION_UPDATED")
end

-- ============================================================================
-- SHARED VISUAL ID RESOLVER
-- ============================================================================
-- Items from Blizzard's GetCategoryAppearances have visualID pre-set.
-- Items from other paths (sets, tooltips, right-clicks) may not.
-- This resolves visualID at runtime via C_TransmogCollection.GetItemInfo().
-- All files should use ETB:ResolveVisualID(item) instead of checking
-- item.visualID directly.
-- ============================================================================

local visualIDCache = {}

function ETB:ResolveVisualID(item)
    if not item then return nil end
    
    -- 1. Already has visualID from Blizzard enumeration
    if item.visualID and item.visualID > 0 then
        return item.visualID
    end
    
    -- Need itemID for runtime lookup
    if not item.itemID then return nil end
    
    -- 2. Check runtime cache
    if visualIDCache[item.itemID] then
        item.visualID = visualIDCache[item.itemID]
        return item.visualID
    end
    
    -- 3. Runtime lookup via C_TransmogCollection.GetItemInfo
    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
        local ok, appID = pcall(C_TransmogCollection.GetItemInfo, item.itemID)
        if ok and appID and appID > 0 then
            visualIDCache[item.itemID] = appID
            item.visualID = appID
            return appID
        end
    end
    
    return nil
end

-- Resolve from just an itemID (no item table needed)
function ETB:ResolveVisualIDFromItemID(itemID)
    if not itemID then return nil end
    if visualIDCache[itemID] then return visualIDCache[itemID] end
    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
        local ok, appID = pcall(C_TransmogCollection.GetItemInfo, itemID)
        if ok and appID and appID > 0 then
            visualIDCache[itemID] = appID
            return appID
        end
    end
    return nil
end

-- Clear cache (on collection update, some items may gain new data)
function ETB:ClearVisualIDCache()
    wipe(visualIDCache)
end

-- ============================================================================
-- EVENT REGISTRATION
-- ============================================================================

ETB.EventBus:Register("ADDON_LOADED", function(event, addon)
    if addon == ADDON_NAME then
        ETB:OnAddonLoaded()
    end
end, ETB.EventBus.Priority.FIRST)

ETB.EventBus:Register("PLAYER_LOGIN", function()
    ETB:OnPlayerLogin()
end, ETB.EventBus.Priority.EARLY)

ETB.EventBus:Register("TRANSMOG_COLLECTION_UPDATED", function()
    ETB:OnCollectionUpdated()
end, ETB.EventBus.Priority.NORMAL)

-- Capture async item info responses and refresh visible UI
local pendingRefresh = false
ETB.EventBus:Register("GET_ITEM_INFO_RECEIVED", function(event, itemID, success)
    if not itemID then return end
    
    -- Update BlizzardData cache
    if ETB.BlizzardData and ETB.BlizzardData.pendingItemInfo and ETB.BlizzardData.pendingItemInfo[itemID] then
        ETB.BlizzardData:UpdateItemInfo(itemID)
        ETB.BlizzardData.pendingItemInfo[itemID] = nil
    end
    
    -- Debounced refresh of the main browser (0.2s delay to batch multiple callbacks).
    -- v15.42.0: use a boolean flag, not the C_Timer.After return value —
    -- C_Timer.After returns nil on this client, so the old `pendingRefreshTimer`
    -- handle was always nil and every event scheduled its own refresh (no
    -- coalescing). The flag actually batches the storm into one refresh.
    if ETB.MainFrame and ETB.MainFrame.frame and ETB.MainFrame.frame:IsShown() then
        if not pendingRefresh then
            pendingRefresh = true
            C_Timer.After(0.2, function()
                pendingRefresh = false
                if ETB.MainFrame and ETB.MainFrame.frame and ETB.MainFrame.frame:IsShown() then
                    ETB.MainFrame:UpdateDisplay()
                end
            end)
        end
    end
end, ETB.EventBus.Priority.EARLY)

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