local addonName, privateDB = ...
local HDG = _G.HousingDecorGuide

-- Module namespace
HDG_Data = {}

-- ============================================================================
-- STORE HELPERS
-- ============================================================================

local function GetStore()
    local hdg = _G.HousingDecorGuide
    return hdg and hdg.Store
end

local function GetState()
    local store = GetStore()
    return store and store:GetState()
end

local function Dispatch(action, payload)
    local store = GetStore()
    if store then
        store:Dispatch(action, payload)
    end
end

local function GetCharacters()
    local state = GetState()
    return state and state.characters or {}
end

-- Recipe filter state lives on the consumer (HDG_RecipesTab owns the search
-- EditBox). RecipeMatchesFilters takes a filterContext from the caller; this
-- module no longer keeps shared filter state. recipeLowerNameCache stays here
-- because it caches recipe-name lowercasing (data layer, not filter state).
local recipeLowerNameCache = {}
local function GetRecipeLowerName(recipe)
    local key = recipe.recipeKey or recipe.name
    if not key then return "" end
    if not recipeLowerNameCache[key] then
        recipeLowerNameCache[key] = (recipe.name or ""):lower()
    end
    return recipeLowerNameCache[key]
end

-- Local references for speed
local C_Item = C_Item
local C_Timer = C_Timer
local C_TradeSkillUI = C_TradeSkillUI
local C_SpellBook = C_SpellBook
local time = time

-- Caching tables
local itemNameCache = {}
local itemNameLowerCache = {} -- pre-lowercased names for search
local collectionCache = {} -- itemID -> true (populated by catalog searcher callback)
local knownRecipeSpells = {}
local recipeSpellsBuilt = false
local pendingRefresh = false
local pendingRefreshTimer = nil
local itemLoadTimes = {} -- [itemID] = GetTime() when first requested
local qualityVariantsCache = nil -- [baseItemID] = { variant1, variant2, ... } (forward-declared for ClearCaches)
local UNRELEASED_TIMEOUT = 8 -- seconds before "Loading..." becomes "Unreleased"

-- Lumber data (reference centralized HDG_ExpansionData module)
local LUMBER_TYPES = HDG_ExpansionData.LUMBER_TYPES
HDG_Data.LUMBER_TYPES = LUMBER_TYPES

-- ============================================================================
-- HELPER FUNCTIONS
-- ============================================================================
-- Note: Expansion-related functions (GetExpansionColor, GetExpansionAbbr,
-- NormalizeExpansionName, GetExpansionFromSkill) have been moved to
-- HDG_ExpansionData module. Use HDG_ExpansionData directly.

-- Get cached item name
function HDG_Data.GetCachedItemName(itemID)
    if itemNameCache[itemID] then
        return itemNameCache[itemID], false
    end

    -- Try synchronous lookup first
    local itemName = C_Item.GetItemNameByID(itemID)
    if itemName then
        itemNameCache[itemID] = itemName
        itemNameLowerCache[itemID] = itemName:lower()
        return itemName, false
    end

    -- Check if we've been waiting too long (unreleased/future item)
    local now = GetTime()
    if itemLoadTimes[itemID] then
        if (now - itemLoadTimes[itemID]) > UNRELEASED_TIMEOUT then
            itemNameCache[itemID] = "Unreleased"
            itemNameLowerCache[itemID] = "unreleased"
            itemLoadTimes[itemID] = nil -- clear to prevent unbounded growth
            return "Unreleased", false
        end
    else
        itemLoadTimes[itemID] = now
    end

    -- Fall back to async loading
    local item = Item:CreateFromItemID(itemID)
    if not item:IsItemEmpty() then
        item:ContinueOnItemLoad(function()
            local name = item:GetItemName()
            if name then
                itemNameCache[itemID] = name
                itemNameLowerCache[itemID] = name:lower()
                itemLoadTimes[itemID] = nil
                -- Batch refresh (re-arm timer on each resolve)
                if privateDB.RefreshUI then
                    if pendingRefreshTimer then pendingRefreshTimer:Cancel() end
                    pendingRefreshTimer = C_Timer.NewTimer(0.5, function()
                        pendingRefreshTimer = nil
                        pendingRefresh = false
                        privateDB.RefreshUI()
                    end)
                    pendingRefresh = true
                end
            end
        end)
    end

    return "Loading...", true
end

-- Get localized item name, preferring WoW API over hardcoded DB name
-- This ensures names display in the user's client language
function HDG_Data.GetLocalizedName(itemID, fallbackName)
    if not itemID then
        return fallbackName or "Unknown"
    end

    local localizedName, isLoading = HDG_Data.GetCachedItemName(itemID)

    -- If we got a real name from the API, use it
    if localizedName and localizedName ~= "Loading..." then
        return localizedName
    end

    -- Still loading - use fallback but trigger async load
    return fallbackName or ("Item #" .. itemID)
end

-- Get pre-lowercased cached item name (for search filtering)
function HDG_Data.GetCachedItemNameLower(itemID)
    if itemNameLowerCache[itemID] then return itemNameLowerCache[itemID] end
    -- Trigger normal cache population
    local name = HDG_Data.GetCachedItemName(itemID)
    return itemNameLowerCache[itemID] or (name and name:lower()) or ""
end

-- ============================================================================
-- COLLECTION DETECTION (Blizzard Canonical Arithmetic, Gated on Storage Ready)
-- ============================================================================
-- Per Blizzard_HousingTemplates/Blizzard_HousingCatalogEntry.lua:437-438:
--   local stored = entryInfo.quantity + entryInfo.remainingRedeemable
--   local total  = entryInfo.numPlaced + stored
--   isOwned      = total > 0
-- (Reference doc HOUSING_CATALOG_API.md:419-421 notes the API documentation
-- uses totalNumStored / totalNumPlaced, but the runtime fields are quantity /
-- numPlaced. Using the doc names returns nil → 0 and misses every real owned item.)
--
-- Replaced the old searcher+sweep+heuristic path (2026-04-24) after confirming
-- with /hdgdebug testcollected:
--   1. info.numPlaced / info.numStored are nil in 12.0.5 (real fields are
--      totalNumPlaced / totalNumStored) — the old sweep read dead fields.
--   2. The firstAcquisitionBonus==0 heuristic produced 17 false positives.
--
-- The CatalogSearcher is used purely as a READINESS GATE: its callback only
-- fires once storage data has arrived from the server. We ignore its results
-- and run the arithmetic sweep in the callback, so we never sweep against
-- uninitialized (all-zero) entryInfo structs. Also registers
-- HOUSING_STORAGE_UPDATED to refresh the set when items are placed/stored
-- during the session.

local ownedDecorIDs = {}     -- decorID -> true (the collected set)
local liveDecorIDs = {}      -- decorID -> true (set of decor exposed to live client)
local liveDecorReady = false -- true once the searcher has populated liveDecorIDs once
-- Central catalog cache. Per-decorID rows containing static fields (name,
-- icon, category, tags, dyeIDs, customizations) and mutable fields (counts,
-- variants). Rows persist across /reload; static fields are invalidated only
-- on patch ver crossing. See spec 2026-04-25-hdg-catalog-cache-design.md.
-- Step 4 (canonicalization): catalog data lives canonically in
-- HDG.Store.state.collection.decorCatalog and .catalogByItem. Module-locals
-- have been retired; ensureCatalogTables() returns the live Store-resident
-- tables (creating empty ones on cold-cold sessions).
-- Mutators always wipe-in-place (never reassign), preserving table identity
-- for short-lived caller handles like StyleEngine.BuildCache's
-- `local catalog = HDG_Data.GetAllCatalog()`.
local _catalogReady = false      -- true after first successful sweep/restore this session
local _lastSweepHashChanges = 0  -- diagnostics: how many rows changed in last sweep
local ownedSearchDone = false
local ownedSearchInProgress = false
local ownedSearchCallbacks = {} -- pending onComplete callbacks
local gateSearcher = nil        -- module-scope anchor; Blizzard pattern (Dashboard
                                -- stores on self.catalogSearcher) keeps one searcher
                                -- alive for the whole session and reuses it.
                                -- Runs OwnedOnly=false → captures the browse catalog
                                -- (one entry per recordID, subtype 0 only).
local searchKickedAt = nil      -- GetTime() when the most recent RunSearch() was kicked
local collectionReadyThrottle = nil  -- C_Timer handle coalescing rapid per-entry events

-- SavedVariables persistence for the owned set. Data lives in
-- HDG.Store.state.collection and is persisted via the normal Store save/load
-- machinery. Scope: this module only touches state.collection; all other
-- state slices (characters, craftingHistory, farmingHistory, config, etc.)
-- are untouched.
local SAVE_SCHEMA_VERSION = 3 -- v3 adds decorCatalog (per-decor row cache) + clientVer

-- Timer trace helper — gated behind /hdgdebug timer (default off).
-- Prefix: [HDG Timer Collection] so traces group with HDG_UI.RefreshAll timing.
local function TT(fmt, ...)
    local dbg = privateDB and privateDB.HDG_Debug
    if not (dbg and dbg.IsTimerDebug and dbg.IsTimerDebug()) then return end
    print(string.format("|cFFFFFF00[HDG Timer Collection]|r " .. fmt, ...))
end

local function CountKeys(t)
    local n = 0
    if t then for _ in pairs(t) do n = n + 1 end end
    return n
end

-- Store-direct ownership read. Used as a fallback when the module-local
-- collection cache hasn't been warmed yet (e.g. zone scanner firing at PEW
-- before the user has opened the HDG window). Returns nil if Store doesn't
-- have collection data either (cold-cold session — first install, no
-- SavedVariables yet). Lets opt-in features like the zone scanner work
-- against persisted state without forcing a WarmCollectionCache cycle.
local function StoreOwnedSet()
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    local col = store and store.state and store.state.collection
    if type(col) ~= "table" then return nil end
    local owned = col.ownedDecorIDs
    if type(owned) ~= "table" or not next(owned) then return nil end
    return owned
end

-- Store accessor for the catalog (canonicalization plan, Step 1).
-- Returns the state.collection table if Store is initialized, nil otherwise.
-- Read path: callers read state.collection.decorCatalog / .catalogByItem.
-- See docs/superpowers/plans/2026-05-03-hdg-catalog-store-canonicalization.md
local function GetCatalogState()
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    return store and store.state and store.state.collection or nil
end

-- Write path helper (Step 4). Returns the canonical decorCatalog and
-- catalogByItem tables, creating empty ones on first call if Store hasn't
-- received a SavedVariables-loaded collection yet. Table identity is stable
-- for the session — sweep mutators wipe-in-place on these tables; never
-- reassign. Returns nil/nil only if Store itself isn't initialized (boot
-- order edge case).
local function ensureCatalogTables()
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    if not store or not store.state then return nil, nil end
    if not store.state.collection then store.state.collection = {} end
    local col = store.state.collection
    if not col.decorCatalog then col.decorCatalog = {} end
    if not col.catalogByItem then col.catalogByItem = {} end
    return col.decorCatalog, col.catalogByItem
end

