--[[
    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.

    NOTIFICATION FLOW (toast lane; display in NotificationManager):
      NEW_MOUNT_ADDED / NEW_PET_ADDED / NEW_TOY_ADDED / bag scan -> dedup -> WN_COLLECTIBLE_OBTAINED
      ACHIEVEMENT_EARNED + Warband achievement popups ON -> AddAlert hook (ShowAchievementNotification)
        + ScheduleAchievementToastFallback if AddAlert never fires
      ACHIEVEMENT_EARNED + Warband popups OFF -> Blizzard AddAlert only (suppressToast on WN_COLLECTIBLE_OBTAINED)
    Currency/reputation: ChatMessageService only (not WN_COLLECTIBLE_OBTAINED).

    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

local function CollectionNameMatchesSearch(name, searchText)
    if searchText == "" then return true end
    if type(name) ~= "string" then return false end
    local lower = (Utilities and Utilities.SafeLower and Utilities:SafeLower(name)) or ""
    if lower == "" then return false end
    return lower:find(searchText, 1, true) ~= nil
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

---GetCategoryList() is empty until achievement APIs hydrate (often after login).
local ACHIEVEMENT_CATEGORY_LIST_MIN_IDS = 40
local achievementScanDeferAttempts = 0
local ACHIEVEMENT_SCAN_DEFER_MAX_ATTEMPTS = 24

local function AchievementCategoryListReady()
    if not GetCategoryList then return false end
    local list = GetCategoryList() or {}
    return #list >= ACHIEVEMENT_CATEGORY_LIST_MIN_IDS
end

local function EnsurePlansAchievementLoadingState(isLoading)
    if not ns.PlansLoadingState then ns.PlansLoadingState = {} end
    if not ns.PlansLoadingState.achievement then
        ns.PlansLoadingState.achievement = { isLoading = false, loader = nil }
    end
    if isLoading == nil then return end
    ns.PlansLoadingState.achievement.isLoading = isLoading
    if isLoading then
        ns.PlansLoadingState.achievement.loadingProgress = ns.PlansLoadingState.achievement.loadingProgress or 0
        ns.PlansLoadingState.achievement.currentStage = ns.PlansLoadingState.achievement.currentStage
            or ((ns.L and ns.L["LOADING_ACHIEVEMENTS"]) or "Loading achievements...")
    end
end

local function SafeAchievementDisplayName(id, name)
    if not id or (issecretvalue and issecretvalue(id)) then return nil end
    if name and name ~= "" then
        if issecretvalue and issecretvalue(name) then
            return "ID:" .. tostring(id)
        end
        return name
    end
    return "ID:" .. tostring(id)
end

---GetAchievementInfo(categoryID, index[, includeAll]) — try includeAll=true first (journal parity).
local function GetAchievementInfoByCategoryIndex(categoryID, achIndex)
    if not GetAchievementInfo or not categoryID or not achIndex then return false end
    local ok, id, name, points, completed, month, day, year, description, flags, icon, achRewardText =
        pcall(GetAchievementInfo, categoryID, achIndex, true)
    if ok and id and not (issecretvalue and issecretvalue(id)) then
        return true, id, name, points, completed, month, day, year, description, flags, icon, achRewardText
    end
    ok, id, name, points, completed, month, day, year, description, flags, icon, achRewardText =
        pcall(GetAchievementInfo, categoryID, achIndex)
    if ok and id and not (issecretvalue and issecretvalue(id)) then
        return true, id, name, points, completed, month, day, year, description, flags, icon, achRewardText
    end
    return false
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

---Full journal scan should persist thousands of rows; a tiny store means a failed/partial scan.
local ACHIEVEMENT_STORE_MIN_TRUST = 250

local function CountAchievementStoreEntries()
    local store = collectionStore.achievement
    if type(store) ~= "table" then return 0 end
    local n = 0
    for _ in pairs(store) do
        n = n + 1
    end
    return n
end

local function IsAchievementStoreInadequate()
    return CountAchievementStoreEntries() < ACHIEVEMENT_STORE_MIN_TRUST
end

local function SignalAchievementScanPipelineDone(sender, payload)
    EnsurePlansAchievementLoadingState(false)
    if Constants and Constants.EVENTS and sender and sender.SendMessage then
        sender:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, payload or { category = "achievement" })
    end
end

---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 hasAchievements and IsAchievementStoreInadequate() 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
    if category == "achievement" and IsAchievementStoreInadequate() 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 = {}, 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 = {}, 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

    if IsAchievementStoreInadequate() then
        collectionCache.lastAchievementScan = 0
    end

    -- 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 or IsAchievementStoreInadequate() 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 and not IsAchievementStoreInadequate() then
        return _allAchievementsMergedCache
    end
    ClearAchievementMergedListCache()
    if IsAchievementStoreInadequate() and self.ScanAchievementsAsync then
        self:ScanAchievementsAsync()
    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)

        -- "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 ~= "" and not (issecretvalue and issecretvalue(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)

        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)

        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)

            -- 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,
    }
    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
            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: Warband achievement popups ON uses AddAlert replacement; OFF uses Blizzard AddAlert only.
    local useWarbandPopups = ns.NotificationPresentation
        and ns.NotificationPresentation.UseWarbandAchievementPopups()
    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 useWarbandPopups then
        -- Defer toast to NotificationManager's AddAlert hook (ShowAchievementNotification).
        -- Do NOT MarkAsNotified here: ACHIEVEMENT_EARNED often fires before AddAlert; pre-marking
        -- makes ShowAchievementNotification return true (handled) without showing, while the hook
        -- swallows Blizzard's frame — user sees no popup at all.
        DebugPrint("|cff888888[WN CollectionService]|r Defer achievement toast to AddAlert replacement")
        if Notify.ScheduleAchievementToastFallback then
            Notify.ScheduleAchievementToastFallback(achievementID)
        end
    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 (Warband popups 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 Warband achievement popups are ON.
---@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
    local NP = ns.NotificationPresentation
    if not NP or not NP.CanShowWarbandAchievementEarnedToast() then return false end
    -- Session dedup: only report handled when toast was displayed or permanently recorded.
    if Notify.WasRecentlyNotified("achievement", achievementID) then
        if Notify.WasAchievementToastDisplayed(achievementID)
            or Notify.WasAlreadyNotified("achievement", achievementID) then
            return true
        end
        -- Stale session mark without a displayed toast — allow retry / fallback.
    end
    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
    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 Notify.WasAchievementToastDisplayed and Notify.WasAchievementToastDisplayed(achievementID) or false
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 =
                GetAchievementInfoByCategoryIndex(item.categoryID, item.achIndex)
            if not success or not id then return nil end
            local displayName = SafeAchievementDisplayName(id, name)
            if not displayName 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 = displayName,
                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 == "" or (issecretvalue and issecretvalue(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
    if issecretvalue and issecretvalue(text) 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
    if issecretvalue and issecretvalue(sourceText) 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 and not (issecretvalue and issecretvalue(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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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) — bypass when persisted store is empty/truncated
    local forceRescan = IsAchievementStoreInadequate()
    if timeSinceLastScan < 300 and not forceRescan then
    DebugPrintf("|cffffcc00[WN CollectionService]|r Achievement scan skipped (last scan %.0f seconds ago)", timeSinceLastScan)
        SignalAchievementScanPipelineDone(self, { category = "achievement", skipped = true })
        return
    end

    if not AchievementCategoryListReady() then
        achievementScanDeferAttempts = achievementScanDeferAttempts + 1
        if achievementScanDeferAttempts >= ACHIEVEMENT_SCAN_DEFER_MAX_ATTEMPTS then
            achievementScanDeferAttempts = 0
            DebugPrint("|cffff0000[WN CollectionService]|r GetCategoryList() still empty after defer — achievement scan aborted")
            EnsurePlansAchievementLoadingState(false)
            ns.CollectionLoadingState.isLoading = false
            if Constants and Constants.EVENTS then
                self:SendMessage(Constants.EVENTS.COLLECTION_SCAN_COMPLETE, { category = "achievement", failed = true })
            end
            return
        end
        EnsurePlansAchievementLoadingState(true)
        ns.CollectionLoadingState.isLoading = true
        ns.CollectionLoadingState.loadingProgress = 0
        ns.CollectionLoadingState.currentStage = (ns.L and ns.L["LOADING_ACHIEVEMENTS"]) or "Loading achievements..."
        ns.CollectionLoadingState.currentCategory = "achievement"
        DebugPrint("|cffffcc00[WN CollectionService]|r GetCategoryList() not ready — deferring achievement scan")
        C_Timer.After(0.25, function()
            self:ScanAchievementsAsync()
        end)
        return
    end
    achievementScanDeferAttempts = 0
    
    DebugPrint("|cff9370DB[WN CollectionService]|r Starting async achievement scan...")
    
    -- Update loading state (may already be set by GetUncollectedAchievements)
    EnsurePlansAchievementLoadingState(true)
    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 during scan")
            return
        end
        
        for i = 1, #categoryList do
            local categoryID = categoryList[i]
            if categoryID then
                local numAchievements = GetCategoryAchievementListCount(categoryID)

                for achIndex = 1, numAchievements do
                    local success, id, name, points, completed, month, day, year, description, flags, icon, achRewardText =
                        GetAchievementInfoByCategoryIndex(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 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 ~= "" 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
                        if not rewardTitle and achRewardText and achRewardText ~= "" and not (issecretvalue and issecretvalue(achRewardText)) then
                            rewardTitle = achRewardText
                        end

                        local displayName = SafeAchievementDisplayName(id, name)
                        if not displayName then
                            -- skip secret id
                        else
                        -- 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
                    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.
        local storedAchCount = CountAchievementStoreEntries()
        if storedAchCount >= ACHIEVEMENT_STORE_MIN_TRUST then
            collectionCache.lastAchievementScan = time()
        else
            collectionCache.lastAchievementScan = 0
            DebugPrintf("|cffffcc00[WN CollectionService]|r Achievement scan stored only %d rows — will retry on next request", storedAchCount)
        end
        
        -- Save to DB
        self:SaveCollectionStore()
        self:SaveCollectionCache()
        WipeUncollectedResultsCacheAndMergedAchievements()

        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 not hasData then
        EmitPlansBrowseCollectionEnsure("achievement")
        return {}
    end

    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 CollectionNameMatchesSearch(name, searchText) 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

---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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 CollectionNameMatchesSearch(name, searchText) 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 ~= "" and not (issecretvalue and issecretvalue(rewardTitle)) then
        local _, name, _, _, _, _, _, _, _, icon = GetAchievementInfo(achievementID)
        local safeName = (name and not (issecretvalue and issecretvalue(name))) and name or nil
        return {
            type = "title",
            title = rewardTitle,
            achievementName = safeName,
            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"
        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
