-- 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 localization helper (English name -> L key)
local PROF_LOCALE_KEYS = {
    ["All"] = "PROF_ALL",
    ["Alchemy"] = "PROF_ALCHEMY",
    ["Blacksmithing"] = "PROF_BLACKSMITHING",
    ["Cooking"] = "PROF_COOKING",
    ["Enchanting"] = "PROF_ENCHANTING",
    ["Engineering"] = "PROF_ENGINEERING",
    ["Inscription"] = "PROF_INSCRIPTION",
    ["Jewelcrafting"] = "PROF_JEWELCRAFTING",
    ["Leatherworking"] = "PROF_LEATHERWORKING",
    ["Tailoring"] = "PROF_TAILORING",
}

local function GetLocalizedProfession(englishName)
    local key = PROF_LOCALE_KEYS[englishName]
    return key and L[key] or englishName
end

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


-- State color helpers for toggle buttons (return r, g, b, a)

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

-- Scroll position tracking
local scrollOffset = 0

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

-- Helper: Normalize expansion name - use centralized HDG_ExpansionData module
local function NormalizeExpansionName(expansion)
    return HDG_ExpansionData.NormalizeToDisplayName(expansion)
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()
        if dlgScheme then
            f:SetBackdropColor(dlgScheme.bg.r, dlgScheme.bg.g, dlgScheme.bg.b, 0.95)
        else
            f:SetBackdropColor(0.08, 0.08, 0.1, 0.95)
        end
        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
    if f.hasTagFilters then
        local matchesTags = false
        if itemData.tags then
            for _, tag in ipairs(itemData.tags) do
                if f.tagFilters[tag] 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

-- Helper: Build recipe object for adding to shopping list queue
local function BuildRecipeForQueue(itemData)
    -- itemData can come from row.itemData or recipeData from HDG_DecorDB
    return {
        id = itemData.itemID,
        name = itemData.name,
        spell = itemData.spellID,
        reagents = itemData.reagents,
        expansion = NormalizeExpansionName(itemData.expansion),
        profession = itemData.profession
    }
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

