-- 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 = {}

local cacheBuilt = false
local cacheBuilding = false

-- Merged style definitions: HDG_StyleDefinitions + custom styles
local allStyleDefs = {}

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

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

local SOURCE_LABELS = {
    [0] = "Unknown", [1] = "Achievement", [2] = "Quest", [3] = "World Quest",
    [4] = "Drop", [5] = "Vendor", [6] = "Crafted", [7] = "Learned",
    [8] = "Gathering", [9] = "Treasure", [10] = "Promotion", [11] = "Profession",
}

-- 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 function IsItemUnreleased(itemID)
    local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
    if not entry then return true end
    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)
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

-- Count how many query values an item's facet matches (proportional scoring)
-- Returns matchCount, totalQueryVals, itemFacetCount
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

-- ============================================================================
-- 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
    if query then
        for facetName, queryVals in pairs(query) do
            queryTotal = queryTotal + 1
            local hits, total, itemCount = IntersectCount(facets[facetName], queryVals)
            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
    if boost then
        for facetName, boostVals in pairs(boost) do
            if Intersects(facets[facetName], boostVals) then
                score = score + BOOST_WEIGHT
            end
        end
    end

    -- Anti scoring (each match subtracts ANTI_WEIGHT)
    local anti = styleDef.anti
    if anti then
        for facetName, antiVals in pairs(anti) do
            if Intersects(facets[facetName], antiVals) 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
local function RebuildAllStyleDefs()
    wipe(allStyleDefs)
    if HDG_StyleDefinitions then
        for k, v in pairs(HDG_StyleDefinitions) do
            allStyleDefs[k] = v
        end
    end
    if HDG and HDG.Store then
        local customs = HDG.Store:GetState().customStyles
        if customs then
            for k, v in pairs(customs) do
                allStyleDefs[k] = v
            end
        end
    end
end

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

    -- Step 1: Load vocabulary tables
    if HDG_FacetVocab then
        for key, tbl in pairs(HDG_FacetVocab) do
            vocab[key] = tbl
        end
    end

    -- Step 2: Decode FacetDB entries into facetStore
    if HDG_FacetDB then
        for itemID, raw in pairs(HDG_FacetDB) do
            facetStore[itemID] = DecodeFacetEntry(raw)
        end
    end

    -- Step 3: Build itemToDecor / decorToItem from AllDecorDB
    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

    -- Step 3b: Build freeform facet from Blizzard dataTagsByID
    local freeformVocab = {}
    local freeformVocabIdx = 0
    local tagNameToIdx = {}

    for itemID, _ in pairs(facetStore) do
        local entryInfo = C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem
            and C_HousingCatalog.GetCatalogEntryInfoByItem(itemID, true)
        if entryInfo and entryInfo.dataTagsByID then
            local itemTags = {}
            for _, tagName in pairs(entryInfo.dataTagsByID) do
                if not tagNameToIdx[tagName] then
                    freeformVocabIdx = freeformVocabIdx + 1
                    tagNameToIdx[tagName] = freeformVocabIdx
                    freeformVocab[freeformVocabIdx] = tagName
                end
                itemTags[#itemTags + 1] = tagName
            end
            facetStore[itemID].freeform = itemTags
        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

    -- Step 4: Build room cache from facetStore
    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 5: Build reverse indexes
    BuildReverseIndex()

    -- Step 6: Merge built-in + custom style definitions
    RebuildAllStyleDefs()

    -- Step 7: Pre-compute style item assignments
    self:_BuildStyleItemCache()

    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 = {}
    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

    for styleId, def in pairs(allStyleDefs) do
        if 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 itemID, facets in pairs(facetStore) do
        local decorID = itemToDecor[itemID]
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        if decorID and entry then
            local score = ScoreItem(facets, def)

            -- Cache the score for GetCompatibility lookups
            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 item = { itemID = itemID, decorID = decorID, entry = entry }
            buckets[tier][#buckets[tier] + 1] = item
            buckets._byItemID[itemID] = tier
        end
    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
    for itemID, facets in pairs(facetStore) do
        local decorID = itemToDecor[itemID]
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        if decorID and entry then
            local parentScore = ScoreItem(facets, def)
            local threshold = def.minScore or TIER_SECOND
            if parentScore >= threshold then
                -- Cache parent score
                if not compatCache[decorID] then compatCache[decorID] = {} end
                compatCache[decorID][styleId] = parentScore

                -- Find best matching subcategory
                local item = { itemID = itemID, decorID = decorID, entry = 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
                    buckets[bestSubcat][#buckets[bestSubcat] + 1] = item
                    buckets._byItemID[itemID] = bestSubcat
                else
                    buckets._uncategorized[#buckets._uncategorized + 1] = item
                    buckets._byItemID[itemID] = "_uncategorized"
                end
            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

-- ============================================================================
-- 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.subcategories then
        -- Thematic mode: count per subcategory
        local stats = {}
        for key, bucket in pairs(items) do
            stats[key] = #bucket
        end
        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]

    -- 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
        for _, bucket in pairs(items) do
            for _, item in ipairs(bucket) do
                if not hideUnreleased or not IsItemUnreleased(item.itemID) then
                    total = total + 1
                    if HDG_Data and HDG_Data.IsItemCollected(item.itemID) then
                        collected = collected + 1
                    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.IsItemCollected(item.itemID) 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: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: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)
    return HDG_PowerCrafting and HDG_PowerCrafting.GetRecipeByItemID(itemID) or nil
end

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

function StyleEngine:GetSourceLabel(sourceType)
    return SOURCE_LABELS[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 == "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

-- ============================================================================
-- 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
