-- 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 decorItemsCacheTime = 0
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
local DECOR_CACHE_TTL = 60  -- seconds

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

-- Row pool for item list (optimization: reuse frames instead of creating new ones)
local rowPool

-- 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 instance cache (cleared on HOUSING_STORAGE_UPDATED)
local destroyableCache = {}
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 cached = destroyableCache[decorID]
    if cached then return cached[1] end
    local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID, 1, decorID, true)
    local d = (ok and info and info.destroyableInstanceCount) or 0
    local s = (ok and info and info.numStored) or 0
    local p = (ok and info and info.numPlaced) or 0
    destroyableCache[decorID] = {d, s, p}
    return d
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.professionFilter = HDG_DecorPreview.professionFilter
    f.uncollectedOnly = HDG_DecorPreview.uncollectedOnly or false
    f.destroyableOnly = HDG_DecorPreview.destroyableOnly 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.knownDecorIDs = f.hideUnreleased and GetKnownDecorIDs() or nil
    f.now = f.hideUnreleased and time() or 0
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

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

    -- Dyed category filter
    if f.selectedCategory == "Dyed" then
        if not itemData.isDyed 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

    -- Hide unreleased
    if f.hideUnreleased then
        if itemData.name == "Unreleased" then return false end
        if itemData.decorID and f.knownDecorIDs then
            local addedTS = f.knownDecorIDs[itemData.decorID]
            if not addedTS or addedTS > f.now then return false end
        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
    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)
local collectedDecorCache = nil
local collectedDecorCacheTime = 0
local collectionSearchInProgress = false
local activeCatalogSearcher = nil -- prevent GC before callback fires
local dyeVariantsAppended = false -- guard: only run dye variant search once per cache

-- Get CACHE_TTL from CONFIG (accessed after init)
local function GetCacheTTL()
    return (CONFIG and CONFIG.PREVIEW_TAB and CONFIG.PREVIEW_TAB.CACHE_TTL) or 300
end

-- 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
        for spellID, recipe in pairs(HDG_DecorDB) do
            if recipe.decorID then
                decorToRecipeCache[recipe.decorID] = {
                    itemID = recipe.itemID,
                    spellID = spellID,
                    profession = recipe.profession,
                    expansion = recipe.expansion,
                    reagents = recipe.reagents,
                    name = recipe.name,
                }
            end
        end
    end
    return decorToRecipeCache
end

