-- Housing Decor Guide - Decor Preview Module
-- Shows 3D preview of housing decor items with item details

local addonName, addon = ...

-- Module namespace
HDG_DecorPreview = HDG_DecorPreview or {}

-- Localization
local L = HDG_L or {}

-- Profession-name localization is centralized in
-- HDG_ExpansionData.GetLocalizedProfessionName (called inline below).

-- ============================================================================
-- THEME HELPERS (delegating to ThemeEngine canonical helpers)
-- ============================================================================

local HDG = _G.HousingDecorGuide
local function GetScheme() return HDG.Theme.GetCurrentScheme() end
local function RegisterWidget(w, t) HDG.Theme.RegisterWidget(w, t) end
local ApplyFont = function(fs, s, ft) HDG.Theme.ApplyFont(fs, s, ft) end

-- Header toggle chip: a HDG.UI:Button with a right-anchored "eye" texture and
-- an optional left-anchored category icon. Used for the Unknown/Stored
-- filters in the Preview header. Returns { btn, eye, fs } so the caller
-- can drive state via PaintToggleChip().
local function CreateHeaderToggleChip(parent, opts)
    local btn = HDG.UI:Button(parent, opts.label, 100, 20, opts.onClick)
    btn:SetPoint(unpack(opts.anchor))
    HDG_UIHelpers.SetupTooltip(btn, {
        title = opts.tooltip.title,
        desc  = opts.tooltip.desc,
        hook  = true,
    })

    local eye = btn:CreateTexture(nil, "OVERLAY")
    eye:SetSize(28, 28)
    eye:SetPoint("RIGHT", btn, "RIGHT", 2, 0)

    local leftAnchor, leftPoint, leftDX = btn, "LEFT", 4
    if opts.leftIcon then
        local icon = btn:CreateTexture(nil, "OVERLAY")
        icon:SetAtlas(opts.leftIcon)
        icon:SetSize(14, 14)
        icon:SetPoint("LEFT", btn, "LEFT", 4, 0)
        leftAnchor, leftPoint, leftDX = icon, "RIGHT", 2
    end

    local fs = btn:GetFontString()
    fs:ClearAllPoints()
    fs:SetPoint("LEFT", leftAnchor, leftPoint, leftDX, 0)
    fs:SetPoint("RIGHT", eye, "LEFT", -2, 0)
    fs:SetJustifyH("LEFT")

    return { btn = btn, eye = eye, fs = fs }
end