-- Get quality color (theme-aware: uses Solarized colors for Uncommon/Rare)
local function GetQualityColor(quality)
    if quality then
        return HDG.Theme.GetQualityRGB(quality)
    end
    return 1, 1, 1
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)

        local itemTags = {}
        if entryInfo.tags then
            for _, tagName in pairs(entryInfo.tags) do
                table.insert(itemTags, tagName)
            end
        end
        if isDyed then
            table.insert(itemTags, "Dyed")
        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
                local name = adEntry.name
                if not name then
                    local itemName = C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(itemID)
                    name = itemName or ("Item " .. itemID)
                end
                local iconTex
                if C_Item and C_Item.GetItemIconByID then
                    iconTex = C_Item.GetItemIconByID(itemID)
                end
                -- Populate cat/subcat IDs+names from the catalog row reverse
                -- index. Items reach this branch when emitItem skipped them
                -- (typically because entryInfo.name was nil in the live row);
                -- the row itself is still in HDG_Data._catalog with valid
                -- categoryID/subcategoryID, so we can look them up cheaply.
                local catRow = HDG_Data and HDG_Data.GetCatalogByItem
                           and HDG_Data.GetCatalogByItem(itemID)
                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)
                    if decorInfo.tags then
                        for _, tagName in pairs(decorInfo.tags) do
                            table.insert(itemTags, 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 categories from items
local function GetCategories(items)
    local categories = {}
    local seen = {}
    
    for _, item in ipairs(items) do
        local cat = item.categoryName or "Uncategorized"
        if not seen[cat] then
            seen[cat] = true
            table.insert(categories, cat)
        end
    end
    
    table.sort(categories)
    table.insert(categories, 1, "All Categories")
    
    return categories
end

-- Get unique professions from items
local function GetProfessions(items)
    local professions = {}
    local seen = {}

    for _, item in ipairs(items) do
        local prof = item.profession or "Unknown"
        if not seen[prof] then
            seen[prof] = true
            table.insert(professions, prof)
        end
    end

    table.sort(professions)
    table.insert(professions, 1, L["FILTER_ALL_PROFESSIONS"] or "All Professions")

    return professions
end

-- Get unique tags from items with counts
local function GetUniqueTags(items)
    local tagCounts = {}

    for _, item in ipairs(items) do
        if item.tags then
            for _, tag in ipairs(item.tags) do
                tagCounts[tag] = (tagCounts[tag] or 0) + 1
            end
        end
    end

    -- Convert to sorted array
    local tags = {}
    for tag, count in pairs(tagCounts) do
        table.insert(tags, { name = tag, count = count })
    end

    -- Sort by count descending (most common tags first)
    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 pools for info pane (pre-allocated, reused across selections)
local infoLeftPool = {}
local infoRightPool = {}

-- Forward declaration: GetTagCategory is defined further down (after the
-- TAG_CATEGORIES/TAG_TO_ENGLISH tables it depends on); UpdateInfoPane needs it
-- to filter Sizes/Expansions tags out of the Tags line and elevate them.
local GetTagCategory

-- Update the info pane with all available data (two columns)
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
    for _, fs in ipairs(infoRightPool) 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 rightY = -8
    local leftIdx = 0
    local rightIdx = 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

    local function AddRightLine(text)
        rightIdx = rightIdx + 1
        local line = infoRightPool[rightIdx]
        if not line then
            line = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            line:SetJustifyH("LEFT")
            line:SetShadowOffset(0, 0)
            infoRightPool[rightIdx] = line
        end
        line:ClearAllPoints()
        line:SetPoint("TOPLEFT", midPoint, rightY)
        line:SetPoint("RIGHT", -8, 0)
        line:SetText(text)
        line:Show()
        rightY = rightY - lineHeight
        return line
    end
    
    -- Use cached decorInfo from GetAllDecorItems; fall back to central cache if missing
    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
    
    -- === LEFT COLUMN: Player-interesting info ===

    -- 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)
    -- Pre-fetch the catalog row early so we can append the dyeable palette icon
    -- to the title (replaces the standalone "Dyeable: Yes" line below).
    local _decorInfo = itemData.decorInfo
                       or (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))
    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 _, tagName in pairs(decorInfo.tags) do
            if GetTagCategory(tagName) == "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, plus alternate when present
    -- (e.g. vendor primary + achievement-gate alt, crafted + reagent vendor).
    do
        local ST = CONFIG and CONFIG.SOURCE_TYPE
        local si = HDG_AcqData and HDG_AcqData.GetSourceInfo and HDG_AcqData.GetSourceInfo(itemData.itemID)

        -- Build a "<icon> Label: Name [+ vendor cost if vendor]" line.
        -- Returns nil when the source has no usable label.
        local function BuildSourceLine(sType, sName, labelOverride)
            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 that
            -- looked like the line was a sub-item.
            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 itemData.itemID then
                    -- Wrap vendor name as hyperlink → jumps to Acquire > By Item.
                    -- Bracketed + cyan link colour matches WoW chat-hyperlink
                    -- convention so the affordance reads as clickable.
                    line = line .. " |cFF40C7EB|Hhdgvendor:" .. itemData.itemID ..
                        "|h[" .. sName .. "]|h|r"
                else
                    line = line .. " " .. HDG.Theme.C_RecipeKnown() .. sName .. "|r"
                end
            end
            -- Append vendor cost inline (icons-only — names cause overlap).
            if isVendorSource and itemData.itemID and HDG_VendorByItem and HDG_VendorDB then
                local npcIDs = HDG_VendorByItem[itemData.itemID]
                if npcIDs then
                    for _, npcID in ipairs(npcIDs) do
                        local ve = HDG_VendorDB[npcID]
                        local ie = ve and ve.items and ve.items[itemData.itemID]
                        if ie then
                            local gc = ie[1] or 0
                            if gc > 0 then
                                line = line .. " " .. GetCoinTextureString(gc)
                            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 ""
                                        line = line .. " " .. 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 ""
                                        line = line .. " " .. ct[3] .. iStr
                                    end
                                end
                            end
                            break
                        end
                    end
                end
            end
            return line
        end

        -- Primary: prefer "Profession: <name>" wording for crafted items
        local primarySType, primarySName
        if si then
            primarySType, primarySName = si[2], si[3]
        end
        -- Track the FontString of whichever line carries the vendor source so
        -- the cart buttons can anchor inline to its right.
        infoFrame._vendorLineFS = nil

        -- Track whether ANY emitted primary line represents a vendor source
        -- (real or synthesized) so the synthetic-alt branch below doesn't
        -- double-emit a Vendor line for vendor-only items.
        local primaryIsVendor = (ST and primarySType == ST.VENDOR)
        if ST and primarySType == ST.CRAFTED and itemData.profession and itemData.profession ~= "Unknown" then
            -- Prefer the source-name (e.g. "Draenor Engineering (80)") over bare
            -- profession — it carries the expansion-tier + skill level too.
            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
            if primarySType and primarySType ~= 0 then
                primaryLine = BuildSourceLine(primarySType, primarySName)
            elseif itemData.itemID and HDG_VendorByItem and HDG_VendorByItem[itemData.itemID] then
                -- Synthesized vendor primary: look up the actual NPC name so
                -- the line reads as `Vendor: [NPC Name] cost` not bare `Vendor:`.
                local npcIDs = HDG_VendorByItem[itemData.itemID]
                local vendorName
                for _, npcID in ipairs(npcIDs) do
                    local ve = HDG_VendorDB and HDG_VendorDB[npcID]
                    if ve and ve[1] then vendorName = ve[1]; break end
                end
                primaryLine = BuildSourceLine(ST and ST.VENDOR, vendorName, "Vendor")
                primaryIsVendor = true
            end
            if primaryLine then
                local fs = AddLeftLine(primaryLine)
                if primaryIsVendor then infoFrame._vendorLineFS = fs end
            end
        end

        -- Alt: emit when meaningfully different from primary (different type or name).
        -- Also synthesize a Vendor alt when the primary isn't vendor but the item
        -- has VendorDB entries (vendor data lives outside AllDecorDB.alt).
        local alt = si and si.alt
        if alt and alt[1] and alt[1] ~= 0 then
            local altType, altName = alt[1], alt[2]
            -- Dedup on name: when alt name matches primary, the alt typically
            -- just relabels the same source (e.g. CRAFTED + PROFESSION pair).
            local sameName = (altName and primarySName and altName == primarySName)
            if not sameName then
                local altLine = BuildSourceLine(altType, altName)
                if altLine then
                    -- Indent alt sources under the primary, prefixed with a
                    -- rotate-icon to visually chain the alt back to its primary.
                    local prefix = "  " .. CreateAtlasMarkup("common-icon-rotateright", 12, 12) .. "  "
                    local fs = AddLeftLine(prefix .. altLine)
                    if ST and altType == ST.VENDOR then infoFrame._vendorLineFS = fs end
                end
            end
        end
        local altIsVendor = (alt and ST and alt[1] == ST.VENDOR)
        if not primaryIsVendor and not altIsVendor and itemData.itemID
           and HDG_VendorByItem and HDG_VendorByItem[itemData.itemID] then
            -- Use first vendor's NPC name as the source name
            local npcIDs = HDG_VendorByItem[itemData.itemID]
            local vendorName
            for _, npcID in ipairs(npcIDs) do
                local ve = HDG_VendorDB and HDG_VendorDB[npcID]
                if ve and ve[1] then vendorName = ve[1]; break end
            end
            local altLine = BuildSourceLine(ST and ST.VENDOR, vendorName, "Vendor")
            if altLine then
                -- Indent + rotate-icon to chain the synthesized vendor alt
                -- back to its primary source line.
                local prefix = "  " .. CreateAtlasMarkup("common-icon-rotateright", 12, 12) .. "  "
                local fs = AddLeftLine(prefix .. altLine)
                infoFrame._vendorLineFS = fs
            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
                local btn = CreateFrame("Button", nil, infoFrame)
                btn:SetSize(16, 16)
                btn:SetNormalAtlas("128-RedButton-Delete")
                btn:SetPushedAtlas("128-RedButton-Delete-Pressed")
                btn:SetDisabledAtlas("128-RedButton-Delete-Disabled")
                infoFrame.collectedDestroyBtn = btn
            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

        -- Hide destroy button (overlay version on the model viewport)
        if infoFrame.destroyBtn then infoFrame.destroyBtn:Hide() 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 _, tagName in pairs(decorInfo.tags) do
                local cat = GetTagCategory(tagName)
                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

    -- === RIGHT COLUMN ===

    -- Destroyable count + destroy controls (right column)
    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
                    local btn = CreateFrame("Button", nil, overlayParent)
                    btn:SetSize(18, 18)
                    btn:SetNormalAtlas("128-RedButton-Minus")
                    btn:SetPushedAtlas("128-RedButton-Minus-Pressed")
                    btn:SetDisabledAtlas("128-RedButton-Minus-Disabled")
                    infoFrame.destroyMinus = btn
                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
                    local btn = CreateFrame("Button", nil, overlayParent)
                    btn:SetSize(18, 18)
                    btn:SetNormalAtlas("128-RedButton-Plus")
                    btn:SetPushedAtlas("128-RedButton-Plus-Pressed")
                    btn:SetDisabledAtlas("128-RedButton-Plus-Disabled")
                    infoFrame.destroyPlus = btn
                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 (right column). Editable inline; autosaves on focus loss.
    if not infoFrame.noteContainer then
        local container = CreateFrame("Frame", nil, infoFrame, "BackdropTemplate")
        container:SetBackdrop({
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
        })
        -- Gentle gold border, no fill
        container:SetBackdropBorderColor(0.85, 0.72, 0.35, 0.55)
        infoFrame.noteContainer = container

        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

        -- Subtle gold underline beneath the label, matching border tone
        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) -- white note text
        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 to focus the editbox (the scroll
        -- frame doesn't always forward clicks to the editbox by default).
        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
    end
    -- Anchor each refresh: align top with the metadata band (below the name
    -- divider) so it doesn't ride up over the title.
    infoFrame.noteContainer:ClearAllPoints()
    infoFrame.noteContainer:SetPoint("TOPLEFT", infoFrame, "TOP", 8, -38)
    infoFrame.noteContainer:SetPoint("BOTTOMRIGHT", infoFrame, "BOTTOMRIGHT", -8, 8)
    -- Match editbox width to scroll content width (deferred since SetPoint
    -- only resolves dimensions on the next layout pass).
    infoFrame.noteContainer.editBox:SetWidth(infoFrame.noteContainer.scroll:GetWidth() - 18)
    if itemData.itemID then
        local note = HDG.Store and HDG.Store.GetUserNote and HDG.Store:GetUserNote(itemData.itemID)
        infoFrame.noteContainer.editBox._currentItemID = itemData.itemID
        infoFrame.noteContainer.editBox:SetText(note and note.text or "")
        infoFrame.noteContainer:Show()
    else
        infoFrame.noteContainer:Hide()
    end

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

-- 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 Unreleased checkbox (default on — hides items with ver > client build)
    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

    -- Show Only Unreleased checkbox (inverse — sneak peek of upcoming items).
    -- Mutually exclusive with Hide Unreleased: toggling one off the other.
    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

    releasedCheck.onChanged = function(checked)
        HDG_DecorPreview.hideUnreleased = checked
        if checked and HDG_DecorPreview.showOnlyUnreleased then
            HDG_DecorPreview.showOnlyUnreleased = false
            onlyUnreleasedCheck:SetChecked(false)
        end
        knownDecorIDs = nil
        HDG_DecorPreview:RefreshList()
    end
    onlyUnreleasedCheck.onChanged = function(checked)
        HDG_DecorPreview.showOnlyUnreleased = checked
        if checked and HDG_DecorPreview.hideUnreleased then
            HDG_DecorPreview.hideUnreleased = false
            releasedCheck:SetChecked(false)
        end
        knownDecorIDs = nil
        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")
    if titleScheme then
        title:SetTextColor(titleScheme.text_header.r, titleScheme.text_header.g, titleScheme.text_header.b, titleScheme.text_header.a)
    end
    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

    -- Hoisted onto HDG_DecorPreview so RefreshTagFilters (different scope) can
    -- still reach it if needed. Refreshes Unknown + Stored header button visuals.
    -- (All Decor / Crafted / House XP live in the "All" sub-tag row and manage
    --  their own active state via HDG_UIHelpers.UpdateChipButtonState.)
    HDG_DecorPreview._UpdateHeaderToggles = function()
        local scheme = GetScheme()
        local activeR, activeG, activeB = 1, 1, 1
        local dimR, dimG, dimB = 0.5, 0.5, 0.5
        if scheme then
            activeR, activeG, activeB = scheme.text_header.r, scheme.text_header.g, scheme.text_header.b
            dimR, dimG, dimB = scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b
        end
        if previewPanel.uncollectedBtn then
            local uFS = previewPanel.uncollectedBtn:GetFontString()
            uFS:SetTextColor(HDG_DecorPreview.uncollectedOnly and activeR or dimR, HDG_DecorPreview.uncollectedOnly and activeG or dimG, HDG_DecorPreview.uncollectedOnly and activeB or dimB)
        end
        if previewPanel.uncollectedEye then
            local r, g, b = HDG_DecorPreview.uncollectedOnly and activeR or dimR, HDG_DecorPreview.uncollectedOnly and activeG or dimG, HDG_DecorPreview.uncollectedOnly and activeB or dimB
            previewPanel.uncollectedEye:SetAtlas(HDG_DecorPreview.uncollectedOnly and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
            previewPanel.uncollectedEye:SetVertexColor(r, g, b)
        end
        if previewPanel.destroyableBtn then
            local dFS = previewPanel.destroyableBtn:GetFontString()
            dFS:SetTextColor(HDG_DecorPreview.destroyableOnly and activeR or dimR, HDG_DecorPreview.destroyableOnly and activeG or dimG, HDG_DecorPreview.destroyableOnly and activeB or dimB)
        end
        if previewPanel.destroyableEye then
            local r, g, b = HDG_DecorPreview.destroyableOnly and activeR or dimR, HDG_DecorPreview.destroyableOnly and activeG or dimG, HDG_DecorPreview.destroyableOnly and activeB or dimB
            previewPanel.destroyableEye:SetAtlas(HDG_DecorPreview.destroyableOnly and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
            previewPanel.destroyableEye:SetVertexColor(r, g, b)
        end
    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).

    local uncollectedLabel = L["STATUS_UNCOLLECTED"] or "Unknown"
    local uncollectedBtn = HDG.UI:Button(categoryRow, uncollectedLabel, 100, 20, 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)
    uncollectedBtn:SetPoint("RIGHT", categoryRow, "RIGHT", -104, 1)
    uncollectedBtn:HookScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(L["TIP_UNCOLLECTED_ONLY"] or "Uncollected Only", 1, 0.82, 0.3)
        GameTooltip:AddLine(L["TIP_HIDE_COLLECTED"] or "Hide items you've already collected", 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    uncollectedBtn:HookScript("OnLeave", function() GameTooltip:Hide() end)
    local uncollectedEye = uncollectedBtn:CreateTexture(nil, "OVERLAY")
    uncollectedEye:SetSize(28, 28)
    uncollectedEye:SetPoint("RIGHT", uncollectedBtn, "RIGHT", 2, 0)
    uncollectedEye:SetAtlas(HDG_DecorPreview.uncollectedOnly and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
    previewPanel.uncollectedEye = uncollectedEye
    local uFS = uncollectedBtn:GetFontString()
    uFS:ClearAllPoints()
    uFS:SetPoint("LEFT", 4, 0)
    uFS:SetPoint("RIGHT", uncollectedEye, "LEFT", -2, 0)
    uFS:SetJustifyH("LEFT")
    previewPanel.uncollectedBtn = uncollectedBtn

    local destroyableBtn = HDG.UI:Button(categoryRow, "Stored", 100, 20, 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)
    destroyableBtn:SetPoint("RIGHT", categoryRow, "RIGHT", 0, 1)
    destroyableBtn:HookScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(L["TIP_STORED"], 1, 0.82, 0.3)
        GameTooltip:AddLine(L["TIP_STORED_FILTER_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    destroyableBtn:HookScript("OnLeave", function() GameTooltip:Hide() end)
    local storedIcon = destroyableBtn:CreateTexture(nil, "OVERLAY")
    storedIcon:SetAtlas("transmog-icon-remove")
    storedIcon:SetSize(14, 14)
    storedIcon:SetPoint("LEFT", destroyableBtn, "LEFT", 4, 0)
    local destroyableEye = destroyableBtn:CreateTexture(nil, "OVERLAY")
    destroyableEye:SetSize(28, 28)
    destroyableEye:SetPoint("RIGHT", destroyableBtn, "RIGHT", 2, 0)
    destroyableEye:SetAtlas(HDG_DecorPreview.destroyableOnly and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
    previewPanel.destroyableEye = destroyableEye
    local dFS = destroyableBtn:GetFontString()
    dFS:ClearAllPoints()
    dFS:SetPoint("LEFT", storedIcon, "RIGHT", 2, 0)
    dFS:SetPoint("RIGHT", destroyableEye, "LEFT", -2, 0)
    dFS:SetJustifyH("LEFT")
    previewPanel.destroyableBtn = destroyableBtn

    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()
    if listScheme then
        listPanel:SetBackdropColor(listScheme.bg.r, listScheme.bg.g, listScheme.bg.b, listScheme.bg.a)
        RegisterWidget(listPanel, "Background")
    else
        listPanel:SetBackdropColor(0.08, 0.08, 0.1, 0.95)
    end

    -- 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
    headerCraftableFrame:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:AddLine(L["TIP_CRAFTABLE"], 1, 0.82, 0.3)
        GameTooltip:AddLine(L["TIP_CRAFTABLE_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(HDG.Theme.C_Accent() .. L["TIP_STAR_KNOWN_YOU"] .. "|r", unpack(CONFIG.COLORS.TEXT_INACTIVE))
        GameTooltip:AddLine(HDG.Theme.C_Dim() .. L["TIP_STAR_KNOWN_ALT"] .. "|r", unpack(CONFIG.COLORS.TEXT_INACTIVE))
        GameTooltip:AddLine(HDG.Theme.C_Dim() .. L["TIP_CHECK_NOT_KNOWN"] .. "|r", unpack(CONFIG.COLORS.TEXT_INACTIVE))
        GameTooltip:AddLine(HDG.Theme.C_Dim() .. (L["TIP_X_NOT_CRAFTABLE"] or "Empty = Not a recipe (vendor / drop / event)") .. "|r", unpack(CONFIG.COLORS.TEXT_INACTIVE))
        GameTooltip:Show()
    end)
    headerCraftableFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- 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
    headerCollectedFrame:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:AddLine(L["STATUS_COLLECTED"], 1, 0.82, 0.3)
        GameTooltip:AddLine(L["TIP_COLLECTED_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(HDG.Theme.C_Success() .. L["TIP_GREEN_COLLECTED"] .. "|r", unpack(CONFIG.COLORS.TEXT_INACTIVE))
        GameTooltip:AddLine(HDG.Theme.C_Dim() .. L["TIP_GRAY_NOT_COLLECTED"] .. "|r", unpack(CONFIG.COLORS.TEXT_INACTIVE))
        GameTooltip:Show()
    end)
    headerCollectedFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)

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

            -- OnClick: right-click toggles favorite; shift+left-click adds to shopping list;
            -- normal left-click selects + preview.
            frame:SetScript("OnClick", function(self, button)
                if button == "RightButton" then
                    if self.itemData and self.itemData.itemID and HDG.Favorites then
                        local newState = HDG.Favorites.Toggle(self.itemData.itemID)
                        if frame.favoriteStar then
                            if newState then frame.favoriteStar:Show()
                            else frame.favoriteStar:Hide() end
                        end
                        if HDG.Store and HDG.Store.QueueSave then HDG.Store:QueueSave() end
                    end
                    return
                end
                if IsShiftKeyDown() and self.itemData and HDG.Store then
                    local itemID = self.itemData.itemID
                    if itemID then
                        local localizedName = HDG_Data.GetLocalizedName(itemID, self.itemData.name) or self.itemData.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
                    else
                        print(HDG.Theme.C_Warning() .. "[HDG]|r " .. (L["PREVIEW_NO_ITEM_ID"] or "Could not find item ID for this decor"))
                    end
                else
                    local clickedItem = self.itemData
                    selectedItemKey = ItemKey(clickedItem)
                    HDG_DecorPreview:Refresh()
                    HDG_DecorPreview:UpdateModelAndInfo(clickedItem)
                end
            end)

            -- 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()
                    if hoverScheme then
                        self.bg:SetColorTexture(hoverScheme.button_hover.r, hoverScheme.button_hover.g, hoverScheme.button_hover.b, 0.8)
                    else
                        self.bg:SetColorTexture(0.15, 0.15, 0.2, 0.8)
                    end
                    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 and #self.itemData.tags > 0 then
                        GameTooltip:AddLine(" ")
                        GameTooltip:AddLine(string.format(L["TIP_TAGS_PREFIX"], table.concat(self.itemData.tags, ", ")), 0.6, 0.6, 0.8)
                    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

        -- Zebra striping baseColor stored for hover-exit restore
        local baseColor
        if index % 2 == 0 then
            baseColor = scheme and {scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.6} or {0.1, 0.1, 0.12, 0.6}
        else
            baseColor = scheme and {scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.6} or {0.08, 0.08, 0.1, 0.6}
        end
        frame.baseColor = baseColor

        -- Selection highlight
        local isSelected = (selectedItemKey == ItemKey(elementData))
        if isSelected then
            if scheme then
                frame.bg:SetColorTexture(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.8)
            else
                frame.bg:SetColorTexture(0.2, 0.4, 0.6, 0.8)
            end
        else
            frame.bg:SetColorTexture(baseColor[1], baseColor[2], baseColor[3], baseColor[4])
        end

        -- Icon + name + quality color
        local cachedName, _, quality, itemIcon, colorPrefix
        if elementData.itemID then
            cachedName, _, quality, itemIcon, colorPrefix = GetCachedItemInfo(elementData.itemID)
        end
        if itemIcon then
            frame.icon:SetTexture(itemIcon)
        elseif elementData.iconTexture then
            frame.icon:SetTexture(elementData.iconTexture)
        else
            frame.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
            if elementData.itemID then C_Item.RequestLoadItemDataByID(elementData.itemID) end
        end

        -- Favorite star
        if elementData.itemID and HDG.Favorites and HDG.Favorites.IsFavorited(elementData.itemID) then
            frame.favoriteStar:Show()
        else
            frame.favoriteStar:Hide()
        end

        local itemName = elementData.itemID and HDG_Data.GetLocalizedName(elementData.itemID, elementData.name) or elementData.name or "Unknown"
        -- Strip "(Dyed)" suffix — dyed status is now shown via inline color droplets after the name.
        itemName = itemName:gsub("%s*%(Dyed%)$", "")
        if colorPrefix then
            frame.nameText:SetText(colorPrefix .. itemName .. "|r")
        else
            frame.nameText:SetText(HDG.Theme.C_Text() .. itemName .. "|r")
        end

        -- Inline dye droplets: position after the rendered name text width.
        for _, d in ipairs(frame.dyeDrops) do d:Hide() end
        if elementData.isDyed and elementData.dyeIDs and C_DyeColor and C_DyeColor.GetDyeColorInfo then
            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

        -- Collected icon
        if isCollected then
            frame.collectedIcon:SetTexture(CONFIG.TEXTURES.CHECKMARK)
            frame.collectedIcon:SetDesaturated(false)
            if scheme then
                frame.collectedIcon:SetVertexColor(scheme.success.r, scheme.success.g, scheme.success.b)
            else
                frame.collectedIcon:SetVertexColor(unpack(CONFIG.COLORS.COLLECTED))
            end
            frame.collectedIcon:SetAlpha(1)
            frame.collectedIcon:Show()
        else
            frame.collectedIcon:Hide()
        end

        -- Destroyable qty overlay (in destroyable filter mode only). Row-level
        -- destroyable indicator was removed; the badge now lives in the detail block.
        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

        -- Craftable status (known / alt-known / unknown / not craftable)
        local isCraftable = elementData.spellID ~= nil
        if isCraftable then
            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
            -- All three "recipe exists" states share the ReputationStar texture
            -- so the column reads as one icon family. Brightness encodes who
            -- knows it: gold = you, silver = alt, faint = nobody.
            frame.craftableIcon:SetTexture("Interface\\COMMON\\ReputationStar")
            frame.craftableIcon:SetTexCoord(0, 0.5, 0, 0.5)
            frame.craftableIcon:SetDesaturated(false)
            frame.craftableIcon:Show()
            if isKnown then
                if scheme then
                    frame.craftableIcon:SetVertexColor(scheme.warning.r, scheme.warning.g, scheme.warning.b)
                else
                    frame.craftableIcon:SetVertexColor(unpack(CONFIG.COLORS.CRAFTABLE))
                end
                frame.craftableIcon:SetAlpha(1)
            elseif isAltKnown then
                if scheme then
                    local altColor = scheme.isLight and scheme.text or scheme.text_dim
                    frame.craftableIcon:SetVertexColor(altColor.r, altColor.g, altColor.b)
                else
                    frame.craftableIcon:SetVertexColor(unpack(CONFIG.COLORS.TEXT_INACTIVE))
                end
                frame.craftableIcon:SetAlpha(0.7)
            else
                -- Recipe exists but no character knows it — faint outline star.
                if scheme then
                    frame.craftableIcon:SetVertexColor(scheme.text.r, scheme.text.g, scheme.text.b)
                else
                    frame.craftableIcon:SetVertexColor(0.7, 0.7, 0.7)
                end
                frame.craftableIcon:SetAlpha(0.25)
            end
        else
            -- Not a recipe (vendor / drop / event). Leave the cell empty so
            -- the column reads cleanly — source category is in the tooltip
            -- and the Acquire tab, not here.
            frame.craftableIcon:Hide()
        end
    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()
    if addBtnScheme then
        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")
    else
        addAllBtn:SetBackdropColor(0.08, 0.35, 0.35, 0.9)
        addAllBtn:SetBackdropBorderColor(0, 0.5, 0.5, 0.8)
    end

    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(BuildRecipeForQueue(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()
        if hScheme then
            self:SetBackdropColor(hScheme.button_hover.r, hScheme.button_hover.g, hScheme.button_hover.b, 1.0)
        else
            self:SetBackdropColor(0.12, 0.45, 0.45, 1.0)
        end
        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()
        if lScheme then
            self:SetBackdropColor(lScheme.button_normal.r, lScheme.button_normal.g, lScheme.button_normal.b, 0.9)
        else
            self:SetBackdropColor(0.08, 0.35, 0.35, 0.9)
        end
        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()
    if modelScheme then
        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")
    else
        modelPanel:SetBackdropColor(unpack(CONFIG.COLORS.PANEL_POPUP))
        modelPanel:SetBackdropBorderColor(unpack(HDG_UIHelpers.BORDER_COLOR))
    end

    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()
    if descScheme then
        descText:SetTextColor(descScheme.text.r, descScheme.text.g, descScheme.text.b, 1.0)
    else
        descText:SetTextColor(unpack(CONFIG.COLORS.TEXT_BODY))
    end
    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()
    if infoScheme then
        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")
    else
        infoPanel:SetBackdropColor(0.08, 0.08, 0.1, 0.9)
        infoPanel:SetBackdropBorderColor(unpack(HDG_UIHelpers.BORDER_COLOR))
    end
    
    -- 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

-- Tag category definitions - map tags to categories (English canonical names)
local TAG_CATEGORIES = {
    Sizes = { "Small", "Medium", "Large", "Tiny", "Huge", "Lavish" },
    Factions = { "Alliance", "Horde", "Draenei", "Orcish", "Nightborne", "Gilnean / Worgen",
                 "Zandalari Troll", "Troll", "Human", "Kul Tiran", "Tauren", "Goblin", "Gnomish",
                 "Bronzebeard Dwarf (Ironforge)", "Dark Iron Dwarf", "Blood Elf", "Pandaren",
                 "Dwarven", "Elven", "Undead", "Earthen (Dornish)", "Dracthyr" },
    Styles = { "Elegant", "Bold", "Simple", "Rugged", "Spooky", "Pirate", "Fae", "Cute", "Cozy",
               "Light", "Magical", "Mechanical", "Nature" },
    Expansions = { "Classic", "The Burning Crusade", "Burning Crusade", "Wrath of the Lich King",
                   "Cataclysm", "Mists of Pandaria", "Warlords of Draenor", "Legion",
                   "Battle for Azeroth", "Shadowlands", "Dragonflight", "The War Within", "Midnight" },
}

-- Reverse lookup: Localized tag name -> English canonical name
-- This allows tags from non-English clients to be categorized correctly
local TAG_TO_ENGLISH = {
    -- Spanish (esES/esMX)
    ["Pequeño"] = "Small", ["Mediano"] = "Medium", ["Grande"] = "Large",
    ["Diminuto"] = "Tiny", ["Enorme"] = "Huge", ["Suntuoso"] = "Lavish",
    ["Alianza"] = "Alliance", ["Horda"] = "Horde", ["Draenei"] = "Draenei",
    ["Orcos"] = "Orcish", ["Natonocturno"] = "Nightborne", ["Gilneanos / Huargen"] = "Gilnean / Worgen",
    ["Gilneanos"] = "Gilnean / Worgen", ["Trol Zandalari"] = "Zandalari Troll", ["Trol"] = "Troll",
    ["Humano"] = "Human", ["Ciudadano de Kul Tiras"] = "Kul Tiran", ["Tauren"] = "Tauren",
    ["Goblin"] = "Goblin", ["Gnomo"] = "Gnomish", ["Enanos"] = "Dwarven",
    ["Enano Barbabronce (Forjaz)"] = "Bronzebeard Dwarf (Ironforge)", ["Enano Hierro Negro"] = "Dark Iron Dwarf",
    ["Elfo de Sangre"] = "Blood Elf", ["Pandaren"] = "Pandaren", ["Élfico"] = "Elven",
    ["No-muerto"] = "Undead", ["Terráneos (dórnicos)"] = "Earthen (Dornish)", ["Dracthyr"] = "Dracthyr",
    ["Elegante"] = "Elegant", ["Audaz"] = "Bold", ["Simple"] = "Simple", ["Rústico"] = "Rugged",
    ["Tétrico"] = "Spooky", ["Pirata"] = "Pirate", ["Feérico"] = "Fae", ["Adorable"] = "Cute",
    ["Acogedor"] = "Cozy", ["Luminoso"] = "Light", ["Mágica"] = "Magical", ["Mecánico"] = "Mechanical",
    ["Naturaleza"] = "Nature",
    -- French (frFR)
    ["Petit"] = "Small", ["Moyen"] = "Medium", ["Grand"] = "Large",
    ["Minuscule"] = "Tiny", ["Énorme"] = "Huge", ["Somptueux"] = "Lavish",
    ["Draeneï"] = "Draenei", ["Orc"] = "Orcish", ["Sacrenuit"] = "Nightborne",
    ["Gilnéen / Worgen"] = "Gilnean / Worgen", ["Troll zandalari"] = "Zandalari Troll",
    ["Humain"] = "Human", ["Kultirassien"] = "Kul Tiran", ["Gnome"] = "Gnomish",
    ["Nain de Forgefer"] = "Bronzebeard Dwarf (Ironforge)", ["Nain sombrefer"] = "Dark Iron Dwarf",
    ["Elfe de sang"] = "Blood Elf", ["Pandarène"] = "Pandaren", ["Nain"] = "Dwarven",
    ["Elfique"] = "Elven", ["Mort-vivant"] = "Undead", ["Terrestre (dornogal)"] = "Earthen (Dornish)",
    ["Élégant"] = "Elegant", ["Audacieux"] = "Bold", ["Rustique"] = "Rugged",
    ["Effrayant"] = "Spooky", ["Faë"] = "Fae", ["Mignon"] = "Cute",
    ["Douillet"] = "Cozy", ["Lumineux"] = "Light", ["Magique"] = "Magical", ["Mécanique"] = "Mechanical",
    ["Nature"] = "Nature",
    -- German (deDE)
    ["Klein"] = "Small", ["Mittel"] = "Medium", ["Groß"] = "Large",
    ["Winzig"] = "Tiny", ["Riesig"] = "Huge", ["Üppig"] = "Lavish",
    ["Allianz"] = "Alliance", ["Orkisch"] = "Orcish", ["Nachtgeborene"] = "Nightborne",
    ["Gilnäisch / Worgen"] = "Gilnean / Worgen", ["Zandalaritroll"] = "Zandalari Troll",
    ["Mensch"] = "Human", ["Kul Tiraner"] = "Kul Tiran", ["Gnomisch"] = "Gnomish",
    ["Bronzebartzwerg (Eisenschmiede)"] = "Bronzebeard Dwarf (Ironforge)", ["Dunkeleisenzwerg"] = "Dark Iron Dwarf",
    ["Blutelf"] = "Blood Elf", ["Zwergisch"] = "Dwarven", ["Elfisch"] = "Elven",
    ["Untot"] = "Undead", ["Irdene (Dornogal)"] = "Earthen (Dornish)",
    ["Kühn"] = "Bold", ["Robust"] = "Rugged", ["Gruselig"] = "Spooky",
    ["Feenhaft"] = "Fae", ["Niedlich"] = "Cute", ["Gemütlich"] = "Cozy",
    ["Hell"] = "Light", ["Magisch"] = "Magical", ["Mechanisch"] = "Mechanical", ["Natur"] = "Nature",
    -- Portuguese (ptBR)
    ["Pequeno"] = "Small", ["Médio"] = "Medium", ["Minúsculo"] = "Tiny",
    ["Gigante"] = "Huge", ["Luxuoso"] = "Lavish", ["Órquico"] = "Orcish",
    ["Anão Barbabronze (Altaforja)"] = "Bronzebeard Dwarf (Ironforge)", ["Anão Ferro Negro"] = "Dark Iron Dwarf",
    ["Elfo Sangrento"] = "Blood Elf", ["Anão"] = "Dwarven", ["Élfico"] = "Elven",
    ["Morto-vivo"] = "Undead", ["Terreno (Dornogal)"] = "Earthen (Dornish)",
    ["Ousado"] = "Bold", ["Rústico"] = "Rugged", ["Assustador"] = "Spooky",
    ["Feérico"] = "Fae", ["Fofo"] = "Cute", ["Aconchegante"] = "Cozy",
    ["Claro"] = "Light", ["Mágico"] = "Magical", ["Mecânico"] = "Mechanical", ["Natureza"] = "Nature",
    -- Italian (itIT)
    ["Piccolo"] = "Small", ["Medio"] = "Medium", ["Minuscolo"] = "Tiny",
    ["Immenso"] = "Huge", ["Sontuoso"] = "Lavish", ["Alleanza"] = "Alliance", ["Orda"] = "Horde",
    ["Orchesco"] = "Orcish", ["Nobile Oscuro"] = "Nightborne", ["Gilneano / Worgen"] = "Gilnean / Worgen",
    ["Troll Zandalari"] = "Zandalari Troll", ["Umano"] = "Human", ["Kul Tirano"] = "Kul Tiran",
    ["Gnomesco"] = "Gnomish", ["Nano Barbabronzea (Ironforge)"] = "Bronzebeard Dwarf (Ironforge)",
    ["Nano Ferroscuro"] = "Dark Iron Dwarf", ["Elfo del Sangue"] = "Blood Elf", ["Nanesco"] = "Dwarven",
    ["Elfico"] = "Elven", ["Non Morto"] = "Undead", ["Terrigeno (Dornogal)"] = "Earthen (Dornish)",
    ["Coraggioso"] = "Bold", ["Grezzo"] = "Rugged", ["Spaventoso"] = "Spooky",
    ["Fatato"] = "Fae", ["Carino"] = "Cute", ["Accogliente"] = "Cozy",
    ["Luminoso"] = "Light", ["Magico"] = "Magical", ["Meccanico"] = "Mechanical", ["Natura"] = "Nature",
    -- Russian (ruRU)
    ["Маленький"] = "Small", ["Средний"] = "Medium", ["Большой"] = "Large",
    ["Крошечный"] = "Tiny", ["Огромный"] = "Huge", ["Роскошный"] = "Lavish",
    ["Альянс"] = "Alliance", ["Орда"] = "Horde", ["Дренейский"] = "Draenei",
    ["Оркский"] = "Orcish", ["Ночнорожденный"] = "Nightborne", ["Воргенский"] = "Gilnean / Worgen",
    ["Зандаларский тролль"] = "Zandalari Troll", ["Тролль"] = "Troll", ["Человеческий"] = "Human",
    ["Кул-тирасский"] = "Kul Tiran", ["Таурен"] = "Tauren", ["Гоблинский"] = "Goblin",
    ["Гномский"] = "Gnomish", ["Дворф Бронзобород"] = "Bronzebeard Dwarf (Ironforge)",
    ["Дворф Черного Железа"] = "Dark Iron Dwarf", ["Эльф крови"] = "Blood Elf", ["Пандарен"] = "Pandaren",
    ["Дворфийский"] = "Dwarven", ["Эльфийский"] = "Elven", ["Нежить"] = "Undead",
    ["Земельник (Дорногал)"] = "Earthen (Dornish)", ["Драктир"] = "Dracthyr",
    ["Элегантный"] = "Elegant", ["Дерзкий"] = "Bold", ["Простой"] = "Simple", ["Грубый"] = "Rugged",
    ["Жуткий"] = "Spooky", ["Пиратский"] = "Pirate", ["Волшебный"] = "Fae", ["Милый"] = "Cute",
    ["Уютный"] = "Cozy", ["Светлый"] = "Light", ["Магический"] = "Magical", ["Механический"] = "Mechanical",
    ["Природный"] = "Nature",
    -- Korean (koKR)
    ["작은"] = "Small", ["중간"] = "Medium", ["큰"] = "Large",
    ["아주 작은"] = "Tiny", ["거대한"] = "Huge", ["호화로운"] = "Lavish",
    ["얼라이언스"] = "Alliance", ["호드"] = "Horde",
    -- Chinese Simplified (zhCN)
    ["小型"] = "Small", ["中型"] = "Medium", ["大型"] = "Large",
    ["微型"] = "Tiny", ["巨型"] = "Huge", ["奢华"] = "Lavish",
    ["联盟"] = "Alliance", ["部落"] = "Horde", ["德莱尼"] = "Draenei",
    ["兽人"] = "Orcish", ["夜之子"] = "Nightborne", ["吉尔尼斯/狼人"] = "Gilnean / Worgen",
    ["赞达拉巨魔"] = "Zandalari Troll", ["巨魔"] = "Troll", ["人类"] = "Human",
    ["库尔提拉斯"] = "Kul Tiran", ["牛头人"] = "Tauren", ["地精"] = "Goblin",
    ["侏儒"] = "Gnomish", ["铜须矮人(铁炉堡)"] = "Bronzebeard Dwarf (Ironforge)",
    ["黑铁矮人"] = "Dark Iron Dwarf", ["血精灵"] = "Blood Elf", ["熊猫人"] = "Pandaren",
    ["矮人"] = "Dwarven", ["精灵"] = "Elven", ["亡灵"] = "Undead",
    ["土灵(多恩诺加)"] = "Earthen (Dornish)", ["龙希尔"] = "Dracthyr",
    ["优雅"] = "Elegant", ["大胆"] = "Bold", ["简约"] = "Simple", ["粗犷"] = "Rugged",
    ["恐怖"] = "Spooky", ["海盗"] = "Pirate", ["仙灵"] = "Fae", ["可爱"] = "Cute",
    ["舒适"] = "Cozy", ["明亮"] = "Light", ["魔法"] = "Magical", ["机械"] = "Mechanical",
    ["自然"] = "Nature",
    -- Chinese Traditional (zhTW)
    ["小型"] = "Small", ["中型"] = "Medium", ["大型"] = "Large",
    ["微型"] = "Tiny", ["巨型"] = "Huge", ["奢華"] = "Lavish",
    ["聯盟"] = "Alliance", ["部落"] = "Horde", ["乾淨俐落"] = "Simple",
}

-- 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",
}

-- Helper: Normalize localized tag to English canonical name
local function NormalizeTagToEnglish(tagName)
    if not tagName then return nil end
    -- Check reverse lookup first
    if TAG_TO_ENGLISH[tagName] then
        return TAG_TO_ENGLISH[tagName]
    end
    -- Already English or unknown - return as-is
    return tagName
end

-- O(1) reverse lookup: English tag name → category (built once)
local TAG_CATEGORY_LOOKUP = {}
for category, tagList in pairs(TAG_CATEGORIES) do
    for _, tag in ipairs(tagList) do
        TAG_CATEGORY_LOOKUP[tag] = category
    end
end

-- Assign to the forward-declared local at file head (used by UpdateInfoPane).
GetTagCategory = function(tagName)
    local englishTag = NormalizeTagToEnglish(tagName)
    return TAG_CATEGORY_LOOKUP[englishTag] or "Other"
end

-- 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
    local categoryDefs = {
        { 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" },
    }

    if not categoryButtonsCreated then
        categoryButtonsCreated = true
        previewPanel.categoryButtons = {}
        local xOffset = 38

        for _, catDef in ipairs(categoryDefs) 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
        local maxWidth = 780

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

                if xOffset + btnWidth > maxWidth 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)

                btn:SetScript("OnEnter", function(self)
                    GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
                    GameTooltip:AddLine(profName, 1, 0.82, 0.3)
                    GameTooltip:AddLine(string.format(L["TIP_N_RECIPES"], count), 0.7, 0.7, 0.7)
                    GameTooltip:Show()
                end)
                btn:SetScript("OnLeave", function(_)
                    GameTooltip:Hide()
                end)

                table.insert(previewPanel.tagButtons, btn)
                xOffset = xOffset + btnWidth + 2
            end
        end
    else
        -- Standard tag categories
        local xOffset = 38
        local maxWidth = 780

        local categoryTagList = {}
        for _, tagData in ipairs(tags) do
            local cat = GetTagCategory(tagData.name)
            if cat == selectedCategory then
                table.insert(categoryTagList, { name = tagData.name, count = tagData.count })
            end
        end

        if selectedCategory == "Expansions" then
            table.sort(categoryTagList, function(a, b)
                local orderA = HDG_ExpansionData.GetSortOrder(NormalizeTagToEnglish(a.name)) or 99
                local orderB = HDG_ExpansionData.GetSortOrder(NormalizeTagToEnglish(b.name)) or 99
                return orderA < orderB
            end)
        else
            table.sort(categoryTagList, function(a, b) return a.count > b.count end)
        end

        for _, tagData in ipairs(categoryTagList) do
            local tagName = tagData.name
            local count = tagData.count
            if count and count > 0 then
                local englishTag = NormalizeTagToEnglish(tagName)
                local displayName = tagName
                if selectedCategory == "Expansions" then
                    local short = HDG_ExpansionData.GetShortName(englishTag)
                    if short then displayName = short end
                elseif FACTION_TAG_SHORT[englishTag] then
                    displayName = FACTION_TAG_SHORT[englishTag]
                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 > maxWidth then break end

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

                -- Apply expansion color to button text
                if selectedCategory == "Expansions" then
                    local expColor = HDG_ExpansionData.GetColor(englishTag)
                    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
                local tooltipR, tooltipG, tooltipB = 1, 0.82, 0.3
                if selectedCategory == "Expansions" then
                    local expColor = HDG_ExpansionData.GetColor(englishTag)
                    if expColor then tooltipR, tooltipG, tooltipB = expColor[1], expColor[2], expColor[3] end
                end
                btn:SetScript("OnEnter", function(self)
                    GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
                    GameTooltip:AddLine(tagName, tooltipR, tooltipG, tooltipB)
                    GameTooltip:AddLine(string.format(L["TIP_N_ITEMS"], count), 0.7, 0.7, 0.7)
                    GameTooltip:Show()
                end)
                btn:SetScript("OnLeave", function(_)
                    GameTooltip:Hide()
                end)

                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, exemptCount = 0, 0
        if C_HousingCatalog and C_HousingCatalog.GetDecorTotalOwnedCount then
            totalOwned, exemptCount = 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()
    if headerScheme then
        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
    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_Settings then
        HDG_Settings.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

    -- Get item name from WoW API
    local itemName = C_Item.GetItemNameByID(itemID)
    if itemName 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
