--[[
    Warband Nexus - Items Cache Service
    Smart bag/bank scanning with hash-based change detection
    
    Architecture:
    - Hash-based BAG_UPDATE filtering (ignore durability/charges/cooldown changes)
    - Event-based bank frame detection (only scan when bank is open)
    - DB-backed persistence (per-character storage)
    - Throttled updates (2s spam prevention)
    
    Events fired:
    - WN_ITEMS_UPDATED: Bag/bank contents changed (UI should refresh)
    
    Events handled:
    - BAG_UPDATE(bagID) - Fires on item add/remove/move/durability/charges
    - BANKFRAME_OPENED - Bank UI opened
    - BANKFRAME_CLOSED - Bank UI closed
    - PLAYERREAGENTBANKSLOTS_CHANGED - Reagent bank changed
]]

local ADDON_NAME, ns = ...
local WarbandNexus = ns.WarbandNexus
local issecretvalue = issecretvalue

-- Debug print helper (only prints if debug mode enabled)
local DebugPrint = ns.DebugPrint
local DebugVerbosePrint = ns.DebugVerbosePrint or function() end

-- Keystone detection moved to PvECacheService (C_MythicPlus API, event-driven)

-- Get library references for compression
local LibSerialize = LibStub("AceSerializer-3.0")
local LibDeflate = LibStub:GetLibrary("LibDeflate")

-- ============================================================================
-- CONSTANTS
-- ============================================================================

local Constants = ns.Constants
local CACHE_VERSION = Constants.ITEMS_CACHE_VERSION

--- Persisted itemStorage key for the logged-in character (GUID slot when available).
local function ResolveCurrentItemStorageKey()
    local ck = ns.CharacterService and ns.CharacterService.ResolveCharactersTableKey and ns.CharacterService:ResolveCharactersTableKey(WarbandNexus)
    if not ck and ns.Utilities.GetCharacterStorageKey then
        ck = ns.Utilities:GetCharacterStorageKey(WarbandNexus)
    end
    if not ck then
        ck = ns.Utilities:GetCharacterKey()
    end
    return ck
end

local function CanonicalItemsMessageKey(storageKey)
    local k = storageKey
    if k and ns.Utilities.GetCanonicalCharacterKey then
        return ns.Utilities:GetCanonicalCharacterKey(k) or k
    end
    return k
end

--- Bump when persisted bag/bank/warband data or item metadata changes so Gear storage scan cache invalidates.
local function BumpGearStorageScanGeneration()
    ns._gearStorageInvGen = (ns._gearStorageInvGen or 0) + 1
end

local UPDATE_THROTTLE = Constants.THROTTLE.PERSONAL_FREQUENT

-- Bag ID ranges
local INVENTORY_BAGS = ns.INVENTORY_BAGS or {0, 1, 2, 3, 4, 5} -- Includes reagent bag
local BANK_BAGS = ns.PERSONAL_BANK_BAGS or {-1, 6, 7, 8, 9, 10, 11}
local WARBAND_BAGS = ns.WARBAND_BAGS or {13, 14, 15, 16, 17}

-- O(1) bag type lookup table (replaces 3 sequential linear searches in ThrottledBagUpdate)
local BAG_TYPE_LOOKUP = {}  -- [bagID] = "inventory" | "bank" | "warband"
for i = 1, #INVENTORY_BAGS do
    local id = INVENTORY_BAGS[i]
    BAG_TYPE_LOOKUP[id] = "inventory"
end
for i = 1, #BANK_BAGS do
    local id = BANK_BAGS[i]
    BAG_TYPE_LOOKUP[id] = "bank"
end
for i = 1, #WARBAND_BAGS do
    local id = WARBAND_BAGS[i]
    BAG_TYPE_LOOKUP[id] = "warband"
end

-- ============================================================================
-- STATE MANAGEMENT
-- ============================================================================

-- Global loading state (accessible from UI)
ns.ItemsLoadingState = {
    isLoading = false,           -- Currently scanning bags
    scanProgress = 0,            -- 0-100 progress percentage
    loadingProgress = 0,       -- Mirror for UI_CreateLoadingStateCard (also reads loadingProgress)
    currentStage = nil,          -- Current stage (e.g., "Scanning Inventory")
}

-- Bank frame state (event-based detection)
local isBankOpen = false
local isWarbandBankOpen = false
local bankScanInProgress = false  -- True during OnBankOpened deferred scans; suppresses duplicate work

-- Hash cache for bag change detection (RAM only)
local bagHashCache = {} -- [bagID] = hash

-- Throttle timers
local lastUpdateTime = {}
local pendingUpdates = {}

-- ============================================================================
-- DECOMPRESSED DATA SESSION CACHE
-- Avoids repeated decompress+deserialize on every GetItemsData()/tooltip hover.
-- Invalidated per-key when items are scanned. Cleared on UI OnHide.
-- ============================================================================
local decompressedItemCache = {}  -- [charKey] = { bags={}, bank={}, bagsLastUpdate=N, bankLastUpdate=N }
local decompressedWarbandCache = nil  -- { items={}, lastUpdate=N }

-- ============================================================================
-- PRE-INDEXED ITEM COUNT SUMMARY
-- Instead of iterating ALL items on every tooltip hover, maintain a
-- pre-computed { [itemID] = { bags=N, bank=N } } per character.
-- Rebuilt lazily: marked "pending" when items change, processed on next tooltip access.
-- ============================================================================
local itemSummaryIndex = {
    characters = {},  -- [charKey] = { [itemID] = { bags=N, bank=N } }
    warband = {},     -- [itemID] = N
    pending = {},     -- [charKey] = true (needs rebuild)
    warbandPending = false,
}

-- Session-only item metadata cache (never persisted)
-- C_Item.GetItemInfoInstant is fast but we still avoid redundant calls
local itemMetadataCache = {}       -- [itemID] = { name, link, icon, classID, subclassID, itemType, pending? }
local itemMetadataCacheOrder = {}  -- FIFO eviction order tracking (head index + count)
local itemMetadataCacheHead = 1    -- Circular buffer head index
local ITEM_METADATA_CACHE_MAX = 2048

-- Async item metadata resolution tracking
local pendingItemLoads = {}              -- [itemID] = true (prevents duplicate async loads)
local pendingMetadataRefreshTimer = nil  -- Debounce timer for UI refresh after batch resolution

-- ============================================================================
-- COMPRESSION UTILITIES
-- ============================================================================

---Compress item data for storage
---@param data table Item data
---@return string|nil compressed Compressed string or nil on failure
local function CompressItemData(data)
    if not LibSerialize or not LibDeflate then
        return nil
    end
    
    if not data or type(data) ~= "table" then
        return nil
    end
    
    -- Serialize
    local serialized = LibSerialize:Serialize(data)
    if not serialized then
        return nil
    end
    
    -- Compress
    local compressed = LibDeflate:CompressDeflate(serialized)
    if not compressed then
        return nil
    end
    
    -- Encode for storage
    local encoded = LibDeflate:EncodeForPrint(compressed)
    if not encoded then
        return nil
    end
    
    return encoded
end

---Decompress item data from storage
---@param compressed string Compressed string
---@return table|nil data Decompressed data or nil on failure
local function DecompressItemData(compressed)
    if not LibSerialize or not LibDeflate then
        return nil
    end
    
    if not compressed or type(compressed) ~= "string" then
        return nil
    end
    
    -- Decode
    local decoded = LibDeflate:DecodeForPrint(compressed)
    if not decoded then
        return nil
    end
    
    -- Decompress
    local decompressed = LibDeflate:DecompressDeflate(decoded)
    if not decompressed then
        return nil
    end
    
    -- Deserialize
    local success, data = LibSerialize:Deserialize(decompressed)
    if not success or type(data) ~= "table" then
        return nil
    end
    
    return data
end

-- ============================================================================
-- ON-DEMAND ITEM METADATA RESOLVER (Session RAM only)
-- ============================================================================