-- Repaint a header toggle chip from its boolean active state. Atlas swaps
-- between visible/hidden eye; text + eye tint switch between header + dim.
local function PaintToggleChip(toggle, isActive, scheme)
    if not toggle then return end
    local color = isActive and scheme.text_header or scheme.text_dim
    toggle.fs:SetTextColor(color.r, color.g, color.b)
    toggle.eye:SetAtlas(isActive and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
    toggle.eye:SetVertexColor(color.r, color.g, color.b)
end

-- Wire two HDG.UI:Checkbox widgets as a mutually-exclusive pair: turning one
-- on uncoils the other. `onCommit(a, b)` fires once per user click, with the
-- final checked state of both checkboxes.
local function WireExclusiveCheckPair(cbA, cbB, onCommit)
    cbA.onChanged = function(checked)
        if checked and cbB:GetChecked() then cbB:SetChecked(false) end
        onCommit(cbA:GetChecked(), cbB:GetChecked())
    end
    cbB.onChanged = function(checked)
        if checked and cbA:GetChecked() then cbA:SetChecked(false) end
        onCommit(cbA:GetChecked(), cbB:GetChecked())
    end
end

-- Local references (set during init)
local CONFIG
local mainFrame
local db

-- Module frame references
local previewPanel = nil
local selectedItemKey = nil  -- decorID:subtypeId for unique selection (handles dyed variants)
local displayedItems = {}   -- ordered array of itemData shown after filtering (for arrow key nav)

-- Search debounce timer (prevents excessive RefreshList calls while typing)
local searchDebounceTimer = nil

-- GetAllDecorItems cache (avoids rebuilding item list with API calls on every refresh)
local decorItemsCache = nil
local decorItemsByKey = nil  -- [itemKey] → itemData O(1) index

-- Unique key for an item (decorID:subtypeId distinguishes base vs dyed variants)
local function ItemKey(itemData)
    return itemData.decorID .. ":" .. (itemData.subtypeId or 0)
end

-- Category/subcategory name caches (only ~10-20 unique IDs, called hundreds of times)
local categoryNameCache = {}
local subcategoryNameCache = {}

local function GetCachedCategoryName(catID)
    if not catID then return nil end
    local cached = categoryNameCache[catID]
    if cached ~= nil then return cached end
    local info = C_HousingCatalog.GetCatalogCategoryInfo and C_HousingCatalog.GetCatalogCategoryInfo(catID)
    local name = (info and info.name) or false
    categoryNameCache[catID] = name
    return name
end

local function GetCachedSubcategoryName(subID)
    if not subID then return nil end
    local cached = subcategoryNameCache[subID]
    if cached ~= nil then return cached end
    local info = C_HousingCatalog.GetCatalogSubcategoryInfo and C_HousingCatalog.GetCatalogSubcategoryInfo(subID)
    local name = (info and info.name) or false
    subcategoryNameCache[subID] = name
    return name
end

-- Text width measurement helper (reusable, avoids creating/destroying frames)
local textMeasureFrame = nil
local function GetTextWidth(text, fontObject)
    if not textMeasureFrame then
        textMeasureFrame = UIParent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        textMeasureFrame:Hide()
    end
    textMeasureFrame:SetText(text)
    return textMeasureFrame:GetStringWidth()
end

-- Collection check uses centralized HDG_Data.IsDecorCollected(decorID)

-- Lazy-built lookup: decorID → added timestamp from AllDecorDB for released-item filtering
local knownDecorIDs = nil
local function GetKnownDecorIDs()
    if knownDecorIDs then return knownDecorIDs end
    knownDecorIDs = {}
    if HDG_AllDecorDB then
        for _, entry in pairs(HDG_AllDecorDB) do
            knownDecorIDs[entry[1]] = entry[5] or 0 -- decorID → added timestamp
        end
    end
    return knownDecorIDs
end

-- Destroyable counts now read from HDG_Data central catalog cache; no per-tab cache needed.
local destroyConfirmDialog = nil  -- singleton confirmation dialog
local destroyQty = 1  -- current destroy quantity selected via +/- buttons

local function ShowDestroyConfirm(itemName, decorInfo, destroyMax, destroyQty)
    if not destroyConfirmDialog then
        local f = CreateFrame("Frame", "HDG_DestroyConfirmDialog", UIParent, "BackdropTemplate")
        f:SetSize(380, 180)
        f:SetPoint("CENTER")
        f:SetFrameStrata("DIALOG")
        f:SetMovable(true)
        f:EnableMouse(true)
        f:SetClampedToScreen(true)
        f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart", f.StartMoving)
        f:SetScript("OnDragStop", f.StopMovingOrSizing)
        f:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 12, insets = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        local dlgScheme = GetScheme()
        f:SetBackdropColor(dlgScheme.bg.r, dlgScheme.bg.g, dlgScheme.bg.b, 0.95)
        f:SetBackdropBorderColor(0.6, 0.3, 0.3, 1) -- intentional danger red
        RegisterWidget(f, "Frame")

        f.text = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        f.text:SetPoint("TOP", 0, -16)
        f.text:SetWidth(340)

        f.warning = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        f.warning:SetPoint("TOP", f.text, "BOTTOM", 0, -8)
        f.warning:SetWidth(340)

        f.note = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        f.note:SetPoint("BOTTOM", 0, 8)
        f.note:SetWidth(340)
        f.note:SetText(CONFIG.TEXT_COLORS.DIM .. "If this was a mistake, Vamoose feels very sorry but there is nothing he can do.|r")

        f.destroyBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
        f.destroyBtn:SetSize(110, 26)
        f.destroyBtn:SetPoint("BOTTOMLEFT", 40, 28)
        f.destroyBtn:SetText("Destroy")

        f.cancelBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
        f.cancelBtn:SetSize(110, 26)
        f.cancelBtn:SetPoint("BOTTOMRIGHT", -40, 28)
        f.cancelBtn:SetText("Cancel")
        f.cancelBtn:SetScript("OnClick", function() f:Hide() end)

        destroyConfirmDialog = f
    end

    local f = destroyConfirmDialog
    local qty = destroyQty or 1
    local plural = qty == 1 and "copy" or "copies"
    f.text:SetText(string.format("Destroy %d %s of\n|cFF14b8a6%s|r?", qty, plural, itemName or "item"))
    local maxPlural = destroyMax == 1 and "copy" or "copies"
    f.warning:SetText("|cFFcb4b16This cannot be undone. (" .. destroyMax .. " stored " .. maxPlural .. ")|r")
    f.destroyBtn:SetScript("OnClick", function()
        if decorInfo and decorInfo.entryID then
            for _ = 1, qty do
                C_HousingCatalog.DestroyEntry(decorInfo.entryID, false)
            end
        end
        f:Hide()
    end)
    f:Show()
    f:Raise()
end

-- Cache stores {destroyable, stored, placed} per decorID
local function GetDestroyableCount(decorID)
    local row = HDG_Data and HDG_Data.GetCatalog and HDG_Data.GetCatalog(decorID)
    return row and row.destroyableInstanceCount or 0
end


-- Helper: Check if item passes all current filters
-- Pre-computed filter state (hoisted outside per-item loop to avoid 1700+ table reads)
local _filterState = {}
local function PrepareFilterState()
    local f = _filterState
    f.searchFilter = HDG_DecorPreview.searchFilter or ""
    f.categoryFilter = HDG_DecorPreview.categoryFilter
    f.categoryIDFilter = HDG_DecorPreview.categoryIDFilter
    f.subcategoryFilter = HDG_DecorPreview.subcategoryFilter
    f.subcategoryIDFilter = HDG_DecorPreview.subcategoryIDFilter
    f.professionFilter = HDG_DecorPreview.professionFilter
    f.uncollectedOnly = HDG_DecorPreview.uncollectedOnly or false
    f.destroyableOnly = HDG_DecorPreview.destroyableOnly or false
    f.favoritesOnly   = HDG_DecorPreview.favoritesOnly or false
    f.selectedCategory = HDG_DecorPreview.selectedCategory
    f.tagFilters = HDG_DecorPreview.tagFilters or {}
    f.hasTagFilters = next(f.tagFilters) ~= nil
    f.hideUnreleased = HDG_DecorPreview.hideUnreleased
    f.showOnlyUnreleased = HDG_DecorPreview.showOnlyUnreleased
    f.onlyXPEligible = HDG_DecorPreview.onlyXPEligible or false
    -- XP filter implies unreleased are excluded (an unreleased item can't grant XP yet).
    local wantsReleasedCheck = f.hideUnreleased or f.showOnlyUnreleased or f.onlyXPEligible
    f.knownDecorIDs = wantsReleasedCheck and GetKnownDecorIDs() or nil
    f.now = wantsReleasedCheck and time() or 0
    f.clientVer = wantsReleasedCheck and select(4, (type(Build) == "table" and Build.GetBuildInfo or GetBuildInfo)()) or 0
end

-- Authoritative "is this unreleased?" check. Unreleased = item's patch ver is
-- above current client ver, or the legacy name/timestamp heuristic flags it.
-- Used by both the Hide Unreleased filter (drops true → filter out) and the
-- Show Only Unreleased filter (drops false → filter out).
local function IsItemUnreleased(itemData, f)
    -- Synthesized rows (not returned by Blizzard's live catalog searcher)
    -- are always treated as unreleased. They're either PTR-scraped items
    -- awaiting release or items Blizzard has removed from the live catalog.
    if itemData.isPreviewOnly then return true end
    if itemData.itemID and HDG_AllDecorDB then
        local entry = HDG_AllDecorDB[itemData.itemID]
        if entry and entry.ver then
            return entry.ver > f.clientVer
        end
    end
    -- Legacy fallback: no itemID/entry/ver. Use name + added timestamp.
    if itemData.name == "Unreleased" then return true end
    if itemData.decorID and f.knownDecorIDs then
        local addedTS = f.knownDecorIDs[itemData.decorID]
        if not addedTS or addedTS > f.now then return true end
    end
    return false
end

local function ItemPassesFilters(itemData)
    local f = _filterState

    -- Search filter (cheapest check first — skip string ops if no search)
    if f.searchFilter ~= "" then
        local name = itemData._nameLower
        if not name then
            name = (itemData.name or ""):lower()
            itemData._nameLower = name
        end
        local found = name:find(f.searchFilter, 1, true)
        if not found and itemData.englishName then
            local eName = itemData._engLower
            if not eName then
                eName = itemData.englishName:lower()
                itemData._engLower = eName
            end
            found = eName:find(f.searchFilter, 1, true)
        end
        if not found then return false end
    end

    -- Category filter
    if f.categoryFilter and itemData.categoryName ~= f.categoryFilter then return false end

    -- Subcategory filter (cross-tab navigation from House tab "Closest to
    -- complete" cards lands here). Prefer ID match — string-name compare is
    -- fragile across locales / whitespace. CategoryID + SubcategoryID is a
    -- composite — same subcatID can live under multiple categoryIDs (e.g.
    -- "Windows" exists in both "Structural" and "Nature").
    if f.categoryIDFilter and itemData.categoryID ~= f.categoryIDFilter then return false end
    if f.subcategoryIDFilter and itemData.subcategoryID ~= f.subcategoryIDFilter then return false end
    if f.subcategoryFilter and itemData.subcategoryName ~= f.subcategoryFilter then return false end

    -- Profession filter
    if f.professionFilter then
        if not itemData.profession or itemData.profession ~= f.professionFilter then return false end
    end

    -- Uncollected filter
    if f.uncollectedOnly then
        if HDG_Data.IsDecorCollected(itemData.decorID) then return false end
    end

    -- Destroyable filter
    if f.destroyableOnly then
        if GetDestroyableCount(itemData.decorID) < 1 then return false end
    end

    -- Favorites-only filter (Favorites chip in the All sub-tag row)
    if f.favoritesOnly then
        local hdgF = _G.HousingDecorGuide and _G.HousingDecorGuide.Favorites
        if not (itemData.itemID and hdgF and hdgF.IsFavorited(itemData.itemID)) then
            return false
        end
    end

    -- Dyed category filter
    if f.selectedCategory == "Dyed" then
        if not itemData.isDyed then return false end
    end

    -- Dyeable category filter — items with the canCustomize affordance
    -- (whether or not the player owns dyed copies)
    if f.selectedCategory == "Dyeable" then
        if not itemData.canCustomize then return false end
    end

    -- Tag filters: itemData.tags is a {[tagID]=name} map; tagFilters is keyed
    -- by tagID (stable across locales). Match on any tagID overlap.
    if f.hasTagFilters then
        local matchesTags = false
        if itemData.tags then
            for tagID in pairs(itemData.tags) do
                if f.tagFilters[tagID] then matchesTags = true; break end
            end
        end
        if not matchesTags then return false end
    end

    -- Released/unreleased filtering (mutually exclusive toggles).
    -- The XP-eligible filter also force-hides unreleased items (they can't grant XP yet).
    if f.hideUnreleased or f.onlyXPEligible then
        if IsItemUnreleased(itemData, f) then return false end
    elseif f.showOnlyUnreleased then
        if not IsItemUnreleased(itemData, f) then return false end
    end

    -- House XP eligibility: firstAcquisitionBonus > 0 means player has never owned
    -- this item (bonus not yet consumed) AND the game will grant house XP on first acquire.
    if f.onlyXPEligible then
        if (itemData.firstAcquisitionBonus or 0) <= 0 then return false end
    end

    return true
end

-- Recipe→queue normalisation now lives in HDG_PowerCrafting.MakeQueueItem; the
-- shape produced from itemData is identical, so callers pass itemData directly.

-- ============================================================================
-- VENDOR LINE HELPERS (used by the Source line in UpdateInfoPane)
-- ============================================================================

-- Return the name of the first vendor known to sell `itemID`, or nil.
local function GetFirstVendorName(itemID)
    if not itemID or not HDG_VendorByItem or not HDG_VendorDB then return nil end
    local npcIDs = HDG_VendorByItem[itemID]
    if not npcIDs then return nil end
    for _, npcID in ipairs(npcIDs) do
        local ve = HDG_VendorDB[npcID]
        if ve and ve[1] then return ve[1] end
    end
    return nil
end

-- Build a concatenated icon-only cost string for a vendor item (gold coins +
-- currencies + alt-item costs). Returns "" when no cost data is available.
local function FormatVendorCostString(itemID)
    if not itemID or not HDG_VendorByItem or not HDG_VendorDB then return "" end
    local npcIDs = HDG_VendorByItem[itemID]
    if not npcIDs then return "" end
    for _, npcID in ipairs(npcIDs) do
        local ve = HDG_VendorDB[npcID]
        local ie = ve and ve.items and ve.items[itemID]
        if ie then
            local out = ""
            local gc = ie[1] or 0
            if gc > 0 then
                -- fontHeight=12 keeps coin icons in scale with the adjacent
                -- |T...:12:12|t currency/item icons on the same source line.
                out = " " .. C_CurrencyInfo.GetCoinTextureString(gc, 12)
            elseif ie[5] then
                for _, ct in ipairs(ie[5]) do
                    if ct[1] == "c" then
                        local info = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(ct[2])
                        local cIcon = info and info.iconFileID
                        local iStr = cIcon and string.format("|T%s:12:12|t", cIcon) or ""
                        out = out .. " " .. ct[3] .. iStr
                    elseif ct[1] == "i" then
                        local iIcon = C_Item.GetItemIconByID(ct[2])
                        local iStr = iIcon and string.format("|T%s:12:12|t", iIcon) or ""
                        out = out .. " " .. ct[3] .. iStr
                    end
                end
            end
            return out
        end
    end
    return ""
end

-- Build a "<icon> Label: Name [+ vendor cost if vendor]" line, or nil when the
-- source has no usable label. `ST` is CONFIG.SOURCE_TYPE; `itemID` is needed
-- for vendor hyperlink + cost lookup.
local function BuildSourceLine(sType, sName, labelOverride, itemID, ST)
    local typeLabel = labelOverride
    local iconStr = ""
    if not typeLabel and ST and sType and sType ~= 0 and sType ~= ST.UNKNOWN then
        typeLabel = HDG_AcqData.GetSourceTypeLabel(sType, CONFIG)
    end
    if not typeLabel then return nil end
    if sType and ST then
        local atlas = HDG_AcqData.GetSourceTypeAtlas(sType, CONFIG)
        if atlas then
            if atlas:find("^TEXTURE:") then
                iconStr = string.format("|T%s:12:12|t ", atlas:sub(9))
            else
                iconStr = CreateAtlasMarkup(atlas, 12, 12) .. " "
            end
        end
    end
    -- No icon for Vendor — the "Vendor:" label is enough indication and the
    -- Garr-Shop fallback was creating a perceived indent.
    local line = iconStr .. HDG.Theme.C_Accent() .. typeLabel .. ":|r"
    local isVendorSource = (ST and sType == ST.VENDOR) or (typeLabel == "Vendor")
    if sName and sName ~= "" then
        if isVendorSource and itemID then
            -- Wrap vendor name as hyperlink → jumps to Acquire > By Item.
            line = line .. " |cFF40C7EB|Hhdgvendor:" .. itemID ..
                "|h[" .. sName .. "]|h|r"
        else
            line = line .. " " .. HDG.Theme.C_RecipeKnown() .. sName .. "|r"
        end
    end
    if isVendorSource then
        line = line .. FormatVendorCostString(itemID)
    end
    return line
end

-- Get item info with caching (returns name, link, quality, icon, colorPrefix)
local itemCache = {}
local function GetCachedItemInfo(itemID)
    if itemCache[itemID] then
        return unpack(itemCache[itemID])
    end

    local name, link, quality, _, _, _, _, _, _, icon = C_Item.GetItemInfo(itemID)
    if name then
        local colorPrefix
        if quality then
            local r, g, b = HDG.Theme.GetQualityRGB(quality)
            colorPrefix = string.format("|cFF%02x%02x%02x", r*255, g*255, b*255)
        end
        itemCache[itemID] = {name, link, quality, icon, colorPrefix}
        return name, link, quality, icon, colorPrefix
    end
    -- Cold cache: request async load. The GET_ITEM_INFO_RECEIVED listener
    -- (see storageEventFrame section) will invalidate the missed entry and
    -- refresh the list when data arrives.
    if itemID then C_Item.RequestLoadItemDataByID(itemID) end
    return nil, nil, nil, nil, nil
end

-- Keyword-based fallback categorization for items without API category data
-- Cache results to avoid repeated string matching
local fallbackCategoryCache = {}

local function GetFallbackCategoryUncached(name)
    -- Check for subcategory keywords (more specific first)
    -- Furnishings subcategories
    if name:find("bookcase") or name:find("shelf") or name:find("shelves") or name:find("cabinet") or name:find("chest") then
        return 1, 6 -- Furnishings > Storage
    elseif name:find("table") or name:find("desk") then
        return 1, 5 -- Furnishings > Tables and Desks
    elseif name:find("chair") or name:find("seat") or name:find("bench") or name:find("stool") or name:find("throne") then
        return 1, 1 -- Furnishings > Seating
    elseif name:find("bed") or name:find("cot") then
        return 1, 2 -- Furnishings > Beds

    -- Lighting subcategories
    elseif name:find("brazier") or name:find("chandelier") or name:find("torch") then
        return 4, 16 -- Lighting > Large Lights
    elseif name:find("candle") or name:find("lamp") or name:find("lantern") then
        return 4, 19 -- Lighting > Small Lights
    elseif name:find("ceiling light") then
        return 4, 18 -- Lighting > Ceiling Lights
    elseif name:find("wall light") or name:find("sconce") then
        return 4, 17 -- Lighting > Wall Lights

    -- Structural subcategories
    elseif name:find("door") or name:find("gate") then
        return 2, 3 -- Structural > Doors
    elseif name:find("wall") or name:find("column") or name:find("pillar") then
        return 2, 4 -- Structural > Walls and Columns
    elseif name:find("window") then
        return 2, 8 -- Structural > Windows
    elseif name:find("floor") or name:find("tile") then
        return 2, 14 -- Structural > Floor

    -- Accents subcategories
    elseif name:find("painting") or name:find("banner") or name:find("tapestry") or name:find("portrait") then
        return 3, 12 -- Accents > Wall Hangings
    elseif name:find("ornament") or name:find("statue") or name:find("sculpture") then
        return 3, 11 -- Accents > Ornamental

    -- Nature subcategories
    elseif name:find("tree") or name:find("plant") then
        return 6, 25 -- Nature > Large Foliage
    elseif name:find("bush") or name:find("shrub") then
        return 6, 27 -- Nature > Bushes
    elseif name:find("flower") or name:find("grass") or name:find("vine") then
        return 6, 26 -- Nature > Small Foliage

    -- Functional subcategories
    elseif name:find("food") or name:find("drink") or name:find("meal") or name:find("plate") then
        return 5, 13 -- Functional > Food and Drink

    -- Broad category fallbacks (no subcategory)
    elseif name:find("furniture") then
        return 1, nil -- Furnishings
    elseif name:find("light") then
        return 4, nil -- Lighting
    elseif name:find("structure") then
        return 2, nil -- Structural
    elseif name:find("decoration") or name:find("decor") then
        return 3, nil -- Accents
    end

    -- No match found
    return nil, nil
end

local function GetFallbackCategory(itemName)
    if not itemName then return nil, nil end

    -- Check cache first
    local cached = fallbackCategoryCache[itemName]
    if cached then
        return cached[1], cached[2]
    end

    -- Compute and cache result
    local name = itemName:lower()
    local catID, subID = GetFallbackCategoryUncached(name)
    fallbackCategoryCache[itemName] = { catID, subID }

    return catID, subID
end

-- Build list of all decor items from the player's housing catalog
-- Cache for collected decor items (populated asynchronously).
-- Event-invalidated only — no TTL. HOUSING_STORAGE_UPDATED / _ENTRY_UPDATED
-- trigger a re-search that wholesale-replaces the cache. Ensures correctness
-- without thrashing on every render.
local collectedDecorCache = nil
-- Subscribe-once flag for the central catalog ready event.
local _collectionReadySubscribed = false

-- Lazy-built reverse lookup: decorID → recipe data (HDG_DecorDB is static after load)
local decorToRecipeCache = nil
local function GetDecorToRecipe()
    if decorToRecipeCache then return decorToRecipeCache end
    decorToRecipeCache = {}
    if HDG_DecorDB then
        -- HDG_DecorDB key is the legacy decor record id, NOT the recipe
        -- spellID. Always read the spellID from the entry's `spellID` field.
        -- Do NOT fall back to the key — passing a decor record id where a
        -- spellID is expected silently breaks recipe-knowledge lookups.
        for _, recipe in pairs(HDG_DecorDB) do
            if recipe.decorID then
                decorToRecipeCache[recipe.decorID] = {
                    itemID = recipe.itemID,
                    spellID = recipe.spellID,
                    profession = recipe.profession,
                    expansion = recipe.expansion,
                    reagents = recipe.reagents,
                    name = recipe.name,
                }
            end
        end
    end
    return decorToRecipeCache
end

-- Build item list by iterating the central catalog cache. Each base row becomes
-- one item; each dye variant in row.variants becomes its own item entry. The
-- central cache (HDG_Data._catalog) is populated by a single shared searcher.
local function ProcessSearchResults()
    local items = {}
    local catalog = HDG_Data and HDG_Data.GetAllCatalog and HDG_Data.GetAllCatalog()

    local decorToRecipe = GetDecorToRecipe()

    -- emit one item entry from a (recordID, info-shaped table, subtypeId) triple.
    -- For variants, info is built by merging variant counts/dyes onto the base row.
    local function emitItem(recordID, entryInfo, subtypeId, dyeNames, dyeIDs, dyeColorsByChannel)
        if not entryInfo or not entryInfo.name then return end
        local isDyed = subtypeId ~= 0
            or (dyeNames and #dyeNames > 0)
            or (dyeIDs and #dyeIDs > 0)

        -- Preserve {[tagID] = localizedName} shape — tagIDs are the stable
        -- key for categorisation (HDG_TagData.GetCategory), names are for display.
        -- The synthetic "Dyed" pseudo-tag was retired; dye status is now shown
        -- via inline droplets and the selectedCategory == "Dyed" filter handles
        -- chip selection without needing an entry in itemTags.
        local itemTags = {}
        if entryInfo.tags then
            for tagID, tagName in pairs(entryInfo.tags) do
                itemTags[tagID] = tagName
            end
        end

        local categoryID = entryInfo.categoryID
        local subcategoryID = entryInfo.subcategoryID
        local categoryName = categoryID and GetCachedCategoryName(categoryID) or nil
        local subcategoryName = subcategoryID and GetCachedSubcategoryName(subcategoryID) or nil

        local recipeData = decorToRecipe[recordID]
        local isCraftable = recipeData ~= nil
        local itemID = recipeData and recipeData.itemID or entryInfo.itemID
        local profession = recipeData and recipeData.profession or nil
        local expansion = recipeData and recipeData.expansion or "Unknown"
        local spellID = recipeData and recipeData.spellID or nil
        local reagents = recipeData and recipeData.reagents or nil

        local displayName = entryInfo.name
        if isDyed then
            displayName = displayName .. " (Dyed)"
        end

        table.insert(items, {
            itemID = itemID,
            decorID = recordID,
            subtypeId = subtypeId,
            name = displayName,
            englishName = recipeData and recipeData.name or nil,
            iconTexture = entryInfo.iconTexture,
            iconAtlas = entryInfo.iconAtlas,
            profession = profession,
            expansion = expansion,
            spellID = spellID,
            reagents = reagents,
            tags = itemTags,
            categoryName = categoryName,
            subcategoryName = subcategoryName,
            categoryID = categoryID,
            subcategoryID = subcategoryID,
            isCatalogItem = not isCraftable,
            isDyed = isDyed,
            dyeNames = (isDyed and (dyeNames or entryInfo.customizations)) or nil,
            dyeIDs = (isDyed and dyeIDs) or nil, -- flat array for inline droplet display
            dyeColorsByChannel = dyeColorsByChannel, -- 0/1/2 keyed map for SetGradientMaskWithDyes
            canCustomize = entryInfo.canCustomize,
            firstAcquisitionBonus = entryInfo.firstAcquisitionBonus or 0,
        })
    end

    if catalog then
        for decorID, row in pairs(catalog) do
            -- Base row (subtypeID = 0) — emit one item.
            emitItem(decorID, row, 0)

            -- Dye variants — emit one item per variant slot.
            if row.variants then
                for subtypeID, v in pairs(row.variants) do
                    emitItem(decorID, row, subtypeID, v.dyeNames, v.dyeIDs, v.dyeColorsByChannel)
                end
            end
        end
    end

    -- Synthesis pass: add rows for items in HDG_AllDecorDB that the central
    -- catalog does NOT contain. These are typically unreleased/pre-patch decor
    -- scraped from PTR or items Blizzard has removed from the live catalog.
    -- All synthesized rows carry isPreviewOnly=true so the unreleased filter
    -- catches them regardless of ver field.
    if HDG_AllDecorDB then
        local seenDecorIDs = {}
        for _, item in ipairs(items) do
            if item.decorID then seenDecorIDs[item.decorID] = true end
        end
        for itemID, adEntry in pairs(HDG_AllDecorDB) do
            local decorID = adEntry[1]
            if decorID and not seenDecorIDs[decorID] then
                seenDecorIDs[decorID] = true
                -- Items reach this branch when emitItem skipped them (typically
                -- because entryInfo.name was nil in the live row); the row may
                -- still exist in HDG_Data._catalog with a valid iconTexture
                -- and category IDs, so look that up first.
                local catRow = HDG_Data and HDG_Data.GetCatalogByItem
                           and HDG_Data.GetCatalogByItem(itemID)
                local name = adEntry.name
                              or (catRow and catRow.name)
                              or HDG_Data.GetLocalizedName(itemID, "Item " .. itemID)
                local iconTex = (catRow and catRow.iconTexture)
                              or (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(itemID))
                              or nil
                local catID  = catRow and catRow.categoryID
                local subID  = catRow and catRow.subcategoryID
                local catNm  = catID and GetCachedCategoryName(catID) or nil
                local subNm  = subID and GetCachedSubcategoryName(subID) or nil
                table.insert(items, {
                    itemID = itemID,
                    decorID = decorID,
                    subtypeId = 0,
                    name = name,
                    iconTexture = iconTex,
                    expansion = adEntry.exp or "Unknown",
                    tags = {},
                    isCatalogItem = true,
                    isDyed = false,
                    canCustomize = adEntry.canCustomize,
                    isPreviewOnly = true,
                    categoryID = catID,
                    subcategoryID = subID,
                    categoryName = catNm,
                    subcategoryName = subNm,
                })
            end
        end
    end

    return items
end

-- Build/refresh the items list from the central catalog. The per-tab searcher
-- + BuildDyedVariants pipeline retired: HDG_Data owns the only searcher, and
-- variants are already in row.variants by the time we read it. Repaints are
-- driven by HDG_COLLECTION_READY events from the central system.
local function TriggerAllDecorSearch()
    if not (HDG_Data and HDG_Data.IsLiveCatalogReady) then return end

    -- If the central cache is ready, build items directly. Otherwise the cache
    -- will populate asynchronously and HDG_COLLECTION_READY will trigger a refresh.
    if HDG_Data.IsLiveCatalogReady() then
        local items = ProcessSearchResults()
        collectedDecorCache = items
        decorItemsByKey = {}
        for _, item in ipairs(items) do
            if item.decorID then decorItemsByKey[ItemKey(item)] = item end
        end
        if HDG_DecorPreview.RefreshList then HDG_DecorPreview:RefreshList() end
        if HDG_DecorPreview.RefreshTagFilters then HDG_DecorPreview:RefreshTagFilters() end
    elseif HDG_Data.RefreshCatalog then
        HDG_Data.RefreshCatalog()
    end
end

-- Get all decor items from cache. Cache is event-invalidated (HOUSING_STORAGE_*
-- events wipe it via storageEventFrame) — no TTL. If cache is nil we trigger
-- an async search and return empty; the searcher callback will populate the
-- cache and trigger RefreshList.
local function GetAllDecorFromAPI()
    if collectedDecorCache then
        return collectedDecorCache
    end
    TriggerAllDecorSearch()
    return collectedDecorCache or {}
end

-- Build list of all decor items from database (craftable recipes)
local function GetAllDecorItems()
    -- If "My Collection" mode is enabled, show collected items from API
    -- Otherwise show craftable items from HDG_DecorDB
    if HDG_DecorPreview.showAllDecor then
        return GetAllDecorFromAPI()
    end

    -- Cache populated? Return it. HDG_DecorDB is static after load, so the
    -- cache is authoritative until InvalidateCache() is called (e.g. on
    -- TRADE_SKILL_LIST_UPDATE elsewhere in HDG).
    if decorItemsCache then
        return decorItemsCache
    end

    -- Default: Show craftable items from our database (HDG_DecorDB)

    local items = {}

    -- Access global HDG_DecorDB directly (the raw data)
    if not HDG_DecorDB then
        return items
    end

    -- HDG_DecorDB is keyed by a legacy decor record id; the actual recipe
    -- spellID lives in recipe.spellID. The loop var name here is historical.
    for spellID, recipe in pairs(HDG_DecorDB) do
        if recipe.itemID and recipe.decorID then
            -- Try to get category and tags from the housing catalog API
            local categoryName = nil
            local subcategoryName = nil
            local categoryID = nil
            local subcategoryID = nil
            local itemTags = {}
            local decorInfo = nil

            if HDG_Data and HDG_Data.GetCatalogByItem then
                decorInfo = HDG_Data.GetCatalogByItem(recipe.itemID)

                if decorInfo then
                    if decorInfo.categoryID then
                        categoryID = decorInfo.categoryID
                        categoryName = GetCachedCategoryName(categoryID) or nil
                    end
                    if decorInfo.subcategoryID then
                        subcategoryID = decorInfo.subcategoryID
                        subcategoryName = GetCachedSubcategoryName(subcategoryID) or nil
                    end

                    -- Extract tags (size, faction, expansion, style) preserving
                    -- {[tagID] = localizedName} shape — tagID is the stable key.
                    if decorInfo.tags then
                        for tagID, tagName in pairs(decorInfo.tags) do
                            itemTags[tagID] = tagName
                        end
                    end
                end
            end

            -- Fallback: If no category from API, try keyword-based categorization
            if not categoryID and recipe.name then
                local fallbackCatID, fallbackSubID = GetFallbackCategory(recipe.name)
                if fallbackCatID then
                    categoryID = fallbackCatID
                    categoryName = GetCachedCategoryName(categoryID) or nil
                    if fallbackSubID then
                        subcategoryID = fallbackSubID
                        subcategoryName = GetCachedSubcategoryName(subcategoryID) or nil
                    end
                end
            end
            
            table.insert(items, {
                itemID = recipe.itemID,
                decorID = recipe.decorID,
                name = recipe.name,
                profession = recipe.profession or "Unknown",
                expansion = recipe.expansion or "Unknown",
                spellID = recipe.spellID or spellID,
                reagents = recipe.reagents,
                categoryID = categoryID,
                categoryName = categoryName or "Uncategorized",
                subcategoryID = subcategoryID,
                subcategoryName = subcategoryName,
                tags = itemTags,
                decorInfo = decorInfo, -- cached from API call above
            })
        end
    end
    
    -- Sort alphabetically by name
    table.sort(items, function(a, b)
        return (a.name or "") < (b.name or "")
    end)

    -- Cache the results and build O(1) index (static after load; event-invalidated via InvalidateCache).
    decorItemsCache = items
    decorItemsByKey = {}
    for _, item in ipairs(items) do
        if item.decorID then decorItemsByKey[ItemKey(item)] = item end
    end

    return items
end

-- Invalidate the decor items cache (call when collection changes).
-- Also resets Preview's own searcher state so a stuck "in progress" flag
-- doesn't block the next TriggerAllDecorSearch from running. The searcher
-- itself is dropped (nil'd) so the next call creates a fresh one — belt
-- + suspenders for the "refresh button doesn't unstick Preview" case.
function HDG_DecorPreview:InvalidateCache()
    decorItemsCache = nil
    decorItemsByKey = nil
    collectedDecorCache = nil
end

-- Get unique tags from items with counts. Returns an array of {id, name, count}
-- records sorted by descending count. Aggregation is keyed by tagID so the
-- same tag in different locales merges into one entry.
local function GetUniqueTags(items)
    local byID = {}  -- [tagID] = { id, name, count }
    for _, item in ipairs(items) do
        if item.tags then
            for tagID, tagName in pairs(item.tags) do
                local rec = byID[tagID]
                if not rec then
                    rec = { id = tagID, name = tagName, count = 0 }
                    byID[tagID] = rec
                end
                rec.count = rec.count + 1
            end
        end
    end
    local tags = {}
    for _, rec in pairs(byID) do tags[#tags + 1] = rec end
    table.sort(tags, function(a, b) return a.count > b.count end)
    return tags
end

-- Cache for unique tags (rebuilt when items change)
local cachedTags = nil
local cachedTagsForItems = nil  -- reference to the items table the tags were built from

-- Column widths for 3-column layout
-- Right-align status columns against scrollbar, maximize name space
local COL_STATUS_SIZE = 14       -- Icon size for status columns
local COL_STATUS_PADDING = 2     -- Padding between status columns (tight)
local COL_RIGHT_MARGIN = 2       -- Right margin from scrollbar (minimal)

local function UpdateModelDisplay(itemData)
    if not previewPanel or not previewPanel.decorPreview or not itemData then return end
    previewPanel.selectedItemData = itemData
    previewPanel.decorPreview:LoadItemData(itemData)
end

-- Font string pool for info pane left column (pre-allocated, reused across selections).
-- The right column uses persistent named FontStrings (placement, destroy controls,
-- note container) so it has no pool.
local infoLeftPool = {}

-- One-time builder for the user-note editor. Creates a backdrop'd container
-- with a label, underline, and an InputScrollFrame editbox that auto-saves on
-- focus loss. Subsequent renders just call BindNoteEditor() to update the text.
local function CreateNoteEditor(infoFrame)
    local container = CreateFrame("Frame", nil, infoFrame, "BackdropTemplate")
    container:SetBackdrop({ edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
    container:SetBackdropBorderColor(0.85, 0.72, 0.35, 0.55)  -- gentle gold border, no fill

    local label = container:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label:SetPoint("TOPLEFT", 6, -4)
    label:SetText(HDG.Theme.C_Accent() .. "Your decor notes|r")
    container.label = label

    local underline = container:CreateTexture(nil, "ARTWORK")
    underline:SetColorTexture(0.85, 0.72, 0.35, 0.45)
    underline:SetHeight(1)
    underline:SetPoint("TOPLEFT", label, "BOTTOMLEFT", 0, -1)
    underline:SetPoint("RIGHT", container, "RIGHT", -6, 0)
    container.underline = underline

    local scroll = CreateFrame("ScrollFrame", nil, container, "InputScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", 4, -16)
    scroll:SetPoint("BOTTOMRIGHT", -4, 4)
    if scroll.CharCount then scroll.CharCount:Hide() end
    -- Hide the template's built-in 9-slice background textures
    for _, key in ipairs({"TopTex", "MiddleTex", "BottomTex", "LeftTex", "RightTex",
                          "TopLeftTex", "TopRightTex", "BottomLeftTex", "BottomRightTex"}) do
        if scroll[key] then scroll[key]:Hide() end
    end

    local edit = scroll.EditBox
    edit:SetFontObject("GameFontHighlightSmall")
    edit:SetTextColor(1, 1, 1, 1)
    edit:SetMaxLetters(CONFIG and CONFIG.ACQUISITION_TAB and CONFIG.ACQUISITION_TAB.MAX_NOTE_LENGTH or 200)
    edit:SetAutoFocus(false)
    edit.cursorOffset = 0
    edit.cursorHeight = 0
    edit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    edit:SetScript("OnEditFocusLost", function(self)
        local id = self._currentItemID
        if not id or not HDG or not HDG.Store then return end
        local text = strtrim(self:GetText() or "")
        if text == "" then HDG.Store:RemoveUserNote(id)
        else HDG.Store:SetUserNote(id, text) end
    end)
    -- Click anywhere in the container/scroll forwards focus to the editbox.
    container:EnableMouse(true)
    container:SetScript("OnMouseDown", function() edit:SetFocus() end)
    scroll:EnableMouse(true)
    scroll:SetScript("OnMouseDown", function() edit:SetFocus() end)
    container.editBox = edit
    container.scroll = scroll
    return container
end

-- Per-render binding for the note editor. Anchors the container, syncs
-- editbox width to scroll content, and loads the saved text for itemData.
local function BindNoteEditor(container, itemData)
    container:ClearAllPoints()
    container:SetPoint("TOPLEFT", container:GetParent(), "TOP", 8, -38)
    container:SetPoint("BOTTOMRIGHT", container:GetParent(), "BOTTOMRIGHT", -8, 8)
    container.editBox:SetWidth(container.scroll:GetWidth() - 18)
    if itemData.itemID then
        local note = HDG.Store and HDG.Store.GetUserNote and HDG.Store:GetUserNote(itemData.itemID)
        container.editBox._currentItemID = itemData.itemID
        container.editBox:SetText(note and note.text or "")
        container:Show()
    else
        container:Hide()
    end
end

-- Update the info pane with all available data
local function UpdateInfoPane(infoFrame, itemData)
    if not infoFrame or not itemData then return end

    -- Hide all pooled font strings
    for _, fs in ipairs(infoLeftPool) do fs:Hide() end

    -- Clear stale dye swatches from previous selection
    if infoFrame.dyeSwatches then
        for _, sw in ipairs(infoFrame.dyeSwatches) do sw:Hide() end
    end

    local lineHeight = 14
    local leftMargin = 8
    local midPoint = infoFrame:GetWidth() / 2 + 10

    local leftY = -8
    local leftIdx = 0

    local function AddLeftLine(text)
        leftIdx = leftIdx + 1
        local line = infoLeftPool[leftIdx]
        if not line then
            line = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            line:SetWidth(midPoint - leftMargin - 10)
            line:SetJustifyH("LEFT")
            line:SetWordWrap(false)
            line:SetShadowOffset(0, 0)
            infoLeftPool[leftIdx] = line
        end
        line:ClearAllPoints()
        line:SetPoint("TOPLEFT", leftMargin, leftY)
        line:SetText(text)
        line:Show()
        leftY = leftY - lineHeight
        return line
    end

    -- Resolve decorInfo once: use cached on itemData, else look up via central catalog.
    local decorInfo = itemData.decorInfo
    if not decorInfo and HDG_Data then
        decorInfo = (itemData.itemID and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemData.itemID))
                 or (itemData.decorID and HDG_Data.GetCatalog and HDG_Data.GetCatalog(itemData.decorID))
    end
    
    -- === DETAIL BLOCK: name + metadata + source + collected/placement + tags ===

    -- Item name (large, bold, persistent FontString — not pooled — with divider).
    local localizedDetailName = itemData.itemID and HDG_Data.GetLocalizedName(itemData.itemID, itemData.name) or itemData.name or "Unknown"
    if itemData.isDyed and not localizedDetailName:find("%(Dyed%)") then
        localizedDetailName = localizedDetailName .. " (Dyed)"
    end
    local colorPrefix = "|cFFFFFFFF"
    if itemData.itemID then
        local _, _, _, _, cp = GetCachedItemInfo(itemData.itemID)
        if cp then colorPrefix = cp end
    end
    if not infoFrame.nameTitle then
        infoFrame.nameTitle = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
        infoFrame.nameTitle:SetJustifyH("LEFT")
        infoFrame.nameTitle:SetWordWrap(false)
    end
    if not infoFrame.expansionTitle then
        infoFrame.expansionTitle = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        infoFrame.expansionTitle:SetJustifyH("RIGHT")
        infoFrame.expansionTitle:SetWordWrap(false)
    end
    if not infoFrame.nameDivider then
        infoFrame.nameDivider = infoFrame:CreateTexture(nil, "ARTWORK")
        infoFrame.nameDivider:SetColorTexture(0.4, 0.4, 0.4, 0.5)
        infoFrame.nameDivider:SetHeight(1)
    end
    infoFrame.nameTitle:ClearAllPoints()
    infoFrame.nameTitle:SetPoint("TOPLEFT", leftMargin, -6)
    -- Append the dyeable palette icon to the title for items that support customization.
    local titleText = colorPrefix .. localizedDetailName .. "|r"
    if decorInfo and decorInfo.canCustomize then
        titleText = titleText .. " " .. CreateAtlasMarkup("housing-dyable-palette-icon", 11, 11)
    end
    infoFrame.nameTitle:SetText(titleText)
    infoFrame.nameTitle:Show()
    -- Expansion as a right-aligned accent on the title row, in its expansion color.
    -- Live tags (via HDG_ExpansionData helper) are source of truth; AllDecorDB
    -- and itemData.expansion are stale fallbacks for catalog-missing items.
    do
        local tagExp = decorInfo and decorInfo.tags
            and HDG_ExpansionData.GetExpansionFromCatalogTags(decorInfo.tags) or nil
        local realExp
        if itemData.itemID and HDG_AllDecorDB and HDG_AllDecorDB[itemData.itemID] then
            realExp = HDG_AllDecorDB[itemData.itemID].exp
        end
        local expansionTitleText = tagExp
            or realExp
            or ((itemData.expansion and itemData.expansion ~= "Unknown") and itemData.expansion)
        if expansionTitleText then
            local expHex = HDG.Theme.C_Dim()
            local expColor = HDG_ExpansionData and HDG_ExpansionData.GetColor and HDG_ExpansionData.GetColor(expansionTitleText)
            if expColor then
                expHex = string.format("|cFF%02x%02x%02x", math.floor(expColor[1] * 255), math.floor(expColor[2] * 255), math.floor(expColor[3] * 255))
            end
            infoFrame.expansionTitle:ClearAllPoints()
            infoFrame.expansionTitle:SetPoint("RIGHT", infoFrame, "RIGHT", -leftMargin, 0)
            infoFrame.expansionTitle:SetPoint("TOP", infoFrame.nameTitle, "TOP", 0, -8)
            infoFrame.expansionTitle:SetText(expHex .. expansionTitleText .. "|r")
            infoFrame.expansionTitle:Show()
        else
            infoFrame.expansionTitle:Hide()
        end
    end
    infoFrame.nameDivider:ClearAllPoints()
    infoFrame.nameDivider:SetPoint("TOPLEFT", leftMargin, -32)
    infoFrame.nameDivider:SetPoint("RIGHT", infoFrame, "RIGHT", -leftMargin, 0)
    infoFrame.nameDivider:Show()
    -- Push subsequent left lines below the name + divider
    leftY = -38
    
    -- Category > Subcategory  ·  Size  ·  Expansion  🏆 Trophy
    local catText = itemData.categoryName or "Uncategorized"
    if itemData.subcategoryName then
        catText = catText .. " > " .. itemData.subcategoryName
    end
    local catLine = HDG.Theme.C_Accent() .. catText .. "|r"
    -- Pull size out of tags (expansion now lives on the title row).
    local sizeTag
    if decorInfo and decorInfo.tags then
        for tagID, tagName in pairs(decorInfo.tags) do
            if HDG_TagData.GetCategory(tagID) == "Sizes" then sizeTag = tagName; break end
        end
    end
    if sizeTag then
        catLine = catLine .. HDG.Theme.C_Dim() .. "  -  |r" .. HDG.Theme.C_Text() .. sizeTag .. "|r"
    end
    if decorInfo and decorInfo.isUniqueTrophy then
        catLine = catLine .. "  " .. HDG.Theme.C_Warning() .. "Trophy|r"
    end
    AddLeftLine(catLine)

    -- Source line(s): primary acquisition source + optional alternate
    -- (e.g. vendor + achievement-gate, crafted + reagent vendor). Vendor data
    -- lives outside AllDecorDB.alt so we synthesize a vendor row when the item
    -- has VendorDB entries but neither primary nor alt is already vendor.
    -- Tracks the FontString of whichever line is the vendor row so the cart
    -- buttons can anchor inline to its right.
    infoFrame._vendorLineFS = nil
    do
        local ST = CONFIG and CONFIG.SOURCE_TYPE
        local si = HDG_AcqData and HDG_AcqData.GetSourceInfo and HDG_AcqData.GetSourceInfo(itemData.itemID)
        local itemID = itemData.itemID

        -- Resolve primary (sType, sName, labelOverride). Synthesizes a vendor
        -- primary when the item has no source-info row but has VendorDB entries.
        local primarySType, primarySName, primaryLabel
        if si then primarySType, primarySName = si[2], si[3] end
        if (not primarySType or primarySType == 0) and GetFirstVendorName(itemID) then
            primarySType  = ST and ST.VENDOR
            primarySName  = GetFirstVendorName(itemID)
            primaryLabel  = "Vendor"
        end
        local primaryIsVendor = (ST and primarySType == ST.VENDOR) or (primaryLabel == "Vendor")

        -- Crafted items render as "Profession: <name>" instead of going through
        -- BuildSourceLine — the profession label carries the expansion tier and
        -- skill level (e.g. "Draenor Engineering (80)").
        if ST and primarySType == ST.CRAFTED and itemData.profession and itemData.profession ~= "Unknown" then
            local profValue = (primarySName and primarySName ~= "" and primarySName ~= "Crafted")
                              and primarySName or itemData.profession
            AddLeftLine(HDG.Theme.C_Accent() .. (L["PREVIEW_INFO_PROFESSION"] or "Profession:") .. "|r " ..
                HDG.Theme.C_RecipeKnown() .. profValue .. "|r")
        else
            local primaryLine = BuildSourceLine(primarySType, primarySName, primaryLabel, itemID, ST)
            if primaryLine then
                local fs = AddLeftLine(primaryLine)
                if primaryIsVendor then infoFrame._vendorLineFS = fs end
            end
        end

        -- Helper: emit a chained "alt" line indented under the primary.
        local prefix = "  " .. CreateAtlasMarkup("common-icon-rotateright", 12, 12) .. "  "
        local function EmitChainedAlt(sType, sName, label)
            local altLine = BuildSourceLine(sType, sName, label, itemID, ST)
            if altLine then
                local fs = AddLeftLine(prefix .. altLine)
                local isVendor = (ST and sType == ST.VENDOR) or (label == "Vendor")
                if isVendor then infoFrame._vendorLineFS = fs end
                return isVendor
            end
            return false
        end

        -- Real alt from AcqData (skip when it just relabels the primary).
        local alt = si and si.alt
        local altIsVendor = false
        if alt and alt[1] and alt[1] ~= 0 then
            local altSType, altSName = alt[1], alt[2]
            local sameAsPrimary = (altSName and primarySName and altSName == primarySName)
            if not sameAsPrimary then
                altIsVendor = EmitChainedAlt(altSType, altSName)
            else
                altIsVendor = (ST and altSType == ST.VENDOR)
            end
        end

        -- Synthesized vendor alt: when neither primary nor alt was vendor but
        -- VendorDB has entries (vendor data lives outside AllDecorDB.alt).
        if not primaryIsVendor and not altIsVendor then
            local vendorName = GetFirstVendorName(itemID)
            if vendorName then
                EmitChainedAlt(ST and ST.VENDOR, vendorName, "Vendor")
            end
        end
    end

    if decorInfo then
        -- Build the placement text up front so it can sit inline next to Collected.
        local indoorsText = (L["PREVIEW_INFO_INDOORS"] or "Indoors")
        local outdoorsText = (L["PREVIEW_INFO_OUTDOORS"] or "Outdoors")
        local placementText
        if decorInfo.isAllowedIndoors and decorInfo.isAllowedOutdoors then
            placementText = HDG.Theme.C_Text() .. indoorsText .. " + " .. outdoorsText .. "|r"
        elseif decorInfo.isAllowedIndoors then
            placementText = HDG.Theme.C_Text() .. indoorsText .. " only|r"
        elseif decorInfo.isAllowedOutdoors then
            placementText = HDG.Theme.C_Text() .. outdoorsText .. " only|r"
        else
            placementText = HDG.Theme.C_Dim() .. "Not placeable|r"
        end
        local cost = decorInfo.placementCost or 0
        local placementPrefix = CreateAtlasMarkup("house-decor-budget-icon", 14, 14) .. " "
        local placementFull = placementPrefix .. placementText
        if cost > 0 then
            placementFull = placementFull .. HDG.Theme.C_Dim() .. " (|r" ..
                HDG.Theme.C_Text() .. cost .. HDG.Theme.C_Dim() .. ")|r"
        end

        -- Owned status - use firstAcquisitionBonus == 0 method (same as Recipes pane)
        local isCollected = HDG_Data.IsDecorCollected(itemData.decorID)
        if isCollected then
            local ownedText = HDG.Theme.C_Success() .. "Collected|r"
            -- numStored from catalog API can report 0 even with copies in storage;
            -- destroyableInstanceCount is the reliable signal for owned-and-unplaced.
            -- Sum across base + dye variants. Each variant has its own numStored
            -- (Blizzard's tooltip shows the aggregate, not the base alone).
            local baseStored = math.max(decorInfo.numStored or 0, decorInfo.destroyableInstanceCount or 0)
            local variantStored = 0
            if decorInfo.variants then
                for _, v in pairs(decorInfo.variants) do
                    variantStored = variantStored + (v.quantity or 0)
                end
            end
            local stored = baseStored + variantStored
            local chestIcon = CreateAtlasMarkup("house-chest-icon", 14, 14)
            if stored > 0 then
                ownedText = ownedText .. " " .. HDG.Theme.C_Dim() .. "(|r" ..
                    HDG.Theme.C_Text() .. stored .. "|r " .. chestIcon ..
                    HDG.Theme.C_Dim() .. ")|r"
            end
            if decorInfo.numPlaced and decorInfo.numPlaced > 0 then
                ownedText = ownedText .. " " .. HDG.Theme.C_Dim() .. "(|r" ..
                    HDG.Theme.C_Text() .. decorInfo.numPlaced .. " placed" ..
                    HDG.Theme.C_Dim() .. ")|r"
            end
            local ownedLine = AddLeftLine(ownedText)

            -- Destroy button anchored to the Collected line. Enabled state +
            -- click → destroy dialog; disabled state communicates "not destroyable".
            if not infoFrame.collectedDestroyBtn then
                infoFrame.collectedDestroyBtn = HDG_UIHelpers.CreateAtlasButton(
                    infoFrame, "128-RedButton-Delete", 16, { disabled = true })
            end
            local cdBtn = infoFrame.collectedDestroyBtn
            cdBtn:ClearAllPoints()
            -- Anchor to LEFT + measured text width: the FontString is sized to the
            -- whole left column, so "RIGHT" would land at midPoint not next to text.
            cdBtn:SetPoint("LEFT", ownedLine, "LEFT", ownedLine:GetStringWidth() + 6, 0)
            local dCount = decorInfo.destroyableInstanceCount or 0
            if dCount > 0 then
                cdBtn:Enable()
                cdBtn:SetScript("OnClick", function()
                    ShowDestroyConfirm(localizedDetailName, decorInfo, dCount, 1)
                end)
                cdBtn:SetScript("OnEnter", function(self)
                    GameTooltip:SetOwner(self, "ANCHOR_TOP")
                    GameTooltip:AddLine("Destroy", 1, 0.3, 0.3)
                    GameTooltip:AddLine("Click to destroy a stored copy", 0.7, 0.7, 0.7)
                    GameTooltip:Show()
                end)
            else
                cdBtn:Disable()
                cdBtn:SetScript("OnClick", nil)
                cdBtn:SetScript("OnEnter", function(self)
                    GameTooltip:SetOwner(self, "ANCHOR_TOP")
                    GameTooltip:AddLine("Not destroyable", 0.7, 0.7, 0.7)
                    GameTooltip:Show()
                end)
            end
            cdBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
            cdBtn:Show()

            -- Placement sits inline to the right of the destroy button on the
            -- same row as Collected (Gemini-style consolidation).
            if not infoFrame.placementInline then
                local fs = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                fs:SetJustifyH("LEFT")
                fs:SetWordWrap(false)
                fs:SetShadowOffset(0, 0)
                infoFrame.placementInline = fs
            end
            infoFrame.placementInline:ClearAllPoints()
            infoFrame.placementInline:SetPoint("LEFT", cdBtn, "RIGHT", 8, 0)
            infoFrame.placementInline:SetText(placementFull)
            infoFrame.placementInline:Show()
        else
            local notCollectedText = HDG.Theme.C_Warning() .. "Not Collected|r"
            local fab = decorInfo.firstAcquisitionBonus or 0
            if fab > 0 then
                notCollectedText = notCollectedText .. "  " .. HDG.Theme.C_Dim() .. "(|r" ..
                    CreateAtlasMarkup("housing-dashboard-icon-xp", 14, 14) .. " " ..
                    HDG.Theme.C_Success() .. "+" .. fab .. " XP" ..
                    HDG.Theme.C_Dim() .. ")|r"
            end
            -- Placement on the same line for symmetry with the collected branch.
            notCollectedText = notCollectedText .. "   " .. placementFull
            AddLeftLine(notCollectedText)
            if infoFrame.collectedDestroyBtn then infoFrame.collectedDestroyBtn:Hide() end
            if infoFrame.placementInline then infoFrame.placementInline:Hide() end
        end

        -- Tags filtered to Styles + Factions/Cultures only — Blizzard's "Other"
        -- bucket has lexical junk (cup, cups, mug, mugs, tankard, stein) and
        -- Sizes/Expansions live elsewhere. Dyeable is the palette icon on title.
        if decorInfo.tags then
            local tags = {}
            for tagID, tagName in pairs(decorInfo.tags) do
                local cat = HDG_TagData.GetCategory(tagID)
                if cat == "Styles" or cat == "Factions" then
                    table.insert(tags, tagName)
                end
            end
            if #tags > 0 then
                AddLeftLine(HDG.Theme.C_Accent() .. (L["PREVIEW_INFO_TAGS"] or "Tags:") .. "|r " .. HDG.Theme.C_RecipeKnown() .. table.concat(tags, ", ") .. "|r")
            end
        end

        -- Dye colors (if this is a dyed variant with known dye names)
        if itemData.isDyed and itemData.dyeNames and #itemData.dyeNames > 0 then
            local dyeStr = table.concat(itemData.dyeNames, ", ")
            AddLeftLine(HDG.Theme.C_Accent() .. "Dyes:|r |cFF4dc8ff" .. dyeStr .. "|r")
        end
    end

    -- === MODEL OVERLAY: destroy controls + note editor + cart buttons ===
    -- Reset destroy controls; rebuilt below if the destroyable filter is active.
    if infoFrame.destroyBtn then infoFrame.destroyBtn:Hide() end
    if infoFrame.destroyMinus then infoFrame.destroyMinus:Hide() end
    if infoFrame.destroyPlus then infoFrame.destroyPlus:Hide() end
    if infoFrame.destroyQtyText then infoFrame.destroyQtyText:Hide() end

    if decorInfo then
        local dCount = decorInfo.destroyableInstanceCount or 0
        if dCount > 0 then
            -- Destroyable indicator moved inline to the Collected line as a small
            -- icon. Right column stays empty so the model viewport carries the
            -- destroy controls + cart corner button without competing chrome.
            if HDG_DecorPreview.destroyableOnly then
                -- Clamp destroyQty to valid range
                if destroyQty > dCount then destroyQty = dCount end
                if destroyQty < 1 then destroyQty = 1 end

                -- Helper to update +/- button states and qty text
                local function UpdateDestroyControls()
                    if infoFrame.destroyQtyText then
                        infoFrame.destroyQtyText:SetText(HDG.Theme.C_Text() .. destroyQty .. "|r")
                    end
                    if infoFrame.destroyBtn then
                        local bPlural = destroyQty == 1 and "" or " " .. destroyQty
                        infoFrame.destroyBtn:SetText("Destroy" .. bPlural)
                    end
                    if infoFrame.destroyMinus then
                        if destroyQty <= 1 then
                            infoFrame.destroyMinus:Disable()
                        else
                            infoFrame.destroyMinus:Enable()
                        end
                    end
                    if infoFrame.destroyPlus then
                        if destroyQty >= dCount then
                            infoFrame.destroyPlus:Disable()
                        else
                            infoFrame.destroyPlus:Enable()
                        end
                    end
                end

                -- Destroy controls overlay the model viewport (right edge, vertically
                -- centred) so the detail block keeps its space.
                local overlayParent = previewPanel and previewPanel.modelOverlayFrame or infoFrame

                -- Destroy button (anchored first; +/- row sits above it)
                if not infoFrame.destroyBtn then
                    infoFrame.destroyBtn = CreateFrame("Button", nil, overlayParent, "UIPanelButtonTemplate")
                    infoFrame.destroyBtn:SetSize(100, 22)
                end
                infoFrame.destroyBtn:SetParent(overlayParent)
                infoFrame.destroyBtn:ClearAllPoints()
                infoFrame.destroyBtn:SetPoint("RIGHT", overlayParent, "RIGHT", -10, 0)
                infoFrame.destroyBtn:SetScript("OnClick", function()
                    ShowDestroyConfirm(localizedDetailName, decorInfo, dCount, destroyQty)
                end)
                infoFrame.destroyBtn:Show()

                -- Minus button (above destroy button)
                if not infoFrame.destroyMinus then
                    infoFrame.destroyMinus = HDG_UIHelpers.CreateAtlasButton(
                        overlayParent, "128-RedButton-Minus", 18, { disabled = true })
                end
                infoFrame.destroyMinus:SetParent(overlayParent)
                infoFrame.destroyMinus:ClearAllPoints()
                -- Stack: minus + qty + plus row centred above the Destroy button.
                -- Row total width ≈ 18+24+18 + 2+2 padding = 64; offset minus by -32 from button centre.
                infoFrame.destroyMinus:SetPoint("BOTTOM", infoFrame.destroyBtn, "TOP", -32 + 9, 6)
                infoFrame.destroyMinus:SetScript("OnClick", function()
                    if destroyQty > 1 then destroyQty = destroyQty - 1 end
                    UpdateDestroyControls()
                end)
                infoFrame.destroyMinus:Show()

                -- Quantity text
                if not infoFrame.destroyQtyText then
                    local fs = overlayParent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                    fs:SetWidth(24)
                    fs:SetJustifyH("CENTER")
                    fs:SetShadowOffset(1, -1)
                    fs:SetShadowColor(0, 0, 0, 1)
                    infoFrame.destroyQtyText = fs
                end
                infoFrame.destroyQtyText:SetParent(overlayParent)
                infoFrame.destroyQtyText:ClearAllPoints()
                infoFrame.destroyQtyText:SetPoint("LEFT", infoFrame.destroyMinus, "RIGHT", 2, 0)
                infoFrame.destroyQtyText:Show()

                -- Plus button
                if not infoFrame.destroyPlus then
                    infoFrame.destroyPlus = HDG_UIHelpers.CreateAtlasButton(
                        overlayParent, "128-RedButton-Plus", 18, { disabled = true })
                end
                infoFrame.destroyPlus:SetParent(overlayParent)
                infoFrame.destroyPlus:ClearAllPoints()
                infoFrame.destroyPlus:SetPoint("LEFT", infoFrame.destroyQtyText, "RIGHT", 2, 0)
                infoFrame.destroyPlus:SetScript("OnClick", function()
                    if destroyQty < dCount then destroyQty = destroyQty + 1 end
                    UpdateDestroyControls()
                end)
                infoFrame.destroyPlus:Show()

                UpdateDestroyControls()
            end

            -- Vendor cost moved to the left-column Source line (inline with vendor name).
        end
    end

    -- User note editor: editable inline, autosaves on focus loss.
    if not infoFrame.noteContainer then
        infoFrame.noteContainer = CreateNoteEditor(infoFrame)
    end
    BindNoteEditor(infoFrame.noteContainer, itemData)

    -- Cart buttons — add/remove item from vendor shopping list. Inline next to
    -- the vendor source line; hidden entirely when the item has no vendor.
    if infoFrame.cartBtns then infoFrame.cartBtns:Hide() end
    if itemData.itemID and infoFrame._vendorLineFS then
        if not infoFrame.cartBtns then
            infoFrame.cartBtns = HDG_UIHelpers.CreateCartButtons(infoFrame, 16)
        end
        local npcIDs = HDG_VendorByItem and HDG_VendorByItem[itemData.itemID]
        local npcID = (npcIDs and #npcIDs > 0) and npcIDs[1] or nil
        infoFrame.cartBtns:SetItem(itemData.itemID, npcID)
        infoFrame.cartBtns:ClearAllPoints()
        infoFrame.cartBtns:SetPoint("LEFT", infoFrame._vendorLineFS, "LEFT",
            infoFrame._vendorLineFS:GetStringWidth() + 6, 0)
        infoFrame.cartBtns:Show()
    end

    -- "Find Vendor" affordance is now the clickable vendor name in the Source line
    -- (hyperlink handled at infoPanel:OnHyperlinkClick). Hide any legacy button.
    if infoFrame.findVendorBtn then infoFrame.findVendorBtn:Hide() end
end

-- ============================================================================
-- ROW BINDERS — called by view:SetElementInitializer's per-recycle pass.
-- Each binder is a single-purpose paint over (frame, elementData, scheme).
-- ============================================================================

-- Zebra striping + selection highlight. `index` is the row's position in the
-- filtered list (set by RefreshList). Stores baseColor on the frame so the
-- hover-exit handler can restore it.
local function ApplyRowBackground(frame, elementData, index, scheme)
    local stripeColor = (index % 2 == 0) and scheme.panel or scheme.bg
    local baseColor = { stripeColor.r, stripeColor.g, stripeColor.b, 0.6 }
    frame.baseColor = baseColor
    if selectedItemKey == ItemKey(elementData) then
        frame.bg:SetColorTexture(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.8)
    else
        frame.bg:SetColorTexture(baseColor[1], baseColor[2], baseColor[3], baseColor[4])
    end
end

-- Item icon with fallback chain. Prefers the central catalog cache
-- (elementData.iconTexture from HDG_Data) — already fetched once at catalog
-- load time, no API call. Falls back to GetItemInfoInstant (synchronous,
-- queries the local item DB), then to a placeholder + async load.
local function ApplyRowIcon(frame, elementData)
    local itemIcon = elementData.iconTexture
    if not itemIcon and elementData.itemID then
        itemIcon = select(5, C_Item.GetItemInfoInstant(elementData.itemID))
    end
    if itemIcon then
        frame.icon:SetTexture(itemIcon)
    else
        frame.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
        if elementData.itemID then C_Item.RequestLoadItemDataByID(elementData.itemID) end
    end
end

local function ApplyFavoriteStar(frame, elementData)
    local on = elementData.itemID and HDG.Favorites and HDG.Favorites.IsFavorited(elementData.itemID)
    if on then frame.favoriteStar:Show() else frame.favoriteStar:Hide() end
end

-- Item name with quality-color prefix; strips "(Dyed)" suffix because dye
-- status is shown via inline droplets after the name.
local function ApplyRowName(frame, elementData)
    local colorPrefix
    if elementData.itemID then
        _, _, _, _, colorPrefix = GetCachedItemInfo(elementData.itemID)
    end
    local itemName = elementData.itemID and HDG_Data.GetLocalizedName(elementData.itemID, elementData.name)
                     or elementData.name or "Unknown"
    itemName = itemName:gsub("%s*%(Dyed%)$", "")
    frame.nameText:SetText((colorPrefix or HDG.Theme.C_Text()) .. itemName .. "|r")
end

-- Inline dye droplets after the rendered name text. Magic 7px stacking spacing
-- matches the row's tight visual rhythm.
local function ApplyDyeDroplets(frame, elementData)
    for _, d in ipairs(frame.dyeDrops) do d:Hide() end
    if not (elementData.isDyed and elementData.dyeIDs and C_DyeColor and C_DyeColor.GetDyeColorInfo) then
        return
    end
    local nameLeftOffset = 26 -- matches nameText:SetPoint("LEFT", 26, 0)
    local nameWidth = frame.nameText:GetStringWidth() or 0
    local dropX = nameLeftOffset + nameWidth + 4 -- 4px gap after name
    for i, dyeColorID in ipairs(elementData.dyeIDs) do
        local d = frame.dyeDrops[i]
        if d and dyeColorID and dyeColorID > 0 then
            d:ClearAllPoints()
            d:SetPoint("LEFT", dropX, 0)
            local dyeInfo = C_DyeColor.GetDyeColorInfo(dyeColorID)
            if dyeInfo and dyeInfo.swatchColorStart then
                local c = dyeInfo.swatchColorStart
                d:SetVertexColor(c.r, c.g, c.b, 1)
                d:SetAlpha(1)
            else
                d:SetVertexColor(1, 1, 1, 1)
                d:SetAlpha(0.2)
            end
            d:Show()
            dropX = dropX + 7 -- tight stacking right
        end
    end
end

local function ApplyCollectedIcon(frame, isCollected, scheme)
    if not isCollected then frame.collectedIcon:Hide(); return end
    frame.collectedIcon:SetTexture(CONFIG.TEXTURES.CHECKMARK)
    frame.collectedIcon:SetDesaturated(false)
    frame.collectedIcon:SetVertexColor(scheme.success.r, scheme.success.g, scheme.success.b)
    frame.collectedIcon:SetAlpha(1)
    frame.collectedIcon:Show()
end

-- Destroyable qty overlay — only visible while the destroyable filter is active.
local function ApplyQtyOverlay(frame, elementData)
    local destroyCount = GetDestroyableCount(elementData.decorID)
    if HDG_DecorPreview.destroyableOnly and destroyCount > 0 then
        frame.qtyOverlay:SetText("|cFF00FF00" .. destroyCount .. "|r")
        frame.qtyOverlay:Show()
        frame.icon:SetAlpha(0.4)
    else
        frame.qtyOverlay:Hide()
        frame.icon:SetAlpha(1)
    end
end

-- Row click handler. Three branches:
--   right-click          → toggle favorite (or open crate menu with Ctrl held)
--   shift + left-click   → add to vendor shopping / wish list
--   plain left-click     → select + refresh model/info preview
local function OnRowClick(self, button)
    local data = self.itemData
    if not data then return end

    if button == "RightButton" then
        -- Ctrl+Right-click: open Add-to-crate context menu.
        if IsControlKeyDown() and data.itemID then
            local helper = addon and addon.HDG_CrateContextMenu
            if helper and helper.Show then helper.Show(self, data.itemID, data.name) end
            return
        end
        -- Plain right-click: toggle favorite.
        if data.itemID and HDG.Favorites then
            local newState = HDG.Favorites.Toggle(data.itemID)
            if self.favoriteStar then
                if newState then self.favoriteStar:Show() else self.favoriteStar:Hide() end
            end
            if HDG.Store and HDG.Store.QueueSave then HDG.Store:QueueSave() end
        end
        return
    end

    if IsShiftKeyDown() and HDG.Store then
        local itemID = data.itemID
        if not itemID then
            print(HDG.Theme.C_Warning() .. "[HDG]|r " .. (L["PREVIEW_NO_ITEM_ID"] or "Could not find item ID for this decor"))
            return
        end
        local localizedName = HDG_Data.GetLocalizedName(itemID, data.name) or data.name or "item"
        local npcIDs = HDG_VendorByItem and HDG_VendorByItem[itemID]
        local npcID = (npcIDs and #npcIDs > 0) and npcIDs[1] or nil
        HDG.Store:AddToVendorShoppingList(itemID, npcID)
        local dest = npcID and (L["PREVIEW_SHOPPING_LIST"] or "shopping list") or (L["PREVIEW_WISH_LIST"] or "wish list")
        print(string.format(HDG.Theme.C_Success() .. "[HDG]|r " .. (L["PREVIEW_ADDED_TO_LIST"] or "Added %s to %s"),
            "|cFF14b8a6" .. localizedName .. "|r", dest))
        if HDG_VendorShoppingList and HDG_VendorShoppingList.IsShown and HDG_VendorShoppingList.IsShown() then
            HDG_VendorShoppingList.Refresh()
        end
        if HDG_UI and HDG_UI.UpdateShopListBadge then HDG_UI.UpdateShopListBadge() end
        return
    end

    -- Plain left-click: select + refresh.
    selectedItemKey = ItemKey(data)
    HDG_DecorPreview:Refresh()
    HDG_DecorPreview:UpdateModelAndInfo(data)
end

-- Craftable star: same texture across the three "recipe exists" states; color
-- + alpha encode who knows it (gold = you, silver = alt, faint = nobody).
local function ApplyCraftableStar(frame, elementData, scheme)
    if not elementData.spellID then
        frame.craftableIcon:Hide()
        return
    end
    local isKnown = HDG_Data and HDG_Data.PlayerKnowsRecipeBySpellID
                    and HDG_Data.PlayerKnowsRecipeBySpellID(elementData.spellID)
    local isAltKnown = false
    if not isKnown and HDG_Data.GetCharactersWhoKnowRecipe then
        local altsWhoKnow = HDG_Data.GetCharactersWhoKnowRecipe(elementData.spellID, elementData.profession)
        isAltKnown = altsWhoKnow and #altsWhoKnow > 0
    end
    local starColor, starAlpha
    if isKnown then
        starColor, starAlpha = scheme.warning, 1
    elseif isAltKnown then
        starColor, starAlpha = (scheme.isLight and scheme.text or scheme.text_dim), 0.7
    else
        starColor, starAlpha = scheme.text, 0.25
    end
    frame.craftableIcon:SetTexture("Interface\\COMMON\\ReputationStar")
    frame.craftableIcon:SetTexCoord(0, 0.5, 0, 0.5)
    frame.craftableIcon:SetDesaturated(false)
    frame.craftableIcon:SetVertexColor(starColor.r, starColor.g, starColor.b)
    frame.craftableIcon:SetAlpha(starAlpha)
    frame.craftableIcon:Show()
end

-- Initialize the module
function HDG_DecorPreview:Init(configRef, mainFrameRef, dbRef)
    CONFIG = configRef
    mainFrame = mainFrameRef
    db = dbRef
end

-- Create the preview panel
function HDG_DecorPreview:CreatePanel()
    if previewPanel then return previewPanel end

    -- Use factory for main panel (no header/scroll - custom layout with sub-panels)
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = false,
    })

    previewPanel = layout.frame
    previewPanel:Hide()

    -- Header background (matches Shopping List style)
    local headerBg = CreateFrame("Frame", nil, previewPanel, "BackdropTemplate")
    headerBg:SetPoint("TOPLEFT", 1, -1)
    headerBg:SetPoint("TOPRIGHT", -1, -1)
    headerBg:SetHeight(22)
    headerBg:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", tile = false })
    RegisterWidget(headerBg, "HeaderBar")  -- Theme-aware: updates on theme change

    -- Search box in header (left side)
    local searchBox = CreateFrame("EditBox", nil, headerBg, "SearchBoxTemplate")
    searchBox:SetSize(CONFIG.PREVIEW_TAB.SEARCH_BOX_WIDTH, CONFIG.PREVIEW_TAB.SEARCH_BOX_HEIGHT)
    searchBox:SetPoint("LEFT", 6, 0)
    searchBox:SetAutoFocus(false)
    searchBox:SetScript("OnTextChanged", function(self)
        SearchBoxTemplate_OnTextChanged(self)
        local searchText = self:GetText():lower()
        HDG_DecorPreview.searchFilter = searchText
        -- Drop any cross-tab drill-down filter so the search isn't narrowed
        -- by a leftover Closest-to-Complete click. User-typed search wins.
        if HDG_DecorPreview._ClearXTabFilter then
            HDG_DecorPreview:_ClearXTabFilter()
        end
        -- Debounce: wait 300ms after typing stops before refreshing
        if searchDebounceTimer then searchDebounceTimer:Cancel() end
        searchDebounceTimer = C_Timer.NewTimer(0.3, function()
            HDG_DecorPreview:RefreshList()
        end)
    end)
    RegisterWidget(searchBox, "SearchBox")  -- Theme-aware: updates on theme change
    previewPanel.searchBox = searchBox

    -- Hide / Show-Only Unreleased: mutually-exclusive pair. Hide is default on;
    -- Show Only is the inverse sneak-peek view.
    HDG_DecorPreview.hideUnreleased = true
    HDG_DecorPreview.showOnlyUnreleased = false

    local releasedCheck = HDG.UI:Checkbox(headerBg, 14, L["PREVIEW_HIDE_UNRELEASED"] or "Hide Unreleased")
    releasedCheck:SetPoint("RIGHT", headerBg, "RIGHT", -8, 0)
    releasedCheck:SetChecked(true)
    previewPanel.releasedCheck = releasedCheck

    local onlyUnreleasedCheck = HDG.UI:Checkbox(headerBg, 14, L["PREVIEW_SHOW_ONLY_UNRELEASED"] or "Only Unreleased")
    onlyUnreleasedCheck:SetPoint("RIGHT", releasedCheck, "LEFT", -12, 0)
    onlyUnreleasedCheck:SetChecked(false)
    previewPanel.onlyUnreleasedCheck = onlyUnreleasedCheck

    WireExclusiveCheckPair(releasedCheck, onlyUnreleasedCheck, function(hideOn, onlyOn)
        HDG_DecorPreview.hideUnreleased = hideOn
        HDG_DecorPreview.showOnlyUnreleased = onlyOn
        knownDecorIDs = nil  -- ver-vs-build cache invalidated
        HDG_DecorPreview:RefreshList()
    end)

    local title = headerBg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("CENTER", 0, 0)
    local titleScheme = GetScheme()
    ApplyFont(title, titleScheme, "header")
    title:SetText(L["PREVIEW_TAB_TITLE"] or "Decor Preview")
    title:SetTextColor(titleScheme.text_header.r, titleScheme.text_header.g, titleScheme.text_header.b, titleScheme.text_header.a)
    title.isHeader = true
    HDG.Theme.StyleHeaderTitle(title, headerBg)
    previewPanel.title = title

    -- Filter bar background (themed for light/dark modes, registered for dynamic updates)
    local filterBarBg = CreateFrame("Frame", nil, previewPanel, "BackdropTemplate")
    filterBarBg:SetPoint("TOPLEFT", 1, -24)
    filterBarBg:SetPoint("TOPRIGHT", -1, -24)
    filterBarBg:SetHeight(40)  -- Covers both filter rows
    filterBarBg:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", tile = false })
    RegisterWidget(filterBarBg, "FilterBarBg")  -- Theme-aware: updates on theme change
    filterBarBg:SetFrameLevel(previewPanel:GetFrameLevel() + 1)
    previewPanel.filterBarBg = filterBarBg

    -- Row 1: Category buttons + All Decor button
    local categoryRow = CreateFrame("Frame", nil, previewPanel)
    categoryRow:SetPoint("TOPLEFT", 12, -28)
    categoryRow:SetPoint("RIGHT", previewPanel, "RIGHT", -12, 0)
    categoryRow:SetHeight(20)
    categoryRow:SetFrameLevel(filterBarBg:GetFrameLevel() + 1)
    previewPanel.categoryRow = categoryRow

    -- "Filter:" label before category buttons
    local filterLabel = categoryRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    filterLabel:SetPoint("LEFT", 0, 0)
    filterLabel:SetShadowOffset(0, 0)
    ApplyFont(filterLabel, titleScheme, "small")
    filterLabel:SetText(HDG.Theme.C_Label() .. (L["PREVIEW_FILTER"] or "Filter:") .. "|r")
    RegisterWidget(filterLabel, "Text")
    previewPanel.filterLabel = filterLabel

    -- Collection / Uncollected toggle buttons (red Blizzard style)
    -- Default view = All Decor (showAllDecor=true). The "All" sub-tag row is a
    -- radio: All Decor / Crafted / House XP. Unknown and Stored stay on the header.
    if HDG_DecorPreview.showAllDecor == nil then HDG_DecorPreview.showAllDecor = true end
    if HDG_DecorPreview.uncollectedOnly == nil then HDG_DecorPreview.uncollectedOnly = false end
    if HDG_DecorPreview.destroyableOnly == nil then HDG_DecorPreview.destroyableOnly = false end

    -- "All Decor" / "Crafted" / "House XP" are sub-tags under the "All" category
    -- (exclusive radio). "Unknown" and "Stored" stay on the header row as
    -- independent filters so they can be combined with category/profession filters
    -- (e.g. "Unknown" + "Crafted" > "Alchemy" shows uncollected alchemy recipes).

    previewPanel.uncollectedToggle = CreateHeaderToggleChip(categoryRow, {
        label = L["STATUS_UNCOLLECTED"] or "Unknown",
        anchor = { "RIGHT", categoryRow, "RIGHT", -104, 1 },
        tooltip = {
            title = L["TIP_UNCOLLECTED_ONLY"] or "Uncollected Only",
            desc  = L["TIP_HIDE_COLLECTED"] or "Hide items you've already collected",
        },
        onClick = function()
            HDG_DecorPreview.uncollectedOnly = not HDG_DecorPreview.uncollectedOnly
            if HDG_DecorPreview.uncollectedOnly then HDG_DecorPreview.destroyableOnly = false end
            HDG_DecorPreview._UpdateHeaderToggles()
            HDG_DecorPreview:RefreshList()
        end,
    })

    previewPanel.destroyableToggle = CreateHeaderToggleChip(categoryRow, {
        label    = "Stored",
        leftIcon = "transmog-icon-remove",
        anchor   = { "RIGHT", categoryRow, "RIGHT", 0, 1 },
        tooltip  = {
            title = L["TIP_STORED"],
            desc  = L["TIP_STORED_FILTER_DESC"],
        },
        onClick = function()
            HDG_DecorPreview.destroyableOnly = not HDG_DecorPreview.destroyableOnly
            if HDG_DecorPreview.destroyableOnly then HDG_DecorPreview.uncollectedOnly = false end
            HDG_DecorPreview._UpdateHeaderToggles()
            HDG_DecorPreview:RefreshList()
            if selectedItemKey and previewPanel.infoPanel and decorItemsByKey then
                local id = decorItemsByKey[selectedItemKey]
                if id then UpdateInfoPane(previewPanel.infoPanel, id) end
            end
        end,
    })

    -- Hoisted onto HDG_DecorPreview so callers in other scopes can request a
    -- repaint when the toggle flags change.
    HDG_DecorPreview._UpdateHeaderToggles = function()
        local scheme = GetScheme()
        PaintToggleChip(previewPanel.uncollectedToggle, HDG_DecorPreview.uncollectedOnly, scheme)
        PaintToggleChip(previewPanel.destroyableToggle, HDG_DecorPreview.destroyableOnly, scheme)
    end

    local destroyableStats = categoryRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    destroyableStats:SetPoint("TOPRIGHT", categoryRow, "BOTTOMRIGHT", 0, -2)
    destroyableStats:SetShadowOffset(0, 0)
    destroyableStats:SetJustifyH("RIGHT")
    destroyableStats:Hide()
    previewPanel.destroyableStats = destroyableStats

    HDG_DecorPreview._UpdateHeaderToggles()

    -- Row 2: Tag buttons for selected category
    local tagRow = CreateFrame("Frame", nil, previewPanel)
    tagRow:SetPoint("TOPLEFT", categoryRow, "BOTTOMLEFT", 0, -2)
    tagRow:SetPoint("RIGHT", previewPanel, "RIGHT", -12, 0)
    tagRow:SetHeight(18)
    previewPanel.tagRow = tagRow

    -- "Tags:" label before tag buttons
    local tagsLabel = tagRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    tagsLabel:SetPoint("LEFT", 0, 0)
    tagsLabel:SetShadowOffset(0, 0)
    ApplyFont(tagsLabel, titleScheme, "small")
    tagsLabel:SetText(HDG.Theme.C_Label() .. (L["PREVIEW_INFO_TAGS"] or "Tags:") .. "|r")
    previewPanel.tagsLabel = tagsLabel

    -- Containers for buttons
    previewPanel.categoryButtons = {}
    previewPanel.tagButtons = {}

    -- Initialize tag filter state
    HDG_DecorPreview.tagFilters = HDG_DecorPreview.tagFilters or {}
    HDG_DecorPreview.selectedCategory = HDG_DecorPreview.selectedCategory or "All"

    -- Left panel: Item list (starts below filter rows)
    local listPanel = CreateFrame("Frame", nil, previewPanel, "BackdropTemplate")
    listPanel:SetPoint("TOPLEFT", 8, -70)
    listPanel:SetPoint("BOTTOMLEFT", 8, 8)
    listPanel:SetWidth(CONFIG.PREVIEW_TAB.LIST_PANEL_WIDTH)
    listPanel:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        tile = false
    })
    local listScheme = GetScheme()
    listPanel:SetBackdropColor(listScheme.bg.r, listScheme.bg.g, listScheme.bg.b, listScheme.bg.a)
    RegisterWidget(listPanel, "Background")

    -- Column headers row (3 columns: Name, Collected, Craftable)
    local headerRow = CreateFrame("Frame", nil, listPanel, "BackdropTemplate")
    headerRow:SetPoint("TOPLEFT", 1, -1)
    headerRow:SetPoint("TOPRIGHT", -1, -1)
    headerRow:SetHeight(18)
    headerRow:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", tile = false })
    RegisterWidget(headerRow, "PanelHeader")  -- Theme-aware: uses panel_header color

    -- Header: Title with count - matches "3D Preview" styling
    local headerTitle = headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    headerTitle:SetPoint("LEFT", 6, 0)
    headerTitle:SetShadowOffset(0, 0)
    ApplyFont(headerTitle, listScheme, "small")
    headerTitle:SetText(HDG.Theme.C_Text() .. (L["PREVIEW_ITEMS"] or "Items") .. "|r")
    previewPanel.headerTitle = headerTitle

    -- Header icons align with list content (account for scrollbar width + scroll padding)
    local HEADER_SCROLLBAR_OFFSET = 23  -- scrollBox has -20 right offset plus MinimalScrollBar width + 1px header inset

    -- Header: Craftable icon (rightmost) - with tooltip
    local headerCraftableFrame = CreateFrame("Frame", nil, headerRow)
    headerCraftableFrame:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
    headerCraftableFrame:SetPoint("RIGHT", -(COL_RIGHT_MARGIN + HEADER_SCROLLBAR_OFFSET), 0)
    headerCraftableFrame:EnableMouse(true)
    local headerCraftableIcon = headerCraftableFrame:CreateTexture(nil, "ARTWORK")
    headerCraftableIcon:SetAllPoints()
    headerCraftableIcon:SetTexture("Interface\\Icons\\INV_Misc_Book_09")
    previewPanel.headerCraftableIcon = headerCraftableIcon  -- Store for theme refresh
    HDG_UIHelpers.SetupTooltip(headerCraftableFrame, {
        title = L["TIP_CRAFTABLE"],
        desc  = { { text = L["TIP_CRAFTABLE_DESC"], color = {0.7, 0.7, 0.7} }, HDG.Theme.C_Accent() .. L["TIP_STAR_KNOWN_YOU"] .. "|r", HDG.Theme.C_Dim() .. L["TIP_STAR_KNOWN_ALT"] .. "|r", HDG.Theme.C_Dim() .. L["TIP_CHECK_NOT_KNOWN"] .. "|r", HDG.Theme.C_Dim() .. (L["TIP_X_NOT_CRAFTABLE"] or "Empty = Not a recipe (vendor / drop / event)") .. "|r" },
        anchor = "ANCHOR_TOP",
    })

    -- Header: Collected icon - to the left of craftable, with tooltip
    local headerCollectedFrame = CreateFrame("Frame", nil, headerRow)
    headerCollectedFrame:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
    headerCollectedFrame:SetPoint("RIGHT", headerCraftableFrame, "LEFT", -COL_STATUS_PADDING, 0)
    headerCollectedFrame:EnableMouse(true)
    local headerCollectedIcon = headerCollectedFrame:CreateTexture(nil, "ARTWORK")
    headerCollectedIcon:SetAllPoints()
    headerCollectedIcon:SetTexture("Interface\\Icons\\INV_Crate_04")
    previewPanel.headerCollectedIcon = headerCollectedIcon  -- Store for theme refresh
    HDG_UIHelpers.SetupTooltip(headerCollectedFrame, {
        title = L["STATUS_COLLECTED"],
        desc  = { { text = L["TIP_COLLECTED_DESC"], color = {0.7, 0.7, 0.7} }, HDG.Theme.C_Success() .. L["TIP_GREEN_COLLECTED"] .. "|r", HDG.Theme.C_Dim() .. L["TIP_GRAY_NOT_COLLECTED"] .. "|r" },
        anchor = "ANCHOR_TOP",
    })

    -- Item list: modern ScrollBox + DataProvider (was UIPanelScrollFrameTemplate +
    -- manual row pool with 1632 persistent frames costing ~540ms per Show).
    -- APIs verified against Gethe/wow-ui-source (Blizzard_Communities/GuildPerks.lua,
    -- Blizzard_SharedXML/Shared/Scroll/ScrollBoxListView.lua, ScrollBoxLinearView.lua).
    local rowHeight = CONFIG.PREVIEW_TAB.ITEM_ROW_HEIGHT
    local rowSpacing = math.max(0, (CONFIG.PREVIEW_TAB.ITEM_ROW_SPACING or rowHeight) - rowHeight)

    local scrollBox = CreateFrame("Frame", nil, listPanel, "WowScrollBoxList")
    scrollBox:SetPoint("TOPLEFT", 6, -20)
    scrollBox:SetPoint("BOTTOMRIGHT", -20, 28)

    local scrollBar = CreateFrame("EventFrame", nil, listPanel, "MinimalScrollBar")
    scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 2, 0)
    scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 2, 0)

    -- CreateScrollBoxListLinearView(top, bottom, left, right, spacing) - all default 0
    local view = CreateScrollBoxListLinearView(0, 0, 0, 0, rowSpacing)
    view:SetElementExtent(rowHeight)

    -- Overlay frame for favorite stars: parented to listPanel (so stars render
    -- outside the ScrollBox clip region), but with a frame level above
    -- scrollBox so the star draws ON TOP of row children (icon, name, etc.)
    -- rather than behind them.
    local favoritesOverlay = CreateFrame("Frame", nil, listPanel)
    favoritesOverlay:SetAllPoints(scrollBox)
    favoritesOverlay:SetFrameLevel(scrollBox:GetFrameLevel() + 10)
    previewPanel.favoritesOverlay = favoritesOverlay

    previewPanel.scrollBox = scrollBox
    previewPanel.scrollBar = scrollBar
    previewPanel.scrollView = view

    -- Arrow key navigation
    listPanel:EnableKeyboard(true)
    listPanel:SetScript("OnKeyDown", function(self, key)
        if (key ~= "UP" and key ~= "DOWN") or #displayedItems == 0 then
            self:SetPropagateKeyboardInput(true)
            return
        end
        self:SetPropagateKeyboardInput(false)

        local curIdx = 0
        if selectedItemKey then
            for i, item in ipairs(displayedItems) do
                if ItemKey(item) == selectedItemKey then curIdx = i; break end
            end
        end

        local newIdx
        if key == "DOWN" then
            newIdx = curIdx < #displayedItems and curIdx + 1 or 1
        else
            newIdx = curIdx > 1 and curIdx - 1 or #displayedItems
        end

        local newItem = displayedItems[newIdx]
        selectedItemKey = ItemKey(newItem)
        HDG_DecorPreview:Refresh()
        HDG_DecorPreview:UpdateModelAndInfo(newItem)

        -- Auto-scroll to keep selection visible. ScrollBox API (Align=Nearest
        -- only scrolls when the element is outside the viewport, which matches
        -- the old manual math below).
        if previewPanel.scrollBox and previewPanel.scrollBox.ScrollToNearest then
            previewPanel.scrollBox:ScrollToNearest(newIdx, 0, true)
        end
    end)

    -- Full row initializer. One-time setup (textures, scripts) runs once per
    -- frame (guarded by _hdgInit); data binding runs every time the frame is
    -- bound to a new elementData as the user scrolls.
    view:SetElementInitializer("Button", function(frame, elementData)
        if not frame._hdgInit then
            frame._hdgInit = true
            frame:SetHeight(rowHeight)
            -- Bare "Button" frame type doesn't auto-inherit scrollbox width; set it explicitly.
            frame:SetWidth(scrollBox:GetWidth())
            frame:RegisterForClicks("LeftButtonUp", "RightButtonUp")

            -- Full-frame background texture (replaces SetBackdrop; per-row color via SetColorTexture)
            frame.bg = frame:CreateTexture(nil, "BACKGROUND")
            frame.bg:SetAllPoints()

            -- Column 1: item icon
            frame.icon = frame:CreateTexture(nil, "ARTWORK")
            frame.icon:SetSize(CONFIG.PREVIEW_TAB.ITEM_ICON_SIZE, CONFIG.PREVIEW_TAB.ITEM_ICON_SIZE)
            frame.icon:SetPoint("LEFT", 4, 0)

            -- Favorite star (auctionhouse-icon-favorite atlas). Lives on
            -- favoritesOverlay (a sibling above scrollBox) so it renders
            -- ABOVE row children (icon, etc.) AND outside the ScrollBox clip
            -- region. Anchored to row LEFT — follows row as it scrolls.
            -- Visibility tied to the row's Show/Hide so it doesn't ghost when
            -- the row is recycled out.
            frame.favoriteStar = favoritesOverlay:CreateTexture(nil, "OVERLAY", nil, 7)
            frame.favoriteStar:SetAtlas("auctionhouse-icon-favorite")
            frame.favoriteStar:SetSize(14, 14)
            frame.favoriteStar:SetPoint("CENTER", frame, "LEFT", 4, 0)
            frame.favoriteStar:Hide()
            frame:HookScript("OnHide", function() frame.favoriteStar:Hide() end)

            -- Quantity overlay on top of icon (destroyable mode)
            frame.qtyOverlay = frame:CreateFontString(nil, "OVERLAY", "NumberFontNormal")
            frame.qtyOverlay:SetPoint("CENTER", frame.icon, "CENTER", 0, 0)
            frame.qtyOverlay:SetJustifyH("CENTER")
            frame.qtyOverlay:SetShadowColor(0, 0, 0, 1)
            frame.qtyOverlay:SetShadowOffset(2, -2)
            frame.qtyOverlay:Hide()

            -- Item name stretches between icon and right-anchored status icons
            frame.nameText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            frame.nameText:SetPoint("LEFT", 26, 0)
            frame.nameText:SetPoint("RIGHT", -52, 0)
            frame.nameText:SetJustifyH("LEFT")
            frame.nameText:SetWordWrap(false)
            frame.nameText:SetShadowOffset(0, 0)
            ApplyFont(frame.nameText, GetScheme(), "small")

            -- Dye droplets: up to 3, anchored after the name text inline.
            -- Position is set per-row from nameText:GetStringWidth() since names vary.
            -- Atlas: dye-drop_32 (white), tinted via SetVertexColor with swatchColorStart.
            frame.dyeDrops = {}
            for i = 1, 3 do
                local d = frame:CreateTexture(nil, "OVERLAY")
                d:SetSize(8, 10)
                d:SetAtlas("dye-drop_32")
                d:Hide()
                frame.dyeDrops[i] = d
            end

            -- Right-anchored status columns (craftable, collected, destroyable)
            frame.craftableIcon = frame:CreateTexture(nil, "ARTWORK")
            frame.craftableIcon:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
            frame.craftableIcon:SetPoint("RIGHT", -COL_RIGHT_MARGIN, 0)

            frame.collectedIcon = frame:CreateTexture(nil, "ARTWORK")
            frame.collectedIcon:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
            frame.collectedIcon:SetPoint("RIGHT", frame.craftableIcon, "LEFT", -COL_STATUS_PADDING, 0)

            frame:SetScript("OnClick", OnRowClick)

            -- OnEnter: hover tint + 3D preview swap + tooltip
            frame:SetScript("OnEnter", function(self)
                if not self.itemData then return end
                if selectedItemKey ~= ItemKey(self.itemData) then
                    local hoverScheme = GetScheme()
                    self.bg:SetColorTexture(hoverScheme.button_hover.r, hoverScheme.button_hover.g, hoverScheme.button_hover.b, 0.8)
                    UpdateModelDisplay(self.itemData)
                end
                GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)

                local function AddCustomLines()
                    GameTooltip:AddLine(" ")
                    local isCollected = HDG_Data.IsDecorCollected(self.itemData.decorID)
                    if isCollected then
                        GameTooltip:AddLine(HDG.Theme.C_Success() .. L["TIP_ITEM_COLLECTED"] .. "|r", unpack(CONFIG.COLORS.COLLECTED))
                    else
                        GameTooltip:AddLine(HDG.Theme.C_Dim() .. L["TIP_NOT_COLLECTED_DIM"] .. "|r", 0.78, 0.78, 0.78)
                    end
                    if self.itemData.spellID then
                        local isKnown = HDG_Data and HDG_Data.PlayerKnowsRecipeBySpellID and HDG_Data.PlayerKnowsRecipeBySpellID(self.itemData.spellID)
                        if isKnown then
                            local charName = UnitName("player")
                            GameTooltip:AddLine(HDG.Theme.C_Accent() .. string.format(L["TIP_RECIPE_KNOWN_BY"], charName) .. "|r", unpack(CONFIG.COLORS.CRAFTABLE))
                        else
                            local profession = self.itemData.profession or "Unknown"
                            GameTooltip:AddLine(HDG.Theme.C_Dim() .. string.format(L["TIP_CRAFTABLE_BY"], profession) .. "|r", 0.78, 0.78, 0.78)
                        end
                    end
                    if self.itemData.itemID and HDG_VendorResolver then
                        local vendors = HDG_VendorResolver.GetVendorsForItem(self.itemData.itemID)
                        if vendors and #vendors > 0 then
                            GameTooltip:AddLine(" ")
                            GameTooltip:AddLine(HDG.Theme.C_Accent() .. L["TIP_AVAILABLE_VENDOR"] .. "|r", 0.9, 0.7, 0.2)
                            local currencyStr
                            do
                                local currencies = HDG_VendorResolver.GetCurrencyCost(self.itemData.itemID)
                                if currencies and #currencies > 0 then
                                    local parts = {}
                                    for _, cc in ipairs(currencies) do
                                        local iconStr = HDG_VendorResolver.FormatCurrencyIcon(cc.icon, 14)
                                        local seg = (iconStr ~= "" and (iconStr .. " ") or "") .. (cc.name or "Currency")
                                        if cc.amount and cc.amount ~= "" then
                                            seg = tostring(cc.amount) .. " " .. seg
                                        end
                                        parts[#parts+1] = seg
                                    end
                                    currencyStr = table.concat(parts, ", ")
                                end
                            end
                            for i, vendor in ipairs(vendors) do
                                if i > 3 then
                                    GameTooltip:AddLine(HDG.Theme.C_Dim() .. string.format(L["TIP_AND_N_MORE"], #vendors - 3) .. "|r", 0.78, 0.78, 0.78)
                                    break
                                end
                                local priceStr = HDG_VendorResolver.FormatGoldCost(vendor.goldCost)
                                local vendorLine = "  " .. vendor.name .. " (" .. vendor.zone .. ")"
                                if priceStr then
                                    vendorLine = vendorLine .. " - " .. priceStr
                                elseif currencyStr then
                                    vendorLine = vendorLine .. " - " .. currencyStr
                                else
                                    vendorLine = vendorLine .. " - |cFFAAAAAAcurrency|r"
                                end
                                GameTooltip:AddLine(vendorLine, 0.8, 0.8, 0.8)
                                if vendor.factionID and vendor.factionID ~= 0 then
                                    local repStr = HDG_VendorResolver.FormatRepRequirement(vendor)
                                    if repStr then
                                        GameTooltip:AddLine("    " .. repStr, 0.6, 0.8, 1.0)
                                    end
                                end
                                if vendor.questID and vendor.questID ~= 0 then
                                    local questStr = HDG_VendorResolver.FormatQuestRequirement(vendor)
                                    if questStr then
                                        GameTooltip:AddLine(string.format("    " .. L["TIP_QUEST_PREFIX"], questStr), 0.6, 0.8, 1.0)
                                    end
                                end
                                if vendor.achieveID and vendor.achieveID ~= 0 then
                                    local achieveStr = HDG_VendorResolver.FormatAchievementRequirement(vendor)
                                    if achieveStr then
                                        GameTooltip:AddLine(string.format("    " .. L["TIP_ACHIEVEMENT_PREFIX"], achieveStr), 0.6, 0.8, 1.0)
                                    end
                                end
                            end
                        end
                    end
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine(HDG.Theme.C_Success() .. L["TIP_SHIFTCLICK_ADD_SHOPPING"] .. "|r", 0.7, 0.7, 0.7)
                    GameTooltip:Show()
                end

                if self.itemData.itemID then
                    local cachedName = GetCachedItemInfo(self.itemData.itemID)
                    if cachedName then
                        GameTooltip:SetItemByID(self.itemData.itemID)
                        AddCustomLines()
                    else
                        local item = Item:CreateFromItemID(self.itemData.itemID)
                        item:ContinueOnItemLoad(function()
                            if GameTooltip:IsOwned(self) then
                                GameTooltip:SetItemByID(self.itemData.itemID)
                                AddCustomLines()
                            end
                        end)
                    end
                else
                    local localizedTooltipName = self.itemData.itemID and HDG_Data.GetLocalizedName(self.itemData.itemID, self.itemData.name) or self.itemData.name or "Unknown"
                    GameTooltip:AddLine(localizedTooltipName, 1, 1, 1)
                    GameTooltip:AddLine(HDG.Theme.C_Dim() .. L["TIP_COLLECTED_DECOR_ITEM"] .. "|r", 0.78, 0.78, 0.78)
                    if self.itemData.tags then
                        local tagNames = {}
                        for _, tagName in pairs(self.itemData.tags) do
                            tagNames[#tagNames + 1] = tagName
                        end
                        if #tagNames > 0 then
                            GameTooltip:AddLine(" ")
                            GameTooltip:AddLine(string.format(L["TIP_TAGS_PREFIX"], table.concat(tagNames, ", ")), 0.6, 0.6, 0.8)
                        end
                    end
                    GameTooltip:Show()
                end
            end)

            frame:SetScript("OnLeave", function(self)
                if not self.itemData then GameTooltip:Hide(); return end
                if selectedItemKey ~= ItemKey(self.itemData) then
                    if self.baseColor then
                        self.bg:SetColorTexture(self.baseColor[1], self.baseColor[2], self.baseColor[3], self.baseColor[4])
                    end
                    if previewPanel and previewPanel.selectedItemData then
                        UpdateModelDisplay(previewPanel.selectedItemData)
                    end
                end
                GameTooltip:Hide()
            end)
        end

        -- Data binding (every time this frame is bound to new data)
        frame.itemData = elementData
        local scheme = GetScheme()
        local index = elementData._rowIndex or 1
        local isCollected = elementData._isCollected
        if isCollected == nil then
            isCollected = HDG_Data.IsDecorCollected(elementData.decorID)
        end

        ApplyRowBackground(frame, elementData, index, scheme)
        ApplyRowIcon(frame, elementData)
        ApplyFavoriteStar(frame, elementData)
        ApplyRowName(frame, elementData)
        ApplyDyeDroplets(frame, elementData)
        ApplyCollectedIcon(frame, isCollected, scheme)
        ApplyQtyOverlay(frame, elementData)
        ApplyCraftableStar(frame, elementData, scheme)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)

    -- Add All to Queue button (at bottom of list panel)
    local addAllBtn = CreateFrame("Button", nil, listPanel, "BackdropTemplate")
    addAllBtn:SetSize(CONFIG.PREVIEW_TAB.LIST_PANEL_WIDTH - 16, 20)
    addAllBtn:SetPoint("BOTTOM", 0, 4)
    addAllBtn:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        tile = false, edgeSize = 1,
    })
    local addBtnScheme = GetScheme()
    addAllBtn:SetBackdropColor(addBtnScheme.button_normal.r, addBtnScheme.button_normal.g, addBtnScheme.button_normal.b, 0.9)
    addAllBtn:SetBackdropBorderColor(addBtnScheme.accent.r, addBtnScheme.accent.g, addBtnScheme.accent.b, 0.8)
    RegisterWidget(addAllBtn, "Button")

    local addAllText = addAllBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    addAllText:SetPoint("CENTER", 0, 0)
    ApplyFont(addAllText, addBtnScheme, "small")
    addAllText:SetText(HDG.Theme.C_Text() .. "Add All to Queue|r")

    addAllBtn:SetScript("OnClick", function()
        -- Add all currently FILTERED craftable items to the crafting queue
        local items = GetAllDecorItems()
        local addedCount = 0
        PrepareFilterState()

        for _, itemData in ipairs(items) do
            -- Use shared filter helper (same as RefreshList)
            if ItemPassesFilters(itemData) and itemData.spellID and itemData.itemID and itemData.reagents then
                if HDG_UI and HDG_UI.AddToCraftingQueue then
                    -- Skip refresh during batch add (true = skipRefresh)
                    HDG_UI.AddToCraftingQueue(itemData, true)
                    addedCount = addedCount + 1
                end
            end
        end

        -- Refresh once after all items added
        if addedCount > 0 then
            if HDG_UI and HDG_UI.RefreshUI then
                HDG_UI.RefreshUI()
            end
            print(string.format(HDG.Theme.C_Accent() .. "[Housing Decor Guide]|r " .. (L["PREVIEW_ADDED_QUEUE"] or "Added %d craftable item(s) to crafting queue"), addedCount))
        else
            print(HDG.Theme.C_Accent() .. "[Housing Decor Guide]|r " .. (L["PREVIEW_NO_CRAFTABLE"] or "No craftable items to add (check your filters)"))
        end
    end)

    addAllBtn:SetScript("OnEnter", function(self)
        local hScheme = GetScheme()
        self:SetBackdropColor(hScheme.button_hover.r, hScheme.button_hover.g, hScheme.button_hover.b, 1.0)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Accent() .. "Add All to Queue|r", 1, 1, 1)
        GameTooltip:AddLine(L["TIP_ADD_ALL_QUEUE_DESC1"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["TIP_ADD_ALL_QUEUE_DESC2"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)

    addAllBtn:SetScript("OnLeave", function(self)
        local lScheme = GetScheme()
        self:SetBackdropColor(lScheme.button_normal.r, lScheme.button_normal.g, lScheme.button_normal.b, 0.9)
        GameTooltip:Hide()
    end)

    previewPanel.addAllBtn = addAllBtn
    
    -- Right panel: Model + Info
    local rightPanel = CreateFrame("Frame", nil, previewPanel)
    rightPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 8, 0)
    rightPanel:SetPoint("BOTTOMRIGHT", -8, 8)
    
    -- Model viewer (larger - info panel will be smaller)
    local modelPanel = CreateFrame("Frame", nil, rightPanel, "BackdropTemplate")
    modelPanel:SetPoint("TOPLEFT", 0, 0)
    modelPanel:SetPoint("TOPRIGHT", 0, 0)
    modelPanel:SetHeight(CONFIG.PREVIEW_TAB.MODEL_PANEL_HEIGHT)
    modelPanel:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        tile = false, edgeSize = 1,
        insets = {left = 0, right = 0, top = 0, bottom = 0}
    })
    local modelScheme = GetScheme()
    modelPanel:SetBackdropColor(modelScheme.bg.r, modelScheme.bg.g, modelScheme.bg.b, 0.95)
    modelPanel:SetBackdropBorderColor(modelScheme.border.r, modelScheme.border.g, modelScheme.border.b, modelScheme.border.a)
    RegisterWidget(modelPanel, "Panel")

    local decorPreview = HDG_UIHelpers.CreateDecorPreview(modelPanel, {
        squareAspect = false,
        showOverlays = false,
        showControls = true,
        showCorbels = true,
        placeholderText = L["PREVIEW_PLACEHOLDER"] or "Select an item to preview",
    })
    decorPreview:SetAllPoints()
    previewPanel.decorPreview = decorPreview

    -- Description overlay (top of model panel, from DecorNotes)
    -- Needs own frame above ModelScene which renders in 3D space
    local descOverlay = CreateFrame("Frame", nil, modelPanel)
    descOverlay:SetAllPoints()
    descOverlay:SetFrameLevel(decorPreview:GetFrameLevel() + 10)
    previewPanel.modelOverlayFrame = descOverlay -- parent for destroy controls (above model)
    local descText = descOverlay:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    descText:SetPoint("TOPLEFT", descOverlay, "TOPLEFT", 8, -6)
    descText:SetPoint("TOPRIGHT", descOverlay, "TOPRIGHT", -8, -6)
    descText:SetJustifyH("LEFT")
    descText:SetWordWrap(true)
    local descScheme = GetScheme()
    descText:SetTextColor(descScheme.text.r, descScheme.text.g, descScheme.text.b, 1.0)
    descText:SetShadowOffset(1, -1)
    descText:SetShadowColor(0, 0, 0, 0.8)
    descText:SetText("")
    RegisterWidget(descText, "Text")
    previewPanel.descOverlay = descText

    -- Info panel (25% of right panel height)
    local infoPanel = CreateFrame("Frame", nil, rightPanel, "BackdropTemplate")
    infoPanel:SetPoint("TOPLEFT", modelPanel, "BOTTOMLEFT", 0, -8)
    infoPanel:SetPoint("BOTTOMRIGHT", 0, 0)
    infoPanel:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        tile = false, edgeSize = 1,
        insets = {left = 0, right = 0, top = 0, bottom = 0}
    })
    local infoScheme = GetScheme()
    infoPanel:SetBackdropColor(infoScheme.panel.r, infoScheme.panel.g, infoScheme.panel.b, 0.9)
    infoPanel:SetBackdropBorderColor(infoScheme.border.r, infoScheme.border.g, infoScheme.border.b, infoScheme.border.a)
    RegisterWidget(infoPanel, "Panel")
    
    -- Enable hyperlinks so the vendor name in the Source line can act as a
    -- "Find Vendor" link (jumps to Acquire > By Item with the row selected).
    infoPanel:EnableMouse(true)
    infoPanel:SetHyperlinksEnabled(true)
    infoPanel:SetScript("OnHyperlinkClick", function(self, link, _, button)
        local kind, itemIDStr = strsplit(":", link or "", 2)
        if kind == "hdgvendor" then
            local itemID = tonumber(itemIDStr)
            if itemID and HDG_UI and HDG_UI.SwitchTab then
                HDG_UI.SwitchTab("acquisition")
                if HDG_AcquisitionTab then
                    if not HDG_AcquisitionTab.initialized then
                        HDG_AcquisitionTab:Init(CONFIG, mainFrame, addon)
                        HDG_AcquisitionTab.initialized = true
                    end
                    HDG_AcquisitionTab:Show()
                    HDG_AcquisitionTab:SelectItem(itemID)
                end
            end
        end
    end)
    infoPanel:SetScript("OnHyperlinkEnter", function(self, link)
        local kind = strsplit(":", link or "")
        if kind == "hdgvendor" then
            GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
            GameTooltip:AddLine("Find Vendor", 1, 0.82, 0.3)
            GameTooltip:AddLine("Click to open Acquire > By Item", 0.7, 0.7, 0.7)
            GameTooltip:Show()
        end
    end)
    infoPanel:SetScript("OnHyperlinkLeave", function() GameTooltip:Hide() end)

    previewPanel.infoPanel = infoPanel

    mainFrame.previewPanel = previewPanel
    return previewPanel
end

-- Short display names for expansion tags - use centralized HDG_ExpansionData
-- HDG_ExpansionData.GetShortName(tagName) returns 3-char short name (e.g., "TBC", "WLK")

-- Shortened names for faction tags (long names cause UI overlap)
local FACTION_TAG_SHORT = {
    ["Gilnean / Worgen"] = "Worgen",
    ["Pandaren"] = "Pandaren",
    ["Zandalari Troll"] = "Zandalari",
    ["Nightborne"] = "Nightborne",
    ["Kul Tiran"] = "Kul Tiran",
    ["Bronzebeard Dwarf (Ironforge)"] = "Bronzebeard",
    ["Dark Iron Dwarf"] = "Dark Iron",
    ["Earthen (Dornish)"] = "Earthen",
}

-- Tag categorisation now lives in modules/HDG_TagData.lua, anchored to tagIDs
-- from C_HousingCatalog.GetAllFilterTagGroups() so it picks up new tags
-- automatically and works on every locale without per-locale maps.

-- Category-row chips, in display order. Built once at file load — locale files
-- load earlier in the TOC, so L["..."] lookups resolve correctly here.
local CATEGORY_DEFS = {
    { key = "All",        display = L["CAT_ALL"]        or "All" },
    { key = "Dyed",       display = "Dyed" },
    { key = "Dyeable",    display = "Dyeable" },
    { key = "Crafted",    display = L["CAT_CRAFTED"]    or "Crafted" },
    { key = "Sizes",      display = L["CAT_SIZES"]      or "Sizes" },
    { key = "Factions",   display = L["CAT_FACTIONS"]   or "Factions" },
    { key = "Styles",     display = L["CAT_STYLES"]     or "Styles" },
    { key = "Expansions", display = L["CAT_EXPANSIONS"] or "Expansions" },
    { key = "Other",      display = L["CAT_OTHER"]      or "Other" },
}

-- Pixel budget for the tag-row chip strip. Chips wrap to a single horizontal
-- band; once xOffset + chipWidth would exceed this, layout stops adding chips.
local TAG_ROW_MAX_WIDTH = 780

-- Helper to get first word of a tag name for "Other" category display
-- e.g., "Table Tables" → "Table", "Shelf Shelves" → "Shelf"
local function GetFirstWord(tagName)
    return tagName:match("^(%S+)") or tagName
end

-- Filter buttons use HDG_UIHelpers.CreateChipButton (default options: height=16, inactive text #B0B0B0)

-- Refresh tag filter buttons (two-row category system)
-- Category buttons (row 1) are created once and cached; tag buttons (row 2) use a pool
local categoryButtonsCreated = false
local tagButtonPool = {} -- reusable pool for row 2 buttons

function HDG_DecorPreview:RefreshTagFilters()
    if not previewPanel or not previewPanel.categoryRow then return end

    -- Get items to extract tags and counts
    local success, items = pcall(GetAllDecorItems)
    if not success then return end

    -- Reuse cached tags if items table reference hasn't changed
    local tags
    if cachedTags and cachedTagsForItems == items then
        tags = cachedTags
    else
        tags = GetUniqueTags(items)
        cachedTags = tags
        cachedTagsForItems = items
    end

    -- Count active filters
    local activeCount = 0
    for _ in pairs(self.tagFilters or {}) do
        activeCount = activeCount + 1
    end

    local selectedCategory = self.selectedCategory or "All"

    -- Row 1: Category buttons — create once, then just update state.
    if not categoryButtonsCreated then
        categoryButtonsCreated = true
        previewPanel.categoryButtons = {}
        local xOffset = 38

        for _, catDef in ipairs(CATEGORY_DEFS) do
            local displayText = catDef.display
            if catDef.key == "All" and activeCount > 0 then
                displayText = (L["CAT_ALL"] or "All") .. "*"
            end

            local textWidth = GetTextWidth(displayText)
            local btnWidth = math.max(textWidth + 22, 40)

            local isActive = (selectedCategory == catDef.key)
            local btn = HDG_UIHelpers.CreateChipButton(
                previewPanel.categoryRow,
                displayText,
                btnWidth,
                isActive,
                function()
                    HDG_DecorPreview.selectedCategory = catDef.key
                    HDG_DecorPreview.tagFilters = {}
                    HDG_DecorPreview.professionFilter = nil
                    HDG_DecorPreview:RefreshList()
                    HDG_DecorPreview:RefreshTagFilters()
                end
            )
            btn:SetPoint("LEFT", xOffset, 0)
            btn.catKey = catDef.key
            btn.baseDisplay = catDef.display
            table.insert(previewPanel.categoryButtons, btn)
            xOffset = xOffset + btnWidth + 2
        end
    end

    -- Update category button states
    for _, btn in ipairs(previewPanel.categoryButtons) do
        local isActive = (selectedCategory == btn.catKey)
        local displayText = btn.baseDisplay
        if btn.catKey == "All" and activeCount > 0 then
            displayText = (L["CAT_ALL"] or "All") .. "*"
        end
        HDG_UIHelpers.UpdateChipButtonState(btn, isActive, displayText)
    end

    -- Row 2: Tag buttons — pool pattern (hide all, then show/configure needed ones)
    -- Hide all pooled tag buttons first
    for _, btn in ipairs(tagButtonPool) do
        btn:Hide()
    end
    previewPanel.tagButtons = {}

    -- Show/hide "Tags:" label (always on now that "All" has a House XP tag).
    if previewPanel.tagsLabel then previewPanel.tagsLabel:Show() end

    -- Helper: acquire a tag button from pool or create a new one
    local poolIndex = 0
    local function AcquireTagButton(displayName, btnWidth, isActive, onClick)
        poolIndex = poolIndex + 1
        local btn = tagButtonPool[poolIndex]
        if not btn then
            btn = HDG_UIHelpers.CreateChipButton(previewPanel.tagRow, displayName, btnWidth, isActive, onClick)
            tagButtonPool[poolIndex] = btn
        else
            btn:SetParent(previewPanel.tagRow)
            btn:SetWidth(btnWidth)
            btn:SetScript("OnClick", onClick)
            HDG_UIHelpers.UpdateChipButtonState(btn, isActive, displayName)
            btn:Show()
        end
        return btn
    end

    if selectedCategory == "All" then
        -- "All" sub-tags are mutually exclusive data-source / filter views.
        -- Order: All Decor, Crafted, House XP.
        --   All Decor — full catalog, no collection filter.
        --   Crafted   — craftable-only (HDG_DecorDB, recipe-backed items).
        --   House XP  — unowned items whose first-acquisition bonus is still available.
        -- "Unknown" stays on the header row (see below) as an INDEPENDENT filter so
        -- it can be combined with category/profession filters (e.g. Unknown + Crafted > Alchemy).
        local xOffset = 38

        -- Active view precedence: Favorites > House XP > All Decor > Crafted.
        local activeView
        if HDG_DecorPreview.favoritesOnly then activeView = "favorites"
        elseif HDG_DecorPreview.onlyXPEligible then activeView = "houseXP"
        elseif HDG_DecorPreview.showAllDecor then activeView = "allDecor"
        else activeView = "crafted" end

        local function SetActiveView(view)
            HDG_DecorPreview.favoritesOnly  = (view == "favorites")
            HDG_DecorPreview.showAllDecor   = (view == "allDecor" or view == "houseXP" or view == "favorites")
            HDG_DecorPreview.onlyXPEligible = (view == "houseXP")
            HDG_DecorPreview._UpdateHeaderToggles()
            HDG_DecorPreview:RefreshTagFilters()
            HDG_DecorPreview:RefreshList()
        end

        local function AddViewChip(view, label, tooltip)
            local w = math.max(GetTextWidth(label) + 18, 60)
            local btn = AcquireTagButton(label, w, activeView == view, function()
                SetActiveView(view)
            end)
            btn:SetPoint("LEFT", xOffset, 0)
            btn.tooltipText = tooltip
            table.insert(previewPanel.tagButtons, btn)
            xOffset = xOffset + w + 2
        end

        AddViewChip("allDecor", L["STATUS_COLLECTION"] or "All Decor",
            L["TIP_SHOW_ALL_DECOR"] or "Show ALL housing decor items from the catalog (not just craftable recipes).")
        AddViewChip("favorites", L["PREVIEW_TAG_FAVORITES"] or "Favorites",
            L["PREVIEW_TAG_FAVORITES_TT"] or "Show only items you've right-click favorited.")
        AddViewChip("crafted", L["CAT_CRAFTED"] or "Crafted",
            L["TIP_CRAFTED"] or "Show only craftable items from the recipe database.")
        AddViewChip("houseXP", L["PREVIEW_TAG_HOUSE_XP"] or "House XP",
            L["PREVIEW_TAG_HOUSE_XP_TT"] or "Show only unowned items that will grant house XP on first acquisition.")
        return
    end

    if selectedCategory == "Crafted" then
        -- Profession sub-filters
        local professionCounts = {}
        local professionOrder = { "Alchemy", "Blacksmithing", "Enchanting", "Engineering",
                                  "Inscription", "Jewelcrafting", "Leatherworking", "Tailoring", "Cooking" }
        for _, item in ipairs(items) do
            if not item.isCatalogItem and item.profession then
                professionCounts[item.profession] = (professionCounts[item.profession] or 0) + 1
            end
        end

        local xOffset = 38

        for _, profName in ipairs(professionOrder) do
            local count = professionCounts[profName]
            if count and count > 0 then
                local displayName = HDG_ExpansionData.GetLocalizedProfessionName(profName)
                local textWidth = GetTextWidth(displayName)
                local btnWidth = math.min(textWidth + 8, CONFIG.PREVIEW_TAB.CATEGORY_BUTTON_MAX_WIDTH or 80)

                if xOffset + btnWidth > TAG_ROW_MAX_WIDTH then break end

                local isActive = self.professionFilter == profName
                local btn = AcquireTagButton(displayName, btnWidth, isActive, function()
                    if HDG_DecorPreview.professionFilter == profName then
                        HDG_DecorPreview.professionFilter = nil
                    else
                        HDG_DecorPreview.professionFilter = profName
                    end
                    HDG_DecorPreview:RefreshTagFilters()
                    HDG_DecorPreview:RefreshList()
                end)
                btn:SetPoint("LEFT", xOffset, 0)
                HDG_UIHelpers.SetupTooltip(btn, {
                    title = profName,
                    desc  = string.format(L["TIP_N_RECIPES"], count),
                })

                table.insert(previewPanel.tagButtons, btn)
                xOffset = xOffset + btnWidth + 2
            end
        end
    else
        -- Standard tag categories. tagData = { id, name, count }; bucket by id.
        local xOffset = 38

        local categoryTagList = {}
        for _, tagData in ipairs(tags) do
            if HDG_TagData.GetCategory(tagData.id) == selectedCategory then
                table.insert(categoryTagList, tagData)
            end
        end

        if selectedCategory == "Expansions" then
            -- HDG_ExpansionData.GetSortOrder accepts localized names directly
            -- via its alias maps — no separate locale→English step needed.
            table.sort(categoryTagList, function(a, b)
                return (HDG_ExpansionData.GetSortOrder(a.name) or 99)
                     < (HDG_ExpansionData.GetSortOrder(b.name) or 99)
            end)
        else
            table.sort(categoryTagList, function(a, b) return a.count > b.count end)
        end

        for _, tagData in ipairs(categoryTagList) do
            local tagID, tagName, count = tagData.id, tagData.name, tagData.count
            if count and count > 0 then
                local displayName = tagName
                if selectedCategory == "Expansions" then
                    local short = HDG_ExpansionData.GetShortName(tagName)
                    if short then displayName = short end
                elseif FACTION_TAG_SHORT[tagName] then
                    -- Faction shortener is keyed by English; on non-English
                    -- clients this just falls through and we keep the full name.
                    displayName = FACTION_TAG_SHORT[tagName]
                elseif selectedCategory == "Other" then
                    displayName = GetFirstWord(tagName)
                end

                local textWidth = GetTextWidth(displayName)
                local btnWidth = math.min(textWidth + 8, CONFIG.PREVIEW_TAB.TAG_BUTTON_MAX_WIDTH or 70)

                if xOffset + btnWidth > TAG_ROW_MAX_WIDTH then break end

                local isActive = self.tagFilters and self.tagFilters[tagID]
                local btn = AcquireTagButton(displayName, btnWidth, isActive, function()
                    local wasActive = HDG_DecorPreview.tagFilters[tagID]
                    HDG_DecorPreview.tagFilters = {}
                    if not wasActive then
                        HDG_DecorPreview.tagFilters[tagID] = true
                    end
                    HDG_DecorPreview:RefreshTagFilters()
                    HDG_DecorPreview:RefreshList()
                end)
                btn:SetPoint("LEFT", xOffset, 0)
                btn.tagID = tagID

                -- Apply expansion color to button text
                if selectedCategory == "Expansions" then
                    local expColor = HDG_ExpansionData.GetColor(tagName)
                    if expColor then
                        local colorHex = string.format("%02x%02x%02x", expColor[1] * 255, expColor[2] * 255, expColor[3] * 255)
                        if isActive then
                            btn.text:SetText("|cFF" .. colorHex .. displayName .. "|r")
                        else
                            local dimHex = string.format("%02x%02x%02x", math.min(255, expColor[1] * 220), math.min(255, expColor[2] * 220), math.min(255, expColor[3] * 220))
                            btn.text:SetText("|cFF" .. dimHex .. displayName .. "|r")
                        end
                    end
                end

                -- Tooltip with full name and count; expansion tags use expansion color for the title.
                local titleColor
                if selectedCategory == "Expansions" then
                    titleColor = HDG_ExpansionData.GetColor(tagName)
                end
                HDG_UIHelpers.SetupTooltip(btn, {
                    title      = tagName,
                    titleColor = titleColor,
                    desc       = string.format(L["TIP_N_ITEMS"], count),
                })

                table.insert(previewPanel.tagButtons, btn)
                xOffset = xOffset + btnWidth + 2
            end
        end
    end
end

-- Refresh just the item list
function HDG_DecorPreview:RefreshList()
    if not previewPanel then return end
    if not previewPanel.scrollBox then return end

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

    local success, items = pcall(GetAllDecorItems)
    if not success then
        return
    end

    -- Sort by destroyable quantity (descending) when destroyable filter is active
    -- Sort a shallow copy to avoid mutating the canonical cache order
    if HDG_DecorPreview.destroyableOnly then
        items = Timed("Preview.RefreshList.sort", function()
            local sorted = {}
            for i, v in ipairs(items) do sorted[i] = v end
            table.sort(sorted, function(a, b)
                local da = GetDestroyableCount(a.decorID)
                local db = GetDestroyableCount(b.decorID)
                if da ~= db then return da > db end
                return (a.name or "") < (b.name or "")
            end)
            return sorted
        end)
    end

    local index = 0
    local collectedCount = 0
    local totalDestroyable = 0
    wipe(displayedItems)
    PrepareFilterState()

    -- Build the filtered list and feed it to the ScrollBox as a DataProvider.
    -- The ScrollBox creates/recycles only viewport frames (~20), not the full
    -- filtered list (~1600), so this scales independent of item count.
    local dataProvider = CreateDataProvider()
    Timed("Preview.RefreshList.rowLoop[" .. #items .. " scanned]", function()
        for _, itemData in ipairs(items) do
            if ItemPassesFilters(itemData) then
                index = index + 1
                displayedItems[index] = itemData
                local isCollected = HDG_Data.IsDecorCollected(itemData.decorID)
                if isCollected then collectedCount = collectedCount + 1 end
                local row = HDG_Data.GetCatalog and HDG_Data.GetCatalog(itemData.decorID)
                if row and row.destroyableInstanceCount then
                    totalDestroyable = totalDestroyable + row.destroyableInstanceCount
                end
                -- Attach render-time state so the initializer doesn't have to
                -- recompute or reach into module scope
                itemData._rowIndex = index
                itemData._isCollected = isCollected
                dataProvider:Insert(itemData)
            end
        end
    end)

    previewPanel.scrollBox:SetDataProvider(dataProvider, ScrollBoxConstants.RetainScrollPosition)

    -- Debug diagnostic: print counts to locate where rows go missing
    local HDG_Debug_rl = addon and addon.HDG_Debug
    if HDG_Debug_rl and HDG_Debug_rl.IsEnabled and HDG_Debug_rl:IsEnabled() then
        local itemCountFromAPI = items and #items or 0
        local dpSize = dataProvider and dataProvider.GetSize and dataProvider:GetSize() or -1
        local boxW = previewPanel.scrollBox:GetWidth() or 0
        local boxH = previewPanel.scrollBox:GetHeight() or 0
        print(string.format("|cFFfcd34d[HDG diag]|r items=%d filtered=%d dp=%d box=%dx%d shown=%s",
            itemCountFromAPI, index, dpSize, boxW, boxH, tostring(previewPanel.scrollBox:IsShown())))
    end

    -- Update title with count and collection progress
    if previewPanel.headerTitle then
        if index == 0 and HDG_Data and HDG_Data.IsLiveCatalogReady and not HDG_Data.IsLiveCatalogReady() then
            previewPanel.headerTitle:SetText(HDG.Theme.C_Dim() .. (L["PREVIEW_LOADING"] or "Loading catalog...") .. "|r")
        else
            local pct = index > 0 and math.floor((collectedCount / index) * 100) or 0
            local pctColor = pct >= 100 and HDG.Theme.C_Success() or (pct >= 50 and HDG.Theme.C_Accent() or HDG.Theme.C_Text())
            previewPanel.headerTitle:SetText(string.format(
                "%s%s|r %s(%d)|r  %s%d/%d (%d%%)|r",
                HDG.Theme.C_Text(), L["PREVIEW_ITEMS"] or "Items", HDG.Theme.C_Dim(), index, pctColor, collectedCount, index, pct
            ))
        end
    end

    -- Storage stats line below filter buttons (always visible)
    if previewPanel.destroyableStats then
        local totalOwned = 0
        if C_HousingCatalog and C_HousingCatalog.GetDecorTotalOwnedCount then
            totalOwned = C_HousingCatalog.GetDecorTotalOwnedCount()
        end
        local maxOwned = C_HousingCatalog and C_HousingCatalog.GetDecorMaxOwnedCount and C_HousingCatalog.GetDecorMaxOwnedCount() or 0
        if totalDestroyable > 0 then
            previewPanel.destroyableStats:SetText(string.format(
                "%sOwned: %d/%d  |  %d stored|r",
                HDG.Theme.C_Dim(), totalOwned, maxOwned, totalDestroyable
            ))
        else
            previewPanel.destroyableStats:SetText(string.format(
                "%sOwned: %d/%d|r",
                HDG.Theme.C_Dim(), totalOwned, maxOwned
            ))
        end
        previewPanel.destroyableStats:Show()
    end

    -- Update header icon colors for current theme (use text color on light themes for contrast)
    local headerScheme = GetScheme()
    local iconColor = headerScheme.isLight and headerScheme.text or headerScheme.text_dim
    if previewPanel.headerCraftableIcon then
        previewPanel.headerCraftableIcon:SetVertexColor(iconColor.r, iconColor.g, iconColor.b)
    end
    if previewPanel.headerCollectedIcon then
        previewPanel.headerCollectedIcon:SetVertexColor(iconColor.r, iconColor.g, iconColor.b)
    end

    -- Cross-tab drill-down auto-select. SetSubcategoryFilter sets _xtabSelectFirst
    -- so the first filtered item lands in the 3D preview + info panel without
    -- the user having to click. Clears the flag after the first refresh that
    -- has results — async cache population may fire RefreshList again before
    -- the catalog is ready, in which case we want the second pass to claim it.
    if HDG_DecorPreview._xtabSelectFirst and index > 0 and displayedItems[1] then
        local first = displayedItems[1]
        selectedItemKey = ItemKey(first)
        HDG_DecorPreview:UpdateModelAndInfo(first)
        HDG_DecorPreview._xtabSelectFirst = nil
    end
end

-- Update model and info for selected item
function HDG_DecorPreview:UpdateModelAndInfo(itemData)
    if not previewPanel then return end
    destroyQty = 1  -- reset quantity when selecting a new item
    UpdateModelDisplay(itemData)
    UpdateInfoPane(previewPanel.infoPanel, itemData)

    -- Description overlay from DecorNotes
    if previewPanel.descOverlay then
        local desc = itemData and itemData.itemID and HDG_DecorNotes and HDG_DecorNotes[itemData.itemID]
        previewPanel.descOverlay:SetText(desc and desc.n or "")
    end
end

-- Full refresh
function HDG_DecorPreview:Refresh()
    -- Guard: don't refresh if not initialized yet
    if not CONFIG then return end

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

    if not previewPanel then
        Timed("Preview.CreatePanel", function() self:CreatePanel() end)
    end

    Timed("Preview.RefreshTagFilters", function() self:RefreshTagFilters() end)
    Timed("Preview.RefreshList", function() self:RefreshList() end)

    -- If we have a selected item, update its display (O(1) lookup)
    if selectedItemKey then
        Timed("Preview.UpdateModelAndInfo", function()
            GetAllDecorItems() -- ensure cache + index are populated
            local itemData = decorItemsByKey and decorItemsByKey[selectedItemKey]
            if itemData then
                self:UpdateModelAndInfo(itemData)
            end
        end)
    end
end

-- Show the panel
function HDG_DecorPreview:Show()
    local HDG_Debug = addon and addon.HDG_Debug
    local Timed = (HDG_Debug and HDG_Debug.Timed) or function(_, fn) return fn() end
    local debugEnabled = HDG_Debug and HDG_Debug.IsEnabled and HDG_Debug:IsEnabled()

    -- Subscribe-once to the central catalog ready event for live repaints.
    if HDG and HDG.EventBus and not _collectionReadySubscribed then
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            collectedDecorCache = nil  -- force ProcessSearchResults rebuild
            if previewPanel and previewPanel:IsShown() then
                self:RefreshList()
                if selectedItemKey and previewPanel.infoPanel and decorItemsByKey then
                    local itemData = decorItemsByKey[selectedItemKey]
                    if itemData then UpdateInfoPane(previewPanel.infoPanel, itemData) end
                end
            end
        end)
        _collectionReadySubscribed = true
    end

    Timed("Preview.Show.total", function()
        if not previewPanel then
            Timed("Preview.Show.CreatePanel", function() self:CreatePanel() end)
        end
        Timed("Preview.Show.Refresh", function() self:Refresh() end)

        -- One-time diagnostic: when debug is on, profile each descendant's
        -- OnShow to find which child owns the ~500ms cost we see on :Show().
        -- Wrappers restore themselves after the cascade fires.
        if debugEnabled and previewPanel and not previewPanel._hdgOnShowProfileArmed then
            previewPanel._hdgOnShowProfileArmed = true
            local results = {}
            local restores = {}
            local function arm(f, depth, path)
                if not f or depth > 3 then return end
                local existing = f:GetScript("OnShow")
                if existing then
                    restores[#restores + 1] = { frame = f, original = existing }
                    f:SetScript("OnShow", function(self, ...)
                        debugprofilestart()
                        local ok, err = pcall(existing, self, ...)
                        results[#results + 1] = { path = path, ms = debugprofilestop(), ok = ok, err = err }
                    end)
                end
                if f.GetChildren then
                    local kids = { f:GetChildren() }
                    for i, kid in ipairs(kids) do
                        local nm = kid.GetName and kid:GetName() or "?"
                        arm(kid, depth + 1, path .. " > " .. i .. "(" .. nm .. ")")
                    end
                end
            end
            arm(previewPanel, 0, "previewPanel")
            -- After the Show cascade, print top offenders and restore scripts
            C_Timer.After(0, function()
                table.sort(results, function(a, b) return a.ms > b.ms end)
                print(string.format("|cFFfcd34d[HDG perf]|r previewPanel OnShow profile (%d handlers, top 10):", #results))
                for i = 1, math.min(10, #results) do
                    local r = results[i]
                    print(string.format("  %.2fms  %s%s", r.ms, r.path, (not r.ok) and " [err: " .. tostring(r.err) .. "]" or ""))
                end
                for _, r in ipairs(restores) do r.frame:SetScript("OnShow", r.original) end
                previewPanel._hdgOnShowProfileArmed = nil  -- allow next switch to re-profile
            end)
        end

        Timed("Preview.Show.previewPanel:Show", function() previewPanel:Show() end)
    end)
end

-- Hide the panel
function HDG_DecorPreview:Hide()
    if previewPanel then
        previewPanel:Hide()
    end
end

-- Check if panel is shown
function HDG_DecorPreview:IsShown()
    return previewPanel and previewPanel:IsShown()
end

-- Preview a specific decor item by name
-- This is the main preview function - switches to Preview tab, filters by name, and refreshes
-- Optional itemID and decorID can be passed for direct 3D preview (bypasses search)
-- Cross-tab navigation entry: filter the Preview list to a single
-- subcategory (and optionally only-uncollected). Used by the House tab
-- "Closest to complete" cards. Caller is responsible for switching the
-- active tab; this just sets the filter state and refreshes.
--
-- subcategoryID (numeric, from C_HousingCatalog) is preferred — locale +
-- whitespace robust. Names are kept as a fallback for callers without IDs.
function HDG_DecorPreview:SetSubcategoryFilter(subcategoryID, subcategoryName, parentCategoryName, uncollectedOnly, categoryID)
    self.searchFilter        = ""
    self.tagFilters          = {}
    self.professionFilter    = nil
    self.destroyableOnly     = false
    self.onlyXPEligible      = false
    self.showAllDecor        = true   -- full catalog (match My-Collection scope)
    -- categoryID + subcategoryID is the composite the Aggregator buckets by.
    -- Same subcatID can live under multiple categoryIDs (e.g. Windows lives
    -- under both Structural and Nature) — without categoryID the filter
    -- pulls items from BOTH and the count won't match.
    self.categoryIDFilter    = categoryID
    self.subcategoryIDFilter = subcategoryID
    self.categoryFilter      = parentCategoryName   -- name fallback
    self.subcategoryFilter   = subcategoryName      -- name fallback
    self.uncollectedOnly     = uncollectedOnly ~= false  -- default true
    -- Auto-select the first filtered item after refresh (RefreshList claims
    -- this flag once it has a non-empty result list).
    self._xtabSelectFirst    = true
    if previewPanel and previewPanel.searchBox then
        previewPanel.searchBox:SetText("")
    end
    -- Filter state stays set so async cache-population refreshes pick it up.
    -- It gets cleared by HDG_DecorPreview:_ClearXTabFilter() — called from
    -- the search-box OnTextChanged handler so the next user-typed search
    -- starts clean.
    if self.RefreshList then self:RefreshList() end
end

function HDG_DecorPreview:_ClearXTabFilter()
    self.categoryIDFilter    = nil
    self.subcategoryIDFilter = nil
    self.subcategoryFilter   = nil
    self.categoryFilter      = nil
    self.uncollectedOnly     = false
end

function HDG_DecorPreview:PreviewByName(itemName, itemID, decorID)
    if not itemName or itemName == "" then return false end

    -- Ensure we have a reference to mainFrame
    local frame = mainFrame or _G["HDG_MainFrame"]
    if not frame then
        print(HDG.Theme.C_Error() .. "[Housing Decor Guide]|r " .. (L["PREVIEW_NO_WINDOW"] or "Main window not available. Open /hdg first."))
        return false
    end

    -- Ensure CONFIG is available
    if not CONFIG then
        if addon and addon.CONFIG then
            CONFIG = addon.CONFIG
        end
    end
    if not CONFIG then
        print(HDG.Theme.C_Error() .. "[Housing Decor Guide]|r " .. (L["PREVIEW_NOT_INITIALIZED"] or "Module not initialized. Try reopening /hdg."))
        return false
    end

    if not mainFrame then
        mainFrame = frame
    end

    -- Switch to Decor Preview tab
    if HDG and HDG.Store then
        HDG.Store:SetUI("activeTab", "preview")
    end
    if HDG_UI and HDG_UI.UpdateTabs then
        HDG_UI.UpdateTabs()
    end

    -- Ensure panel is created first
    if not previewPanel then
        self:CreatePanel()
    end

    -- Enable "My Collection" mode to show non-craftable items too
    self.showAllDecor = true

    -- Clear other filters but use search to show only this item
    self.categoryFilter = nil
    self.professionFilter = nil
    self.uncollectedOnly = false
    self.destroyableOnly = false
    self.tagFilters = {}

    -- Set search filter to the item name
    self.searchFilter = itemName:lower()

    -- Update the search box UI to show the search term
    if previewPanel and previewPanel.searchBox then
        previewPanel.searchBox:SetText(itemName)
    end

    -- Find and select the matching item for 3D preview BEFORE refresh
    local searchLower = itemName:lower()
    local foundItemData = nil

    -- If decorID or itemID was passed directly, use it (fastest path)
    if decorID or itemID then
        foundItemData = {
            name = itemName,
            itemID = itemID,
            decorID = decorID,
        }
        selectedItemKey = ItemKey(foundItemData)
    end

    -- If no IDs passed, check HDG_DecorDB directly (always available, unlike async API cache)
    if not foundItemData and HDG_DecorDB then
        for dbKey, recipe in pairs(HDG_DecorDB) do
            if recipe.name and recipe.name:lower():find(searchLower, 1, true) then
                -- Build minimal itemData for UpdateModelAndInfo
                foundItemData = {
                    name = recipe.name,
                    itemID = recipe.itemID,
                    decorID = recipe.decorID,
                    spellID = recipe.spellID or dbKey,
                }
                selectedItemKey = ItemKey(foundItemData)
                break
            end
        end
    end

    -- Fallback: check API cache if not found in DecorDB
    if not foundItemData then
        local items = GetAllDecorItems()
        for _, itemData in ipairs(items) do
            local checkName = itemData.name or ""
            if checkName:lower():find(searchLower, 1, true) then
                selectedItemKey = ItemKey(itemData)
                foundItemData = itemData
                break
            end
        end
    end

    -- Refresh will update the list AND call UpdateModelAndInfo for selectedItemKey
    self:Refresh()

    -- If Refresh didn't find it (cache timing issue), force update with our found data
    if foundItemData then
        self:UpdateModelAndInfo(foundItemData)
    end

    return true
end

-- Convenience wrapper: Preview by itemID (looks up name then calls PreviewByName with itemID)
function HDG_DecorPreview:PreviewByItemID(itemID)
    if not itemID then return false end

    -- Use HDG_Data's localized-name resolver (caches + handles async load
    -- + unreleased-timeout fallback) instead of bare C_Item.GetItemNameByID.
    local itemName = HDG_Data and HDG_Data.GetLocalizedName and HDG_Data.GetLocalizedName(itemID)
    if itemName and itemName ~= ("Item #" .. itemID) then
        return self:PreviewByName(itemName, itemID, nil)
    end

    -- Fallback: search HDG_DecorDB for the name and decorID
    if HDG_DecorDB then
        for _, recipe in pairs(HDG_DecorDB) do
            if recipe.itemID == itemID and recipe.name then
                return self:PreviewByName(recipe.name, itemID, recipe.decorID)
            end
        end
    end

    return false
end

-- Storage event listener. Mirrors Blizzard_HousingDashboardCatalog:OnEvent:
-- HOUSING_STORAGE_UPDATED → kick the persistent searcher; its callback will
-- populate collectedDecorCache and repaint. Critical for cold-login: the first
-- RunSearch at tab-open fires before the catalog finishes loading, so the
-- callback never fires on attempt #1. This event is the signal that catalog
-- data is now available, so we re-kick and also clear the in-progress flag
-- (which otherwise stays true forever and blocks subsequent retries).
-- Storage events are handled by HDG_Data centrally; we just need to invalidate
-- our derived items list and repaint when HDG_COLLECTION_READY fires below.
-- This stub remains so external triggers (e.g. legacy callers of the
-- TriggerAllDecorSearch path) still find the local refresh hook.

-- GET_ITEM_INFO_RECEIVED listener: when async item data arrives for previously
-- uncached items (fire-and-forget RequestLoadItemDataByID sites), invalidate
-- the cached miss and refresh the visible list so rows that showed as "?" icon
-- pick up the real icon. Throttled to coalesce the initial-login burst.
local itemInfoRefreshPending = false
local itemInfoFrame = CreateFrame("Frame")
itemInfoFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
itemInfoFrame:SetScript("OnEvent", function(_, _, itemID, success)
    if not success then return end
    if itemCache then itemCache[itemID] = nil end  -- invalidate the nil-entry miss
    if not (previewPanel and previewPanel:IsShown()) then return end
    if itemInfoRefreshPending then return end
    itemInfoRefreshPending = true
    C_Timer.After(0.15, function()
        itemInfoRefreshPending = false
        if previewPanel and previewPanel:IsShown() and HDG_DecorPreview.RefreshList then
            HDG_DecorPreview:RefreshList()
        end
    end)
end)

-- HDG_COLLECTION_READY listener: repaint only. The per-row "collected"
-- markers are queried live via HDG_Data.IsDecorCollected at paint time, so
-- any change in the owned set just needs a RefreshList to propagate.
-- Preview's own searcher + cache are owned by Show()/Refresh() (kicked on
-- tab-switch via HDG_UI.UpdateTabs → HDG_DecorPreview:Show() → :Refresh()).
if HDG and HDG.EventBus then
    HDG.EventBus:Register("HDG_COLLECTION_READY", function()
        if previewPanel and previewPanel:IsShown() and HDG_DecorPreview.RefreshList then
            HDG_DecorPreview:RefreshList()
        end
    end)
end
