-- HDG_HT_Aggregator.lua
-- Single-pass scan of HDG_AllDecorDB → produces shared dataSnapshot for all
-- widgets. Eager rebuild on invalidation events; debounced 100ms; widgets
-- subscribe via Aggregator.Subscribe(fn).

local _, privateDB = ...
privateDB.HDG_HT = privateDB.HDG_HT or {}
local Aggregator = {}
privateDB.HDG_HT.Aggregator = Aggregator

local _snapshot      = nil
local _subscribers   = {}
local _rebuildPending = false

function Aggregator.Subscribe(fn)
    -- Defensive dedup — caller may forget their own _subscribed flag.
    for _, existing in ipairs(_subscribers) do
        if existing == fn then return end
    end
    _subscribers[#_subscribers + 1] = fn
end

function Aggregator.GetSnapshot()
    return _snapshot
end

local function notifyAll()
    for _, fn in ipairs(_subscribers) do
        local ok, err = pcall(fn, _snapshot)
        if not ok then
            local HDG = _G.HousingDecorGuide
            local debug = HDG and HDG.Store and HDG.Store:GetState()
                          and HDG.Store:GetState().config
                          and HDG.Store:GetState().config.debug
            if debug then
                print("|cFFFF0000[HDG cockpit]|r aggregator subscriber error: " .. tostring(err))
            end
        end
    end
end

function Aggregator.Build()
    _snapshot = privateDB.HDG_HT._BuildSnapshot and privateDB.HDG_HT._BuildSnapshot() or {}
    notifyAll()
    -- An empty first snapshot at /reload is expected: HDG_Data's catalog
    -- cache populates asynchronously and fires HDG_COLLECTION_READY when
    -- ready. The EventBus subscription below drives the rebuild -- no
    -- C_Timer polling needed.
end

-- Debounced public scheduler — coalesces multiple invalidation events.
function Aggregator.RequestRebuild()
    if _rebuildPending then return end
    _rebuildPending = true
    C_Timer.After(0.1, function()
        _rebuildPending = false
        Aggregator.Build()
    end)
end

-- Named indirection kept for future per-event partial rebuilds.
local function onCollectionReady() Aggregator.RequestRebuild() end
local function onBagUpdate()       Aggregator.RequestRebuild() end
local function onCurrencyUpdate()  Aggregator.RequestRebuild() end
local function onRecentActivity()  Aggregator.RequestRebuild() end

local function setupEventBus()
    local HDG = _G.HousingDecorGuide
    if HDG and HDG.EventBus and HDG.EventBus.Register then
        HDG.EventBus:Register("HDG_COLLECTION_READY",        onCollectionReady)
        HDG.EventBus:Register("HDG_RECENT_ACTIVITY_UPDATED", onRecentActivity)
    end
end

local function setupBlizzardEvents()
    local f = CreateFrame("Frame")
    f:RegisterEvent("BAG_UPDATE_DELAYED")
    f:RegisterEvent("CURRENCY_DISPLAY_UPDATE")
    f:RegisterEvent("HOUSE_LEVEL_FAVOR_UPDATED")
    f:SetScript("OnEvent", function(_, event)
        if event == "BAG_UPDATE_DELAYED" then onBagUpdate()
        elseif event == "CURRENCY_DISPLAY_UPDATE" then onCurrencyUpdate()
        elseif event == "HOUSE_LEVEL_FAVOR_UPDATED" then Aggregator.RequestRebuild() end
    end)
end

-- ============================================================================
-- Snapshot builder — single-pass scan over HDG_AllDecorDB
-- ============================================================================
-- Produces every field consumed by widgets in one walk. Mirrors the existing
-- BuildCollectionStats logic but exposes results via Aggregator.GetSnapshot().

local function buildSnapshot()
    local snap = {
        -- Core counts
        bySource    = {},   -- [sourceType] = { total, collected }
        byExp       = {},   -- [expansion] = { total, collected }
        totalAll    = 0,
        collectedAll = 0,

        -- Capacity (A1) — populated below from C_HousingCatalog
        capacity    = nil,

        -- B1 hot picks: top-5 uncollected by firstAcquisitionBonus
        firstCatchPicks = {},
        firstCatchXPLeft = 0,

        -- C1 craftable now: count of uncollected items the player can craft right now
        craftable   = { canCraftNow = 0, almostCraftable = 0, almostCost = 0 },

        -- C2 recent activity (filled separately by HDG_RecentActivity)
        recentPlaced = {},

        -- Tier/title/persona (D1)
        title       = nil,
        titleTier   = nil,
        topStyles   = nil,
        persona     = nil,

        -- Trophies, close-cards, featured (preserved from v2.37 logic)
        trophies            = {},
        trophiesTotal       = 0,
        trophiesCollected   = 0,
        closestToComplete   = {},
        featured            = nil,

        -- C3 themed sets (top closest-to-complete)
        themedSets          = {},

        -- C4 currency wallet (lumber item-counts + housing currency-counts).
        -- housingNeed[currID] = total amount of that currency needed to buy
        -- every uncollected vendor decor (computed during catalog scan).
        wallet              = { lumber = {}, housing = {}, housingNeed = {} },
    }

    -- Capacity (sync APIs; safe to call any time)
    if C_HousingCatalog and C_HousingCatalog.GetDecorMaxOwnedCount
       and C_HousingCatalog.GetDecorTotalOwnedCount then
        local owned, exempt = C_HousingCatalog.GetDecorTotalOwnedCount()
        local maxOwn = C_HousingCatalog.GetDecorMaxOwnedCount()
        owned  = owned  or 0
        exempt = exempt or 0
        maxOwn = maxOwn or 0
        local pct = (maxOwn > 0) and (owned / maxOwn) or 0
        local tier = "healthy"
        if pct >= 0.95 then tier = "full"
        elseif pct >= 0.80 then tier = "warn" end
        snap.capacity = {
            owned = owned, max = maxOwn, exempt = exempt,
            pct = pct, tier = tier,
        }
    end

    -- B4 Rooms: count entryType == 2 entries + owned. Single pass alongside
    -- the main catalog scan below; field initialised here.
    snap.rooms = { total = 0, owned = 0 }

    -- Catalog scan
    local HDG_Data = privateDB.HDG_Data or _G.HDG_Data
    local AllDecorDB = _G.HDG_AllDecorDB
    if not AllDecorDB then snap.skipped = true; return snap end
    if not HDG_Data or not HDG_Data.GetCatalogByItem then snap.skipped = true; return snap end

    local clientVer = select(4, GetBuildInfo())
    local hotPicksRaw = {}      -- collect, sort, take top 5
    local CONFIG = privateDB.CONFIG or {}
    local SOURCE_TYPE = CONFIG.SOURCE_TYPE or {}

    -- C3 themed-set match buckets — flat compile of HDG_CollectionDefinitions
    -- happens lazily once. Each entry: { id, name, color, namePatterns[],
    -- excludePatterns[], counters }.
    local THEMED = privateDB.HDG_HT._themedDefs
    if not THEMED and _G.HDG_CollectionDefinitions then
        THEMED = {}
        for id, def in pairs(_G.HDG_CollectionDefinitions) do
            if def.namePatterns and #def.namePatterns > 0 then
                THEMED[#THEMED + 1] = {
                    id = id,
                    name = def.displayName or id,
                    color = def.color,
                    inc  = def.namePatterns,
                    exc  = def.excludePatterns,
                }
            end
        end
        privateDB.HDG_HT._themedDefs = THEMED
    end
    local themedCounts = {}     -- [defIdx] = { total, collected }
    if THEMED then
        for i = 1, #THEMED do themedCounts[i] = { total = 0, collected = 0 } end
    end

    -- Build skip-set of tag IDs from Size/Expansion groups so they don't
    -- pollute the "style" fingerprint. Same data source as Blizzard's filter.
    -- Cached on first build.
    local skipTagIDs = privateDB.HDG_HT._skipTagIDs
    if not skipTagIDs and C_HousingCatalog and C_HousingCatalog.GetAllFilterTagGroups then
        skipTagIDs = {}
        local groups = C_HousingCatalog.GetAllFilterTagGroups()
        if groups then
            for _, g in ipairs(groups) do
                local gn = g.groupName or ""
                if gn:find("Expansion") or gn:find("Size") then
                    for _, ti in pairs(g.tags or {}) do
                        if ti and ti.tagID then skipTagIDs[ti.tagID] = true end
                    end
                end
            end
        end
        privateDB.HDG_HT._skipTagIDs = skipTagIDs
    end
    skipTagIDs = skipTagIDs or {}

    for itemID, entry in pairs(AllDecorDB) do
        local catalogRow = HDG_Data.GetCatalogByItem(itemID)
        local isUnreleased = (not catalogRow) or (entry.ver and clientVer and entry.ver > clientVer)
        if not isUnreleased then
            local sourceType = entry[2] or 0
            if sourceType == 0 and _G.HDG_VendorByItem and _G.HDG_VendorByItem[itemID] and SOURCE_TYPE.VENDOR then
                sourceType = SOURCE_TYPE.VENDOR
            end
            local expansion = entry.exp or "Unknown"
            local collected = HDG_Data.IsItemCollected and HDG_Data.IsItemCollected(itemID)

            snap.bySource[sourceType] = snap.bySource[sourceType] or { total = 0, collected = 0 }
            snap.bySource[sourceType].total = snap.bySource[sourceType].total + 1
            snap.byExp[expansion] = snap.byExp[expansion] or { total = 0, collected = 0 }
            snap.byExp[expansion].total = snap.byExp[expansion].total + 1
            snap.totalAll = snap.totalAll + 1

            -- B4 Rooms: catalog rows with entryType == 2 are unlockable rooms
            if catalogRow and catalogRow.entryType == 2 then
                snap.rooms.total = snap.rooms.total + 1
                if collected then snap.rooms.owned = snap.rooms.owned + 1 end
            end

            -- Item name (used by trophy detection, themed-set match, etc.)
            local itemName = (catalogRow and catalogRow.name) or (entry and entry.name) or ""

            -- C3 themed-set membership (string.find on item name; OR includes,
            -- AND excludes — same logic as HDG_CollectionDefinitions consumers)
            if THEMED and itemName ~= "" then
                for di = 1, #THEMED do
                    local def = THEMED[di]
                    local matched = false
                    for _, pat in ipairs(def.inc) do
                        if itemName:find(pat, 1, true) then matched = true; break end
                    end
                    if matched and def.exc then
                        for _, pat in ipairs(def.exc) do
                            if itemName:find(pat, 1, true) then matched = false; break end
                        end
                    end
                    if matched then
                        local c = themedCounts[di]
                        c.total = c.total + 1
                        if collected then c.collected = c.collected + 1 end
                    end
                end
            end
            -- Trophy detection (preserved from v2.37 BuildCollectionStats)
            local isUnique = catalogRow and catalogRow.isUniqueTrophy
            local isPrey = itemName:find("Preyseeker") and (itemName:find("Bust") or itemName:find("Effigy"))
            if isUnique or isPrey then
                snap.trophiesTotal = snap.trophiesTotal + 1
                if collected then
                    snap.trophiesCollected = snap.trophiesCollected + 1
                    local iconID = (catalogRow and catalogRow.iconTexture)
                                or (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(itemID))
                    snap.trophies[#snap.trophies + 1] = {
                        itemID = itemID, iconID = iconID, name = itemName,
                        isUnique = isUnique, owned = true,
                    }
                end
            end

            -- By (category, subcategory) pair — for "Closest to Complete" cards.
            -- IMPORTANT: subcategoryID is NOT globally unique. Subcat 8
            -- ("Windows") exists in both "Structural" and "Nature" with
            -- distinct items in each. Bucket by composite key so categories
            -- don't merge, and so the click-through filter targets the right
            -- bucket.
            local subcatID = catalogRow and catalogRow.subcategoryID
            local catID    = catalogRow and catalogRow.categoryID
            if subcatID and catID then
                local key = catID * 100000 + subcatID  -- composite numeric key
                snap._bySubcat = snap._bySubcat or {}
                local s = snap._bySubcat[key]
                if not s then
                    local subcatName, parentName
                    if C_HousingCatalog and C_HousingCatalog.GetCatalogSubcategoryInfo then
                        local info = C_HousingCatalog.GetCatalogSubcategoryInfo(subcatID)
                        subcatName = info and info.name
                    end
                    if C_HousingCatalog and C_HousingCatalog.GetCatalogCategoryInfo then
                        local cinfo = C_HousingCatalog.GetCatalogCategoryInfo(catID)
                        parentName = cinfo and cinfo.name
                    end
                    s = {
                        categoryID = catID, subcategoryID = subcatID,
                        total = 0, collected = 0,
                        name = subcatName or tostring(subcatID),
                        parentName = parentName or "",
                    }
                    snap._bySubcat[key] = s
                end
                s.total = s.total + 1
                if collected then s.collected = s.collected + 1 end
            end
            -- Style tags (skip Size/Expansion groups via skipTagIDs)
            if catalogRow and catalogRow.tags then
                snap._styleTags = snap._styleTags or {}
                snap._styleTagsTotal = snap._styleTagsTotal or {}
                for tid, tname in pairs(catalogRow.tags) do
                    if type(tname) == "string" and not skipTagIDs[tid] then
                        snap._styleTagsTotal[tname] = (snap._styleTagsTotal[tname] or 0) + 1
                        if collected then
                            snap._styleTags[tname] = (snap._styleTags[tname] or 0) + 1
                        end
                    end
                end
            end

            if collected then
                snap.bySource[sourceType].collected = snap.bySource[sourceType].collected + 1
                snap.byExp[expansion].collected = snap.byExp[expansion].collected + 1
                snap.collectedAll = snap.collectedAll + 1
            else
                local bonus = catalogRow and catalogRow.firstAcquisitionBonus
                if bonus and bonus > 0 then
                    snap.firstCatchXPLeft = snap.firstCatchXPLeft + bonus
                    hotPicksRaw[#hotPicksRaw + 1] = {
                        itemID = itemID,
                        name   = (catalogRow and catalogRow.name) or (entry and entry.name),
                        icon   = (catalogRow and catalogRow.iconTexture) or (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(itemID)),
                        xp     = bonus,
                        sourceName = entry[3], zone = entry[4],
                    }
                end

                -- Currency-need: sum vendor currency cost for uncollected items.
                -- First vendor in HDG_VendorByItem wins (most items have one).
                local vbi = _G.HDG_VendorByItem
                local vdb = _G.HDG_VendorDB
                if vbi and vdb then
                    local npcIDs = vbi[itemID]
                    if npcIDs and npcIDs[1] then
                        local v = vdb[npcIDs[1]]
                        local ie = v and v.items and v.items[itemID]
                        local currCost = ie and ie[5]
                        if type(currCost) == "table" then
                            for _, cost in ipairs(currCost) do
                                if cost[1] == "c" and type(cost[2]) == "number" then
                                    local cid, amount = cost[2], cost[3] or 0
                                    snap.wallet.housingNeed[cid] =
                                        (snap.wallet.housingNeed[cid] or 0) + amount
                                end
                            end
                        end
                    end
                    -- Top-vendors widget: count uncollected items per vendor
                    -- AND capture a sample item icon for the row art (first
                    -- uncollected item encountered per vendor wins). Each
                    -- vendor that sells this item gets +1 — multiple stalls
                    -- selling the same item all count it.
                    if npcIDs then
                        snap._vendorUncollected = snap._vendorUncollected or {}
                        snap._vendorSampleItem  = snap._vendorSampleItem  or {}
                        for _, nid in ipairs(npcIDs) do
                            snap._vendorUncollected[nid] = (snap._vendorUncollected[nid] or 0) + 1
                            if not snap._vendorSampleItem[nid] then
                                snap._vendorSampleItem[nid] = {
                                    itemID = itemID,
                                    icon   = (catalogRow and catalogRow.iconTexture)
                                              or (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(itemID)),
                                    name   = itemName,
                                }
                            end
                        end
                    end
                end
            end
        end
    end

    -- Hot picks: top 5 by xp
    table.sort(hotPicksRaw, function(a, b) return (a.xp or 0) > (b.xp or 0) end)
    for i = 1, math.min(5, #hotPicksRaw) do snap.firstCatchPicks[i] = hotPicksRaw[i] end

    -- Trophies: v2.37 ordering — Blizzard isUniqueTrophy items first (alpha),
    -- then Preyseeker hunter trophies (alpha). Re-bucket post-loop.
    do
        local uniques, prey = {}, {}
        for _, t in ipairs(snap.trophies) do
            if t.isUnique then uniques[#uniques + 1] = t else prey[#prey + 1] = t end
        end
        table.sort(uniques, function(a, b) return (a.name or "") < (b.name or "") end)
        table.sort(prey,    function(a, b) return (a.name or "") < (b.name or "") end)
        snap.trophies = {}
        for _, t in ipairs(uniques) do snap.trophies[#snap.trophies + 1] = t end
        for _, t in ipairs(prey)    do snap.trophies[#snap.trophies + 1] = t end
    end

    -- C4 wallet: split lumber (account-wide counts) + decor currencies.
    do
        local ED = _G.HDG_ExpansionData
        local lumberTypes = (ED and ED.LUMBER_TYPES) or {}
        local lumber = snap.wallet.lumber
        local Data = _G.HDG_Data

        -- Lumber: account-wide counts via HDG_Data.GetAccountWideLumberCount
        -- (sums across all characters, not just the current bag).
        if Data and Data.GetAccountWideLumberCount then
            for itemID, info in pairs(lumberTypes) do
                local accountTotal = Data.GetAccountWideLumberCount(itemID) or 0
                if accountTotal > 0 then
                    lumber[#lumber + 1] = {
                        id        = itemID,
                        name      = info.shortName or info.name,
                        count     = accountTotal,
                        expansion = info.expansion,
                    }
                end
            end
            table.sort(lumber, function(a, b) return a.count > b.count end)
        end

        -- Decor currencies: enumerate via HDG_VendorDB. For each currency, also
        -- record the EARLIEST expansion (per HDG_ExpansionData.EXPANSION_ORDER)
        -- where any vendor accepts it — that's typically when the currency was
        -- introduced. Both maps cached on first build.
        local DECOR_CURRENCY_IDS = privateDB.HDG_HT._decorCurrencyIDs
        local DECOR_CURRENCY_EXP = privateDB.HDG_HT._decorCurrencyExp
        if not DECOR_CURRENCY_IDS and _G.HDG_VendorDB then
            local edOrder = (ED and ED.EXPANSION_ORDER) or {}
            local rank = {}
            for i, name in ipairs(edOrder) do rank[name] = i end

            local seen = {}    -- [currID] = bestRank (lower = earlier)
            local expByID = {} -- [currID] = expansion display name
            for _, vendor in pairs(_G.HDG_VendorDB) do
                local vexp = vendor.exp
                local vrank = (vexp and rank[vexp]) or 9999
                for _, item in pairs(vendor.items or {}) do
                    local currCost = item[5]
                    if type(currCost) == "table" then
                        for _, cost in ipairs(currCost) do
                            if cost[1] == "c" and type(cost[2]) == "number" then
                                local cid = cost[2]
                                if not seen[cid] or vrank < seen[cid] then
                                    seen[cid] = vrank
                                    expByID[cid] = vexp
                                end
                            end
                        end
                    end
                end
            end
            DECOR_CURRENCY_IDS = {}
            for id in pairs(seen) do DECOR_CURRENCY_IDS[#DECOR_CURRENCY_IDS + 1] = id end
            DECOR_CURRENCY_EXP = expByID
            privateDB.HDG_HT._decorCurrencyIDs = DECOR_CURRENCY_IDS
            privateDB.HDG_HT._decorCurrencyExp = DECOR_CURRENCY_EXP
        end

        local CCI = _G.C_CurrencyInfo
        if CCI and CCI.GetCurrencyInfo and DECOR_CURRENCY_IDS then
            for _, id in ipairs(DECOR_CURRENCY_IDS) do
                local info = CCI.GetCurrencyInfo(id)
                local have = (info and info.quantity) or 0
                local need = snap.wallet.housingNeed[id] or 0
                if info and (have > 0 or need > 0) then
                    local exp = (ED and ED.GetCurrencyExpansion and ED.GetCurrencyExpansion(id))
                        or (DECOR_CURRENCY_EXP and DECOR_CURRENCY_EXP[id])
                    snap.wallet.housing[#snap.wallet.housing + 1] = {
                        id        = id,
                        name      = info.name,
                        count     = have,
                        needed    = need,
                        cap       = info.maxQuantity,
                        icon      = info.iconFileID,
                        expansion = exp,
                    }
                end
            end
            -- Sort: currencies you still need before owned-only;
            -- within each group, by count desc.
            table.sort(snap.wallet.housing, function(a, b)
                local aNeed = (a.needed or 0) > 0 and 1 or 0
                local bNeed = (b.needed or 0) > 0 and 1 or 0
                if aNeed ~= bNeed then return aNeed > bNeed end
                return a.count > b.count
            end)
        end
    end

    -- C3 themed sets: top 4 closest-to-complete (highest pct < 100%; falls back
    -- to highest pct if everyone's done).
    if THEMED then
        local list = {}
        for di = 1, #THEMED do
            local def = THEMED[di]
            local c   = themedCounts[di]
            if c.total >= 5 then  -- ignore tiny noise sets
                local pct = c.collected / c.total
                list[#list + 1] = {
                    name      = def.name,
                    color     = def.color,
                    collected = c.collected,
                    total     = c.total,
                    pct       = pct,
                }
            end
        end
        table.sort(list, function(a, b)
            local aDone = (a.pct >= 1) and 1 or 0
            local bDone = (b.pct >= 1) and 1 or 0
            if aDone ~= bDone then return aDone < bDone end  -- in-progress before done
            return a.pct > b.pct                              -- closest first
        end)
        for i = 1, math.min(4, #list) do snap.themedSets[i] = list[i] end
    end

    -- topStyles: top 5 tags by total bucket size (need >= 10 items to count)
    do
        local styleList = {}
        for name, total in pairs(snap._styleTagsTotal or {}) do
            if total >= 10 then
                styleList[#styleList + 1] = {
                    name = name, total = total,
                    collected = (snap._styleTags or {})[name] or 0,
                }
            end
        end
        table.sort(styleList, function(a, b) return a.total > b.total end)
        snap.topStyles = {}
        for i = 1, math.min(5, #styleList) do
            local s = styleList[i]
            snap.topStyles[i] = {
                name = s.name, collected = s.collected, total = s.total,
                pct = s.total > 0 and (s.collected / s.total * 100) or 0,
            }
        end
    end

    -- Title tier ladder
    local TITLE_TIERS = {
        { threshold = 0,    name = "Aspiring Decorator" },
        { threshold = 1,    name = "Score a Decor" },
        { threshold = 100,  name = "Casual Collector" },
        { threshold = 250,  name = "Well-Travelled Collection" },
        { threshold = 500,  name = "Fully Furnished" },
        { threshold = 750,  name = "Decor Maven",         vamoose = true },
        { threshold = 1000, name = "Decor Master",        vamoose = true },
        { threshold = 1500, name = "Grand Curator",       vamoose = true },
        { threshold = 2000, name = "Legendary Decorator", vamoose = true },
    }
    local titleTier = { current = TITLE_TIERS[1], prev = nil, next = TITLE_TIERS[2] }
    for i = 1, #TITLE_TIERS do
        if snap.collectedAll >= TITLE_TIERS[i].threshold then
            titleTier.current = TITLE_TIERS[i]
            titleTier.prev    = TITLE_TIERS[i - 1]
            titleTier.next    = TITLE_TIERS[i + 1]
        end
    end
    snap.title = titleTier.current.name
    -- Affinity: dominant source >= 50% of collected
    if snap.collectedAll > 0 then
        local dominantSrc, dominantCount = nil, 0
        for src, s in pairs(snap.bySource) do
            if s.collected > dominantCount then dominantSrc, dominantCount = src, s.collected end
        end
        if dominantSrc and dominantCount / snap.collectedAll >= 0.5 then
            local ST = SOURCE_TYPE
            local affinityNames = {
                [ST.ACHIEVEMENT] = "Achievement Hunter", [ST.QUEST] = "Quest Hoarder",
                [ST.WORLD_QUEST] = "World Quester",      [ST.DROP] = "Loot Goblin",
                [ST.VENDOR] = "Vendor Connoisseur",      [ST.CRAFTED] = "Crafter Supreme",
                [ST.TREASURE] = "Treasure Tracker",      [ST.PROMOTION] = "Promo King",
                [ST.PROFESSION] = "Profession Master",
            }
            local aff = affinityNames[dominantSrc]
            if aff then titleTier.affinity = aff end
        end
    end
    snap.titleTier = titleTier

    -- D1 persona: composed from topStyle[1] + dominant-source affinity
    local topStyleName = (snap.topStyles and snap.topStyles[1] and snap.topStyles[1].name) or "Eclectic"
    local affinity = (snap.titleTier and snap.titleTier.affinity) or ""
    snap.persona = (affinity ~= "")
        and string.format("%s - %s", affinity, topStyleName)
        or  topStyleName

    -- Closest-to-complete (category, subcategory) buckets: 50-99% completion,
    -- smallest gap. id field carries subcategoryID for backward compat with
    -- click-through; categoryID is on its own field.
    do
        local closeList = {}
        for _, s in pairs(snap._bySubcat or {}) do
            if s.name and s.total >= 5 and s.collected < s.total then
                local pct = s.collected / s.total
                if pct >= 0.5 then
                    closeList[#closeList + 1] = {
                        id            = s.subcategoryID,
                        categoryID    = s.categoryID,
                        subcategoryID = s.subcategoryID,
                        name          = tostring(s.name),
                        parentName    = tostring(s.parentName or ""),
                        collected     = s.collected, total = s.total, pct = pct,
                        gap           = s.total - s.collected,
                    }
                end
            end
        end
        table.sort(closeList, function(a, b)
            if a.gap ~= b.gap then return a.gap < b.gap end
            return a.pct > b.pct
        end)
        for i = 1, math.min(3, #closeList) do snap.closestToComplete[i] = closeList[i] end
    end

    -- Featured of the week: 4 deterministic picks from collected items, rotates
    -- weekly. Stride spacing (#collected / 4) avoids clumping when the seed
    -- happens to land near the start of the array.
    snap.featured = {}
    if snap.collectedAll > 0 and HDG_Data and HDG_Data.IsItemCollected then
        local collectedIDs = {}
        for itemID in pairs(AllDecorDB) do
            if HDG_Data.IsItemCollected(itemID) and HDG_Data.GetCatalogByItem(itemID) then
                collectedIDs[#collectedIDs + 1] = itemID
            end
        end
        local n = #collectedIDs
        if n > 0 then
            table.sort(collectedIDs)  -- stable order so seeded picks are reproducible
            local seed = tonumber(date("%Y%W")) or 0
            local count = math.min(4, n)
            local stride = math.max(1, math.floor(n / count))
            for i = 1, count do
                local idx = ((seed + (i - 1) * stride) % n) + 1
                local fid = collectedIDs[idx]
                local entry = AllDecorDB[fid]
                local row = HDG_Data.GetCatalogByItem(fid)
                snap.featured[i] = {
                    itemID = fid,
                    name = (row and row.name) or (entry and entry.name) or ("Item " .. fid),
                    icon = (row and row.iconTexture) or (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(fid)),
                    sourceName = entry and entry[3] or nil,
                    sourceDetail = entry and entry[4] or nil,
                }
            end
        end
    end

    -- Craftable now (uncollected only)
    if _G.HDG_RecipesDB and HDG_Data and HDG_Data.IsItemCollected then
        local canCraft = 0
        for outputItemID, recipe in pairs(_G.HDG_RecipesDB) do
            if not HDG_Data.IsItemCollected(outputItemID) and recipe.reagents then
                local haveAll = true
                for reagentID, qty in pairs(recipe.reagents) do
                    -- C_Item.GetItemCount(itemID, includeBank, includeUses,
                    --                     includeReagentBank, includeAccountBank)
                    -- Count bag + bank + reagent bank + Warband Account Bank.
                    local owned = (C_Item and C_Item.GetItemCount
                        and C_Item.GetItemCount(reagentID, true, false, true, true)) or 0
                    if owned < qty then haveAll = false; break end
                end
                if haveAll then canCraft = canCraft + 1 end
            end
        end
        snap.craftable.canCraftNow = canCraft
    end

    -- Top vendors: 5 vendors with the most uncollected decor at their stall.
    snap.topVendors = {}
    if snap._vendorUncollected and _G.HDG_VendorDB then
        local list = {}
        for npcID, count in pairs(snap._vendorUncollected) do
            if count > 0 then
                local v = _G.HDG_VendorDB[npcID]
                if v then
                    local sample = snap._vendorSampleItem and snap._vendorSampleItem[npcID]
                    list[#list + 1] = {
                        npcID = npcID,
                        name  = (HDG_VendorResolver and HDG_VendorResolver.GetVendorNameByID
                                  and HDG_VendorResolver.GetVendorNameByID(npcID))
                                or v[1] or v.name or ("NPC #" .. npcID),
                        zone  = v[2] or v.zone or "",
                        count = count,
                        sampleIcon   = sample and sample.icon,
                        sampleItemID = sample and sample.itemID,
                        sampleName   = sample and sample.name,
                    }
                end
            end
        end
        table.sort(list, function(a, b)
            if a.count ~= b.count then return a.count > b.count end
            return (a.name or "") < (b.name or "")
        end)
        for i = 1, math.min(5, #list) do snap.topVendors[i] = list[i] end
    end
    snap._vendorUncollected = nil  -- intermediate; not consumed by widgets
    snap._vendorSampleItem  = nil

    -- Goblin top-5 by $/lumber. Pulls from HDG_GoblinTab's already-computed
    -- profitData (lazily triggers a calc if the user hasn't opened the Goblin
    -- tab yet). Each row is normalised for the IconRowList pattern.
    snap.goblinTopLumber = {}
    if _G.HDG_GoblinTab and _G.HDG_GoblinTab.GetTopByLumberValue then
        local ED = _G.HDG_ExpansionData
        local top = _G.HDG_GoblinTab.GetTopByLumberValue(5) or {}
        for i, item in ipairs(top) do
            local lumberInfo = item.lumberType and ED and ED.GetLumberInfo
                                and ED.GetLumberInfo(item.lumberType) or nil
            local lumberExp = lumberInfo and lumberInfo.expansion
            local color = lumberExp and ED and ED.GetColor and ED.GetColor(lumberExp) or nil
            snap.goblinTopLumber[i] = {
                itemID         = item.itemID,
                name           = item.name,
                icon           = item.itemID and C_Item and C_Item.GetItemIconByID
                                  and C_Item.GetItemIconByID(item.itemID) or nil,
                lumberValue    = item.lumberValue,
                lumberTypeID   = item.lumberType,
                lumberExpansion = lumberExp,
                lumberShortName = lumberInfo and (lumberInfo.shortName or lumberInfo.name),
                color          = color,
            }
        end
    end

    -- Recent activity: last 5 acquisition-history entries (Crafted / Learned /
    -- looted decor) — same source the Data tab uses. Lives in
    -- HDG_Settings.craftingHistory regardless of which house the player is in.
    local hist = HDG and HDG.Store and HDG.Store.state.craftingHistory
    if type(hist) == "table" and #hist > 0 then
        -- History is appended chronologically; sort by timestamp desc just in
        -- case, then take top 5.
        local rows = {}
        for _, e in ipairs(hist) do
            rows[#rows + 1] = {
                itemID    = e.itemID,
                name      = e.name or e.itemName,
                icon      = e.itemID and C_Item and C_Item.GetItemIconByID
                            and C_Item.GetItemIconByID(e.itemID) or nil,
                timestamp = e.timestamp,
                source    = e.source,        -- "Crafted" / "Learned" / etc.
            }
        end
        table.sort(rows, function(a, b) return (a.timestamp or 0) > (b.timestamp or 0) end)
        for i = 1, math.min(5, #rows) do snap.recentPlaced[i] = rows[i] end
    end

    -- Velocity = collection growth, not crafting throughput. Only "Learned"
    -- entries grow the collection (and earn House XP); a 65-barrel craft session
    -- adds one Learned event (the first one) plus 64 Crafted entries that
    -- contribute zero to collection size. Filter to source=="Learned" so the
    -- pace reflects items/week of NEW decor, not total recipe pulls.
    --
    -- snap.velocity stays nil when the history has no Learned entries at all
    -- (cold install / fresh char), so the widget can show "Insufficient history
    -- yet" rather than a misleading 0.0/week. Once any Learned entry exists,
    -- velocity is set even if recentCount is 0 (honest "you learned 0 this
    -- month" reading instead of pretending we have nothing).
    local now = time and time() or 0
    if type(hist) == "table" and now > 0 then
        local thirtyDayCutoff = now - (30 * 86400)
        local recentCount = 0
        local totalLearned = 0
        for _, e in ipairs(hist) do
            if e.source == "Learned" then
                totalLearned = totalLearned + 1
                if e.timestamp and e.timestamp >= thirtyDayCutoff then
                    recentCount = recentCount + 1
                end
            end
        end
        if totalLearned > 0 then
            local perDay = recentCount / 30
            local perWeek = perDay * 7
            snap.velocity = {
                recentCount       = recentCount,    -- Learned in last 30 days
                recentDays        = 30,
                perDay            = perDay,
                perWeek           = perWeek,
                totalLearned      = totalLearned,
                ninetyDayForecast = (snap.collectedAll or 0) + math.floor(perDay * 90 + 0.5),
            }
            -- ETA to next title-tier threshold (if a next tier exists and pace > 0)
            local tt = snap.titleTier
            if tt and tt.next and tt.next.threshold and perDay > 0 then
                local remaining = tt.next.threshold - (snap.collectedAll or 0)
                if remaining > 0 then
                    snap.velocity.daysToNextTier = math.ceil(remaining / perDay)
                    snap.velocity.nextTierName   = tt.next.name
                    snap.velocity.nextTierThreshold = tt.next.threshold
                end
            end
        end
    end

    return snap
end

-- Expose for Aggregator.Build() (set after both modules are loaded)
privateDB.HDG_HT._BuildSnapshot = buildSnapshot

-- ============================================================================
-- Lazy init: wait for PLAYER_LOGIN so HDG.EventBus exists.
local initFrame = CreateFrame("Frame")
initFrame:RegisterEvent("PLAYER_LOGIN")
initFrame:SetScript("OnEvent", function(self)
    setupEventBus()
    setupBlizzardEvents()
    self:UnregisterAllEvents()
end)
