-- HDG_StyleEngine.lua (V2)
-- Query-based classification engine over HDG_FacetDB.
-- Scores items against style definitions using query/boost/anti fields.
-- All public API signatures from V1 are preserved -- UI code needs zero changes.

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

local StyleEngine = {}
privateDB.HDG_StyleEngine = StyleEngine

-- ============================================================================
-- INTERNAL STATE
-- ============================================================================

-- itemID -> decoded facet table { cat="seating", col={"red","brown"}, ... }
local facetStore = {}

-- facetName -> value -> { itemID1, itemID2, ... }  (lazy-built reverse index)
local reverseIndex = {}

-- styleId -> { first={items}, second={items}, neutral={items}, clash={items} }
-- or for thematic: { [subcatKey]={items}, _uncategorized={items} }
local styleItemCache = {}

-- decorID -> { [styleId]=score, ... }  (compatibility score cache)
local compatCache = {}

-- itemID -> { "bedroom", "study", ... }
local roomCache = {}

-- Reverse lookup: itemID -> decorID
local itemToDecor = {}
-- Reverse lookup: decorID -> itemID
local decorToItem = {}

-- itemID -> { name, decorID, iconTexture, iconAtlas, asset, sourceType, ... }
-- Unified pre-computed item cache: populated once in BuildCache Step 3b.
-- The StylesTab reads ALL per-item data from here -- no Blizzard API calls during render.
local itemCache = {}

local cacheBuilt = false
local cacheBuilding = false

-- Merged style definitions: HDG_StyleDefinitions + custom styles
local allStyleDefs = {}
-- Pre-filtered list built once per BuildCache: items that have both a
-- facetStore entry and a valid AllDecorDB entry. _BuildTierCache /
-- _BuildThematicCache iterate this instead of re-filtering facetStore
-- per style (saves ~100k redundant itemToDecor + HDG_AllDecorDB lookups).
local validItemList = {}

-- Vocab lookup: facetKey -> { [intID] = "string-value" }
local vocab = {}

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

-- Source-type display names — read from CONFIG.SOURCE_TYPE_NAMES (single source of truth)

-- Short key -> facet registry key for decoding FacetDB entries
local DECODE_MAP = {
    cat = "category", col = "color", pal = "palette", mat = "material",
    cul = "culture", mod = "mood", rm = "room", con = "condition",
    frm = "formality", mot = "motif", lit = "light", sea = "seasonal",
    wgt = "weight", sym = "symmetry", plc = "placement", vn = "noise",
    sz = "size", io = "inout", era = "era", ff = "freeform",
    sub = "subject", sr = "structural_role",
}

-- Facets used in reverse indexing (only those referenced by style queries)
local INDEXED_FACETS = {
    "category", "color", "palette", "material", "culture", "mood", "room",
    "condition", "formality", "motif", "light", "seasonal", "weight",
    "symmetry", "placement", "noise", "size", "inout", "era", "freeform",
    "subject", "structural_role",
}

-- Scoring weights
local QUERY_WEIGHT = 1.0
local BOOST_WEIGHT = 0.15
local ANTI_WEIGHT  = -0.3
local BLIZZARD_BONUS = 0.2

-- Tier thresholds (saved styles use score thresholds; live editor uses queryHits)
local TIER_FIRST   = 1.0
local TIER_SECOND  = 0.5
local TIER_NEUTRAL = 0.0

-- ============================================================================
-- HELPERS
-- ============================================================================

local _clientInterfaceVersion = select(4, (type(Build) == "table" and Build.GetBuildInfo or GetBuildInfo)()) -- e.g. 120005

local function IsItemUnreleased(itemID)
    local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
    if not entry then return true end
    -- ver is authoritative when present — skip sourceType/name heuristics
    if entry.ver then return entry.ver > _clientInterfaceVersion end
    -- Legacy: no ver field, fall back to heuristics
    if entry[2] == 0 then return true end -- sourceType 0 = Unknown
    if entry[5] and entry[5] > time() then return true end -- future release date
    if HDG_Data and HDG_Data.GetCachedItemName then
        local name = HDG_Data.GetCachedItemName(itemID)
        if name == "Unreleased" then return true end
    end
    return false
end