-- Process search results into item list
-- Note: C_HousingCatalog API does NOT return itemID - we use name/iconTexture/recordID instead
local function ProcessSearchResults(catalogSearcher)
    local items = {}

    -- Try GetCatalogSearchResults first (Blizzard's method), fall back to GetAllSearchItems
    local allItems = nil
    if catalogSearcher.GetCatalogSearchResults then
        allItems = catalogSearcher:GetCatalogSearchResults()
    end
    if not allItems or #allItems == 0 then
        if catalogSearcher.GetAllSearchItems then
            allItems = catalogSearcher:GetAllSearchItems()
        end
    end
    if not allItems then
        return items
    end

    local decorToRecipe = GetDecorToRecipe()

    -- Process each entry - handle both entryID objects and raw results
    for _, entry in ipairs(allItems) do
        local entryInfo = nil
        local recordID = nil

        -- Determine entry format and get entryInfo
        if entry and entry.recordID then
            -- Entry is an entryID object (from GetAllSearchItems or GetCatalogSearchResults)
            recordID = entry.recordID
            -- Try GetCatalogEntryInfo first (Blizzard's method), fall back to GetCatalogEntryInfoByRecordID
            local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfo, entry)
            if ok and info then
                entryInfo = info
            else
                local entryType = entry.entryType or 1
                ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID, entryType, recordID, true)
                if ok and info then
                    entryInfo = info
                end
            end
        end

        if entryInfo and entryInfo.name and recordID then
            -- Dye detection: subtypeIdentifier ~= 0 is the primary signal for dyed variants
            local subtypeId = entry.subtypeIdentifier or 0
            local isDyed = subtypeId ~= 0
                or (entryInfo.customizations and #entryInfo.customizations > 0)
                or (entryInfo.dyeIDs and #entryInfo.dyeIDs > 0)


            -- Get tags
            local itemTags = {}
            if entryInfo.dataTagsByID then
                for _, tagName in pairs(entryInfo.dataTagsByID) do
                    table.insert(itemTags, tagName)
                end
            end
            if isDyed then
                table.insert(itemTags, "Dyed")
            end

            -- Get category info from API
            local categoryName = nil
            local subcategoryName = nil
            local categoryID = nil
            local subcategoryID = nil
            if entryInfo.categoryIDs and entryInfo.categoryIDs[1] then
                categoryID = entryInfo.categoryIDs[1]
                categoryName = GetCachedCategoryName(categoryID) or nil
            end
            if entryInfo.subcategoryIDs and entryInfo.subcategoryIDs[1] then
                subcategoryID = entryInfo.subcategoryIDs[1]
                subcategoryName = GetCachedSubcategoryName(subcategoryID) or nil
            end

            -- Check if this is a craftable recipe from our database
            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  -- nil for non-craftable
            local expansion = recipeData and recipeData.expansion or "Unknown"
            local spellID = recipeData and recipeData.spellID or nil
            local reagents = recipeData and recipeData.reagents or nil

            -- Display name: append "(Dyed)" like Blizzard does
            local displayName = entryInfo.name
            if isDyed then
                displayName = displayName .. " (Dyed)"
            end

            table.insert(items, {
                itemID = itemID,  -- May be nil for non-craftable items
                decorID = recordID,
                subtypeId = subtypeId, -- unique per dyed variant (0=base)
                name = displayName,
                englishName = recipeData and recipeData.name or nil,  -- English name from DB for search
                iconTexture = entryInfo.iconTexture,  -- Use API icon when no itemID
                profession = profession,  -- nil for non-craftable items
                expansion = expansion,
                spellID = spellID,
                reagents = reagents,
                tags = itemTags,
                categoryName = categoryName,
                subcategoryName = subcategoryName,
                categoryID = categoryID,
                subcategoryID = subcategoryID,
                isCatalogItem = not isCraftable,
                isDyed = isDyed,
                dyeNames = isDyed and entryInfo.customizations or nil,
                canCustomize = entryInfo.canCustomize,
            })
        end
    end

    return items
end

-- Trigger async search for ALL decor (results cached for future calls)
-- Uses Blizzard's catalog API to get all decor items regardless of collection status
local function TriggerAllDecorSearch()
    if collectionSearchInProgress then
        return
    end
    if not C_HousingCatalog or not C_HousingCatalog.CreateCatalogSearcher then
        return
    end

    collectionSearchInProgress = true
    dyeVariantsAppended = false -- reset so dye search runs fresh

    local ok, catalogSearcher = pcall(C_HousingCatalog.CreateCatalogSearcher)
    if not ok or not catalogSearcher then
        collectionSearchInProgress = false
        return
    end
    activeCatalogSearcher = catalogSearcher -- prevent GC before callback fires

    -- Configure searcher for ALL decor items (not just owned)
    -- This matches Blizzard's HousingCatalogFrameMixin approach
    -- Wrap in pcall in case API methods changed
    local configOk = pcall(function()
        if catalogSearcher.SetOwnedOnly then
            catalogSearcher:SetOwnedOnly(false)  -- Get ALL items, not just owned
        end
        if catalogSearcher.SetCollected then
            catalogSearcher:SetCollected(true)   -- Include collected
        end
        if catalogSearcher.SetUncollected then
            catalogSearcher:SetUncollected(true) -- Include uncollected
        end
        -- Filter to BasicDecor mode to exclude rooms (like Blizzard's catalog does)
        if catalogSearcher.SetEditorModeContext and Enum and Enum.HouseEditorMode then
            catalogSearcher:SetEditorModeContext(Enum.HouseEditorMode.BasicDecor)
        end
        -- Don't include market entries (cash shop items)
        if catalogSearcher.SetIncludeMarketEntries then
            catalogSearcher:SetIncludeMarketEntries(false)
        end
    end)

    if not configOk then
        collectionSearchInProgress = false
        activeCatalogSearcher = nil
        if catalogSearcher.Release then
            pcall(catalogSearcher.Release, catalogSearcher)
        end
        return
    end

    -- Set callback for when results are ready
    local callbackOk = pcall(function()
        catalogSearcher:SetResultsUpdatedCallback(function()
            local items = ProcessSearchResults(catalogSearcher)

            -- Update cache and build O(1) index
            collectedDecorCache = items
            collectedDecorCacheTime = time()
            collectionSearchInProgress = false
            decorItemsByKey = {}
            for _, item in ipairs(items) do
                if item.decorID then decorItemsByKey[ItemKey(item)] = item end
            end

            -- Release searcher
            activeCatalogSearcher = nil
            if catalogSearcher.Release then
                pcall(catalogSearcher.Release, catalogSearcher)
            end

            -- Chain a secondary owned-only search to discover dyed variants (once per cache)
            if dyeVariantsAppended then
                -- Already appended — just refresh
                if HDG_DecorPreview.RefreshList then HDG_DecorPreview:RefreshList() end
                if HDG_DecorPreview.RefreshTagFilters then HDG_DecorPreview:RefreshTagFilters() end
                return
            end

            -- Only check items we know are dyeable (canCustomize) from the first search
            local dyeableDecorIDs = {}
            for _, item in ipairs(items) do
                if item.canCustomize then dyeableDecorIDs[item.decorID] = true end
            end

            if next(dyeableDecorIDs) then
                -- Build reverse lookup: decorID → itemID (once, not per-entry)
                local decorToItemLookup = {}
                if HDG_AllDecorDB then
                    for iid, adEntry in pairs(HDG_AllDecorDB) do
                        if adEntry[1] then decorToItemLookup[adEntry[1]] = iid end
                    end
                end

                local ok2, ownedSearcher = pcall(C_HousingCatalog.CreateCatalogSearcher)
                if ok2 and ownedSearcher then
                    if ownedSearcher.SetOwnedOnly then ownedSearcher:SetOwnedOnly(true) end
                    if ownedSearcher.SetEditorModeContext and Enum and Enum.HouseEditorMode then
                        ownedSearcher:SetEditorModeContext(Enum.HouseEditorMode.BasicDecor)
                    end
                    if ownedSearcher.SetIncludeMarketEntries then ownedSearcher:SetIncludeMarketEntries(false) end
                    ownedSearcher:SetResultsUpdatedCallback(function()
                        local ownedResults = ownedSearcher:GetCatalogSearchResults()
                        if ownedResults then
                            local existingKeys = {}
                            for _, item in ipairs(collectedDecorCache) do
                                existingKeys[item.decorID .. "_" .. (item.subtypeId or 0)] = true
                            end
                            for _, entry in ipairs(ownedResults) do
                                local stId = entry.subtypeIdentifier or 0
                                -- Only process dyed variants of dyeable items
                                if stId ~= 0 and dyeableDecorIDs[entry.recordID] then
                                    local key = entry.recordID .. "_" .. stId
                                    if not existingKeys[key] then
                                        local eOk, eInfo = pcall(C_HousingCatalog.GetCatalogEntryInfo, entry)
                                        if eOk and eInfo and eInfo.name then
                                            local dyeNames = eInfo.customizations or {}
                                            -- Skip if no actual dye data
                                            if #dyeNames > 0 or (eInfo.dyeIDs and #eInfo.dyeIDs > 0) then
                                                local baseItem = decorItemsByKey[entry.recordID .. "_0"]
                                                local newItem = {
                                                    itemID = eInfo.itemID or decorToItemLookup[entry.recordID],
                                                    decorID = entry.recordID,
                                                    subtypeId = stId,
                                                    name = eInfo.name .. " (Dyed)",
                                                    iconTexture = eInfo.iconTexture,
                                                    profession = baseItem and baseItem.profession,
                                                    expansion = baseItem and baseItem.expansion,
                                                    tags = {},
                                                    categoryName = baseItem and baseItem.categoryName,
                                                    subcategoryName = baseItem and baseItem.subcategoryName,
                                                    isCatalogItem = true,
                                                    isDyed = true,
                                                    dyeNames = dyeNames,
                                                    canCustomize = eInfo.canCustomize,
                                                }
                                                if baseItem and baseItem.tags then
                                                    for _, t in ipairs(baseItem.tags) do newItem.tags[#newItem.tags + 1] = t end
                                                end
                                                newItem.tags[#newItem.tags + 1] = "Dyed"
                                                collectedDecorCache[#collectedDecorCache + 1] = newItem
                                                decorItemsByKey[key] = newItem
                                                existingKeys[key] = true
                                            end
                                        end
                                    end
                                end
                            end
                        end
                        dyeVariantsAppended = true
                        if ownedSearcher.Release then pcall(ownedSearcher.Release, ownedSearcher) end
                        if HDG_DecorPreview.RefreshList then HDG_DecorPreview:RefreshList() end
                        if HDG_DecorPreview.RefreshTagFilters then HDG_DecorPreview:RefreshTagFilters() end
                    end)
                    ownedSearcher:RunSearch()
                end
            end

            -- Refresh the UI (initial, before dyed variants)
            if HDG_DecorPreview.RefreshList then
                HDG_DecorPreview:RefreshList()
            end
            if HDG_DecorPreview.RefreshTagFilters then
                HDG_DecorPreview:RefreshTagFilters()
            end
        end)
    end)

    if not callbackOk then
        collectionSearchInProgress = false
        activeCatalogSearcher = nil
        if catalogSearcher.Release then
            pcall(catalogSearcher.Release, catalogSearcher)
        end
        return
    end

    -- Start the async search
    local searchOk = pcall(function()
        catalogSearcher:RunSearch()
    end)

    if not searchOk then
        collectionSearchInProgress = false
        activeCatalogSearcher = nil
        if catalogSearcher.Release then
            pcall(catalogSearcher.Release, catalogSearcher)
        end
        return
    end

    -- Watchdog: if callback hasn't fired in 10s, reset so retry is possible
    C_Timer.After(10, function()
        if collectionSearchInProgress then
            collectionSearchInProgress = false
            if activeCatalogSearcher then
                if activeCatalogSearcher.Release then
                    pcall(activeCatalogSearcher.Release, activeCatalogSearcher)
                end
                activeCatalogSearcher = nil
            end
        end
    end)
end

-- Get all decor items from cache (triggers async refresh if needed)
local function GetAllDecorFromAPI()
    -- Check if cache is valid
    local now = time()
    if collectedDecorCache and (now - collectedDecorCacheTime) < GetCacheTTL() then
        return collectedDecorCache
    end

    -- Trigger async search (will update cache and refresh UI when done)
    TriggerAllDecorSearch()

    -- Return cached data if available (even if stale), or empty list
    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

    -- Check cache first (only for craftable items mode, not API mode)
    local now = GetTime()
    if decorItemsCache and (now - decorItemsCacheTime) < DECOR_CACHE_TTL 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 a table keyed by spellID with recipe data
    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 C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem then
                decorInfo = C_HousingCatalog.GetCatalogEntryInfoByItem(recipe.itemID, true)

                if decorInfo then
                    if decorInfo.categoryIDs and decorInfo.categoryIDs[1] then
                        categoryID = decorInfo.categoryIDs[1]
                        categoryName = GetCachedCategoryName(categoryID) or nil
                    end
                    if decorInfo.subcategoryIDs and decorInfo.subcategoryIDs[1] then
                        subcategoryID = decorInfo.subcategoryIDs[1]
                        subcategoryName = GetCachedSubcategoryName(subcategoryID) or nil
                    end

                    -- Extract tags (size, faction, expansion, style)
                    if decorInfo.dataTagsByID then
                        for _, tagName in pairs(decorInfo.dataTagsByID) 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
    decorItemsCache = items
    decorItemsCacheTime = GetTime()
    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)
function HDG_DecorPreview:InvalidateCache()
    decorItemsCache = nil
    decorItemsCacheTime = 0
    decorItemsByKey = 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)

-- Create or reuse item list entry row (with pooling)
-- 3-column layout: | Icon + Name | Collected | Craftable |
-- scheme + isCollected passed from RefreshList to avoid redundant calls
local function CreateItemListEntry(parent, width, yOffset, itemData, index, scheme, isCollected)
    local row = rowPool:Acquire(parent)

    -- Update row size and position
    row:SetSize(width, CONFIG.PREVIEW_TAB.ITEM_ROW_HEIGHT)
    row:SetPoint("TOPLEFT", 0, yOffset)

    -- Update row data
    row.rowIndex = index
    row.itemData = itemData

    -- Update base color based on index
    local baseColor
    if index % 2 == 0 then
        if scheme then
            baseColor = {scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.6}
        else
            baseColor = {0.1, 0.1, 0.12, 0.6}
        end
    else
        if scheme then
            baseColor = {scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.6}
        else
            baseColor = {0.08, 0.08, 0.1, 0.6}
        end
    end
    row.baseColor = baseColor

    -- Update selection state
    local isSelected = (selectedItemKey == ItemKey(itemData))
    if isSelected then
        if scheme then
            row:SetBackdropColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.8)
        else
            row:SetBackdropColor(0.2, 0.4, 0.6, 0.8)
        end
    else
        row:SetBackdropColor(unpack(baseColor))
    end

    -- Get item info once (name, link, quality, icon, colorPrefix)
    local cachedName, _, quality, itemIcon, colorPrefix
    if itemData.itemID then
        cachedName, _, quality, itemIcon, colorPrefix = GetCachedItemInfo(itemData.itemID)
    end

    -- Update item icon
    if itemIcon then
        row.icon:SetTexture(itemIcon)
    elseif itemData.iconTexture then
        row.icon:SetTexture(itemData.iconTexture)
    else
        row.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
        if itemData.itemID then C_Item.RequestLoadItemDataByID(itemData.itemID) end
    end

    -- Column 1: Item name (with quality color if available)
    local itemName = itemData.itemID and HDG_Data.GetLocalizedName(itemData.itemID, itemData.name) or itemData.name or "Unknown"
    if itemData.isDyed and not itemName:find("%(Dyed%)") then
        itemName = itemName .. " (Dyed)"
    end
    if colorPrefix then
        row.nameText:SetText(colorPrefix .. itemName .. "|r")
    else
        row.nameText:SetText(HDG.Theme.C_Text() .. itemName .. "|r")
    end

    -- Column 2: Collected status (checkmark icon, hidden if not collected)
    if isCollected then
        row.collectedIcon:SetTexture(CONFIG.TEXTURES.CHECKMARK)
        row.collectedIcon:SetDesaturated(false)
        if scheme then
            row.collectedIcon:SetVertexColor(scheme.success.r, scheme.success.g, scheme.success.b)
        else
            row.collectedIcon:SetVertexColor(unpack(CONFIG.COLORS.COLLECTED))
        end
        row.collectedIcon:SetAlpha(1)
        row.collectedIcon:Show()
    else
        row.collectedIcon:Hide()
    end

    -- Column 4: Destroyable status (red X for items with destroyable stored copies)
    local destroyCount = GetDestroyableCount(itemData.decorID)
    if destroyCount > 0 then
        row.destroyableIcon:SetAtlas("transmog-icon-remove")
        row.destroyableIcon:SetAlpha(0.4)
        row.destroyableIcon:Show()
    else
        row.destroyableIcon:Hide()
    end

    -- Quantity overlay on icon (shown in destroyable mode)
    if HDG_DecorPreview.destroyableOnly and destroyCount > 0 then
        row.qtyOverlay:SetText("|cFF00FF00" .. destroyCount .. "|r")
        row.qtyOverlay:Show()
        row.icon:SetAlpha(0.4)
    else
        row.qtyOverlay:Hide()
        row.icon:SetAlpha(1)
    end

    -- Column 3: Craftable status
    -- Check if this item is craftable (has a spellID in our database)
    local isCraftable = itemData.spellID ~= nil
    if isCraftable then
        -- Check if the player knows the recipe
        local isKnown = HDG_Data and HDG_Data.PlayerKnowsRecipeBySpellID and HDG_Data.PlayerKnowsRecipeBySpellID(itemData.spellID)

        -- Check if an alt knows the recipe (only if current char doesn't know it)
        local isAltKnown = false
        if not isKnown and HDG_Data.GetCharactersWhoKnowRecipe then
            local altsWhoKnow = HDG_Data.GetCharactersWhoKnowRecipe(itemData.spellID, itemData.profession)
            isAltKnown = altsWhoKnow and #altsWhoKnow > 0
        end

        if isKnown then
            row.craftableIcon:SetTexture("Interface\\COMMON\\ReputationStar")
            row.craftableIcon:SetTexCoord(0, 0.5, 0, 0.5)
            row.craftableIcon:SetDesaturated(false)
            if scheme then
                row.craftableIcon:SetVertexColor(scheme.warning.r, scheme.warning.g, scheme.warning.b)
            else
                row.craftableIcon:SetVertexColor(unpack(CONFIG.COLORS.CRAFTABLE))
            end
            row.craftableIcon:SetAlpha(1)
        elseif isAltKnown then
            row.craftableIcon:SetTexture("Interface\\COMMON\\ReputationStar")
            row.craftableIcon:SetTexCoord(0, 0.5, 0, 0.5)
            row.craftableIcon:SetDesaturated(false)
            if scheme then
                local altColor = scheme.isLight and scheme.text or scheme.text_dim
                row.craftableIcon:SetVertexColor(altColor.r, altColor.g, altColor.b)
            else
                row.craftableIcon:SetVertexColor(unpack(CONFIG.COLORS.TEXT_INACTIVE))
            end
            row.craftableIcon:SetAlpha(0.8)
        else
            row.craftableIcon:SetTexture(CONFIG.TEXTURES.CHECKMARK)
            row.craftableIcon:SetTexCoord(0, 1, 0, 1)
            row.craftableIcon:SetDesaturated(true)
            if scheme then
                row.craftableIcon:SetVertexColor(scheme.text.r, scheme.text.g, scheme.text.b)
            else
                row.craftableIcon:SetVertexColor(1, 1, 1)
            end
            row.craftableIcon:SetAlpha(0.5)
        end
    else
        row.craftableIcon:SetTexture(CONFIG.TEXTURES.XMARK)
        row.craftableIcon:SetTexCoord(0, 1, 0, 1)
        row.craftableIcon:SetDesaturated(false)
        if scheme then
            local ncColor = scheme.isLight and scheme.text or scheme.text_dim
            row.craftableIcon:SetVertexColor(ncColor.r, ncColor.g, ncColor.b)
        else
            row.craftableIcon:SetVertexColor(0.4, 0.4, 0.4)
        end
        row.craftableIcon:SetAlpha(0.4)
    end

    row:Show()
    return yOffset - CONFIG.PREVIEW_TAB.ITEM_ROW_SPACING
end

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

-- 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 API if missing
    local decorInfo = itemData.decorInfo
    if not decorInfo and itemData.itemID and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem then
        decorInfo = C_HousingCatalog.GetCatalogEntryInfoByItem(itemData.itemID, true)
    end
    if not decorInfo and itemData.decorID and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByRecordID then
        local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID, 1, itemData.decorID, true)
        if ok and info then decorInfo = info end
    end
    
    -- === LEFT COLUMN: Player-interesting info ===

    -- Item name with quality color (if available)
    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
    AddLeftLine(colorPrefix .. localizedDetailName .. "|r")
    
    -- Category > Subcategory
    local catText = itemData.categoryName or "Uncategorized"
    if itemData.subcategoryName then
        catText = catText .. " > " .. itemData.subcategoryName
    end
    AddLeftLine(HDG.Theme.C_Accent() .. catText .. "|r")

    -- Profession (blue label, orange value)
    AddLeftLine(HDG.Theme.C_Accent() .. (L["PREVIEW_INFO_PROFESSION"] or "Profession:") .. "|r " .. HDG.Theme.C_RecipeKnown() .. (itemData.profession or "Unknown") .. "|r")

    if decorInfo then
        -- 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"
            if decorInfo.numStored and decorInfo.numStored > 0 then
                ownedText = ownedText .. " " .. HDG.Theme.C_Dim() .. string.format(L["PREVIEW_INFO_STORED"] or "(Stored: %d)", decorInfo.numStored) .. "|r"
            end
            if decorInfo.numPlaced and decorInfo.numPlaced > 0 then
                ownedText = ownedText .. " " .. HDG.Theme.C_Dim() .. string.format(L["PREVIEW_INFO_PLACED"] or "(Placed: %d)", decorInfo.numPlaced) .. "|r"
            end
            AddLeftLine(ownedText)
        else
            AddLeftLine(HDG.Theme.C_Warning() .. "Not Collected|r")
        end

        -- Hide destroy button (will be shown in right column if needed)
        if infoFrame.destroyBtn then infoFrame.destroyBtn:Hide() end

        -- Placement allowed (blue label, orange/dim values based on state)
        local allowedText = HDG.Theme.C_Accent() .. (L["PREVIEW_INFO_ALLOWED"] or "Allowed:") .. "|r "
        if decorInfo.isAllowedIndoors then
            allowedText = allowedText .. HDG.Theme.C_RecipeKnown() .. (L["PREVIEW_INFO_INDOORS"] or "Indoors") .. "|r "
        else
            allowedText = allowedText .. HDG.Theme.C_Dim() .. (L["PREVIEW_INFO_INDOORS"] or "Indoors") .. "|r "
        end
        if decorInfo.isAllowedOutdoors then
            allowedText = allowedText .. HDG.Theme.C_RecipeKnown() .. (L["PREVIEW_INFO_OUTDOORS"] or "Outdoors") .. "|r"
        else
            allowedText = allowedText .. HDG.Theme.C_Dim() .. (L["PREVIEW_INFO_OUTDOORS"] or "Outdoors") .. "|r"
        end
        AddLeftLine(allowedText)

        -- Tags (blue label, orange values)
        if decorInfo.dataTagsByID then
            local tags = {}
            for _, tagName in pairs(decorInfo.dataTagsByID) do
                table.insert(tags, tagName)
            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

        -- Dyeable status (left column)
        if decorInfo.canCustomize then
            AddLeftLine(HDG.Theme.C_Accent() .. (L["PREVIEW_INFO_DYEABLE"] or "Dyeable:") .. "|r " .. HDG.Theme.C_Success() .. "Yes|r")
        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
            local dPlural = dCount == 1 and "copy" or "copies"
            AddRightLine(HDG.Theme.C_Warning() .. string.format("Stored: %d %s", dCount, dPlural) .. "|r")
            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

                -- Minus button
                if not infoFrame.destroyMinus then
                    local btn = CreateFrame("Button", nil, infoFrame)
                    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:ClearAllPoints()
                infoFrame.destroyMinus:SetPoint("TOPLEFT", midPoint, rightY - 4)
                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 = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                    fs:SetWidth(24)
                    fs:SetJustifyH("CENTER")
                    infoFrame.destroyQtyText = fs
                end
                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, infoFrame)
                    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: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()

                -- Destroy button
                if not infoFrame.destroyBtn then
                    infoFrame.destroyBtn = CreateFrame("Button", nil, infoFrame, "UIPanelButtonTemplate")
                    infoFrame.destroyBtn:SetSize(100, 22)
                end
                infoFrame.destroyBtn:ClearAllPoints()
                infoFrame.destroyBtn:SetPoint("TOPLEFT", midPoint, rightY - 26)
                infoFrame.destroyBtn:SetScript("OnClick", function()
                    ShowDestroyConfirm(localizedDetailName, decorInfo, dCount, destroyQty)
                end)
                infoFrame.destroyBtn:Show()

                UpdateDestroyControls()

                -- Advance rightY past the destroy controls (+/- row + button)
                rightY = rightY - 52
            end

            -- Vendor cost (shown when item is sold by a vendor)
            if 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
                            local vendorName = ve[1] or ("NPC #" .. npcID)
                            if gc > 0 then
                                AddRightLine(HDG.Theme.C_Dim() .. vendorName .. ":|r")
                                AddRightLine(GetCoinTextureString(gc))
                            elseif ie[5] then
                                AddRightLine(HDG.Theme.C_Dim() .. vendorName .. ":|r")
                                for _, ct in ipairs(ie[5]) do
                                    if ct[1] == "c" then
                                        local info = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(ct[2])
                                        local cName = info and info.name or ("Currency " .. ct[2])
                                        local cIcon = info and info.iconFileID
                                        local iconStr = cIcon and string.format("|T%s:14:14|t", cIcon) or ""
                                        AddRightLine(iconStr .. " " .. ct[3] .. " " .. cName)
                                    elseif ct[1] == "i" then
                                        local iName = C_Item.GetItemNameByID(ct[2]) or ("Item " .. ct[2])
                                        AddRightLine(ct[3] .. "x " .. iName)
                                    end
                                end
                            end
                            break -- show first vendor only
                        end
                    end
                end
            end
        end
    end

    -- Cart buttons — add/remove item from vendor shopping list
    if infoFrame.cartBtns then
        infoFrame.cartBtns:Hide()
    end
    if itemData.itemID then
        if not infoFrame.cartBtns then
            infoFrame.cartBtns = HDG_UIHelpers.CreateCartButtons(infoFrame, 36)
        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("BOTTOMRIGHT", infoFrame, "BOTTOMRIGHT", -8, 22)
        infoFrame.cartBtns:Show()
    end

    -- "Find Vendor" button — show for vendor-sourced items (in VendorDB)
    if infoFrame.findVendorBtn then
        infoFrame.findVendorBtn:Hide()
    end
    if itemData.itemID and HDG_VendorByItem and HDG_VendorByItem[itemData.itemID] then
        if not infoFrame.findVendorBtn then
            infoFrame.findVendorBtn = HDG.UI:Button(infoFrame, L["PREVIEW_FIND_VENDOR"] or "Find Vendor", 90, 20, function()
                if infoFrame._findVendorItemID 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(infoFrame._findVendorItemID)
                    end
                end
            end)
        end
        infoFrame._findVendorItemID = itemData.itemID
        infoFrame.findVendorBtn:SetPoint("BOTTOMRIGHT", infoFrame, "BOTTOMRIGHT", -8, 0)
        infoFrame.findVendorBtn:Show()
    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
        -- 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 not in AllDecorDB)
    HDG_DecorPreview.hideUnreleased = true
    local releasedCheck = HDG.UI:Checkbox(headerBg, 14, L["PREVIEW_HIDE_UNRELEASED"] or "Hide Unreleased")
    releasedCheck:SetPoint("RIGHT", headerBg, "RIGHT", -8, 0)
    releasedCheck:SetChecked(true)
    releasedCheck.onChanged = function(checked)
        HDG_DecorPreview.hideUnreleased = checked
        knownDecorIDs = nil -- invalidate cache on toggle
        HDG_DecorPreview:RefreshList()
    end
    previewPanel.releasedCheck = releasedCheck

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

    local collectionLabel = L["STATUS_COLLECTION"] or "All Decor"
    local uncollectedLabel = L["STATUS_UNCOLLECTED"] or "Unknown"

    local function UpdateCollectionToggle()
        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
        local cFS = previewPanel.collectionBtn:GetFontString()
        local uFS = previewPanel.uncollectedBtn:GetFontString()
        cFS:SetTextColor(HDG_DecorPreview.showAllDecor and activeR or dimR, HDG_DecorPreview.showAllDecor and activeG or dimG, HDG_DecorPreview.showAllDecor and activeB or dimB)
        uFS:SetTextColor(HDG_DecorPreview.uncollectedOnly and activeR or dimR, HDG_DecorPreview.uncollectedOnly and activeG or dimG, HDG_DecorPreview.uncollectedOnly and activeB or dimB)
        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
        -- Update eye icons (atlas + vertex color to match text)
        if previewPanel.collectionEye then
            local r, g, b = HDG_DecorPreview.showAllDecor and activeR or dimR, HDG_DecorPreview.showAllDecor and activeG or dimG, HDG_DecorPreview.showAllDecor and activeB or dimB
            previewPanel.collectionEye:SetAtlas(HDG_DecorPreview.showAllDecor and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
            previewPanel.collectionEye:SetVertexColor(r, g, b)
        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.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

    local collectionBtn = HDG.UI:Button(categoryRow, collectionLabel, 100, 20, function()
        HDG_DecorPreview.showAllDecor = not HDG_DecorPreview.showAllDecor
        UpdateCollectionToggle()
        HDG_DecorPreview:RefreshList()
        HDG_DecorPreview:RefreshTagFilters()
    end)
    collectionBtn:SetPoint("RIGHT", categoryRow, "RIGHT", -208, 1)
    collectionBtn:HookScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(L["TIP_MY_COLLECTION"] or "My Collection", 1, 0.82, 0.3)
        GameTooltip:AddLine(L["TIP_SHOW_ALL_DECOR"] or "Show ALL housing decor items from the catalog", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["TIP_NOT_JUST_CRAFTABLE"] or "(not just craftable recipes).", 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    collectionBtn:HookScript("OnLeave", function() GameTooltip:Hide() end)
    local collectionEye = collectionBtn:CreateTexture(nil, "OVERLAY")
    collectionEye:SetSize(28, 28)
    collectionEye:SetPoint("RIGHT", collectionBtn, "RIGHT", 2, 0)
    collectionEye:SetAtlas(HDG_DecorPreview.showAllDecor and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
    previewPanel.collectionEye = collectionEye
    local cFS = collectionBtn:GetFontString()
    cFS:ClearAllPoints()
    cFS:SetPoint("LEFT", 4, 0)
    cFS:SetPoint("RIGHT", collectionEye, "LEFT", -2, 0)
    cFS:SetJustifyH("LEFT")
    previewPanel.collectionBtn = collectionBtn

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

    UpdateCollectionToggle()

    -- 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  -- listScroll has -24 right offset, plus 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: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)

    -- Header: Destroyable icon - to the left of collected, with tooltip
    local headerDestroyableFrame = CreateFrame("Frame", nil, headerRow)
    headerDestroyableFrame:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
    headerDestroyableFrame:SetPoint("RIGHT", headerCollectedFrame, "LEFT", -COL_STATUS_PADDING, 0)
    headerDestroyableFrame:EnableMouse(true)
    local headerDestroyableIcon = headerDestroyableFrame:CreateTexture(nil, "ARTWORK")
    headerDestroyableIcon:SetAllPoints()
    headerDestroyableIcon:SetAtlas("transmog-icon-remove")
    headerDestroyableFrame:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:AddLine(L["TIP_STORED"], 1, 0.82, 0.3)
        GameTooltip:AddLine(L["TIP_STORED_COL_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    headerDestroyableFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Item list scroll frame (starts below header row)
    local listScroll = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    listScroll:SetPoint("TOPLEFT", 6, -20)  -- Below 18px header + 2px padding
    listScroll:SetPoint("BOTTOMRIGHT", -24, 28)  -- Leave room for Add All button

    local listContent = CreateFrame("Frame", nil, listScroll)
    listContent:SetSize(CONFIG.PREVIEW_TAB.LIST_CONTENT_WIDTH, 1)
    listScroll:SetScrollChild(listContent)
    previewPanel.listContent = listContent
    previewPanel.listScroll = listScroll

    -- 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
        local rowH = CONFIG.PREVIEW_TAB.ITEM_ROW_SPACING
        local rowTop = (newIdx - 1) * rowH
        local sf = previewPanel.listScroll
        local viewH = sf:GetHeight()
        local curScroll = sf:GetVerticalScroll()
        if rowTop < curScroll then
            sf:SetVerticalScroll(rowTop)
        elseif rowTop + rowH > curScroll + viewH then
            sf:SetVerticalScroll(rowTop + rowH - viewH)
        end
    end)

    -- Initialize row pool
    rowPool = HDG_UIHelpers.CreateRowPool(function(parent)
        local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
        row:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            tile = false,
        })

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

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

        -- Column 1: Item name (after icon) - stretches to fill available space
        row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.nameText:SetPoint("LEFT", 26, 0)
        row.nameText:SetPoint("RIGHT", -52, 0)
        row.nameText:SetJustifyH("LEFT")
        row.nameText:SetWordWrap(false)
        row.nameText:SetShadowOffset(0, 0)
        ApplyFont(row.nameText, GetScheme(), "small")

        -- Column 3: Craftable status icon (rightmost) - anchor from RIGHT
        row.craftableIcon = row:CreateTexture(nil, "ARTWORK")
        row.craftableIcon:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
        row.craftableIcon:SetPoint("RIGHT", -COL_RIGHT_MARGIN, 0)

        -- Column 2: Collected status icon - to the left of craftable
        row.collectedIcon = row:CreateTexture(nil, "ARTWORK")
        row.collectedIcon:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
        row.collectedIcon:SetPoint("RIGHT", row.craftableIcon, "LEFT", -COL_STATUS_PADDING, 0)

        -- Column 4: Destroyable status icon - to the left of collected
        row.destroyableIcon = row:CreateTexture(nil, "ARTWORK")
        row.destroyableIcon:SetSize(COL_STATUS_SIZE, COL_STATUS_SIZE)
        row.destroyableIcon:SetPoint("RIGHT", row.collectedIcon, "LEFT", -COL_STATUS_PADDING, 0)
        row.destroyableIcon:Hide()

        -- Click handler (set once, uses self.itemData which is updated per-row)
        row:SetScript("OnClick", function(self, button)
            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 -- capture before Refresh() recycles pool
                selectedItemKey = ItemKey(clickedItem)
                HDG_DecorPreview:Refresh()
                HDG_DecorPreview:UpdateModelAndInfo(clickedItem)
            end
        end)

        -- Hover effects
        row:SetScript("OnEnter", function(self)
            if selectedItemKey ~= ItemKey(self.itemData) then
                local hoverScheme = GetScheme()
                if hoverScheme then
                    self:SetBackdropColor(hoverScheme.button_hover.r, hoverScheme.button_hover.g, hoverScheme.button_hover.b, 0.8)
                else
                    self:SetBackdropColor(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)
                        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
                            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
                -- Use cached data if available; skip async Item:CreateFromItemID
                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)

        row:SetScript("OnLeave", function(self)
            if selectedItemKey ~= ItemKey(self.itemData) then
                self:SetBackdropColor(unpack(self.baseColor))
                -- Restore the clicked item's preview
                if previewPanel and previewPanel.selectedItemData then
                    UpdateModelDisplay(previewPanel.selectedItemData)
                end
            end
            GameTooltip:Hide()
        end)

        return row
    end)

    -- Apply minimalist scrollbar styling
    HDG_UIHelpers.StyleScrollBar(listScroll)

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

local function GetTagCategory(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 = "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
    if previewPanel.tagsLabel then
        if selectedCategory ~= "All" then
            previewPanel.tagsLabel:Show()
        else
            previewPanel.tagsLabel:Hide()
        end
    end

    if selectedCategory == "All" then return 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 == "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.listContent then
        return
    end

    local content = previewPanel.listContent

    -- Release pooled rows for reuse (instead of destroying)
    rowPool:ReleaseAll(function(row)
        if row.nameText then
            row.nameText:SetText("")
        end
    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
        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)
        items = sorted
    end

    local yOffset = 0
    local index = 0
    local collectedCount = 0
    local listWidth = CONFIG.PREVIEW_TAB.LIST_CONTENT_WIDTH
    local scheme = GetScheme()

    local totalDestroyable = 0
    wipe(displayedItems)
    PrepareFilterState()

    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 cached = destroyableCache[itemData.decorID]
            if cached then totalDestroyable = totalDestroyable + cached[1] end
            local ok, newOffset = pcall(CreateItemListEntry, content, listWidth, yOffset, itemData, index, scheme, isCollected)
            if ok then
                yOffset = newOffset
            end
        end
    end

    content:SetHeight(math.abs(yOffset) + 10)

    -- Update title with count and collection progress
    if previewPanel.headerTitle then
        if index == 0 and collectionSearchInProgress 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)
    if scheme then
        local iconColor = scheme.isLight and scheme.text or scheme.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
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

    if not previewPanel then
        self:CreatePanel()
    end

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

-- Show the panel
function HDG_DecorPreview:Show()
    if not previewPanel then
        self:CreatePanel()
    end
    self:Refresh()
    previewPanel:Show()
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)
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 spellID, 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 = spellID,
                }
                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: clear destroyable cache and refresh when storage changes
local storageEventFrame = CreateFrame("Frame")
storageEventFrame:RegisterEvent("HOUSING_STORAGE_UPDATED")
storageEventFrame:RegisterEvent("HOUSING_STORAGE_ENTRY_UPDATED")
storageEventFrame:SetScript("OnEvent", function()
    wipe(destroyableCache)
    if previewPanel and previewPanel:IsShown() then
        HDG_DecorPreview:RefreshList()
        if selectedItemKey and previewPanel.infoPanel and decorItemsByKey then
            local itemData = decorItemsByKey[selectedItemKey]
            if itemData then UpdateInfoPane(previewPanel.infoPanel, itemData) end
        end
    end
end)