-- Write ownedDecorIDs to HDG.Store.state.collection.
local function SaveOwnedSet()
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    if not store then return end

    -- Deep-copy module-locals so the Store owns its own tables (avoids
    -- aliasing tables that get wiped on next sweep).
    local ownedSnap = {}
    for decorID in pairs(ownedDecorIDs) do ownedSnap[decorID] = true end

    local liveSnap = nil
    if liveDecorReady then
        liveSnap = {}
        for decorID in pairs(liveDecorIDs) do liveSnap[decorID] = true end
    end

    -- Step 4 (canonicalization): catalog data already lives in Store's
    -- state.collection.decorCatalog / .catalogByItem. SaveOwnedSet's job is
    -- just to make sure the snapshot fields point at the canonical tables
    -- when the Store table is rebuilt below (it's reassigned wholesale).
    local catalogSnap, byItemSnap = nil, nil
    if _catalogReady then
        catalogSnap, byItemSnap = ensureCatalogTables()
    end

    local _, _, _, clientVer = (type(Build) == "table" and Build.GetBuildInfo or GetBuildInfo)()

    store.state.collection = {
        version = SAVE_SCHEMA_VERSION,
        ownedDecorIDs = ownedSnap,
        liveDecorIDs = liveSnap,
        decorCatalog = catalogSnap,
        catalogByItem = byItemSnap,
        clientVer = clientVer,
        lastSweep = time(),
    }
    if store.QueueSave then store:QueueSave() end
    TT("SaveOwnedSet wrote %d owned, %d live, %d catalog rows (schema v%d, client %s)",
        CountKeys(ownedDecorIDs),
        liveSnap and CountKeys(liveSnap) or 0,
        catalogSnap and CountKeys(catalogSnap) or 0,
        SAVE_SCHEMA_VERSION,
        tostring(clientVer))
end

-- Coalesce rapid per-entry updates: 100 items learned in a burst = 1 event fired
-- ~0.3s after the last change, instead of 100 tab-repaints.
local function FireCollectionReady()
    if collectionReadyThrottle then return end
    collectionReadyThrottle = C_Timer.NewTimer(0.3, function()
        collectionReadyThrottle = nil
        local HDG = _G.HousingDecorGuide
        if HDG and HDG.EventBus then
            TT("firing HDG_COLLECTION_READY (coalesced)")
            HDG.EventBus:Trigger("HDG_COLLECTION_READY")
        end
    end)
end

-- Restore ownedDecorIDs from HDG.Store.state.collection at first use.
-- Returns true if data was successfully loaded (caller fires
-- HDG_COLLECTION_READY for instant tab paint). Returns false on
-- missing/wrong-version/malformed data — caller proceeds with cold-start.
local function LoadOwnedSet()
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    local saved = store and store.state and store.state.collection
    if not saved then return false end
    if saved.version ~= SAVE_SCHEMA_VERSION then
        TT("LoadOwnedSet skipped — schema mismatch (saved=%s, current=%d)",
            tostring(saved.version), SAVE_SCHEMA_VERSION)
        return false
    end
    if type(saved.ownedDecorIDs) ~= "table" then return false end

    wipe(ownedDecorIDs)
    local loaded = 0
    for decorID in pairs(saved.ownedDecorIDs) do
        if type(decorID) == "number" and decorID > 0 then
            ownedDecorIDs[decorID] = true
            loaded = loaded + 1
        end
    end
    if loaded == 0 then return false end

    -- Restore live-catalog set if persisted. Treat as authoritative until the
    -- next searcher run replaces it; release schedule changes only on patches.
    if type(saved.liveDecorIDs) == "table" then
        wipe(liveDecorIDs)
        local liveLoaded = 0
        for decorID in pairs(saved.liveDecorIDs) do
            if type(decorID) == "number" and decorID > 0 then
                liveDecorIDs[decorID] = true
                liveLoaded = liveLoaded + 1
            end
        end
        if liveLoaded > 0 then
            liveDecorReady = true
            TT("LoadOwnedSet restored %d live decor IDs", liveLoaded)
        end
    end

    -- Step 4 (canonicalization): catalog data already lives in Store's
    -- state.collection.decorCatalog / .catalogByItem (loaded from disk by
    -- Store:LoadFromSavedVariables). No copying needed — just verify the
    -- saved data is current-patch and flip _catalogReady.
    --
    -- Static fields (name, icon, tags, etc.) are patch-stable. clientVer
    -- mismatch means a patch shipped while the addon was offline; drop the
    -- stale catalog and let the next sweep refill from Blizzard.
    local _, _, _, currentClientVer = (type(Build) == "table" and Build.GetBuildInfo or GetBuildInfo)()
    if type(saved.decorCatalog) == "table"
       and type(saved.catalogByItem) == "table"
       and saved.clientVer == currentClientVer then
        local rowCount = CountKeys(saved.decorCatalog)
        if rowCount > 0 then
            _catalogReady = true
            TT("LoadOwnedSet — catalog ready from Store (%d rows, clientVer %s)",
                rowCount, tostring(currentClientVer))
        end
    elseif saved.decorCatalog then
        -- clientVer mismatch — clear stale catalog from Store. _catalogReady
        -- stays false; sweep refills.
        saved.decorCatalog = nil
        saved.catalogByItem = nil
        TT("LoadOwnedSet dropped catalog — clientVer %s != saved %s",
            tostring(currentClientVer), tostring(saved.clientVer))
    end

    -- Rebuild collectionCache for itemID-keyed callers
    if HDG_AllDecorDB then
        wipe(collectionCache)
        for itemID, adEntry in pairs(HDG_AllDecorDB) do
            local decorID = adEntry[1]
            if decorID and ownedDecorIDs[decorID] then
                collectionCache[itemID] = true
            end
        end
    end

    ownedSearchDone = true  -- tabs can query IsDecorCollected immediately
    TT("LoadOwnedSet restored %d decor IDs from Store", loaded)
    return true
end

-- Forward declaration: storage-event handler references RunOwnedSearch before
-- its definition (Lua local closures bind at define time).
local RunOwnedSearch
local VerifyOwnedSetIDsOnce
local _verifyDone = false  -- once-per-session gate

-- Per-row hash for delta detection. Hashes mutable fields only; static fields
-- (name/icon/etc) are patch-stable and invalidated separately via clientVer.
local function ComputeRowHash(row)
    if not row then return 0 end
    local h = 5381
    h = ((h * 33) + (row.itemID or 0)) % 0x7FFFFFFF
    h = ((h * 33) + (row.quantity or 0)) % 0x7FFFFFFF
    h = ((h * 33) + (row.numPlaced or 0)) % 0x7FFFFFFF
    h = ((h * 33) + (row.numStored or 0)) % 0x7FFFFFFF
    h = ((h * 33) + (row.remainingRedeemable or 0)) % 0x7FFFFFFF
    h = ((h * 33) + (row.destroyableInstanceCount or 0)) % 0x7FFFFFFF
    h = ((h * 33) + (row.firstAcquisitionBonus or 0)) % 0x7FFFFFFF
    if row.variants then
        for subID, v in pairs(row.variants) do
            h = ((h * 33) + subID) % 0x7FFFFFFF
            h = ((h * 33) + (v.quantity or 0)) % 0x7FFFFFFF
            h = ((h * 33) + (v.numPlaced or 0)) % 0x7FFFFFFF
        end
    end
    return h
end

-- Rebuild a row's variants table from C_HousingCatalog.GetAllVariantInfosForEntry.
-- The variant API is the only reliable variant signal in 12.0.5 — searcher
-- entries no longer carry subtypeIdentifier. Call this from the sweep (per
-- canCustomize row) and from UpdateSingleEntry (when a per-entry event lands
-- on a variant). Mutates row.variants in place.
local function PopulateVariantsForRow(row, entryType)
    if not (row and row.decorID and C_HousingCatalog and C_HousingCatalog.GetAllVariantInfosForEntry) then return end
    local variantInfos = C_HousingCatalog.GetAllVariantInfosForEntry({
        recordID = row.decorID, entryType = entryType or 1,
    })
    if not variantInfos then return end
    local newVariants = {}
    for _, vi in ipairs(variantInfos) do
        local vid = vi.entryVariantID
        if vid and vid.variantIdentifier and vid.variantIdentifier ~= 0
           and (vi.numStored or 0) > 0 then
            local dyeIDsList, dyeNamesList = {}, {}
            -- Per-channel map for SetGradientMaskWithDyes (12.0.5+). Channels are
            -- 0-indexed in vi.dyeSlots; preserve them so dyed model preview can
            -- pass dyeColorsByChannel[0..2] positionally.
            local dyeColorsByChannel = {}
            if vi.dyeSlots then
                for _, slot in ipairs(vi.dyeSlots) do
                    if slot.dyeColorID then
                        dyeIDsList[#dyeIDsList + 1] = slot.dyeColorID
                        if slot.channel ~= nil then
                            dyeColorsByChannel[slot.channel] = slot.dyeColorID
                        end
                        if C_DyeColor and C_DyeColor.GetDyeColorInfo then
                            local dci = C_DyeColor.GetDyeColorInfo(slot.dyeColorID)
                            if dci and dci.name then
                                dyeNamesList[#dyeNamesList + 1] = dci.name
                            end
                        end
                    end
                end
            end
            newVariants[vid.variantIdentifier] = {
                quantity = vi.numStored or 0,
                numPlaced = 0, -- not exposed by variant API
                dyeNames = #dyeNamesList > 0 and dyeNamesList or nil,
                dyeIDs = dyeIDsList,
                dyeColorsByChannel = next(dyeColorsByChannel) and dyeColorsByChannel or nil,
                entryID = vid,
            }
        end
    end
    row.variants = next(newVariants) and newVariants or nil
end

-- Full arithmetic sweep across AllDecorDB. Used ONLY for cold-start (first
-- time collection data is requested, triggered via searcher readiness gate)
-- and HOUSING_STORAGE_UPDATED (bulk catalog event). Per-entry updates take
-- the UpdateSingleEntry targeted path — see the storage event handler below.
local function RunArithmeticSweep()
    if not (HDG_AllDecorDB and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByRecordID) then
        TT("sweep aborted — AllDecorDB or C_HousingCatalog unavailable")
        return
    end

    local sweepStart = GetTimePreciseSec and GetTimePreciseSec() or GetTime()
    TT("sweep START (iterating %d AllDecorDB entries)", (function()
        local n = 0; for _ in pairs(HDG_AllDecorDB) do n = n + 1 end; return n
    end)())

    -- Build the new owned set into a LOCAL first, then swap in. Cheap on
    -- the main thread and keeps the in-memory state consistent if we bail.
    local newOwned = {}
    local ownedCount = 0
    for itemID, adEntry in pairs(HDG_AllDecorDB) do
        local decorID = adEntry[1]
        if decorID then
            local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID, 1, decorID, true)
            if ok and info then
                local total = (info.quantity or 0)
                            + (info.remainingRedeemable or 0)
                            + (info.numPlaced or 0)
                if total > 0 then
                    newOwned[decorID] = true
                    ownedCount = ownedCount + 1
                end
            end
        end
    end

    local sweepEnd = GetTimePreciseSec and GetTimePreciseSec() or GetTime()
    local sweepElapsed = sweepEnd - sweepStart
    TT("sweep END — %d owned in %.3fs", ownedCount, sweepElapsed)
    if searchKickedAt then
        TT("total: RunSearch kick → sweep complete = %.3fs", GetTime() - searchKickedAt)
        searchKickedAt = nil
    end

    -- Swap in the new set.
    wipe(ownedDecorIDs)
    for decorID in pairs(newOwned) do ownedDecorIDs[decorID] = true end

    ownedSearchDone = true
    ownedSearchInProgress = false

    -- Populate collectionCache for itemID-keyed callers
    for itemID, adEntry in pairs(HDG_AllDecorDB) do
        local decorID = adEntry[1]
        if decorID and ownedDecorIDs[decorID] then
            collectionCache[itemID] = true
        else
            collectionCache[itemID] = nil
        end
    end

    -- Persist the owned set for instant load on next /reload.
    SaveOwnedSet()

    local cbs = ownedSearchCallbacks
    ownedSearchCallbacks = {}
    for _, cb in ipairs(cbs) do cb() end

    local HDG = _G.HousingDecorGuide
    if HDG and HDG.EventBus then
        if _lastSweepHashChanges and _lastSweepHashChanges > 0 then
            TT("firing HDG_COLLECTION_READY event (%d row deltas)", _lastSweepHashChanges)
            HDG.EventBus:Trigger("HDG_COLLECTION_READY")
        else
            TT("sweep complete — no row deltas, skipping HDG_COLLECTION_READY")
        end
    end
end

-- Targeted per-entry update — mirrors Blizzard_HousingDashboardCatalog's
-- OnCatalogEntryUpdated(entryID). The entry event fires once per item learned,
-- delivering the single entryID that changed. GetCatalogEntryInfo(entryID) is
-- authoritative for THAT entry even when the catalog bulk-state is still
-- propagating — so we can skip the whole sweep/debounce/shrink-guard machinery.
--
-- Fires two events:
--   HDG_ENTRY_UPDATED(change) — immediate, per-entry, no throttle. Consumers
--       that display per-item counts (Styles tab, House Editor) subscribe here
--       and patch one row. Matches Blizzard's UpdateEntryData precision.
--   HDG_COLLECTION_READY() — coalesced 0.3s, no payload. Broadcast for tabs
--       that refresh their whole view on any ownership change.
-- The split preserves existing listeners (they see coalesced bulk events) while
-- letting precision consumers opt in without a latency penalty.
local function UpdateSingleEntry(entryID)
    if not entryID or not C_HousingCatalog or not C_HousingCatalog.GetCatalogEntryInfo then return end

    local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfo, entryID)
    if not ok or not info then return end

    -- entryInfo.entryID is a struct {type, recordID}; recordID is our decorID.
    local decorID = (type(info.entryID) == "table") and info.entryID.recordID or nil
    if not decorID then return end

    local stored     = info.quantity or 0
    local redeemable = info.remainingRedeemable or 0
    local placed     = info.numPlaced or 0
    local total      = stored + redeemable + placed
    local isOwned    = total > 0
    local wasOwned   = ownedDecorIDs[decorID] and true or false
    local membershipChanged = (isOwned ~= wasOwned)

    local HDG = _G.HousingDecorGuide
    local itemID = HDG and HDG._decorToItem and HDG._decorToItem[decorID] or nil

    if membershipChanged then
        if isOwned then
            ownedDecorIDs[decorID] = true
            if itemID then collectionCache[itemID] = true end
            TT("entry %d (decor %d, item %s) → OWNED", entryID, decorID, tostring(itemID))
        else
            ownedDecorIDs[decorID] = nil
            if itemID then collectionCache[itemID] = nil end
            TT("entry %d (decor %d, item %s) → UNOWNED", entryID, decorID, tostring(itemID))
        end
        SaveOwnedSet()
    end

    -- Patch the catalog row's mutable fields. The event-delivered entryID has
    -- `subtypeIdentifier` (per-entry hash) which lives in a different namespace
    -- than `variantIdentifier` (per-variant from GetAllVariantInfosForEntry).
    -- For base entries (subtype=0), patch the row's mutable fields directly.
    -- For variant entries (subtype != 0), re-fetch the row's full variant table
    -- via the variant API rather than patching by the wrong key.
    local rowHashChanged = false
    if _catalogReady then
        local entryStruct = (type(info.entryID) == "table") and info.entryID or nil
        local subtypeID = entryStruct and entryStruct.subtypeIdentifier or 0
        local catalog = ensureCatalogTables()
        local row = catalog and catalog[decorID]
        if row then
            local oldHash = row._hash
            if subtypeID == 0 then
                row.quantity = info.quantity or 0
                row.numPlaced = info.numPlaced or 0
                row.numStored = info.numStored or 0
                row.remainingRedeemable = info.remainingRedeemable or 0
                row.destroyableInstanceCount = info.destroyableInstanceCount or 0
                row.firstAcquisitionBonus = info.firstAcquisitionBonus or 0
            else
                -- Variant update: rebuild the entire variants table for this
                -- decor from GetAllVariantInfosForEntry (correct namespace).
                PopulateVariantsForRow(row, entryStruct and entryStruct.entryType or 1)
            end
            row._hash = ComputeRowHash(row)
            rowHashChanged = (oldHash ~= row._hash)
        end
    end

    -- Fire per-entry event EVERY time (count-only changes matter for count-showing
    -- consumers like Styles / House Editor). Payload is a table so future fields
    -- can be added without breaking subscribers.
    if HDG and HDG.EventBus then
        HDG.EventBus:Trigger("HDG_ENTRY_UPDATED", {
            decorID    = decorID,
            itemID     = itemID,
            isOwned    = isOwned,
            wasOwned   = wasOwned,
            stored     = stored,
            placed     = placed,
            redeemable = redeemable,
        })
    end

    -- Fire the coalesced bulk event for broadcast listeners (Acquire, Data, Alts,
    -- House, Preview, Recipes, ZoneMode, LumberTracker, Styles). Trigger on either
    -- ownership change OR any mutable-count change (placed/stored/etc) — count-only
    -- changes still need a repaint for tabs that render numbers. The 0.3s debounce
    -- collapses a 100-item burst into one repaint cycle.
    if membershipChanged or rowHashChanged then
        FireCollectionReady()
    end
end

-- Edge-case ID-only verification. One-shot at first WarmCollectionCache from
-- a SavedVars-restore. Uses an OwnedOnly=true searcher to enumerate the
-- player's currently-owned decorIDs, compares to persisted ownedDecorIDs,
-- and patches only the diff (added → UpdateSingleEntry, removed → drop row).
-- No row reads, no variant population unless drift is actually detected.
--
-- Catches: addon-disabled-mid-session, crash before save flushed, account
-- changes that bypassed the live event stream. Skips entirely if no drift.
VerifyOwnedSetIDsOnce = function()
    if _verifyDone then return end
    _verifyDone = true

    if not C_HousingCatalog or not C_HousingCatalog.CreateCatalogSearcher then return end
    local ok, searcher = pcall(C_HousingCatalog.CreateCatalogSearcher)
    if not ok or not searcher then return end

    -- Anchor to keep searcher alive through the async callback.
    local _searcherAnchor = searcher

    searcher:SetResultsUpdatedCallback(function()
        local _ = _searcherAnchor
        if not searcher.GetAllSearchItems then return end
        local items = searcher:GetAllSearchItems()
        if not items then return end

        -- Build observed owned-decorID set; multiple variant entries collapse
        -- to one recordID (12.0.5 OwnedOnly behaviour).
        local observed = {}
        for _, entry in ipairs(items) do
            local rid = entry.recordID
            if rid then observed[rid] = true end
        end

        local addedIDs, removedIDs = {}, {}
        for rid in pairs(observed) do
            if not ownedDecorIDs[rid] then addedIDs[#addedIDs + 1] = rid end
        end
        for rid in pairs(ownedDecorIDs) do
            if not observed[rid] then removedIDs[#removedIDs + 1] = rid end
        end

        if #addedIDs == 0 and #removedIDs == 0 then
            TT("VerifyOwnedSetIDsOnce — no drift detected (%d owned)",
                CountKeys(observed))
            return
        end

        TT("VerifyOwnedSetIDsOnce — drift detected: +%d / -%d",
            #addedIDs, #removedIDs)

        -- Apply removals first (cheap), then additions (each fires
        -- UpdateSingleEntry which patches the row + variants for that one
        -- entry into the canonical Store-resident catalog table).
        local catalog = ensureCatalogTables()
        for _, rid in ipairs(removedIDs) do
            ownedDecorIDs[rid] = nil
            if catalog then catalog[rid] = nil end
        end
        for _, rid in ipairs(addedIDs) do
            ownedDecorIDs[rid] = true
            local existing = catalog and catalog[rid]
            local entryID = (existing and existing.entryID)
                or { recordID = rid, entryType = 1, entrySubtype = 3, subtypeIdentifier = 0 }
            UpdateSingleEntry(entryID)
        end

        -- Rebuild itemID-keyed collection cache for downstream callers.
        if HDG_AllDecorDB then
            wipe(collectionCache)
            for itemID, adEntry in pairs(HDG_AllDecorDB) do
                local decorID = adEntry[1]
                if decorID and ownedDecorIDs[decorID] then
                    collectionCache[itemID] = true
                end
            end
        end

        SaveOwnedSet()
        local HDG = _G.HousingDecorGuide
        if HDG and HDG.EventBus then
            HDG.EventBus:Trigger("HDG_COLLECTION_READY")
        end
    end)

    -- Configure for owned-only ID enumeration.
    if searcher.SetAutoUpdateOnParamChanges then searcher:SetAutoUpdateOnParamChanges(false) end
    if searcher.SetOwnedOnly then searcher:SetOwnedOnly(true) end
    if searcher.SetCollected then searcher:SetCollected(true) end
    if searcher.SetUncollected then searcher:SetUncollected(false) end
    if searcher.SetAutoUpdateOnParamChanges then searcher:SetAutoUpdateOnParamChanges(true) end

    pcall(function() searcher:RunSearch() end)
end

-- Kick off collection search. Pattern mirrors Blizzard's
-- Blizzard_HousingDashboardCatalog.lua exactly:
--   1. Create searcher once, keep alive on module var (like frame self.catalogSearcher)
--   2. Register callback BEFORE configuring params
--   3. SetAutoUpdateOnParamChanges(false) while setting params
--   4. Set config: SetOwnedOnly(false), SetBaseVariantOnly(false) [12.0.5+;
--      SetDistinctPerRecordID(true) on older builds], SetEditorModeContext(...)
--   5. Flip SetAutoUpdateOnParamChanges(true), then RunSearch()
--   6. On HOUSING_STORAGE_UPDATED / HOUSING_STORAGE_ENTRY_UPDATED later,
--      call RunSearch() on the SAME searcher (don't create new ones)
-- The callback fires on initial RunSearch AND on subsequent storage events,
-- so we get reliable first-load AND in-session refresh.
-- (Assignment to the forward-declared local above, not a new declaration.)
RunOwnedSearch = function()
    -- In-flight short-circuit — silent on purpose. Callers (IsDecorCollected
    -- inside tab refresh loops) hit this dozens of times per refresh; tracing
    -- each one drowns the useful output.
    if ownedSearchInProgress then return end
    ownedSearchInProgress = true

    -- Already have a searcher from a prior call? Just kick it again.
    if gateSearcher then
        TT("RunOwnedSearch — reusing existing searcher, calling RunSearch()")
        searchKickedAt = GetTime()
        local okRun = pcall(function() gateSearcher:RunSearch() end)
        if not okRun then
            TT("RunSearch() on existing searcher FAILED")
            ownedSearchInProgress = false
        end
        return
    end

    if not C_HousingCatalog or not C_HousingCatalog.CreateCatalogSearcher then
        TT("RunOwnedSearch aborted — C_HousingCatalog unavailable")
        ownedSearchInProgress = false
        return
    end

    TT("RunOwnedSearch — first call, creating searcher")
    local okCreate, searcher = pcall(C_HousingCatalog.CreateCatalogSearcher)
    if not okCreate or not searcher then
        TT("CreateCatalogSearcher FAILED")
        ownedSearchInProgress = false
        return
    end

    gateSearcher = searcher

    -- Register callback BEFORE config (Blizzard order). Callback triggers the
    -- arithmetic sweep AND captures the searcher's result set (the decorIDs
    -- the live client knows about) for tabs/widgets that need to filter
    -- "unreleased" items. The per-item GetCatalogEntryInfoByRecordID call
    -- often returns valid info for decorIDs that the searcher does NOT
    -- iterate, so the searcher's result set is the authoritative live signal.
    searcher:SetResultsUpdatedCallback(function()
        -- Anchor via closure so searcher doesn't GC during async callbacks
        local _ = searcher
        TT("<<< SetResultsUpdatedCallback FIRED (%.3fs after kick)",
            searchKickedAt and (GetTime() - searchKickedAt) or 0)
        if searcher.GetAllSearchItems and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfo then
            local items = searcher:GetAllSearchItems()
            if items then
                local newLive = {}
                local newCatalog = {}
                local newByItem = {}
                local liveCount, rowCount, variantCount = 0, 0, 0

                for _, entry in ipairs(items) do
                    local rid = entry.recordID
                    if rid then
                        newLive[rid] = true
                        liveCount = liveCount + 1

                        local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfo, entry)
                        if ok and info then
                            local subtype = entry.subtypeIdentifier or 0
                            if subtype == 0 then
                                -- Base row
                                local row = newCatalog[rid] or {}
                                row.decorID = rid
                                row.itemID = info.itemID
                                row.entryID = info.entryID
                                row.name = info.name
                                row.iconTexture = info.iconTexture
                                row.iconAtlas = info.iconAtlas
                                row.categoryID = info.categoryIDs and info.categoryIDs[1]
                                row.subcategoryID = info.subcategoryIDs and info.subcategoryIDs[1]
                                row.tags = info.dataTagsByID
                                row.dyeIDs = info.dyeIDs
                                row.customizations = info.customizations
                                row.canCustomize = info.canCustomize
                                row.placementCost = info.placementCost or 0
                                row.isAllowedIndoors = info.isAllowedIndoors
                                row.isAllowedOutdoors = info.isAllowedOutdoors
                                row.isUniqueTrophy = info.isUniqueTrophy
                                row.uiModelSceneID = info.uiModelSceneID
                                row.showQuantity = info.showQuantity
                                row.asset = info.asset
                                row.recordID = rid -- alias for compatibility
                                row.quantity = info.quantity or 0
                                row.numPlaced = info.numPlaced or 0
                                row.numStored = info.numStored or 0
                                row.remainingRedeemable = info.remainingRedeemable or 0
                                row.destroyableInstanceCount = info.destroyableInstanceCount or 0
                                row.firstAcquisitionBonus = info.firstAcquisitionBonus or 0
                                newCatalog[rid] = row
                                if info.itemID then newByItem[info.itemID] = rid end
                                rowCount = rowCount + 1

                                -- Variants come from C_HousingCatalog.GetAllVariantInfosForEntry
                                -- (12.0.5 removed subtypeIdentifier from searcher entries).
                                -- Helper handles dye-name resolution and numStored > 0 filter.
                                if info.canCustomize then
                                    local entryType = (info.entryID and info.entryID.entryType) or 1
                                    PopulateVariantsForRow(row, entryType)
                                    variantCount = variantCount + (row.variants and CountKeys(row.variants) or 0)
                                end
                            else
                                -- Subtype != 0 entries from searcher are no longer used (variants now
                                -- come from GetAllVariantInfosForEntry). Skip silently.
                            end
                        end
                    end
                end

                -- Step 4 (canonicalization): write through to the canonical
                -- Store-resident tables. wipe-in-place preserves table
                -- identity for any short-lived caller handle from
                -- GetAllCatalog().
                local catalog, byItem = ensureCatalogTables()

                -- Compute per-row hashes for delta detection.
                local hashChanged = 0
                for decorID, row in pairs(newCatalog) do
                    local newHash = ComputeRowHash(row)
                    local oldRow = catalog and catalog[decorID]
                    if not oldRow or oldRow._hash ~= newHash then
                        hashChanged = hashChanged + 1
                    end
                    row._hash = newHash
                end
                -- Removed-row delta
                if catalog then
                    for decorID in pairs(catalog) do
                        if not newCatalog[decorID] then hashChanged = hashChanged + 1 end
                    end
                end

                wipe(liveDecorIDs)
                for k in pairs(newLive) do liveDecorIDs[k] = true end
                if catalog then
                    wipe(catalog)
                    for k, v in pairs(newCatalog) do catalog[k] = v end
                end
                if byItem then
                    wipe(byItem)
                    for k, v in pairs(newByItem) do byItem[k] = v end
                end

                liveDecorReady = true
                _catalogReady = true
                _lastSweepHashChanges = hashChanged
                TT("catalog populated — %d live, %d rows, %d variants, %d hash changes",
                    liveCount, rowCount, variantCount, hashChanged)
            end
        end
        RunArithmeticSweep()
        -- Variants are populated inline in the browse callback above via
        -- C_HousingCatalog.GetAllVariantInfosForEntry. The owned-only
        -- variant searcher was retired in 12.0.5 (subtypeIdentifier no
        -- longer exposed on searcher entries — confirmed via diagnostic).
    end)

    local okCfg = pcall(function()
        -- Browse-mode config: returns one entry per recordID. Dye variants are
        -- populated inline in the callback above via GetAllVariantInfosForEntry.
        if searcher.SetAutoUpdateOnParamChanges then searcher:SetAutoUpdateOnParamChanges(false) end
        if searcher.SetOwnedOnly then searcher:SetOwnedOnly(false) end
        -- 12.0.5: SetDistinctPerRecordID was hard-removed; SetBaseVariantOnly is the
        -- semantic inverse. Old: distinct=true → return each variant separately.
        -- New: baseOnly=false → return each variant separately. Pre-12.0.5 fallback
        -- preserved (the legacy method silently no-ops on builds where it's missing).
        if searcher.SetBaseVariantOnly then searcher:SetBaseVariantOnly(false)
        elseif searcher.SetDistinctPerRecordID then searcher:SetDistinctPerRecordID(true) end
        if searcher.SetCollected then searcher:SetCollected(true) end
        if searcher.SetUncollected then searcher:SetUncollected(true) end
        if searcher.SetEditorModeContext and Enum and Enum.HouseEditorMode then
            searcher:SetEditorModeContext(Enum.HouseEditorMode.BasicDecor)
        end
        if searcher.SetAutoUpdateOnParamChanges then searcher:SetAutoUpdateOnParamChanges(true) end
        TT("searcher configured — calling RunSearch()")
        searchKickedAt = GetTime()
        searcher:RunSearch()
    end)
    if not okCfg then
        TT("searcher config/RunSearch FAILED")
        -- Clear in-flight flag so future kicks aren't permanently blocked.
        ownedSearchInProgress = false
    end
end

-- Variant discovery uses C_HousingCatalog.GetAllVariantInfosForEntry inline in
-- the browse callback (see RunOwnedSearch above). The owned-only variant
-- searcher this slot used to occupy was retired when 12.0.5 dropped
-- subtypeIdentifier from CatalogSearcher results.

-- Storage-change listener. Mirrors Blizzard_HousingDashboardCatalog:OnEvent:
--   HOUSING_STORAGE_UPDATED       → bulk refresh (full sweep)
--   HOUSING_STORAGE_ENTRY_UPDATED → targeted per-entry patch via GetCatalogEntryInfo.
-- Per-entry is Blizzard's authoritative path and avoids the mid-update catalog
-- problem: the sweep re-reads every entry (many still zero during a bulk load),
-- but GetCatalogEntryInfo(entryID) for the one entry that just fired the event
-- is always current. No debounce / shrink-guard / retry needed.
do
    local storageFrame = CreateFrame("Frame")
    storageFrame:RegisterEvent("HOUSING_STORAGE_UPDATED")
    storageFrame:RegisterEvent("HOUSING_STORAGE_ENTRY_UPDATED")
    storageFrame:SetScript("OnEvent", function(_, event, arg1)
        if event == "HOUSING_STORAGE_UPDATED" then
            TT("HOUSING_STORAGE_UPDATED → full sweep")
            if gateSearcher then
                -- Searcher exists: kick it, callback will drive the sweep.
                pcall(function() gateSearcher:RunSearch() end)
            else
                -- No searcher yet; catalog is clearly ready (event fired), sweep directly.
                RunArithmeticSweep()
            end
        elseif event == "HOUSING_STORAGE_ENTRY_UPDATED" then
            UpdateSingleEntry(arg1)
        end
    end)
end

-- Check if decor is collected (by decorID) — authoritative
function HDG_Data.IsDecorCollected(decorID)
    if not decorID then return false end
    if ownedSearchDone then
        return ownedDecorIDs[decorID] or false
    end
    -- Pre-warm path: if Store has ownership from SavedVariables, answer from
    -- there without kicking a sweep. Lets the zone scanner answer at PEW
    -- without triggering WarmCollectionCache. Cold-cold sessions (no Store
    -- data) fall through and lazy-warm as before.
    local storeSet = StoreOwnedSet()
    if storeSet then return storeSet[decorID] == true end
    RunOwnedSearch()
    return ownedDecorIDs[decorID] or false
end

-- Live-catalog membership query. Returns true if the decor is exposed by
-- the live client (Blizzard's catalog searcher iterated it on the last
-- RunSearch). Returns nil when the searcher has not yet run -- callers
-- should treat nil as "unknown" and fall back to local heuristics.
-- This is the authoritative "is this released?" signal: per-item
-- GetCatalogEntryInfoByRecordID frequently returns valid data for
-- decorIDs the searcher does NOT iterate (seasonal items, etc.), so
-- direct API calls aren't reliable.
function HDG_Data.IsItemReleased(itemID)
    if not itemID then return false end
    if not liveDecorReady then
        if not ownedSearchDone then RunOwnedSearch() end
        return nil
    end
    local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
    local decorID = entry and entry[1]
    if not decorID then return false end
    return liveDecorIDs[decorID] == true
end

function HDG_Data.IsLiveCatalogReady()
    return liveDecorReady
end

-- Public refresh hook. Kicks the central searcher to re-fetch the catalog.
-- Use for non-storage events that mutate decor state (e.g. HOUSING_DECOR_PLACE_*,
-- HOUSING_DECOR_CUSTOMIZATION_CHANGED, HOUSE_DECOR_ADDED_TO_CHEST). The next
-- callback fires HDG_COLLECTION_READY only on hash delta — subscribers refresh
-- themselves via that event rather than passing one-shot callbacks here.
function HDG_Data.RefreshCatalog()
    RunOwnedSearch()
end

-- Get the full catalog row for a decorID. Returns the live row by reference
-- (consumers MUST NOT mutate). Returns nil if the live client doesn't expose
-- this decor or the cache hasn't been built yet. Triggers a background search
-- on first call if needed.
function HDG_Data.GetCatalog(decorID)
    if not decorID then return nil end
    if not _catalogReady then
        if not ownedSearchDone then RunOwnedSearch() end
        return nil
    end
    -- Read through Store — canonical home of catalog data after Step 4.
    local col = GetCatalogState()
    return col and col.decorCatalog and col.decorCatalog[decorID] or nil
end

-- Get the catalog row by itemID via the reverse index. O(1) after first sweep.
function HDG_Data.GetCatalogByItem(itemID)
    if not itemID then return nil end
    if not _catalogReady then
        if not ownedSearchDone then RunOwnedSearch() end
        return nil
    end
    local col = GetCatalogState()
    if not col or not col.catalogByItem or not col.decorCatalog then return nil end
    local decorID = col.catalogByItem[itemID]
    return decorID and col.decorCatalog[decorID] or nil
end

-- Get a specific variant's mutable counts. subtypeID 0 returns the base row's
-- counts; non-zero returns the variant slot. Returns nil if no such variant.
function HDG_Data.GetVariant(decorID, subtypeID)
    local row = HDG_Data.GetCatalog(decorID)
    if not row then return nil end
    if not subtypeID or subtypeID == 0 then
        return {
            quantity = row.quantity or 0,
            numPlaced = row.numPlaced or 0,
            numStored = row.numStored or 0,
            entryID = row.entryID,
        }
    end
    return row.variants and row.variants[subtypeID] or nil
end

-- Get the entire catalog table by reference (read-only — consumers MUST NOT mutate).
-- Used by tabs that need to iterate every decorID (Preview's row builder,
-- StylesEditor's coverage stats). Returns nil if the cache hasn't been built.
function HDG_Data.GetAllCatalog()
    if not _catalogReady then return nil end
    local col = GetCatalogState()
    return col and col.decorCatalog or nil
end

-- Diagnostic stats for /hdgdebug. Cheap (no iteration).
function HDG_Data.GetCatalogStats()
    local n = 0
    local col = GetCatalogState()
    if col and col.decorCatalog then
        for _ in pairs(col.decorCatalog) do n = n + 1 end
    end
    return { rowCount = n, ready = _catalogReady, liveReady = liveDecorReady }
end

-- Check if item is collected (by itemID) — resolves to decorID
function HDG_Data.IsItemCollected(itemID)
    if not itemID then return false end

    -- Fast path: check collectionCache (populated after owned search)
    local cached = collectionCache[itemID]
    if cached == true then return true end

    -- Resolve decorID
    local adEntry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
    local decorID = adEntry and adEntry[1]
    if not decorID then return false end

    -- Module-local owned set (post-warm)
    if ownedDecorIDs[decorID] then return true end

    -- Store fallback: lets pre-warm callers (zone scanner at PEW) answer
    -- against persisted ownership without forcing a warm cycle. Cold-cold
    -- sessions return false here and self-resolve on first window open.
    if not ownedSearchDone then
        local storeSet = StoreOwnedSet()
        if storeSet and storeSet[decorID] then return true end
    end

    return false
end

-- Warm the collection cache — called from HDG_UI.RefreshAll(silent=true) on
-- window OnShow. Tries SavedVariables first (instant, no API calls); falls
-- back to the full searcher + sweep if no saved data is available.
--
-- Housing collection data is account-wide and slow-changing, so the saved
-- set is almost always current. Mid-session drift (placement, acquire,
-- destroy) is caught by HOUSING_STORAGE_* events firing → searcher refresh
-- → sweep → SaveOwnedSet overwrites. The Refresh button wipes HDG_DB.collection
-- for an explicit rebuild.
function HDG_Data.WarmCollectionCache(onProgress, onComplete)
    -- Fast path: BOTH owned set and catalog are loaded. Gating on ownedSearchDone
    -- alone deadlocks the addon if LoadOwnedSet restored the owned set but
    -- dropped the catalog (clientVer drift) or never persisted one — Style
    -- Curator, Coverage, and any GetAllCatalog consumer would render empty
    -- forever until the user clicks Refresh.
    if ownedSearchDone and _catalogReady then
        if onComplete then onComplete() end
        return
    end

    -- Re-entry path: owned set already warm (earlier call, or
    -- IsDecorCollected fallback) but catalog is empty. Skip LoadOwnedSet
    -- and go straight to a sweep refill.
    if ownedSearchDone and not _catalogReady then
        TT("WarmCollectionCache — owned set warm but catalog empty; kicking sweep refill")
        if onComplete then
            ownedSearchCallbacks[#ownedSearchCallbacks + 1] = onComplete
        end
        RunOwnedSearch()
        return
    end

    -- Try SavedVariables — instant paint, no API cost.
    if LoadOwnedSet() then
        local HDG = _G.HousingDecorGuide
        if HDG and HDG.EventBus then
            TT("WarmCollectionCache — restored from SavedVars, firing HDG_COLLECTION_READY")
            HDG.EventBus:Trigger("HDG_COLLECTION_READY")
        end

        -- LoadOwnedSet succeeded but catalog didn't restore (clientVer drift
        -- dropped it at line ~388, or an older save never persisted rows).
        -- Kick a sweep refill — honours the "sweep refills" comment in
        -- LoadOwnedSet which was previously a no-op.
        if not _catalogReady then
            TT("WarmCollectionCache — owned set restored but catalog missing; kicking sweep refill")
            if onComplete then
                ownedSearchCallbacks[#ownedSearchCallbacks + 1] = onComplete
            end
            RunOwnedSearch()
        elseif onComplete then
            onComplete()
        end

        -- Edge-case sanity check: kick a lightweight ID-only diff against
        -- Blizzard's owned-only searcher. No row reads, no variant work — just
        -- confirms our persisted decorID set matches what Blizzard currently
        -- reports. If a drift is detected (rare: addon disabled during a
        -- previous session, crash mid-session before save flushed, etc),
        -- fetch full row data ONLY for the changed decorIDs via the existing
        -- UpdateSingleEntry path. Most users see zero work here.
        VerifyOwnedSetIDsOnce()
        return
    end

    -- Cold start — run the full searcher + sweep.
    TT("WarmCollectionCache — no saved set; cold start via RunOwnedSearch")
    if onComplete then
        ownedSearchCallbacks[#ownedSearchCallbacks + 1] = onComplete
    end
    RunOwnedSearch()
end

function HDG_Data.IsCollectionCacheWarmed()
    if ownedSearchDone then return true end
    -- Treat Store-resident ownership as "warmed enough" for read-only callers
    -- (zone scanner). The IsItemCollected / IsDecorCollected fallbacks read
    -- straight from Store, so the gate can pass.
    return StoreOwnedSet() ~= nil
end

-- Invalidate collection cache — triggers a fresh catalog search.
-- Also wipes the persisted Store state.collection so the Refresh button is
-- a full reset — the follow-up sweep writes a fresh save when it completes.
-- Scope is narrow: ONLY state.collection is nil'd. All other Store state
-- (characters, craftingHistory, farmingHistory, customStyles, userNotes,
-- config, etc.) is untouched.
function HDG_Data.InvalidateCollectionCache()
    ownedSearchDone = false
    wipe(ownedDecorIDs)
    wipe(liveDecorIDs)
    liveDecorReady = false
    _catalogReady = false
    _lastSweepHashChanges = 0
    wipe(collectionCache)
    -- Step 4: wipe the canonical Store-resident catalog tables. Don't nil out
    -- state.collection wholesale (that breaks any short-lived caller handle);
    -- wipe-in-place keeps table identity stable so a sweep-in-progress reader
    -- doesn't dereference a stale snapshot.
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    if store and store.state and store.state.collection then
        local col = store.state.collection
        if type(col.decorCatalog) == "table" then wipe(col.decorCatalog) end
        if type(col.catalogByItem) == "table" then wipe(col.catalogByItem) end
        if type(col.ownedDecorIDs) == "table" then wipe(col.ownedDecorIDs) end
        if type(col.liveDecorIDs) == "table" then wipe(col.liveDecorIDs) end
        col.lastSweep = nil
        if store.QueueSave then store:QueueSave() end
    end
    TT("InvalidateCollectionCache — wiped in-memory + Store state.collection; re-sweeping")
    RunOwnedSearch()
end

-- Clear caches
function HDG_Data.ClearCaches()
    itemNameCache = {}
    itemNameLowerCache = {}
    itemLoadTimes = {}
    qualityVariantsCache = nil
    recipeLowerNameCache = {}
    -- Collection data NOT cleared here — it's from the catalog searcher
    -- and is authoritative for the session. Use InvalidateCollectionCache()
    -- only when collection state actually changes (item acquired/placed).
end

-- Get item count in bags/bank/reagent bank (includes Warband Bank)
function HDG_Data.GetItemCount(itemID)
    if not itemID then return 0 end
    -- Include Warband Bank (Account Bank)
    return C_Item.GetItemCount(itemID, true, false, true, true) or 0
end

-- ============================================================================
-- QUALITY VARIANT TRACKING (v2.1.4)
-- Maps base itemID -> all quality variants (R1, R2, R3) for material counting
-- ============================================================================

-- Build quality variants lookup from HDG_ProfessionsDB
local function BuildQualityVariantsCache()
    if qualityVariantsCache then return end
    qualityVariantsCache = {}

    if not HDG_ProfessionsDB then return end

    for _, recipe in pairs(HDG_ProfessionsDB) do
        if recipe.slots then
            for _, slot in ipairs(recipe.slots) do
                if slot.itemID and slot.variants and #slot.variants > 0 then
                    -- Build full group: base + all variants
                    local allIDs = { slot.itemID }
                    for _, v in ipairs(slot.variants) do allIDs[#allIDs + 1] = v end
                    -- Map every ID to all OTHER IDs in the group
                    for i, id in ipairs(allIDs) do
                        local others = {}
                        for j, other in ipairs(allIDs) do
                            if j ~= i then others[#others + 1] = other end
                        end
                        if not qualityVariantsCache[id] or #others > #qualityVariantsCache[id] then
                            qualityVariantsCache[id] = others
                        end
                    end
                end
            end
        end
    end
end

-- Get item count including all quality variants (R1 + R2 + R3)
-- Used for checking if player has enough mats for crafting
function HDG_Data.GetItemCountWithVariants(itemID)
    if not itemID then return 0 end

    -- Start with base item count
    local total = C_Item.GetItemCount(itemID, true, false, true, true) or 0

    -- Build variants cache if needed
    BuildQualityVariantsCache()

    -- Add counts from all quality variants
    local variants = qualityVariantsCache and qualityVariantsCache[itemID]
    if variants then
        for _, variantID in ipairs(variants) do
            total = total + (C_Item.GetItemCount(variantID, true, false, true, true) or 0)
        end
    end

    return total
end

-- Get item count in personal bags/bank/reagent bank ONLY (excludes Warband Bank)
-- Used for saving per-character inventory to avoid double-counting shared Warband Bank
function HDG_Data.GetPersonalItemCount(itemID)
    if not itemID then return 0 end
    -- Exclude Warband Bank (5th param = false)
    return C_Item.GetItemCount(itemID, true, false, true, false) or 0
end

-- Get item count in Warband Bank ONLY
function HDG_Data.GetWarbandBankCount(itemID)
    if not itemID then return 0 end
    local total = C_Item.GetItemCount(itemID, true, false, true, true) or 0
    local personal = C_Item.GetItemCount(itemID, true, false, true, false) or 0
    return total - personal
end

-- ============================================================================
-- ACCOUNT-WIDE LUMBER TRACKING (v2.1.4)
-- Store and retrieve lumber counts across all characters
-- ============================================================================

-- Save current character's lumber inventory to Store
function HDG_Data.SaveCharacterLumberInventory()
    local charKey = HDG_Data.GetCharacterKey()
    if not charKey then return end

    -- Build lumber inventory (personal only - excludes Warband Bank to avoid double-counting)
    local lumberInventory = {}

    for lumberId, _ in pairs(LUMBER_TYPES) do
        -- Use personal count only (bags + bank + reagent bank, NOT Warband Bank)
        local count = HDG_Data.GetPersonalItemCount(lumberId)
        if count > 0 then
            lumberInventory[lumberId] = count
        end
    end

    -- Dispatch update to Store
    Dispatch("UPDATE_CHARACTER_DATA", {
        charKey = charKey,
        lumberInventory = lumberInventory,
        lumberUpdated = time(),
    })
end

-- Get account-wide lumber total for a specific lumber type
-- Returns: total count, table of per-character breakdowns
-- Note: Warband Bank is shared account-wide, so we add it once (not per-character)
function HDG_Data.GetAccountWideLumberCount(lumberId)
    local characters = GetCharacters()
    if not characters or not next(characters) then
        return HDG_Data.GetItemCount(lumberId), {}
    end

    local total = 0
    local breakdown = {}
    local currentChar = HDG_Data.GetCharacterKey()

    -- Sum personal inventories from all characters
    for charKey, charData in pairs(characters) do
        local count = 0

        if charKey == currentChar then
            -- Use live personal count for current character (excludes Warband Bank)
            count = HDG_Data.GetPersonalItemCount(lumberId)
        elseif charData.lumberInventory and charData.lumberInventory[lumberId] then
            -- Use saved count for other characters (already excludes Warband Bank)
            count = charData.lumberInventory[lumberId]
        end

        if count > 0 then
            total = total + count
            -- Extract just character name (without realm) for display
            local charName = charKey:match("^([^-]+)") or charKey
            table.insert(breakdown, {
                name = charName,
                fullName = charKey,
                count = count,
                isCurrent = (charKey == currentChar)
            })
        end
    end

    -- Add Warband Bank count once (it's shared account-wide)
    local warbandCount = HDG_Data.GetWarbandBankCount(lumberId)
    if warbandCount > 0 then
        total = total + warbandCount
        table.insert(breakdown, {
            name = "Warband Bank",
            fullName = "Warband Bank",
            count = warbandCount,
            isCurrent = false,
            isWarband = true
        })
    end

    -- Sort breakdown: current character first, then Warband Bank, then by count descending
    table.sort(breakdown, function(a, b)
        if a.isCurrent ~= b.isCurrent then
            return a.isCurrent
        end
        if a.isWarband ~= b.isWarband then
            return a.isWarband -- Warband Bank second after current char
        end
        return a.count > b.count
    end)

    return total, breakdown
end

-- Get per-character lumber breakdown for tooltip display
function HDG_Data.GetLumberBreakdown(lumberId)
    local _, breakdown = HDG_Data.GetAccountWideLumberCount(lumberId)
    return breakdown
end

-- ============================================================================
-- RECIPE KNOWLEDGE
-- ============================================================================

-- Check if player knows recipe by spellID.
--
-- The PERSISTED CACHE is primary — C_SpellBook.IsSpellKnown only sees the
-- currently-slotted expansion profession, so older-expansion recipes return
-- false unless their profession is active. Our cache scan (triggered when
-- the profession window opens) records every learned recipe across all
-- expansions onto HDG_DB.characters[me].professions[*].knownRecipes, so
-- that's the authoritative answer once the user has opened the prof window
-- at least once.
--
-- IsSpellKnown stays as a fallback for fresh characters who haven't opened
-- the prof window yet (covers current-expansion recipes pre-scan).
function HDG_Data.PlayerKnowsRecipeBySpellID(spellID)
    if not spellID or spellID == 0 then return false end

    -- Primary: persisted cache for the current character.
    local currentChar = HDG_Data.GetCharacterKey()
    local characters = GetCharacters()
    local charData = currentChar and characters and characters[currentChar]
    if charData and charData.professions then
        for _, profData in pairs(charData.professions) do
            if profData.knownRecipes and profData.knownRecipes[spellID] then
                return true
            end
        end
    end

    -- Fallback: live spellbook (covers current-expansion recipes before the
    -- first prof-window scan has populated the cache).
    if C_SpellBook and C_SpellBook.IsSpellKnown and C_SpellBook.IsSpellKnown(spellID) then
        return true
    end

    return false
end

-- Build cache of known recipe spellIDs (Fallback)
function HDG_Data.BuildKnownRecipeCache()
    if recipeSpellsBuilt then return end

    local recipeIDs = C_TradeSkillUI.GetAllRecipeIDs()
    if not recipeIDs or #recipeIDs == 0 then return end

    for _, recipeID in ipairs(recipeIDs) do
        local recipeInfo = C_TradeSkillUI.GetRecipeInfo(recipeID)
        if recipeInfo and recipeInfo.name and recipeInfo.learned then
            knownRecipeSpells[recipeInfo.name] = recipeID
        end
    end

    recipeSpellsBuilt = true
end

-- Invalidate recipe cache
function HDG_Data.InvalidateRecipeCache()
    knownRecipeSpells = {}
    recipeSpellsBuilt = false
end

-- Check if player knows recipe by name (Legacy/Fallback)
function HDG_Data.PlayerKnowsRecipe(recipeName)
    if not recipeName then return false end
    HDG_Data.BuildKnownRecipeCache()
    return knownRecipeSpells[recipeName] ~= nil
end

-- Get spellID for a recipe name
function HDG_Data.GetRecipeSpellID(recipeName)
    if not recipeName then return nil end
    HDG_Data.BuildKnownRecipeCache()
    return knownRecipeSpells[recipeName]
end

-- Cached set of skillLine IDs the player has (nil = stale, rebuild on next call)
local playerProfessionSkillLines = nil

function HDG_Data.InvalidateProfessionCache()
    playerProfessionSkillLines = nil
end

local function GetPlayerProfessionSkillLines()
    if playerProfessionSkillLines then return playerProfessionSkillLines end
    playerProfessionSkillLines = {}
    local prof1, prof2, _, _, cooking = GetProfessions()
    for _, idx in ipairs({ prof1, prof2, cooking }) do
        if idx then
            local _, _, _, _, _, _, skillLine = GetProfessionInfo(idx)
            if skillLine then playerProfessionSkillLines[skillLine] = true end
        end
    end
    return playerProfessionSkillLines
end

-- Check if player has profession (uses centralized profession data)
function HDG_Data.PlayerHasProfession(professionName)
    local targetID = HDG_ExpansionData.GetProfessionID(professionName)
    if not targetID then return false end
    return GetPlayerProfessionSkillLines()[targetID] == true
end

-- Open profession window and navigate to a specific recipe
-- professionName: "Alchemy", "Blacksmithing", etc.
-- recipeSpellID: The spell ID of the recipe to navigate to
function HDG_Data.OpenProfessionAtRecipe(professionName, recipeSpellID)
    -- If profession window is already open and we have a recipe, just navigate to it
    if recipeSpellID and C_TradeSkillUI and C_TradeSkillUI.IsTradeSkillReady and C_TradeSkillUI.IsTradeSkillReady() then
        if C_TradeSkillUI.OpenRecipe then
            C_TradeSkillUI.OpenRecipe(recipeSpellID)
        end
        return true
    end

    -- Need to open profession first
    if not professionName then return false end

    local targetID = HDG_ExpansionData.GetProfessionID(professionName)
    if not targetID then return false end

    -- Find the profession and cast the spell to open it
    local prof1, prof2, archaeology, fishing, cooking = GetProfessions()
    local profIndexes = { prof1, prof2, cooking }

    for _, profIndex in ipairs(profIndexes) do
        if profIndex then
            local name, icon, skillLevel, maxSkillLevel, numAbilities, spelloffset, skillLine = GetProfessionInfo(
            profIndex)
            if skillLine == targetID and spelloffset then
                -- Cast the profession spell to open the window
                CastSpell(spelloffset + 1, skillLine)

                -- Wait for the tradeskill list to be ready before navigating.
                -- Was a blind 0.5s wait; now event-driven on TRADE_SKILL_LIST_UPDATE
                -- with a 2s fallback (mirrors HDG_RecipesTab's OpenRecipeAndThen).
                if recipeSpellID then
                    local function openNow()
                        if C_TradeSkillUI and C_TradeSkillUI.OpenRecipe then
                            C_TradeSkillUI.OpenRecipe(recipeSpellID)
                        end
                    end
                    if C_TradeSkillUI and C_TradeSkillUI.IsTradeSkillReady and C_TradeSkillUI.IsTradeSkillReady() then
                        openNow()
                    else
                        local f = CreateFrame("Frame")
                        local done = false
                        local function finish()
                            if done then return end
                            done = true
                            f:UnregisterAllEvents()
                            f:SetScript("OnEvent", nil)
                            openNow()
                        end
                        f:RegisterEvent("TRADE_SKILL_LIST_UPDATE")
                        f:SetScript("OnEvent", finish)
                        C_Timer.After(2, finish)
                    end
                end
                return true
            end
        end
    end

    return false
end

-- Get player professions (filtered list for UI display)
function HDG_Data.GetPlayerProfessions()
    local playerProfessions = {}

    -- Get the per-character profession filter from Store
    local filter = HDG.Store:GetCharacterProfessionFilter()

    -- Filter professions from DB
    if privateDB.professions then
        for _, profData in ipairs(privateDB.professions) do
            -- Show all professions if filter is "All", otherwise show only selected
            if filter == "All" or profData.name == filter then
                table.insert(playerProfessions, profData)
            end
        end
    end

    return playerProfessions
end

-- ============================================================================
-- DATA PROCESSING
-- ============================================================================

-- Function to transform HDG_DecorDB into addon format
function HDG_Data.InitializeDatabase()
    if not HDG_DecorDB then
        print("|cFFFF0000[Housing Decor Guide]|r ERROR: HDG_DecorDB not found!")
        return false
    end

    -- Initialize structures in privateDB
    privateDB.professions = {}
    privateDB.decorItem = {}

    -- Use centralized expansion sort order
    local expansionOrder = HDG_ExpansionData.EXPANSION_SORT_ORDER

    -- Group recipes by profession
    local professionGroups = {}

    for dbKey, recipe in pairs(HDG_DecorDB) do
        local profName = recipe.profession
        if not profName then -- skip entries with no profession
            -- still index by itemID if present
            if recipe.itemID and recipe.decorID then
                privateDB.decorItem[recipe.itemID] = { decorID = recipe.decorID }
            end
        else

        local expansion = recipe.expansion or "Unknown"

        if not professionGroups[profName] then
            professionGroups[profName] = {}
        end

        local correctSpellID = recipe.spellID

        local extractedExpansion = expansion:match("^([^%s]+)") or "Unknown"
        local normalizedExpansion = HDG_ExpansionData.NormalizeToDisplayName(extractedExpansion)

        local recipeEntry = {
            itemID = recipe.itemID,
            name = recipe.name,
            spellID = correctSpellID,
            decorID = recipe.decorID,
            skill = expansion,
            skillNeeded = 1,
            expansion = normalizedExpansion,
            reagents = {},
            expansionOrder = expansionOrder[extractedExpansion] or 999
        }

        if recipe.reagents then
            for reagentID, reagentData in pairs(recipe.reagents) do
                local source = (HDG_ReagentsDB and HDG_ReagentsDB[reagentID] and HDG_ReagentsDB[reagentID][1]) or "Unknown"
                table.insert(recipeEntry.reagents, {
                    id = reagentID,
                    name = reagentData.name,
                    nameLower = reagentData.name and reagentData.name:lower() or nil,
                    qty = reagentData.qty,
                    source = source
                })
            end
        end

        table.insert(professionGroups[profName], recipeEntry)

        if recipe.itemID and recipe.decorID then
            privateDB.decorItem[recipe.itemID] = {
                decorID = recipe.decorID,
                profession = profName,
                expansion = normalizedExpansion,
            }
        end
        end -- close profName guard
    end

    -- Convert groups to array and sort
    for profName, recipes in pairs(professionGroups) do
        table.sort(recipes, function(a, b)
            if a.expansionOrder ~= b.expansionOrder then
                return a.expansionOrder < b.expansionOrder
            end
            return (a.skill or "") < (b.skill or "")
        end)

        table.insert(privateDB.professions, {
            name = profName,
            items = recipes
        })
    end

    table.sort(privateDB.professions, function(a, b)
        return a.name < b.name
    end)

    return true
end

-- ============================================================================
-- LUMBER FUNCTIONS (delegates to HDG_LumberTracker)
-- Note: Materials functions (AggregateMaterials, FilterAndSortMaterials,
-- CategorizeMaterials) are defined directly in HDG_RecipesTab - no delegation
-- ============================================================================

-- Calculate lumber needs
function HDG_Data.CalculateLumberNeeds(professions)
    return HDG_LumberTracker.CalculateLumberNeeds(professions)
end

-- Calculate lumber needs from crafting queue
function HDG_Data.CalculateCraftingQueueLumber(queue)
    return HDG_LumberTracker.CalculateCraftingQueueLumber(queue)
end

-- ============================================================================
-- PRICE INTEGRATION (Delegated to HDG_PriceIntegration module)
-- ============================================================================

-- Delegate to centralized price integration module
function HDG_Data.GetItemPrice(itemID)
    return HDG_PriceIntegration.GetItemPrice(itemID)
end

function HDG_Data.FormatGold(copper, useTextures)
    return HDG_PriceIntegration.FormatGold(copper, useTextures)
end

function HDG_Data.HasPriceAddon()
    return HDG_PriceIntegration.HasPriceAddon()
end

-- ============================================================================
-- CRAFTING HISTORY (delegates to HDG_DataTab)
-- ============================================================================

-- Log a craft from item link (new method using TRADE_SKILL_ITEM_CRAFTED_RESULT)
function HDG_Data.LogCraftFromItemLink(itemLink, quantity)
    local dt = privateDB.HDG_DataTab
    if dt then return dt.LogCraftFromItemLink(itemLink, quantity) end
end

-- Get crafting history
function HDG_Data.GetCraftingHistory()
    local dt = privateDB.HDG_DataTab
    if dt then return dt.GetCraftingHistory() end
    return {}
end

-- Clear crafting history
function HDG_Data.ClearCraftingHistory()
    local dt = privateDB.HDG_DataTab
    if dt then return dt.ClearCraftingHistory() end
end

-- Format timestamp for display
function HDG_Data.FormatTimestamp(timestamp)
    local dt = privateDB.HDG_DataTab
    if dt then return dt.FormatTimestamp(timestamp) end
    return ""
end

-- ============================================================================
-- RECIPE FILTERS & SEARCH (extracted from HDG_RecipesTab Phase 1 refactor)
-- ============================================================================

-- Check if a recipe matches caller-supplied filter context.
-- Caller (HDG_RecipesTab) builds the ctx from its own search-box state +
-- HDG.Store:GetConfig() reads. This function is pure data-layer logic now;
-- it doesn't touch shared filter state. ctx fields:
--   search          -- pre-lowercased search string ("" or nil = no search)
--   searchReagents  -- bool; if true, search reagent names too
--   uncollectedOnly -- bool
--   expansion       -- "All" or a canonical expansion display name
--   knownOnly       -- bool; account-wide (current char OR any alt)
function HDG_Data.RecipeMatchesFilters(recipe, ctx)
    ctx = ctx or {}

    local search = ctx.search
    if search and search ~= "" then
        local recipeName = GetRecipeLowerName(recipe)
        local matchesName = recipeName:find(search, 1, true)

        local matchesReagent = false
        if not matchesName and ctx.searchReagents and recipe.reagents then
            for _, reagent in ipairs(recipe.reagents) do
                local reagentName = reagent.nameLower or (reagent.name and reagent.name:lower())
                if reagentName and reagentName:find(search, 1, true) then
                    matchesReagent = true
                    break
                end
            end
        end

        if not matchesName and not matchesReagent then
            return false
        end
    end

    if ctx.uncollectedOnly and HDG_Data.IsItemCollected(recipe.itemID) then
        return false
    end

    if ctx.expansion and ctx.expansion ~= "All" then
        local norm = HDG_ExpansionData.NormalizeToDisplayName(recipe.expansion)
        if norm ~= ctx.expansion then return false end
    end

    -- Known filter -- account-wide: include if the current character OR any alt
    -- knows the recipe. Jack's feedback (2026-04-25): character-only scoping made
    -- the Known tab unusable for his workflow.
    if ctx.knownOnly then
        if not HDG_Data.PlayerKnowsRecipeBySpellID(recipe.spellID) then
            local knowers = HDG_Data.GetCharactersWhoKnowRecipe and HDG_Data.GetCharactersWhoKnowRecipe(recipe.spellID)
            if not (knowers and #knowers > 0) then return false end
        end
    end

    return true
end

-- ============================================================================
-- "WHAT CAN I CRAFT?" MODE
-- ============================================================================

-- Check if player can craft a specific recipe with current inventory
function HDG_Data.CanCraftRecipe(recipe)
    if not recipe or not recipe.reagents or #recipe.reagents == 0 then
        return false, 0, nil, 0
    end

    local maxCraftable = 999999 -- Start with a very high number
    local missingMats = {}
    local canCraft = true

    -- Track material availability for quantity-weighted percentage calculation
    -- Formula: sum(min(have, need)) / sum(need) * 100
    -- This weights each material by how much is needed, not treating each type equally
    local totalQuantityNeeded = 0
    local totalQuantityHave = 0

    for _, reagent in ipairs(recipe.reagents) do
        -- Use GetItemCountWithVariants to count all quality ranks (R1+R2+R3)
        local have = HDG_Data.GetItemCountWithVariants(reagent.id)
        local need = reagent.qty or 1

        -- Accumulate for quantity-weighted percentage (cap have at need, don't count excess)
        totalQuantityNeeded = totalQuantityNeeded + need
        totalQuantityHave = totalQuantityHave + math.min(have, need)

        if have < need then
            -- Missing this material
            canCraft = false
            table.insert(missingMats, {
                id = reagent.id,
                have = have,
                need = need,
                missing = need - have
            })
        else
            -- Fully have this material
            -- Calculate how many times we could craft based on this material
            local possibleFromThisMat = math.floor(have / need)
            if possibleFromThisMat < maxCraftable then
                maxCraftable = possibleFromThisMat
            end
        end
    end

    -- Calculate quantity-weighted material percentage
    local materialPercentage = totalQuantityNeeded > 0 and (totalQuantityHave / totalQuantityNeeded * 100) or 0

    if not canCraft then
        return false, 0, missingMats, materialPercentage
    end

    return true, maxCraftable, nil, 100
end

-- Get all craftable recipes for a profession (or all professions)
function HDG_Data.GetCraftableRecipes(professionFilter)
    local craftable = {}

    if not privateDB.professions then return craftable end

    for _, profession in ipairs(privateDB.professions) do
        -- Apply profession filter if specified
        if not professionFilter or professionFilter == "All" or profession.name == professionFilter then
            for _, recipe in ipairs(profession.items) do
                -- Only check recipes the player knows
                local knowsRecipe = HDG_Data.PlayerKnowsRecipeBySpellID(recipe.spellID)

                if knowsRecipe then
                    local canCraft, maxQty = HDG_Data.CanCraftRecipe(recipe)
                    if canCraft and maxQty > 0 then
                        table.insert(craftable, {
                            recipe = recipe,
                            profession = profession.name,
                            maxQuantity = maxQty
                        })
                    end
                end
            end
        end
    end

    return craftable
end

-- ============================================================================
-- MULTI-CHARACTER PROFESSION TRACKING (v2.0)
-- ============================================================================

-- Reference centralized profession data (ID -> Name lookup)
local PROFESSION_SKILL_LINES = HDG_ExpansionData.PROFESSION_NAMES

-- Get unique character key for current character
function HDG_Data.GetCharacterKey()
    local name = UnitName("player")
    local realm = GetRealmName()
    if name and realm then
        return name .. "-" .. realm
    end
    return nil
end

-- Get list of housing decor spellIDs for a given profession
local function GetDecorSpellIDsForProfession(professionName)
    local spellIDs = {}
    if privateDB.professions then
        for _, profData in ipairs(privateDB.professions) do
            if profData.name == professionName and profData.items then
                for _, recipe in ipairs(profData.items) do
                    if recipe.spellID then
                        spellIDs[recipe.spellID] = true
                    end
                end
            end
        end
    end
    return spellIDs
end

-- Collect and cache current character's profession data
-- Reference centralized expansion data
HDG_Data.EXPANSION_ORDER = HDG_ExpansionData.EXPANSION_ORDER
HDG_Data.EXPANSION_ABBR = HDG_ExpansionData.EXPANSION_ABBR

-- Profession decor thresholds are loaded from data/HDG_ProfessionThresholds.lua
-- Use HDG_GetDecorThreshold(profession, expansion) to get per-profession threshold values

-- Delegate to centralized expansion data module
local function NormalizeApiExpansionName(apiName)
    return HDG_ExpansionData.NormalizeApiName(apiName)
end

function HDG_Data.CollectCharacterProfessions()
    local charKey = HDG_Data.GetCharacterKey()
    if not charKey then return end

    -- Get existing character data from Store
    local characters = GetCharacters()
    local existingCharData = characters[charKey] or { professions = {} }
    local existingProfessions = existingCharData.professions or {}

    -- Build a name→skillLine map from GetProfessions so we can match the open
    -- prof window's fresh skill data. GetProfessions can return nil for
    -- secondary professions like Cooking even when the player knows recipes,
    -- so we iterate by decor-recipe profession name (below) and look up the
    -- skillLine here only when it's available.
    local prof1, prof2, archaeology, fishing, cooking = GetProfessions()
    local profNameToSkillLine = {}
    for _, profIndex in ipairs({ prof1, prof2, cooking, archaeology, fishing }) do
        if profIndex then
            local _, _, _, _, _, _, skillLine = GetProfessionInfo(profIndex)
            local name = PROFESSION_SKILL_LINES[skillLine]
            if name then profNameToSkillLine[name] = skillLine end
        end
    end

    -- Collect every profession name that has at least one decor recipe in
    -- HDG_DecorDB. This guarantees Cooking (and any other profession with
    -- decor recipes) is always scanned, regardless of GetProfessions().
    local decorProfessionNames = {}
    if privateDB.professions then
        for _, profData in ipairs(privateDB.professions) do
            if profData.name and profData.items and #profData.items > 0 then
                decorProfessionNames[profData.name] = true
            end
        end
    end

    -- Union: also include professions the character has per GetProfessions
    -- (e.g. gathering profs like Skinning/Herbalism/Mining that have no decor
    -- recipes but should still appear in the character's row when owned).
    local profsToScan = {}
    for name in pairs(decorProfessionNames) do profsToScan[name] = true end
    for name in pairs(profNameToSkillLine) do profsToScan[name] = true end

    -- Get currently open profession's skill data (C_TradeSkillUI only works when prof window is open)
    local openProfSkillLevels = {}
    local openProfSkillLine = nil -- Use skillLine ID for reliable matching

    if C_TradeSkillUI.GetChildProfessionInfos then
        local childInfos = C_TradeSkillUI.GetChildProfessionInfos()
        if childInfos and #childInfos > 0 then
            local expOrder = HDG_ExpansionData and HDG_ExpansionData.EXPANSION_ORDER or {}
            for i, childInfo in ipairs(childInfos) do
                if childInfo.skillLevel and childInfo.maxSkillLevel then
                    local normalizedName
                    if childInfo.expansionName and childInfo.expansionName ~= "" then
                        normalizedName = NormalizeApiExpansionName(childInfo.expansionName)
                    end
                    -- Fallback 1: try full professionName (e.g., "Кул-тирасская алхимия" contains "Кул-тирас")
                    if (not normalizedName or normalizedName == "Unknown") and childInfo.professionName and childInfo.professionName ~= "" then
                        normalizedName = HDG_ExpansionData.GetExpansionFromSkill(childInfo.professionName)
                    end
                    -- Fallback 2: use array index to determine expansion (index 1=Classic, 2=TBC, etc.)
                    if (not normalizedName or normalizedName == "Unknown") and expOrder[i] then
                        normalizedName = expOrder[i]
                    end
                    if normalizedName and normalizedName ~= "Unknown" then
                        openProfSkillLevels[normalizedName] = {
                            current = childInfo.skillLevel,
                            max = childInfo.maxSkillLevel,
                        }
                    end
                end
            end
            -- Get the profession's skillLine ID for reliable matching
            local baseInfo = C_TradeSkillUI.GetBaseProfessionInfo()
            if baseInfo and baseInfo.professionID then
                openProfSkillLine = baseInfo.professionID
            end
        end
    end

    -- Build new professions data
    local newProfessions = {}

    for profName in pairs(profsToScan) do
        local skillLine = profNameToSkillLine[profName] -- may be nil if not in GetProfessions slots
        do
            if profName then

                -- Get housing decor spellIDs for this profession
                local decorSpells = GetDecorSpellIDsForProfession(profName)

                -- Count known recipes
                local knownRecipes = {}
                local knownCount = 0
                local totalCount = 0

                for spellID, _ in pairs(decorSpells) do
                    totalCount = totalCount + 1
                    local isKnown = C_SpellBook.IsSpellKnown(spellID)
                    if isKnown then
                        knownRecipes[spellID] = true
                        knownCount = knownCount + 1
                    end
                end

                -- Determine skill levels: use fresh data if this is the open profession,
                -- otherwise preserve existing cached data (with normalization)
                local skillLevels = {}
                if openProfSkillLine == skillLine and next(openProfSkillLevels) then
                    -- This profession window is open - use fresh skill data (already normalized)
                    skillLevels = openProfSkillLevels
                elseif existingProfessions[profName] and existingProfessions[profName].skillLevels then
                    -- Preserve existing cached skill levels, but normalize keys to fix legacy data
                    -- (e.g., "Outland" -> "The Burning Crusade")
                    local oldSkillLevels = existingProfessions[profName].skillLevels
                    for expName, skillData in pairs(oldSkillLevels) do
                        local normalizedName = NormalizeApiExpansionName(expName)
                        -- Only keep the highest value if we have duplicates after normalization
                        if not skillLevels[normalizedName] or skillData.current > (skillLevels[normalizedName].current or 0) then
                            skillLevels[normalizedName] = skillData
                        end
                    end
                end

                local existingHasSkillData = existingProfessions[profName]
                    and existingProfessions[profName].skillLevels
                    and next(existingProfessions[profName].skillLevels) ~= nil
                local charHasProfession = skillLine ~= nil
                    or existingHasSkillData
                    or knownCount > 0

                if charHasProfession then
                    newProfessions[profName] = {
                        knownRecipes = knownRecipes,
                        knownCount = knownCount,
                        totalCount = totalCount,
                        skillLevels = skillLevels,
                    }
                end
            end
        end
    end

    -- Check if current character knows Find Lumber (spell 1256697).
    -- IsPlayerSpell was deprecated; C_SpellBook.IsSpellKnown is the verified
    -- replacement on retail.
    local knowsFindLumber = false
    if C_SpellBook and C_SpellBook.IsSpellKnown then
        knowsFindLumber = C_SpellBook.IsSpellKnown(1256697) or false
    end

    -- Dispatch update to Store
    Dispatch("UPDATE_CHARACTER_DATA", {
        charKey = charKey,
        professions = newProfessions,
        knowsFindLumber = knowsFindLumber,
        lastUpdated = time(),
    })

    -- Return the updated character data for immediate use
    return {
        professions = newProfessions,
        lastUpdated = time(),
    }
end

-- Get cached profession data for all characters (excluding current)
function HDG_Data.GetAltProfessionData(professionName)
    local currentChar = HDG_Data.GetCharacterKey()
    local altData = {}
    local characters = GetCharacters()

    for charKey, charData in pairs(characters) do
        if charKey ~= currentChar and charData.professions and charData.professions[professionName] then
            local profData = charData.professions[professionName]
            table.insert(altData, {
                character = charKey,
                knownCount = profData.knownCount or 0,
                totalCount = profData.totalCount or 0,
            })
        end
    end

    -- Sort by known count (highest first)
    table.sort(altData, function(a, b) return a.knownCount > b.knownCount end)

    return altData
end

-- Get the best alt's recipe count for a profession (highest known count)
function HDG_Data.GetBestAltRecipeCount(professionName)
    local altData = HDG_Data.GetAltProfessionData(professionName)
    if #altData > 0 then
        return altData[1].knownCount, altData[1].totalCount, altData[1].character
    end
    return 0, 0, nil
end

-- Check if any alt has a given profession
function HDG_Data.AnyAltHasProfession(professionName)
    local altData = HDG_Data.GetAltProfessionData(professionName)
    return #altData > 0
end

-- Get all characters that know a specific recipe by spellID
-- Returns array of { name = "CharName-Realm", canCraft = true/false }
-- canCraft is true if they know the recipe AND have the profession
function HDG_Data.GetCharactersWhoKnowRecipe(spellID, professionName)
    local result = {}
    local characters = GetCharacters()

    -- Check current character first. Use the same cache-first strategy as
    -- PlayerKnowsRecipeBySpellID — C_SpellBook.IsSpellKnown alone misses
    -- older-expansion recipes when their profession isn't currently slotted,
    -- which would cause "Recipe known by:" tooltips to omit the current
    -- character even when they know it. The cache scan records every learned
    -- recipe across all expansions, so it's authoritative once populated.
    local currentChar = HDG_Data.GetCharacterKey()
    local currentKnows = false
    local currentCharData = currentChar and characters and characters[currentChar]
    if currentCharData and currentCharData.professions then
        for _, profData in pairs(currentCharData.professions) do
            if profData.knownRecipes and profData.knownRecipes[spellID] then
                currentKnows = true
                break
            end
        end
    end
    if not currentKnows and C_SpellBook and C_SpellBook.IsSpellKnown then
        currentKnows = C_SpellBook.IsSpellKnown(spellID)
    end
    if currentKnows then
        table.insert(result, {
            name = currentChar,
            isCurrent = true,
        })
    end

    -- Check all other characters
    for charKey, charData in pairs(characters) do
        if charKey ~= currentChar and charData.professions then
            -- Check if this character has the profession and knows the recipe
            if professionName and charData.professions[professionName] then
                local profData = charData.professions[professionName]
                if profData.knownRecipes and profData.knownRecipes[spellID] then
                    table.insert(result, {
                        name = charKey,
                        isCurrent = false,
                    })
                end
            else
                -- No profession specified, check all professions
                for _, profData in pairs(charData.professions) do
                    if profData.knownRecipes and profData.knownRecipes[spellID] then
                        table.insert(result, {
                            name = charKey,
                            isCurrent = false,
                        })
                        break -- Only add character once
                    end
                end
            end
        end
    end

    -- Sort: current character first, then alphabetically
    table.sort(result, function(a, b)
        if a.isCurrent ~= b.isCurrent then
            return a.isCurrent
        end
        return a.name < b.name
    end)

    return result
end

-- ============================================================================
-- BLIZZARD EVENT WIRING
-- Called once from HousingDecorGuide.lua's ADDON_LOADED handler.
-- Owns the data-layer concerns of trade-skill / recipe / craft events:
--   TRADE_SKILL_LIST_UPDATE, NEW_RECIPE_LEARNED -- recipe + profession
--     cache invalidation; debounced profession-data collection; lumber-needs
--     invalidation (cross-module); UI refresh fan-out via main file's
--     QueueRecipeUpdate (debounced).
--   TRADE_SKILL_SHOW                -- collect profession data when the
--     tradeskill list is actually ready (not just when the window opens).
--   TRADE_SKILL_ITEM_CRAFTED_RESULT -- log craft to acquisition history.
--     The crafting-queue auto-decrement and talking-head craft quote live
--     in their own modules' event frames (HDG_PowerCrafting, HDG_Vamoose).
-- ============================================================================
local _pendingProfDataTimer = nil
function HDG_Data.RegisterEvents()
    local f = CreateFrame("Frame")
    f:RegisterEvent("TRADE_SKILL_LIST_UPDATE")
    f:RegisterEvent("NEW_RECIPE_LEARNED")
    f:RegisterEvent("TRADE_SKILL_SHOW")
    f:RegisterEvent("TRADE_SKILL_ITEM_CRAFTED_RESULT")

    f:SetScript("OnEvent", function(_, event, arg1)
        if event == "TRADE_SKILL_LIST_UPDATE" or event == "NEW_RECIPE_LEARNED" then
            HDG_Data.InvalidateRecipeCache()
            HDG_Data.InvalidateProfessionCache()
            if privateDB.QueueRecipeUpdate then privateDB.QueueRecipeUpdate() end
            if HDG_LumberTracker and HDG_LumberTracker.InvalidateLumberNeeds then
                HDG_LumberTracker.InvalidateLumberNeeds()
            end
            -- Debounced profession-data collection (recipes can fire dozens
            -- of these in a single tradeskill open).
            if _pendingProfDataTimer then _pendingProfDataTimer:Cancel() end
            _pendingProfDataTimer = C_Timer.NewTimer(0.5, function()
                _pendingProfDataTimer = nil
                HDG_Data.CollectCharacterProfessions()
            end)

        elseif event == "TRADE_SKILL_SHOW" then
            -- TRADE_SKILL_SHOW fires before the list itself is ready; the
            -- actual "data usable" signal is TRADE_SKILL_LIST_UPDATE (or
            -- IsTradeSkillReady returning true if Blizzard cached it).
            -- Was a blind 0.3s wait.
            local function collectProfData()
                HDG_Data.CollectCharacterProfessions()
                if HDG and HDG.EventBus then
                    HDG.EventBus:Trigger("HDG_PROFESSION_DATA_UPDATED")
                end
            end
            if C_TradeSkillUI and C_TradeSkillUI.IsTradeSkillReady
                and C_TradeSkillUI.IsTradeSkillReady() then
                collectProfData()
            else
                local waiter = CreateFrame("Frame")
                local done = false
                local function finish()
                    if done then return end
                    done = true
                    waiter:UnregisterAllEvents()
                    waiter:SetScript("OnEvent", nil)
                    collectProfData()
                end
                waiter:RegisterEvent("TRADE_SKILL_LIST_UPDATE")
                waiter:SetScript("OnEvent", finish)
                -- Safety fallback: if the event somehow never fires, collect
                -- anyway after 2s so we don't silently miss a refresh.
                C_Timer.After(2, finish)
            end

        elseif event == "TRADE_SKILL_ITEM_CRAFTED_RESULT" then
            -- arg1 = result table { hyperlink, quantity, isCrit }.
            -- Acquisition history log only -- queue auto-decrement and
            -- craft quote live in their own modules' event frames.
            if arg1 and arg1.hyperlink then
                HDG_Data.LogCraftFromItemLink(arg1.hyperlink, arg1.quantity or 1)
            end
        end
    end)
end

-- Export data module functions to global if needed or keep using HDG_Data table
_G.HDG_Data = HDG_Data