-- Decode a single integer or integer array from FacetDB using vocab lookup
local function DecodeValue(raw, vocabKey)
    if not raw then return nil end
    local vt = vocab[vocabKey]
    if not vt then return nil end

    if type(raw) == "table" then
        local result = {}
        for _, id in ipairs(raw) do
            local v = vt[id]
            if v then result[#result + 1] = v end
        end
        return #result > 0 and result or nil
    else
        return vt[raw]
    end
end

-- Decode an entire FacetDB entry into human-readable facets
local function DecodeFacetEntry(raw)
    local facets = {}
    for shortKey, facetName in pairs(DECODE_MAP) do
        local val = raw[shortKey]
        if val ~= nil then
            local decoded = DecodeValue(val, facetName)
            if decoded then
                facets[facetName] = decoded
            end
        end
    end
    return facets
end

-- Check if item facet value(s) intersect with a query value list
-- facetVal can be a string or array of strings; queryVals is always an array
-- Returns boolean for binary match checks (boost/anti)
-- Array-based variants (legacy; used by subcategory scoring which doesn't have
-- precomputed sets). ScoreItem prefers IntersectsSet / IntersectCountSet below.
local function Intersects(facetVal, queryVals)
    if not facetVal or not queryVals then return false end

    if type(facetVal) == "table" then
        for _, fv in ipairs(facetVal) do
            for _, qv in ipairs(queryVals) do
                if fv == qv then return true end
            end
        end
    else
        for _, qv in ipairs(queryVals) do
            if facetVal == qv then return true end
        end
    end
    return false
end

local function IntersectCount(facetVal, queryVals)
    if not facetVal or not queryVals then return 0, #(queryVals or {}), 0 end
    local hits = 0
    local total = #queryVals
    local itemCount
    if type(facetVal) == "table" then
        itemCount = #facetVal
        for _, qv in ipairs(queryVals) do
            for _, fv in ipairs(facetVal) do
                if fv == qv then hits = hits + 1; break end
            end
        end
    else
        itemCount = 1
        for _, qv in ipairs(queryVals) do
            if facetVal == qv then hits = hits + 1 end
        end
    end
    return hits, total, itemCount
end

-- Set-based variants. Called from ScoreItem with a precomputed {[val]=true,...}
-- set and a separate totalVals count (since #set doesn't work). Avoids the
-- nested-array scan in the hot path (100k+ calls during BuildCache).
local function IntersectsSet(facetVal, queryValsSet)
    if not facetVal or not queryValsSet then return false end
    if type(facetVal) == "table" then
        for _, fv in ipairs(facetVal) do
            if queryValsSet[fv] then return true end
        end
    else
        if queryValsSet[facetVal] then return true end
    end
    return false
end

local function IntersectCountSet(facetVal, queryValsSet, totalVals)
    if not facetVal or not queryValsSet then return 0, totalVals or 0, 0 end
    local hits = 0
    local itemCount
    if type(facetVal) == "table" then
        itemCount = #facetVal
        for _, fv in ipairs(facetVal) do
            if queryValsSet[fv] then hits = hits + 1 end
        end
    else
        itemCount = 1
        if queryValsSet[facetVal] then hits = 1 end
    end
    return hits, totalVals, itemCount
end

-- ============================================================================
-- SCORING ENGINE
-- ============================================================================

-- Score a single item against a style's query/boost/anti
local function ScoreItem(facets, styleDef)
    local score = 0
    local queryHits = 0
    local queryTotal = 0

    -- Query scoring: linear proportion * specificity within each facet
    -- Specificity = how focused an item is on the queried tags.
    --   Full match:  specificity = min(1, total*2 / itemCount)  (gentle penalty)
    --     e.g. 1 chip, 2 item tags: min(1, 2/2) = 1.0 → full score
    --     e.g. 1 chip, 5 item tags: min(1, 2/5) = 0.4 → reduced (generic item)
    --   Partial match: specificity = min(1, total / itemCount)  (stricter penalty)
    --     e.g. 1-of-2 chips, 2 item tags: 0.5 * min(1, 2/2) = 0.5
    --     e.g. 1-of-2 chips, 8 item tags: 0.5 * min(1, 2/8) = 0.125
    -- Mismatch (item has a value but matches none) penalizes as clash
    local query = styleDef.query
    local querySets = styleDef._querySets
    if query then
        for facetName, queryVals in pairs(query) do
            queryTotal = queryTotal + 1
            local qSet = querySets and querySets[facetName]
            local hits, total, itemCount
            if qSet then
                hits, total, itemCount = IntersectCountSet(facets[facetName], qSet, #queryVals)
            else
                hits, total, itemCount = IntersectCount(facets[facetName], queryVals)
            end
            if hits > 0 then
                if hits == total then
                    local specificity = math.min(1, total * 2 / itemCount)
                    score = score + QUERY_WEIGHT * specificity
                    if specificity >= 1 then queryHits = queryHits + 1 end
                else
                    local specificity = math.min(1, total / itemCount)
                    score = score + QUERY_WEIGHT * (hits / total) * specificity
                end
            elseif facets[facetName] then
                score = score + ANTI_WEIGHT -- item has a different value = clash
            end
        end
    end

    -- Boost scoring (each match adds BOOST_WEIGHT)
    local boost = styleDef.boost
    local boostSets = styleDef._boostSets
    if boost then
        for facetName, boostVals in pairs(boost) do
            local bSet = boostSets and boostSets[facetName]
            local hit = bSet and IntersectsSet(facets[facetName], bSet)
                     or (not bSet and Intersects(facets[facetName], boostVals))
            if hit then
                score = score + BOOST_WEIGHT
            end
        end
    end

    -- Anti scoring (each match subtracts ANTI_WEIGHT)
    local anti = styleDef.anti
    local antiSets = styleDef._antiSets
    if anti then
        for facetName, antiVals in pairs(anti) do
            local aSet = antiSets and antiSets[facetName]
            local hit = aSet and IntersectsSet(facets[facetName], aSet)
                     or (not aSet and Intersects(facets[facetName], antiVals))
            if hit then
                score = score + ANTI_WEIGHT
            end
        end
    end

    -- Normalize by query facet count so tiers stay meaningful as facets are added
    -- With 3 facets: match all 3 = 1.0 (Signature), match 2 = 0.67 (Accent), etc.
    if queryTotal > 0 then
        score = score / queryTotal
    end

    return score, queryHits, queryTotal
end

-- Score item against a subcategory query (simpler: just AND intersection)
local function ScoreSubcategory(facets, subcatQuery)
    local hits = 0
    local total = 0
    for facetName, queryVals in pairs(subcatQuery) do
        total = total + 1
        if Intersects(facets[facetName], queryVals) then
            hits = hits + 1
        end
    end
    return hits, total
end

-- ============================================================================
-- REVERSE INDEX
-- ============================================================================

local function BuildReverseIndex()
    for _, facetName in ipairs(INDEXED_FACETS) do
        reverseIndex[facetName] = {}
    end

    for itemID, facets in pairs(facetStore) do
        for _, facetName in ipairs(INDEXED_FACETS) do
            local val = facets[facetName]
            if val then
                local idx = reverseIndex[facetName]
                if type(val) == "table" then
                    for _, v in ipairs(val) do
                        if not idx[v] then idx[v] = {} end
                        idx[v][#idx[v] + 1] = itemID
                    end
                else
                    if not idx[val] then idx[val] = {} end
                    idx[val][#idx[val] + 1] = itemID
                end
            end
        end
    end
end

-- ============================================================================
-- CACHE BUILDING
-- ============================================================================

-- Rebuild the merged allStyleDefs from built-in + custom styles
-- Precompute O(1) lookup sets for a style def's query/boost/anti facet arrays.
-- ScoreItem is called ~100k times during BuildCache; doing set-lookup instead
-- of nested array scans is the single biggest perf lever for step 7.
local function PrecomputeStyleDefSets(def)
    if not def then return end
    for _, field in ipairs({"query", "boost", "anti"}) do
        local src = def[field]
        if src then
            local sets = {}
            for facetName, vals in pairs(src) do
                local set = {}
                for _, v in ipairs(vals) do set[v] = true end
                sets[facetName] = set
            end
            def["_" .. field .. "Sets"] = sets
        end
    end
end

local function RebuildAllStyleDefs()
    wipe(allStyleDefs)
    if HDG_StyleDefinitions then
        for k, v in pairs(HDG_StyleDefinitions) do
            allStyleDefs[k] = v
            PrecomputeStyleDefSets(v)
        end
    end
    if HDG_CollectionDefinitions then
        for k, v in pairs(HDG_CollectionDefinitions) do
            allStyleDefs[k] = v
            PrecomputeStyleDefSets(v)
        end
    end
    if HDG.Store and HDG.Store.Collection_ListByType then
        for collID, coll in pairs(HDG.Store:Collection_ListByType("style")) do
            local styleId = collID:sub(7) -- strip "style:" prefix
            allStyleDefs[styleId] = coll
            PrecomputeStyleDefSets(coll)
        end
    end
end

function StyleEngine:BuildCache()
    if cacheBuilt or cacheBuilding then return end
    cacheBuilding = true

    local HDG_Debug = privateDB and privateDB.HDG_Debug
    local Timed = (HDG_Debug and HDG_Debug.Timed) or function(_, fn) return fn() end

    -- Step 1: Load vocabulary tables
    Timed("StyleEngine.BuildCache.step1_vocab", function()
        if HDG_FacetVocab then
            for key, tbl in pairs(HDG_FacetVocab) do
                vocab[key] = tbl
            end
        end
    end)

    -- Step 2: Decode FacetDB entries into facetStore
    Timed("StyleEngine.BuildCache.step2_decodeFacet", function()
        if HDG_FacetDB then
            for itemID, raw in pairs(HDG_FacetDB) do
                facetStore[itemID] = DecodeFacetEntry(raw)
            end
        end
    end)

    -- Step 3: Build itemToDecor / decorToItem from AllDecorDB
    Timed("StyleEngine.BuildCache.step3_itemToDecor", function()
        if HDG_AllDecorDB then
            for itemID, entry in pairs(HDG_AllDecorDB) do
                local decorID = entry[1]
                itemToDecor[itemID] = decorID
                decorToItem[decorID] = itemID

                -- Items missing from FacetDB get empty facets (keyword fallback happens at query time)
                if not facetStore[itemID] then
                    facetStore[itemID] = {}
                end
            end
        end
    end)

    -- Step 3b: Build freeform facet from cache row tags (resolved via the
    -- central catalog cache; the raw API's dataTagsByID is normalised to a
    -- flat string array under entryInfo.tags) + unified itemCache
    Timed("StyleEngine.BuildCache.step3b_freeform+itemCache", function()
    local freeformVocab = {}
    local freeformVocabIdx = 0
    local tagNameToIdx = {}
    local ENDEAVOR_CURRENCY_ID = 3363
    local now = time()

    for itemID, _ in pairs(facetStore) do
        local entryInfo = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)
        if entryInfo then
            local itemTags = {}
            if entryInfo.tags then
                for _, tagName in pairs(entryInfo.tags) do
                    if not tagNameToIdx[tagName] then
                        freeformVocabIdx = freeformVocabIdx + 1
                        tagNameToIdx[tagName] = freeformVocabIdx
                        freeformVocab[freeformVocabIdx] = tagName
                    end
                    itemTags[#itemTags + 1] = tagName
                end
            end
            facetStore[itemID].freeform = #itemTags > 0 and itemTags or { "Other" }

            -- Build unified itemCache entry (one per item, all fields the UI needs)
            local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
            local decorID = itemToDecor[itemID]
            if decorID and entry then
                local itemName = C_HousingDecor and C_HousingDecor.GetDecorName(decorID) or (entry.name or "")
                local ic = {
                    decorID       = decorID,
                    name          = itemName,
                    iconTexture   = entryInfo.iconTexture,
                    iconAtlas     = entryInfo.iconAtlas,
                    asset         = entryInfo.asset,
                    sourceType    = entry[2],
                    sourceName    = entry[3] or "",
                    sourceDetail  = entry[4] or "",
                    releaseDate   = entry[5],
                    isDyeable     = entryInfo.canCustomize or false,
                    isUniqueTrophy = entryInfo.isUniqueTrophy or false,
                    isUnreleased  = false,
                    hasRepReq     = false,
                    costsEndeavor = false,
                    isCraftable   = HDG_PowerCrafting and HDG_PowerCrafting.GetRecipeByItemID
                                    and HDG_PowerCrafting.GetRecipeByItemID(itemID) ~= nil or false,
                    placementCost = entryInfo.placementCost or (entry.placementCost or 0),
                    isCollected   = false,
                    quantity      = entryInfo.quantity or 0,
                    showQuantity  = entryInfo.showQuantity or false,
                }
                -- isUnreleased: ver is authoritative when present; otherwise heuristics
                if entry.ver then
                    ic.isUnreleased = entry.ver > _clientInterfaceVersion
                elseif entry[2] == 0 or (entry[5] and entry[5] > now) or itemName == "Unreleased" then
                    ic.isUnreleased = true
                end
                -- Vendor flags: iterate VendorByItem once per item at build time
                local npcIDs = HDG_VendorByItem and HDG_VendorByItem[itemID]
                if npcIDs then
                    for _, npcID in ipairs(npcIDs) do
                        local vendor = HDG_VendorDB and HDG_VendorDB[npcID]
                        local ie = vendor and vendor.items and vendor.items[itemID]
                        if ie then
                            if ie[2] and ie[2] > 0 then ic.hasRepReq = true end
                            local costs = ie[5]
                            if costs then
                                for _, t in ipairs(costs) do
                                    if t[1] == "c" and t[2] == ENDEAVOR_CURRENCY_ID then ic.costsEndeavor = true end
                                end
                            end
                        end
                    end
                end
                itemCache[itemID] = ic
            end
        else
            facetStore[itemID].freeform = { "Other" }
        end
    end
    if not tagNameToIdx["Other"] then
        freeformVocabIdx = freeformVocabIdx + 1
        freeformVocab[freeformVocabIdx] = "Other"
    end
    if HDG_FacetVocab then
        HDG_FacetVocab.freeform = freeformVocab
    end
    vocab.freeform = freeformVocab
    end)  -- close step3b_freeform+itemCache Timed

    -- Step 4: Build room cache from facetStore
    Timed("StyleEngine.BuildCache.step4_roomCache", function()
        for itemID, facets in pairs(facetStore) do
            if facets.room then
                if type(facets.room) == "table" then
                    roomCache[itemID] = facets.room
                else
                    roomCache[itemID] = { facets.room }
                end
            end
        end
        -- Step 4b: Attach room references to itemCache
        for itemID, rooms in pairs(roomCache) do
            if itemCache[itemID] then itemCache[itemID].rooms = rooms end
        end
    end)

    -- Step 5: Build reverse indexes
    Timed("StyleEngine.BuildCache.step5_reverseIndex", function() BuildReverseIndex() end)

    -- Step 6: Merge built-in + custom style definitions
    Timed("StyleEngine.BuildCache.step6_styleDefs", function() RebuildAllStyleDefs() end)

    -- Step 7: Pre-compute style item assignments
    Timed("StyleEngine.BuildCache.step7_styleItemCache", function() self:_BuildStyleItemCache() end)

    cacheBuilt = true
    cacheBuilding = false

    -- Free raw FacetDB — all data now decoded into facetStore
    _G.HDG_FacetDB = nil
end

function StyleEngine:InvalidateCache()
    facetStore = {}
    reverseIndex = {}
    styleItemCache = {}
    compatCache = {}
    roomCache = {}
    itemToDecor = {}
    decorToItem = {}
    itemCache = {}
    wipe(allStyleDefs)
    cacheBuilt = false
    cacheBuilding = false
end

function StyleEngine:IsCacheReady()
    return cacheBuilt
end

-- ============================================================================
-- STYLE ITEM CACHE (pre-compute all style assignments)
-- ============================================================================

function StyleEngine:_BuildStyleItemCache()
    if not allStyleDefs or not next(allStyleDefs) then return end

    -- Pre-filter once: skip items missing from AllDecorDB or without a decorID
    wipe(validItemList)
    for itemID, facets in pairs(facetStore) do
        local decorID = itemToDecor[itemID]
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        if decorID and entry then
            validItemList[#validItemList + 1] = {
                itemID = itemID, facets = facets, decorID = decorID, entry = entry,
            }
        end
    end

    for styleId, def in pairs(allStyleDefs) do
        if def.tier == "curated" then
            self:_BuildCuratedCache(styleId, def)
        elseif def.tier == "collection" then
            self:_BuildCollectionCache(styleId, def)
        elseif def.tier == "snapshot" then
            self:_BuildSnapshotCache(styleId, def)
        elseif def.subcategories ~= nil then
            self:_BuildThematicCache(styleId, def)
        else
            self:_BuildTierCache(styleId, def)
        end
    end
end

function StyleEngine:_BuildTierCache(styleId, def)
    local buckets = {
        first = {},
        second = {},
        neutral = {},
        clash = {},
        unclassified = {},
        _byItemID = {},
    }

    for i = 1, #validItemList do
        local v = validItemList[i]
        local score = ScoreItem(v.facets, def)

        -- Cache the score for GetCompatibility lookups
        local decorID = v.decorID
        if not compatCache[decorID] then compatCache[decorID] = {} end
        compatCache[decorID][styleId] = score

        local tier
        if score >= TIER_FIRST then
            tier = "first"
        elseif score >= TIER_SECOND then
            tier = "second"
        elseif score >= TIER_NEUTRAL then
            tier = "neutral"
        else
            tier = "clash"
        end

        local itemID = v.itemID
        local bucket = buckets[tier]
        bucket[#bucket + 1] = { itemID = itemID, decorID = decorID, entry = v.entry }
        buckets._byItemID[itemID] = tier
    end

    styleItemCache[styleId] = buckets
end

function StyleEngine:_BuildThematicCache(styleId, def)
    local buckets = {}
    for _, subcat in ipairs(def.subcategories) do
        buckets[subcat.key] = {}
    end
    buckets._uncategorized = {}
    buckets._byItemID = {}

    -- First: score items against the parent query to get candidates
    local threshold = def.minScore or TIER_SECOND
    for i = 1, #validItemList do
        local v = validItemList[i]
        local facets = v.facets
        local parentScore = ScoreItem(facets, def)
        if parentScore >= threshold then
            local decorID = v.decorID
            if not compatCache[decorID] then compatCache[decorID] = {} end
            compatCache[decorID][styleId] = parentScore

            -- Find best matching subcategory
            local itemID = v.itemID
            local item = { itemID = itemID, decorID = decorID, entry = v.entry }
            local bestSubcat = nil
            local bestHits = 0

            for _, subcat in ipairs(def.subcategories) do
                if subcat.query then
                    local hits, total = ScoreSubcategory(facets, subcat.query)
                    if hits > 0 and hits > bestHits then
                        bestHits = hits
                        bestSubcat = subcat.key
                    end
                end
            end

            if bestSubcat then
                local bucket = buckets[bestSubcat]
                bucket[#bucket + 1] = item
                buckets._byItemID[itemID] = bestSubcat
            else
                buckets._uncategorized[#buckets._uncategorized + 1] = item
                buckets._byItemID[itemID] = "_uncategorized"
            end
        end
    end

    styleItemCache[styleId] = buckets
end

function StyleEngine:_BuildSnapshotCache(styleId, def)
    local items = {}
    if def.snapshot and def.snapshot.items then
        for itemID, counts in pairs(def.snapshot.items) do
            local decorID = itemToDecor[itemID]
            local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
            if decorID and entry then
                items[#items + 1] = {
                    itemID = itemID,
                    decorID = decorID,
                    entry = entry,
                    placed = counts.placed or 0,
                    stored = counts.stored or 0,
                }
            end
        end
    end
    local byItemID = {}
    for _, item in ipairs(items) do
        byItemID[item.itemID] = "items"
    end
    styleItemCache[styleId] = { items = items, _byItemID = byItemID }
end

function StyleEngine:_BuildCuratedCache(styleId, def)
    local items = {}
    if def.curatedItems then
        for itemID in pairs(def.curatedItems) do
            local decorID = itemToDecor[itemID]
            local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
            if decorID and entry then
                items[#items + 1] = { itemID = itemID, decorID = decorID, entry = entry }
                -- Register membership in compatCache so audit-mode queries
                -- (IsUnassigned / GetUserStyleMembership) can read user-style
                -- membership uniformly with smart-style scores.
                -- TIER_FIRST keeps GetCompatibility's `score >= TIER_FIRST`
                -- math correct (curated = explicit pick = first-order match).
                compatCache[decorID] = compatCache[decorID] or {}
                compatCache[decorID][styleId] = TIER_FIRST
            end
        end
    end
    local byItemID = {}
    for _, item in ipairs(items) do byItemID[item.itemID] = "curated" end
    styleItemCache[styleId] = { curated = items, _byItemID = byItemID }
end

function StyleEngine:_BuildCollectionCache(styleId, def)
    local items = {}
    if def.resolver == "dyeable" and HDG_AllDecorDB then
        -- Special resolver: all items with canCustomize from catalog
        for itemID, entry in pairs(HDG_AllDecorDB) do
            if itemCache[itemID] and itemCache[itemID].isDyeable then
                local decorID = itemToDecor[itemID]
                if decorID then
                    items[#items + 1] = { itemID = itemID, decorID = decorID, entry = entry }
                end
            end
        end
    elseif def.resolver == "trophies" and HDG_AllDecorDB then
        -- Special resolver: items flagged isUniqueTrophy by the catalog API,
        -- plus any item whose name starts with "Preyseeker" (PVE prey raid drops).
        for itemID, entry in pairs(HDG_AllDecorDB) do
            local ic = itemCache[itemID]
            local itemName = (ic and ic.name ~= "" and ic.name) or entry.name or ""
            local isTrophy = ic and ic.isUniqueTrophy
            local isPreyseeker = itemName:find("Preyseeker", 1, true) ~= nil
            if isTrophy or isPreyseeker then
                local decorID = itemToDecor[itemID]
                if decorID then
                    items[#items + 1] = { itemID = itemID, decorID = decorID, entry = entry }
                end
            end
        end
    elseif def.resolver == "recently-learned" then
        -- Special resolver: de-duped items from acquisition history (source == "Learned" only)
        local state = HDG.Store and HDG.Store:GetState()
        local history = state and state.craftingHistory or {}
        local seen = {}
        for _, entry in ipairs(history) do
            local iid = entry.itemID
            if iid and not seen[iid] and entry.source == "Learned" then
                seen[iid] = true
                local decorID = itemToDecor[iid]
                local adEntry = HDG_AllDecorDB and HDG_AllDecorDB[iid]
                if decorID and adEntry then
                    items[#items + 1] = { itemID = iid, decorID = decorID, entry = adEntry }
                end
            end
        end
    elseif def.namePatterns and HDG_AllDecorDB then
        for itemID, entry in pairs(HDG_AllDecorDB) do
            -- Use live Blizzard name from itemCache (GetDecorName), fallback to static entry.name
            local ic = itemCache[itemID]
            local itemName = (ic and ic.name ~= "" and ic.name) or entry.name or ""
            local matched = false
            for _, pattern in ipairs(def.namePatterns) do
                if itemName:find(pattern, 1, true) then
                    matched = true
                    break
                end
            end
            if matched and def.excludePatterns then
                for _, excl in ipairs(def.excludePatterns) do
                    if itemName:find(excl, 1, true) then
                        matched = false
                        break
                    end
                end
            end
            if matched then
                local decorID = itemToDecor[itemID]
                if decorID then
                    items[#items + 1] = { itemID = itemID, decorID = decorID, entry = entry }
                end
            end
        end
    end
    local byItemID = {}
    for _, item in ipairs(items) do byItemID[item.itemID] = "curated" end
    styleItemCache[styleId] = { curated = items, _byItemID = byItemID }
end

-- ============================================================================
-- PUBLIC API: Style Items & Stats
-- ============================================================================

function StyleEngine:GetStyleItems(styleId)
    if not cacheBuilt then self:BuildCache() end
    return styleItemCache[styleId]
end

function StyleEngine:GetStyleStats(styleId)
    if not cacheBuilt then self:BuildCache() end
    local items = styleItemCache[styleId]
    if not items then return {} end

    local def = allStyleDefs[styleId]
    if def and (def.tier == "curated" or def.tier == "collection") then
        return { curatedCount = items.curated and #items.curated or 0 }
    end
    if def and def.subcategories then
        -- Thematic mode: count per subcategory
        local stats = {}
        local total = 0
        for key, bucket in pairs(items) do
            if key ~= "_byItemID" then
                local n = #bucket
                stats[key] = n
                total = total + n
            end
        end
        stats.totalCount = total
        return stats
    end

    -- Tier mode
    return {
        firstCount = items.first and #items.first or 0,
        secondCount = items.second and #items.second or 0,
        neutralCount = items.neutral and #items.neutral or 0,
        clashCount = items.clash and #items.clash or 0,
        unclassifiedCount = items.unclassified and #items.unclassified or 0,
    }
end

function StyleEngine:GetStyleCollectionStats(styleId, hideUnreleased)
    if not cacheBuilt then self:BuildCache() end
    local items = styleItemCache[styleId]
    if not items then return { collected = 0, total = 0 } end

    local collected, total = 0, 0
    local def = allStyleDefs[styleId]

    -- Curated / collection mode: count collected from explicit item list
    if def and (def.tier == "curated" or def.tier == "collection") then
        if items.curated then
            for _, item in ipairs(items.curated) do
                if not hideUnreleased or not IsItemUnreleased(item.itemID) then
                    total = total + 1
                    if HDG_Data and HDG_Data.IsDecorCollected(item.decorID) then
                        collected = collected + 1
                    end
                end
            end
        end
        return { collected = collected, total = total }
    end

    -- Snapshot mode: unique items = collected, total placed count = total
    if def and def.tier == "snapshot" and def.snapshot then
        local uniqueItems, totalPlaced = 0, 0
        for itemID, counts in pairs(def.snapshot.items or {}) do
            uniqueItems = uniqueItems + 1
            totalPlaced = totalPlaced + (counts.placed or 0)
        end
        return { collected = uniqueItems, total = totalPlaced }
    end

    if def and def.subcategories then
        -- Thematic mode: count all subcategory items (skip _byItemID metadata table)
        for key, bucket in pairs(items) do
            if type(key) == "string" and key:sub(1, 1) == "_" then
                -- skip _byItemID, _uncategorized metadata
            elseif type(bucket) == "table" and bucket[1] and bucket[1].itemID then
                for _, item in ipairs(bucket) do
                    if not hideUnreleased or not IsItemUnreleased(item.itemID) then
                        total = total + 1
                        if HDG_Data and HDG_Data.IsDecorCollected(item.decorID) then
                            collected = collected + 1
                        end
                    end
                end
            end
        end
        return { collected = collected, total = total }
    end

    -- Tier mode: count first-order only
    if items.first then
        for _, item in ipairs(items.first) do
            if not hideUnreleased or not IsItemUnreleased(item.itemID) then
                total = total + 1
                if HDG_Data and HDG_Data.IsDecorCollected(item.decorID) then
                    collected = collected + 1
                end
            end
        end
    end
    return { collected = collected, total = total }
end

-- ============================================================================
-- PUBLIC API: Item Classification
-- ============================================================================

function StyleEngine:GetCompatibility(styleId, decorID)
    if not cacheBuilt then self:BuildCache() end
    local def = allStyleDefs[styleId]
    if not def then return "unclassified" end

    -- For thematic styles, return subcategory key
    if def.subcategories then
        local itemID = decorToItem[decorID]
        if not itemID then return "_uncategorized" end
        return self:GetSubcategory(styleId, itemID, decorID)
    end

    -- Tier mode: use cached score
    local scores = compatCache[decorID]
    local score = scores and scores[styleId]

    if not score then
        -- Score on demand if not cached
        local itemID = decorToItem[decorID]
        if not itemID then return "unclassified" end
        local facets = facetStore[itemID]
        if not facets then return "unclassified" end
        score = ScoreItem(facets, def)
    end

    if score >= TIER_FIRST then return "first-order" end
    if score >= TIER_SECOND then return "second-order" end
    if score >= TIER_NEUTRAL then return "neutral" end
    return "clash"
end

function StyleEngine:GetDecorTags(decorID)
    if not cacheBuilt then self:BuildCache() end
    return compatCache[decorID]
end

function StyleEngine:GetUserStyleMembership(decorID)
    if not cacheBuilt then self:BuildCache() end
    local scores = compatCache[decorID]
    if not scores then return {} end
    local out = {}
    for styleId in pairs(scores) do
        local def = HDG.Store and HDG.Store:Collection_Get("style:" .. styleId)
        if def and def.tier == "curated" then out[#out + 1] = styleId end
    end
    return out
end

function StyleEngine:IsUnassigned(decorID)
    if not cacheBuilt then self:BuildCache() end
    local scores = compatCache[decorID]
    if not scores then return true end
    for styleId in pairs(scores) do
        local def = HDG.Store and HDG.Store:Collection_Get("style:" .. styleId)
        if def and def.tier == "curated" then return false end
    end
    return true
end

-- Iterate all decor in the central catalog that are unassigned (no curated-style
-- membership). Optional `pred(decorID, catalogRow)` further narrows the result.
-- Returns an iterator function suitable for `for decorID, row in ... do`.
function StyleEngine:IterateUnassigned(pred)
    if not cacheBuilt then self:BuildCache() end
    local catalog = (HDG_Data and HDG_Data.GetAllCatalog and HDG_Data.GetAllCatalog()) or {}
    local decorIDs = {}
    for decorID in pairs(catalog) do decorIDs[#decorIDs + 1] = decorID end
    local i = 0
    return function()
        while true do
            i = i + 1
            local decorID = decorIDs[i]
            if not decorID then return nil end
            local row = catalog[decorID]
            if self:IsUnassigned(decorID) and (not pred or pred(decorID, row)) then
                return decorID, row
            end
        end
    end
end

function StyleEngine:AddItemToStyle(styleId, itemID)
    if not cacheBuilt then self:BuildCache() end
    local def = HDG.Store and HDG.Store:Collection_Get("style:" .. styleId)
    if not def or def.tier ~= "curated" then return false end
    def.curatedItems = def.curatedItems or {}
    if def.curatedItems[itemID] then return false end  -- idempotent

    -- Persistent write — mutate live record + queue save. We deliberately
    -- bypass Collection_SetField (which would fire FIELD_CHANGED and force
    -- cache rebuild) because we patch compatCache/styleItemCache below.
    def.curatedItems[itemID] = true
    if HDG.Store.QueueSave then HDG.Store:QueueSave() end

    -- Cache patch
    local decorID = itemToDecor[itemID]
    if decorID then
        compatCache[decorID] = compatCache[decorID] or {}
        compatCache[decorID][styleId] = TIER_FIRST
    end
    local items = styleItemCache[styleId]
    if items and items.curated then
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        if entry and decorID then
            items.curated[#items.curated + 1] = { itemID = itemID, decorID = decorID, entry = entry }
            items._byItemID[itemID] = "curated"
        end
    end

    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_STYLE_MEMBERSHIP_CHANGED", {
            styleId = styleId, itemID = itemID, op = "add",
        })
    end
    return true
end

function StyleEngine:RemoveItemFromStyle(styleId, itemID)
    if not cacheBuilt then self:BuildCache() end
    local def = HDG.Store and HDG.Store:Collection_Get("style:" .. styleId)
    if not def or def.tier ~= "curated" then return false end
    if not (def.curatedItems and def.curatedItems[itemID]) then return false end

    def.curatedItems[itemID] = nil
    if HDG.Store.QueueSave then HDG.Store:QueueSave() end

    local decorID = itemToDecor[itemID]
    if decorID and compatCache[decorID] then
        compatCache[decorID][styleId] = nil
    end
    local items = styleItemCache[styleId]
    if items and items.curated then
        for i = #items.curated, 1, -1 do
            if items.curated[i].itemID == itemID then
                table.remove(items.curated, i)
                break
            end
        end
        items._byItemID[itemID] = nil
    end

    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_STYLE_MEMBERSHIP_CHANGED", {
            styleId = styleId, itemID = itemID, op = "remove",
        })
    end
    return true
end

function StyleEngine:GetSubcategory(styleId, itemID, decorID)
    if not cacheBuilt then self:BuildCache() end
    local items = styleItemCache[styleId]
    if not items or not items._byItemID then return "_uncategorized" end
    return items._byItemID[itemID] or "_uncategorized"
end

function StyleEngine:BelongsToStyle(styleId, itemID, decorID)
    if not cacheBuilt then self:BuildCache() end
    local items = styleItemCache[styleId]
    if not items or not items._byItemID then return false end
    return items._byItemID[itemID] ~= nil
end

function StyleEngine:IsSubcategoryStyle(styleId)
    local def = allStyleDefs[styleId]
    return def and def.subcategories ~= nil
end

function StyleEngine:GetSubcategoryLabel(styleId, subcatKey)
    local def = allStyleDefs[styleId]
    if not def or not def.subcategories then return subcatKey end
    for _, sub in ipairs(def.subcategories) do
        if sub.key == subcatKey then return sub.label end
    end
    return subcatKey
end

-- ============================================================================
-- PUBLIC API: Item Data
-- ============================================================================

function StyleEngine:GetItemRooms(itemID)
    if not cacheBuilt then self:BuildCache() end
    return roomCache[itemID]
end

function StyleEngine:IsPreClassified(itemID)
    return facetStore[itemID] ~= nil
end

function StyleEngine:IsDyeable(itemID)
    if not cacheBuilt then self:BuildCache() end
    local ic = itemCache[itemID]
    return ic and ic.isDyeable or false
end

function StyleEngine:GetItemCache()
    if not cacheBuilt then self:BuildCache() end
    return itemCache
end

function StyleEngine:GetItemInfo(itemID)
    if not cacheBuilt then self:BuildCache() end
    return itemCache[itemID]
end

function StyleEngine:RefreshDynamicFields()
    if not cacheBuilt then return end
    for itemID, ic in pairs(itemCache) do
        ic.isCollected = HDG_Data and HDG_Data.IsItemCollected(itemID) or false
    end
end

function StyleEngine:GetTierReason(styleId, decorID)
    if not cacheBuilt then self:BuildCache() end
    local def = allStyleDefs[styleId]
    if not def then return "" end

    -- Thematic: return subcategory label
    if def.subcategories then
        local itemID = decorToItem[decorID]
        if not itemID then return "" end
        local subcat = self:GetSubcategory(styleId, itemID, decorID)
        if subcat and subcat ~= "_uncategorized" then
            return "Category: " .. self:GetSubcategoryLabel(styleId, subcat)
        end
        return ""
    end

    -- Tier mode: build a reason from matching facets
    local itemID = decorToItem[decorID]
    if not itemID then return "" end
    local facets = facetStore[itemID]
    if not facets then return "" end

    local reasons = {}

    -- Check query matches
    if def.query then
        for facetName, queryVals in pairs(def.query) do
            if Intersects(facets[facetName], queryVals) then
                local val = facets[facetName]
                if type(val) == "table" then
                    reasons[#reasons + 1] = table.concat(val, ", ")
                else
                    reasons[#reasons + 1] = tostring(val)
                end
            end
        end
    end

    -- Check boost matches (show top 2)
    local boostReasons = {}
    if def.boost then
        for facetName, boostVals in pairs(def.boost) do
            if Intersects(facets[facetName], boostVals) then
                local val = facets[facetName]
                if type(val) == "table" then
                    boostReasons[#boostReasons + 1] = table.concat(val, ", ")
                else
                    boostReasons[#boostReasons + 1] = tostring(val)
                end
            end
        end
    end

    for i = 1, math.min(#boostReasons, 2) do
        reasons[#reasons + 1] = boostReasons[i]
    end

    if #reasons > 0 then
        return "Facets: " .. table.concat(reasons, " | ")
    end
    return ""
end

-- ============================================================================
-- PUBLIC API: Recipe/Crafting Helpers
-- ============================================================================

function StyleEngine:IsCraftable(itemID)
    return HDG_PowerCrafting and HDG_PowerCrafting.GetRecipeByItemID(itemID) ~= nil
end

function StyleEngine:GetRecipe(itemID)
    -- HDG_AcqData.recipeByItem returns the canonical DecorDB recipe with
    -- spellID + dict-form reagents. MakeQueueItem normalizes the reagent
    -- shape on add. HDG_PowerCrafting.GetRecipeByItemID returns the raw
    -- ProfessionsDB row (uses `slots`, no `spellID`), which produces a
    -- queue item with nil reagents — Materials panel can't roll it up.
    if HDG_AcqData and HDG_AcqData.recipeByItem then
        return HDG_AcqData.recipeByItem[itemID]
    end
    return nil
end

-- ============================================================================
-- PUBLIC API: Utilities
-- ============================================================================

function StyleEngine:GetSourceLabel(sourceType)
    local CONFIG = privateDB and privateDB.CONFIG
    return (CONFIG and CONFIG.SOURCE_TYPE_NAMES and CONFIG.SOURCE_TYPE_NAMES[sourceType]) or "Unknown"
end

-- ============================================================================
-- V2 API (for future use)
-- ============================================================================

function StyleEngine:GetItemFacets(itemID)
    if not cacheBuilt then self:BuildCache() end
    return facetStore[itemID]
end

function StyleEngine:GetItemsByFacet(facetName, value)
    if not cacheBuilt then self:BuildCache() end
    local idx = reverseIndex[facetName]
    if not idx then return {} end
    return idx[value] or {}
end

function StyleEngine:QueryFacets(query)
    if not cacheBuilt then self:BuildCache() end
    -- Returns scored items for an arbitrary facet query
    local results = {}
    for itemID, facets in pairs(facetStore) do
        local score = 0
        local hits = 0
        local total = 0
        for facetName, queryVals in pairs(query) do
            total = total + 1
            if Intersects(facets[facetName], queryVals) then
                score = score + 1.0
                hits = hits + 1
            end
        end
        if hits > 0 then
            results[#results + 1] = { itemID = itemID, score = score, hits = hits, total = total }
        end
    end
    table.sort(results, function(a, b) return a.score > b.score end)
    return results
end

-- ============================================================================
-- CUSTOM STYLE SUPPORT
-- ============================================================================

function StyleEngine:GetAllStyleDefs()
    return allStyleDefs
end

function StyleEngine:InvalidateCustomStyle(styleId)
    -- Rebuild allStyleDefs to pick up new/changed custom styles
    RebuildAllStyleDefs()
    -- Clear this one style from cache and rebuild it
    styleItemCache[styleId] = nil
    -- Also clear compat entries for this style
    for decorID, scores in pairs(compatCache) do
        scores[styleId] = nil
    end
    local def = allStyleDefs[styleId]
    if def then
        if def.tier == "curated" then
            self:_BuildCuratedCache(styleId, def)
        elseif def.tier == "snapshot" then
            self:_BuildSnapshotCache(styleId, def)
        elseif def.subcategories then
            self:_BuildThematicCache(styleId, def)
        else
            self:_BuildTierCache(styleId, def)
        end
    end
end

function StyleEngine:RemoveCustomStyle(styleId)
    -- Remove from allStyleDefs and caches
    allStyleDefs[styleId] = nil
    styleItemCache[styleId] = nil
    for decorID, scores in pairs(compatCache) do
        scores[styleId] = nil
    end
end

function StyleEngine:ScoreStyleLive(styleDef, limit, filterFn)
    if not cacheBuilt then self:BuildCache() end
    limit = limit or 6

    local firstCount, secondCount, neutralCount, clashCount = 0, 0, 0, 0
    local sampleItems = {}

    for itemID, facets in pairs(facetStore) do
        local decorID = itemToDecor[itemID]
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        if decorID and entry then
            if not filterFn or filterFn(itemID, entry) then
                local score, queryHits = ScoreItem(facets, styleDef)
                -- Tier: Signature = all facets match perfectly; Accent = full facet
                -- match OR partial >= 50%; Clashing = net negative; Versatile = rest
                local tier
                if score >= TIER_FIRST then
                    tier = "first"
                elseif score < TIER_NEUTRAL then
                    tier = "clash"
                elseif queryHits > 0 or score >= TIER_SECOND then
                    tier = "second"
                else
                    tier = "neutral"
                end
                if tier == "first" then
                    firstCount = firstCount + 1
                elseif tier == "second" then
                    secondCount = secondCount + 1
                elseif tier == "clash" then
                    clashCount = clashCount + 1
                else
                    neutralCount = neutralCount + 1
                end
                sampleItems[#sampleItems + 1] = { itemID = itemID, decorID = decorID, score = score, tier = tier }
            end
        end
    end
    table.sort(sampleItems, function(a, b) return a.score > b.score end)

    return {
        firstCount = firstCount,
        secondCount = secondCount,
        neutralCount = neutralCount,
        clashCount = clashCount,
        sampleItems = sampleItems,
    }
end

-- ============================================================================
-- FACET FILTERING (curated mode browse)
-- ============================================================================

-- Filter items by facet values (AND across facets, OR within each facet).
-- filters = { [facetKey] = { tagVal1, tagVal2, ... }, ... }
-- searchText = optional item name substring (case-insensitive)
-- filterFn = optional per-item filter (same as ScoreStyleLive)
-- Returns flat array of { itemID, decorID, score=0 }
function StyleEngine:FilterItemsByFacets(filters, searchText, filterFn)
    if not cacheBuilt then self:BuildCache() end

    local hasFilters = filters and next(filters)
    local hasSearch = searchText and searchText ~= ""
    local searchLower = hasSearch and searchText:lower() or nil
    local results = {}

    for itemID, facets in pairs(facetStore) do
        local decorID = itemToDecor[itemID]
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        if decorID and entry then
            if not filterFn or filterFn(itemID, entry) then
                local pass = true
                -- AND across facet categories, OR within each
                if hasFilters then
                    for facetKey, queryVals in pairs(filters) do
                        if not Intersects(facets[facetKey], queryVals) then
                            pass = false
                            break
                        end
                    end
                end
                -- Text search on item name
                if pass and hasSearch then
                    local itemName = HDG_Data and HDG_Data.GetCachedItemName(itemID) or ""
                    if not itemName:lower():find(searchLower, 1, true) then
                        pass = false
                    end
                end
                if pass then
                    results[#results + 1] = { itemID = itemID, decorID = decorID, score = 0 }
                end
            end
        end
    end

    return results
end

-- ============================================================================
-- TAG AFFINITY (co-occurrence suggestions for style editor)
-- ============================================================================

-- Given the user's current query tags, compute co-occurrence of all other tags.
-- Returns { [facetKey] = { [tagValue] = percentage (0-100) } }
-- percentage = how many items matching the query also have this tag.
function StyleEngine:ComputeTagAffinity(queryTags)
    if not cacheBuilt then self:BuildCache() end
    if not queryTags or not next(queryTags) then return nil end

    -- Step 1: Find items matching ANY query tag (union)
    local matchSet = {} -- itemID -> true
    local matchCount = 0
    for facetKey, vals in pairs(queryTags) do
        if type(vals) == "table" then
            for _, v in ipairs(vals) do
                local items = reverseIndex[facetKey] and reverseIndex[facetKey][v]
                if items then
                    for _, id in ipairs(items) do
                        if not matchSet[id] then
                            matchSet[id] = true
                            matchCount = matchCount + 1
                        end
                    end
                end
            end
        end
    end

    if matchCount < 5 then return nil end -- too few items for meaningful stats

    -- Step 2: Count facet value frequency within the match set
    local affinity = {}
    for itemID in pairs(matchSet) do
        local facets = facetStore[itemID]
        if facets then
            for _, facetName in ipairs(INDEXED_FACETS) do
                local val = facets[facetName]
                if val then
                    if not affinity[facetName] then affinity[facetName] = {} end
                    local af = affinity[facetName]
                    if type(val) == "table" then
                        for _, v in ipairs(val) do
                            af[v] = (af[v] or 0) + 1
                        end
                    else
                        af[val] = (af[val] or 0) + 1
                    end
                end
            end
        end
    end

    -- Step 3: Convert counts to percentages
    for facetName, tags in pairs(affinity) do
        for tag, count in pairs(tags) do
            tags[tag] = math.floor(count / matchCount * 100 + 0.5)
        end
    end

    return affinity, matchCount
end

-- ============================================================================
-- DEBUG / DIAGNOSTIC
-- ============================================================================

function StyleEngine:DumpItem(decorID)
    if not cacheBuilt then self:BuildCache() end
    local itemID = decorToItem[decorID]
    if not itemID then
        print("|cffff6666[HDG Style]|r decorID " .. decorID .. ": not found")
        return
    end
    local facets = facetStore[itemID]
    if not facets then
        print("|cffff6666[HDG Style]|r itemID " .. itemID .. ": no facets")
        return
    end
    local name = C_HousingDecor and C_HousingDecor.GetDecorName(decorID) or "?"
    print("|cff00ff00[HDG Style]|r " .. name .. " (decorID=" .. decorID .. ", itemID=" .. itemID .. "):")
    for k, v in pairs(facets) do
        if type(v) == "table" then
            print("  " .. k .. " = {" .. table.concat(v, ", ") .. "}")
        else
            print("  " .. k .. " = " .. tostring(v))
        end
    end

    -- Show style scores
    local scores = compatCache[decorID]
    if scores then
        print("  Style scores:")
        for styleId, score in pairs(scores) do
            print("    " .. styleId .. " = " .. string.format("%.2f", score))
        end
    end
end

function StyleEngine:DumpCoverageStats()
    if not cacheBuilt then self:BuildCache() end
    local totalDB = 0
    local totalFacets = 0
    for _ in pairs(HDG_AllDecorDB) do totalDB = totalDB + 1 end
    for _ in pairs(facetStore) do totalFacets = totalFacets + 1 end
    print("|cff00ff00[HDG Style V2]|r FacetDB coverage: " .. totalFacets .. "/" .. totalDB
        .. " (" .. math.floor(totalFacets / math.max(totalDB, 1) * 100) .. "%)")

    -- Per-facet coverage
    local facetCounts = {}
    for _, facets in pairs(facetStore) do
        for k, v in pairs(facets) do
            facetCounts[k] = (facetCounts[k] or 0) + 1
        end
    end
    for k, count in pairs(facetCounts) do
        print("  " .. k .. ": " .. count .. " items")
    end
end

-- ============================================================================
-- Cache invalidation hooks — fired by HDG.Store:Collection_* mutations.
-- Internal AddItemToStyle/RemoveItemFromStyle bypass these (they patch
-- compatCache + styleItemCache directly), so only out-of-module style
-- collection writes (Editor save, Audit move, Tab delete) wind up here.
--
-- Scoped invalidation: collID maps 1:1 to a styleId (collID == "style:" .. styleId),
-- so mutations only rebuild the affected style's bucket + its compatCache slice.
-- Steps 1-6 (vocab/facetStore/itemToDecor/freeform/roomCache/reverseIndex) and
-- the other ~100 styles' buckets stay valid -- saves the ~237ms full nuke on every
-- single-style edit. If the cache hasn't been built yet (cacheBuilt == false),
-- skip work entirely; next StylesTab:Show triggers BuildCache which rereads
-- collections and naturally picks up fresh state.
-- ============================================================================
if HDG.EventBus then
    local function styleIdFromCollID(collID)
        if type(collID) ~= "string" then return nil end
        if collID:sub(1, 6) ~= "style:" then return nil end
        return collID:sub(7)
    end

    local function onStyleMutated(payload)
        if not (payload and payload.type == "style") then return end
        if not cacheBuilt then return end
        local styleId = styleIdFromCollID(payload.collID)
        if not styleId then return end
        StyleEngine:InvalidateCustomStyle(styleId)
    end

    local function onStyleDeleted(payload)
        if not (payload and payload.type == "style") then return end
        if not cacheBuilt then return end
        local styleId = styleIdFromCollID(payload.collID)
        if not styleId then return end
        StyleEngine:RemoveCustomStyle(styleId)
    end

    HDG.EventBus:Register("HDG_COLLECTION_UPSERTED", onStyleMutated)
    HDG.EventBus:Register("HDG_COLLECTION_DELETED", onStyleDeleted)
    HDG.EventBus:Register("HDG_COLLECTION_DECOR_ADDED", onStyleMutated)
    HDG.EventBus:Register("HDG_COLLECTION_DECOR_REMOVED", onStyleMutated)
    HDG.EventBus:Register("HDG_COLLECTION_FIELD_CHANGED", onStyleMutated)
end
