--[[
    Warband Nexus - Collection Service
    DB-backed mount/pet/toy browse cache + real-time obtain detection.
    collectionStore (SV) + collectionCache.owned (RAM); incremental EnsureCollectionData scans.
    Emits WN_COLLECTIBLE_OBTAINED, WN_COLLECTION_SCAN_COMPLETE, WN_COLLECTION_SCAN_PROGRESS.

    WN_NONUI_UI: `bagScanFrame` (and similar) are background event hosts (`CreateFrame`); Browse UI stays in Modules/UI.
]]

local ADDON_NAME, ns = ...
local WarbandNexus = ns.WarbandNexus
local issecretvalue = issecretvalue
local tinsert = table.insert
local Utilities = ns.Utilities
local function CmpItemName(a, b)
    return (Utilities and Utilities.SafeLower and Utilities:SafeLower(a.name) or "") < (Utilities and Utilities.SafeLower and Utilities:SafeLower(b.name) or "")
end

--- Plans/collection browse search: never call :lower() on secret API strings (Midnight 12.0+).
local function NormalizeCollectionSearchText(searchText)
    if searchText == nil then return "" end
    if type(searchText) ~= "string" then return "" end
    if issecretvalue and issecretvalue(searchText) then return "" end
    return searchText:lower()
end

-- Debug print helper (only prints if debug mode + debugVerbose enabled; reduces BAG SCAN spam)
local IsDebugModeEnabled = ns.IsDebugModeEnabled
local DebugChatPrint = ns.DebugChatPrint

local function CollectionUserPrint(self, msg)
    if DebugChatPrint then
        DebugChatPrint(msg)
    elseif IsDebugModeEnabled and IsDebugModeEnabled() and self.Print then
        self:Print(msg)
    end
end

local DebugPrint = (ns.CreateDebugPrinter and ns.CreateDebugPrinter(
    nil,
    { verboseOnly = true, suppressWhenTryCounterLoot = true }
)) or function() end
local IsDebugVerboseEnabled = ns.IsDebugVerboseEnabled
local function DebugPrintf(fmt, ...)
    if IsDebugVerboseEnabled and IsDebugVerboseEnabled() then
        DebugPrint(string.format(fmt, ...))
    end
end

local Notify = ns.CollectionNotify
local Mat = ns.CollectionMaterialize

---How many achievements to iterate in a category via GetAchievementInfo(categoryID, index).
---Must use includeAll=true: GetAchievementInfo(catID, index) can return subcategory achievements
---when iterating a parent category. Without includeAll=true the count is too low and many
---achievements (including Glory/meta achievements in deep subcategories) are silently skipped.
local function GetCategoryAchievementListCount(categoryID)
    if not categoryID or not GetCategoryNumAchievements then return 0 end
    local ok, total = pcall(function()
        return select(1, GetCategoryNumAchievements(categoryID, true))
    end)
    if ok and type(total) == "number" then return total end
    return select(1, GetCategoryNumAchievements(categoryID)) or 0
end

-- CONSTANTS

local Constants = ns.Constants
local E = Constants.EVENTS
local FRAME_BUDGET_MS = Constants.FRAME_BUDGET_MS
local BATCH_SIZE = Constants.BATCH_SIZE
local CACHE_VERSION = Constants.COLLECTION_CACHE_VERSION

---Reused for COLLECTION_SCAN_PROGRESS only (listeners read fields synchronously).
local collectionScanProgressPayload = {}

local function SendCollectionScanProgress(sender, collectionType, progress, scanned, total)
    if not Constants or not Constants.EVENTS or not sender or not sender.SendMessage then return end
    local ev = Constants.EVENTS.COLLECTION_SCAN_PROGRESS
    if not ev then return end
    collectionScanProgressPayload.category = collectionType
    collectionScanProgressPayload.progress = progress
    collectionScanProgressPayload.scanned = scanned
    collectionScanProgressPayload.total = total
    sender:SendMessage(ev, collectionScanProgressPayload)
end

---Coalesce burst triggers (e.g. many GetUncollected* calls) into a single next-frame EnsureCollectionData.
local ensureCollectionDeferredPending = false
local function ScheduleEnsureCollectionDataDeferred()
    if ensureCollectionDeferredPending then return end
    ensureCollectionDeferredPending = true
    -- Two zero-delay timers: coalesce burst callers and run one tick after first scheduling
    -- so login/UI layout is less likely to share a frame with EnsureCollectionData startup.
    C_Timer.After(0, function()
        C_Timer.After(0, function()
            ensureCollectionDeferredPending = false
            if WarbandNexus.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
                WarbandNexus:EnsureCollectionData()
            end
        end)
    end)
end
ns.ScheduleEnsureCollectionDataDeferred = ScheduleEnsureCollectionDataDeferred

local function EmitPlansBrowseCollectionEnsure(category)
    if not category then return end
    if E.PLANS_BROWSE_COLLECTION_ENSURE_REQUESTED and WarbandNexus.SendMessage then
        WarbandNexus:SendMessage(E.PLANS_BROWSE_COLLECTION_ENSURE_REQUESTED, { category = category })
        return
    end
    if ns.RequestPlansBrowseCollectionEnsure then
        ns.RequestPlansBrowseCollectionEnsure(category)
        return
    end
    if WarbandNexus.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
        ScheduleEnsureCollectionDataDeferred()
    end
end

-- COLLECTION CACHE (PERSISTENT IN DB)
--[[
    Unified cache for both real-time and background scanning:
    - owned: {mountID/speciesID/itemID -> true} - O(1) lookups (RAM only)
    - uncollected: {type -> {id -> {name, icon, source...}}} - Browse UI data (PERSISTED TO DB)
    
    IMPORTANT: uncollected cache is saved to DB (SavedVariables) to avoid re-scanning on every reload.
    Only scan when:
    1. DB cache is empty (first time)
    2. User manually requests refresh
    3. Real-time detection adds/removes items (incremental update)
    
    Startup order (InitializationService):
    - P0: InitializeCollectionCache (load collectionStore from SV)
    - P1: RunCollectionOwnedCacheWarmup — owned O(1) cache; quiet when store already matches CACHE_VERSION (no extra LoadingTracker row)
    - P5: EnsureFullCollectionData — EnsureCollectionData if any category missing / version mismatch
    
    Persisted in db.global.collectionStore (per row): mount/pet include id, name, icon, source, collected, sourceType/sourceTypeIndex metadata; toy id+name only (collected from API when needed). Achievements/titles/illusions have their own store tables.
    Profiler labels (when /wn profiler on): EnsureBlizzard_CollectionsLoad, BuildFullCollectionData [async], EnsureCollectionData [async], BuildCollectionCache / BuildCollectionCache_quiet [async].
]]

-- SINGLE SOURCE: all collection data in one structure. Collections (full) and Plans (uncollected) read from the same source.
-- The API only kicks in when data is missing or on a version update.
local collectionStore = {
    version = CACHE_VERSION,
    lastBuilt = 0,
    mount = {},
    pet = {},
    toy = {},
    achievement = {},
    title = {},
    illusion = {},
}

-- collectionData = collectionStore alias (GetAllMountsData etc. use collectionData)
local collectionData = collectionStore

---True when EnsureCollectionData would return immediately (SV version matches, store has all categories).
---Includes toy/pet count sanity vs journal totals (same rules as EnsureCollectionData).
---@return boolean
local function IsCollectionEnsureDataComplete(self)
    if not self.db or not self.db.global then return false end
    local dbStore = self.db.global.collectionStore
    local versionOk = dbStore and dbStore.version == CACHE_VERSION
    local hasMounts = collectionStore.mount and next(collectionStore.mount) ~= nil
    local hasPets = collectionStore.pet and next(collectionStore.pet) ~= nil
    local hasToys = collectionStore.toy and next(collectionStore.toy) ~= nil
    local hasAchievements = collectionStore.achievement and next(collectionStore.achievement) ~= nil
    local hasTitles = collectionStore.title and next(collectionStore.title) ~= nil
    local hasIllusions = collectionStore.illusion and next(collectionStore.illusion) ~= nil
    local needAchievementIncludeAllRescan = self.db.global and not self.db.global.wnAchievementDirectScanV2
    if not (versionOk and hasMounts and hasPets and hasToys and hasAchievements and hasTitles and hasIllusions and not needAchievementIncludeAllRescan) then
        return false
    end
    if hasToys and C_ToyBox and C_ToyBox.GetNumToys then
        local apiTotal = C_ToyBox.GetNumToys() or 0
        if apiTotal and apiTotal > 0 then
            local storeCount = 0
            for _ in pairs(collectionStore.toy) do
                storeCount = storeCount + 1
            end
            if storeCount < (apiTotal * 0.5) then
                return false
            end
        end
    end
    if hasPets and C_PetJournal and C_PetJournal.GetNumPets then
        local apiTotal = C_PetJournal.GetNumPets() or 0
        if apiTotal and apiTotal > 0 then
            local storeCount = 0
            for _ in pairs(collectionStore.pet) do
                storeCount = storeCount + 1
            end
            if storeCount < (apiTotal * 0.5) then
                return false
            end
        end
    end
    return true
end

--- Exposed for UI: true when persisted collectionStore matches code version and all categories are populated.
---@return boolean
function WarbandNexus:IsCollectionEnsureDataComplete()
    return IsCollectionEnsureDataComplete(self)
end

--- Plans browse: true when collectionStore has no rows for mount/pet/toy/illusion/title yet.
---@return boolean
function WarbandNexus:IsPlansBrowseCategoryStoreEmpty(category)
    if not category then return true end
    local tbl = collectionStore[category]
    return not tbl or next(tbl) == nil
end

-- collectionCache: deprecated; GetUncollected* now filters from collectionStore. ScanCollection still writes uncollected (transitional).
local collectionCache = {
    owned = { mounts = {}, pets = {}, toys = {} },
    uncollected = { mount = {}, pet = {}, toy = {}, achievement = {}, title = {}, transmog = {}, illusion = {} },
    completed = { achievement = {} },
    lastScan = 0,
    lastAchievementScan = 0,
}

--- True when persisted collectionStore can hydrate RAM owned cache without a journal scan.
---@param self table
---@return boolean
local function CanHydrateOwnedCacheFromStore(self)
    return IsCollectionEnsureDataComplete(self)
end

--- Fill collectionCache.owned from collectionStore.collected flags (fast; no C_MountJournal scan).
---@param self table
---@return number ownedCount approximate entries set
local function HydrateOwnedCacheFromStore(self)
    local owned = collectionCache.owned
    wipe(owned.mounts)
    wipe(owned.pets)
    wipe(owned.toys)
    local count = 0

    local function absorb(store, bucket)
        if type(store) ~= "table" then return end
        for id, data in pairs(store) do
            if type(data) == "table" and data.collected == true then
                bucket[id] = true
                count = count + 1
            end
        end
    end

    absorb(collectionStore.mount, owned.mounts)
    absorb(collectionStore.pet, owned.pets)
    absorb(collectionStore.toy, owned.toys)
    collectionCache.lastScan = time()
    return count
end

local TOY_OWNED_HYDRATE_BUDGET_MS = 4

--- Time-budgeted PlayerHasToy pass for toys (store rows lack collected flag in SV).
function WarbandNexus:ScheduleLazyOwnedToyHydrate()
    if self._lazyToyHydrateRunning then return end
    local store = collectionStore.toy
    if type(store) ~= "table" or next(store) == nil then return end
    self._lazyToyHydrateRunning = true
    local toyIds = {}
    for id in pairs(store) do
        toyIds[#toyIds + 1] = id
    end
    local idx = 1
    local _sv = issecretvalue
    local function batch()
        if not self._lazyToyHydrateRunning then return end
        local batchStart = debugprofilestop()
        while idx <= #toyIds do
            local id = toyIds[idx]
            idx = idx + 1
            if PlayerHasToy then
                local raw = PlayerHasToy(id)
                local collected = false
                if _sv and raw and _sv(raw) then
                    collected = true
                elseif raw == true then
                    collected = true
                end
                if collected then
                    collectionCache.owned.toys[id] = true
                end
            end
            if debugprofilestop() - batchStart > TOY_OWNED_HYDRATE_BUDGET_MS then
                C_Timer.After(0, batch)
                return
            end
        end
        self._lazyToyHydrateRunning = false
    end
    C_Timer.After(0.5, batch)
end

---P1 warmup: when persisted collectionStore is already complete, hydrate owned RAM cache (skip journal scan).
function WarbandNexus:RunCollectionOwnedCacheWarmup()
    if CanHydrateOwnedCacheFromStore(self) then
        local n = HydrateOwnedCacheFromStore(self)
        DebugPrint(string.format(
            "|cff00ccff[WN CollectionService]|r Owned cache hydrated from store (%d entries, no journal scan)",
            n))
        self:ScheduleLazyOwnedToyHydrate()
        return
    end
    self:BuildCollectionCache()
end

---Coalesced EnsureCollectionData (slash / other callers). Does not wipe SV; use DebugForceCollectionRebuild for that.
function WarbandNexus:RequestCollectionDataRefresh()
    if ns.CollectionLoadingState and ns.CollectionLoadingState.isLoading then
        CollectionUserPrint(self, "|cffff8800[WN]|r Collection pipeline already running; skipped. |cff00ccff/wn collection status|r for stage.|r")
        return
    end
    if IsCollectionEnsureDataComplete(self) then
        CollectionUserPrint(self, "|cff888888[WN]|r Collection store already matches code version and categories; nothing to rescan. (|cff00ccff/wn collection rebuild|r to force, or bump cache version in a release.)|r")
        return
    end
    ScheduleEnsureCollectionDataDeferred()
    CollectionUserPrint(self, "|cff00ccff[WN]|r Collection refresh scheduled (EnsureCollectionData next frames).|r")
end

---Debug: print collectionStore counts, SV version, and CollectionLoadingState snapshot.
function WarbandNexus:PrintCollectionDataStatus()
    local dbStore = self.db and self.db.global and self.db.global.collectionStore
    local svVer = dbStore and dbStore.version or "?"
    local nm, np, nt, na, nti, nIll = 0, 0, 0, 0, 0, 0
    for _ in pairs(collectionStore.mount or {}) do nm = nm + 1 end
    for _ in pairs(collectionStore.pet or {}) do np = np + 1 end
    for _ in pairs(collectionStore.toy or {}) do nt = nt + 1 end
    for _ in pairs(collectionStore.achievement or {}) do na = na + 1 end
    for _ in pairs(collectionStore.title or {}) do nti = nti + 1 end
    for _ in pairs(collectionStore.illusion or {}) do nIll = nIll + 1 end
    local load = ns.CollectionLoadingState and ns.CollectionLoadingState.isLoading
    local stage = (ns.CollectionLoadingState and ns.CollectionLoadingState.currentStage) or "-"
    CollectionUserPrint(self, string.format(
        "|cff00ccff[WN Collection]|r SV.version=%s loading=%s stage=%s | store m/p/t/a/ti/il=%d/%d/%d/%d/%d/%d|r",
        tostring(svVer), tostring(load), tostring(stage), nm, np, nt, na, nti, nIll))
end

-- Forward declaration: defined later (BACKGROUND SCANNING section); used by BuildFullCollectionData.
local COLLECTION_CONFIGS

-- Global loading state (accessible from UI modules like PlansUI)
ns.CollectionLoadingState = {
    isLoading = false,
    loadingProgress = 0,  -- 0-100
    currentStage = nil,   -- "Mounts", "Pets", "Toys", "Achievements", etc.
    currentCategory = nil,
    totalItems = 0,
    scannedItems = 0,
}

-- Session-only RAM cache for collection metadata (icon, source, description). Cleared on tab leave / reload.
local METADATA_CACHE_MAX = 512
local metadataCache = {}
local metadataCacheOrder = {}   -- Circular buffer
local metadataCacheHead = 1     -- Circular buffer head index

-- Short-lived results cache for GetUncollected* functions.
-- Prevents expensive store re-iteration on rapid UI redraws (category switches, toggle clicks).
-- Invalidated by collection events (scan complete, collectible obtained) — those wipes,
-- not this TTL, are what keep results fresh, so the TTL can comfortably outlive redraw cadence.
local RESULTS_CACHE_TTL = 10 -- seconds
local uncollectedResultsCache = {}
--- Merged achievement list for GetAllAchievementsData; cleared with uncollectedResultsCache.
local _allAchievementsMergedCache = nil

local function ClearAchievementMergedListCache()
    _allAchievementsMergedCache = nil
end

local function WipeUncollectedResultsCacheAndMergedAchievements()
    wipe(uncollectedResultsCache)
    ClearAchievementMergedListCache()
end

-- DUPLICATE NOTIFICATION PREVENTION (BAG SCAN + COLLECTION EVENTS)
--[[
    Two-layer dedup architecture:
    
    LAYER 1 — Persistent DB (bagDetectedCollectibles):
      Keyed by type_id, 2-hour expiry. Survives /reload.
      Prevents duplicate when bag scan fires BEFORE Blizzard collection event.
    
    LAYER 2 — Session ring buffer (recentNotifications):
      Keyed by string (type_id OR itemName), configurable cooldown per entry.
      O(1) eviction via circular buffer (no O(n) oldest-search).
      Merges the old recentlyNotified (5s, by id) + recentNotificationsByName (2s, by name)
      + bagDetectedPetNames (60s, by name) into a single bounded structure.
]]

-- Bag scan: CollectionService_Scan.lua (ns.CollectionScan)

-- Notification dedup: CollectionService_NotifyDedup.lua (ns.CollectionNotify)

-- COLLECTION CACHE INITIALIZATION

---Initialize collection cache from DB (load persisted data)
---Called on addon load to restore previous scan results
function WarbandNexus:InitializeCollectionCache()
    local debugMode = self.db and self.db.profile and self.db.profile.debugMode
    
    if debugMode then
    DebugPrint("|cff9370DB[WN CollectionService]|r InitializeCollectionCache called")
    end
    
    -- Ensure DB is initialized
    if not self.db or not self.db.global then
        if debugMode then
    DebugPrint("|cffff0000[WN CollectionService]|r ERROR: DB not initialized yet!")
        end
        -- Retry after 1 second
        C_Timer.After(1, function()
            if self and self.InitializeCollectionCache then
                self:InitializeCollectionCache()
            end
        end)
        return
    end
    
    local defaultUncollected = { mount = {}, pet = {}, toy = {}, achievement = {}, title = {}, transmog = {}, illusion = {} }

    -- Reset the legacy cache when missing or version-mismatched, but DO NOT return
    -- early: the collectionStore load below must still run. The cache and the store
    -- are written by different paths and their SV versions can diverge — bailing out
    -- here used to discard a perfectly valid persisted store for the whole session.
    if not self.db.global.collectionCache then
        self.db.global.collectionCache = {
            uncollected = defaultUncollected,
            version = CACHE_VERSION,
            lastScan = time()
        }
        if debugMode then
    DebugPrint("|cffffcc00[WN CollectionService]|r Initialized NEW collection cache (empty)")
        end
    elseif self.db.global.collectionCache.version ~= CACHE_VERSION then
        if debugMode then
    DebugPrint("|cffffcc00[WN CollectionService]|r Cache version mismatch (DB: " .. tostring(self.db.global.collectionCache.version) .. ", Code: " .. CACHE_VERSION .. "), clearing cache")
        end
        self.db.global.collectionCache = {
            uncollected = defaultUncollected,
            version = CACHE_VERSION,
            lastScan = time()
        }
    end

    local dbCache = self.db.global.collectionCache

    collectionCache.uncollected = dbCache.uncollected or defaultUncollected
    collectionCache.completed = dbCache.completed or { achievement = {} }
    if not collectionCache.completed.achievement then
        collectionCache.completed.achievement = {}
    end
    collectionCache.lastScan = dbCache.lastScan or 0
    collectionCache.lastAchievementScan = dbCache.lastAchievementScan or 0

    -- One-shot: force a fresh achievement scan when switching from includeAll=true to includeAll=false.
    -- includeAll=false is correct because GetCategoryList() returns all subcategories, and each
    -- category should only scan its direct achievements. V2 flag triggers rescan for all users.
    if self.db.global and not self.db.global.wnAchievementDirectScanV2 then
        collectionCache.lastAchievementScan = 0
    end

    -- SINGLE SOURCE: load collectionStore or migrate from the old DB
    local dbStore = self.db.global.collectionStore
    if dbStore and dbStore.version == CACHE_VERSION then
        collectionStore.mount = dbStore.mount or {}
        collectionStore.pet = dbStore.pet or {}
        local rawToy = dbStore.toy or {}
        collectionStore.toy = {}
        for id, v in pairs(rawToy) do
            if v and v.id then
                collectionStore.toy[id] = { id = v.id, name = (type(v.name) == "string" and v.name ~= "") and v.name or tostring(v.id) }
            end
        end
        collectionStore.achievement = dbStore.achievement or {}
        collectionStore.title = dbStore.title or {}
        collectionStore.illusion = dbStore.illusion or {}
        collectionStore.lastBuilt = dbStore.lastBuilt or 0
        if debugMode then
            local m, p, t, a, ti, il = 0, 0, 0, 0, 0, 0
            for _ in pairs(collectionStore.mount) do m = m + 1 end
            for _ in pairs(collectionStore.pet) do p = p + 1 end
            for _ in pairs(collectionStore.toy) do t = t + 1 end
            for _ in pairs(collectionStore.achievement) do a = a + 1 end
            for _ in pairs(collectionStore.title) do ti = ti + 1 end
            for _ in pairs(collectionStore.illusion) do il = il + 1 end
            DebugPrintf("|cff00ff00[WN CollectionService]|r Loaded collectionStore: %d mounts, %d pets, %d toys, %d achievements, %d titles, %d illusions", m, p, t, a, ti, il)
        end
    else
        -- Migration: collectionData + collectionCache → collectionStore
        if self.db.global.collectionData and self.db.global.collectionData.version == CACHE_VERSION then
            local cd = self.db.global.collectionData
            collectionStore.mount = cd.mount or {}
            collectionStore.pet = cd.pet or {}
            local rawToy = cd.toy or {}
            collectionStore.toy = {}
            for id, v in pairs(rawToy) do
                if v and v.id then
                    collectionStore.toy[id] = { id = v.id, name = (type(v.name) == "string" and v.name ~= "") and v.name or tostring(v.id) }
                end
            end
            collectionStore.lastBuilt = cd.lastBuilt or 0
        end
        -- collectionCache.completed.achievement → collectionStore.achievement (merge).
        -- The completed cache stores plain display-name STRINGS (see ScanAchievementsAsync);
        -- older versions stored record tables. Handle both — assigning a field on a string
        -- raises and would abort P0 init.
        if collectionCache.completed and collectionCache.completed.achievement then
            for id, ach in pairs(collectionCache.completed.achievement) do
                if ach and not collectionStore.achievement[id] then
                    if type(ach) == "table" then
                        collectionStore.achievement[id] = ach
                        if collectionStore.achievement[id].collected == nil then
                            collectionStore.achievement[id].collected = true
                        end
                    else
                        collectionStore.achievement[id] = { id = id, name = tostring(ach), collected = true }
                    end
                end
            end
        end
        -- collectionCache.uncollected → collectionStore (id->name → id->{id,name,collected=false}; toy: id+name only)
        for ctype, tbl in pairs(collectionCache.uncollected or {}) do
            if type(tbl) == "table" and (ctype == "mount" or ctype == "pet" or ctype == "toy" or ctype == "achievement" or ctype == "title" or ctype == "illusion") then
                local store = collectionStore[ctype]
                if store then
                    for id, name in pairs(tbl) do
                        if not store[id] then
                            local nameStr = (type(name) == "string") and name or ("ID:" .. tostring(id))
                            if ctype == "toy" then
                                store[id] = { id = id, name = nameStr }
                            else
                                store[id] = { id = id, name = nameStr, collected = false }
                            end
                        end
                    end
                end
            end
        end
        if debugMode then
            DebugPrint("|cffffcc00[WN CollectionService]|r Migrated collectionData/cache → collectionStore")
        end
    end

    -- Count loaded items (debug mode only)
    if debugMode then
        local mountCount, petCount, toyCount, achievementCount, titleCount, illusionCount = 0, 0, 0, 0, 0, 0
        for _ in pairs(collectionCache.uncollected.mount or {}) do mountCount = mountCount + 1 end
        for _ in pairs(collectionCache.uncollected.pet or {}) do petCount = petCount + 1 end
        for _ in pairs(collectionCache.uncollected.toy or {}) do toyCount = toyCount + 1 end
        for _ in pairs(collectionCache.uncollected.achievement or {}) do achievementCount = achievementCount + 1 end
        for _ in pairs(collectionCache.uncollected.title or {}) do titleCount = titleCount + 1 end
        for _ in pairs(collectionCache.uncollected.illusion or {}) do illusionCount = illusionCount + 1 end
        
        if achievementCount == 0 then
    DebugPrint("|cffffcc00[WN CollectionService]|r Achievement cache is EMPTY (scan will be triggered on first view)")
        end
        
    DebugPrintf("|cff00ff00[WN CollectionService]|r Loaded cache from DB: %d mounts, %d pets, %d toys, %d achievements, %d titles, %d illusions",
        mountCount, petCount, toyCount, achievementCount, titleCount, illusionCount)
    end
    
    -- Initialize bag-detected collectibles DB (for duplicate prevention)
    if Notify and Notify.InitializeBagDetectedDB then
        Notify.InitializeBagDetectedDB()
    end
end

-- Coalesce store persists triggered from READ paths (GetUncollected* drift sync):
-- serializing the whole store inside a getter on every cache miss is wasteful, and
-- the synced collected-flags are re-derived from the API anyway if a save is missed.
local deferredStoreSaveTimer
local function ScheduleDeferredCollectionStoreSave()
    if deferredStoreSaveTimer then return end
    deferredStoreSaveTimer = C_Timer.NewTimer(2, function()
        deferredStoreSaveTimer = nil
        if WarbandNexus and WarbandNexus.SaveCollectionStore then
            WarbandNexus:SaveCollectionStore()
        end
    end)
end

---Save collection store to DB (single source — Collections + Plans read from the same data)
---Called after scan completion and real-time updates
function WarbandNexus:SaveCollectionStore()
    if not self.db or not self.db.global then return end
    collectionStore.version = CACHE_VERSION
    collectionStore.lastBuilt = collectionStore.lastBuilt or time()
    local toySave = {}
    for id, v in pairs(collectionStore.toy or {}) do
        if v and v.id then
            toySave[id] = { id = v.id, name = (type(v.name) == "string" and v.name ~= "") and v.name or tostring(v.id) }
        end
    end
    self.db.global.collectionStore = {
        version = collectionStore.version,
        lastBuilt = collectionStore.lastBuilt,
        mount = collectionStore.mount,
        pet = collectionStore.pet,
        toy = toySave,
        achievement = collectionStore.achievement,
        title = collectionStore.title,
        illusion = collectionStore.illusion,
    }
    DebugPrint("|cff00ff00[WN CollectionService]|r Saved collectionStore to DB")
end

---Cancel deferred collectionStore save and persist immediately on PLAYER_LOGOUT.
function WarbandNexus:FlushCollectionStoreOnLogout()
    if deferredStoreSaveTimer then
        deferredStoreSaveTimer:Cancel()
        deferredStoreSaveTimer = nil
    end
    self:SaveCollectionStore()
end

---Save collection cache to DB (legacy — syncs collectionCache for backward compat; also saves collectionStore)
---Called after scan completion to avoid re-scanning on reload
function WarbandNexus:SaveCollectionCache()
    if not self.db or not self.db.global then
    DebugPrint("|cffff0000[WN CollectionService ERROR]|r Cannot save cache: DB not initialized")
        return
    end

    self.db.global.collectionCache = {
        uncollected = collectionCache.uncollected,
        completed = collectionCache.completed or { achievement = {} },
        version = CACHE_VERSION,
        lastScan = collectionCache.lastScan,
        lastAchievementScan = collectionCache.lastAchievementScan or collectionCache.lastScan,
    }

    self:SaveCollectionStore()
end

---Invalidate collection cache (mark for refresh)
---Called when collection data changes (e.g., new mount obtained)
function WarbandNexus:InvalidateCollectionCache(category)
    if category then
        if category == "mount" or category == "pet" or category == "toy" then
            local key = category .. "s"
            if collectionCache.owned[key] then collectionCache.owned[key] = {} end
            if collectionCache.uncollected[category] then collectionCache.uncollected[category] = {} end
        elseif collectionCache.uncollected[category] then
            collectionCache.uncollected[category] = {}
        end
        if category == "achievement" then
            collectionCache.lastAchievementScan = 0
        else
            collectionCache.lastScan = 0
        end
    else
        collectionCache.owned.mounts = {}
        collectionCache.owned.pets = {}
        collectionCache.owned.toys = {}
        if collectionCache.uncollected.mount then collectionCache.uncollected.mount = {} end
        if collectionCache.uncollected.pet then collectionCache.uncollected.pet = {} end
        if collectionCache.uncollected.toy then collectionCache.uncollected.toy = {} end
        collectionCache.lastScan = 0
    end
end

-- Blizzard materialize: CollectionService_Materialize.lua (ns.CollectionMaterialize)

---Build full collection data (all mounts, pets, toys with id, name, icon, source, description).
---Runs on login when DB has no data or version changed. Stores result in collectionStore and DB.
function WarbandNexus:BuildFullCollectionData(onComplete)
    -- Prevent re-entrant calls while already building
    if self._buildingCollectionData then
        if onComplete then onComplete() end
        return
    end
    self._buildingCollectionData = true

    collectionCache.owned = { mounts = {}, pets = {}, toys = {} }

    local Pbf = ns.Profiler
    local profBuildState = { started = false }
    if Pbf and Pbf.enabled then
        Pbf:StartAsync("BuildFullCollectionData")
        profBuildState.started = true
    end
    local function stopBuildProf()
        if profBuildState.started and Pbf then
            pcall(function() Pbf:StopAsync("BuildFullCollectionData") end)
            profBuildState.started = false
        end
    end

    Mat.EnsureBlizzardCollectionsLoaded()
    local LT = ns.LoadingTracker
    if LT and not onComplete then
        LT:Register("collection_data", (ns.L and ns.L["LT_COLLECTION_DATA"]) or "Collection Data")
    end

    ns.CollectionLoadingState.isLoading = true
    ns.CollectionLoadingState.loadingProgress = 0
    ns.CollectionLoadingState.currentStage = (ns.L and ns.L["LOADING_COLLECTIONS"]) or "Loading collections..."
    ns.CollectionLoadingState.currentCategory = "mount"

    local _issecretvalue = issecretvalue
    local BUDGET_MS = FRAME_BUDGET_MS
    local configs = { "mount", "pet", "toy" }
    local configIdx = 1
    local itemIdx = 1
    local items = {}
    local currentType = nil
    local config = nil

    local petMatState, toyMatState = nil, nil

    local function nextBatch()
        if configIdx > #configs then
            self._buildingCollectionData = nil
            stopBuildProf()
            ns.CollectionLoadingState.loadingProgress = 99
            collectionStore.lastBuilt = time()
            -- When called from EnsureCollectionData, the pipeline saves once at the end
            -- to avoid two full db.global.collectionStore writes (large SV hitch).
            if not onComplete then
                self:SaveCollectionStore()
            end
            if onComplete then
                onComplete()
            else
                ns.CollectionLoadingState.isLoading = false
                ns.CollectionLoadingState.loadingProgress = 100
                ns.CollectionLoadingState.currentStage = (ns.L and ns.L["SYNC_COMPLETE"]) or "Complete"
                if LT then LT:Complete("collection_data") end
                if Constants and Constants.EVENTS then
                    self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, { category = "all" })
                end
            end
            DebugPrint("|cff00ff00[WN CollectionService]|r Full collection data built and saved")
            return
        end
        currentType = configs[configIdx]
        config = COLLECTION_CONFIGS[currentType]
        if not config then
            configIdx = configIdx + 1
            C_Timer.After(0, nextBatch)
            return
        end
        if itemIdx == 1 then
            if currentType == "mount" then
                petMatState, toyMatState = nil, nil
                if self.EnsureMountSourceIndexMap then self:EnsureMountSourceIndexMap() end
                items = COLLECTION_CONFIGS.mount.iterator()
            elseif currentType == "pet" then
                toyMatState = nil
                petMatState = Mat.PetJournalBuildMaterializeStep(BUDGET_MS, petMatState)
                if not petMatState.done then
                    C_Timer.After(0, nextBatch)
                    return
                end
                items = petMatState.items
                ns._petSpeciesToSourceIndex = petMatState.speciesToSource
                petMatState = nil
            elseif currentType == "toy" then
                toyMatState = Mat.ToyBoxBuildMaterializeStep(BUDGET_MS, toyMatState, self)
                if not toyMatState.done then
                    C_Timer.After(0, nextBatch)
                    return
                end
                items = toyMatState.items
                ns._toyItemIDToSourceIndex = toyMatState.toyToSource
                if ns._toyItemIDToSourceIndexCache then
                    ns._toyItemIDToSourceIndexCache.map = toyMatState.toyToSource
                end
                toyMatState = nil
            else
                items = config.iterator()
            end
        end

        local stageName = (currentType == "mount" and ((ns.L and ns.L["CATEGORY_MOUNTS"]) or "Mounts"))
            or (currentType == "pet" and ((ns.L and ns.L["CATEGORY_PETS"]) or "Pets"))
            or (currentType == "toy" and ((ns.L and ns.L["CATEGORY_TOYS"]) or "Toys"))
            or currentType
        ns.CollectionLoadingState.currentStage = stageName
        ns.CollectionLoadingState.currentCategory = currentType

        local batchStart = debugprofilestop()
        while itemIdx <= #items do
            local id = items[itemIdx]
            local ok, data = pcall(config.extract, id)
            if ok and data and data.id then
                local include = true
                if config.shouldIncludeInAll then
                    include = config.shouldIncludeInAll(data)
                end
                if include then
                    if currentType == "mount" then
                        collectionData.mount[data.id] = data
                    elseif currentType == "pet" then
                        collectionData.pet[data.id] = data
                    elseif currentType == "toy" then
                        collectionData.toy[data.id] = {
                            id = data.id,
                            name = data.name,
                            sourceTypeIndex = data.sourceTypeIndex,
                        }
                    end
                    if data.collected == true then
                        if currentType == "mount" then
                            collectionCache.owned.mounts[data.id] = true
                        elseif currentType == "pet" then
                            collectionCache.owned.pets[data.id] = true
                        elseif currentType == "toy" then
                            collectionCache.owned.toys[data.id] = true
                        end
                    end
                end
            end
            itemIdx = itemIdx + 1
            if itemIdx % 50 == 0 or itemIdx == #items then
                local progress = #items > 0 and math.floor(((configIdx - 1) * 100 / #configs) + (itemIdx / #items) * (100 / #configs)) or 0
                ns.CollectionLoadingState.loadingProgress = math.min(99, progress)
            end
            if debugprofilestop() - batchStart > BUDGET_MS then
                C_Timer.After(0, nextBatch)
                return
            end
        end
        configIdx = configIdx + 1
        itemIdx = 1
        C_Timer.After(0, nextBatch)
    end

    nextBatch()
