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

-- Color code helpers (delegates to ThemeEngine)
local function C_Text() return HDG.Theme.C_Text() end
local function C_Header() return HDG.Theme.C_Header() end
local function C_Dim() return HDG.Theme.C_Dim() end
local function C_Label() return HDG.Theme.C_Label() end
local function C_Accent() return HDG.Theme.C_Accent() end
local function C_Success() return HDG.Theme.C_Success() end
local function C_Warning() return HDG.Theme.C_Warning() end
local function C_Error() return HDG.Theme.C_Error() end
local function C_RecipeKnown() return HDG.Theme.C_RecipeKnown() end
local function GetQualityHex(quality) return HDG.Theme.GetQualityHex(quality) end
local function GetQualityRGB(quality) return HDG.Theme.GetQualityRGB(quality) end

-- State color helpers for toggle buttons (return r, g, b, a)
local function GetStateActive() return HDG.Theme.GetStateActive() end
local function GetStateInactive() return HDG.Theme.GetStateInactive() end
local function GetStateHover() return HDG.Theme.GetStateHover() end

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

-- Module frame references
local previewPanel = nil
local modelFrame = nil
local selectedDecorID = nil  -- Use decorID for unique selection

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

-- 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 = {}
local activeRows = {}  -- Track currently visible rows

local function GetPooledRow(parent)
    -- Find an unused row in the pool
    for i, row in ipairs(rowPool) do
        if not row.inUse then
            row.inUse = true
            row:SetParent(parent)
            -- Don't Show() here - caller will show after setting data
            -- Don't add to activeRows here - caller handles tracking
            return row
        end
    end
    return nil -- No pooled row available, caller should create new
end

local function ReleaseAllRows()
    for _, row in ipairs(activeRows) do
        row.inUse = false
        row:Hide()
        row:ClearAllPoints()
        -- Clear text to prevent stale content when row is reused
        if row.nameText then
            row.nameText:SetText("")
        end
    end
    wipe(activeRows)
end

local function AddRowToPool(row)
    row.inUse = false
    table.insert(rowPool, row)
end

-- Helper: Normalize expansion name - use centralized HDG_ExpansionData module
local function NormalizeExpansionName(expansion)
    return HDG_ExpansionData.NormalizeToDisplayName(expansion)
end

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

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

-- Helper: Check if item passes all current filters
local function ItemPassesFilters(itemData)
    local searchFilter = HDG_DecorPreview.searchFilter or ""
    local categoryFilter = HDG_DecorPreview.categoryFilter
    local professionFilter = HDG_DecorPreview.professionFilter
    local uncollectedOnly = HDG_DecorPreview.uncollectedOnly or false
    local tagFilters = HDG_DecorPreview.tagFilters or {}
    local hasTagFilters = next(tagFilters) ~= nil

    -- Apply search filter (check both localized name and English name from database)
    local itemName = (itemData.name or ""):lower()
    local englishName = (itemData.englishName or ""):lower()
    local matchesSearch = (searchFilter == "" or itemName:find(searchFilter, 1, true) or englishName:find(searchFilter, 1, true))
    if not matchesSearch then return false end

    -- Apply category filter
    if categoryFilter and itemData.categoryName ~= categoryFilter then return false end

    -- Apply profession filter
    -- When a profession is selected, only show items from that profession
    -- Items without a profession (non-craftable catalog items) are filtered out when a profession filter is active
    if professionFilter then
        if not itemData.profession or itemData.profession ~= professionFilter then
            return false
        end
    end

    -- Apply uncollected filter
    if uncollectedOnly then
        local isCollected = HDG_Data.IsDecorCollected(itemData.decorID)
        if isCollected then return false end
    end

    -- Apply tag filters (item must have at least one of the selected tags)
    if hasTagFilters then
        local matchesTags = false
        if itemData.tags then
            for _, tag in ipairs(itemData.tags) do
                if tagFilters[tag] then
                    matchesTags = true
                    break
                end
            end
        end
        if not matchesTags then return false end
    end

    -- Hide unreleased: decorID must exist in AllDecorDB with a non-future timestamp
    if HDG_DecorPreview.hideUnreleased then
        if itemData.name == "Unreleased" then return false end
        local known = GetKnownDecorIDs()
        if itemData.decorID then
            local addedTS = known[itemData.decorID]
            if not addedTS or addedTS > time() 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
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
        itemCache[itemID] = {name, link, quality, icon}
        return name, link, quality, icon
    end
    return nil, nil, nil, nil