---Add a fully-resolved metadata entry to the FIFO eviction cache.
---@param itemID number
local function AddToFIFOCache(itemID)
    if #itemMetadataCacheOrder >= ITEM_METADATA_CACHE_MAX then
        local evictID = itemMetadataCacheOrder[itemMetadataCacheHead]
        if evictID then
            -- Don't evict if the item is still pending async load
            if not (itemMetadataCache[evictID] and itemMetadataCache[evictID].pending) then
                itemMetadataCache[evictID] = nil
            end
        end
        itemMetadataCacheOrder[itemMetadataCacheHead] = itemID
        itemMetadataCacheHead = (itemMetadataCacheHead % ITEM_METADATA_CACHE_MAX) + 1
    else
        itemMetadataCacheOrder[#itemMetadataCacheOrder + 1] = itemID
    end
end

---Sync decompressed item cache with resolved metadata.
---HydrateItem does value copies (not references), so after async resolution
---the cached items still have name=nil, pending=true. This function patches
---them in-place so the UI sees updated names without a full re-hydration.
---@return number syncedCount Number of items that were patched
local function SyncDecompressedCacheWithMetadata()
    local syncedCount = 0
    -- Sync personal item caches (bags + bank per character)
    for charKey, data in pairs(decompressedItemCache) do
        local sources = {"bags", "bank"}
        for i = 1, #sources do
            local source = sources[i]
            local items = data[source]
            if items then
                for j = 1, #items do
                    local item = items[j]
                    if item.pending and item.itemID then
                        local meta = itemMetadataCache[item.itemID]
                        if meta and not meta.pending then
                            item.name = meta.name
                            item.link = item.link or meta.link
                            item.iconFileID = item.iconFileID or meta.iconFileID
                            item.classID = item.classID or meta.classID
                            item.pending = nil
                            syncedCount = syncedCount + 1
                        end
                    end
                end
            end
        end
    end
    -- Sync warband bank cache
    if decompressedWarbandCache and decompressedWarbandCache.items then
        for i = 1, #decompressedWarbandCache.items do
            local item = decompressedWarbandCache.items[i]
            if item.pending and item.itemID then
                local meta = itemMetadataCache[item.itemID]
                if meta and not meta.pending then
                    item.name = meta.name
                    item.link = item.link or meta.link
                    item.iconFileID = item.iconFileID or meta.iconFileID
                    item.classID = item.classID or meta.classID
                    item.pending = nil
                    syncedCount = syncedCount + 1
                end
            end
        end
    end
    return syncedCount
end

---Debounced UI refresh after async item metadata resolution.
---Fires WN_ITEM_METADATA_READY so the UI can re-read metadata from the cache.
---Syncs decompressed caches before firing the event so items show resolved names.
local function ScheduleMetadataRefresh()
    if pendingMetadataRefreshTimer then
        pendingMetadataRefreshTimer:Cancel()
    end
    pendingMetadataRefreshTimer = C_Timer.NewTimer(0, function()
        pendingMetadataRefreshTimer = nil
        -- Patch cached items with resolved metadata before notifying UI
        local synced = SyncDecompressedCacheWithMetadata()
        -- Only notify UI if items were actually updated (prevents unnecessary redraws)
        if synced > 0 then
            -- Do NOT bump `ns._gearStorageInvGen` here: that invalidates Gear stash cache on every
            -- metadata batch and causes repeated full FindGearStorageUpgrades for the same character
            -- (visible as triple "Scan ... Reject/add" lines + frame spikes). Stash invalidation is
            -- driven from UI.lua on ITEM_METADATA_READY / GET_ITEM_INFO via InvalidateGearStorageFindingsCacheForCanon.
            WarbandNexus:SendMessage(Constants.EVENTS.ITEM_METADATA_READY)
        end
    end)
end

---Queue async item load via Item:CreateFromItemID + ContinueOnItemLoad.
---When the item data becomes available, updates the metadata cache and schedules a UI refresh.
---@param itemID number
local function QueueAsyncItemLoad(itemID)
    if pendingItemLoads[itemID] then return end  -- Already queued
    pendingItemLoads[itemID] = true
    
    local item = Item:CreateFromItemID(itemID)
    item:ContinueOnItemLoad(function()
        pendingItemLoads[itemID] = nil
        
        -- Fetch now-available data
        local resolvedName, resolvedLink = C_Item.GetItemInfo(itemID)
        if not resolvedName then return end  -- Safety: still not available (shouldn't happen)
        
        -- Update existing cache entry (or create new one)
        local existing = itemMetadataCache[itemID]
        if existing then
            existing.name = resolvedName
            existing.link = resolvedLink
            existing.pending = nil
            -- Now fully resolved: add to FIFO eviction
            AddToFIFOCache(itemID)
        end
        
        -- Schedule debounced UI refresh (batches multiple resolutions)
        ScheduleMetadataRefresh()
    end)
end

---Resolve item metadata from WoW API (session RAM cache, never persisted).
---Uses ContinueOnItemLoad for async resolution of uncached items.
---@param itemID number
---@return table|nil { name, link, icon, classID, subclassID, itemType, pending? }
local function ResolveItemMetadata(itemID)
    if not itemID or itemID == 0 then return nil end
    
    -- Check RAM cache (return immediately if fully resolved)
    local cached = itemMetadataCache[itemID]
    if cached and not cached.pending then return cached end
    -- If pending, re-check API (may have resolved since last call)
    if cached and cached.pending then
        local name, link
        if C_Item and C_Item.GetItemInfo then
            name, link = C_Item.GetItemInfo(itemID)
        end
        if name then
            cached.name = name
            cached.link = link
            cached.pending = nil
            AddToFIFOCache(itemID)
            pendingItemLoads[itemID] = nil
            return cached
        end
        return cached  -- Still pending
    end
    
    -- C_Item.GetItemInfoInstant is synchronous (icon, classID always available)
    local _, itemType, itemSubType, _, icon, classID, subclassID
    if C_Item and C_Item.GetItemInfoInstant then
        _, itemType, itemSubType, _, icon, classID, subclassID = C_Item.GetItemInfoInstant(itemID)
    end
    
    -- C_Item.GetItemInfo may return nil for uncached items (async)
    local name, link
    if C_Item and C_Item.GetItemInfo then
        name, link = C_Item.GetItemInfo(itemID)
    end
    
    local isPending = (name == nil)
    
    local metadata = {
        name = name,      -- nil if not yet resolved (NO fake "Item 123" fallback)
        link = link,
        icon = icon,
        iconFileID = icon,
        classID = classID,
        subclassID = subclassID,
        itemType = itemType or "Miscellaneous",
        pending = isPending or nil,
    }
    
    -- Store in lookup table (always, so we don't create duplicate entries)
    itemMetadataCache[itemID] = metadata
    
    if isPending then
        -- Queue async load (ContinueOnItemLoad fires when server responds)
        QueueAsyncItemLoad(itemID)
    else
        -- Fully resolved: add to FIFO eviction
        AddToFIFOCache(itemID)
    end
    
    return metadata
end

--[[
    Session cold-cache hint: prime RAM metadata for equipped items only (no GearService tooltip upgrade scan).
    PLAYER_ENTERING_WORLD (login/reload) schedules via InitializationService — ns.GEAR_SLOTS exists at runtime (GearService loads later in TOC but before timers fire).
]]
function WarbandNexus:PrefetchSessionEquippedItemMetadata()
    local mods = self.db and self.db.profile and self.db.profile.modulesEnabled
    if mods then
        if mods.items == false and mods.gear == false and mods.storage == false then
            return
        end
    end

    if ns.CharacterService and not ns.CharacterService:IsCharacterTracked(self) then
        return
    end

    local gearSlots = ns.GEAR_SLOTS
    if not gearSlots then return end

    for si = 1, #gearSlots do
        local slotDef = gearSlots[si]
        local slotID = slotDef and slotDef.id
        if slotID then
            local itemLink = GetInventoryItemLink("player", slotID)
            if itemLink and issecretvalue and issecretvalue(itemLink) then
                itemLink = nil
            end
            if itemLink then
                local itemID = nil
                pcall(function()
                    if C_Item and C_Item.GetItemInfoInstant then
                        itemID = C_Item.GetItemInfoInstant(itemLink)
                    end
                end)
                if not itemID and type(itemLink) == "string" and not (issecretvalue and issecretvalue(itemLink)) then
                    local idFromLink = itemLink:match("item:(%d+)")
                    itemID = idFromLink and tonumber(idFromLink) or nil
                end
                if itemID and itemID ~= 0 then
                    ResolveItemMetadata(itemID)
                end
            end
        end
    end
end

---Hydrate a lean item (from SV) with on-demand metadata (value copy, not reference).
---Sets item.pending = true if the item name is still being loaded asynchronously.
---NOTE: After async resolution, SyncDecompressedCacheWithMetadata() patches items in-place.
---@param item table Lean item { itemID, stackCount, quality, isBound, bagID, slotIndex, ... }
---@return table hydrated Full item with name, link, icon, classID, etc.
local function HydrateItem(item)
    if not item or not item.itemID then return item end
    
    -- If already hydrated (legacy data) and NOT pending, return as-is
    if item.name and item.iconFileID and item.classID and not item.pending then
        return item
    end
    
    local metadata = ResolveItemMetadata(item.itemID)
    if metadata then
        item.name = item.name or metadata.name
        -- Preserve original hyperlink from scan (contains bonus IDs, rank, ilvl).
        -- metadata.link is the base link from C_Item.GetItemInfo(itemID) — lacks bonus IDs.
        local originalLink = item.itemLink or item.link
        item.link = originalLink or metadata.link
        item.itemLink = item.link
        item.iconFileID = item.iconFileID or metadata.iconFileID
        item.classID = item.classID or metadata.classID
        item.subclassID = item.subclassID or metadata.subclassID
        item.itemType = item.itemType or metadata.itemType
        -- Propagate pending state to item (UI uses this to show loading indicator)
        item.pending = metadata.pending or nil
    end
    
    return item
end

---Hydrate an array of lean items with metadata.
---@param items table Array of lean items
---@return table items Same array, items hydrated in-place
local function HydrateItems(items)
    if not items then return items end
    for i = 1, #items do
        local item = items[i]
        HydrateItem(item)
    end
    return items
end

---Clear session-only item metadata cache.
function WarbandNexus:ClearItemMetadataCache()
    wipe(itemMetadataCache)
    wipe(itemMetadataCacheOrder)
    itemMetadataCacheHead = 1
    wipe(pendingItemLoads)
    if pendingMetadataRefreshTimer then
        pendingMetadataRefreshTimer:Cancel()
        pendingMetadataRefreshTimer = nil
    end
    -- Also clear decompressed data cache and summary index
    wipe(decompressedItemCache)
    decompressedWarbandCache = nil
    wipe(itemSummaryIndex.characters)
    wipe(itemSummaryIndex.warband)
    wipe(itemSummaryIndex.pending)
    itemSummaryIndex.warbandPending = false
end

-- ============================================================================
-- HASH GENERATION (CHANGE DETECTION) + BAG SCANNING
-- ============================================================================

-- Per-bag cache: stores raw GetContainerItemInfo results from the hash pass
-- so ScanBag can reuse them instead of re-querying every slot.
local cachedSlotData = {}  -- [bagID] = { [slot] = itemInfo, numSlots = n }

---Generate hash for a bag to detect real changes.
---Also caches raw slot data for ScanBag to reuse (avoids double API calls).
---Hash includes: item count + item links (ignores durability, charges, cooldowns)
---@param bagID number Bag ID
---@return string hash Hash string
local function GenerateItemHash(bagID)
    local items = {}
    local n = 0
    local numSlots = C_Container.GetContainerNumSlots(bagID) or 0
    local slotCache = { numSlots = numSlots }
    
    for slot = 1, numSlots do
        local itemInfo = C_Container.GetContainerItemInfo(bagID, slot)
        slotCache[slot] = itemInfo  -- Cache for ScanBag (nil entries are fine)
        if itemInfo and itemInfo.hyperlink then
            local hl = itemInfo.hyperlink
            if not (issecretvalue and issecretvalue(hl)) then
                -- Hash includes: hyperlink + stack count
                -- Does NOT include: durability, charges, cooldowns
                n = n + 1
                items[n] = hl .. ":" .. (itemInfo.stackCount or 1)
            end
        end
    end
    
    cachedSlotData[bagID] = slotCache
    return table.concat(items, "|")
end

---Check if bag contents actually changed (not just durability/charges)
---@param bagID number Bag ID
---@return boolean changed True if contents changed
local function HasBagChanged(bagID)
    local newHash = GenerateItemHash(bagID)
    local oldHash = bagHashCache[bagID]
    
    if newHash ~= oldHash then
        bagHashCache[bagID] = newHash
        return true
    end
    
    -- Hash unchanged: clear the cached slot data (not needed)
    cachedSlotData[bagID] = nil
    return false
end

--- Snapshot ilvl at scan time so Gear storage recommendations do not depend on cold GetItemInfo.
---@param bagID number
---@param slot number
---@param itemID number|nil
---@param itemLink string|nil
---@return number
local function CaptureContainerSlotItemLevel(bagID, slot, itemID, itemLink)
    if bagID ~= nil and slot and ItemLocation and ItemLocation.CreateFromBagAndSlot
        and C_Item and C_Item.GetCurrentItemLevel then
        local okLoc, ilFromLoc = pcall(function()
            local loc = ItemLocation:CreateFromBagAndSlot(bagID, slot)
            if loc and loc.IsValid and loc:IsValid() then
                local v = C_Item.GetCurrentItemLevel(loc)
                if type(v) == "number" and v > 0 and not (issecretvalue and issecretvalue(v)) then
                    return v
                end
            end
            return 0
        end)
        if okLoc and ilFromLoc and ilFromLoc > 0 then return ilFromLoc end
    end
    if itemLink and not (issecretvalue and issecretvalue(itemLink)) and C_Item and C_Item.GetDetailedItemLevelInfo then
        local okD, det = pcall(C_Item.GetDetailedItemLevelInfo, itemLink)
        if okD and type(det) == "number" and det > 0 and not (issecretvalue and issecretvalue(det)) then
            return det
        end
    end
    if itemID and C_Item and C_Item.GetDetailedItemLevelInfo then
        local okI, detI = pcall(C_Item.GetDetailedItemLevelInfo, itemID)
        if okI and type(detI) == "number" and detI > 0 and not (issecretvalue and issecretvalue(detI)) then
            return detI
        end
    end
    return 0
end

---Scan a specific bag and return LEAN item data (metadata stripped).
---Reuses cached slot data from GenerateItemHash when available (single pass).
---Only stores: itemID, stackCount, quality, isBound, positional fields.
---Metadata (name, link, icon, classID) is resolved on-demand when reading.
---@param bagID number Bag ID
---@return table items Array of lean item data
local function ScanBag(bagID)
    local items = {}
    local n = 0  -- Manual count avoids #items overhead per insert
    local slotCache = cachedSlotData[bagID]
    local numSlots
    
    if slotCache then
        -- Fast path: reuse cached data from GenerateItemHash (no API calls)
        numSlots = slotCache.numSlots or 0
        for slot = 1, numSlots do
            local itemInfo = slotCache[slot]
            if itemInfo and itemInfo.hyperlink then
                local hl = itemInfo.hyperlink
                if not (issecretvalue and issecretvalue(hl)) then
                    local itemID = C_Item.GetItemInfoInstant(hl)
                    if itemID then
                        n = n + 1
                        local snapIlvl = CaptureContainerSlotItemLevel(bagID, slot, itemID, hl)
                        items[n] = {
                            actualBagID = bagID,
                            bagID = bagID,
                            slotIndex = slot,
                            slot = slot,
                            itemID = itemID,
                            itemLink = hl,
                            stackCount = itemInfo.stackCount or 1,
                            quality = itemInfo.quality,
                            isBound = itemInfo.isBound or false,
                            itemLevel = (snapIlvl > 0) and snapIlvl or nil,
                        }
                    end
                end
            end
        end
        cachedSlotData[bagID] = nil  -- Consumed; free memory
    else
        -- Fallback: no cached data (bank bags, deferred updates, etc.)
        numSlots = C_Container.GetContainerNumSlots(bagID) or 0
        for slot = 1, numSlots do
            local itemInfo = C_Container.GetContainerItemInfo(bagID, slot)
            if itemInfo and itemInfo.hyperlink then
                local hl = itemInfo.hyperlink
                if not (issecretvalue and issecretvalue(hl)) then
                    local itemID = C_Item.GetItemInfoInstant(hl)
                    if itemID then
                        n = n + 1
                        local snapIlvl = CaptureContainerSlotItemLevel(bagID, slot, itemID, hl)
                        items[n] = {
                            actualBagID = bagID,
                            bagID = bagID,
                            slotIndex = slot,
                            slot = slot,
                            itemID = itemID,
                            itemLink = hl,
                            stackCount = itemInfo.stackCount or 1,
                            quality = itemInfo.quality,
                            isBound = itemInfo.isBound or false,
                            itemLevel = (snapIlvl > 0) and snapIlvl or nil,
                        }
                    end
                end
            end
        end
    end
    
    return items
end

---Scan all inventory bags for current character
---@param charKey string Character key
---INCREMENTAL UPDATE: Update only specific bag (single bag scan)
---@param charKey string Character key
---@param bagID number Specific bag to update
function WarbandNexus:UpdateSingleBag(charKey, bagID)
    -- GUARD: Only update bags if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return
    end
    
    if not charKey then
        charKey = ResolveCurrentItemStorageKey()
    end
    
    -- Determine if this is an inventory bag or bank bag
    local isInventoryBag = false
    local isBankBag = false
    
    for i = 1, #INVENTORY_BAGS do
        local invBagID = INVENTORY_BAGS[i]
        if bagID == invBagID then
            isInventoryBag = true
            break
        end
    end
    
    for i = 1, #BANK_BAGS do
        local bankBagID = BANK_BAGS[i]
        if bagID == bankBagID then
            isBankBag = true
            break
        end
    end
    
    if not isInventoryBag and not isBankBag then
        return {}
    end
    
    -- Get current data from DB
    local currentData = self:GetItemsData(charKey)
    local dataType = isInventoryBag and "bags" or "bank"
    local allItems = isInventoryBag and (currentData.bags or {}) or (currentData.bank or {})
    
    -- Remove old items from this specific bag
    for i = #allItems, 1, -1 do
        if allItems[i].bagID == bagID then
            table.remove(allItems, i)
        end
    end
    
    -- Scan only this bag
    local newBagItems = ScanBag(bagID)
    -- Add new items from this bag
    for i = 1, #newBagItems do
        allItems[#allItems + 1] = newBagItems[i]
    end
    
    -- Save to DB (compressed)
    self:SaveItemsCompressed(charKey, dataType, allItems)
    
    return allItems
end

---INCREMENTAL UPDATE: Update only a specific warband bank bag (single tab scan)
---Avoids full ScanWarbandBank() which scans all 5 tabs on every change
---@param bagID number Warband bag ID (13-17)
function WarbandNexus:UpdateSingleWarbandBag(bagID)
    
    -- GUARD: Only update if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return
    end
    
    -- Determine tabIndex from WARBAND_BAGS array position
    local tabIndex = nil
    for i = 1, #WARBAND_BAGS do
        local warbandBagID = WARBAND_BAGS[i]
        if bagID == warbandBagID then
            tabIndex = i
            break
        end
    end
    
    if not tabIndex then
        return  -- Not a warband bag
    end
    
    -- Get current warband data from DB
    local warbandData = self:GetWarbandBankData()
    local allItems = warbandData.items or {}
    
    -- Remove old items from this specific bag
    for i = #allItems, 1, -1 do
        if allItems[i].bagID == bagID then
            table.remove(allItems, i)
        end
    end
    
    -- Scan only this bag
    local newBagItems = ScanBag(bagID)
    
    -- Add new items with tabIndex
    for i = 1, #newBagItems do
        local item = newBagItems[i]
        item.tabIndex = tabIndex
        allItems[#allItems + 1] = item
    end
    
    -- Save to DB (compressed, warband bank is account-wide)
    self:SaveWarbandBankCompressed(allItems)
end

---FULL SCAN: Scan all inventory bags (used on login or manual refresh)
---@param charKey string Character key
function WarbandNexus:ScanInventoryBags(charKey)
    -- GUARD: Only scan if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return {}
    end
    
    if not charKey then
        charKey = ResolveCurrentItemStorageKey()
    end
    
    local allItems = {}
    local totalSlots = 0
    
    -- Scan ALL inventory bags
    for ii = 1, #INVENTORY_BAGS do
        local bagID = INVENTORY_BAGS[ii]
        totalSlots = totalSlots + (C_Container.GetContainerNumSlots(bagID) or 0)
        local bagItems = ScanBag(bagID)
        for i = 1, #bagItems do
            allItems[#allItems + 1] = bagItems[i]
        end
    end
    
    -- Save to DB (compressed)
    self:SaveItemsCompressed(charKey, "bags", allItems)
    
    -- Populate legacy metadata for ItemsUI header (usedSlots, totalSlots, lastScan).
    -- This replaces the expensive DataService:ScanCharacterBags() login scan which called
    -- C_Item.GetItemInfo() per slot. We compute the same metadata from our lean scan.
    if self.db and self.db.char then
        if not self.db.char.bags then self.db.char.bags = {} end
        self.db.char.bags.usedSlots = #allItems
        self.db.char.bags.totalSlots = totalSlots
        self.db.char.bags.lastScan = time()
    end
    
    return allItems
end

---Scan all bank bags for current character
---@param charKey string Character key
function WarbandNexus:ScanBankBags(charKey)
    -- GUARD: Only scan if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return {}
    end
    
    if not charKey then
        charKey = ResolveCurrentItemStorageKey()
    end
    
    local allItems = {}
    
    -- Scan ALL bank bags (NO FLAG CHECK - just scan)
    for ii = 1, #BANK_BAGS do
        local bagID = BANK_BAGS[ii]
        local bagItems = ScanBag(bagID)
        for i = 1, #bagItems do
            allItems[#allItems + 1] = bagItems[i]
        end
    end
    
    -- Save to DB (compressed)
    self:SaveItemsCompressed(charKey, "bank", allItems)
    
    return allItems
end

---Scan warband bank
function WarbandNexus:ScanWarbandBank()
    -- GUARD: Only scan if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return {}
    end
    
    local allItems = {}
    
    -- Scan ALL warband bank tabs (NO FLAG CHECK - just scan)
    for ii = 1, #WARBAND_BAGS do
        local bagID = WARBAND_BAGS[ii]
        local bagItems = ScanBag(bagID)
        for i = 1, #bagItems do
            local item = bagItems[i]
            -- Add tab index for warband bank (1-5)
            item.tabIndex = ii
            allItems[#allItems + 1] = item
        end
    end
    
    -- Save to global (compressed, warband bank is account-wide)
    self:SaveWarbandBankCompressed(allItems)
    
    return allItems
end

-- ============================================================================
-- THROTTLED UPDATE SYSTEM
-- ============================================================================

---Throttled bag update (prevents BAG_UPDATE spam)
---Returns true if the update was processed (or scheduled), false if suppressed.
---@param bagID number Bag ID
---@return boolean processed
local function ThrottledBagUpdate(bagID)
    -- Suppress during bank open deferred scans (OnBankOpened handles it)
    if bankScanInProgress then
        return false
    end
    
    local currentTime = GetTime()
    local lastUpdate = lastUpdateTime[bagID] or 0
    
    if currentTime - lastUpdate < UPDATE_THROTTLE then
        -- Schedule pending update with timer
        if not pendingUpdates[bagID] then
            pendingUpdates[bagID] = true
            
            -- Schedule processing after throttle period
            C_Timer.After(UPDATE_THROTTLE - (currentTime - lastUpdate), function()
                if pendingUpdates[bagID] then
                    pendingUpdates[bagID] = nil
                    local processed = ThrottledBagUpdate(bagID)  -- Retry
                    -- Send coalesced message for deferred retry (no caller to batch with)
                    if processed then
                        local retryKey = CanonicalItemsMessageKey(ResolveCurrentItemStorageKey())
                        WarbandNexus:SendMessage(Constants.EVENTS.ITEMS_UPDATED, {type = "batch", charKey = retryKey})
                    end
                end
            end)
        end
        return false
    end
    
    lastUpdateTime[bagID] = currentTime
    pendingUpdates[bagID] = nil
    
    -- Determine bag type via O(1) lookup and scan INCREMENTALLY (only changed bag)
    local charKey = ResolveCurrentItemStorageKey()
    local bagType = BAG_TYPE_LOOKUP[bagID]
    
    if bagType == "inventory" then
        WarbandNexus:UpdateSingleBag(charKey, bagID)
        return true  -- message sent by caller (batched)
    elseif bagType == "bank" then
        if isBankOpen then
            WarbandNexus:UpdateSingleBag(charKey, bagID)
            return true
        end
        return false
    elseif bagType == "warband" then
        if isWarbandBankOpen then
            WarbandNexus:UpdateSingleWarbandBag(bagID)
            return true
        end
        return false
    end
    
    return false
end

---Process pending bag updates (called by timer)
function WarbandNexus:ProcessPendingBagUpdates()
    local anyProcessed = false
    for bagID, _ in pairs(pendingUpdates) do
        if ThrottledBagUpdate(bagID) then
            anyProcessed = true
        end
    end
    -- Batch: one message for all processed pending bags
    if anyProcessed then
        local msgKey = CanonicalItemsMessageKey(ResolveCurrentItemStorageKey())
        self:SendMessage(Constants.EVENTS.ITEMS_UPDATED, {type = "batch", charKey = msgKey})
    end
end

-- ============================================================================
-- EVENT HANDLERS (Will be registered by EventManager)
-- ============================================================================

---Handle BAG_UPDATE event (from RegisterBucketEvent)
---Batches all bag updates and sends ONE coalesced ITEMS_UPDATED message
---@param bagIDs table Table of bagIDs that were updated (from bucket)
function WarbandNexus:OnBagUpdate(bagIDs)
    DebugVerbosePrint("|cff9370DB[WN ItemsCache]|r [Items Event] BAG_UPDATE (bucket) triggered")
    -- GUARD: Only process bag updates if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return
    end
    
    -- RegisterBucketEvent passes a table of bagIDs
    if not bagIDs or type(bagIDs) ~= "table" then
        return
    end
    
    -- Process each bag that was updated, track if any actually changed
    local anyProcessed = false
    for bagID in pairs(bagIDs) do
        -- Smart filter: Check if bag contents actually changed
        if HasBagChanged(bagID) then
            if ThrottledBagUpdate(bagID) then
                anyProcessed = true
            end
        end
    end
    
    -- Send ONE coalesced message for all processed bags (instead of per-bag)
    if anyProcessed then
        local msgKey = CanonicalItemsMessageKey(ResolveCurrentItemStorageKey())
        self:SendMessage(Constants.EVENTS.ITEMS_UPDATED, {type = "batch", charKey = msgKey})
    end
end

---Chunked bank-area scan: one bag (container) per frame to cap frame cost (inventory → bank → warband).
---@param charKey string
function WarbandNexus:RunBudgetedBankOpenScan(charKey)
    if not charKey then
        charKey = ResolveCurrentItemStorageKey()
    end

    local invIdx = 1
    local allInv = {}
    local totalSlots = 0

    local bankIdx = 1
    local allBank = {}

    local wbIdx = 1
    local allWb = {}

    local runInv
    local runBank
    local runWarband

    runWarband = function()
        if not isBankOpen then
            bankScanInProgress = false
            return
        end
        if wbIdx > #WARBAND_BAGS then
            self:SaveWarbandBankCompressed(allWb)
            bankScanInProgress = false
            self:SendMessage(Constants.EVENTS.ITEMS_UPDATED, { type = "all", charKey = CanonicalItemsMessageKey(charKey) })
            return
        end
        local bagID = WARBAND_BAGS[wbIdx]
        local bagItems = ScanBag(bagID)
        for i = 1, #bagItems do
            local item = bagItems[i]
            item.tabIndex = wbIdx
            allWb[#allWb + 1] = item
        end
        wbIdx = wbIdx + 1
        C_Timer.After(0, runWarband)
    end

    runBank = function()
        if not isBankOpen then
            bankScanInProgress = false
            return
        end
        if bankIdx > #BANK_BAGS then
            self:SaveItemsCompressed(charKey, "bank", allBank)
            C_Timer.After(0, runWarband)
            return
        end
        local bagID = BANK_BAGS[bankIdx]
        local bagItems = ScanBag(bagID)
        for i = 1, #bagItems do
            allBank[#allBank + 1] = bagItems[i]
        end
        bankIdx = bankIdx + 1
        C_Timer.After(0, runBank)
    end

    runInv = function()
        if not isBankOpen then
            bankScanInProgress = false
            return
        end
        if invIdx > #INVENTORY_BAGS then
            self:SaveItemsCompressed(charKey, "bags", allInv)
            if self.db and self.db.char then
                if not self.db.char.bags then self.db.char.bags = {} end
                self.db.char.bags.usedSlots = #allInv
                self.db.char.bags.totalSlots = totalSlots
                self.db.char.bags.lastScan = time()
            end
            C_Timer.After(0, runBank)
            return
        end
        local bagID = INVENTORY_BAGS[invIdx]
        totalSlots = totalSlots + (C_Container.GetContainerNumSlots(bagID) or 0)
        local bagItems = ScanBag(bagID)
        for i = 1, #bagItems do
            allInv[#allInv + 1] = bagItems[i]
        end
        invIdx = invIdx + 1
        C_Timer.After(0, runInv)
    end

    runInv()
end

---Handle BANKFRAME_OPENED event
function WarbandNexus:OnBankOpened()
    DebugVerbosePrint("|cff9370DB[WN ItemsCache]|r [Bank Event] BANKFRAME_OPENED triggered")
    
    -- Set global flag for Gold Manager
    WarbandNexus.bankIsOpen = true
    
    -- GOLD MANAGER: Trigger gold management
    if self.TriggerGoldManagement then
        C_Timer.After(0.1, function()
            self:TriggerGoldManagement()
        end)
    end
    
    -- GUARD: Only process if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return
    end
    
    isBankOpen = true
    isWarbandBankOpen = true
    bankScanInProgress = true  -- Suppress ThrottledBagUpdate while we do the full scan
    
    local charKey = ResolveCurrentItemStorageKey()
    
    -- One container per frame (replaces nested 0 + 0.05s timers; avoids large synchronous spikes)
    C_Timer.After(0, function()
        self:RunBudgetedBankOpenScan(charKey)
    end)
end

---Handle BANKFRAME_CLOSED event
function WarbandNexus:OnBankClosed()
    isBankOpen = false
    isWarbandBankOpen = false  -- Both tabs close together
    bankScanInProgress = false  -- Safety: ensure flag is cleared
    WarbandNexus.bankIsOpen = false  -- Clear global flag for Gold Manager
    DebugVerbosePrint("|cff9370DB[WN ItemsCache]|r [Bank Event] BANKFRAME_CLOSED")
end

---Handle BAG_UPDATE_DELAYED (fires once after all pending bag operations complete)
---
---ARCHITECTURE NOTE: This is a lightweight "settled" signal, NOT a data scanner.
---Data updates are already handled by the BAG_UPDATE bucket → ThrottledBagUpdate → SingleBagUpdate.
---
---BAG_UPDATE_DELAYED "settled" signal handler.
---
---ARCHITECTURE: This is a NO-OP handler. Data updates are already handled by:
---  - BAG_UPDATE bucket (0.5s) → OnBagUpdate → ThrottledBagUpdate → fires WN_ITEMS_UPDATED
---  - CollectionService raw frame → ScanBagsForNewCollectibles (independent)
---
---Previously fired WN_BAGS_UPDATED here (1.0s debounce), causing DOUBLE UI refresh
---for every bag change: WN_ITEMS_UPDATED at ~0.5s + WN_BAGS_UPDATED at ~1.5s.
---The 800ms cooldown in UI.lua suppressed most of these but still caused unnecessary
---handler invocations and timer management overhead.
---
---REMOVED: WN_BAGS_UPDATED fire (redundant with WN_ITEMS_UPDATED from OnBagUpdate).
---REMOVED: GetBagFingerprint (~100+ API calls) and ScanInventoryBags (full redundant scan).
function WarbandNexus:OnInventoryBagsChanged()
    -- Intentionally empty: BAG_UPDATE bucket handles all data updates.
    -- This handler exists only to consume the BAG_UPDATE_DELAYED registration
    -- so no "unhandled event" warnings fire. All real work is in OnBagUpdate.
end

-- OnWarbandBankFrameOpened / OnWarbandBankFrameClosed: REMOVED — Dead code.
-- These handlers were defined but never registered to any event.
-- Warband bank scanning is handled by OnBankOpened (BANKFRAME_OPENED) which scans
-- all bank types including warband tabs. BAG_UPDATE bucket catches incremental changes.

---Handle PLAYERREAGENTBANKSLOTS_CHANGED event
function WarbandNexus:OnReagentBankChanged()
    -- Reagent bank is part of bank (bag 5)
    if isBankOpen then
        ThrottledBagUpdate(5) -- Reagent bag ID
    end
end

-- ============================================================================
-- COMPRESSED STORAGE (SAVE/LOAD)
-- ============================================================================

---Save items data (compressed)
---@param charKey string Character key
---@param dataType string "bags" or "bank"
---@param items table Items array
function WarbandNexus:SaveItemsCompressed(charKey, dataType, items)
    -- GUARD: Only save if character is tracked
    if not ns.CharacterService or not ns.CharacterService:IsCharacterTracked(self) then
        return
    end
    
    if not charKey or not dataType or not items then return end
    
    -- Initialize structure
    if not self.db.global.itemStorage then
        self.db.global.itemStorage = {}
    end
    
    if not self.db.global.itemStorage[charKey] then
        self.db.global.itemStorage[charKey] = {}
    end
    
    -- Compress data
    local compressed = CompressItemData(items)
    local slotCount = #items
    local stackTotal = 0
    for si = 1, slotCount do
        stackTotal = stackTotal + (items[si].stackCount or 1)
    end
    if not compressed then
        -- Fallback: store uncompressed
        self.db.global.itemStorage[charKey][dataType] = {
            compressed = false,
            data = items,
            lastUpdate = time(),
            slotCount = slotCount,
            stackTotal = stackTotal,
        }
        -- Invalidate caches even on uncompressed fallback
        decompressedItemCache[charKey] = nil
        itemSummaryIndex.pending[charKey] = true
        BumpGearStorageScanGeneration()
        return
    end
    
    -- Store compressed
    self.db.global.itemStorage[charKey][dataType] = {
        compressed = true,
        data = compressed,
        lastUpdate = time(),
        slotCount = slotCount,
        stackTotal = stackTotal,
    }
    
    -- Invalidate decompressed cache + mark summary index pending for this character
    decompressedItemCache[charKey] = nil
    itemSummaryIndex.pending[charKey] = true
    BumpGearStorageScanGeneration()
end

---Save warband bank data (compressed)
---@param items table Items array
function WarbandNexus:SaveWarbandBankCompressed(items)
    if not items then return end
    
    -- Initialize structure
    if not self.db.global.itemStorage then
        self.db.global.itemStorage = {}
    end
    
    -- Compress data
    local compressed = CompressItemData(items)
    local slotCount = #items
    local stackTotal = 0
    for si = 1, slotCount do
        stackTotal = stackTotal + (items[si].stackCount or 1)
    end
    if not compressed then
        -- Fallback: store uncompressed
        self.db.global.itemStorage.warbandBank = {
            compressed = false,
            data = items,
            lastUpdate = time(),
            slotCount = slotCount,
            stackTotal = stackTotal,
        }
        decompressedWarbandCache = nil
        itemSummaryIndex.warbandPending = true
        BumpGearStorageScanGeneration()
        return
    end
    
    -- Store compressed
    self.db.global.itemStorage.warbandBank = {
        compressed = true,
        data = compressed,
        lastUpdate = time(),
        slotCount = slotCount,
        stackTotal = stackTotal,
    }
    
    -- Invalidate decompressed cache + mark warband summary pending
    decompressedWarbandCache = nil
    itemSummaryIndex.warbandPending = true
    BumpGearStorageScanGeneration()
end

-- ============================================================================
-- MERGE: v2 itemStorage + legacy character inventory + personalBanks
-- `itemStorage[charKey] = {}` (empty) or v2-only must still see legacy table rows;
-- `characters[charKey]` and canonical key may differ.
-- ============================================================================

---@param item table
---@return string|nil
local function ItemMergeDedupeKey(item)
    if not item or not item.itemID then return nil end
    local link = item.itemLink or item.link
    if link and type(link) == "string" and link ~= "" then
        if issecretvalue and issecretvalue(link) then
            return "I:" .. tostring(item.itemID)
        end
        return "L:" .. link
    end
    return "I:" .. tostring(item.itemID) .. ":" .. tostring(item.quality or 0)
end

---@param list table|nil
---@param seen table<string, boolean>
local function AddSeenFromItemList(list, seen)
    if not list or not seen then return end
    for i = 1, #list do
        local it = list[i]
        if it and it.itemID then
            local k = ItemMergeDedupeKey(it)
            if k then seen[k] = true end
        end
    end
end

---@param target table
---@param source table
---@param seen table<string, boolean>
local function AppendItemsDeduped(target, source, seen)
    if not target or not source or not seen then return end
    for i = 1, #source do
        local it = source[i]
        if it and it.itemID then
            local k = ItemMergeDedupeKey(it)
            if k and not seen[k] then
                seen[k] = true
                target[#target + 1] = it
            end
        end
    end
end

---@param pb table|nil
---@return table
local function PersonalBankToItemArray(pb)
    if not pb then return {} end
    local bankData = pb.compressed and (DecompressItemData(pb.data) or {}) or (pb.data or {})
    local bankArr = {}
    if type(bankData) == "table" and not bankData[1] then
        for bagID, bagData in pairs(bankData) do
            if type(bagData) == "table" then
                for slotID, item in pairs(bagData) do
                    if type(item) == "table" and item.itemID then
                        bankArr[#bankArr + 1] = {
                            bagID = item.actualBagID or bagID,
                            slot = slotID,
                            slotIndex = slotID,
                            itemID = item.itemID,
                            itemLink = item.itemLink,
                            stackCount = item.stackCount or 1,
                            quality = item.quality,
                            isBound = item.isBound or false,
                        }
                    end
                end
            end
        end
    end
    return bankArr
end

---@param self table
---@param result table
---@param charKey string
local function MergeExtraItemSourcesIntoItemsData(self, result, charKey)
    if not result or not charKey or charKey == "" then return end
    result.bags = result.bags or {}
    result.bank = result.bank or {}
    local global = self.db and self.db.global
    if not global then return end

    local seen = {}
    AddSeenFromItemList(result.bags, seen)
    AddSeenFromItemList(result.bank, seen)

    local keys = {}
    local function addKey(k)
        if not k or k == "" then return end
        for i = 1, #keys do
            if keys[i] == k then return end
        end
        keys[#keys + 1] = k
    end
    addKey(charKey)
    if ns.Utilities and ns.Utilities.GetCanonicalCharacterKey then
        addKey(ns.Utilities:GetCanonicalCharacterKey(charKey))
    end

    for i = 1, #keys do
        local k = keys[i]
        local ch = global.characters and global.characters[k]
        if ch then
            if ch.items and #ch.items > 0 then
                AppendItemsDeduped(result.bags, HydrateItems(ch.items), seen)
            end
            if ch.bank and #ch.bank > 0 then
                AppendItemsDeduped(result.bank, HydrateItems(ch.bank), seen)
            end
        end
        local pb = global.personalBanks and global.personalBanks[k]
        if pb then
            local arr = PersonalBankToItemArray(pb)
            if #arr > 0 then
                AppendItemsDeduped(result.bank, HydrateItems(arr), seen)
            end
        end
    end
    HydrateItems(result.bags)
    HydrateItems(result.bank)
end

-- ============================================================================
-- PUBLIC API (FOR UI AND DATASERVICE)
-- ============================================================================

--- One-time slotCount backfill for legacy compressed buckets (persists in AceDB on next save).
---@param bucket table
---@return number|nil slotCount
---@return number|nil stackTotal
local function LazyBackfillSlotCountFromCompressed(bucket)
    if not bucket or bucket.slotCount or not bucket.compressed or not bucket.data then
        return nil, nil
    end
    local items = DecompressItemData(bucket.data)
    if not items then
        return nil, nil
    end
    local slotCount = #items
    local stackTotal = 0
    for i = 1, slotCount do
        stackTotal = stackTotal + (items[i].stackCount or 1)
    end
    bucket.slotCount = slotCount
    bucket.stackTotal = stackTotal
    return slotCount, stackTotal
end

--- Occupied slots + stack totals from a persisted itemStorage bucket without decompress.
---@param bucket table|nil
---@param allowLazyBackfill boolean|nil
---@return number|nil slotCount
---@return number|nil stackTotal
---@return number|nil lastUpdate
local function OccupiedSlotsFromStorageBucket(bucket, allowLazyBackfill)
    if not bucket then
        return nil, nil, nil
    end
    local lastUpdate = bucket.lastUpdate or 0
    if bucket.slotCount then
        return bucket.slotCount, bucket.stackTotal or bucket.slotCount, lastUpdate
    end
    if bucket.compressed == false and type(bucket.data) == "table" then
        local slots = #bucket.data
        local stacks = 0
        for i = 1, slots do
            local item = bucket.data[i]
            stacks = stacks + (item and item.stackCount or 1)
        end
        return slots, stacks, lastUpdate
    end
    if bucket.compressed and allowLazyBackfill ~= false then
        local slots, stacks = LazyBackfillSlotCountFromCompressed(bucket)
        if slots then
            return slots, stacks, lastUpdate
        end
    end
    return nil, nil, lastUpdate
end

--- Resolve v2 itemStorage row for a character key (GUID / Name-Realm aliases).
---@param charKey string
---@return table|nil storageRow
local function ResolveItemStorageRow(charKey)
    local globalIS = WarbandNexus.db.global.itemStorage
    if not globalIS or not charKey or charKey == "" then
        return nil
    end
    local storage = globalIS[charKey]
    local function storageHasPayload(ent)
        return ent and (ent.bags or ent.bank)
    end
    if not storageHasPayload(storage) and ns.Utilities and ns.Utilities.GetCanonicalCharacterKey then
        local alt = ns.Utilities:GetCanonicalCharacterKey(charKey)
        if alt and alt ~= charKey then
            local s2 = globalIS[alt]
            if storageHasPayload(s2) then
                storage = s2
            end
        end
    end
    if not storageHasPayload(storage) then
        local charData = WarbandNexus.db.global.characters and WarbandNexus.db.global.characters[charKey]
        local U = ns.Utilities
        if charData and U and U.GetCharacterKey then
            local legacyKey = U:GetCharacterKey(charData.name, charData.realm)
            if legacyKey and legacyKey ~= charKey then
                local sLegacy = globalIS[legacyKey]
                if storageHasPayload(sLegacy) then
                    storage = sLegacy
                end
            end
        end
    end
    return storage
end

--- Fast occupied-slot tally for one character bucket (bags/bank) — no decompress.
---@param charKey string
---@param dataType string "bags"|"bank"
---@param allowLazyBackfill boolean|nil
---@return number slotCount
---@return number stackTotal
---@return number lastUpdate
function WarbandNexus:GetItemStorageOccupiedSlotTally(charKey, dataType, allowLazyBackfill)
    local storage = ResolveItemStorageRow(charKey)
    local bucket = storage and storage[dataType]
    local slots, stacks, lastUpdate = OccupiedSlotsFromStorageBucket(bucket, allowLazyBackfill)
    if slots then
        return slots, stacks or slots, lastUpdate or 0
    end
    if bucket and (lastUpdate or bucket.lastUpdate or 0) > 0 then
        return 0, 0, lastUpdate or bucket.lastUpdate or 0
    end

    local cached = decompressedItemCache[charKey]
    if cached then
        local arr = dataType == "bags" and cached.bags or cached.bank
        if arr then
            local n = #arr
            local st = 0
            for i = 1, n do
                st = st + (arr[i].stackCount or 1)
            end
            local lu = dataType == "bags" and (cached.bagsLastUpdate or 0) or (cached.bankLastUpdate or 0)
            return n, st, lu
        end
    end

    if dataType == "bags" then
        local charData = self.db.global.characters and self.db.global.characters[charKey]
        if charData and charData.items then
            local n = #charData.items
            return n, n, charData.itemsLastUpdate or 0
        end
    elseif dataType == "bank" then
        local charData = self.db.global.characters and self.db.global.characters[charKey]
        if charData and charData.bank then
            local n = #charData.bank
            return n, n, charData.bankLastUpdate or 0
        end
        local pb = self.db.global.personalBanks and self.db.global.personalBanks[charKey]
        if pb then
            local n = pb.items and #pb.items or 0
            return n, n, pb.lastUpdate or 0
        end
    end

    return 0, 0, 0
end

--- Fast warband bank occupied slots — no decompress when slotCount metadata exists.
---@param allowLazyBackfill boolean|nil
---@return number slotCount
---@return number stackTotal
---@return number lastUpdate
function WarbandNexus:GetWarbandBankOccupiedSlotTally(allowLazyBackfill)
    if decompressedWarbandCache and decompressedWarbandCache.items then
        local items = decompressedWarbandCache.items
        local n = #items
        local st = 0
        for i = 1, n do
            st = st + (items[i].stackCount or 1)
        end
        return n, st, decompressedWarbandCache.lastUpdate or 0
    end

    local storage = self.db.global.itemStorage and self.db.global.itemStorage.warbandBank
    local slots, stacks, lastUpdate = OccupiedSlotsFromStorageBucket(storage, allowLazyBackfill)
    if slots then
        return slots, stacks or slots, lastUpdate or 0
    end
    if storage and (lastUpdate or storage.lastUpdate or 0) > 0 then
        return 0, 0, lastUpdate or storage.lastUpdate or 0
    end

    if self.db.global.warbandBank and type(self.db.global.warbandBank.items) == "table" then
        local legacy = self.db.global.warbandBank.items
        local n = #legacy
        if n > 0 then
            return n, n, self.db.global.warbandBank.lastUpdate or 0
        end
    end

    return 0, 0, 0
end

--- Sum tracked roster personal storage using fast slot tallies (Items > Warband stats + tree scan).
---@param allowLazyBackfill boolean|nil When false, skip legacy compressed decompress (collapsed embed stats).
---@return number stackTotal
---@return number usedSlots
---@return number lastScan
function WarbandNexus:SumTrackedPersonalStorageSlotTally(allowLazyBackfill)
    local stackTotal, usedSlots, lastScan = 0, 0, 0
    local allCharacters = self.GetAllCharacters and self:GetAllCharacters() or {}
    for i = 1, #allCharacters do
        local char = allCharacters[i]
        if char and char.isTracked ~= false and char._key then
            local bagSlots, bagStacks, bagLast = self:GetItemStorageOccupiedSlotTally(char._key, "bags", allowLazyBackfill)
            local bankSlots, bankStacks, bankLast = self:GetItemStorageOccupiedSlotTally(char._key, "bank", allowLazyBackfill)
            usedSlots = usedSlots + bagSlots + bankSlots
            stackTotal = stackTotal + bagStacks + bankStacks
            lastScan = math.max(lastScan, bagLast, bankLast)
        end
    end
    return stackTotal, usedSlots, lastScan
end

---Get items data for a specific character (decompressed + hydrated with metadata).
---Uses session RAM cache to avoid repeated decompression. Invalidated when items are scanned.
---@param charKey string Character key
---@return table data {bags, bank, lastUpdate}
function WarbandNexus:GetItemsData(charKey)
    -- Check session cache first (avoids repeated decompress+deserialize)
    local cached = decompressedItemCache[charKey]
    if cached then return cached end
    
    local result = { bags = {}, bank = {}, bagsLastUpdate = 0, bankLastUpdate = 0 }
    -- v2 itemStorage is keyed by ResolveCurrentItemStorageKey (GUID when available); UI may pass Name-Realm.
    local globalIS = self.db.global.itemStorage
    local storage = nil
    if globalIS and charKey and charKey ~= "" then
        storage = globalIS[charKey]
        local function storageHasPayload(ent)
            return ent and (ent.bags or ent.bank)
        end
        if not storageHasPayload(storage) and ns.Utilities and ns.Utilities.GetCanonicalCharacterKey then
            local alt = ns.Utilities:GetCanonicalCharacterKey(charKey)
            if alt and alt ~= charKey then
                local s2 = globalIS[alt]
                if storageHasPayload(s2) then
                    storage = s2
                end
            end
        end
        -- GUID-indexed characters row but itemStorage still under legacy Name-Realm (pre-migration slot).
        if not storageHasPayload(storage) then
            local charData = self.db.global.characters and self.db.global.characters[charKey]
            local U = ns.Utilities
            if charData and U and U.GetCharacterKey then
                local legacyKey = U:GetCharacterKey(charData.name, charData.realm)
                if legacyKey and legacyKey ~= charKey then
                    local sLegacy = globalIS[legacyKey]
                    if storageHasPayload(sLegacy) then
                        storage = sLegacy
                    end
                end
            end
        end
    end
    local hasV2Data = storage and (storage.bags or storage.bank)

    if not hasV2Data then
        if self.db.global.characters and self.db.global.characters[charKey] then
            local charData = self.db.global.characters[charKey]
            result = {
                bags = HydrateItems(charData.items or {}),
                bank = HydrateItems(charData.bank or {}),
                bagsLastUpdate = charData.itemsLastUpdate or 0,
                bankLastUpdate = charData.bankLastUpdate or 0,
            }
        elseif self.db.global.personalBanks and self.db.global.personalBanks[charKey] then
            local pb = self.db.global.personalBanks[charKey]
            local arr = PersonalBankToItemArray(pb)
            result = {
                bags = {},
                bank = HydrateItems(arr),
                bagsLastUpdate = 0,
                bankLastUpdate = pb.lastUpdate or 0,
            }
        end
    else
        if storage.bags then
            if storage.bags.compressed then
                result.bags = DecompressItemData(storage.bags.data) or {}
            else
                result.bags = storage.bags.data or {}
            end
            HydrateItems(result.bags)
            result.bagsLastUpdate = storage.bags.lastUpdate or 0
        end
        if storage.bank then
            if storage.bank.compressed then
                result.bank = DecompressItemData(storage.bank.data) or {}
            else
                result.bank = storage.bank.data or {}
            end
            HydrateItems(result.bank)
            result.bankLastUpdate = storage.bank.lastUpdate or 0
        end
    end

    MergeExtraItemSourcesIntoItemsData(self, result, charKey)
    decompressedItemCache[charKey] = result
    return result
end

---Get warband bank data (decompressed + hydrated with metadata).
---Uses session RAM cache. Invalidated when warband bank is scanned.
---@return table data {items, lastUpdate}
function WarbandNexus:GetWarbandBankData()
    -- Check session cache first
    if decompressedWarbandCache then return decompressedWarbandCache end
    
    -- Check if new storage system exists
    if not self.db.global.itemStorage or not self.db.global.itemStorage.warbandBank then
        -- Fallback: legacy storage
        if self.db.global.warbandBank then
            local result = {
                items = HydrateItems(self.db.global.warbandBank.items or {}),
                lastUpdate = self.db.global.warbandBank.lastUpdate or 0,
            }
            decompressedWarbandCache = result
            return result
        end
        
        return {items = {}, lastUpdate = 0}
    end
    
    -- Decompress from DB storage (on-demand, cached for session)
    local storage = self.db.global.itemStorage.warbandBank
    local result = {
        items = {},
        lastUpdate = 0,
    }
    
    if storage.compressed then
        result.items = DecompressItemData(storage.data) or {}
    else
        result.items = storage.data or {}
    end
    HydrateItems(result.items)
    result.lastUpdate = storage.lastUpdate or 0

    -- If v2 bucket exists but is empty, keep showing legacy SavedVariables (no data loss during migrations).
    if (#result.items == 0) and self.db.global.warbandBank and type(self.db.global.warbandBank.items) == "table" then
        local legacy = self.db.global.warbandBank.items
        if #legacy > 0 then
            result.items = HydrateItems(legacy)
        end
        if (not result.lastUpdate or result.lastUpdate == 0) and self.db.global.warbandBank.lastUpdate then
            result.lastUpdate = self.db.global.warbandBank.lastUpdate
        end
    end
    
    -- Cache for session (invalidated when warband bank is re-scanned)
    decompressedWarbandCache = result
    return result
end

-- ============================================================================
-- PRE-INDEXED ITEM COUNT SUMMARY
-- O(1) tooltip lookup instead of iterating all items on every hover.
-- Summary rebuilt lazily: marked "pending" on item change, processed on next access.
-- ============================================================================

---Build item count summary for a single character (bags + bank).
---@param charKey string Character key
local function BuildCharacterSummary(charKey)
    local summary = {}  -- [itemID] = { bags=N, bank=N }
    
    -- Use the decompressed cache (GetItemsData populates it)
    local itemsData = WarbandNexus:GetItemsData(charKey)
    if not itemsData then
        itemSummaryIndex.characters[charKey] = summary
        return
    end
    
    -- Count bags
    if itemsData.bags then
        for i = 1, #itemsData.bags do
            local item = itemsData.bags[i]
            if item.itemID then
                local entry = summary[item.itemID]
                if not entry then
                    entry = { bags = 0, bank = 0 }
                    summary[item.itemID] = entry
                end
                entry.bags = entry.bags + (item.stackCount or 1)
            end
        end
    end
    
    -- Count bank
    if itemsData.bank then
        for i = 1, #itemsData.bank do
            local item = itemsData.bank[i]
            if item.itemID then
                local entry = summary[item.itemID]
                if not entry then
                    entry = { bags = 0, bank = 0 }
                    summary[item.itemID] = entry
                end
                entry.bank = entry.bank + (item.stackCount or 1)
            end
        end
    end
    
    itemSummaryIndex.characters[charKey] = summary
end

---Build item count summary for warband bank.
local function BuildWarbandSummary()
    local summary = {}  -- [itemID] = N
    
    local warbandData = WarbandNexus:GetWarbandBankData()
    if warbandData and warbandData.items then
        for i = 1, #warbandData.items do
            local item = warbandData.items[i]
            if item.itemID then
                summary[item.itemID] = (summary[item.itemID] or 0) + (item.stackCount or 1)
            end
        end
    end
    
    itemSummaryIndex.warband = summary
    itemSummaryIndex.warbandPending = false
end

---Process any pending summary rebuilds (call before tooltip lookup).
---Lazy rebuild on demand, not on every bag event.
local function ProcessPendingSummaries()
    -- Process pending characters
    for charKey in pairs(itemSummaryIndex.pending) do
        BuildCharacterSummary(charKey)
        itemSummaryIndex.pending[charKey] = nil
    end
    
    -- Process pending warband
    if itemSummaryIndex.warbandPending then
        BuildWarbandSummary()
    end
end

---Get detailed item counts for tooltip display (O(1) lookup after lazy rebuild).
---Replaces the old DataService:GetDetailedItemCounts which iterated all items per hover.
---@param itemID number
---@return table|nil { warbandBank=N, personalBankTotal=N, characters={{charName,classFile,bagCount,bankCount,total},...} }
function WarbandNexus:GetDetailedItemCountsFast(itemID)
    if not itemID then return nil end
    
    -- Ensure all characters have summaries (first-time lazy build)
    -- Mark any character that doesn't have a summary yet as pending
    if self.db and self.db.global and self.db.global.characters then
        for charKey in pairs(self.db.global.characters) do
            if not itemSummaryIndex.characters[charKey] and not itemSummaryIndex.pending[charKey] then
                itemSummaryIndex.pending[charKey] = true
            end
        end
    end
    -- Also ensure warband summary exists
    if not next(itemSummaryIndex.warband) and not itemSummaryIndex.warbandPending then
        itemSummaryIndex.warbandPending = true
    end
    
    -- Process any pending rebuilds (lazy, batched)
    ProcessPendingSummaries()
    
    -- O(1) lookup: warband bank
    local warbandCount = itemSummaryIndex.warband[itemID] or 0
    
    -- O(1) lookup per character
    local result = {
        warbandBank = warbandCount,
        personalBankTotal = 0,
        characters = {},
    }
    
    local liveRaw = ResolveCurrentItemStorageKey()
    local U = ns.Utilities
    local liveCanon = (U and U.GetCanonicalCharacterKey and U:GetCanonicalCharacterKey(liveRaw)) or liveRaw
    for charKey, charData in pairs(self.db.global.characters or {}) do
        local bagCount, bankCount = 0, 0
        local rowCanon = (U and U.GetCanonicalCharacterKey and U:GetCanonicalCharacterKey(charKey)) or charKey
        if rowCanon == liveCanon then
            -- Current character: use live Blizzard API (O(1), always accurate)
            bagCount = GetItemCount(itemID) or 0
            local totalIncBank = GetItemCount(itemID, true) or 0
            bankCount = totalIncBank - bagCount
        else
            -- Other characters: O(1) summary lookup
            local summary = itemSummaryIndex.characters[charKey]
            if summary and summary[itemID] then
                bagCount = summary[itemID].bags or 0
                bankCount = summary[itemID].bank or 0
            end
        end
        
        if bagCount > 0 or bankCount > 0 then
            result.personalBankTotal = result.personalBankTotal + bankCount
            result.characters[#result.characters + 1] = {
                charName = (function()
                    if charData.name then return charData.name end
                    if type(charKey) ~= "string" or charKey == "" then return nil end
                    if issecretvalue and issecretvalue(charKey) then return nil end
                    return charKey:match("^([^-]+)")
                end)(),
                classFile = charData.classFile or charData.class,
                bagCount = bagCount,
                bankCount = bankCount,
                total = bagCount + bankCount,
                isLiveSession = (rowCanon == liveCanon),
            }
        end
    end
    
    -- Sort: current character first, then by total descending
    table.sort(result.characters, function(a, b)
        local aIsCurrent = a.isLiveSession
        local bIsCurrent = b.isLiveSession
        if aIsCurrent ~= bIsCurrent then return aIsCurrent end
        return a.total > b.total
    end)
    
    return result
end

---Invalidate item summary for a character (call when bags/bank change).
---The summary will be rebuilt lazily on next tooltip access.
---@param charKey string|nil Character key (nil = invalidate all + warband)
function WarbandNexus:InvalidateItemSummary(charKey)
    if charKey then
        itemSummaryIndex.pending[charKey] = true
    else
        -- Invalidate everything
        for key in pairs(itemSummaryIndex.characters) do
            itemSummaryIndex.pending[key] = true
        end
        itemSummaryIndex.warbandPending = true
    end
end

---Force refresh all bags (ignore cache)
function WarbandNexus:RefreshAllBags()
    local charKey = ResolveCurrentItemStorageKey()
    
    -- Clear hash cache to force scan
    bagHashCache = {}
    
    -- Scan all
    self:ScanInventoryBags(charKey)
    
    if isBankOpen then
        self:ScanBankBags(charKey)
    end
    
    if isWarbandBankOpen then
        self:ScanWarbandBank()
    end
    
    self:SendMessage(Constants.EVENTS.ITEMS_UPDATED, {type = "all", charKey = CanonicalItemsMessageKey(charKey)})
end

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

---Initialize items cache on login
function WarbandNexus:InitializeItemsCache()
    -- ── Event Ownership (single owner for all bag/bank events) ──
    WarbandNexus:RegisterBucketEvent("BAG_UPDATE", 0.5, "OnBagUpdate")
    WarbandNexus:RegisterBucketEvent("PLAYERBANKSLOTS_CHANGED", 0.5, "OnBagUpdate")
    WarbandNexus:RegisterEvent("BANKFRAME_OPENED", "OnBankOpened")
    WarbandNexus:RegisterEvent("BANKFRAME_CLOSED", "OnBankClosed")
    WarbandNexus:RegisterEvent("BAG_UPDATE_DELAYED", "OnInventoryBagsChanged")
    
    -- Set loading state (initial scan in progress)
    ns.ItemsLoadingState.isLoading = true
    ns.ItemsLoadingState.currentStage = (ns.L and ns.L["ITEMS_LOADING_STAGE_WAIT"]) or "Waiting to start"
    ns.ItemsLoadingState.scanProgress = 0
    ns.ItemsLoadingState.loadingProgress = 0

    local function setItemsBootProgress(pct, stage)
        ns.ItemsLoadingState.scanProgress = pct
        ns.ItemsLoadingState.loadingProgress = pct
        if stage then
            ns.ItemsLoadingState.currentStage = stage
        end
    end

    -- Defer one tick so login init returns quickly; avoid multi-second fake progress (first Items tab paint uses saved data).
    C_Timer.After(0, function()
        if not ns.ItemsLoadingState.isLoading then return end
        setItemsBootProgress(60, (ns.L and ns.L["ITEMS_LOADING_STAGE_SCAN"]) or "Scanning inventory bags")

        local charKey = ResolveCurrentItemStorageKey()
        self:ScanInventoryBags(charKey)

        ns.ItemsLoadingState.scanProgress = 100
        ns.ItemsLoadingState.loadingProgress = 100
        ns.ItemsLoadingState.isLoading = false
        ns.ItemsLoadingState.currentStage = nil

        -- Notify UI that initial scan is done (fixes stuck loading state)
        self:SendMessage(Constants.EVENTS.ITEMS_UPDATED, {type = "all", charKey = CanonicalItemsMessageKey(charKey)})
    end)
end

-- ============================================================================
-- LOAD MESSAGE
-- ============================================================================

-- Module loaded - verbose logging hidden (debug mode only)