end

---Ensure collection data is populated. Core-level init — full scan when the version changed or data is missing.
---Plans/Collections sekmelerinden tetiklenmez; sadece init.
function WarbandNexus:EnsureCollectionData(onComplete)
    if not self.db or not self.db.global then
        if onComplete then onComplete() end
        return
    end

    if IsCollectionEnsureDataComplete(self) then
        if onComplete then onComplete() end
        return
    end

    local hasMounts = collectionStore.mount and next(collectionStore.mount) ~= nil
    local hasPets = collectionStore.pet and next(collectionStore.pet) ~= nil
    local hasToys = collectionStore.toy and next(collectionStore.toy) ~= nil
    local hasAchievements = collectionStore.achievement and next(collectionStore.achievement) ~= nil
    local hasTitles = collectionStore.title and next(collectionStore.title) ~= nil
    local hasIllusions = collectionStore.illusion and next(collectionStore.illusion) ~= nil
    local needAchievementIncludeAllRescan = self.db.global and not self.db.global.wnAchievementDirectScanV2

    -- Sanity check: if toy store count is far below ToyBox total, force a rebuild
    if hasToys and C_ToyBox and C_ToyBox.GetNumToys then
        local apiTotal = C_ToyBox.GetNumToys() or 0
        if apiTotal and apiTotal > 0 then
            local storeCount = 0
            for _ in pairs(collectionStore.toy) do
                storeCount = storeCount + 1
            end
            -- If we have less than half of the toys in store, consider it stale
            if storeCount < (apiTotal * 0.5) then
                DebugPrintf("|cffffcc00[WN CollectionService]|r Toy store size (%d) << ToyBox total (%d) — forcing full rebuild", storeCount, apiTotal)
                hasMounts = false   -- trigger BuildFullCollectionData (mount/pet/toy) below
                hasPets = false
                hasToys = false
            end
        end
    end

    -- Same net for pets: an in-combat materialize cannot reset journal filters and
    -- can persist a truncated pet store; toys had this check, pets did not.
    if hasPets and C_PetJournal and C_PetJournal.GetNumPets then
        local apiTotal = C_PetJournal.GetNumPets() or 0
        if apiTotal and apiTotal > 0 then
            local storeCount = 0
            for _ in pairs(collectionStore.pet) do
                storeCount = storeCount + 1
            end
            if storeCount < (apiTotal * 0.5) then
                DebugPrintf("|cffffcc00[WN CollectionService]|r Pet store size (%d) << journal total (%d) — forcing full rebuild", storeCount, apiTotal)
                hasMounts = false
                hasPets = false
                hasToys = false
            end
        end
    end

    local LT = ns.LoadingTracker
    if LT then
        LT:Register("collection_data", (ns.L and ns.L["LT_COLLECTION_DATA"]) or "Collection Data")
    end

    ns.CollectionLoadingState.isLoading = true
    ns.CollectionLoadingState.loadingProgress = 0
    ns.CollectionLoadingState.currentStage = (ns.L and ns.L["LOADING_COLLECTIONS"]) or "Loading collections..."

    Mat.EnsureBlizzardCollectionsLoaded()

    local queue = {}
    -- BuildFullCollectionData handles mount+pet+toy together; trigger when any of them is missing/stale
    if not hasMounts or not hasPets or not hasToys then
        queue[#queue + 1] = "build"  -- BuildFullCollectionData (mounts, pets, toys)
    end
    if (not hasAchievements) or needAchievementIncludeAllRescan then
        queue[#queue + 1] = "achievement"
    end
    if not (collectionStore.title and next(collectionStore.title)) then
        queue[#queue + 1] = "title"
    end
    if not (collectionStore.illusion and next(collectionStore.illusion)) then
        queue[#queue + 1] = "illusion"
    end

    local P = ns.Profiler
    local profEnsureState = { started = false }
    local function stopEnsureProf()
        if profEnsureState.started and P then
            pcall(function() P:StopAsync("EnsureCollectionData") end)
            profEnsureState.started = false
        end
    end

    if #queue == 0 then
        ns.CollectionLoadingState.isLoading = false
        ns.CollectionLoadingState.loadingProgress = 100
        if LT then LT:Complete("collection_data") end
        if Constants and Constants.EVENTS then
            self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, { category = "all" })
        end
        if onComplete then onComplete() end
        return
    end

    if P and P.enabled then
        P:StartAsync("EnsureCollectionData")
        profEnsureState.started = true
    end

    local idx = 1
    local function RunNext()
        -- Defer entire pipeline step while in combat (PetJournal / mount APIs may skip or error mid-phase).
        if InCombatLockdown and InCombatLockdown() then
            C_Timer.After(0, RunNext)
            return
        end
        if idx > #queue then
            stopEnsureProf()
            ns.CollectionLoadingState.isLoading = false
            ns.CollectionLoadingState.loadingProgress = 100
            if LT then LT:Complete("collection_data") end
            self:SaveCollectionStore()
            if Constants and Constants.EVENTS then
                self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, { category = "all" })
            end
            if onComplete then onComplete() end
            return
        end

        local step = queue[idx]
        idx = idx + 1
        local progress = ((idx - 2) / #queue) * 100

        if step == "build" then
            ns.CollectionLoadingState.currentStage = (ns.L and ns.L["CATEGORY_MOUNTS"]) or "Mounts"
            self:BuildFullCollectionData(function()
                ns.CollectionLoadingState.loadingProgress = math.min(99, (idx - 1) / #queue * 100)
                C_Timer.After(0.2, RunNext)
            end)
        elseif step == "achievement" then
            ns.CollectionLoadingState.currentStage = (ns.L and ns.L["CATEGORY_ACHIEVEMENTS"]) or "Achievements"
            local msgName = E.COLLECTION_SCAN_COMPLETE
            local handler
            handler = function(_, data)
                if data and data.category == "achievement" then
                    self:UnregisterMessage(msgName, handler)
                    ns.CollectionLoadingState.loadingProgress = math.min(99, (idx - 1) / #queue * 100)
                    C_Timer.After(0.2, RunNext)
                end
            end
            self:RegisterMessage(msgName, handler)
            self:ScanAchievementsAsync()
            return
        elseif step == "title" or step == "illusion" then
            ns.CollectionLoadingState.currentStage = step == "title" and ((ns.L and ns.L["CATEGORY_TITLES"]) or "Titles") or ((ns.L and ns.L["CATEGORY_ILLUSIONS"]) or "Illusions")
            self:ScanCollection(step, nil, function()
                ns.CollectionLoadingState.loadingProgress = math.min(99, (idx - 1) / #queue * 100)
                C_Timer.After(0.2, RunNext)
            end)
        end
    end

    RunNext()

    -- Safety timeout: if the pipeline hasn't completed within 60s, force-clear loading state
    -- to prevent the UI from being permanently stuck on the loading overlay
    C_Timer.After(60, function()
        if ns.CollectionLoadingState.isLoading then
            stopEnsureProf()
            ns.CollectionLoadingState.isLoading = false
            ns.CollectionLoadingState.loadingProgress = 100
            if LT then LT:Complete("collection_data") end
            if WarbandNexus.SaveCollectionStore then
                WarbandNexus:SaveCollectionStore()
            end
            DebugPrint("|cffff4444[WN CollectionService]|r EnsureCollectionData safety timeout — force-cleared loading state")
        end
    end)
end

---Legacy: EnsureFullCollectionData — use EnsureCollectionData instead
function WarbandNexus:EnsureFullCollectionData()
    self:EnsureCollectionData()
end

---Get single mount record from global collection data (id, name, icon, source, description, creatureDisplayID, collected).
---@return table|nil
function WarbandNexus:GetMountData(mountID)
    if not mountID or not collectionData.mount then return nil end
    return collectionData.mount[mountID]
end

---Get all mount records from global collection data for UI (search uses name; list uses id).
---@return table[] Array of { id, name, icon, source, description, creatureDisplayID, collected }
function WarbandNexus:GetAllMountsData()
    if not collectionData.mount then return {} end
    local out = {}
    for id, d in pairs(collectionData.mount) do
        if d and d.id then
            out[#out + 1] = d
        end
    end
    return out
end

---Get all pet records from global collection data for UI (search uses name; list uses speciesID).
---@return table[] Array of { id, name, icon, source, description, creatureDisplayID, collected }
function WarbandNexus:GetAllPetsData()
    if not collectionData.pet then return {} end
    local out = {}
    for id, d in pairs(collectionData.pet) do
        if d and d.id then
            out[#out + 1] = d
        end
    end
    return out
end

---Get all toy records from store (id, name only in DB). Adds collected from API for compatibility.
---@return table[] Array of { id, name, collected }
function WarbandNexus:GetAllToysData()
    if not collectionStore.toy then return {} end
    local out = {}
    for id, d in pairs(collectionStore.toy) do
        if d and d.id then
            local collected = false
            if PlayerHasToy then
                local raw = PlayerHasToy(d.id)
                if issecretvalue and raw and issecretvalue(raw) then collected = true else collected = raw == true end
            end
            out[#out + 1] = {
                id = d.id,
                name = d.name or tostring(d.id),
                collected = collected,
                sourceTypeIndex = d.sourceTypeIndex,
            }
        end
    end
    return out
end

-- TOY: C_ToyBox source type only. Categories = Blizzard Sources (Drop, Quest, Vendor, ...).
local TOY_SOURCE_TYPE_MAX = 32
-- Blizzard Toy Box Filter > Sources order (fallback when TOY_SOURCE_TYPE_N globals missing or SOURCE_TYPE_OTHER)
local TOY_SOURCE_TYPE_NAMES = {
    [1] = (ns.L and ns.L["SOURCE_TYPE_DROP"]) or BATTLE_PET_SOURCE_1 or "Drop",
    [2] = (ns.L and ns.L["SOURCE_TYPE_QUEST"]) or BATTLE_PET_SOURCE_2 or "Quest",
    [3] = (ns.L and ns.L["SOURCE_TYPE_VENDOR"]) or BATTLE_PET_SOURCE_3 or "Vendor",
    [4] = (ns.L and ns.L["SOURCE_TYPE_PROFESSION"]) or BATTLE_PET_SOURCE_4 or "Profession",
    [5] = (ns.L and ns.L["SOURCE_TYPE_PET_BATTLE"]) or BATTLE_PET_SOURCE_5 or "Pet Battle",
    [6] = (ns.L and ns.L["SOURCE_TYPE_ACHIEVEMENT"]) or BATTLE_PET_SOURCE_6 or "Achievement",
    [7] = (ns.L and ns.L["SOURCE_TYPE_WORLD_EVENT"]) or BATTLE_PET_SOURCE_7 or "World Event",
    [8] = (ns.L and ns.L["SOURCE_TYPE_PROMOTION"]) or BATTLE_PET_SOURCE_8 or "Promotion",
    [9] = (ns.L and ns.L["SOURCE_TYPE_TRADING_CARD"]) or BATTLE_PET_SOURCE_9 or "Trading Card Game",
    [10] = (ns.L and ns.L["SOURCE_TYPE_IN_GAME_SHOP"]) or BATTLE_PET_SOURCE_10 or "In-Game Shop",
    [11] = (ns.L and ns.L["SOURCE_TYPE_TRADING_POST"]) or "Trading Post",
    [12] = (ns.L and ns.L["SOURCE_TYPE_PVP"]) or "PvP",
}

---Build grouped toy tables from session itemID->sourceIndex map (no ToyBox filter sweep).
---@return table grouped same shape as GetToysGroupedBySourceType
local function BuildToyGroupedTablesFromMap(addon, itemIDToSource)
    local grouped = {}
    for itemID, srcIdx in pairs(itemIDToSource) do
        if type(itemID) == "number" and itemID > 0 and type(srcIdx) == "number" and srcIdx > 0 then
            local g = grouped[srcIdx]
            if not g then
                g = { name = addon:GetToySourceTypeName(srcIdx), itemIDs = {} }
                grouped[srcIdx] = g
            end
            tinsert(g.itemIDs, itemID)
        end
    end
    return grouped
end

---Category label for Blizzard source type index. Prefer game globals/locale; else use Sources filter names (Drop, Quest, Vendor, ...).
function WarbandNexus:GetToySourceTypeName(sourceIndex)
    if not sourceIndex or type(sourceIndex) ~= "number" or sourceIndex < 1 then return "" end
    local key = "TOY_SOURCE_TYPE_" .. sourceIndex
    local L = ns.L
    if L and L[key] and L[key] ~= key and L[key] ~= "SOURCE_TYPE_OTHER" then return L[key] end
    local g = _G[key]
    if type(g) == "string" and g ~= "" and g ~= "SOURCE_TYPE_OTHER" then return g end
    return TOY_SOURCE_TYPE_NAMES[sourceIndex] or string.format((L and L["SOURCE_TYPE_CATEGORY_FORMAT"]) or "Category %d", sourceIndex)
end

function WarbandNexus:GetToySourceTypeCount()
    if not C_ToyBox then return 0 end
    Mat.EnsureBlizzardCollectionsLoaded()
    if C_ToyBox.GetNumSourceTypeFilters and type(C_ToyBox.GetNumSourceTypeFilters) == "function" then
        local ok, n = pcall(C_ToyBox.GetNumSourceTypeFilters)
        if ok and type(n) == "number" and n >= 1 then return n end
    end
    return math.min(24, TOY_SOURCE_TYPE_MAX)
end

---Group toys by C_ToyBox source type filter. Saves/restores filter state.
function WarbandNexus:GetToysGroupedBySourceType()
    local grouped = {}
    local itemIDToSource = {}
    if not C_ToyBox then return grouped, itemIDToSource end
    Mat.EnsureBlizzardCollectionsLoaded()
    if InCombatLockdown() then return grouped, itemIDToSource end
    if ns._toyGroupedFromMapValid and ns._toyItemIDToSourceIndex and next(ns._toyItemIDToSourceIndex) then
        local map = ns._toyItemIDToSourceIndex
        return BuildToyGroupedTablesFromMap(self, map), map
    end
    local origCollected = C_ToyBox.GetCollectedShown and C_ToyBox.GetCollectedShown()
    local origUncollected = C_ToyBox.GetUncollectedShown and C_ToyBox.GetUncollectedShown()
    local origFilterString = C_ToyBox.GetFilterString and C_ToyBox.GetFilterString() or ""
    local origSourceFilters = {}
    local count = self:GetToySourceTypeCount()
    for i = 1, count do
        if C_ToyBox.IsSourceTypeFilterChecked then
            local ok, checked = pcall(C_ToyBox.IsSourceTypeFilterChecked, i)
            if ok and checked ~= nil then origSourceFilters[i] = checked end
        end
    end
    pcall(function()
        C_ToyBox.SetCollectedShown(true)
        C_ToyBox.SetUncollectedShown(true)
        C_ToyBox.SetFilterString("")
        for sourceIndex = 1, count do
            C_ToyBox.SetAllSourceTypeFilters(false)
            C_ToyBox.SetSourceTypeFilter(sourceIndex, true)
            if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end
            local numFiltered = (C_ToyBox.GetNumFilteredToys and C_ToyBox.GetNumFilteredToys()) or 0
            if issecretvalue and numFiltered and issecretvalue(numFiltered) then numFiltered = 0 end
            local itemIDs = {}
            for j = 1, numFiltered do
                local itemID = C_ToyBox.GetToyFromIndex(j)
                if itemID and itemID > 0 and not (issecretvalue and issecretvalue(itemID)) then
                    if not itemIDToSource[itemID] then
                        itemIDToSource[itemID] = sourceIndex
                        itemIDs[#itemIDs + 1] = itemID
                    end
                end
            end
            grouped[sourceIndex] = { name = self:GetToySourceTypeName(sourceIndex), itemIDs = itemIDs }
        end
        C_ToyBox.SetAllSourceTypeFilters(true)
        if origCollected ~= nil then C_ToyBox.SetCollectedShown(origCollected) end
        if origUncollected ~= nil then C_ToyBox.SetUncollectedShown(origUncollected) end
        if origFilterString then C_ToyBox.SetFilterString(origFilterString) end
        for i, checked in pairs(origSourceFilters) do
            if C_ToyBox.SetSourceTypeFilter then C_ToyBox.SetSourceTypeFilter(i, checked) end
        end
        if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end
    end)
    ns._toyItemIDToSourceIndex = itemIDToSource
    ns._toyGroupedFromMapValid = next(itemIDToSource) ~= nil
    return grouped, itemIDToSource
end

---Single source of truth for toy source line: C_ToyBox source type name (Drop, Quest, Vendor, ...). Used by Collections and Plans.
---Caches itemID->sourceIndex from GetToysGroupedBySourceType so repeated lookups are cheap.
function WarbandNexus:GetToySourceTypeNameForItem(itemID)
    if not itemID then return nil end
    local idx = self:GetToySourceTypeIndexForItem(itemID)
    if not idx then return nil end
    return self:GetToySourceTypeName(idx)
end

---Pure-API toy source classifier: returns the BattlePetSources filter index (1-based).
---Mirrors GetToySourceTypeNameForItem but exposes the raw integer for UI categorization.
---@return number|nil sourceIndex (1=Drop, 2=Quest, 3=Vendor, ...) or nil if untracked
function WarbandNexus:GetToySourceTypeIndexForItem(itemID)
    if not itemID then return nil end
    if ns._toyGroupedFromMapValid and ns._toyItemIDToSourceIndex then
        local fastIdx = ns._toyItemIDToSourceIndex[itemID]
        if fastIdx ~= nil then return fastIdx end
    end
    if not ns._toyItemIDToSourceIndexCache then ns._toyItemIDToSourceIndexCache = {} end
    local cache = ns._toyItemIDToSourceIndexCache
    if not cache.map then
        local _, itemIDToSource = self:GetToysGroupedBySourceType()
        cache.map = itemIDToSource or {}
    end
    return cache.map[itemID]
end

-- MOUNT: C_MountJournal source filter map (mirrors pet/toy SetSourceFilter sweep).
-- GetMountInfoByID().sourceType is often 0 ("Other") while the journal source tab is correct.

local MOUNT_SOURCE_FILTER_MAX = 24

---@return boolean|nil
local function SafeJournalBool(val)
    if val == nil then return nil end
    if issecretvalue and issecretvalue(val) then return nil end
    if val == true then return true end
    if val == false then return false end
    return nil
end

function WarbandNexus:GetMountSourceFilterCount()
    if not C_MountJournal then return 0 end
    Mat.EnsureBlizzardCollectionsLoaded()
    if C_MountJournal.IsSourceChecked then
        local maxIdx = 0
        for i = 1, MOUNT_SOURCE_FILTER_MAX do
            local ok, checked = pcall(C_MountJournal.IsSourceChecked, i)
            if ok and checked ~= nil then
                maxIdx = i
            else
                break
            end
        end
        if maxIdx > 0 then return maxIdx end
    end
    return 11
end

---Build mountID -> journal source filter index (1=Drop, 10=Shop, ...). Persists in ns._mountIDToSourceIndex.
function WarbandNexus:EnsureMountSourceIndexMap()
    if ns._mountGroupedFromMapValid and ns._mountIDToSourceIndex and next(ns._mountIDToSourceIndex) then
        return ns._mountIDToSourceIndex
    end
    local mountIDToSource = {}
    if not C_MountJournal or not C_MountJournal.SetSourceFilter or not C_MountJournal.GetDisplayedMountInfo then
        ns._mountIDToSourceIndex = mountIDToSource
        ns._mountGroupedFromMapValid = false
        return mountIDToSource
    end
    if InCombatLockdown() then
        return ns._mountIDToSourceIndex or mountIDToSource
    end
    Mat.EnsureBlizzardCollectionsLoaded()
    local count = self:GetMountSourceFilterCount()
    local origSourceFilters = {}
    for i = 1, count do
        if C_MountJournal.IsSourceChecked then
            local ok, checked = pcall(C_MountJournal.IsSourceChecked, i)
            if ok and checked ~= nil then origSourceFilters[i] = checked end
        end
    end
    local origSearch = (C_MountJournal.GetSearch and C_MountJournal.GetSearch()) or ""
    pcall(function()
        if C_MountJournal.SetSearch then C_MountJournal.SetSearch("") end
        if C_MountJournal.SetAllSourceFilters then C_MountJournal.SetAllSourceFilters(false) end
        if C_MountJournal.SetAllTypeFilters then C_MountJournal.SetAllTypeFilters(true) end
        if C_MountJournal.SetCollectedFilterSetting then
            C_MountJournal.SetCollectedFilterSetting(1, true)
            C_MountJournal.SetCollectedFilterSetting(2, true)
            C_MountJournal.SetCollectedFilterSetting(3, true)
        end
        for sourceIndex = 1, count do
            if C_MountJournal.SetAllSourceFilters then C_MountJournal.SetAllSourceFilters(false) end
            C_MountJournal.SetSourceFilter(sourceIndex, true)
            local numDisplayed = (C_MountJournal.GetNumDisplayedMounts and C_MountJournal.GetNumDisplayedMounts()) or 0
            if issecretvalue and numDisplayed and issecretvalue(numDisplayed) then numDisplayed = 0 end
            for j = 1, numDisplayed do
                local _, _, _, _, _, _, _, _, _, _, _, mountID = C_MountJournal.GetDisplayedMountInfo(j)
                if mountID and mountID > 0 and not (issecretvalue and issecretvalue(mountID)) then
                    if not mountIDToSource[mountID] then
                        mountIDToSource[mountID] = sourceIndex
                    end
                end
            end
        end
        if C_MountJournal.SetAllSourceFilters then C_MountJournal.SetAllSourceFilters(true) end
        if origSearch ~= "" and C_MountJournal.SetSearch then C_MountJournal.SetSearch(origSearch) end
        for i, checked in pairs(origSourceFilters) do
            C_MountJournal.SetSourceFilter(i, checked)
        end
    end)
    ns._mountIDToSourceIndex = mountIDToSource
    ns._mountGroupedFromMapValid = next(mountIDToSource) ~= nil
    return mountIDToSource
end

---Resolve mount browse category integer: live sourceType when >0, else journal filter map.
---@return number|nil
function WarbandNexus:GetMountBrowseSourceType(mountID)
    if not mountID then return nil end
    if C_MountJournal and C_MountJournal.GetMountInfoByID then
        local _, _, _, _, _, sourceType = C_MountJournal.GetMountInfoByID(mountID)
        if sourceType and not (issecretvalue and issecretvalue(sourceType)) and sourceType > 0 then
            return sourceType
        end
    end
    self:EnsureMountSourceIndexMap()
    local fromMap = ns._mountIDToSourceIndex and ns._mountIDToSourceIndex[mountID]
    if fromMap and fromMap > 0 then return fromMap end
    return nil
end

---Hide journal-internal mounts (Soar, class placeholders). Keep cross-faction mounts visible.
---@return boolean true when the mount should be excluded from Collections / To-Do browse
function WarbandNexus:ShouldExcludeMountFromCollectionBrowse(mountID)
    if not mountID or not C_MountJournal or not C_MountJournal.GetMountInfoByID then return true end
    local name, _, _, _, _, _, _, isFactionSpecific, _, shouldHideOnChar = C_MountJournal.GetMountInfoByID(mountID)
    if not name or (issecretvalue and issecretvalue(name)) then return true end
    local hide = SafeJournalBool(shouldHideOnChar)
    if hide ~= true then return false end
    local factionSpec = SafeJournalBool(isFactionSpecific)
    if factionSpec == true then
        return false
    end
    return true
end

---Pet species source filter index (1=Drop). Lazy-builds journal map when SV rows lack sourceTypeIndex.
---@return number|nil
function WarbandNexus:GetPetSourceTypeIndexForSpecies(speciesID)
    if not speciesID then return nil end
    local row = collectionStore.pet and collectionStore.pet[speciesID]
    if row and row.sourceTypeIndex and row.sourceTypeIndex > 0 then
        return row.sourceTypeIndex
    end
    if ns._petSpeciesToSourceIndex and ns._petSpeciesToSourceIndex[speciesID] then
        return ns._petSpeciesToSourceIndex[speciesID]
    end
    if InCombatLockdown() or not C_PetJournal then return nil end
    if ns._petSpeciesToSourceIndex and next(ns._petSpeciesToSourceIndex) then
        return ns._petSpeciesToSourceIndex[speciesID]
    end
    local st = Mat.PetJournalBuildMaterializeStep(FRAME_BUDGET_MS * 4, nil)
    while st and not st.done do
        st = Mat.PetJournalBuildMaterializeStep(FRAME_BUDGET_MS * 4, st)
    end
    if st and st.speciesToSource then
        ns._petSpeciesToSourceIndex = st.speciesToSource
    end
    return ns._petSpeciesToSourceIndex and ns._petSpeciesToSourceIndex[speciesID]
end

---Toys grouped by Blizzard source type for UI. Each item: id, name, icon, collected. DB stores only id+name.
function WarbandNexus:GetToysDataGroupedBySourceType()
    Mat.EnsureBlizzardCollectionsLoaded()
    local grouped = self:GetToysGroupedBySourceType()
    local result = {}
    if not C_ToyBox or not C_ToyBox.GetToyInfo then return result end
    local _issecretvalue = issecretvalue
    local store = collectionStore.toy
    for sourceIndex, group in pairs(grouped) do
        if group and group.itemIDs and #group.itemIDs > 0 then
            local items = {}
            for i = 1, #group.itemIDs do
                local itemID = group.itemIDs[i]
                local _, toyName, icon = C_ToyBox.GetToyInfo(itemID)
                if _issecretvalue and toyName and _issecretvalue(toyName) then toyName = nil end
                local name = (toyName and toyName ~= "") and toyName or nil
                if not name and store and store[itemID] and (store[itemID].name or "") ~= "" then
                    name = store[itemID].name
                end
                if (not name or name == "") and C_Item and C_Item.GetItemInfo then
                    local itemName = C_Item.GetItemInfo(itemID)
                    if itemName and type(itemName) == "string" and itemName ~= "" then
                        if not _issecretvalue or not _issecretvalue(itemName) then name = itemName end
                    end
                end
                if not name or name == "" then name = tostring(itemID) end
                if not icon and (C_Item and C_Item.GetItemInfo) then
                    local _, _, _, _, _, _, _, _, _, tex = C_Item.GetItemInfo(itemID)
                    if tex then icon = tex end
                end
                local collected = false
                if PlayerHasToy then
                    local raw = PlayerHasToy(itemID)
                    if _issecretvalue and raw and _issecretvalue(raw) then collected = true
                    else collected = raw == true end
                end
                items[#items + 1] = {
                    id = itemID,
                    name = name,
                    icon = icon,
                    collected = collected,
                    isCollected = collected,
                    sourceTypeIndex = sourceIndex,
                    sourceTypeName = group.name,
                }
            end
            table.sort(items, CmpItemName)
            result[sourceIndex] = { name = group.name, items = items }
        end
    end
    return result
end

---Tooltip lines for selected toy (detail panel description only). Source comes from source type, not tooltip.
---Returns { name, icon, lines = string[], isCollected }. Call TooltipUtil.SurfaceArgs before reading lines.
function WarbandNexus:GetToyTooltipForDisplay(itemID)
    if not itemID or not C_ToyBox or not C_ToyBox.GetToyInfo then return nil end
    local _, name, icon = C_ToyBox.GetToyInfo(itemID)
    if issecretvalue and name and issecretvalue(name) then name = nil end
    if not name then name = "" end
    if not icon and (C_Item and C_Item.GetItemInfo) then
        local _, _, _, _, _, _, _, _, _, itemTexture = C_Item.GetItemInfo(itemID)
        if itemTexture then icon = itemTexture end
    end
    local isCollected = false
    if PlayerHasToy then
        local raw = PlayerHasToy(itemID)
        if issecretvalue and raw and issecretvalue(raw) then isCollected = true
        else isCollected = raw == true end
    end
    local tooltipData = nil
    if C_TooltipInfo then
        local ok1, r1 = pcall(C_TooltipInfo.GetToyByItemID, itemID)
        if ok1 and r1 and r1.lines and #r1.lines > 0 then tooltipData = r1 end
        if (not tooltipData or not tooltipData.lines or #tooltipData.lines == 0) and C_TooltipInfo.GetItemByID then
            local ok2, r2 = pcall(C_TooltipInfo.GetItemByID, itemID)
            if ok2 and r2 and r2.lines and #r2.lines > 0 then tooltipData = r2 end
        end
    end
    if tooltipData and tooltipData.lines and TooltipUtil and TooltipUtil.SurfaceArgs then
        pcall(TooltipUtil.SurfaceArgs, tooltipData)
    end
    local _issecretvalue = issecretvalue
    local function safeText(t)
        if not t or type(t) ~= "string" then return nil end
        if _issecretvalue and _issecretvalue(t) then return nil end
        return t
    end
    local function strip(s)
        if not s or type(s) ~= "string" then return "" end
        return s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|c%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|H.-|h", ""):gsub("|h", ""):gsub("|T.-|t", "")
    end
    local fallback1 = (ns.L and ns.L["FALLBACK_TOY_COLLECTION"]) or "Toy Collection"
    local fallback2 = (ns.L and ns.L["FALLBACK_TOY_BOX"]) or "Toy Box"
    local fallback3 = (ns.L and ns.L["FALLBACK_WARBAND_TOY"]) or "Warband Toy"
    local function skipLine(lineStr)
        if not lineStr or lineStr == "" then return true end
        local t = (lineStr:gsub("^%s+", ""):gsub("%s+$", ""))
        if t == "" or t == fallback1 or t == fallback2 or t == fallback3 then return true end
        return false
    end
    local lines = {}
    if tooltipData and tooltipData.lines then
        for i = 1, #tooltipData.lines do
            local line = tooltipData.lines[i]
            if line then
                local left = safeText(line.leftText)
                local right = safeText(line.rightText)
                local lineText = (left and left ~= "" and left) or (right and right ~= "" and right) or nil
                if lineText then
                    local cleaned = strip(lineText):gsub("^%s+", ""):gsub("%s+$", "")
                    if not skipLine(cleaned) then lines[#lines + 1] = cleaned end
                end
            end
        end
    end
    return { name = name, icon = icon, lines = lines, isCollected = isCollected }
end

-- Canonical collection counts from Blizzard API only (single source for Statistics + Collections).
-- Cache invalidated on WN_COLLECTION_UPDATED / WN_COLLECTIBLE_OBTAINED so both tabs show same numbers.
local _collectionCountsAPICache = nil
local COLLECTION_COUNTS_API_TTL = 60

---Return cached or freshly computed collection counts from Blizzard API only.
---Single source of truth for Statistics and Collections (e.g. mount total 1577 in both).
---@return table { mounts = { collected, total }, pets = { collected, totalSpecies, uniqueSpecies, journalEntries }, toys = { collected, total }, achievementPoints = number }
function WarbandNexus:GetCollectionCountsFromStore()
    if not IsCollectionEnsureDataComplete(self) then
        return nil
    end

    local data = {
        mounts = { collected = 0, total = 0 },
        pets = { collected = 0, totalSpecies = 0, uniqueSpecies = 0, journalEntries = 0 },
        toys = { collected = 0, total = 0 },
        achievementPoints = 0,
    }

    if GetTotalAchievementPoints then
        data.achievementPoints = GetTotalAchievementPoints() or 0
    end

    local mountStore = collectionStore.mount
    if type(mountStore) == "table" then
        for _, row in pairs(mountStore) do
            data.mounts.total = data.mounts.total + 1
            if type(row) == "table" and row.collected == true then
                data.mounts.collected = data.mounts.collected + 1
            end
        end
    end

    local petStore = collectionStore.pet
    if type(petStore) == "table" then
        local uniqueCollected = 0
        for _, row in pairs(petStore) do
            data.pets.totalSpecies = data.pets.totalSpecies + 1
            if type(row) == "table" and row.collected == true then
                data.pets.collected = data.pets.collected + 1
                uniqueCollected = uniqueCollected + 1
            end
        end
        data.pets.uniqueSpecies = uniqueCollected
        data.pets.journalEntries = data.pets.collected
    end

    local toyStore = collectionStore.toy
    if type(toyStore) == "table" then
        for _ in pairs(toyStore) do
            data.toys.total = data.toys.total + 1
        end
        local ownedToys = collectionCache.owned and collectionCache.owned.toys
        if type(ownedToys) == "table" then
            for _ in pairs(ownedToys) do
                data.toys.collected = data.toys.collected + 1
            end
        end
    end

    return data
end

function WarbandNexus:GetCollectionCountsFromAPI()
    local now = GetTime()
    if _collectionCountsAPICache and (now - _collectionCountsAPICache.timestamp) < COLLECTION_COUNTS_API_TTL then
        return _collectionCountsAPICache.data
    end

    local data = {
        mounts = { collected = 0, total = 0 },
        pets = { collected = 0, totalSpecies = 0, uniqueSpecies = 0, journalEntries = 0 },
        toys = { collected = 0, total = 0 },
        achievementPoints = 0,
    }

    if GetTotalAchievementPoints then
        data.achievementPoints = GetTotalAchievementPoints() or 0
    end

    if C_MountJournal and C_MountJournal.GetMountIDs then
        local mountIDs = C_MountJournal.GetMountIDs()
        if mountIDs then
            local totalVisible = 0
            local collectedCount = 0
            for i = 1, #mountIDs do
                local _, _, _, _, _, _, _, isFactionSpecific, _, shouldHideOnChar, isCollected = C_MountJournal.GetMountInfoByID(mountIDs[i])
                local hidden = false
                local hide = SafeJournalBool(shouldHideOnChar)
                if hide == true then
                    local factionSpec = SafeJournalBool(isFactionSpecific)
                    if factionSpec ~= true then
                        hidden = true
                    end
                end
                if not hidden then
                    totalVisible = totalVisible + 1
                    if issecretvalue and isCollected and issecretvalue(isCollected) then
                        collectedCount = collectedCount + 1
                    elseif isCollected == true then
                        collectedCount = collectedCount + 1
                    end
                end
            end
            data.mounts.total = totalVisible
            data.mounts.collected = collectedCount
        end
    end

    if C_PetJournal then
        if ns.EnsureBlizzardCollectionsLoaded then ns.EnsureBlizzardCollectionsLoaded() end
        local numJournalEntries, numCollectedPets = 0, 0
        if C_PetJournal.GetNumPets then
            numJournalEntries, numCollectedPets = C_PetJournal.GetNumPets()
            if issecretvalue and numJournalEntries and issecretvalue(numJournalEntries) then numJournalEntries = 0 end
            if issecretvalue and numCollectedPets and issecretvalue(numCollectedPets) then numCollectedPets = 0 end
        end
        data.pets.journalEntries = numJournalEntries or 0
        data.pets.collected = numCollectedPets or 0
        local numUniqueSpecies = 0
        local numTotalSpecies = 0
        if C_PetJournal.GetOwnedPetIDs and C_PetJournal.GetPetInfoTableByPetID then
            local ownedPetIDs = C_PetJournal.GetOwnedPetIDs()
            if ownedPetIDs then
                data.pets.collected = #ownedPetIDs
                local ownedSpecies = {}
                for j = 1, #ownedPetIDs do
                    local info = C_PetJournal.GetPetInfoTableByPetID(ownedPetIDs[j])
                    if info and info.speciesID then
                        ownedSpecies[info.speciesID] = true
                    end
                end
                for _ in pairs(ownedSpecies) do
                    numUniqueSpecies = numUniqueSpecies + 1
                end
            end
        end
        data.pets.uniqueSpecies = numUniqueSpecies
        if C_PetJournal.GetPetInfoByIndex then
            local allSpecies = {}
            for i = 1, numJournalEntries do
                local petID, speciesID = C_PetJournal.GetPetInfoByIndex(i)
                if speciesID then
                    allSpecies[speciesID] = true
                end
            end
            for _ in pairs(allSpecies) do
                numTotalSpecies = numTotalSpecies + 1
            end
            data.pets.totalSpecies = numTotalSpecies
        else
            data.pets.totalSpecies = numJournalEntries
        end
    end

    if C_ToyBox then
        -- Total: filter-independent (GetNumToys). Collected: GetToyFromIndex is filter-dependent, so set "show all" before iterating.
        local numToys = C_ToyBox.GetNumToys() or (C_ToyBox.GetNumTotalDisplayedToys() or 0)
        data.toys.total = numToys
        local collected = 0
        if PlayerHasToy and numToys and numToys > 0 then
            local origCollected, origUncollected, origFilterString
            if ns.EnsureBlizzardCollectionsLoaded then ns.EnsureBlizzardCollectionsLoaded() end
            if not InCombatLockdown() then
                origCollected = C_ToyBox.GetCollectedShown and C_ToyBox.GetCollectedShown()
                origUncollected = C_ToyBox.GetUncollectedShown and C_ToyBox.GetUncollectedShown()
                origFilterString = C_ToyBox.GetFilterString and C_ToyBox.GetFilterString() or ""
                pcall(function()
                    C_ToyBox.SetCollectedShown(true)
                    C_ToyBox.SetUncollectedShown(true)
                    if C_ToyBox.SetAllSourceTypeFilters then C_ToyBox.SetAllSourceTypeFilters(true) end
                    if C_ToyBox.SetFilterString then C_ToyBox.SetFilterString("") end
                    if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end
                end)
            end
            for i = 1, numToys do
                local itemID = C_ToyBox.GetToyFromIndex(i)
                if itemID and (PlayerHasToy(itemID) == true or (issecretvalue and PlayerHasToy(itemID) and issecretvalue(PlayerHasToy(itemID)))) then
                    collected = collected + 1
                end
            end
            if not InCombatLockdown() and (origCollected ~= nil or origUncollected ~= nil or origFilterString) then
                pcall(function()
                    if origCollected ~= nil and C_ToyBox.SetCollectedShown then C_ToyBox.SetCollectedShown(origCollected) end
                    if origUncollected ~= nil and C_ToyBox.SetUncollectedShown then C_ToyBox.SetUncollectedShown(origUncollected) end
                    if origFilterString and C_ToyBox.SetFilterString then C_ToyBox.SetFilterString(origFilterString) end
                    if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end
                end)
            end
        end
        data.toys.collected = collected
    end

    _collectionCountsAPICache = { timestamp = now, data = data }
    return data
end

---Invalidate the API counts cache so next GetCollectionCountsFromAPI() recomputes.
---For a confirmed mount/pet/toy OBTAIN the cached counters adjust in place instead:
---during farm sessions every obtain wiped the cache, so the next UI access paid the
---full O(journals) API pass again. Totals can drift by at most one until the 60s TTL
---recomputes (only when a journal entry is added in the same window — patch days).
function WarbandNexus:InvalidateCollectionCountsAPICache(obtainedType)
    local c = _collectionCountsAPICache
    if c and c.data then
        if obtainedType == "mount" and c.data.mounts then
            c.data.mounts.collected = (c.data.mounts.collected or 0) + 1
            return
        elseif obtainedType == "toy" and c.data.toys then
            c.data.toys.collected = (c.data.toys.collected or 0) + 1
            return
        elseif obtainedType == "pet" and c.data.pets then
            c.data.pets.collected = (c.data.pets.collected or 0) + 1
            c.data.pets.uniqueSpecies = (c.data.pets.uniqueSpecies or 0) + 1
            c.data.pets.journalEntries = (c.data.pets.journalEntries or 0) + 1
            return
        end
    end
    _collectionCountsAPICache = nil
end

---Get all achievements (complete + incomplete) for Collections UI. Uses cache from ScanAchievementsAsync.
---@return table[] Array of { id, name, icon, points, description, categoryID, collected }
function WarbandNexus:GetAllAchievementsData()
    if _allAchievementsMergedCache then
        return _allAchievementsMergedCache
    end
    local uncollected = self:GetUncollectedAchievements("", 999999) or {}
    local completed = self:GetCompletedAchievements("", 999999) or {}
    local seen = {}
    local out = {}
    for i = 1, #uncollected do
        local a = uncollected[i]
        if a and a.id and not seen[a.id] then
            seen[a.id] = true
            a.collected = false
            a.isCollected = false
            out[#out + 1] = a
        end
    end
    for i = 1, #completed do
        local a = completed[i]
        if a and a.id and not seen[a.id] then
            seen[a.id] = true
            a.collected = true
            a.isCollected = true
            out[#out + 1] = a
        end
    end
    local achTotal = #out
    local achCollected = 0
    for j = 1, achTotal do
        local e = out[j]
        if e and (e.isCollected or e.collected) then
            achCollected = achCollected + 1
        end
    end
    out._wnAchCollected = achCollected
    out._wnAchTotal = achTotal
    _allAchievementsMergedCache = out
    return out
end

---Remove collectible from uncollected cache (incremental update when player obtains it)
---This is called by event handlers when player collects a new mount/pet/toy
function WarbandNexus:RemoveFromUncollected(collectionType, id)
    local uncollected = collectionCache.uncollected[collectionType]
    local entry = uncollected and uncollected[id]
    local itemName = (type(entry) == "string") and entry or (entry and entry.name) or (ns.L and ns.L["UNKNOWN"]) or "Unknown"

    if entry ~= nil then
        collectionCache.uncollected[collectionType][id] = nil
    end

    -- Single source: update collectionStore (collected=true)
    local store = collectionStore[collectionType]
    local didUpdate = false
    if store then
        if not store[id] then
            store[id] = { id = id, name = itemName, collected = true }
            didUpdate = true
        elseif store[id].collected ~= true then
            store[id].collected = true
            didUpdate = true
        end
    end

    -- Update owned cache
    if collectionType == "mount" or collectionType == "pet" or collectionType == "toy" then
        local key = collectionType .. "s"
        if not collectionCache.owned[key] then
            collectionCache.owned[key] = {}
        end
        collectionCache.owned[key][id] = true
    end

    if didUpdate and store then
        self:SaveCollectionStore()
        DebugPrintf("|cff00ff00[WN CollectionService]|r INCREMENTAL UPDATE: %s marked collected in %ss",
            itemName, collectionType)
    end
end

-- Active coroutines for async scanning
local activeCoroutines = {}

-- ASYNC SCAN ABORT PROTOCOL (for tab switches)

---Abort all active collection scans (called when switching away from Plans tab)
function WarbandNexus:AbortCollectionScans()
    local abortCount = 0
    for collectionType, co in pairs(activeCoroutines) do
        if co and coroutine.status(co) ~= "dead" then
            -- Coroutines can't be forcibly killed in Lua, but we can mark them as aborted
            -- The scan loop will check and exit gracefully
            activeCoroutines[collectionType] = nil
            abortCount = abortCount + 1
        end
    end
    
    if abortCount > 0 then
    DebugPrint("|cffffcc00[WN CollectionService]|r Aborted " .. abortCount .. " active scans (tab switch)")
    end
end

-- NOTIFICATION SEEDING (must be defined before BuildCollectionCache)

---One-time seed: mark all currently owned collectibles as notified.
---Runs once per account (when notifiedCollectibles DB is first created).
---Prevents false "You got it on your first try!" for already-owned items
---after addon update introduces the persistent dedup layer.
local function SeedNotifiedFromOwned()
    if Notify and Notify.InitializeNotifiedDB then
        Notify.InitializeNotifiedDB()
    end
    if not WarbandNexus.db or not WarbandNexus.db.global then return end
    local db = WarbandNexus.db.global.notifiedCollectibles
    if not db then return end
    
    -- Only seed once: if DB already has entries, skip
    if db._seeded then return end
    
    local count = 0
    for mountID in pairs(collectionCache.owned.mounts) do
        local key = "mount_" .. tostring(mountID)
        if not db[key] then db[key] = true; count = count + 1 end
    end
    for speciesID in pairs(collectionCache.owned.pets) do
        local key = "pet_" .. tostring(speciesID)
        if not db[key] then db[key] = true; count = count + 1 end
    end
    for itemID in pairs(collectionCache.owned.toys) do
        local key = "toy_" .. tostring(itemID)
        if not db[key] then db[key] = true; count = count + 1 end
    end
    
    db._seeded = true
    DebugPrintf("|cff00ccff[WN CollectionService]|r Seeded notifiedCollectibles: %d entries from owned cache", count)
end

-- REAL-TIME CACHE BUILDING (Fast O(1) Lookup)

---Build or refresh owned collection cache (mount/pet/toy O(1) ownership for notifications and try-counter).
---Uses time-budgeted batching: each phase yields when the 4ms budget is exceeded.
function WarbandNexus:BuildCollectionCache(opts)
    opts = opts or {}
    local quiet = opts.quiet == true
    if not opts.forceJournalScan and CanHydrateOwnedCacheFromStore(self) then
        HydrateOwnedCacheFromStore(self)
        self:ScheduleLazyOwnedToyHydrate()
        return
    end
    -- Re-entrant calls (e.g. IsCollectibleOwned while pets/toys phases still empty) used to
    -- wipe owned cache and stack parallel timer chains, leaving LoadingTracker "collections" stuck.
    if self._buildingCollectionCache then return end
    self._buildingCollectionCache = true

    if self._collectionCacheSafetyTimer then
        self._collectionCacheSafetyTimer:Cancel()
        self._collectionCacheSafetyTimer = nil
    end

    local _issecretvalue = issecretvalue
    local BUDGET_MS = 4
    local P = ns.Profiler
    if P then P:StartAsync(quiet and "BuildCollectionCache_quiet" or "BuildCollectionCache") end
    local LT = ns.LoadingTracker
    if LT and not quiet then LT:Register("collections", (ns.L and ns.L["LT_COLLECTIONS"]) or "Collections") end

    local addonSelf = self
    self._collectionCacheSafetyTimer = C_Timer.NewTimer(60, function()
        addonSelf._collectionCacheSafetyTimer = nil
        if not addonSelf._buildingCollectionCache then return end
        addonSelf._buildingCollectionCache = nil
        if P then pcall(function() P:StopAsync(quiet and "BuildCollectionCache_quiet" or "BuildCollectionCache") end) end
        local lt = ns.LoadingTracker
        if lt and not quiet then lt:Complete("collections") end
        DebugPrint("|cffffcc00[WN CollectionService]|r BuildCollectionCache safety timeout — released LoadingTracker (collections)")
    end)

    collectionCache.owned = {
        mounts = {},
        pets = {},
        toys = {}
    }
    
    -- Pre-declare all phase functions for forward references
    local MountBatch, StartPetPhase, PetBatch, StartToyPhase, ToyBatch

    local function onOwnedCacheBuildDone()
        if addonSelf._collectionCacheSafetyTimer then
            addonSelf._collectionCacheSafetyTimer:Cancel()
            addonSelf._collectionCacheSafetyTimer = nil
        end
        addonSelf._buildingCollectionCache = nil
        if P then pcall(function() P:StopAsync(quiet and "BuildCollectionCache_quiet" or "BuildCollectionCache") end) end
        if LT and not quiet then LT:Complete("collections") end
    end

    -- ── Phase 1: Mounts (time-budgeted) ──
    local mountIDs
    local ok1, err1 = pcall(function()
        if C_MountJournal and C_MountJournal.GetMountIDs then
            mountIDs = C_MountJournal.GetMountIDs()
        end
    end)
    if not ok1 then
        DebugPrint("|cffff4444[WN CollectionService ERROR]|r Mount cache init failed: " .. tostring(err1))
    end
    
    local mountIdx = 1
    MountBatch = function()
        if not mountIDs then StartPetPhase() return end
        local batchStart = debugprofilestop()
        local ok, err = pcall(function()
            while mountIdx <= #mountIDs do
                local mountID = mountIDs[mountIdx]
                local _, _, _, _, _, _, _, _, _, _, isCollected = C_MountJournal.GetMountInfoByID(mountID)
                if _issecretvalue and isCollected and _issecretvalue(isCollected) then
                    -- skip
                elseif isCollected then
                    collectionCache.owned.mounts[mountID] = true
                end
                mountIdx = mountIdx + 1
                if debugprofilestop() - batchStart > BUDGET_MS then
                    C_Timer.After(0, MountBatch)
                    return
                end
            end
            StartPetPhase()
        end)
        if not ok then
            DebugPrint("|cffff4444[WN CollectionService ERROR]|r Mount batch failed: " .. tostring(err))
            StartPetPhase()
        end
    end
    
    -- ── Phase 2: Pets (time-budgeted) ──
    local petIdx = 1
    local numPets = 0
    StartPetPhase = function()
        local ok, err = pcall(function()
            if C_PetJournal and C_PetJournal.GetNumPets then
                numPets = C_PetJournal.GetNumPets() or 0
                if _issecretvalue and numPets and _issecretvalue(numPets) then
                    numPets = 0
                end
            end
        end)
        if not ok then
            DebugPrint("|cffff4444[WN CollectionService ERROR]|r Pet init failed: " .. tostring(err))
        end
        C_Timer.After(0, PetBatch)
    end
    
    PetBatch = function()
        local ok, err = pcall(function()
            local batchStart = debugprofilestop()
            while petIdx <= numPets do
                local petID, speciesID, owned = C_PetJournal.GetPetInfoByIndex(petIdx)
                local speciesSecret = _issecretvalue and speciesID and _issecretvalue(speciesID)
                local ownedSecret = _issecretvalue and owned and _issecretvalue(owned)
                if not speciesSecret and not ownedSecret and speciesID and owned then
                    collectionCache.owned.pets[speciesID] = true
                end
                petIdx = petIdx + 1
                if debugprofilestop() - batchStart > BUDGET_MS then
                    C_Timer.After(0, PetBatch)
                    return
                end
            end
            StartToyPhase()
        end)
        if not ok then
            DebugPrint("|cffff4444[WN CollectionService ERROR]|r Pet batch failed: " .. tostring(err))
            StartToyPhase()
        end
    end
    
    -- ── Phase 3: Toys (time-budgeted) ──
    local toyIdx = 1
    local numToys = 0
    StartToyPhase = function()
        local ok, err = pcall(function()
            if C_ToyBox and C_ToyBox.GetNumToys then
                numToys = C_ToyBox.GetNumToys() or 0
                if _issecretvalue and numToys and _issecretvalue(numToys) then
                    numToys = 0
                end
            end
        end)
        if not ok then
            DebugPrint("|cffff4444[WN CollectionService ERROR]|r Toy init failed: " .. tostring(err))
        end
        C_Timer.After(0, ToyBatch)
    end
    
    ToyBatch = function()
        local ok, err = pcall(function()
            local batchStart = debugprofilestop()
            while toyIdx <= numToys do
                local itemID = C_ToyBox.GetToyFromIndex(toyIdx)
                if itemID and not (_issecretvalue and _issecretvalue(itemID)) then
                    local hasToy = PlayerHasToy and PlayerHasToy(itemID)
                    if hasToy and not (_issecretvalue and _issecretvalue(hasToy)) then
                        collectionCache.owned.toys[itemID] = true
                    end
                end
                toyIdx = toyIdx + 1
                if debugprofilestop() - batchStart > BUDGET_MS then
                    C_Timer.After(0, ToyBatch)
                    return
                end
            end
            onOwnedCacheBuildDone()
            SeedNotifiedFromOwned()
        end)
        if not ok then
            DebugPrint("|cffff4444[WN CollectionService ERROR]|r Toy batch failed: " .. tostring(err))
            onOwnedCacheBuildDone()
        end
    end
    
    MountBatch()
end

---Check if player owns a collectible
---@return boolean owned
function WarbandNexus:IsCollectibleOwned(collectibleType, id)
    local key = collectibleType and (collectibleType .. "s") or nil
    if not key then return false end

    local cache = collectionCache.owned[key]
    if not cache or next(cache) == nil then
        if CanHydrateOwnedCacheFromStore(self) then
            HydrateOwnedCacheFromStore(self)
            cache = collectionCache.owned[key]
            if cache and cache[id] == true then
                return true
            end
        end
        self:BuildCollectionCache({ forceJournalScan = true })
        cache = collectionCache.owned[key]
    end

    return cache and cache[id] == true
end

-- REAL-TIME EVENT HANDLERS (NEW_MOUNT_ADDED, NEW_PET_ADDED, NEW_TOY_ADDED)
--
-- The three handlers below share a skeleton (retry → owned/store update → dedup
-- gates → toast → COLLECTION_UPDATED) but are DELIBERATELY kept as parallel flat
-- functions, not a shared core: the dedup chains differ in both content and ORDER
-- (pets check fanfare/wrapped state and duplicate-species count before the common
-- layers, mounts add the grind chat line, toys invalidate their source caches),
-- and a callback-config abstraction here would obscure exactly the part that has
-- to be auditable. Keep edits synchronized across all three by hand.

---Handle NEW_MOUNT_ADDED event
---Fires when player learns a new mount
function WarbandNexus:OnNewMount(event, mountID, retryCount)
    if not mountID then return end
    retryCount = retryCount or 0

    local name, _, icon, _, _, sourceType = C_MountJournal.GetMountInfoByID(mountID)
    if issecretvalue then
        if name and issecretvalue(name) then name = nil end
        if icon and issecretvalue(icon) then icon = nil end
        if sourceType and issecretvalue(sourceType) then sourceType = nil end
    end
    if not name then
        if retryCount < 3 then
            DebugPrint("|cffffcc00[WN CollectionService]|r OnNewMount: Data not ready for mountID=" .. mountID .. ", retry " .. (retryCount + 1) .. "/3")
            C_Timer.After(0.5, function()
                self:OnNewMount(event, mountID, retryCount + 1)
            end)
        else
            DebugPrint("|cffff0000[WN CollectionService]|r OnNewMount: Data unavailable after 3 retries for mountID=" .. mountID)
        end
        return
    end

    -- ALWAYS update collected status in store (data correctness must not depend on notification dedup)
    collectionCache.owned.mounts[mountID] = true
    self:RemoveFromUncollected("mount", mountID)

    if not collectionStore.mount then collectionStore.mount = {} end
    local m = collectionStore.mount[mountID]
    local storeChanged = false
    if not m then
        local sourceText = ""
        if C_MountJournal.GetMountInfoExtraByID then
            local _, description, src = C_MountJournal.GetMountInfoExtraByID(mountID)
            if src and not (issecretvalue and issecretvalue(src)) then sourceText = src end
        end
        collectionStore.mount[mountID] = {
            id = mountID, name = name, icon = icon, source = sourceText, sourceType = sourceType, description = "",
            creatureDisplayID = nil, collected = true,
        }
        storeChanged = true
    else
        if m.collected ~= true then
            m.collected = true
            storeChanged = true
        end
        -- Backfill sourceType if missing (DB migration from pre-3.0 store entries)
        if sourceType and m.sourceType ~= sourceType then
            m.sourceType = sourceType
            storeChanged = true
        end
    end
    if storeChanged then
        self:SaveCollectionStore()
    end

    -- Dedup layers only gate NOTIFICATIONS — data update above always runs
    local skipNotification = false
    -- Repeatable (farmable) affects try-counter / drop DB only — never bypass permanent toast dedup
    -- or the same mount can notify again on every journal/login refresh.
    if Notify.WasAlreadyNotified("mount", mountID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ PERMANENT DEDUP: mount " .. mountID .. " (already notified)")
    elseif Notify.WasRecentlyShownByName(name) then
        skipNotification = true
        DebugPrint("|cffff8800[WN CollectionService]|r SKIP (name debounce): " .. name)
    elseif Notify.WasDetectedInBag("mount", mountID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ DUPLICATE BLOCKED: mount " .. name .. " (detected in bag before; 2h bag-detect window)")
    elseif Notify.WasRecentlyNotified("mount", mountID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ DUPLICATE BLOCKED: mount " .. name .. " (notified within 5s)")
    end

    if not skipNotification then
        Notify.MarkAsNotified("mount", mountID)
        Notify.MarkAsShownByName(name)
        Notify.MarkAsPermanentlyNotified("mount", mountID)

        -- "What a grind" chat line when cumulative drop probability > 70%.
        -- Honors hideTryCounterChat; gracefully no-ops when rate/itemID unknown.
        if ns.CollectibleSourceDB and ns.CollectibleSourceDB.GetCumulativeProbability
            and C_MountJournal and C_MountJournal.GetMountItemID then
            local okItem, mountItemID = pcall(C_MountJournal.GetMountItemID, mountID)
            if okItem and mountItemID and type(mountItemID) == "number" and mountItemID > 0 then
                local tries = (self.GetTryCount and self:GetTryCount("mount", mountID)) or 0
                local total = (tonumber(tries) or 0) + 1
                local cumP = ns.CollectibleSourceDB.GetCumulativeProbability(mountItemID, total)
                if cumP and cumP > 0.70 then
                    local hideChat = self.db and self.db.profile and self.db.profile.notifications
                        and self.db.profile.notifications.hideTryCounterChat
                    if not hideChat then
                        local itemLink
                        if GetItemInfo then
                            local _, link = GetItemInfo(mountItemID)
                            if link and link ~= "" then itemLink = link end
                        end
                        if not itemLink then
                            itemLink = (ns.UI_GetBrightHex and ns.UI_GetBrightHex() or "|cffeeeeee") .. "[" .. (name or "Mount") .. "]|r"
                        end
                        local L = ns.L
                        local fmt = (L and L["TRYCOUNTER_WHAT_A_GRIND"])
                            or "What a grind! %d attempts (expected ~%d%% to have it by now) for %s"
                        local pct = math.floor(cumP * 100 + 0.5)
                        local msg = "|cffff8800[WN-Grind]|r " .. string.format(fmt, total, pct, itemLink)
                        if ns.ChatOutput and ns.ChatOutput.SendTryCounterMessage then
                            ns.ChatOutput.SendTryCounterMessage(msg)
                        elseif self.Print then
                            self:Print(msg)
                        end
                    end
                end
            end
        end

        self:SendMessage(E.COLLECTIBLE_OBTAINED, {
            type = "mount",
            id = mountID,
            name = name,
            icon = icon,
            obtainedBy = Notify.CollectiblePayloadObtainedBy(),
        })
    end

    -- Fire data-update event so UI refreshes (even when notification is deduped).
    -- NOTE: Do NOT call InvalidateCollectionCache here — the incremental updates above
    -- (RemoveFromUncollected + direct store write) are correct and sufficient.
    -- InvalidateCollectionCache would clear collectionCache.owned.mounts={}, undoing
    -- the owned cache set we just did. WN_COLLECTION_UPDATED triggers the UI to
    -- invalidate its own display caches and refresh.
    if Constants and Constants.EVENTS and Constants.EVENTS.COLLECTION_UPDATED then
        self:SendMessage(Constants.EVENTS.COLLECTION_UPDATED, "mount")
    end

    DebugPrint("|cff00ff00[WN CollectionService]|r NEW MOUNT: " .. name .. (skipNotification and " (notification deduped)" or ""))
end

---Handle NEW_PET_ADDED event
---Fires when player learns a new battle pet
function WarbandNexus:OnNewPet(event, petGUID, retryCount)
    if not petGUID then return end
    retryCount = retryCount or 0

    local speciesID, customName, level, xp, maxXp, displayID, isFavorite, name, icon = C_PetJournal.GetPetInfoByPetID(petGUID)
    if issecretvalue then
        if speciesID and issecretvalue(speciesID) then speciesID = nil end
        if name and issecretvalue(name) then name = nil end
        if icon and issecretvalue(icon) then icon = nil end
    end

    if not speciesID or not name then
        -- Pet journal may not be ready immediately when NEW_PET_ADDED fires; retry with backoff (up to 5 tries, 0.8s apart)
        if retryCount < 5 then
            DebugPrint("|cffffcc00[WN CollectionService]|r OnNewPet: Data not ready for petGUID=" .. tostring(petGUID) .. ", retry " .. (retryCount + 1) .. "/5")
            C_Timer.After(0.8, function()
                self:OnNewPet(event, petGUID, retryCount + 1)
            end)
        else
            DebugPrint("|cffff0000[WN CollectionService]|r OnNewPet: Data unavailable after 5 retries for petGUID=" .. tostring(petGUID))
        end
        return
    end

    -- ALWAYS update collected status in store (data correctness must not depend on notification dedup)
    collectionCache.owned.pets[speciesID] = true
    self:RemoveFromUncollected("pet", speciesID)

    if not collectionStore.pet then collectionStore.pet = {} end
    local p = collectionStore.pet[speciesID]
    local storeChanged = false
    -- Existing sourceTypeIndex from prior scan; backfilled if a fresh scan ran (rare on real-time path).
    local sourceTypeIndex = ns._petSpeciesToSourceIndex and ns._petSpeciesToSourceIndex[speciesID] or (p and p.sourceTypeIndex)
    if not p then
        local sourceText = ""
        if C_PetJournal.GetPetInfoBySpeciesID then
            local _, _, _, _, src = C_PetJournal.GetPetInfoBySpeciesID(speciesID)
            if src and not (issecretvalue and issecretvalue(src)) then sourceText = src end
        end
        collectionStore.pet[speciesID] = {
            id = speciesID, name = name, icon = icon, source = sourceText, sourceTypeIndex = sourceTypeIndex, description = "",
            creatureDisplayID = displayID, collected = true,
        }
        storeChanged = true
    else
        if p.collected ~= true then
            p.collected = true
            storeChanged = true
        end
        if sourceTypeIndex and p.sourceTypeIndex ~= sourceTypeIndex then
            p.sourceTypeIndex = sourceTypeIndex
            storeChanged = true
        end
    end
    if storeChanged then
        self:SaveCollectionStore()
    end

    -- Dedup layers only gate NOTIFICATIONS — data update above always runs
    local skipNotification = false

    -- Wrapped journal pets: NEW_PET_ADDED can fire before unwrap — skip toast spam until unwrapped.
    if C_PetJournal.PetNeedsFanfare and petGUID and C_PetJournal.PetNeedsFanfare(petGUID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r SKIP: pet needs fanfare (wrapped) petGUID=" .. tostring(petGUID))
    end

    -- Prefer species name for display (stable); journal row name can be wrong during async journal loads.
    local notifyPetName = name
    if speciesID and C_PetJournal.GetPetInfoBySpeciesID then
        local sName = C_PetJournal.GetPetInfoBySpeciesID(speciesID)
        if sName and sName ~= "" and not (issecretvalue and issecretvalue(sName)) then
            notifyPetName = sName
        end
    end

    -- Duplicate pet species (2/3, 3/3 etc.) — skip notification but data is already updated
    local numOwned, limit = C_PetJournal.GetNumCollectedInfo(speciesID)
    if issecretvalue and numOwned and issecretvalue(numOwned) then numOwned = nil end
    if not skipNotification and numOwned and numOwned > 1 then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r SKIP: " .. notifyPetName .. " (" .. numOwned .. "/" .. (limit or 3) .. " owned) - not first acquisition")
    elseif not skipNotification and Notify.WasAlreadyNotified("pet", speciesID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ PERMANENT DEDUP: pet " .. notifyPetName .. " (already notified)")
    elseif not skipNotification and Notify.WasRecentlyShownByName(notifyPetName) then
        skipNotification = true
        DebugPrint("|cffff8800[WN CollectionService]|r SKIP (name debounce): " .. notifyPetName)
    elseif not skipNotification and Notify.WasDetectedInBag("pet", speciesID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ DUPLICATE BLOCKED: pet " .. notifyPetName .. " (detected in bag before; 2h bag-detect window)")
    elseif not skipNotification and Notify.IsPetNameBagCooldownActive(notifyPetName) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ DUPLICATE BLOCKED: pet " .. notifyPetName .. " (item-based bag detection)")
    elseif not skipNotification and Notify.WasRecentlyNotified("pet", speciesID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ DUPLICATE BLOCKED: pet " .. notifyPetName .. " (notified within 5s)")
    end

    if not skipNotification then
        Notify.MarkAsNotified("pet", speciesID)
        Notify.MarkAsShownByName(notifyPetName)
        Notify.MarkAsPermanentlyNotified("pet", speciesID)

        self:SendMessage(E.COLLECTIBLE_OBTAINED, {
            obtainedBy = Notify.CollectiblePayloadObtainedBy(),
            type = "pet",
            id = speciesID,
            name = notifyPetName,
            icon = icon
        })
    end

    -- Fire data-update event so UI refreshes (even when notification is deduped).
    -- NOTE: Do NOT call InvalidateCollectionCache here — incremental updates are sufficient.
    if Constants and Constants.EVENTS and Constants.EVENTS.COLLECTION_UPDATED then
        self:SendMessage(Constants.EVENTS.COLLECTION_UPDATED, "pet")
    end

    DebugPrint("|cff00ff00[WN CollectionService]|r NEW PET: " .. notifyPetName .. " (speciesID: " .. speciesID .. ")" .. (skipNotification and " (notification deduped)" or ""))
end

---Handle NEW_TOY_ADDED event
---Fires when player learns a new toy
function WarbandNexus:OnNewToy(event, itemID, _isFavorite, _retryCount)
    if not itemID then return end
    local retryCount = (type(_retryCount) == "number") and _retryCount or 0

    local success, name = pcall(GetItemInfo, itemID)
    if not success or not name then
        if retryCount < 3 then
            DebugPrint("|cffffcc00[WN CollectionService]|r OnNewToy: Data not ready for itemID=" .. itemID .. ", retry " .. (retryCount + 1) .. "/3")
            C_Timer.After(0.5, function()
                self:OnNewToy(event, itemID, nil, retryCount + 1)
            end)
        else
            DebugPrint("|cffff0000[WN CollectionService]|r OnNewToy: Data unavailable after 3 retries for itemID=" .. itemID)
        end
        return
    end

    local icon = GetItemIcon(itemID)

    -- ALWAYS update collected status in store (data correctness must not depend on notification dedup)
    collectionCache.owned.toys[itemID] = true
    self:RemoveFromUncollected("toy", itemID)

    if not collectionStore.toy then collectionStore.toy = {} end
    local t = collectionStore.toy[itemID]
    if not t then
        collectionStore.toy[itemID] = { id = itemID, name = name }
        self:SaveCollectionStore()
    end

    -- Dedup layers only gate NOTIFICATIONS — data update above always runs
    local skipNotification = false
    if Notify.WasAlreadyNotified("toy", itemID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ PERMANENT DEDUP: toy " .. itemID .. " (already notified)")
    elseif Notify.WasDetectedInBag("toy", itemID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ DUPLICATE BLOCKED: toy " .. name .. " (detected in bag before; 2h bag-detect window)")
    elseif Notify.WasRecentlyNotified("toy", itemID) then
        skipNotification = true
        DebugPrint("|cff888888[WN CollectionService]|r ✓ DUPLICATE BLOCKED: toy " .. name .. " (notified within 5s)")
    elseif Notify.WasRecentlyShownByName(name) then
        skipNotification = true
        DebugPrint("|cffff8800[WN CollectionService]|r SKIP (name debounce): " .. name)
    end

    if not skipNotification then
        Notify.MarkAsNotified("toy", itemID)
        Notify.MarkAsShownByName(name)
        Notify.MarkAsPermanentlyNotified("toy", itemID)

        self:SendMessage(E.COLLECTIBLE_OBTAINED, {
            type = "toy",
            id = itemID,
            name = name,
            icon = icon,
            obtainedBy = Notify.CollectiblePayloadObtainedBy(),
        })
    end

    -- Fire data-update event so UI refreshes (even when notification is deduped).
    -- NOTE: Do NOT call InvalidateCollectionCache here — incremental updates are sufficient.
    -- The fast-path map (_toyGroupedFromMapValid gate) must drop too; clearing only the
    -- fallback cache kept serving a source index that lacked the new toy.
    if ns._toyItemIDToSourceIndexCache then ns._toyItemIDToSourceIndexCache.map = nil end
    ns._toyGroupedFromMapValid = false
    if Constants and Constants.EVENTS and Constants.EVENTS.COLLECTION_UPDATED then
        self:SendMessage(Constants.EVENTS.COLLECTION_UPDATED, "toy")
    end

    DebugPrint("|cff00ff00[WN CollectionService]|r NEW TOY: " .. name .. (skipNotification and " (notification deduped)" or ""))
end

---Handle TRANSMOG_COLLECTION_UPDATED event
---Fires when transmog collection changes (including illusions)
---We need to detect which illusion was added by comparing before/after
function WarbandNexus:OnTransmogCollectionUpdated(event)
    if not C_TransmogCollection or not C_TransmogCollection.GetIllusions then return end
    
    -- Throttle checks to avoid spam (illusions are rare)
    if self._lastIllusionCheck and (GetTime() - self._lastIllusionCheck) < 2 then
        return
    end
    self._lastIllusionCheck = GetTime()
    
    -- Get current illusion state
    local illusions = C_TransmogCollection.GetIllusions()
    if not illusions then return end
    
    -- Build current collected set
    local currentCollected = {}
    for i = 1, #illusions do
        local illusionInfo = illusions[i]
        if illusionInfo and illusionInfo.visualID and illusionInfo.isCollected then
            currentCollected[illusionInfo.visualID] = illusionInfo
        end
    end
    
    -- Initialize previous state if not exists
    if not self._previousIllusionState then
        self._previousIllusionState = currentCollected
        return
    end
    
    -- Compare and find newly collected illusions
    local newIllusionLearned = false
    for visualID, illusionInfo in pairs(currentCollected) do
        if not self._previousIllusionState[visualID] then
            newIllusionLearned = true
            -- NEW ILLUSION COLLECTED!
            local name = illusionInfo.name
            
            -- Try spell name if no name
            if (not name or name == "") and illusionInfo.spellID then
                local spellName = C_Spell and C_Spell.GetSpellName(illusionInfo.spellID)
                if spellName and spellName ~= "" then
                    name = spellName
                end
            end
            
            -- Fallback to visualID
            if not name or name == "" then
                name = ((ns.L and ns.L["TYPE_ILLUSION"]) or "Illusion") .. " " .. visualID
            end
            
            local icon = illusionInfo.icon or 134400
            
            -- Remove from uncollected cache if present
            self:RemoveFromUncollected("illusion", visualID)
            
            -- Persistent dedup (survives logout)
            Notify.MarkAsPermanentlyNotified("illusion", visualID)
            
            -- Fire notification event
            self:SendMessage(E.COLLECTIBLE_OBTAINED, {
                type = "illusion",
                id = visualID,
                name = name,
                icon = icon,
                obtainedBy = Notify.CollectiblePayloadObtainedBy(),
            })
            
    DebugPrint("|cff00ff00[WN CollectionService]|r NEW ILLUSION: " .. name .. " (ID: " .. visualID .. ")")
        end
    end

    if newIllusionLearned and ns.InvalidateIllusionLookupCache then
        ns.InvalidateIllusionLookupCache()
    end

    -- Update previous state
    self._previousIllusionState = currentCollected
end

---Remove Collections → Recent entries older than Constants.COLLECTIONS_RECENT_RETENTION_SEC (newest-first list).
function WarbandNexus:PruneCollectionsRecentObtained()
    if not self.db or not self.db.global then return end
    local list = self.db.global.collectionsRecentObtained
    if type(list) ~= "table" then return end
    local retention = (Constants and Constants.COLLECTIONS_RECENT_RETENTION_SEC) or 604800
    local cutoff = time() - retention
    for i = #list, 1, -1 do
        local e = list[i]
        local et = e and e.t
        if type(et) ~= "number" or et < cutoff then
            table.remove(list, i)
        end
    end
end

---Remove all Collections → Recent rows for one collectible type (Recent tab per-card reset).
function WarbandNexus:ClearCollectionsRecentObtainedForType(collectibleType)
    if not collectibleType or not self.db or not self.db.global then return end
    local list = self.db.global.collectionsRecentObtained
    if type(list) ~= "table" then return end
    for i = #list, 1, -1 do
        local e = list[i]
        if e and e.type == collectibleType then
            table.remove(list, i)
        end
    end
end

---Persist newest collectible acquisitions for the Collections tab "Recently obtained" strip.
function WarbandNexus:AppendCollectionsRecentObtained(data)
    if not data or type(data) ~= "table" or not data.type then return end
    local allowed = {
        mount = true, pet = true, toy = true, achievement = true, title = true, illusion = true, transmog = true,
    }
    if not allowed[data.type] then return end
    if data.id == nil then return end
    if not self.db or not self.db.global then return end
    local list = self.db.global.collectionsRecentObtained
    if type(list) ~= "table" then
        list = {}
        self.db.global.collectionsRecentObtained = list
    end
    local displayName = data.name
    if (not displayName or displayName == "") and data.type == "achievement" then
        displayName = (ns.L and ns.L["HIDDEN_ACHIEVEMENT"]) or "Hidden Achievement"
    end
    if not displayName or displayName == "" then return end
    -- Achievements: only first account completion belongs in Recent (alt re-earn / prior store completion).
    if data.type == "achievement" and data.accountFirstEarn == false then
        return
    end
    -- One Recent row per type+id (account-wide collectibles; stops relog/alt duplicate lines).
    local priorTs = self:GetCollectionsAcquiredAt(data.type, data.id)
    if priorTs ~= nil then
        return
    end
    for i = 1, #list do
        local e = list[i]
        if e and e.type == data.type and e.id == data.id then
            return
        end
    end
    local now = time()
    local first = list[1]
    if first and first.type == data.type and first.id == data.id and (now - (first.t or 0)) < 5 then
        return
    end
    local accountFirstEarn = true
    if data.type == "achievement" then
        accountFirstEarn = (data.accountFirstEarn ~= false)
    end
    table.insert(list, 1, {
        t = now,
        type = data.type,
        id = data.id,
        name = displayName,
        obtainedBy = data.obtainedBy,
        accountFirstEarn = accountFirstEarn,
    })
    self:PruneCollectionsRecentObtained()

    local lastRoot = self.db.global.collectionsLastObtained
    if type(lastRoot) ~= "table" then
        lastRoot = {}
        self.db.global.collectionsLastObtained = lastRoot
    end
    local bucket = lastRoot[data.type]
    if type(bucket) ~= "table" then
        bucket = {}
        lastRoot[data.type] = bucket
    end
    bucket[data.id] = now
end

---Unix time when the addon last recorded this collectible as obtained (WN_COLLECTIBLE_OBTAINED), or nil.
---@return number|nil
function WarbandNexus:GetCollectionsAcquiredAt(collectibleType, id)
    if not collectibleType or id == nil then return nil end
    if not self.db or not self.db.global then return nil end
    local root = self.db.global.collectionsLastObtained
    if type(root) ~= "table" then return nil end
    local b = root[collectibleType]
    if type(b) ~= "table" then return nil end
    local t = b[id]
    if type(t) ~= "number" or t <= 0 then return nil end
    return t
end

-- Invalidate API counts cache so Statistics and Collections show same numbers after any collection change.
-- COLLECTION_UPDATED sends (message, collectionType string). COLLECTIBLE_OBTAINED passes payload table { type = ... }.
-- Must be declared before WN_COLLECTIBLE_OBTAINED RegisterMessage below: Lua 5.1 local function scope starts after this `end`.
local function InvalidateCollectionCountsCache(_, arg)
    -- Table arg = COLLECTIBLE_OBTAINED payload (a confirmed +1); string arg =
    -- COLLECTION_UPDATED (journal refresh, counts may not have changed) → full wipe.
    local ctype = arg
    local isObtain = false
    if type(arg) == "table" then
        ctype = arg.type
        isObtain = true
    end
    if WarbandNexus.InvalidateCollectionCountsAPICache then
        WarbandNexus:InvalidateCollectionCountsAPICache(isObtain and ctype or nil)
    end
    WipeUncollectedResultsCacheAndMergedAchievements()
    -- Toy source lazy cache (GetToySourceTypeIndexForItem) must rebuild after collection changes
    ns._toyItemIDToSourceIndexCache = nil
    ns._toyGroupedFromMapValid = false
    ns._mountGroupedFromMapValid = false
    ns._mountIDToSourceIndex = nil
    -- Pet journal source classification: clear on full invalidation or pet-specific updates (P1 staleness)
    if ctype == nil or ctype == "pet" then
        ns._petSpeciesToSourceIndex = nil
    end
    if ctype == nil or ctype == "mount" then
        ns._mountGroupedFromMapValid = false
        ns._mountIDToSourceIndex = nil
    end
end

-- Dedicated listener key for CollectionService message handlers.
-- AceEvent allows only ONE handler per (event, self) pair; using WarbandNexus as self
-- would be overwritten by CollectionsUI handlers (or vice versa).
local CSListeners = {}

-- Single WN_COLLECTIBLE_OBTAINED handler: two prior registrations on CSListeners overwrote each other
-- (AceEvent table is keyed by self). Order: uncollected prune -> API/uncollected caches -> recent ring -> UI broadcast.
if E and E.COLLECTIBLE_OBTAINED then
    WarbandNexus.RegisterMessage(CSListeners, E.COLLECTIBLE_OBTAINED, function(_, data)
        if not data or not data.type then return end
        local t = data.type
        local id = data.id
        if id and (t == "mount" or t == "pet" or t == "toy") then
            WarbandNexus:RemoveFromUncollected(t, id)
            if data.fromTryCounter then
                Notify.MarkAsNotified(t, id)
                if data.name then Notify.MarkAsShownByName(data.name) end
                Notify.MarkAsPermanentlyNotified(t, id)
            end
        end
        InvalidateCollectionCountsCache(_, data)
        WarbandNexus:AppendCollectionsRecentObtained(data)
        if id and (t == "mount" or t == "pet" or t == "toy") and E.COLLECTION_UPDATED then
            WarbandNexus:SendMessage(E.COLLECTION_UPDATED, t)
        end
    end)
end

-- Register real-time collection events
-- Bag scan handles ALL collectible detection (mount/pet/toy)
-- Real-time collection events: fire when mount/pet/toy is learned (from quests, drops, vendors, etc.)
-- Duplicate prevention: Multi-layer debounce (name, ID, bag-detection) prevents double notifications.
WarbandNexus:RegisterEvent("NEW_MOUNT_ADDED", "OnNewMount")
WarbandNexus:RegisterEvent("NEW_PET_ADDED", "OnNewPet")
WarbandNexus:RegisterEvent("NEW_TOY_ADDED", "OnNewToy")

WarbandNexus.RegisterMessage(CSListeners, Constants.EVENTS.COLLECTION_UPDATED, InvalidateCollectionCountsCache)
WarbandNexus.RegisterMessage(CSListeners, Constants.EVENTS.COLLECTION_SCAN_COMPLETE, function()
    WipeUncollectedResultsCacheAndMergedAchievements()
    WarbandNexus:SendMessage(Constants.EVENTS.ACHIEVEMENT_CATEGORY_CACHE_INVALIDATED)
end)

---Force mount/pet/toy refetch without debug mode (lighter than rebuild full). Clears those SV maps and schedules EnsureCollectionData.
function WarbandNexus:RequestCollectionDataRefreshForce()
    if ns.CollectionLoadingState and ns.CollectionLoadingState.isLoading then
        CollectionUserPrint(self, "|cffff8800[WN]|r Collection pipeline already running; skipped. |cff00ccff/wn collection status|r for stage.|r")
        return
    end
    if not self.db or not self.db.global then
        CollectionUserPrint(self, "|cffff6600[WN Collection]|r DB not ready.|r")
        return
    end
    local function wipeMap(t)
        if not t then return end
        for k in pairs(t) do
            t[k] = nil
        end
    end
    wipeMap(collectionStore.mount)
    wipeMap(collectionStore.pet)
    wipeMap(collectionStore.toy)
    local dbStore = self.db.global.collectionStore
    if dbStore then
        dbStore.version = "__WN_FORCE_RESYNC__"
        wipeMap(dbStore.mount)
        wipeMap(dbStore.pet)
        wipeMap(dbStore.toy)
    end
    self:InvalidateCollectionCache(nil)
    InvalidateCollectionCountsCache(nil, nil)
    if ns.ScheduleEnsureCollectionDataDeferred then
        ns.ScheduleEnsureCollectionDataDeferred()
    elseif self.EnsureCollectionData then
        self:EnsureCollectionData()
    end
    CollectionUserPrint(self, "|cff00ccff[WN]|r Collection rescan scheduled (mounts, pets, toys).|r")
end

--- DEBUG ONLY (gated by CommandService + IsDebugModeEnabled): simulate a collectionStore version bump and re-fetch mount/pet/toy for profiler runs.
function WarbandNexus:DebugForceCollectionRebuild(full)
    full = full == true
    if not self.db or not self.db.global then
        CollectionUserPrint(self, "|cffff6600[WN Collection]|r DB not ready.|r")
        return
    end
    local function wipeMap(t)
        if not t then return end
        for k in pairs(t) do
            t[k] = nil
        end
    end
    wipeMap(collectionStore.mount)
    wipeMap(collectionStore.pet)
    wipeMap(collectionStore.toy)
    if full then
        wipeMap(collectionStore.achievement)
        wipeMap(collectionStore.title)
        wipeMap(collectionStore.illusion)
    end
    local dbStore = self.db.global.collectionStore
    if dbStore then
        dbStore.version = "__WN_DEBUG_FORCE_REBUILD__"
        wipeMap(dbStore.mount)
        wipeMap(dbStore.pet)
        wipeMap(dbStore.toy)
        if full then
            wipeMap(dbStore.achievement)
            wipeMap(dbStore.title)
            wipeMap(dbStore.illusion)
        end
    end
    self:InvalidateCollectionCache(nil)
    InvalidateCollectionCountsCache(nil, nil)
    CollectionUserPrint(self, "|cff00ccff[WN Collection]|r Debug rebuild started (mount/pet/toy" ..
        (full and " + achievements/titles/illusions" or "") ..
        "). Use |cff00ccff/wn profiler on|r then open Collections.|r")
    self:EnsureCollectionData(function()
        CollectionUserPrint(self, "|cff00ff00[WN Collection]|r Debug rebuild finished.|r")
    end)
end

---Handle ACHIEVEMENT_EARNED event
---Removes completed achievement from cache and handles chained achievements
function WarbandNexus:OnAchievementEarned(event, achievementID)
    if not achievementID then return end
    
    DebugPrint("|cff00ff00[WN CollectionService]|r Achievement earned: " .. achievementID)
    
    -- Remove completed achievement from cache
    if collectionCache.uncollected.achievement and collectionCache.uncollected.achievement[achievementID] then
        collectionCache.uncollected.achievement[achievementID] = nil
    DebugPrint("|cff9370DB[WN CollectionService]|r Removed completed achievement " .. achievementID .. " from cache")
    end

    -- Mark as collected in store so Collections UI and GetAllAchievementsData show it as completed
    if not collectionStore.achievement then collectionStore.achievement = {} end
    local prevAchEntry = collectionStore.achievement[achievementID]
    local priorAchievementCollected = prevAchEntry and prevAchEntry.collected == true
    local entry = prevAchEntry
    if not entry then
        collectionStore.achievement[achievementID] = { collected = true }
    else
        entry.collected = true
    end
    self:SaveCollectionStore()

    -- Check for chained/superceding achievements (e.g., 5/5 → 0/10)
    -- GetSupercedingAchievements returns the next achievement in the chain
    if C_AchievementInfo and C_AchievementInfo.GetSupercedingAchievements then
        if not (issecretvalue and achievementID and issecretvalue(achievementID)) then
            local okSup, supercedingAchievements = pcall(C_AchievementInfo.GetSupercedingAchievements, achievementID)
            if okSup and supercedingAchievements and type(supercedingAchievements) == "table" and #supercedingAchievements > 0 then
                for i = 1, #supercedingAchievements do
                    local nextAchievementID = supercedingAchievements[i]
                    if nextAchievementID and not (issecretvalue and issecretvalue(nextAchievementID)) then
                        -- Get achievement info with pcall protection
                        local success, id, name, points, completed, month, day, year, description, flags, icon = pcall(GetAchievementInfo, nextAchievementID)
                        local completedIsSecret = completed and issecretvalue and issecretvalue(completed)
                        if success and name and not (issecretvalue and issecretvalue(name))
                            and not completedIsSecret and completed ~= true then
    DebugPrint("|cffffcc00[WN CollectionService]|r Found chained achievement: " .. tostring(name) .. " (ID: " .. tostring(nextAchievementID) .. ")")
                    
                    -- Get category ID
                    local categoryID
                    if GetAchievementCategory then
                        local okCat, cat = pcall(GetAchievementCategory, nextAchievementID)
                        if okCat and cat and not (issecretvalue and issecretvalue(cat)) then
                            categoryID = cat
                        end
                    end
                    
                    -- Get reward info (with pcall)
                    local rewardItemID, rewardTitle
                    local rewardSuccess, item, title = pcall(GetAchievementReward, nextAchievementID)
                    if rewardSuccess then
                        if type(item) == "number" and not (issecretvalue and issecretvalue(item)) then
                            rewardItemID = item
                        end
                        if type(title) == "string" and title ~= "" and not (issecretvalue and issecretvalue(title)) then
                            rewardTitle = title
                        elseif type(item) == "string" and item ~= "" and not (issecretvalue and issecretvalue(item)) then
                            rewardTitle = item
                        end
                    end
                    
                    local safeDesc = description
                    if safeDesc and issecretvalue and issecretvalue(safeDesc) then safeDesc = nil end
                    
                    -- Add new chained achievement to cache directly (no full re-scan needed!)
                    if not collectionCache.uncollected.achievement then
                        collectionCache.uncollected.achievement = {}
                    end
                    
                    local safePoints = points
                    if safePoints and issecretvalue and issecretvalue(safePoints) then safePoints = nil end
                    local safeIcon = (icon and not (issecretvalue and issecretvalue(icon))) and icon or nil
                    collectionCache.uncollected.achievement[nextAchievementID] = {
                        id = nextAchievementID,
                        name = name,
                        points = safePoints,
                        description = safeDesc,
                        icon = safeIcon,
                        type = "achievement",
                        rewardItemID = rewardItemID,
                        rewardTitle = rewardTitle,
                        categoryID = categoryID
                    }

                    -- Store-driven Plans/Collections read collectionStore, not the legacy
                    -- cache; without this record the chained achievement stays invisible
                    -- until the next full achievement scan.
                    if not collectionStore.achievement[nextAchievementID] then
                        collectionStore.achievement[nextAchievementID] = {
                            id = nextAchievementID,
                            name = name,
                            icon = safeIcon,
                            points = safePoints,
                            description = safeDesc,
                            collected = false,
                            categoryID = categoryID,
                            rewardItemID = rewardItemID,
                            rewardTitle = rewardTitle,
                        }
                        self:SaveCollectionStore()
                    end

    DebugPrint("|cff00ff00[WN CollectionService]|r Added chained achievement to cache: " .. tostring(name))
                    
                    -- Trigger UI refresh (no cache invalidation needed!)
                    if self.SendMessage then
                        self:SendMessage(E.COLLECTION_UPDATED, "achievement")
                    end
                    
                    break -- Only need to process one chained achievement
                end
                    end
                end
            end
        end
    end
    
    -- Notify UI so Collections tab updates immediately (achievement earned).
    if Constants and Constants.EVENTS and Constants.EVENTS.COLLECTION_UPDATED then
        self:SendMessage(Constants.EVENTS.COLLECTION_UPDATED, "achievement")
    end
    -- Emit collectible toast only for a genuinely new completion (store already had .collected skips spam).
    -- AddAlert hook may still run; MarkAsNotified here prevents duplicate toast when priorCollected.
    -- Toast: Replace mode uses AddAlert replacement; classic mode uses Blizzard AddAlert only.
    local replaceAlerts = self.db and self.db.profile and self.db.profile.notifications
        and self.db.profile.notifications.hideBlizzardAchievementAlert
    if priorAchievementCollected then
        Notify.MarkAsNotified("achievement", achievementID)
        Notify.MarkAsPermanentlyNotified("achievement", achievementID)
        DebugPrint("|cff888888[WN CollectionService]|r Skip achievement toast (already completed in collection store)")
    elseif replaceAlerts then
        Notify.MarkAsNotified("achievement", achievementID)
        Notify.MarkAsPermanentlyNotified("achievement", achievementID)
        DebugPrint("|cff888888[WN CollectionService]|r Skip achievement toast (AddAlert replacement handles popup)")
    elseif not Notify.WasRecentlyNotified("achievement", achievementID) then
        Notify.MarkAsNotified("achievement", achievementID)
        local ok, _aid, achName, achPoints, _c, _m, _d, _y, _desc, _flags, achIcon = pcall(GetAchievementInfo, achievementID)
        if not ok then achName = nil; achIcon = nil; achPoints = nil end
        if issecretvalue then
            if achName and issecretvalue(achName) then achName = nil end
            if achIcon and issecretvalue(achIcon) then achIcon = nil end
            if achPoints and issecretvalue(achPoints) then achPoints = nil end
        end
        local displayName = achName or ((ns.L and ns.L["HIDDEN_ACHIEVEMENT"]) or "Hidden Achievement")
        local displayIcon = achIcon
        Notify.MarkAsPermanentlyNotified("achievement", achievementID)
        -- Classic mode (Replace OFF): Blizzard's own popup is the visible alert; the
        -- message still feeds try-counter/recent-ring/caches but must not ALSO toast
        -- (it double-popped). suppressToast skips only the popup in NotificationManager.
        self:SendMessage(E.COLLECTIBLE_OBTAINED, {
            type = "achievement",
            id = achievementID,
            name = displayName,
            icon = displayIcon,
            achievementPoints = (type(achPoints) == "number" and achPoints > 0) and achPoints or nil,
            obtainedBy = Notify.CollectiblePayloadObtainedBy(),
            accountFirstEarn = true,
            suppressToast = true,
        })
    end
end

---Called from AddAlert hook when "Replace Achievement Popup" is on. Builds payload, marks notified, sends WN_COLLECTIBLE_OBTAINED.
---@return boolean shown false when the WN toast will NOT appear — the AddAlert hook then falls back to the Blizzard popup
function WarbandNexus:ShowAchievementNotification(achievementID)
    if not achievementID or type(achievementID) ~= "number" then return false end
    if issecretvalue and issecretvalue(achievementID) then return false end
    -- Notifications master switch off: WN toast can never appear; tell the hook to
    -- show Blizzard's popup instead of swallowing the alert entirely.
    local notif = self.db and self.db.profile and self.db.profile.notifications
    if not notif or notif.enabled == false then return false end
    -- Recently shown by WN: report handled (true) — falling back to Blizzard here
    -- would double-pop the same achievement from the second alert system.
    if Notify.WasRecentlyNotified("achievement", achievementID) then return true end
    Notify.MarkAsNotified("achievement", achievementID)
    local ok, _aid, achName, achPoints, _c, _m, _d, _y, _desc, _flags, achIcon = pcall(GetAchievementInfo, achievementID)
    if not ok then achName = nil; achIcon = nil; achPoints = nil end
    if issecretvalue then
        if achName and issecretvalue(achName) then achName = nil end
        if achIcon and issecretvalue(achIcon) then achIcon = nil end
        if achPoints and issecretvalue(achPoints) then achPoints = nil end
    end
    local displayName = achName
    local displayIcon = achIcon
    if not displayName or displayName == "" then
        displayName = (ns.L and ns.L["HIDDEN_ACHIEVEMENT"]) or "Hidden Achievement"
        displayIcon = nil
    end
    Notify.MarkAsPermanentlyNotified("achievement", achievementID)
    local accountFirstEarn = true
    local okEarn, _, _, _, completed, _, _, _, _, _, _, _, wasEarnedByMe = pcall(GetAchievementInfo, achievementID)
    if okEarn and completed == true and wasEarnedByMe == false then
        accountFirstEarn = false
    end
    self:SendMessage(E.COLLECTIBLE_OBTAINED, {
        type = "achievement",
        id = achievementID,
        name = displayName,
        icon = displayIcon,
        achievementPoints = (type(achPoints) == "number" and achPoints > 0) and achPoints or nil,
        obtainedBy = Notify.CollectiblePayloadObtainedBy(),
        accountFirstEarn = accountFirstEarn,
    })
    return true
end

-- Register achievement earned event for cache invalidation
-- Must be AFTER function definition
WarbandNexus:RegisterEvent("ACHIEVEMENT_EARNED", "OnAchievementEarned")

-- REAL-TIME COLLECTION DETECTION

---Check if an item is a NEW collectible that player doesn't own
---Used for real-time detection (inventory/bank scanning)
---@return table|nil {type, id, name, icon} or nil if not a new collectible
function WarbandNexus:CheckNewCollectible(itemID, hyperlink)
    if not itemID then return nil end
    
    -- FAST PATH: Use GetItemInfoInstant first (non-blocking, cache-only) to get classID/subclassID.
    -- This avoids calling the potentially blocking GetItemInfo for non-collectible items.
    -- Collectibles can be classID 0 (Consumable - vendor toys/elixirs),
    -- 15 (Miscellaneous - most mounts/toys), or 17 (Battle Pet).
    local _, _, _, _, _, instantClassID, instantSubclassID = GetItemInfoInstant(itemID)
    if instantClassID and instantClassID ~= 0 and instantClassID ~= 15 and instantClassID ~= 17 then
        return nil  -- Definitely not a collectible (weapon, armor, reagent, etc.)
    end
    
    -- Get full item info (may block briefly if not cached, but we've narrowed the set)
    local GetItemInfoFn = C_Item and C_Item.GetItemInfo or GetItemInfo
    local itemName, _, _, _, _, _, _, _, _, itemIcon, _, classID, subclassID = GetItemInfoFn(itemID)
    if not classID then
        -- Fallback: use instant values if GetItemInfo hasn't cached yet
        if instantClassID then
            classID = instantClassID
            subclassID = instantSubclassID
        else
            if C_Item and C_Item.RequestLoadItemDataByID then
                C_Item.RequestLoadItemDataByID(itemID)
            end
            return nil
        end
    end
    
    -- MOUNT (classID 15, subclass 5)
    if classID == 15 and subclassID == 5 then
        local result = self:_DetectMount(itemID, itemName, itemIcon)
        if result then return result end
    end
    
    -- PETS (Battle Pets: classID 17, Companion Pets: classID 15/subclass 2)
    if classID == 17 or (classID == 15 and subclassID == 2) then
        local result = self:_DetectPet(itemID, hyperlink, itemName, itemIcon, classID, subclassID)
        if result then return result end
    end
    
    -- TOY (classID 15/subclass 0, or classID 0 - Consumable-type toys like vendor elixirs)
    if (classID == 15 and subclassID == 0) or classID == 0 then
        local result = self:_DetectToy(itemID, itemName, itemIcon)
        if result then return result end
    end
    
    -- FALLBACK: classID 15/0 items that didn't match specific branches
    -- Some vendor pets/mounts have unexpected subclassIDs (e.g., subclass 4 "Other")
    -- Only check items that have a "Use:" spell (skips junk like Spare Parts, Pet Charms)
    if (classID == 15 or classID == 0) and subclassID ~= 5 and subclassID ~= 2 then
        -- Only try fallback if the item is DIRECTLY convertible to a collectible
        -- Check if any collection API recognizes this item (skip generic "Use:" items)
        local isMountItem = C_MountJournal and C_MountJournal.GetMountFromItem and C_MountJournal.GetMountFromItem(itemID)
        local isToyItem = C_ToyBox and C_ToyBox.GetToyInfo and C_ToyBox.GetToyInfo(itemID)
        
        if isMountItem or isToyItem then
            DebugPrint("|cffffcc00[WN CollectionService]|r Fallback detection for classID=15, subclassID=" .. tostring(subclassID) .. " itemID=" .. itemID)
            
            if isMountItem then
                local mountResult = self:_DetectMount(itemID, itemName, itemIcon)
                if mountResult then return mountResult end
            end
            
            if isToyItem then
                local toyResult = self:_DetectToy(itemID, itemName, itemIcon)
                if toyResult then return toyResult end
            end
            
            -- Try pet detection as last resort
            local petResult = self:_DetectPet(itemID, hyperlink, itemName, itemIcon, classID, subclassID)
            if petResult then return petResult end
        end
    end
    
    -- FALLBACK 2: classID 15 subclass 2 where GetPetInfoByItemID failed
    -- Try hyperlink-based detection as alternative
    if classID == 15 and subclassID == 2 and hyperlink then
        -- GetPetInfoByItemID failed in the pet branch above
        -- Try tooltip-based detection via C_TooltipInfo
        local speciesID = self:_DetectPetFromTooltip(itemID)
        if speciesID then
            local result = self:_BuildPetResult(speciesID, itemName, itemIcon)
            if result then return result end
        end
    end
    
    return nil
end

-- DETECTION HELPERS (DRY extraction from CheckNewCollectible)

---Try to detect a mount from itemID
---@return table|nil
function WarbandNexus:_DetectMount(itemID, itemName, itemIcon)
    if not C_MountJournal or not C_MountJournal.GetMountFromItem then
        return nil
    end
    
    local mountID = C_MountJournal.GetMountFromItem(itemID)
    if not mountID then return nil end
    
    -- Check if already owned
    local name, _, icon, _, _, _, _, _, _, _, isCollected = C_MountJournal.GetMountInfoByID(mountID)
    if not name or isCollected then return nil end
    
    -- DUPLICATE PREVENTION
    if Notify.WasDetectedInBag("mount", mountID) then return nil end
    if Notify.WasRecentlyShownByName(name) then return nil end
    
    DebugPrint("|cff00ff00[WN CollectionService]|r NEW MOUNT DETECTED: " .. name .. " (ID: " .. mountID .. ")")
    return {
        type = "mount",
        id = mountID,
        name = name,
        icon = icon
    }
end

---Try to detect a pet from itemID/hyperlink using multiple methods
---@return table|nil
function WarbandNexus:_DetectPet(itemID, hyperlink, itemName, itemIcon, classID, subclassID)
    if not C_PetJournal then return nil end
    
    local speciesID = nil
    local speciesName = nil
    local speciesIcon = nil
    
    -- Method 1: Battle Pet Cage (classID 17) - extract speciesID from hyperlink
    if classID == 17 and hyperlink and type(hyperlink) == "string" and not (issecretvalue and issecretvalue(hyperlink)) then
        speciesID = tonumber(hyperlink:match("|Hbattlepet:(%d+):"))
        if speciesID then
            speciesName, speciesIcon = C_PetJournal.GetPetInfoBySpeciesID(speciesID)
        end
    end
    
    -- Method 2: Companion Pet Item - use C_PetJournal.GetPetInfoByItemID
    -- Return signature is (name, icon, petType, creatureID, sourceText, description,
    --   isWild, canBattle, tradeable, unique, obtainable, displayID, speciesID)
    -- speciesID is the 13th return value, NOT the 1st!
    if not speciesID and C_PetJournal.GetPetInfoByItemID then
        local pName, pIcon, _, _, _, _, _, _, _, _, _, _, pSpeciesID = C_PetJournal.GetPetInfoByItemID(itemID)
        if pSpeciesID and type(pSpeciesID) == "number" then
            speciesID = pSpeciesID
            speciesName = pName
            speciesIcon = pIcon
        end
    end
    
    -- Method 3: Try tooltip-based detection if above methods failed
    if not speciesID or type(speciesID) ~= "number" then
        speciesID = self:_DetectPetFromTooltip(itemID)
        if speciesID then
            speciesName, speciesIcon = C_PetJournal.GetPetInfoBySpeciesID(speciesID)
        end
    end
    
    -- If speciesID found, use full species-aware detection
    if speciesID and type(speciesID) == "number" then
        return self:_BuildPetResult(speciesID, speciesName or itemName, speciesIcon or itemIcon)
    end
    
    -- Method 4: ITEM-BASED FALLBACK
    -- C_PetJournal.GetPetInfoByItemID doesn't support all items (API limitation).
    -- For confirmed companion pet items (classID=15, subclass=2), verify via GetItemSpell
    -- and use item info (name/icon) for the notification instead of species info.
    -- The NEW_PET_ADDED event will handle the authoritative collection detection when learned.
    if classID == 15 and subclassID == 2 then
        -- C_Item.GetItemSpell (10.2.5+) with global fallback for older API signatures
        local getItemSpell = (C_Item and C_Item.GetItemSpell) or _G.GetItemSpell
        local spellName, spellID = nil, nil
        if getItemSpell then
            spellName, spellID = getItemSpell(itemID)
        end
        if spellName and spellID then
    DebugPrint("|cffffcc00[WN CollectionService]|r Pet item fallback: GetPetInfoByItemID failed, using item info. spell=" .. tostring(spellName) .. " spellID=" .. tostring(spellID))
            
            local petName = itemName or ((ns.L and ns.L["FALLBACK_UNKNOWN_PET"]) or "Unknown Pet")
            
            -- DUPLICATE PREVENTION (use item-based key since we don't have speciesID)
            if Notify.WasDetectedInBag("pet_item", itemID) then return nil end
            if Notify.WasRecentlyShownByName(petName) then return nil end
            
    DebugPrint("|cff00ff00[WN CollectionService]|r NEW PET DETECTED (item fallback): " .. petName .. " (itemID: " .. itemID .. ")")
            return {
                type = "pet",
                id = itemID,  -- Use itemID since speciesID unavailable
                name = petName,
                icon = itemIcon or 134400
            }
        end
    end
    
    return nil
end

---Build pet result after speciesID is confirmed
---@return table|nil
function WarbandNexus:_BuildPetResult(speciesID, fallbackName, fallbackIcon)
    if not C_PetJournal then return nil end
    
    -- Get species info if not already available
    local speciesName, speciesIcon = C_PetJournal.GetPetInfoBySpeciesID(speciesID)
    local petName = speciesName or fallbackName or ((ns.L and ns.L["FALLBACK_UNKNOWN_PET"]) or "Unknown Pet")
    local petIcon = speciesIcon or fallbackIcon or 134400
    
    -- Check if player already owns this species (only notify for 0/3 → first acquisition)
    local numOwned = C_PetJournal.GetNumCollectedInfo(speciesID)
    if numOwned and numOwned > 0 then
        return nil  -- Already collected
    end
    
    -- DUPLICATE PREVENTION
    if Notify.WasDetectedInBag("pet", speciesID) then return nil end
    if Notify.WasRecentlyShownByName(petName) then return nil end
    
    DebugPrint("|cff00ff00[WN CollectionService]|r NEW PET DETECTED: " .. petName .. " (speciesID: " .. speciesID .. ")")
    return {
        type = "pet",
        id = speciesID,
        name = petName,
        icon = petIcon
    }
end

---Try to extract speciesID from item tooltip data
---Uses C_TooltipInfo API to check for battle pet markers in tooltip
---@return number|nil speciesID
function WarbandNexus:_DetectPetFromTooltip(itemID)
    -- Method: C_TooltipInfo.GetItemByID returns structured tooltip data
    if not C_TooltipInfo or not C_TooltipInfo.GetItemByID then
        return nil
    end
    
    local success, tooltipData = pcall(C_TooltipInfo.GetItemByID, itemID)
    if not success or not tooltipData or not tooltipData.lines then
        return nil
    end
    
    -- Look for battlepet hyperlink in tooltip lines
    for i = 1, #tooltipData.lines do
        local line = tooltipData.lines[i]
        if line and line.leftText then
            local lineText = line.leftText
            if issecretvalue and issecretvalue(lineText) then
                lineText = nil
            end
            -- Some items embed battlepet:speciesID in tooltip
            if lineText then
                local speciesID = tonumber(lineText:match("battlepet:(%d+)"))
                if speciesID then
    DebugPrint("|cff00ccff[WN CollectionService]|r Tooltip detection found speciesID: " .. speciesID .. " for itemID: " .. itemID)
                    return speciesID
                end
            end
        end
    end
    
    return nil
end

---Try to detect a toy from itemID
---@return table|nil
function WarbandNexus:_DetectToy(itemID, itemName, itemIcon)
    if not C_ToyBox or not C_ToyBox.GetToyInfo then return nil end

    local _, toyName, toyIcon, _, _, itemQuality = C_ToyBox.GetToyInfo(itemID)
    if not toyName then return nil end
    -- Midnight 12.0: API returns may be secret; do not use for display or logic
    if issecretvalue and issecretvalue(toyName) then return nil end

    -- Check if already owned (guard secret return)
    if PlayerHasToy then
        local has = PlayerHasToy(itemID)
        if issecretvalue and has and issecretvalue(has) then return nil end
        if has == true then return nil end
    end
    
    -- DUPLICATE PREVENTION
    if Notify.WasDetectedInBag("toy", itemID) then return nil end
    if Notify.WasRecentlyShownByName(toyName) then return nil end
    
    DebugPrint("|cff00ff00[WN CollectionService]|r NEW TOY DETECTED: " .. toyName .. " (ID: " .. itemID .. ")")
    return {
        type = "toy",
        id = itemID,
        name = toyName,
        icon = toyIcon or itemIcon,
        itemQuality = itemQuality,
    }
end

-- BACKGROUND SCANNING (Async Coroutine-Based)

-- Runtime cache for illusions (used during scan to avoid API re-calls)
local illusionRuntimeCache = nil
local illusionDebugCount = 0  -- Debug counter for illusion logging

---Unified collection configuration for background scanning
COLLECTION_CONFIGS = {
    mount = {
        name = (ns.L and ns.L["CATEGORY_MOUNTS"]) or "Mounts",
        iterator = function()
            Mat.EnsureBlizzardCollectionsLoaded()
            if not C_MountJournal or not C_MountJournal.GetMountIDs then return {} end
            return C_MountJournal.GetMountIDs() or {}
        end,
        extract = function(mountID)
            -- 12.0.5: GetMountInfoByID returns 13 values (mountID at #12, isForDragonriding at #13)
            local name, spellID, icon, _, _, sourceType, _, isFactionSpecific, faction, shouldHideOnChar, isCollected, _mountIDRet, isForDragonriding = C_MountJournal.GetMountInfoByID(mountID)
            if not name then return nil end
            -- Midnight 12.0: name or isCollected may be a secret value; guard against it
            if issecretvalue and name and issecretvalue(name) then return nil end
            if WarbandNexus.ShouldExcludeMountFromCollectionBrowse and WarbandNexus:ShouldExcludeMountFromCollectionBrowse(mountID) then
                return nil
            end
            local collected = false
            if issecretvalue and isCollected and issecretvalue(isCollected) then
                collected = true
            elseif isCollected == true then
                collected = true
            end
            -- shouldHideOnChar may also be secret
            local hideOnChar = false
            if issecretvalue and shouldHideOnChar and issecretvalue(shouldHideOnChar) then
                hideOnChar = false  -- If secret, assume visible (safe default)
            elseif shouldHideOnChar == true then
                hideOnChar = true
            end

            local creatureDisplayID, description, source = C_MountJournal.GetMountInfoExtraByID(mountID)

            local resolvedSourceType = sourceType
            if issecretvalue and resolvedSourceType and issecretvalue(resolvedSourceType) then
                resolvedSourceType = nil
            end
            if (not resolvedSourceType or resolvedSourceType == 0) and ns._mountIDToSourceIndex then
                local fromMap = ns._mountIDToSourceIndex[mountID]
                if fromMap and fromMap > 0 then resolvedSourceType = fromMap end
            end

            return {
                id = mountID,
                name = name,
                icon = icon,
                spellID = spellID,
                source = source or ((ns.L and ns.L["FALLBACK_UNKNOWN_SOURCE"]) or UNKNOWN or "Unknown"),
                sourceType = resolvedSourceType,
                description = description,
                creatureDisplayID = creatureDisplayID,
                collected = collected,
                shouldHideOnChar = hideOnChar,
                isFactionSpecific = isFactionSpecific,
                faction = faction,
                isForDragonriding = isForDragonriding,
            }
        end,
        shouldInclude = function(data)
            if not data or data.collected then return false end
            if data.id and WarbandNexus.ShouldExcludeMountFromCollectionBrowse then
                return not WarbandNexus:ShouldExcludeMountFromCollectionBrowse(data.id)
            end
            return true
        end,
        shouldIncludeInAll = function(data)
            if not data then return false end
            if data.id and WarbandNexus.ShouldExcludeMountFromCollectionBrowse then
                return not WarbandNexus:ShouldExcludeMountFromCollectionBrowse(data.id)
            end
            return true
        end,
    },
    
    pet = {
        name = (ns.L and ns.L["CATEGORY_PETS"]) or "Pets",
        iterator = function()
            if not C_PetJournal then return {} end
            
            -- Pet journal filter functions require Blizzard_Collections to be loaded
            Mat.EnsureBlizzardCollectionsLoaded()
            
            -- Save original filter state
            local origSearch = C_PetJournal.GetSearchFilter and C_PetJournal.GetSearchFilter() or ""
            
            -- Reset filters to show ALL pets for accurate scanning
            -- TAINT GUARD: Filter manipulation taints PetJournal; skip during combat to prevent blocked actions
            if not InCombatLockdown() then
                pcall(function()
                    if C_PetJournal.ClearSearchFilter then C_PetJournal.ClearSearchFilter() end
                    -- Show both collected and uncollected
                    if C_PetJournal.SetFilterChecked then
                        C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_COLLECTED, true)
                        C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_NOT_COLLECTED, true)
                    end
                    -- Show all pet types
                    if C_PetJournal.SetPetTypeFilter then
                        for i = 1, C_PetJournal.GetNumPetTypes() do
                            C_PetJournal.SetPetTypeFilter(i, true)
                        end
                    end
                    -- Show all sources
                    if C_PetJournal.SetPetSourceChecked then
                        for i = 1, C_PetJournal.GetNumPetSources() do
                            C_PetJournal.SetPetSourceChecked(i, true)
                        end
                    end
                end)
            end

            -- Capture per-source speciesID → sourceTypeIndex via filter trick (mirrors GetToysGroupedBySourceType).
            -- Pure API: Blizzard's pet journal source filter is the authoritative classifier.
            local speciesToSource = {}
            local numSources = (C_PetJournal.GetNumPetSources and C_PetJournal.GetNumPetSources()) or 0
            if numSources > 0 and C_PetJournal.SetPetSourceChecked and not InCombatLockdown() then
                pcall(function()
                    for srcIdx = 1, numSources do
                        for i = 1, numSources do
                            C_PetJournal.SetPetSourceChecked(i, i == srcIdx)
                        end
                        local nFiltered = C_PetJournal.GetNumPets() or 0
                        for j = 1, nFiltered do
                            local _, sID = C_PetJournal.GetPetInfoByIndex(j)
                            if sID and not speciesToSource[sID] then
                                speciesToSource[sID] = srcIdx
                            end
                        end
                    end
                    -- Restore "all sources visible" so the next GetNumPets/GetPetInfoByIndex pass sees every species
                    for i = 1, numSources do
                        C_PetJournal.SetPetSourceChecked(i, true)
                    end
                end)
            end
            ns._petSpeciesToSourceIndex = speciesToSource

            local numPets = C_PetJournal.GetNumPets() or 0
            
            -- Resolve speciesIDs NOW while "show all" filters are active
            -- GetPetInfoByIndex depends on current filter state
            local pets = {}
            local seen = {}
            for i = 1, numPets do
                local _, speciesID = C_PetJournal.GetPetInfoByIndex(i)
                if speciesID and not seen[speciesID] then
                    seen[speciesID] = true
                    table.insert(pets, speciesID)
                end
            end
            
            -- Restore original filter state
            -- TAINT GUARD: Same guard as above - skip restore if in combat
            if not InCombatLockdown() then
                pcall(function()
                    if origSearch and origSearch ~= "" and C_PetJournal.SetSearchFilter then
                        C_PetJournal.SetSearchFilter(origSearch)
                    end
                end)
            end
            
            return pets
        end,
        extract = function(speciesID)
            -- speciesID is now passed directly from iterator (not an index)
            if not speciesID then return nil end
            
            -- 12.0.5: GetPetInfoBySpeciesID returns 12 values; #11 is 'obtainable' boolean
            local speciesName, icon, petType, companionID, source, description, isWild, canBattle, isTradeable, isUnique, obtainable, creatureDisplayID_extra = C_PetJournal.GetPetInfoBySpeciesID(speciesID)
            if not speciesName then return nil end
            -- Guard secret values (Midnight 12.0+)
            if issecretvalue and speciesName and issecretvalue(speciesName) then return nil end
            
            -- Filter out unobtainable pets (NPC-only, dev/test, tamer pets, cut content)
            -- obtainable == false means this pet species cannot be collected by players
            if obtainable == false then return nil end
            
            -- Check if player owns this species
            local numCollected = C_PetJournal.GetNumCollectedInfo(speciesID)
            local owned = numCollected and numCollected > 0
            
            local creatureDisplayID = creatureDisplayID_extra
            if not creatureDisplayID and C_PetJournal.GetNumDisplays and C_PetJournal.GetDisplayIDByIndex then
                local numDisplays = C_PetJournal.GetNumDisplays(speciesID) or 0
                if numDisplays > 0 then
                    creatureDisplayID = C_PetJournal.GetDisplayIDByIndex(speciesID, 1)
                end
            end
            
            local sourceTypeIndex = ns._petSpeciesToSourceIndex and ns._petSpeciesToSourceIndex[speciesID] or nil

            return {
                id = speciesID,
                name = speciesName,
                icon = icon,
                source = source or ((ns.L and ns.L["FALLBACK_PET_COLLECTION"]) or "Pet Collection"),
                sourceTypeIndex = sourceTypeIndex,
                description = description,
                collected = owned,
                petType = petType,
                creatureDisplayID = creatureDisplayID,
                obtainable = obtainable,
            }
        end,
        shouldInclude = function(data)
            if not data or data.collected then return false end
            -- Pure API hide rule: obtainable == false (Blizzard says species is unobtainable)
            if data.obtainable == false then return false end
            return true
        end,
        shouldIncludeInAll = function(data)
            if not data then return false end
            if data.obtainable == false then return false end
            return true
        end,
    },

    toy = {
        name = (ns.L and ns.L["CATEGORY_TOYS"]) or "Toys",
        iterator = function()
            if not C_ToyBox then return {} end
            
            -- C_ToyBox filter functions require Blizzard_Collections to be loaded.
            -- Without it, GetNumFilteredToys() returns 0 and filter manipulation has no effect.
            Mat.EnsureBlizzardCollectionsLoaded()
            
            -- Save original filter state to restore after scan
            local origCollected = C_ToyBox.GetCollectedShown and C_ToyBox.GetCollectedShown()
            local origUncollected = C_ToyBox.GetUncollectedShown and C_ToyBox.GetUncollectedShown()
            local origFilterString = C_ToyBox.GetFilterString and C_ToyBox.GetFilterString() or ""

            -- Capture per-source itemID → sourceTypeIndex via filter trick (Blizzard ToyBox source filter).
            -- Pure API: Blizzard's source filter is the authoritative classifier.
            local toyToSource = {}
            if WarbandNexus and WarbandNexus.GetToysGroupedBySourceType then
                local _, itemIDToSource = WarbandNexus:GetToysGroupedBySourceType()
                if itemIDToSource then
                    for k, v in pairs(itemIDToSource) do toyToSource[k] = v end
                end
            end
            ns._toyItemIDToSourceIndex = toyToSource

            -- Ensure all toys are visible for scanning (GetToyFromIndex uses filtered list)
            -- TAINT GUARD: ToyBox filter manipulation is protected; skip during combat
            if not InCombatLockdown() then
                pcall(function()
                    C_ToyBox.SetCollectedShown(true)
                    C_ToyBox.SetUncollectedShown(true)
                    C_ToyBox.SetAllSourceTypeFilters(true)
                    C_ToyBox.SetFilterString("")
                    if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end
                end)
            end
            
            -- Try GetNumFilteredToys first; fall back to GetNumTotalDisplayedToys or GetNumToys
            local numToys = 0
            if C_ToyBox.GetNumFilteredToys then
                numToys = C_ToyBox.GetNumFilteredToys() or 0
            end
            -- If filtered count is 0, the filter may not have applied yet; try total count
            if numToys == 0 and C_ToyBox.GetNumTotalDisplayedToys then
                numToys = C_ToyBox.GetNumTotalDisplayedToys() or 0
            end
            if numToys == 0 and C_ToyBox.GetNumToys then
                numToys = C_ToyBox.GetNumToys() or 0
            end
            
            -- Resolve itemIDs NOW while "show all" filters are active.
            -- GetToyFromIndex uses the current filter state, so we must capture
            -- actual itemIDs before restoring original filters.
            local toys = {}
            for i = 1, numToys do
                local itemID = C_ToyBox.GetToyFromIndex(i)
                if itemID and itemID > 0 then
                    table.insert(toys, itemID)
                end
            end
            
            -- Restore original filter state to avoid side effects on player ToyBox UI
            -- TAINT GUARD: Same guard as above - skip restore if in combat
            if not InCombatLockdown() then
                pcall(function()
                    if origCollected ~= nil then C_ToyBox.SetCollectedShown(origCollected) end
                    if origUncollected ~= nil then C_ToyBox.SetUncollectedShown(origUncollected) end
                    if origFilterString then C_ToyBox.SetFilterString(origFilterString) end
                    if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end
                end)
            end
            
            return toys
        end,
        extract = function(itemID)
            if not itemID or not C_ToyBox or not C_ToyBox.GetToyInfo then return nil end
            -- 12.0.5: GetToyInfo returns itemID, toyName, icon, isFavorite, hasFanfare, itemQuality
            local retItemID, toyName, toyIcon, _, _, itemQuality = C_ToyBox.GetToyInfo(itemID)
            -- If GetToyInfo returns nil for the itemID, the toy doesn't exist in the ToyBox database
            -- (internal/hidden/removed toy) — exclude it
            if not retItemID then return nil end
            if issecretvalue and toyName and issecretvalue(toyName) then toyName = nil end
            -- If name is nil/empty and retItemID is also nil, this is a ghost entry — skip
            if not toyName or toyName == "" then
                -- Try C_Item fallback for name
                if C_Item and C_Item.GetItemInfo then
                    local itemName = C_Item.GetItemInfo(itemID)
                    if itemName and type(itemName) == "string" and itemName ~= "" then
                        if not (issecretvalue and issecretvalue(itemName)) then
                            toyName = itemName
                        end
                    end
                end
            end
            local name = (toyName and toyName ~= "") and toyName or tostring(itemID)
            local collected = false
            if PlayerHasToy then
                local raw = PlayerHasToy(itemID)
                if issecretvalue and raw and issecretvalue(raw) then collected = true
                else collected = raw == true end
            end
            local sourceTypeIndex = ns._toyItemIDToSourceIndex and ns._toyItemIDToSourceIndex[itemID] or nil
            return { id = itemID, name = name, collected = collected, icon = toyIcon, itemQuality = itemQuality, sourceTypeIndex = sourceTypeIndex }
        end,
        shouldInclude = function(data)
            if not data or data.collected then return false end
            -- Pure API: extract already returns nil for hidden/internal toys (GetToyInfo nil).
            return true
        end,
        shouldIncludeInAll = function(data)
            if not data then return false end
            return true
        end,
    },

    achievement = {
        name = (ns.L and ns.L["CATEGORY_ACHIEVEMENTS"]) or "Achievements",
        iterator = function()
            if not GetCategoryList then return {} end
            
            local categoryList = GetCategoryList() or {}
            local allAchievements = {}
            
            for i = 1, #categoryList do
                local categoryID = categoryList[i]
                if categoryID then
                    local numAchievements = GetCategoryAchievementListCount(categoryID)
                    for achIndex = 1, numAchievements do
                        table.insert(allAchievements, {categoryID = categoryID, achIndex = achIndex})
                    end
                end
            end
            
            return allAchievements
        end,
        extract = function(item)
            local success, id, name, points, completed, month, day, year, description, flags, icon = pcall(GetAchievementInfo, item.categoryID, item.achIndex)
            if not success or not id or not name then return nil end
            
            -- Get reward info
            local rewardItemID, rewardTitle
            local rewardSuccess, rewardItem, title = pcall(GetAchievementReward, id)
            if rewardSuccess then
                if type(rewardItem) == "number" then
                    rewardItemID = rewardItem
                end
                if type(title) == "string" and title ~= "" then
                    rewardTitle = title
                elseif type(rewardItem) == "string" and rewardItem ~= "" then
                    rewardTitle = rewardItem
                end
            end
            
            -- Resolve actual category
            local realCategoryID = item.categoryID
            if GetAchievementCategory then
                local actualCat = GetAchievementCategory(id)
                if actualCat and actualCat > 0 then
                    realCategoryID = actualCat
                end
            end
            
            return {
                id = id,
                name = name,
                icon = icon,
                points = points,
                description = description,
                source = description or ((ns.L and ns.L["SOURCE_TYPE_ACHIEVEMENT"]) or BATTLE_PET_SOURCE_6 or "Achievement"),
                collected = completed,
                categoryID = realCategoryID,
                rewardItemID = rewardItemID,
                rewardTitle = rewardTitle,
            }
        end,
        shouldInclude = function(data)
            if not data or data.collected then return false end
            return true
        end,
        shouldIncludeInAll = function(data)
            return data ~= nil
        end,
    },

    illusion = {
        name = (ns.L and ns.L["CATEGORY_ILLUSIONS"]) or "Illusions",
        iterator = function()
            if not C_TransmogCollection or not C_TransmogCollection.GetIllusions then return {} end
            
            -- Reset debug counter for new scan
            illusionDebugCount = 0
            
            local illusions = C_TransmogCollection.GetIllusions()
            if not illusions then return {} end
            
            -- Cache the full illusion table for extract() to use
            -- This avoids calling GetIllusions() hundreds of times in extract()
            illusionRuntimeCache = {}
            
            local illusionList = {}
            for i = 1, #illusions do
                local illusionInfo = illusions[i]
                if illusionInfo and illusionInfo.sourceID then
                    -- Use sourceID (not visualID!) - GetIllusionStrings needs sourceID
                    illusionRuntimeCache[illusionInfo.sourceID] = illusionInfo
                    table.insert(illusionList, illusionInfo.sourceID)
                end
            end
            
            return illusionList
        end,
        extract = function(sourceID)
            if not C_TransmogCollection then return nil end
            
            -- Use cached illusion data for isCollected check
            local illusionInfo = illusionRuntimeCache and illusionRuntimeCache[sourceID]
            if not illusionInfo then return nil end
            
            -- CORRECT API: GetIllusionStrings(sourceID) - NOT visualID!
            local name, hyperlink, sourceText = C_TransmogCollection.GetIllusionStrings(sourceID)
            
            -- Fallback 1: Try basic illusion info if API returns nil
            if not name or name == "" then
                name = illusionInfo.name
            end
            
            -- Fallback 2: Try spell name from spellID if still empty
            if (not name or name == "") and illusionInfo.spellID then
                local spellName = C_Spell and C_Spell.GetSpellName(illusionInfo.spellID)
                if spellName and spellName ~= "" then
                    name = spellName
                end
            end
            
            -- Fallback 3: Use sourceID as last resort
            if not name or name == "" then
                local illusionType = (ns.L and ns.L["TYPE_ILLUSION"]) or "Illusion"
                name = ((ns.L and ns.L["FALLBACK_ILLUSION_FORMAT"]) and string.format(ns.L["FALLBACK_ILLUSION_FORMAT"], sourceID)) or (illusionType .. " " .. sourceID)
            end
            
            -- Clean up sourceText
            if not sourceText or sourceText == "" then
                sourceText = illusionInfo.sourceText or ((ns.L and ns.L["UNKNOWN_SOURCE"]) or "Unknown source")
            end
            
            local icon = illusionInfo.icon or 134400  -- Default icon
            local isCollected = illusionInfo.isCollected
            
            -- DEBUG: Log source info for first few illusions
            if not isCollected then
                if illusionDebugCount < 5 then
    DebugPrintf("|cff00ffff[WN DEBUG Illusion #%d]|r sourceID: %d, visualID: %d",
        illusionDebugCount + 1, sourceID, illusionInfo.visualID or 0)
    DebugPrint("  GetIllusionStrings(" .. sourceID .. ") returned:")
    DebugPrint("    name:", tostring(name))
    DebugPrint("    hyperlink:", tostring(hyperlink))
    DebugPrint("    sourceText:", tostring(sourceText))
                    
                    illusionDebugCount = illusionDebugCount + 1
                end
            end
            
            return {
                id = sourceID,  -- Use sourceID as ID
                name = name,
                icon = icon,
                sourceText = sourceText,
                source = sourceText,
                collected = isCollected,
                visualID = illusionInfo.visualID,  -- Keep for reference
                hyperlink = hyperlink,
            }
        end,
        shouldInclude = function(data)
            if not data or data.collected then return false end
            return true
        end,
        shouldIncludeInAll = function(data)
            if not data then return false end
            return true
        end,
    },

    title = {
        name = (ns.L and ns.L["CATEGORY_TITLES"]) or "Titles",
        iterator = function()
            if not GetNumTitles or not GetTitleName or not IsTitleKnown then return {} end
            
            local numTitles = GetNumTitles()
            if not numTitles or numTitles == 0 then return {} end
            
            local titleList = {}
            for titleMaskID = 1, numTitles do
                table.insert(titleList, titleMaskID)
            end
            
            return titleList
        end,
        extract = function(titleMaskID)
            -- Get title info
            local titleString, playerTitle = GetTitleName(titleMaskID)
            if not titleString or titleString == "" then return nil end
            
            -- Check if player knows this title
            local isKnown = IsTitleKnown(titleMaskID)
            
            -- Format title name (remove %s placeholder)
            local displayName = titleString:gsub("%%s", ""):trim()
            
            return {
                id = titleMaskID,
                name = displayName,
                titleString = titleString,  -- Original format with %s
                playerTitle = playerTitle,  -- Boolean: true = suffix (name Title), false = prefix (Title name)
                collected = isKnown,
                iconAtlas = "poi-legendsoftheharanir",  -- Atlas icon for titles (matches TYPE_ICONS.title)
                icon = nil,  -- No texture icon, use atlas only
                source = (ns.L and ns.L["FALLBACK_PLAYER_TITLE"]) or "Player Title",
                type = "title",
            }
        end,
        shouldInclude = function(data)
            if not data or data.collected then return false end
            return true
        end,
        shouldIncludeInAll = function(data)
            return data ~= nil
        end,
    },
}

---Scan a collection type asynchronously (coroutine-based with duplicate protection)
function WarbandNexus:ScanCollection(collectionType, onProgress, onComplete)
    DebugPrint("|cff9370DB[WN CollectionService]|r ScanCollection called for: " .. tostring(collectionType))
    
    -- Ensure Blizzard_Collections is loaded (required for icons, source text, toy filters)
    Mat.EnsureBlizzardCollectionsLoaded()
    
    local config = COLLECTION_CONFIGS[collectionType]
    if not config then
    DebugPrint("|cffff4444[WN CollectionService ERROR]|r Invalid collection type: " .. tostring(collectionType))
        return
    end
    
    -- Prevent duplicate scans (already scanning)
    if activeCoroutines[collectionType] then
    DebugPrint("|cffffcc00[WN CollectionService]|r Scan already in progress for: " .. tostring(collectionType) .. ", skipping")
        return
    end
    
    -- Check if scan is needed (collectionStore or collectionCache populated and current)
    local storeHasData = collectionStore[collectionType] and next(collectionStore[collectionType]) ~= nil
    local cacheHasData = collectionCache.uncollected[collectionType] and next(collectionCache.uncollected[collectionType]) ~= nil
    local cacheExists = storeHasData or cacheHasData
    if cacheExists then
        local lastScan = collectionCache.lastScan or 0
        local timeSinceLastScan = time() - lastScan
        
        -- If cache exists and scan was recent (< 5 minutes), skip
        if timeSinceLastScan < 300 then
    DebugPrint("|cffffcc00[WN CollectionService]|r Cache exists and recent for " .. tostring(collectionType) .. " (scanned " .. timeSinceLastScan .. "s ago), skipping scan")
            -- EnsureCollectionData relies on onComplete to advance the queue; always invoke when skipping
            if onComplete then onComplete(collectionStore[collectionType] or {}) end
            return
        end
    end
    
    DebugPrint("|cff00ff00[WN CollectionService]|r Starting background scan: " .. tostring(collectionType))
    
    -- Initialize loading state for UI
    if not ns.PlansLoadingState then ns.PlansLoadingState = {} end
    if not ns.PlansLoadingState[collectionType] then
        ns.PlansLoadingState[collectionType] = { isLoading = false, loader = nil }
    end
    
    -- Set loading state to true
    ns.PlansLoadingState[collectionType].isLoading = true
    ns.PlansLoadingState[collectionType].loadingProgress = 0
    ns.PlansLoadingState[collectionType].currentStage = "Preparing..."
    
    local co = coroutine.create(function()
        local startTime = debugprofilestop()
        local results = {}
        local items = {}
        local matBudget = FRAME_BUDGET_MS
        if collectionType == "pet" then
            local st = nil
            local matSteps = 0
            local MAX_MATERIALIZE_STEPS = 200000
            while true do
                matSteps = matSteps + 1
                if matSteps > MAX_MATERIALIZE_STEPS then
                    DebugPrint("|cffff0000[WN CollectionService]|r Pet materialize exceeded step cap; aborting scan")
                    items = {}
                    break
                end
                st = Mat.PetJournalBuildMaterializeStep(matBudget, st)
                if st.done then
                    items = st.items
                    ns._petSpeciesToSourceIndex = st.speciesToSource
                    break
                end
                coroutine.yield()
            end
        elseif collectionType == "toy" then
            local st = nil
            local matSteps = 0
            local MAX_MATERIALIZE_STEPS = 200000
            while true do
                matSteps = matSteps + 1
                if matSteps > MAX_MATERIALIZE_STEPS then
                    DebugPrint("|cffff0000[WN CollectionService]|r Toy materialize exceeded step cap; aborting scan")
                    items = {}
                    break
                end
                st = Mat.ToyBoxBuildMaterializeStep(matBudget, st, self)
                if st.done then
                    items = st.items
                    ns._toyItemIDToSourceIndex = st.toyToSource
                    if ns._toyItemIDToSourceIndexCache then
                        ns._toyItemIDToSourceIndexCache.map = st.toyToSource
                    end
                    break
                end
                coroutine.yield()
            end
        else
            items = config.iterator()
        end
        local total = #items
        local Constants = ns.Constants

        -- Handle empty collection (nothing to scan)
        if total == 0 then
            -- For toy scans, 0 results likely means filters didn't apply yet; schedule retry instead of caching empty
            if collectionType == "toy" then
                local retryKey = "__toyRetryCount"
                local retryCount = ns[retryKey] or 0
                if retryCount < 3 then
                    ns[retryKey] = retryCount + 1
                    ns.PlansLoadingState[collectionType].isLoading = false
                    ns.PlansLoadingState[collectionType].currentStage = "Retrying..."
                    DebugPrint("|cffffcc00[WN CollectionService]|r Toy scan returned 0 results, scheduling retry " .. (retryCount + 1) .. "/3")
                    C_Timer.After(0.5, function()
                        if self and self.ScanCollection then self:ScanCollection("toy") end
                    end)
                    return
                end
                -- After 3 retries, give up and cache empty
                ns[retryKey] = nil
            end
            if collectionStore[collectionType] ~= nil then
                collectionStore[collectionType] = results
            end
            collectionCache.uncollected[collectionType] = results
            ns.PlansLoadingState[collectionType].isLoading = false
            ns.PlansLoadingState[collectionType].loadingProgress = 100
            ns.PlansLoadingState[collectionType].currentStage = "Complete!"
            SendCollectionScanProgress(self, collectionType, 100, 0, 0)
            -- EnsureCollectionData queue advances only when onComplete is called (e.g. illusion/title empty scan)
            if onComplete then onComplete(results) end
            if Constants and Constants.EVENTS then
                self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, { category = collectionType, results = results, elapsed = 0 })
            end
            return
        end

        SendCollectionScanProgress(self, collectionType, 0, 0, total)

        local shouldIncludeFn = config.shouldIncludeInAll or config.shouldInclude
        for i = 1, #items do
            local item = items[i]
            local data = config.extract(item)

            if data and data.id and shouldIncludeFn and shouldIncludeFn(data) then
                results[data.id] = data
            end

            -- PROGRESSIVE UPDATE: Update loading state every 50 items
            if i % 50 == 0 or i == total then
                local progress = math.min(99, math.floor((i / total) * 100))
                ns.PlansLoadingState[collectionType].loadingProgress = progress
                ns.PlansLoadingState[collectionType].currentStage = string.format("Scanning %s... (%d/%d)", config.name, i, total)
                
                -- Fire progress event for UI refresh
                SendCollectionScanProgress(self, collectionType, progress, i, total)
            end
            
            -- Progress callback (legacy)
            if onProgress and i % BATCH_SIZE == 0 then
                onProgress(i, total, data)
            end
            
            -- Yield every BATCH_SIZE items (frame budget management)
            if i % BATCH_SIZE == 0 then
                -- Check if scan was aborted (tab switch)
                if not activeCoroutines[collectionType] then
    DebugPrint("|cffffcc00[WN CollectionService]|r Scan aborted for: " .. collectionType)
                    ns.PlansLoadingState[collectionType].isLoading = false
                    return  -- Exit coroutine gracefully
                end
                coroutine.yield()
            end
        end
        
        -- Merkezi kaynak: collectionStore'a tam veri yaz (mount, pet, toy, achievement, title, illusion)
        -- Preserve collected=true from incremental updates (OnNewMount etc.) that may have
        -- run AFTER the scan already processed that ID — prevents race condition overwrite.
        if collectionStore[collectionType] ~= nil then
            local oldStore = collectionStore[collectionType]
            for id, data in pairs(results) do
                if not data.collected and oldStore[id] and oldStore[id].collected == true then
                    data.collected = true
                end
            end
            collectionStore[collectionType] = results
        end
        -- collectionCache.uncollected: id->name map (uncollected only)
        local uncollectedMap = {}
        for id, d in pairs(results) do
            if d and (d.collected == false or d.collected == nil) then
                uncollectedMap[id] = (d.name and d.name ~= "") and d.name or ("ID:" .. tostring(id))
            end
        end
        collectionCache.uncollected[collectionType] = uncollectedMap
        collectionCache.lastScan = time()
        collectionStore.lastBuilt = time()

        -- FINAL PROGRESS: Set to 100%
        ns.PlansLoadingState[collectionType].loadingProgress = 100
        ns.PlansLoadingState[collectionType].currentStage = "Complete!"
        
        -- Fire final progress event (100%)
        SendCollectionScanProgress(self, collectionType, 100, total, total)
        
        local elapsed = debugprofilestop() - startTime
        local uncollectedCount = 0
        for _ in pairs(results) do uncollectedCount = uncollectedCount + 1 end
        DebugPrintf("|cff00ff00[WN CollectionService]|r Scan complete: %s - %d total, %d uncollected, %.2fms",
            config.name, total, uncollectedCount, elapsed)
        
        -- Verbose-only: listing every cache key is O(categories); skip when debug verbose is off
        if ns.IsDebugVerboseEnabled and ns.IsDebugVerboseEnabled() then
            DebugPrint("|cff00ccff[WN CollectionService DEBUG]|r Cache written to key: '" .. collectionType .. "'")
            DebugPrint("|cff00ccff[WN CollectionService DEBUG]|r Cache now has keys:")
            for key, value in pairs(collectionCache.uncollected) do
                local itemCount = 0
                for _ in pairs(value) do itemCount = itemCount + 1 end
                DebugPrint("|cff00ccff[WN CollectionService]|r   - '" .. key .. "' = " .. itemCount .. " items")
            end
        end
        
        -- PERSIST TO DB (avoid re-scanning on reload)
        self:SaveCollectionCache()
        
        -- Clean up runtime caches (illusion, transmog, etc.)
        if collectionType == "illusion" and illusionRuntimeCache then
            illusionRuntimeCache = nil
            DebugPrint("|cff9370DB[WN CollectionService]|r Cleared runtime cache for illusions")
        end
        
        -- Set loading state to false (scan complete)
        ns.PlansLoadingState[collectionType].isLoading = false
        
        -- Completion callback
        if onComplete then
            onComplete(results)
        end
        
        -- Fire completion event
        if Constants and Constants.EVENTS then
            self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, {
                category = collectionType,
                results = results,
                elapsed = elapsed,
            })
        end
        
        -- UI refreshes via WN_COLLECTION_SCAN_COMPLETE listener in UI.lua (event-driven).
        
    end)
    
    -- Store coroutine; resume with FRAME_BUDGET_MS (same pattern as ScanAchievementsAsync)
    activeCoroutines[collectionType] = co

    local function resetScanLoadingStateOnError()
        if ns.PlansLoadingState and ns.PlansLoadingState[collectionType] then
            ns.PlansLoadingState[collectionType].isLoading = false
            ns.PlansLoadingState[collectionType].loadingProgress = 0
            ns.PlansLoadingState[collectionType].currentStage = "Error"
        end
    end

    local function resumeCoroutine()
        local coRef = activeCoroutines[collectionType]
        if not coRef then return end
        if InCombatLockdown and InCombatLockdown() then
            C_Timer.After(0, resumeCoroutine)
            return
        end
        if coroutine.status(coRef) == "dead" then
            activeCoroutines[collectionType] = nil
            return
        end

        local budget = FRAME_BUDGET_MS
        local startTime = debugprofilestop()
        while (debugprofilestop() - startTime) < budget do
            local success, err = coroutine.resume(coRef)
            if not success then
                self:Debug("CollectionService: Scan error - " .. tostring(err))
                activeCoroutines[collectionType] = nil
                resetScanLoadingStateOnError()
                return
            end
            if coroutine.status(coRef) == "dead" then
                activeCoroutines[collectionType] = nil
                return
            end
        end
        C_Timer.After(0, resumeCoroutine)
    end

    resumeCoroutine()
end

---Session-only metadata cache: circular buffer eviction (O(1) instead of O(n) table.remove).
local function metadataCacheSet(cacheKey, meta)
    if #metadataCacheOrder >= METADATA_CACHE_MAX then
        local oldKey = metadataCacheOrder[metadataCacheHead]
        if oldKey then metadataCache[oldKey] = nil end
        metadataCacheOrder[metadataCacheHead] = cacheKey
        metadataCacheHead = (metadataCacheHead % METADATA_CACHE_MAX) + 1
    else
        metadataCacheOrder[#metadataCacheOrder + 1] = cacheKey
    end
    metadataCache[cacheKey] = meta
end

local function GetToyFallbackSources()
    local fallback1 = (ns.L and ns.L["FALLBACK_TOY_COLLECTION"]) or "Toy Collection"
    local fallback2 = (ns.L and ns.L["FALLBACK_TOY_BOX"]) or "Toy Box"
    local fallback3 = (ns.L and ns.L["FALLBACK_WARBAND_TOY"]) or "Warband Toy"
    return fallback1, fallback2, fallback3
end

local function IsToySourceGeneric(sourceText)
    local fallback1, fallback2, fallback3 = GetToyFallbackSources()
    return not sourceText or sourceText == "" or sourceText == fallback1 or sourceText == fallback2 or sourceText == fallback3
end

local function TrimText(text)
    if type(text) ~= "string" then return "" end
    return text:gsub("^%s+", ""):gsub("%s+$", "")
end

---Validate that toy source text is source-like (not random tooltip garbage).
---@return boolean
function WarbandNexus:IsReliableToySource(sourceText)
    if type(sourceText) ~= "string" then return false end
    local s = TrimText(sourceText)
    if s == "" or IsToySourceGeneric(s) then return false end

    if s:find("|c", 1, true) or s:find("|r", 1, true) or s:find("|T", 1, true) or s:find("|H", 1, true) then
        return false
    end

    local L = ns.L
    local sourceLabel = ((L and L["SOURCE_LABEL"]) or "Source:"):gsub("[:%s]+$", "")
    local keywords = {
        BATTLE_PET_SOURCE_1 or "Drop",
        BATTLE_PET_SOURCE_2 or "Quest",
        BATTLE_PET_SOURCE_3 or "Vendor",
        BATTLE_PET_SOURCE_4 or "Profession",
        BATTLE_PET_SOURCE_5 or "Pet Battle",
        BATTLE_PET_SOURCE_6 or "Achievement",
        BATTLE_PET_SOURCE_7 or "World Event",
        BATTLE_PET_SOURCE_8 or "Promotion",
        (L and L["SOURCE_TYPE_TRADING_POST"]) or "Trading Post",
        (L and L["SOURCE_TYPE_TREASURE"]) or "Treasure",
        (L and L["PARSE_SOLD_BY"]) or "Sold by",
        (L and L["PARSE_CONTAINED_IN"]) or "Contained in",
        (L and L["PARSE_ZONE"]) or ZONE or "Zone",
        (L and L["PARSE_COST"]) or "Cost",
        (L and L["PARSE_AMOUNT"]) or "Amount",
        (L and L["ZONE_DROP"]) or "Zone drop",
        (L and L["FISHING"]) or "Fishing",
        sourceLabel,
    }

    for i = 1, #keywords do
        local kw = keywords[i]
        if type(kw) == "string" and kw ~= "" then
            local escaped = kw:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
            if s:match("^%s*" .. escaped .. "%s*:") or s:match("^%s*" .. escaped .. "%s") then
                return true
            end
        end
    end

    return false
end

---Single place for toy metadata: C_ToyBox.GetToyInfo (name, icon, itemQuality) + tooltip (source, description) + CollectibleSourceDB when tooltip is fallback.
---API note: GetToyInfo returns itemID, toyName, icon, isFavorite, hasFanfare, itemQuality — no sourceText; source comes from tooltip/DB.
---@return table|nil { name, icon, source, description, itemQuality } or nil
function WarbandNexus:GetToySourceInfo(itemID)
    if not itemID or not C_ToyBox or not C_ToyBox.GetToyInfo then return nil end
    -- Session cache: reuse only when source already validated as meaningful.
    local cache = WarbandNexus.toySourceInfoCache
    if cache and cache[itemID] then
        local c = cache[itemID]
        if self:IsReliableToySource(c.source) then
            return c
        end
    end

    local _, name, icon, _, _, itemQuality = C_ToyBox.GetToyInfo(itemID)
    if not name then return nil end
    if issecretvalue and issecretvalue(name) then return nil end
    if not icon and (C_Item and C_Item.GetItemInfo) then
        local _, _, _, _, _, _, _, _, _, itemTexture = C_Item.GetItemInfo(itemID)
        if itemTexture then icon = itemTexture end
    end

    local sourceText = ""
    local descriptionText = ""
    -- Prefer GetToyByItemID: toy tooltip often has "Source:"; GetItemByID returns item tooltip (e.g. weapon stats) with no source line
    local tooltipData = nil
    if C_TooltipInfo then
        if C_TooltipInfo.GetToyByItemID then
            tooltipData = C_TooltipInfo.GetToyByItemID(itemID)
        end
        if (not tooltipData or not tooltipData.lines or #tooltipData.lines == 0) and C_TooltipInfo.GetItemByID then
            tooltipData = C_TooltipInfo.GetItemByID(itemID)
        end
    end
    local _issecretvalue = issecretvalue
    local function safeText(t)
        if not t or type(t) ~= "string" then return nil end
        if _issecretvalue and _issecretvalue(t) then return nil end
        return t
    end
    local function stripColorCodes(s)
        -- nil-passthrough: callers below fall back to other sources when this stays nil
        if not s or type(s) ~= "string" then return s end
        return Utilities:StripFormattingCodes(s)
    end
    local sourceKeywords = {
        BATTLE_PET_SOURCE_1 or "Drop",
        BATTLE_PET_SOURCE_3 or "Vendor",
        BATTLE_PET_SOURCE_2 or "Quest",
        BATTLE_PET_SOURCE_4 or "Profession",
        BATTLE_PET_SOURCE_7 or "World Event",
        BATTLE_PET_SOURCE_8 or "Promotion",
        (ns.L and ns.L["SOURCE_TYPE_TRADING_POST"]) or "Trading Post",
        (ns.L and ns.L["SOURCE_TYPE_TREASURE"]) or "Treasure",
        (ns.L and ns.L["PARSE_ZONE"]) or ZONE or "Zone",
    }
    local sourceLabel = (ns.L and ns.L["SOURCE_LABEL"]) or "Source:"
    local sourceLabelClean = sourceLabel:gsub("[:%s]+$", "")

    local function isSourceLine(line)
        if not line then return false end
        if line.type == 2 or line.type == 25 then return true end
        local left = safeText(line.leftText)
        local right = safeText(line.rightText)
        if left and sourceLabelClean ~= "" and left:find(sourceLabelClean, 1, true) then return true end
        if right and sourceLabelClean ~= "" and right:find(sourceLabelClean, 1, true) then return true end
        -- Only treat as source if line starts with "Keyword:" so description text containing "drop" etc. is not misclassified
        for i = 1, #sourceKeywords do
            local kw = sourceKeywords[i]
            local escaped = kw:gsub("%%", "%%%%")
            if (left and left:match("^%s*" .. escaped .. "%s*:")) or (right and right:match("^%s*" .. escaped .. "%s*:")) then
                return true
            end
        end
        return false
    end

    if tooltipData and tooltipData.lines then
        local sourceParts = {}
        local descParts = {}
        local lines = tooltipData.lines
        for i = 1, #lines do
            local line = lines[i]
            local left = safeText(line.leftText)
            local right = safeText(line.rightText)
            local lineText = (left and left ~= "" and left) or (right and right ~= "" and right) or nil
            if not lineText then
            elseif isSourceLine(line) then
                -- If line is "Source:" + right-side payload, prefer right-side text.
                if left and right and sourceLabelClean ~= "" and left:find(sourceLabelClean, 1, true) then
                    sourceParts[#sourceParts + 1] = right
                else
                    sourceParts[#sourceParts + 1] = lineText
                end
            elseif i > 1 then
                descParts[#descParts + 1] = lineText
            end
        end
        if #sourceParts > 0 then
            sourceText = table.concat(sourceParts, "\n")
        end
        if #descParts > 0 then
            descriptionText = table.concat(descParts, "\n")
        end
    end
    local fallback1 = GetToyFallbackSources()
    if IsToySourceGeneric(sourceText) then
        local dbSource = ns.CollectibleSourceDB and ns.CollectibleSourceDB.GetSourceStringForToy and ns.CollectibleSourceDB.GetSourceStringForToy(itemID)
        if dbSource and dbSource ~= "" then
            sourceText = dbSource
        end
    end
    sourceText = stripColorCodes(sourceText)
    descriptionText = stripColorCodes(descriptionText)
    if not self:IsReliableToySource(sourceText) then
        sourceText = fallback1
    end
    -- Ensure space before and after every colon in source (e.g. "Vendor:Orix" -> "Vendor : Orix")
    sourceText = sourceText:gsub("([^%s]):([^%s])", "%1 : %2")
    local result = {
        name = name,
        icon = icon,
        source = sourceText,
        description = descriptionText,
        itemQuality = itemQuality,
    }
    -- Session cache (no invalidation; name/source/icon do not change on collect)
    if not WarbandNexus.toySourceInfoCache then WarbandNexus.toySourceInfoCache = {} end
    WarbandNexus.toySourceInfoCache[itemID] = result
    return result
end

---Resolve icon/source/description for a collection entry on demand. Uses session RAM cache; cleared on tab leave.
---Mount metadata is API-authoritative to avoid stale legacy DB source text in Plans.
---@return table|nil { name, icon, source, description, ... } or nil if API fails
function WarbandNexus:ResolveCollectionMetadata(collectionType, id)
    if not id or not collectionType then return nil end
    local cacheKey = collectionType .. ":" .. tostring(id)
    local cachedMeta = metadataCache[cacheKey]
    if cachedMeta then
        if collectionType ~= "mount" then
            return cachedMeta
        end
        -- Mount source text may be stale after DB migrations — re-validate, but only
        -- once per 30 seconds to avoid repeated API calls during tab switching/scrolling.
        local cacheAge = cachedMeta._cacheTime and (debugprofilestop() - cachedMeta._cacheTime) or 999999
        if cacheAge < 30000 then return cachedMeta end -- 30s TTL (debugprofilestop is in ms)
        local cachedSource = cachedMeta.source
        if type(cachedSource) == "string" then
            local sourceTrimmed = cachedSource:gsub("^%s+", ""):gsub("%s+$", "")
            local unknownSource = (ns.L and ns.L["UNKNOWN_SOURCE"]) or "Unknown source"
            local sourceUnknown = (ns.L and ns.L["SOURCE_UNKNOWN"]) or "Unknown"
            if sourceTrimmed ~= "" and sourceTrimmed ~= "Legacy" and sourceTrimmed ~= "Unknown" and sourceTrimmed ~= unknownSource and sourceTrimmed ~= sourceUnknown then
                return cachedMeta
            end
        else
            return cachedMeta
        end
        metadataCache[cacheKey] = nil
    end

    -- Keep a DB fallback for mounts only when API data is temporarily unavailable.
    local mountFallbackMeta = nil
    if collectionType == "mount" and collectionData.mount and collectionData.mount[id] then
        local d = collectionData.mount[id]
        mountFallbackMeta = {
            name = d.name,
            icon = d.icon or "Interface\\Icons\\Ability_Mount_RidingHorse",
            source = d.source or "",
            description = d.description or "",
            creatureDisplayID = d.creatureDisplayID,
            isCollected = d.collected,
        }
    end
    -- Prefer full collection data from DB (no API calls) for non-mount types.
    if collectionType == "pet" and collectionData.pet and collectionData.pet[id] then
        local d = collectionData.pet[id]
        local meta = {
            name = d.name,
            icon = d.icon or "Interface\\Icons\\INV_Box_PetCarrier_01",
            source = d.source or "",
            description = d.description or "",
            isCollected = d.collected,
            creatureDisplayID = d.creatureDisplayID,
        }
        metadataCache[cacheKey] = meta
        return meta
    end
    if collectionType == "toy" and collectionData.toy and collectionData.toy[id] then
        local d = collectionData.toy[id]
        local source = self:GetToySourceTypeNameForItem(id) or ((ns.L and ns.L["SOURCE_UNKNOWN"]) or "Unknown")
        if source == "SOURCE_UNKNOWN" then source = "Unknown" end
        local description = d.description or ""
        if description == "" then
            local info = self:GetToySourceInfo(id)
            if info and info.description and info.description ~= "" then description = info.description end
        end
        -- Resolve icon from API when not stored (toys only store id+name in DB)
        local icon = d.icon
        if not icon or icon == "" then
            Mat.EnsureBlizzardCollectionsLoaded()
            if C_ToyBox and C_ToyBox.GetToyInfo then
                local _, _, apiIcon = C_ToyBox.GetToyInfo(id)
                if apiIcon and apiIcon ~= 0 then icon = apiIcon end
            end
            if not icon or icon == "" then
                local info = self:GetToySourceInfo(id)
                if info and info.icon and info.icon ~= "" then icon = info.icon end
            end
        end
        local meta = {
            name = d.name,
            icon = icon or "Interface\\Icons\\INV_Misc_Toy_07",
            source = source,
            description = description,
            isCollected = d.collected,
        }
        metadataCache[cacheKey] = meta
        return meta
    end

    -- Ensure Blizzard_Collections is loaded (required for icons, source text)
    Mat.EnsureBlizzardCollectionsLoaded()

    local meta = nil
    -- Helper: treat 0 and nil as invalid icon (0 is truthy in Lua but invalid fileID)
    local function validIcon(icon)
        if icon and icon ~= 0 then return icon end
        return nil
    end

    -- Track whether we got a real icon from API or used fallback
    local usedFallbackIcon = false
    
    -- Do NOT use `X and X.func()` pattern for multi-return APIs!
    -- In Lua 5.1, `X and func()` truncates to 1 return value because `and` is a
    -- binary expression, not a function call. Only raw function calls at the tail
    -- of an expression list preserve multiple returns.

    if collectionType == "mount" then
        if not C_MountJournal or not C_MountJournal.GetMountInfoByID then return nil end
        local name, spellID, icon, _, _, sourceType, _, _, _, _, isCollected = C_MountJournal.GetMountInfoByID(id)
        if name then
            icon = validIcon(icon)
            -- Fallback: spell texture
            if not icon and spellID then
                if C_Spell and C_Spell.GetSpellTexture then
                    icon = validIcon(C_Spell.GetSpellTexture(spellID))
                elseif GetSpellTexture then
                    icon = validIcon(GetSpellTexture(spellID))
                end
            end
            if not icon then usedFallbackIcon = true end
            local _, description, source = C_MountJournal.GetMountInfoExtraByID(id)
            if issecretvalue and sourceType and issecretvalue(sourceType) then sourceType = nil end
            if (not sourceType or sourceType == 0) and self.GetMountBrowseSourceType then
                local resolved = self:GetMountBrowseSourceType(id)
                if resolved and resolved > 0 then sourceType = resolved end
            end
            meta = { name = name, icon = icon or "Interface\\Icons\\Ability_Mount_RidingHorse", source = source or "", sourceType = sourceType, description = description or "" }
            -- Current collected state (Midnight: isCollected may be secret)
            if issecretvalue and isCollected and issecretvalue(isCollected) then
                meta.isCollected = true
            else
                meta.isCollected = isCollected == true
            end
        end
    elseif collectionType == "pet" then
        if not C_PetJournal or not C_PetJournal.GetPetInfoBySpeciesID then return nil end
        -- 12.0.5: GetPetInfoBySpeciesID returns 12 values; #11 is 'obtainable' boolean
        local name, icon, _, _, source, description, _, _, _, _, obtainable = C_PetJournal.GetPetInfoBySpeciesID(id)
        if name then
            -- Guard secret values (Midnight 12.0+)
            if issecretvalue and name and issecretvalue(name) then return nil end
            -- Filter unobtainable pets (pure API)
            if obtainable == false then return nil end
            icon = validIcon(icon)
            if not icon then usedFallbackIcon = true end
            local creatureDisplayID = nil
            if C_PetJournal.GetNumDisplays and C_PetJournal.GetDisplayIDByIndex then
                local numDisplays = C_PetJournal.GetNumDisplays(id) or 0
                if numDisplays > 0 then
                    creatureDisplayID = C_PetJournal.GetDisplayIDByIndex(id, 1)
                end
            end
            local numCollected = C_PetJournal.GetNumCollectedInfo and C_PetJournal.GetNumCollectedInfo(id)
            local isCollected = numCollected and numCollected > 0
            local sourceTypeIndex = (collectionStore.pet and collectionStore.pet[id] and collectionStore.pet[id].sourceTypeIndex)
                or (ns._petSpeciesToSourceIndex and ns._petSpeciesToSourceIndex[id])
                or nil
            meta = { name = name, icon = icon or "Interface\\Icons\\INV_Box_PetCarrier_01", source = source or "", sourceTypeIndex = sourceTypeIndex, description = description or "", creatureDisplayID = creatureDisplayID, isCollected = isCollected, obtainable = obtainable }
        end
    elseif collectionType == "toy" then
        local info = self:GetToySourceInfo(id)
        if info then
            local icon = validIcon(info.icon)
            if not icon then usedFallbackIcon = true end
            local isCollected
            if PlayerHasToy then
                local isCollectedRaw = PlayerHasToy(id)
                if issecretvalue and isCollectedRaw and issecretvalue(isCollectedRaw) then
                    isCollected = true
                else
                    isCollected = isCollectedRaw == true
                end
            end
            local sourceLine = self:GetToySourceTypeNameForItem(id) or ((ns.L and ns.L["SOURCE_UNKNOWN"]) or "Unknown")
            if sourceLine == "SOURCE_UNKNOWN" then sourceLine = "Unknown" end
            local sourceTypeIndex = self:GetToySourceTypeIndexForItem(id)
            meta = {
                name = info.name,
                icon = icon or "Interface\\Icons\\INV_Misc_Toy_07",
                source = sourceLine,
                sourceTypeIndex = sourceTypeIndex,
                description = info.description or "",
                itemQuality = info.itemQuality,
                isCollected = isCollected,
            }
        end
    elseif collectionType == "achievement" then
        local ok, _, achName, points, _, _, _, _, description, _, achIcon, achRewardText = pcall(GetAchievementInfo, id)
        if ok and achName then
            achIcon = validIcon(achIcon)
            local categoryID = GetAchievementCategory and GetAchievementCategory(id) or nil
            local rewardItemID, rewardTitle
            local rewardOk, rItem, rTitle = pcall(GetAchievementReward, id)
            if rewardOk then
                if type(rItem) == "number" then rewardItemID = rItem end
                if type(rTitle) == "string" and rTitle ~= "" then
                    rewardTitle = rTitle
                elseif type(rItem) == "string" and rItem ~= "" then
                    rewardTitle = rItem
                end
            end
            if not rewardTitle and achRewardText and achRewardText ~= "" then
                rewardTitle = achRewardText
            end
            meta = { name = achName, icon = achIcon or "Interface\\Icons\\Achievement_General", points = points or 0, description = description or "", source = description or "", categoryID = categoryID, rewardItemID = rewardItemID, rewardTitle = rewardTitle, rewardText = rewardTitle }
        end
    elseif collectionType == "illusion" then
        if not C_TransmogCollection or not C_TransmogCollection.GetIllusionStrings then return nil end
        local name, _, sourceText = C_TransmogCollection.GetIllusionStrings(id)
        if name then
            local illusionIcon = nil
            local illusionSourceFallback = nil
            if C_TransmogCollection.GetIllusionInfo then
                local illusionInfo = C_TransmogCollection.GetIllusionInfo(id)
                if illusionInfo then
                    illusionIcon = validIcon(illusionInfo.icon)
                    -- Secondary source fallback from illusionInfo
                    if (not sourceText or sourceText == "") and illusionInfo.sourceText then
                        illusionSourceFallback = illusionInfo.sourceText
                    end
                end
            end
            local resolvedSource = sourceText
            if not resolvedSource or resolvedSource == "" then
                resolvedSource = illusionSourceFallback or ((ns.L and ns.L["SOURCE_ENCHANTING"]) or "Enchanting")
            end
            meta = { name = name, icon = illusionIcon or "Interface\\Icons\\INV_Enchant_Disenchant", source = resolvedSource, description = "" }
        end
    elseif collectionType == "title" then
        if not GetTitleName then return nil end
        local titleString = GetTitleName(id)
        if titleString then
            meta = { name = titleString, icon = "Interface\\Icons\\INV_Scroll_11", source = "", description = "" }
        end
    end

    if not meta and collectionType == "mount" and mountFallbackMeta then
        meta = mountFallbackMeta
    end

    if meta then
        meta.id = id
        meta.type = collectionType
        meta.collected = meta.isCollected or false
        if meta.isCollected == nil then
            meta.isCollected = false
        end
        -- Only permanently cache metadata with real API icons.
        -- If fallback icon was used (API returned 0/nil), DON'T cache — allow re-query
        -- on next access so the real icon can be resolved once the data is available.
        if not usedFallbackIcon then
            if collectionType == "mount" then meta._cacheTime = debugprofilestop() end
            metadataCacheSet(cacheKey, meta)
        end
    end
    return meta
end

---Clear session-only metadata cache (call when user leaves Plans tab to free RAM).
function WarbandNexus:ClearCollectionMetadataCache()
    wipe(metadataCache)
    wipe(metadataCacheOrder)
    metadataCacheHead = 1
    WipeUncollectedResultsCacheAndMergedAchievements()
    if ns._toyItemIDToSourceIndexCache then ns._toyItemIDToSourceIndexCache.map = nil end
end

---Get uncollected mounts (UNIFIED: collectionStore-first, scan if empty). Plans shows uncollected only.
---@return table Array of uncollected mounts {id, name, icon, source, ...}
function WarbandNexus:GetUncollectedMounts(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)

    -- Results cache: avoid expensive store iteration on rapid redraws
    local cacheKey = "mount:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.mount
    local hasData = store and next(store) ~= nil

    if hasData then
        local results = {}
        local count = 0
        local storeChanged = false
        for mountID, d in pairs(store) do
            if d then
                -- Always resolve from Collections API metadata.
                -- This prevents stale legacy DB source text from leaking into Plans.
                local meta = self:ResolveCollectionMetadata("mount", mountID)

                -- If mountID no longer resolves from API, skip (do NOT prune — API may
                -- be temporarily unavailable during loading or combat lockdown).
                if not meta then
                    -- noop: keep store entry intact for next attempt
                elseif meta.isCollected then
                    -- Keep store in sync when API reports collected.
                    if d.collected ~= true then
                        d.collected = true
                        storeChanged = true
                    end
                else
                    local includeInPlans = true
                    if self.ShouldExcludeMountFromCollectionBrowse and self:ShouldExcludeMountFromCollectionBrowse(mountID) then
                        includeInPlans = false
                    end

                    if includeInPlans then
                    if d.collected ~= false then
                        d.collected = false
                        storeChanged = true
                    end

                    local name = meta.name or ("ID:" .. tostring(mountID))
                    if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                        meta.id = mountID
                        meta.isCollected = false
                        meta.collected = false
                        results[#results + 1] = meta
                        count = count + 1
                        if limit and count >= limit then
                            if storeChanged then
                                ScheduleDeferredCollectionStoreSave()
                            end
                            uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
                            return results
                        end
                    end
                    end
                end
            end
        end
        if storeChanged then
            ScheduleDeferredCollectionStoreSave()
        end
        uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
        return results
    end

    -- Store empty: one deferred ensure per category (Plans browse loop guard via message bus).
    EmitPlansBrowseCollectionEnsure("mount")
    return {}
end

---Get uncollected pets (UNIFIED: collectionStore-first). Plans shows uncollected only.
function WarbandNexus:GetUncollectedPets(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)

    local cacheKey = "pet:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.pet
    local hasData = store and next(store) ~= nil

    if hasData then
        local results = {}
        local count = 0
        for petID, d in pairs(store) do
            if d and (d.collected == false or d.collected == nil) then
                local name = d.name or ("ID:" .. tostring(petID))
                if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                    local meta
                    if d.icon or d.source then
                        meta = { id = petID, name = d.name, icon = d.icon, source = d.source, description = d.description, creatureDisplayID = d.creatureDisplayID, isCollected = false }
                    else
                        meta = self:ResolveCollectionMetadata("pet", petID)
                        if meta then meta.isCollected = false end
                    end
                    if meta then
                        results[#results + 1] = meta
                        count = count + 1
                        if limit and count >= limit then
                            uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
                            return results
                        end
                    end
                end
            end
        end
        uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
        return results
    end

    if self.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
        ScheduleEnsureCollectionDataDeferred()
    end
    return {}
end

---Get uncollected toys (UNIFIED: collectionStore-first). Plans shows uncollected only.
---When store has fallback source ("Toy Collection"/"Toy Box"), re-resolves from tooltip so Plans shows correct source.
function WarbandNexus:GetUncollectedToys(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)

    local cacheKey = "toy:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.toy
    local hasData = store and next(store) ~= nil

    if hasData then
        local results = {}
        local count = 0
        for toyID, d in pairs(store) do
            if d and (d.collected == false or d.collected == nil) then
                local name = d.name or ("ID:" .. tostring(toyID))
                if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                    local meta
                    local sourceOk = self:IsReliableToySource(d.source)
                    if (d.icon or d.source) and sourceOk then
                        meta = { id = toyID, name = d.name, icon = d.icon, source = d.source, description = d.description, isCollected = false }
                    else
                        meta = self:ResolveCollectionMetadata("toy", toyID)
                        if meta then
                            meta.isCollected = false
                            meta.id = toyID
                        end
                    end
                    if meta then
                        results[#results + 1] = meta
                        count = count + 1
                        if limit and count >= limit then
                            uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
                            return results
                        end
                    end
                end
            end
        end
        uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
        return results
    end

    if self.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
        ScheduleEnsureCollectionDataDeferred()
    end
    return {}
end

--- Collected mounts for Plans "Show Completed" (GetUncollectedMounts only returns uncollected).
function WarbandNexus:GetCollectedMounts(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)
    local cacheKey = "c_mount:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.mount
    if not store or next(store) == nil then
        EmitPlansBrowseCollectionEnsure("mount")
        return {}
    end

    local results = {}
    local count = 0
    for mountID, d in pairs(store) do
        if d then
            local meta = self:ResolveCollectionMetadata("mount", mountID)
            if meta and meta.isCollected then
                if not (self.ShouldExcludeMountFromCollectionBrowse and self:ShouldExcludeMountFromCollectionBrowse(mountID)) then
                    local name = meta.name or ("ID:" .. tostring(mountID))
                    if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                        local row = {}
                        for k, v in pairs(meta) do
                            row[k] = v
                        end
                        row.id = mountID
                        row.isCollected = true
                        row.collected = true
                        results[#results + 1] = row
                        count = count + 1
                        if limit and count >= limit then break end
                    end
                end
            end
        end
    end
    uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
    return results
end

function WarbandNexus:GetCollectedPets(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)
    local cacheKey = "c_pet:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.pet
    if not store or next(store) == nil then
        EmitPlansBrowseCollectionEnsure("pet")
        return {}
    end

    local results = {}
    local count = 0
    for petID, d in pairs(store) do
        if d and d.collected == true then
            local name = d.name or ("ID:" .. tostring(petID))
            if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                local meta
                if d.icon or d.source then
                    meta = { id = petID, name = d.name, icon = d.icon, source = d.source, sourceTypeIndex = d.sourceTypeIndex, description = d.description, creatureDisplayID = d.creatureDisplayID, isCollected = true, collected = true }
                else
                    meta = self:ResolveCollectionMetadata("pet", petID)
                    if meta then
                        meta.id = petID
                        meta.isCollected = true
                        meta.collected = true
                    end
                end
                if meta then
                    results[#results + 1] = meta
                    count = count + 1
                    if limit and count >= limit then break end
                end
            end
        end
    end
    uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
    return results
end

function WarbandNexus:GetCollectedToys(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)
    local cacheKey = "c_toy:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.toy
    if not store or next(store) == nil then
        EmitPlansBrowseCollectionEnsure("toy")
        return {}
    end

    local results = {}
    local count = 0
    for toyID, d in pairs(store) do
        if d and d.collected == true then
            local name = d.name or ("ID:" .. tostring(toyID))
            if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                local meta
                local sourceOk = self:IsReliableToySource(d.source)
                if (d.icon or d.source) and sourceOk then
                    meta = { id = toyID, name = d.name, icon = d.icon, source = d.source, sourceTypeIndex = self:GetToySourceTypeIndexForItem(toyID), description = d.description, isCollected = true, collected = true }
                else
                    meta = self:ResolveCollectionMetadata("toy", toyID)
                    if meta then
                        meta.id = toyID
                        meta.isCollected = true
                        meta.collected = true
                    end
                end
                if meta then
                    results[#results + 1] = meta
                    count = count + 1
                    if limit and count >= limit then break end
                end
            end
        end
    end
    uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
    return results
end

function WarbandNexus:GetCollectedIllusions(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)
    local cacheKey = "c_illusion:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.illusion
    if not store or next(store) == nil then
        if self.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
            ScheduleEnsureCollectionDataDeferred()
        end
        return {}
    end

    local results = {}
    local count = 0
    for illusionID, d in pairs(store) do
        if d and d.collected == true then
            local name = d.name or ("ID:" .. tostring(illusionID))
            if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                local base = (d.icon or d.source) and d or self:ResolveCollectionMetadata("illusion", illusionID)
                local meta = base and { id = illusionID, name = base.name or name, icon = base.icon, source = base.source, isCollected = true, collected = true }
                if meta then
                    results[#results + 1] = meta
                    count = count + 1
                    if limit and count >= limit then break end
                end
            end
        end
    end
    uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
    return results
end

function WarbandNexus:GetCollectedTitles(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)
    local cacheKey = "c_title:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.title
    if not store or next(store) == nil then
        if self.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
            ScheduleEnsureCollectionDataDeferred()
        end
        return {}
    end

    local results = {}
    local count = 0
    for titleID, d in pairs(store) do
        if d and d.collected == true then
            local name = d.name or d.rewardText or ("ID:" .. tostring(titleID))
            if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                local base = (d.icon or d.rewardText) and d or self:ResolveCollectionMetadata("title", titleID)
                local meta = base and {
                    id = titleID,
                    name = base.name or base.rewardText or name,
                    icon = base.icon,
                    rewardText = base.rewardText,
                    isCollected = true,
                    collected = true,
                }
                if meta then
                    results[#results + 1] = meta
                    count = count + 1
                    if limit and count >= limit then break end
                end
            end
        end
    end
    uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
    return results
end

---Async achievement scanner (background scanning with coroutine)
---Scans all achievements and populates achievement cache
---NOTE: Titles are now scanned separately via Title API
function WarbandNexus:ScanAchievementsAsync()
    -- Prevent duplicate scans
    if activeCoroutines["achievements"] then
    DebugPrint("|cffffcc00[WN CollectionService]|r Achievement scan already in progress")
        return
    end
    
    -- Use separate lastScan timestamp for achievements
    -- Don't share with mount/pet/toy (they update global lastScan)
    local lastAchievementScan = collectionCache.lastAchievementScan or 0
    local timeSinceLastScan = time() - lastAchievementScan
    
    -- Validate timestamp (prevent negative values from corrupted data)
    if timeSinceLastScan < 0 then
    DebugPrint("|cffffcc00[WN CollectionService]|r Invalid lastAchievementScan timestamp detected, forcing scan")
        timeSinceLastScan = math.huge  -- Force scan
    end
    
    -- Check cooldown (5 minutes)
    if timeSinceLastScan < 300 then
    DebugPrintf("|cffffcc00[WN CollectionService]|r Achievement scan skipped (last scan %.0f seconds ago)", timeSinceLastScan)
        return
    end
    
    DebugPrint("|cff9370DB[WN CollectionService]|r Starting async achievement scan...")
    
    -- Update loading state (may already be set by GetUncollectedAchievements)
    ns.CollectionLoadingState.isLoading = true
    ns.CollectionLoadingState.loadingProgress = 0
    ns.CollectionLoadingState.currentStage = "Achievements"
    ns.CollectionLoadingState.currentCategory = "achievement"
    ns.CollectionLoadingState.scannedItems = 0
    ns.CollectionLoadingState.totalItems = 0
    
    -- Fire initial progress event to trigger UI update
    SendCollectionScanProgress(self, "achievement", 0, 0, 0)
    
    local startTime = debugprofilestop()  -- Use debugprofilestop for elapsed time measurement
    local totalAchievements = 0
    local scannedCount = 0
    local totalEstimated = 5000  -- Rough estimate for progress bar
    local lastProgressUpdate = 0  -- Throttle progress events
    local lastYield = debugprofilestop()  -- Track time since last yield
    
    local function scanCoroutine()
        collectionCache.uncollected.achievement = {}
        collectionCache.completed = collectionCache.completed or {}
        collectionCache.completed.achievement = {}

        local categoryList = GetCategoryList()
        if not categoryList or #categoryList == 0 then
    DebugPrint("|cffff0000[WN CollectionService]|r GetCategoryList() returned no categories")
            coroutine.yield()
            return
        end
        
        for i = 1, #categoryList do
            local categoryID = categoryList[i]
            if categoryID then
                local numAchievements = GetCategoryAchievementListCount(categoryID)

                for achIndex = 1, numAchievements do
                    -- GetAchievementInfo with pcall protection
                    local success, id, name, points, completed, month, day, year, description, flags, icon, achRewardText = pcall(GetAchievementInfo, categoryID, achIndex)
                    
                    -- Update progress (throttled to reduce event spam)
                    scannedCount = scannedCount + 1
                    
                    -- OPTIMIZATION: Only fire progress event every 250 achievements (not 100)
                    -- This reduces UI refresh spam and improves FPS
                    if scannedCount % 250 == 0 then
                        local progress = math.min(100, math.floor((scannedCount / totalEstimated) * 100))
                        local timeSinceLastUpdate = debugprofilestop() - lastProgressUpdate
                        
                        -- Only update if at least 100ms passed (throttle UI updates)
                        if timeSinceLastUpdate >= 100 then
                            ns.CollectionLoadingState.loadingProgress = progress
                            ns.CollectionLoadingState.scannedItems = scannedCount
                            
                            -- Also update PlansLoadingState for UI (achievement uses PlansLoadingState)
                            if ns.PlansLoadingState and ns.PlansLoadingState.achievement then
                                ns.PlansLoadingState.achievement.loadingProgress = progress
                                ns.PlansLoadingState.achievement.currentStage = string.format("Scanning Achievements... (%d/%d)", scannedCount, totalEstimated)
                            end
                            
                            lastProgressUpdate = debugprofilestop()
                            
                            -- Fire progress event (UI will throttle refreshes)
                            SendCollectionScanProgress(self, "achievement", progress, scannedCount, totalEstimated)
                        end
                    end
                    
                    if not success or not id or not name then
                        -- Yield and continue
                    else
                        local rewardItemID, rewardTitle
                        local rewardSuccess, item, title = pcall(GetAchievementReward, id)
                        if rewardSuccess then
                            if type(item) == "number" then rewardItemID = item end
                            if type(title) == "string" and title ~= "" then
                                rewardTitle = title
                            elseif type(item) == "string" and item ~= "" then
                                rewardTitle = item
                            end
                        end
                        if not rewardTitle and achRewardText and achRewardText ~= "" then
                            rewardTitle = achRewardText
                        end

                        local displayName = (name and name ~= "") and name or ("ID:" .. tostring(id))
                        -- Store the achievement's ACTUAL category (not the iterator's parent category)
                        local realCategoryID = categoryID
                        if GetAchievementCategory then
                            local actualCat = GetAchievementCategory(id)
                            if actualCat and actualCat > 0 then
                                realCategoryID = actualCat
                            end
                        end
                        local record = {
                            id = id,
                            name = displayName,
                            icon = icon,
                            points = points,
                            description = description,
                            collected = completed,
                            categoryID = realCategoryID,
                            rewardItemID = rewardItemID,
                            rewardTitle = rewardTitle,
                        }
                        collectionStore.achievement[id] = record
                        if completed then
                            collectionCache.completed.achievement[id] = displayName
                        else
                            collectionCache.uncollected.achievement[id] = displayName
                            totalAchievements = totalAchievements + 1
                        end
                    end
                    
                    -- OPTIMIZATION: Yield every 100 achievements (improved from 20)
                    -- Check frame budget (don't exceed 8ms per frame)
                    if scannedCount % 100 == 0 then
                        -- Check if scan was aborted (tab switch)
                        if not activeCoroutines["achievements"] then
    DebugPrint("|cffffcc00[WN CollectionService]|r Achievement scan aborted")
                            if ns.PlansLoadingState and ns.PlansLoadingState.achievement then
                                ns.PlansLoadingState.achievement.isLoading = false
                            end
                            return  -- Exit coroutine gracefully
                        end
                        
                        local timeSinceYield = debugprofilestop() - lastYield
                        -- If we've used more than 8ms, yield immediately
                        if timeSinceYield >= 8 then
                            lastYield = debugprofilestop()
                            coroutine.yield()
                        end
                    end
                end
            end
        end
        
        -- Achievement-specific timestamp only. Writing the shared lastScan here made
        -- ScanCollection's 5-minute skip gate treat title/illusion caches as fresh and
        -- starve those scans right after an achievement pass.
        collectionCache.lastAchievementScan = time()
        
        -- Save to DB
        self:SaveCollectionCache()

        if self.db and self.db.global then
            self.db.global.wnAchievementDirectScanV2 = true
        end
        
        local elapsed = (debugprofilestop() - startTime) / 1000  -- Convert ms to seconds
        
        -- Update loading state
        ns.CollectionLoadingState.isLoading = false
        ns.CollectionLoadingState.loadingProgress = 100
        ns.CollectionLoadingState.scannedItems = scannedCount
        
        -- Update PlansLoadingState for UI (achievement uses PlansLoadingState, not CollectionLoadingState)
        if ns.PlansLoadingState and ns.PlansLoadingState.achievement then
            ns.PlansLoadingState.achievement.isLoading = false
            ns.PlansLoadingState.achievement.loadingProgress = 100
            ns.PlansLoadingState.achievement.currentStage = "Complete!"
        end
        
    DebugPrintf("|cff00ff00[WN CollectionService]|r Achievement scan complete: %d achievements (%.2fs)",
        totalAchievements, elapsed)
        
        -- Fire completion event
        self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, {
            category = "achievement",
            totalAchievements = totalAchievements,
            elapsed = elapsed,
        })
        
        -- UI refreshes via WN_COLLECTION_SCAN_COMPLETE listener in UI.lua (event-driven).
        
        -- Cleanup
        activeCoroutines["achievements"] = nil
    end
    
    -- Start coroutine
    local co = coroutine.create(scanCoroutine)
    activeCoroutines["achievements"] = co
    
    local function resetAchievementLoadingState()
        -- Normal completion already reset both loading states and sent
        -- COLLECTION_SCAN_COMPLETE from inside the coroutine; firing again here made
        -- every listener (UI refresh, results-cache wipe) process the scan twice.
        -- Only act for abnormal exits (resume error, mid-scan abort).
        local stillLoading = ns.CollectionLoadingState.isLoading
            or (ns.PlansLoadingState and ns.PlansLoadingState.achievement and ns.PlansLoadingState.achievement.isLoading)
        if not stillLoading then return end
        ns.CollectionLoadingState.isLoading = false
        ns.CollectionLoadingState.loadingProgress = 100
        if ns.PlansLoadingState and ns.PlansLoadingState.achievement then
            ns.PlansLoadingState.achievement.isLoading = false
            ns.PlansLoadingState.achievement.loadingProgress = 100
        end
        -- Ensure EnsureCollectionData queue can advance even on failure
        if Constants and Constants.EVENTS then
            self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, { category = "achievement" })
        end
    end

    -- Run coroutine with frame budget
    local function resumeCoroutine()
        local co = activeCoroutines["achievements"]
        if not co then return end
        if InCombatLockdown and InCombatLockdown() then
            C_Timer.After(0, resumeCoroutine)
            return
        end

        local budget = FRAME_BUDGET_MS
        local startTime = debugprofilestop()
        
        while (debugprofilestop() - startTime) < budget do
            local status, err = coroutine.resume(co)
            if not status then
    DebugPrint("|cffff0000[WN CollectionService ERROR]|r Achievement scan failed: " .. tostring(err))
                activeCoroutines["achievements"] = nil
                resetAchievementLoadingState()
                return
            end
            
            -- Check if coroutine finished
            if coroutine.status(co) == "dead" then
                activeCoroutines["achievements"] = nil
                resetAchievementLoadingState()
                return
            end
        end
        
        -- Continue next frame
        C_Timer.After(0, resumeCoroutine)
    end
    
    resumeCoroutine()
end

---Get uncollected achievements (UNIFIED: collectionStore-first). Plans shows uncollected only.
function WarbandNexus:GetUncollectedAchievements(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)

    local cacheKey = "ach_u:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.achievement
    local hasData = store and next(store) ~= nil

    if hasData then
        local results = {}
        local count = 0
        for achID, d in pairs(store) do
            if d and (d.collected == false or d.collected == nil) then
                local name = d.name or ("ID:" .. tostring(achID))
                if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                    local meta
                    if d.icon or d.points then
                        meta = { id = achID, name = d.name, icon = d.icon, points = d.points, description = d.description, isCollected = false, categoryID = d.categoryID, rewardItemID = d.rewardItemID, rewardTitle = d.rewardTitle, rewardText = d.rewardTitle }
                    else
                        meta = self:ResolveCollectionMetadata("achievement", achID)
                        if meta then meta.id = achID; meta.isCollected = false end
                    end
                    if meta and not meta.categoryID and GetAchievementCategory then
                        meta.categoryID = GetAchievementCategory(achID)
                    end
                    if meta then
                        results[#results + 1] = meta
                        count = count + 1
                        if limit and count >= limit then
                            uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
                            return results
                        end
                    end
                end
            end
        end
        uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
        return results
    end

    return {}
end

---Get completed achievements (UNIFIED: collectionStore-first). Collections shows collected only.
function WarbandNexus:GetCompletedAchievements(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)

    local cacheKey = "ach_c:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.achievement
    local hasData = store and next(store) ~= nil

    if not hasData then
        if collectionCache.uncollected.achievement and next(collectionCache.uncollected.achievement) then
            return {}
        end
        return self:GetUncollectedAchievements(searchText, 0)
    end

    local results = {}
    local count = 0
    for achID, d in pairs(store) do
        if d and d.collected == true then
            local name = d.name or ("ID:" .. tostring(achID))
            if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                local meta
                if d.icon or d.points then
                    meta = { id = achID, name = d.name, icon = d.icon, points = d.points, description = d.description, isCollected = true, categoryID = d.categoryID, rewardItemID = d.rewardItemID, rewardTitle = d.rewardTitle, rewardText = d.rewardTitle }
                else
                    meta = self:ResolveCollectionMetadata("achievement", achID)
                    if meta then meta.id = achID; meta.isCollected = true end
                end
                if meta and not meta.categoryID and GetAchievementCategory then
                    meta.categoryID = GetAchievementCategory(achID)
                end
                if meta then
                    results[#results + 1] = meta
                    count = count + 1
                    if limit and count >= limit then
                        uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
                        return results
                    end
                end
            end
        end
    end
    uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
    return results
end

---Get uncollected illusions (UNIFIED: collectionStore-first). Plans shows uncollected only.
function WarbandNexus:GetUncollectedIllusions(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)

    local cacheKey = "illusion:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.illusion
    local hasData = store and next(store) ~= nil

    if hasData then
        local results = {}
        local count = 0
        for illusionID, d in pairs(store) do
            if d and (d.collected == false or d.collected == nil) then
                local name = d.name or ("ID:" .. tostring(illusionID))
                if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                    local meta = (d.icon or d.source) and d or self:ResolveCollectionMetadata("illusion", illusionID)
                    if meta then
                        meta = { id = illusionID, name = meta.name or name, icon = meta.icon, source = meta.source, isCollected = false }
                        results[#results + 1] = meta
                        count = count + 1
                        if limit and count >= limit then
                            uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
                            return results
                        end
                    end
                end
            end
        end
        uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
        return results
    end

    if self.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
        ScheduleEnsureCollectionDataDeferred()
    end
    return {}
end

---Get uncollected titles (UNIFIED: collectionStore-first). Plans shows uncollected only.
function WarbandNexus:GetUncollectedTitles(searchText, limit)
    searchText = NormalizeCollectionSearchText(searchText)

    local cacheKey = "title:" .. searchText .. ":" .. tostring(limit or "nil")
    local cached = uncollectedResultsCache[cacheKey]
    if cached and (GetTime() - cached.t) < RESULTS_CACHE_TTL then return cached.r end

    local store = collectionStore.title
    local hasData = store and next(store) ~= nil

    if hasData then
        local results = {}
        local count = 0
        for titleID, d in pairs(store) do
            if d and (d.collected == false or d.collected == nil) then
                local name = d.name or d.rewardText or ("ID:" .. tostring(titleID))
                if searchText == "" or (type(name) == "string" and name:lower():find(searchText, 1, true)) then
                    local meta = (d.icon or d.rewardText) and d or self:ResolveCollectionMetadata("title", titleID)
                    if meta then
                        meta = { id = titleID, name = meta.name or meta.rewardText or name, icon = meta.icon, rewardText = meta.rewardText, isCollected = false }
                        results[#results + 1] = meta
                        count = count + 1
                        if limit and count >= limit then
                            uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
                            return results
                        end
                    end
                end
            end
        end
        uncollectedResultsCache[cacheKey] = { r = results, t = GetTime() }
        return results
    end

    if self.EnsureCollectionData and not ns.CollectionLoadingState.isLoading then
        ScheduleEnsureCollectionDataDeferred()
    end
    return {}
end

-- ACHIEVEMENT REWARD SCANNER

---Scan achievement rewards and categorize them (mount, pet, toy, transmog, etc.)
---@return table|nil Reward info {type, itemID, itemName, icon}
function WarbandNexus:GetAchievementRewardInfo(achievementID)
    if not achievementID then return nil end
    
    local rewardItemID, rewardTitle
    local success, ret1, ret2 = pcall(GetAchievementReward, achievementID)
    if success then
        if type(ret1) == "number" then rewardItemID = ret1 end
        if type(ret2) == "string" and ret2 ~= "" then
            rewardTitle = ret2
        elseif type(ret1) == "string" and ret1 ~= "" then
            rewardTitle = ret1
        end
    end
    
    -- Title reward
    if rewardTitle and rewardTitle ~= "" then
        local _, name, _, _, _, _, _, _, _, icon = GetAchievementInfo(achievementID)
        return {
            type = "title",
            title = rewardTitle,
            achievementName = name,
            achievementID = achievementID,
            icon = icon or 134400
        }
    end
    
    -- Item reward (mount, pet, toy, transmog)
    if rewardItemID then
        local GetItemInfoFn = C_Item and C_Item.GetItemInfo or GetItemInfo
        local itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfoFn(rewardItemID)
        if not itemName then
            local item = Item:CreateFromItemID(rewardItemID)
            if item then
                item:ContinueOnItemLoad(function()
                    if WarbandNexus.SendMessage then
                        WarbandNexus:SendMessage(E.ITEM_METADATA_READY, achievementID)
                    end
                end)
            end
            -- Return text-only fallback from GetAchievementInfo while item loads
            local infoOk, _, _, _, _, _, _, _, _, _, _, achRewardText = pcall(GetAchievementInfo, achievementID)
            if infoOk and achRewardText and achRewardText ~= "" then
                return { type = "item", itemID = rewardItemID, itemName = achRewardText, icon = nil, achievementID = achievementID }
            end
            return nil
        end
        
        -- Determine item type
        local rewardType = "item"
        if C_MountJournal and C_MountJournal.GetMountFromItem and C_MountJournal.GetMountFromItem(rewardItemID) then
            rewardType = "mount"
        elseif C_PetJournal and C_PetJournal.GetPetInfoByItemID and C_PetJournal.GetPetInfoByItemID(rewardItemID) then
            rewardType = "pet"
        elseif C_ToyBox and C_ToyBox.GetToyInfo and C_ToyBox.GetToyInfo(rewardItemID) then
            rewardType = "toy"
        elseif C_TransmogCollection and C_TransmogCollection.GetItemInfo and C_TransmogCollection.GetItemInfo(rewardItemID) then
            rewardType = "transmog"
        end
        
        return {
            type = rewardType,
            itemID = rewardItemID,
            itemName = itemName,
            icon = itemIcon,
            achievementID = achievementID
        }
    end
    
    -- Final fallback: GetAchievementInfo's rewardText covers rewards
    -- not exposed by GetAchievementReward (e.g. "Reward: Pathfinder's Overcoat")
    local infoOk, _, _, _, _, _, _, _, _, _, _, fallbackRewardText = pcall(GetAchievementInfo, achievementID)
    if infoOk and fallbackRewardText and fallbackRewardText ~= "" then
        return {
            type = "text",
            title = fallbackRewardText,
            achievementID = achievementID,
        }
    end
    
    return nil
end

---Add achievement link to item description
---@return table Enhanced item data with achievement link
function WarbandNexus:EnhanceItemWithAchievement(itemData, achievementID)
    if not itemData or not achievementID then return itemData end
    
    local _, achievementName, _, _, _, _, _, _, _, achievementIcon = GetAchievementInfo(achievementID)
    if achievementName then
        itemData.sourceAchievement = achievementID
        itemData.sourceAchievementName = achievementName
        itemData.sourceAchievementIcon = achievementIcon
        local fromAchLabel = (ns.L and ns.L["PARSE_FROM_ACHIEVEMENT"]) or "From Achievement"
        itemData.description = (itemData.description or "") .. "\n\n|cff00ff00" .. fromAchLabel .. ":|r " .. achievementName
    end
    
    return itemData
end

-- Bag scan satellite: CollectionService_Scan.lua (OnBagUpdateForCollectibles + event host)
local CollectionScan = ns.CollectionScan
assert(CollectionScan and CollectionScan.InstallBagScanListener, "CollectionService: load CollectionService_Scan.lua first")
CollectionScan.InstallBagScanListener()

-- Expose CollectionService reference for external modules
ns.CollectionService = ns.CollectionService or {}
ns.CollectionService.collectionCache = collectionCache

-- INITIALIZATION

-- Module loaded - verbose logging removed for normal users