end

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

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

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

    -- Build reverse lookup from HDG_DecorDB: decorID -> recipe data
    local decorToRecipe = {}
    if HDG_DecorDB then
        for spellID, recipe in pairs(HDG_DecorDB) do
            if recipe.decorID then
                decorToRecipe[recipe.decorID] = {
                    itemID = recipe.itemID,
                    spellID = spellID,
                    profession = recipe.profession,
                    expansion = recipe.expansion,
                    reagents = recipe.reagents,
                    name = recipe.name,
                }
            end
        end
    end

    -- 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
            -- Get tags
            local itemTags = {}
            if entryInfo.dataTagsByID then
                for _, tagName in pairs(entryInfo.dataTagsByID) do
                    table.insert(itemTags, tagName)
                end
            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]
                if C_HousingCatalog.GetCatalogCategoryInfo then
                    local catInfo = C_HousingCatalog.GetCatalogCategoryInfo(categoryID)
                    if catInfo and catInfo.name then
                        categoryName = catInfo.name
                    end
                end
            end
            if entryInfo.subcategoryIDs and entryInfo.subcategoryIDs[1] then
                subcategoryID = entryInfo.subcategoryIDs[1]
                if C_HousingCatalog.GetCatalogSubcategoryInfo then
                    local subInfo = C_HousingCatalog.GetCatalogSubcategoryInfo(subcategoryID)
                    if subInfo and subInfo.name then
                        subcategoryName = subInfo.name
                    end
                end
            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 nil
            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

            table.insert(items, {
                itemID = itemID,  -- May be nil for non-craftable items
                decorID = recordID,
                name = entryInfo.name,
                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,
            })
        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

    local ok, catalogSearcher = pcall(C_HousingCatalog.CreateCatalogSearcher)
    if not ok or not catalogSearcher then
        collectionSearchInProgress = false
        return
    end

    -- 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
        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
            collectedDecorCache = items
            collectedDecorCacheTime = time()
            collectionSearchInProgress = false

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

            -- Refresh the UI
            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
        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
        if catalogSearcher.Release then
            pcall(catalogSearcher.Release, catalogSearcher)
        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 = {}

            if C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem then
                local decorInfo = C_HousingCatalog.GetCatalogEntryInfoByItem(recipe.itemID, true)

                if decorInfo then
                    -- categoryIDs is a table like {1 = 4}
                    if decorInfo.categoryIDs and decorInfo.categoryIDs[1] then
                        categoryID = decorInfo.categoryIDs[1]
                        -- Get category name
                        if C_HousingCatalog.GetCatalogCategoryInfo then
                            local catInfo = C_HousingCatalog.GetCatalogCategoryInfo(categoryID)
                            if catInfo and catInfo.name then
                                categoryName = catInfo.name
                            end
                        end
                    end

                    -- subcategoryIDs is a table like {1 = 16}
                    if decorInfo.subcategoryIDs and decorInfo.subcategoryIDs[1] then
                        subcategoryID = decorInfo.subcategoryIDs[1]
                        -- Get subcategory name
                        if C_HousingCatalog.GetCatalogSubcategoryInfo then
                            local subInfo = C_HousingCatalog.GetCatalogSubcategoryInfo(subcategoryID)
                            if subInfo and subInfo.name then
                                subcategoryName = subInfo.name
                            end
                        end
                    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
                    -- Get category name
                    if C_HousingCatalog and C_HousingCatalog.GetCatalogCategoryInfo then
                        local catInfo = C_HousingCatalog.GetCatalogCategoryInfo(categoryID)
                        if catInfo and catInfo.name then
                            categoryName = catInfo.name
                        end
                    end

                    -- Get subcategory name if we have one
                    if fallbackSubID then
                        subcategoryID = fallbackSubID
                        if C_HousingCatalog and C_HousingCatalog.GetCatalogSubcategoryInfo then
                            local subInfo = C_HousingCatalog.GetCatalogSubcategoryInfo(subcategoryID)
                            if subInfo and subInfo.name then
                                subcategoryName = subInfo.name
                            end
                        end
                    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,
            })
        end
    end
    
    -- Sort alphabetically by name
    table.sort(items, function(a, b)
        return (a.name or "") < (b.name or "")
    end)

    -- Cache the results
    decorItemsCache = items
    decorItemsCacheTime = GetTime()

    return items
end

-- Invalidate the decor items cache (call when collection changes)
function HDG_DecorPreview:InvalidateCache()
    decorItemsCache = nil
    decorItemsCacheTime = 0
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

-- 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 |
local function CreateItemListEntry(parent, width, yOffset, itemData, index)
    -- Try to get a pooled row first
    local row = GetPooledRow(parent)

    if not row then
        -- Create new row and add to pool
        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)

        -- 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", -36, 0)  -- Leave room for status icons (2x14 + 2 + 2 + buffer)
        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)

        -- Click handler (set once, uses self.itemData which is updated per-row)
        row:SetScript("OnClick", function(self, button)
            if IsShiftKeyDown() and self.itemData and self.itemData.itemID and self.itemData.reagents then
                if HDG_UI and HDG_UI.AddToShoppingList then
                    HDG_UI.AddToShoppingList(BuildRecipeForQueue(self.itemData))
                    local localizedPrintName = self.itemData.itemID and HDG_Data.GetLocalizedName(self.itemData.itemID, self.itemData.name) or self.itemData.name or "item"
                    print(C_Success() .. "[Decor Preview]|r Added " .. localizedPrintName .. " to crafting queue")
                else
                    print(C_Warning() .. "[Decor Preview]|r Crafting queue not available")
                end
            else
                selectedDecorID = self.itemData.decorID
                HDG_DecorPreview:Refresh()
                HDG_DecorPreview:UpdateModelAndInfo(self.itemData)
            end
        end)

        -- Hover effects
        row:SetScript("OnEnter", function(self)
            if selectedDecorID ~= self.itemData.decorID 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
            end
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)

            -- Helper to add our custom lines after item tooltip
            local function AddCustomLines()
                GameTooltip:AddLine(" ")

                -- Collection status
                local isCollected = HDG_Data.IsDecorCollected(self.itemData.decorID)
                if isCollected then
                    GameTooltip:AddLine(C_Success() .. "Item collected|r", 0.2, 0.9, 0.2)
                else
                    GameTooltip:AddLine(C_Dim() .. "Not collected|r", 0.5, 0.5, 0.5)
                end

                -- Craftable status with character info
                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(C_Accent() .. "Recipe known by:|r " .. charName, 1, 0.85, 0.1)
                    else
                        local profession = self.itemData.profession or "Unknown"
                        GameTooltip:AddLine(C_Dim() .. "Craftable by:|r " .. profession, 0.5, 0.5, 0.5)
                    end
                end

                -- Vendor availability (from HDG_VendorDB)
                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(C_Accent() .. "Available from vendor:|r", 0.9, 0.7, 0.2)
                        for i, vendor in ipairs(vendors) do
                            if i > 3 then -- Limit to 3 vendors in tooltip
                                GameTooltip:AddLine(C_Dim() .. string.format("  ...and %d more", #vendors - 3) .. "|r", 0.5, 0.5, 0.5)
                                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)
                            -- Faction/rep requirement
                            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
                        end
                    end
                end

                GameTooltip:AddLine(" ")
                GameTooltip:AddLine(C_Success() .. "Shift-Click|r to add to Crafting Queue", 0.7, 0.7, 0.7)
                GameTooltip:Show()
            end

            if self.itemData.itemID then
                -- Use Item mixin to ensure item data is loaded before showing tooltip
                local item = Item:CreateFromItemID(self.itemData.itemID)
                item:ContinueOnItemLoad(function()
                    -- Check if we're still hovering this row
                    if GameTooltip:IsOwned(self) then
                        GameTooltip:SetItemByID(self.itemData.itemID)
                        AddCustomLines()
                    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(C_Dim() .. "Collected decor item|r", 0.5, 0.5, 0.5)
                if self.itemData.tags and #self.itemData.tags > 0 then
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine("Tags: " .. table.concat(self.itemData.tags, ", "), 0.6, 0.6, 0.8)
                end
                GameTooltip:Show()
            end
        end)

        row:SetScript("OnLeave", function(self)
            if selectedDecorID ~= self.itemData.decorID then
                self:SetBackdropColor(unpack(self.baseColor))
            end
            GameTooltip:Hide()
        end)

        -- Add to pool for future reuse
        AddRowToPool(row)
    end

    -- 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
    local rowScheme = GetScheme()
    if index % 2 == 0 then
        if rowScheme then
            baseColor = {rowScheme.panel.r, rowScheme.panel.g, rowScheme.panel.b, 0.6}
        else
            baseColor = {0.1, 0.1, 0.12, 0.6}
        end
    else
        if rowScheme then
            baseColor = {rowScheme.bg.r, rowScheme.bg.g, rowScheme.bg.b, 0.6}
        else
            baseColor = {0.08, 0.08, 0.1, 0.6}
        end
    end
    row.baseColor = baseColor

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

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

    -- Column 1: Item name (with quality color if available)
    -- Use localized name from WoW API when itemID is available
    local itemName = itemData.itemID and HDG_Data.GetLocalizedName(itemData.itemID, itemData.name) or itemData.name or "Unknown"
    if itemData.itemID then
        local _, _, quality = GetCachedItemInfo(itemData.itemID)
        if quality then
            local r, g, b = GetQualityColor(quality)
            row.nameText:SetText(string.format("|cFF%02x%02x%02x%s|r", r*255, g*255, b*255, itemName))
        else
            row.nameText:SetText(C_Text() .. itemName .. "|r")
        end
    else
        row.nameText:SetText(C_Text() .. itemName .. "|r")
    end

    -- Column 2: Collected status (checkmark icon, hidden if not collected)
    local collected = HDG_Data.IsDecorCollected(itemData.decorID)
    if collected then
        row.collectedIcon:SetTexture(CONFIG.TEXTURES.CHECKMARK)
        row.collectedIcon:SetDesaturated(false)
        local colScheme = GetScheme()
        if colScheme then
            row.collectedIcon:SetVertexColor(colScheme.success.r, colScheme.success.g, colScheme.success.b)
        else
            row.collectedIcon:SetVertexColor(0.2, 0.9, 0.2)  -- Green
        end
        row.collectedIcon:SetAlpha(1)
        row.collectedIcon:Show()
    else
        row.collectedIcon:Hide()
    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

        local craftScheme = GetScheme()
        if isKnown then
            -- Gold star for known recipes (distinct from green checkmark)
            row.craftableIcon:SetTexture("Interface\\COMMON\\ReputationStar")
            row.craftableIcon:SetTexCoord(0, 0.5, 0, 0.5)  -- Use filled star quadrant
            row.craftableIcon:SetDesaturated(false)
            if craftScheme then
                row.craftableIcon:SetVertexColor(craftScheme.warning.r, craftScheme.warning.g, craftScheme.warning.b)
            else
                row.craftableIcon:SetVertexColor(1, 0.85, 0.1)  -- Bright gold
            end
            row.craftableIcon:SetAlpha(1)
        elseif isAltKnown then
            -- Grey star for recipes known by alts but not current character
            row.craftableIcon:SetTexture("Interface\\COMMON\\ReputationStar")
            row.craftableIcon:SetTexCoord(0, 0.5, 0, 0.5)  -- Use filled star quadrant
            row.craftableIcon:SetDesaturated(false)
            if craftScheme then
                local altColor = craftScheme.isLight and craftScheme.text or craftScheme.text_dim
                row.craftableIcon:SetVertexColor(altColor.r, altColor.g, altColor.b)
            else
                row.craftableIcon:SetVertexColor(0.6, 0.6, 0.6)  -- Grey
            end
            row.craftableIcon:SetAlpha(0.8)
        else
            -- Gray checkmark for craftable but not known by anyone
            row.craftableIcon:SetTexture(CONFIG.TEXTURES.CHECKMARK)
            row.craftableIcon:SetTexCoord(0, 1, 0, 1)
            row.craftableIcon:SetDesaturated(true)
            if craftScheme then
                row.craftableIcon:SetVertexColor(craftScheme.text.r, craftScheme.text.g, craftScheme.text.b)
            else
                row.craftableIcon:SetVertexColor(1, 1, 1)
            end
            row.craftableIcon:SetAlpha(0.5)
        end
    else
        -- X mark for not craftable
        row.craftableIcon:SetTexture(CONFIG.TEXTURES.XMARK)
        row.craftableIcon:SetTexCoord(0, 1, 0, 1)
        row.craftableIcon:SetDesaturated(false)
        local ncScheme = GetScheme()
        if ncScheme then
            local ncColor = ncScheme.isLight and ncScheme.text or ncScheme.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

    -- Mark as in use and track (all rows - new and pooled - must be in activeRows)
    row.inUse = true
    table.insert(activeRows, row)
    row:Show()

    return yOffset - CONFIG.PREVIEW_TAB.ITEM_ROW_SPACING
end

-- Update the 3D model display using ModelScene (proper way for housing decor)
local function UpdateModelDisplay(itemData)
    if not previewPanel or not itemData then return end
    
    -- Hide placeholder
    if previewPanel.placeholderText then
        previewPanel.placeholderText:Hide()
    end
    
    -- Create ModelScene if it doesn't exist (with PanningModelSceneMixinTemplate for proper functionality)
    if not previewPanel.modelScene then
        local modelScene = CreateFrame("ModelScene", nil, previewPanel.modelFrame:GetParent(), "PanningModelSceneMixinTemplate")
        modelScene:SetPoint("TOPLEFT", previewPanel.modelFrame:GetParent(), "TOPLEFT", 8, -24)
        modelScene:SetPoint("BOTTOMRIGHT", previewPanel.modelFrame:GetParent(), "BOTTOMRIGHT", -8, 8)
        
        -- Enable mouse interaction for rotation
        modelScene:EnableMouse(true)
        modelScene:EnableMouseWheel(true)

        local rotating = false
        local lastX, lastY = 0, 0

        modelScene:SetScript("OnMouseDown", function(self, button)
            if button == "LeftButton" then
                rotating = true
                lastX, lastY = GetCursorPosition()
            end
        end)

        modelScene:SetScript("OnMouseUp", function(self, button)
            if button == "LeftButton" then
                rotating = false
            elseif button == "RightButton" then
                -- Reset - reload the model
                if previewPanel.selectedItemData then
                    UpdateModelDisplay(previewPanel.selectedItemData)
                end
            end
        end)

        modelScene:SetScript("OnUpdate", function(self)
            if rotating then
                local x, y = GetCursorPosition()
                local dx = (x - lastX) * 0.01
                local dy = (y - lastY) * 0.01
                local actor = self:GetActorByTag("decor")
                if actor then
                    -- Horizontal drag rotates around Y axis (yaw)
                    actor:SetYaw((actor:GetYaw() or 0) + dx)
                    -- Vertical drag rotates around X axis (pitch)
                    actor:SetPitch((actor:GetPitch() or 0) + dy)
                end
                lastX, lastY = x, y
            end
        end)
        
        modelScene:SetScript("OnMouseWheel", function(self, delta)
            local actor = self:GetActorByTag("decor")
            if actor then
                local scale = actor:GetScale() or 1
                if delta > 0 then
                    scale = scale * 1.1
                else
                    scale = scale * 0.9
                end
                scale = math.max(0.1, math.min(5.0, scale))
                actor:SetScale(scale)
            end
        end)
        
        previewPanel.modelScene = modelScene
        
        -- Hide the old DressUpModel
        if modelFrame then
            modelFrame:Hide()
        end
    end
    
    -- Store selected item for reset
    previewPanel.selectedItemData = itemData
    
    -- Get decor info - try by itemID first, fall back to recordID
    local decorInfo = nil
    if 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
    
    if not decorInfo or not decorInfo.asset then
        -- Fallback: show a message that preview isn't available
        if previewPanel.modelScene then
            previewPanel.modelScene:Hide()
        end
        if previewPanel.placeholderText then
            previewPanel.placeholderText:SetText(C_Dim() .. "Preview not available|r")
            previewPanel.placeholderText:Show()
        end
        return
    end
    
    -- Determine model scene ID
    local modelSceneID = decorInfo.uiModelSceneID
    if not modelSceneID then
        if Constants and Constants.HousingCatalogConsts and Constants.HousingCatalogConsts.HOUSING_CATALOG_DECOR_MODELSCENEID_DEFAULT then
            modelSceneID = Constants.HousingCatalogConsts.HOUSING_CATALOG_DECOR_MODELSCENEID_DEFAULT
        else
            modelSceneID = 859  -- Fallback default
        end
    end
    
    -- Transition to the model scene
    local modelScene = previewPanel.modelScene
    modelScene:Show()
    
    local forceSceneChange = true
    local success = pcall(function()
        modelScene:TransitionToModelSceneID(modelSceneID, CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD, forceSceneChange)
    end)
    
    if success then
        -- Get the decor actor and set up the model
        local actor = modelScene:GetActorByTag("decor")
        if actor then
            -- This is important for proper bounds/positioning
            actor:SetPreferModelCollisionBounds(true)
            -- Set the model using the asset fileID
            actor:SetModelByFileID(decorInfo.asset)
        end
    end
end

-- Update the info pane with all available data (two columns)
local function UpdateInfoPane(infoFrame, itemData)
    if not infoFrame or not itemData then return end
    
    -- Clear existing font strings
    if infoFrame.labels then
        for _, label in ipairs(infoFrame.labels) do
            label:Hide()
            label:SetText("")
        end
    end
    infoFrame.labels = {}

    -- 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 function AddLeftLine(text)
        local line = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        line:SetPoint("TOPLEFT", leftMargin, leftY)
        line:SetWidth(midPoint - leftMargin - 10)
        line:SetJustifyH("LEFT")
        line:SetShadowOffset(0, 0)  -- Remove text shadow
        line:SetText(text)
        line:Show()
        table.insert(infoFrame.labels, line)
        leftY = leftY - lineHeight
        return line
    end

    local function AddRightLine(text)
        local line = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        line:SetPoint("TOPLEFT", midPoint, rightY)
        line:SetPoint("RIGHT", -8, 0)
        line:SetJustifyH("LEFT")
        line:SetShadowOffset(0, 0)  -- Remove text shadow
        line:SetText(text)
        line:Show()
        table.insert(infoFrame.labels, line)
        rightY = rightY - lineHeight
        return line
    end
    
    -- Get fresh decorInfo from API - try by itemID first, fall back to recordID
    local decorInfo = nil
    if 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 r, g, b = 1, 1, 1  -- Default white
    local localizedDetailName = itemData.itemID and HDG_Data.GetLocalizedName(itemData.itemID, itemData.name) or itemData.name or "Unknown"
    if itemData.itemID then
        local _, _, quality = GetCachedItemInfo(itemData.itemID)
        if quality then
            r, g, b = GetQualityColor(quality)
        end
    end
    AddLeftLine(string.format("|cFF%02x%02x%02x%s|r", r*255, g*255, b*255, localizedDetailName))
    
    -- Category > Subcategory
    local catText = itemData.categoryName or "Uncategorized"
    if itemData.subcategoryName then
        catText = catText .. " > " .. itemData.subcategoryName
    end
    AddLeftLine(C_Accent() .. catText .. "|r")

    -- Profession (blue label, orange value)
    AddLeftLine(C_Accent() .. "Profession:|r " .. 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 = C_Success() .. "Collected|r"
            if decorInfo.numStored and decorInfo.numStored > 0 then
                ownedText = ownedText .. " " .. C_Dim() .. "(Stored: " .. decorInfo.numStored .. ")|r"
            end
            if decorInfo.numPlaced and decorInfo.numPlaced > 0 then
                ownedText = ownedText .. " " .. C_Dim() .. "(Placed: " .. decorInfo.numPlaced .. ")|r"
            end
            AddLeftLine(ownedText)
        else
            AddLeftLine(C_Warning() .. "Not Collected|r")
        end

        -- Placement allowed (blue label, orange/dim values based on state)
        local allowedText = C_Accent() .. "Allowed:|r "
        if decorInfo.isAllowedIndoors then
            allowedText = allowedText .. C_RecipeKnown() .. "Indoors|r "
        else
            allowedText = allowedText .. C_Dim() .. "Indoors|r "
        end
        if decorInfo.isAllowedOutdoors then
            allowedText = allowedText .. C_RecipeKnown() .. "Outdoors|r"
        else
            allowedText = allowedText .. C_Dim() .. "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(C_Accent() .. "Tags:|r " .. C_RecipeKnown() .. table.concat(tags, ", ") .. "|r")
            end
        end

        -- Dyeable status (left column)
        if decorInfo.canCustomize then
            AddLeftLine(C_Accent() .. "Dyeable:|r " .. C_Success() .. "Yes|r")

            -- Color swatch strip (one per dye category, uses pre-computed caches)
            pcall(function()
                if not HDG_DyeData or not HDG_DyeData.EnsureInitialized() then return end
                if not infoFrame.dyeSwatches then infoFrame.dyeSwatches = {} end

                local categories = HDG_DyeData.GetCategories()
                local swatchSize = 14
                local swatchX = leftMargin
                local swatchY = leftY - 2

                for i, catID in ipairs(categories) do
                    local sw = infoFrame.dyeSwatches[i]
                    if not sw then
                        sw = infoFrame:CreateTexture(nil, "ARTWORK")
                        sw:SetTexture("Interface\\Buttons\\WHITE8x8")
                        infoFrame.dyeSwatches[i] = sw
                    end
                    sw:SetSize(swatchSize, swatchSize)
                    sw:SetPoint("TOPLEFT", swatchX, swatchY)

                    local c = HDG_DyeData.GetCategorySwatchColor(catID)
                    if c then
                        local hasOwned = HDG_DyeData.HasOwnedDyeInCategory(catID)
                        sw:SetVertexColor(c.r, c.g, c.b, hasOwned and 1 or 0.3)
                    end
                    sw:Show()
                    swatchX = swatchX + swatchSize + 2
                end

                leftY = swatchY - swatchSize - 4
            end)
        end
    end

    -- === RIGHT COLUMN: Technical info ===

    -- IDs
    if itemData.itemID then
        AddRightLine(C_Dim() .. "Item ID:|r " .. C_Text() .. itemData.itemID .. "|r")
    end
    AddRightLine(C_Dim() .. "Decor ID:|r " .. C_Text() .. (itemData.decorID or "?") .. "|r")

    if decorInfo then
        -- Record ID
        if decorInfo.entryID and decorInfo.entryID.recordID then
            AddRightLine(C_Dim() .. "Record ID:|r " .. C_Text() .. decorInfo.entryID.recordID .. "|r")
        end

        -- Placement cost
        if decorInfo.placementCost then
            AddRightLine(C_Dim() .. "Place Cost:|r " .. C_Text() .. decorInfo.placementCost .. "|r")
        end

        -- Size (numeric)
        if decorInfo.size then
            AddRightLine(C_Dim() .. "Size Value:|r " .. C_Text() .. decorInfo.size .. "|r")
        end

        -- Asset ID
        if decorInfo.asset then
            AddRightLine(C_Dim() .. "Asset:|r " .. C_Dim() .. decorInfo.asset .. "|r")
        end


        -- Customizable (kept in right column as technical flag, full dye info in left)
        if decorInfo.canCustomize then
            AddRightLine(C_Dim() .. "Dyeable:|r " .. C_Success() .. "Yes|r")
        end
    end

    -- Expansion (from our DB)
    AddRightLine(C_Dim() .. "Expansion:|r " .. C_Text() .. (itemData.expansion or "?") .. "|r")

    -- (Dyes button removed — Dye Studio accessible from main title bar)

    -- "Find Vendor" button — show for non-crafted items that exist 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, "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, 6)
        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, "Hide Unreleased")
    releasedCheck:SetPoint("LEFT", searchBox, "RIGHT", 6, 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("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(C_Label() .. "Filter:|r")
    RegisterWidget(filterLabel, "Text")
    previewPanel.filterLabel = filterLabel

    -- Segmented toggle container for Collection/Uncollected (stacked vertically)
    local toggleContainer = CreateFrame("Frame", nil, categoryRow, "BackdropTemplate")
    toggleContainer:SetSize(CONFIG.PREVIEW_TAB.TOGGLE_CONTAINER_WIDTH, CONFIG.PREVIEW_TAB.TOGGLE_CONTAINER_HEIGHT)
    toggleContainer:SetPoint("RIGHT", 0, -8)  -- Offset down to center between rows
    toggleContainer:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        tile = false, edgeSize = 1,
        insets = {left = 0, right = 0, top = 0, bottom = 0}
    })
    local toggleScheme = GetScheme()
    if toggleScheme then
        toggleContainer:SetBackdropColor(toggleScheme.panel.r, toggleScheme.panel.g, toggleScheme.panel.b, 1.0)
        toggleContainer:SetBackdropBorderColor(toggleScheme.accent.r, toggleScheme.accent.g, toggleScheme.accent.b, 1)
        RegisterWidget(toggleContainer, "Panel")
    else
        toggleContainer:SetBackdropColor(0.08, 0.08, 0.1, 1.0)
        toggleContainer:SetBackdropBorderColor(0, 0.4, 0.4, 1)
    end
    previewPanel.toggleContainer = toggleContainer

    -- Top segment: My Collection
    local collectionBtn = CreateFrame("Button", nil, toggleContainer)
    collectionBtn:SetSize(CONFIG.PREVIEW_TAB.TOGGLE_CONTAINER_WIDTH - 2, CONFIG.PREVIEW_TAB.TOGGLE_BUTTON_HEIGHT)
    collectionBtn:SetPoint("TOP", 0, -1)

    local collectionBg = collectionBtn:CreateTexture(nil, "BACKGROUND")
    collectionBg:SetAllPoints()
    collectionBg:SetColorTexture(GetStateInactive())
    collectionBtn.bg = collectionBg

    local collectionText = collectionBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    collectionText:SetPoint("CENTER", 0, 0)
    ApplyFont(collectionText, titleScheme, "small")
    collectionText:SetText(C_Dim() .. "Collection|r")
    collectionBtn.text = collectionText

    -- Bottom segment: Uncollected
    local uncollectedBtn = CreateFrame("Button", nil, toggleContainer)
    uncollectedBtn:SetSize(CONFIG.PREVIEW_TAB.TOGGLE_CONTAINER_WIDTH - 2, CONFIG.PREVIEW_TAB.TOGGLE_BUTTON_HEIGHT)
    uncollectedBtn:SetPoint("BOTTOM", 0, 1)

    local uncollectedBg = uncollectedBtn:CreateTexture(nil, "BACKGROUND")
    uncollectedBg:SetAllPoints()
    uncollectedBg:SetColorTexture(GetStateInactive())
    uncollectedBtn.bg = uncollectedBg

    local uncollectedLabel = L["STATUS_UNCOLLECTED"] or "Uncollected"
    local uncollectedText = uncollectedBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    uncollectedText:SetPoint("CENTER", 0, 0)
    ApplyFont(uncollectedText, titleScheme, "small")
    uncollectedText:SetText(C_Dim() .. uncollectedLabel .. "|r")
    uncollectedBtn.text = uncollectedText

    -- Update both segments based on state
    local collectionLabel = L["STATUS_COLLECTION"] or "Collection"
    local function UpdateCollectionToggle()
        if HDG_DecorPreview.showAllDecor then
            -- Collection active
            collectionBg:SetColorTexture(GetStateActive())
            collectionText:SetText(C_Text() .. collectionLabel .. "|r")
        else
            -- Collection inactive
            collectionBg:SetColorTexture(GetStateInactive())
            collectionText:SetText(C_Dim() .. collectionLabel .. "|r")
        end

        if HDG_DecorPreview.uncollectedOnly then
            -- Uncollected active
            uncollectedBg:SetColorTexture(GetStateActive())
            uncollectedText:SetText(C_Text() .. uncollectedLabel .. "|r")
        else
            -- Uncollected inactive
            uncollectedBg:SetColorTexture(GetStateInactive())
            uncollectedText:SetText(C_Dim() .. uncollectedLabel .. "|r")
        end
    end

    collectionBtn:SetScript("OnClick", function()
        HDG_DecorPreview.showAllDecor = not HDG_DecorPreview.showAllDecor
        UpdateCollectionToggle()
        HDG_DecorPreview:RefreshList()
        HDG_DecorPreview:RefreshTagFilters()
    end)
    collectionBtn:SetScript("OnEnter", function(self)
        if not HDG_DecorPreview.showAllDecor then
            self.bg:SetColorTexture(GetStateHover())
        end
        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:SetScript("OnLeave", function(self)
        UpdateCollectionToggle()
        GameTooltip:Hide()
    end)
    previewPanel.collectionBtn = collectionBtn

    uncollectedBtn:SetScript("OnClick", function()
        HDG_DecorPreview.uncollectedOnly = not HDG_DecorPreview.uncollectedOnly
        UpdateCollectionToggle()
        HDG_DecorPreview:RefreshList()
    end)
    uncollectedBtn:SetScript("OnEnter", function(self)
        if not HDG_DecorPreview.uncollectedOnly then
            self.bg:SetColorTexture(GetStateHover())
        end
        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:SetScript("OnLeave", function(self)
        UpdateCollectionToggle()
        GameTooltip:Hide()
    end)
    previewPanel.uncollectedBtn = uncollectedBtn
    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(C_Label() .. "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"
    HDG_DecorPreview.showAllDecor = HDG_DecorPreview.showAllDecor or false

    -- 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(C_Text() .. "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("Craftable", 1, 0.82, 0.3)
        GameTooltip:AddLine("Recipe is available to craft", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(C_Accent() .. "Bright Star|r = Known by you", 0.6, 0.6, 0.6)
        GameTooltip:AddLine(C_Dim() .. "Dim Star|r = Known by alt", 0.6, 0.6, 0.6)
        GameTooltip:AddLine(C_Dim() .. "Dim Check|r = Not known", 0.6, 0.6, 0.6)
        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("Collected", 1, 0.82, 0.3)
        GameTooltip:AddLine("Decor item is in your collection", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(C_Success() .. "Green|r = Collected", 0.6, 0.6, 0.6)
        GameTooltip:AddLine(C_Dim() .. "Gray|r = Not collected", 0.6, 0.6, 0.6)
        GameTooltip:Show()
    end)
    headerCollectedFrame: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

    -- Apply minimalist scrollbar styling
    if HDG_UIHelpers and HDG_UIHelpers.StyleScrollBar then
        HDG_UIHelpers.StyleScrollBar(listScroll)
    end

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

        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.AddToShoppingList then
                    -- Skip refresh during batch add (true = skipRefresh)
                    HDG_UI.AddToShoppingList(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(C_Accent() .. "[Housing Decor Guide]|r Added %d craftable item(s) to crafting queue", addedCount))
        else
            print(C_Accent() .. "[Housing Decor Guide]|r 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(C_Accent() .. "Add All to Queue|r", 1, 1, 1)
        GameTooltip:AddLine("Adds all currently displayed craftable items", 0.7, 0.7, 0.7)
        GameTooltip:AddLine("to your crafting queue", 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(0.05, 0.05, 0.08, 0.95)
        modelPanel:SetBackdropBorderColor(unpack(HDG_UIHelpers.BORDER_COLOR))
    end
    
    -- Model title
    local modelTitle = modelPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    modelTitle:SetPoint("TOP", 0, -6)
    modelTitle:SetShadowOffset(0, 0)  -- Remove text shadow
    ApplyFont(modelTitle, modelScheme, "small")
    modelTitle:SetText(C_Text() .. "3D Preview|r")
    
    -- 3D Model Frame
    modelFrame = CreateFrame("DressUpModel", nil, modelPanel)
    modelFrame:SetPoint("TOPLEFT", 8, -24)
    modelFrame:SetPoint("BOTTOMRIGHT", -8, 8)
    modelFrame:SetModelScale(1)
    modelFrame:SetPosition(0, 0, 0)
    modelFrame:SetFacing(0)
    
    -- Enable mouse rotation
    modelFrame:EnableMouse(true)
    modelFrame:EnableMouseWheel(true)

    local rotating = false
    local lastX, lastY
    -- Track model position for vertical rotation
    local modelPosX, modelPosY, modelPosZ = 0, 0, 0
    -- Track zoom scale (GetCamDistanceScale may not exist on all model types)
    local currentZoomScale = 1.0

    modelFrame:SetScript("OnMouseDown", function(self, button)
        if button == "LeftButton" then
            rotating = true
            lastX, lastY = GetCursorPosition()
        end
    end)

    modelFrame:SetScript("OnMouseUp", function(self, button)
        if button == "LeftButton" then
            rotating = false
        elseif button == "RightButton" then
            -- Reset rotation and position
            self:SetFacing(0)
            currentZoomScale = 1.0
            if self.SetCamDistanceScale then self:SetCamDistanceScale(1.0) end
            modelPosX, modelPosY, modelPosZ = 0, 0, 0
            self:SetPosition(0, 0, 0)
        end
    end)

    modelFrame:SetScript("OnUpdate", function(self)
        if rotating then
            local x, y = GetCursorPosition()
            if lastX and lastY then
                local dx = (x - lastX) * 0.01
                local dy = (y - lastY) * 0.01
                -- Horizontal drag rotates around Y axis (facing)
                self:SetFacing(self:GetFacing() + dx)
                -- Vertical drag adjusts model height/position for vertical viewing angle
                modelPosZ = modelPosZ + dy * 0.5
                modelPosZ = math.max(-2, math.min(2, modelPosZ))  -- Clamp vertical range
                self:SetPosition(modelPosX, modelPosY, modelPosZ)
            end
            lastX, lastY = x, y
        end
    end)
    
    modelFrame:SetScript("OnMouseWheel", function(self, delta)
        local zoomSpeed = CONFIG.PREVIEW_TAB.ZOOM_SPEED
        if delta > 0 then
            currentZoomScale = currentZoomScale - zoomSpeed
        else
            currentZoomScale = currentZoomScale + zoomSpeed
        end
        currentZoomScale = math.max(CONFIG.PREVIEW_TAB.ZOOM_MIN, math.min(CONFIG.PREVIEW_TAB.ZOOM_MAX, currentZoomScale))
        if self.SetCamDistanceScale then self:SetCamDistanceScale(currentZoomScale) end
    end)
    
    previewPanel.modelFrame = modelFrame
    
    -- Placeholder text for model
    local placeholderText = modelPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    placeholderText:SetPoint("CENTER", 0, 0)
    placeholderText:SetShadowOffset(0, 0)  -- Remove text shadow
    ApplyFont(placeholderText, modelScheme, "body")
    placeholderText:SetText(C_Dim() .. "Select an item to preview|r")
    previewPanel.placeholderText = placeholderText

    -- Controls hint
    local controlsHint = modelPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    controlsHint:SetPoint("BOTTOM", 0, 12)
    controlsHint:SetShadowOffset(0, 0)  -- Remove text shadow
    ApplyFont(controlsHint, modelScheme, "small")
    controlsHint:SetText(C_Dim() .. "Drag to rotate (H+V) | Scroll to zoom | Right-click to reset|r")
    
    -- 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

-- Helper to check if a tag belongs to a category
local function GetTagCategory(tagName)
    -- Normalize to English first
    local englishTag = NormalizeTagToEnglish(tagName)
    for category, tags in pairs(TAG_CATEGORIES) do
        for _, tag in ipairs(tags) do
            if tag == englishTag then
                return category
            end
        end
    end
    return "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.CreateFilterButton (default options: height=16, inactive text #B0B0B0)

-- Refresh tag filter buttons (two-row category system)
function HDG_DecorPreview:RefreshTagFilters()
    if not previewPanel or not previewPanel.categoryRow then return end

    -- Clear existing buttons
    if previewPanel.categoryButtons then
        for _, btn in ipairs(previewPanel.categoryButtons) do
            btn:Hide()
            btn:SetParent(nil)
        end
    end
    if previewPanel.tagButtons then
        for _, btn in ipairs(previewPanel.tagButtons) do
            btn:Hide()
            btn:SetParent(nil)
        end
    end
    previewPanel.categoryButtons = {}
    previewPanel.tagButtons = {}

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

    local tags = GetUniqueTags(items)
    cachedTags = tags

    -- Build tag count lookup
    local tagCounts = {}
    for _, tagData in ipairs(tags) do
        tagCounts[tagData.name] = tagData.count
    end

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

    -- Row 1: Category buttons (positioned after "Filter:" label)
    -- "Crafted" shows profession sub-filters, others show game tags
    -- Keys are English (internal), display is localized
    local categoryDefs = {
        { key = "All", display = L["CAT_ALL"] or "All" },
        { 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" },
    }
    local xOffset = 38  -- After "Filter:" label
    local selectedCategory = self.selectedCategory or "Crafted"

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

        -- Calculate dynamic width based on text
        local textWidth = GetTextWidth(displayText)
        local btnWidth = math.max(textWidth + 22, 40)  -- Minimum 40px, padding 22px

        local isActive = (selectedCategory == catDef.key)
        local clickHandler = function()
            HDG_DecorPreview.selectedCategory = catDef.key
            -- Clear filters when switching categories
            HDG_DecorPreview.tagFilters = {}
            HDG_DecorPreview.professionFilter = nil
            HDG_DecorPreview:RefreshList()
            HDG_DecorPreview:RefreshTagFilters()
        end

        local btn = HDG_UIHelpers.CreateFilterButton(
            previewPanel.categoryRow,
            displayText,
            btnWidth,
            isActive,
            clickHandler
        )
        btn:SetPoint("LEFT", xOffset, 0)
        table.insert(previewPanel.categoryButtons, btn)
        xOffset = xOffset + btnWidth + 2
    end

    -- Row 2: Tag buttons for selected category
    -- Show/hide "Tags:" label based on whether tags are displayed
    if previewPanel.tagsLabel then
        if selectedCategory ~= "All" then
            previewPanel.tagsLabel:Show()
        else
            previewPanel.tagsLabel:Hide()
        end
    end

    if selectedCategory ~= "All" then
        -- Special handling for "Crafted" category - show profession filters
        if selectedCategory == "Crafted" then
            -- Build profession counts from craftable items
            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

            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 + 15, CONFIG.PREVIEW_TAB.CATEGORY_BUTTON_MAX_WIDTH or 80)

                    if xOffset + btnWidth > maxWidth then
                        break
                    end

                    local isActive = self.professionFilter == profName
                    local btn = HDG_UIHelpers.CreateFilterButton(
                        previewPanel.tagRow,
                        displayName,  -- Show localized name
                        btnWidth,
                        isActive,
                        function()
                            -- Single-select: clear or set profession filter (use English internally)
                            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)
                        if not self.isActive then
                            local hoverScheme = GetScheme()
                            if hoverScheme then
                                self:SetBackdropColor(hoverScheme.button_hover.r, hoverScheme.button_hover.g, hoverScheme.button_hover.b, 0.9)
                            else
                                self:SetBackdropColor(0.15, 0.15, 0.18, 0.9)
                            end
                        end
                        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
                        GameTooltip:AddLine(profName, 1, 0.82, 0.3)
                        GameTooltip:AddLine(string.format("%d recipes", count), 0.7, 0.7, 0.7)
                        GameTooltip:Show()
                    end)
                    btn:SetScript("OnLeave", function(self)
                        if not self.isActive then
                            local leaveScheme = GetScheme()
                            if leaveScheme then
                                self:SetBackdropColor(leaveScheme.button_inactive.r, leaveScheme.button_inactive.g, leaveScheme.button_inactive.b, 0.8)
                            else
                                self:SetBackdropColor(0.1, 0.1, 0.12, 0.8)
                            end
                        end
                        GameTooltip:Hide()
                    end)

                    table.insert(previewPanel.tagButtons, btn)
                    xOffset = xOffset + btnWidth + 2
                end
            end
        else
            -- Standard tag categories - iterate over actual API tags and filter by category
            -- This works with localized tag names from any language
            xOffset = 38  -- Same offset as categories (after "Tags:" label)
            local maxWidth = 780  -- Full panel width available now

            -- Build list of tags in this category from actual API tags
            local categoryTagList = {}
            for _, tagData in ipairs(tags) do
                local cat = GetTagCategory(tagData.name)  -- Uses normalization
                if cat == selectedCategory then
                    table.insert(categoryTagList, { name = tagData.name, count = tagData.count })
                end
            end

            -- Sort: expansion order for Expansions category, count descending for others
            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
                    -- Get English name for abbreviation lookup
                    local englishTag = NormalizeTagToEnglish(tagName)
                    -- Use short name for expansion/faction tags, or deduplicated name for Other category
                    local displayName = tagName
                    if selectedCategory == "Expansions" then
                        -- Use short name (3 chars max) for expansion tags
                        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

                    -- Calculate button width based on display name (using reusable measurement)
                    local textWidth = GetTextWidth(displayName)
                    local btnWidth = math.min(textWidth + 15, CONFIG.PREVIEW_TAB.TAG_BUTTON_MAX_WIDTH or 70)

                    -- Check if it fits
                    if xOffset + btnWidth > maxWidth then
                        break  -- Stop if we run out of space
                    end

                    local isActive = self.tagFilters and self.tagFilters[tagName]
                    local btn = HDG_UIHelpers.CreateFilterButton(
                        previewPanel.tagRow,
                        displayName,
                        btnWidth,
                        isActive,
                        function()
                            -- Single-select: clear all filters first, then toggle this one
                            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)
                        if not self.isActive then
                            local hoverScheme = GetScheme()
                            if hoverScheme then
                                self:SetBackdropColor(hoverScheme.button_hover.r, hoverScheme.button_hover.g, hoverScheme.button_hover.b, 0.9)
                            else
                                self:SetBackdropColor(0.15, 0.15, 0.18, 0.9)
                            end
                        end
                        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
                        GameTooltip:AddLine(tagName, tooltipR, tooltipG, tooltipB)
                        GameTooltip:AddLine(string.format("%d items", count), 0.7, 0.7, 0.7)
                        GameTooltip:Show()
                    end)
                    btn:SetScript("OnLeave", function(self)
                        if not self.isActive then
                            local leaveScheme = GetScheme()
                            if leaveScheme then
                                self:SetBackdropColor(leaveScheme.button_inactive.r, leaveScheme.button_inactive.g, leaveScheme.button_inactive.b, 0.8)
                            else
                                self:SetBackdropColor(0.1, 0.1, 0.12, 0.8)
                            end
                        end
                        GameTooltip:Hide()
                    end)

                    table.insert(previewPanel.tagButtons, btn)
                    xOffset = xOffset + btnWidth + 2
                end
            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)
    ReleaseAllRows()

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

    local yOffset = 0
    local index = 0
    local collectedCount = 0
    local listWidth = CONFIG.PREVIEW_TAB.LIST_CONTENT_WIDTH

    for _, itemData in ipairs(items) do
        -- Use shared filter helper
        if ItemPassesFilters(itemData) then
            index = index + 1
            -- Track collected count for header stats
            if HDG_Data.IsDecorCollected(itemData.decorID) then
                collectedCount = collectedCount + 1
            end
            local ok, newOffset = pcall(CreateItemListEntry, content, listWidth, yOffset, itemData, index)
            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
        local pct = index > 0 and math.floor((collectedCount / index) * 100) or 0
        local pctColor = pct >= 100 and C_Success() or (pct >= 50 and C_Accent() or C_Text())
        previewPanel.headerTitle:SetText(string.format(
            "%sItems|r %s(%d)|r  %s%d/%d (%d%%)|r",
            C_Text(), C_Dim(), index, pctColor, collectedCount, index, pct
        ))
    end

    -- Update header icon colors for current theme (use text color on light themes for contrast)
    local iconScheme = GetScheme()
    if iconScheme then
        local iconColor = iconScheme.isLight and iconScheme.text or iconScheme.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
    
    -- Hide placeholder
    if previewPanel.placeholderText then
        previewPanel.placeholderText:Hide()
    end
    
    -- Update model
    UpdateModelDisplay(itemData)
    
    -- Update info pane
    UpdateInfoPane(previewPanel.infoPanel, itemData)
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
    if selectedDecorID then
        local items = GetAllDecorItems()
        for _, itemData in ipairs(items) do
            if itemData.decorID == selectedDecorID then
                self:UpdateModelAndInfo(itemData)
                break
            end
        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(C_Error() .. "[Housing Decor Guide]|r 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(C_Error() .. "[Housing Decor Guide]|r 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.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
        selectedDecorID = decorID
        foundItemData = {
            name = itemName,
            itemID = itemID,
            decorID = decorID,
        }
    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
                selectedDecorID = recipe.decorID
                -- Build minimal itemData for UpdateModelAndInfo
                foundItemData = {
                    name = recipe.name,
                    itemID = recipe.itemID,
                    decorID = recipe.decorID,
                    spellID = spellID,
                }
                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
                selectedDecorID = itemData.decorID
                foundItemData = itemData
                break
            end
        end
    end

    -- Refresh will update the list AND call UpdateModelAndInfo for selectedDecorID
    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
