local addonName, privateDB = ...
local HDG = _G.HousingDecorGuide
local Log = privateDB.Log
local LOG_LEVEL = privateDB.LOG_LEVEL

-- ============================================================================
-- HOUSE EDITOR TAB - Style Browser for Blizzard Housing Panel
-- Injects an "HDG Styles" tab into the Blizzard HouseEditorStorageFrame
-- ============================================================================

local MODULE_NAME = "HouseEditorTab"
local injected = false
local hdgTabID = nil

-- Shared UI state
local hdgContentFrame = nil
local modeFrame = nil
local sidebarChild = nil   -- single sidebar scroll child
local gridScrollChild = nil -- single grid scroll child
local gridButtons = {}
local sidebarButtons = {}
local emptyLabel = nil
local currentMode = "styles"
local currentStyleId = nil
local currentShoppingListId = nil
local placedFromHDGTab = false

-- Hover preview (3D model, lazy-created)
local hoverPreviewFrame = nil

local function GetPreviewFrame()
    if hoverPreviewFrame then return hoverPreviewFrame end
    local UIHelpers = HDG_UIHelpers
    if not UIHelpers or not UIHelpers.CreateDecorPreview then return nil end
    hoverPreviewFrame = UIHelpers.CreateDecorPreview(UIParent, {
        squareAspect = true, showOverlays = false, showControls = false, showCorbels = false,
    })
    hoverPreviewFrame:SetSize(150, 150)
    hoverPreviewFrame:SetFrameStrata("TOOLTIP")
    hoverPreviewFrame:Hide()
    if hoverPreviewFrame.placeholder then hoverPreviewFrame.placeholder:Hide() end
    return hoverPreviewFrame
end

-- Layout constants
local SIDEBAR_WIDTH = 140
local SIDEBAR_BTN_HEIGHT = 20
local ICON_SIZE = 64
local ICON_PADDING = 4

-- ============================================================================
-- CATALOG CACHE (single upfront scan, used for ownership + info + dye data)
-- ============================================================================
-- While in house editing mode, the catalog is effectively frozen:
-- items don't appear/disappear, dye status doesn't change.
-- Only quantities change (place/pick up). We scan once on HDG tab show
-- and refresh only on HOUSING_STORAGE_UPDATED.

local catalogCache = {}    -- itemID -> { info, dyeVariants[] }
local catalogReady = false
local catalogScanInFlight = false
local catalogSearcher = nil -- pinned to prevent GC before async callback fires
local catalogGeneration = 0 -- incremented on invalidate to drop stale callbacks
local lastDisplayItems = nil -- store pre-expansion list for re-renders
local pendingCustomizationRebuild = false -- dedup CUSTOMIZATION_CHANGED vs STORAGE_UPDATED

local function BuildCatalogCache(onComplete)
    if catalogScanInFlight then return end
    if not C_HousingCatalog or not C_HousingCatalog.CreateCatalogSearcher then
        catalogReady = true
        if onComplete then onComplete() end
        return
    end
    catalogScanInFlight = true
    local gen = catalogGeneration
    local searcher = C_HousingCatalog.CreateCatalogSearcher()
    catalogSearcher = searcher -- module-level ref prevents GC
    searcher:SetOwnedOnly(true)
    -- Build one-time decorID → itemID reverse lookup for dyed variants missing info.itemID
    local decorToItem = {}
    if HDG_AllDecorDB then
        for iid, adEntry in pairs(HDG_AllDecorDB) do
            if adEntry[1] then decorToItem[adEntry[1]] = iid end
        end
    end
    local function ProcessSearchEntry(entry)
        local decorID = entry.recordID
        if not decorID then return end
        local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfo, entry)
        if not ok or not info then return end
        local itemID = info.itemID or decorToItem[decorID]
        if not itemID then return end
        local cached = catalogCache[itemID]
        if not cached then cached = {}; catalogCache[itemID] = cached end
        if (entry.subtypeIdentifier or 0) == 0 then
            cached.info = info
            cached.decorID = decorID
        else
            -- Only treat as dyed variant if it actually has dye data
            local hasDyes = (info.customizations and #info.customizations > 0)
                or (info.dyeIDs and #info.dyeIDs > 0)
            if hasDyes then
                if not cached.dyeVariants then cached.dyeVariants = {} end
                cached.dyeVariants[#cached.dyeVariants + 1] = {
                    qty = info.quantity or 0,
                    numPlaced = info.numPlaced or 0,
                    dyeNames = info.customizations or {},
                    dyeIDs = info.dyeIDs or {},
                    entryID = info.entryID,
                }
            end
        end
    end

    searcher:SetResultsUpdatedCallback(function()
        if gen ~= catalogGeneration then -- stale callback, cache was invalidated mid-scan
            catalogScanInFlight = false
            if catalogSearcher == searcher then catalogSearcher = nil end
            return
        end
        wipe(catalogCache)
        local results = searcher:GetCatalogSearchResults()
        if results then
            for _, entry in ipairs(results) do ProcessSearchEntry(entry) end
        end
        catalogReady = true
        catalogScanInFlight = false
        catalogSearcher = nil
        if onComplete then onComplete() end
    end)
    searcher:RunSearch()
end

local function InvalidateCatalogCache()
    catalogReady = false
    catalogScanInFlight = false -- allow new scan to start (stale callback dropped via generation)
    catalogGeneration = catalogGeneration + 1
end

local function GetCachedItemInfo(itemID)
    local cached = catalogCache[itemID]
    if cached and cached.info then return cached.info end
    -- Fallback for items not in owned-only scan (e.g. unowned but in a style)
    return C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem
        and C_HousingCatalog.GetCatalogEntryInfoByItem(itemID, true)
end

local function GetDyeVariants(itemID)
    local cached = catalogCache[itemID]
    return cached and cached.dyeVariants
end

-- Expand displayItems: split items with dyed variants into base + dyed cells
local function ExpandDyeVariants(items)
    local expanded = {}
    for _, item in ipairs(items) do
        local variants = GetDyeVariants(item.itemID)
        if variants and #variants > 0 then
            -- Use searcher's base entry directly (undyed-only qty).
            -- Do NOT use GetCachedItemInfo — its fallback returns aggregate.
            local cached = catalogCache[item.itemID]
            local baseInfo = cached and cached.info
            expanded[#expanded + 1] = {
                itemID = item.itemID, decorID = item.decorID,
                entry = item.entry, tier = item.tier,
                _isBaseWithVariants = true,
                _baseQty = baseInfo and (baseInfo.quantity or 0) or 0,
                _basePlaced = baseInfo and (baseInfo.numPlaced or 0) or 0,
                _baseEntryID = baseInfo and baseInfo.entryID,
            }
            for _, v in ipairs(variants) do
                local dyeLabel = (v.dyeNames and #v.dyeNames > 0) and table.concat(v.dyeNames, ", ") or "Dyed"
                expanded[#expanded + 1] = {
                    itemID = item.itemID, decorID = item.decorID,
                    entry = item.entry, tier = item.tier,
                    _isDyedVariant = true,
                    _dyeQty = v.qty,
                    _dyeNumPlaced = v.numPlaced,
                    _dyeLabel = dyeLabel,
                    _dyeIDs = v.dyeIDs,
                    _dyeEntryID = v.entryID,
                }
            end
        else
            expanded[#expanded + 1] = item
        end
    end
    return expanded
end

-- ============================================================================
-- HORIZONTAL RADIO GROUP
-- ============================================================================

local function CreateHorizontalRadioGroup(parent, options, onChange)
    local frame = CreateFrame("Frame", nil, parent)
    frame._buttons = {}
    frame._selected = nil
    frame._onChange = onChange

    local btnWidth = 82
    local btnHeight = 20
    local spacing = 3

    for i, opt in ipairs(options) do
        local btn = CreateFrame("Button", nil, frame)
        btn:SetSize(btnWidth, btnHeight)
        btn:SetPoint("LEFT", (i - 1) * (btnWidth + spacing), 0)

        local indicator = btn:CreateTexture(nil, "ARTWORK")
        indicator:SetSize(10, 10)
        indicator:SetPoint("LEFT", 2, 0)
        indicator:SetColorTexture(0.18, 0.17, 0.12, 1)
        btn._indicator = indicator

        local dot = btn:CreateTexture(nil, "OVERLAY")
        dot:SetSize(5, 5)
        dot:SetPoint("CENTER", indicator, "CENTER")
        dot:SetColorTexture(0.961, 0.722, 0.0, 1)
        dot:Hide()
        btn._dot = dot

        local label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("LEFT", indicator, "RIGHT", 4, 0)
        label:SetText(opt.text or opt)
        label:SetTextColor(0.8, 0.78, 0.7)
        btn._label = label

        btn._value = opt.value or opt
        btn._index = i

        btn:SetScript("OnClick", function()
            frame:SetSelected(i)
            if frame._onChange then
                frame._onChange(btn._value, i)
            end
        end)

        frame._buttons[i] = btn
    end

    local totalWidth = #options * btnWidth + (#options - 1) * spacing
    frame:SetSize(totalWidth, btnHeight)

    function frame:SetSelected(index)
        self._selected = index
        for j, b in ipairs(self._buttons) do
            if j == index then
                b._dot:Show()
                b._indicator:SetColorTexture(0.78, 0.53, 0.04, 1)
                b._label:SetTextColor(1, 0.82, 0)
            else
                b._dot:Hide()
                b._indicator:SetColorTexture(0.18, 0.17, 0.12, 1)
                b._label:SetTextColor(0.8, 0.78, 0.7)
            end
        end
    end

    if #options > 0 then
        frame:SetSelected(1)
    end

    return frame
end

-- ============================================================================
-- ICON BUTTON FACTORY
-- ============================================================================

local function CreateIconButton(parent, index)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(ICON_SIZE, ICON_SIZE)

    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetPoint("TOPLEFT", 3, -3)
    icon:SetPoint("BOTTOMRIGHT", -3, 3)
    btn._icon = icon

    local borderOverlay = btn:CreateTexture(nil, "BORDER")
    borderOverlay:SetAllPoints()
    borderOverlay:SetColorTexture(0.2, 0.2, 0.18, 1)
    btn._borderOverlay = borderOverlay

    local qty = btn:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
    qty:SetPoint("BOTTOMRIGHT", -4, 4)
    qty:SetTextColor(1, 1, 1)
    qty:Hide()
    btn._qty = qty

    -- Placement restriction border (4 thin edges, 2px)
    local function MakeBorderEdge(anchor1, p1, x1, y1, anchor2, p2, x2, y2, w, h)
        local t = btn:CreateTexture(nil, "OVERLAY", nil, 1)
        t:SetPoint(anchor1, btn, p1, x1, y1)
        t:SetPoint(anchor2, btn, p2, x2, y2)
        if w then t:SetWidth(w) end
        if h then t:SetHeight(h) end
        t:SetColorTexture(1.0, 0.5, 0.2, 0.9)
        t:Hide()
        return t
    end
    btn._placeBorder = {
        MakeBorderEdge("TOPLEFT", "TOPLEFT", 0, 0, "TOPRIGHT", "TOPRIGHT", 0, 0, nil, 2),
        MakeBorderEdge("BOTTOMLEFT", "BOTTOMLEFT", 0, 0, "BOTTOMRIGHT", "BOTTOMRIGHT", 0, 0, nil, 2),
        MakeBorderEdge("TOPLEFT", "TOPLEFT", 0, 0, "BOTTOMLEFT", "BOTTOMLEFT", 0, 0, 2, nil),
        MakeBorderEdge("TOPRIGHT", "TOPRIGHT", 0, 0, "BOTTOMRIGHT", "BOTTOMRIGHT", 0, 0, 2, nil),
    }

    -- Dye indicator (Blizzard atlas, top-left corner -- only shown for dyed items)
    local dyeIcon = btn:CreateTexture(nil, "OVERLAY")
    dyeIcon:SetSize(14, 14)
    dyeIcon:SetPoint("TOPLEFT", 1, -1)
    dyeIcon:Hide()
    btn._dyeIcon = dyeIcon

    local hl = btn:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(1, 1, 1, 0.15)

    btn:SetScript("OnEnter", function(self)
        if self._itemName then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(self._itemName, 1, 1, 1)
            local owned = self._ownedQty or 0
            local placed = self._numPlaced or 0
            local total = owned + placed
            if self._isBaseWithVariants then
                GameTooltip:AddLine("Undyed: " .. total .. " (Placed: " .. placed .. ", Storage: " .. owned .. ")", 0, 1, 0)
            else
                GameTooltip:AddLine("Owned: " .. total .. " (Placed: " .. placed .. ", Storage: " .. owned .. ")", 0, 1, 0)
            end
            if self._placementCost and self._placementCost > 0 then
                GameTooltip:AddLine("Placement cost: " .. self._placementCost, 0.6, 0.6, 0.5)
            end
            if self._dyeLabel then
                GameTooltip:AddLine("Dyes: " .. self._dyeLabel, 0.4, 0.8, 1.0)
            end
            if self._placeLabel then
                GameTooltip:AddLine(self._placeLabel, 1.0, 0.5, 0.2)
            end
            GameTooltip:Show()
        end
        -- 3D preview next to hovered icon
        if self._itemID then
            local preview = GetPreviewFrame()
            if preview then
                preview:SetParent(HouseEditorFrame or UIParent)
                preview:SetFrameStrata("DIALOG")
                preview:ClearAllPoints()
                preview:SetPoint("BOTTOMLEFT", HouseEditorFrame and HouseEditorFrame.StoragePanel or hdgContentFrame, "BOTTOMRIGHT", 4, 0)
                preview:LoadItem(self._itemID)
                preview:Show()
            end
        end
    end)
    btn:SetScript("OnLeave", function()
        GameTooltip:Hide()
        if hoverPreviewFrame then hoverPreviewFrame:Hide() end
    end)

    btn._index = index
    btn:Hide()
    return btn
end

-- ============================================================================
-- DATA COLLECTORS
-- ============================================================================

-- Check ownership: present in owned-only scan = owned; fallback to per-item API
local function IsItemOwned(itemID)
    if catalogCache[itemID] then return true end
    local info = GetCachedItemInfo(itemID)
    return info and (info.quantity or 0) + (info.numPlaced or 0) > 0
end

-- Collect items from StyleEngine buckets
-- signatureOnly: only "first" tier items; ownedOnly: filter to collected items
local function CollectStyleItems(styleId, ownedOnly, signatureOnly)
    local StyleEngine = privateDB.HDG_StyleEngine
    if not StyleEngine then return {} end

    local items = StyleEngine:GetStyleItems(styleId)
    if not items then return {} end

    local displayItems = {}
    for bucketKey, bucket in pairs(items) do
        if bucketKey ~= "_byItemID" and type(bucket) == "table" and #bucket > 0 then
            if not signatureOnly or bucketKey == "first" or bucketKey == "curated" then
                for _, item in ipairs(bucket) do
                    if not ownedOnly or IsItemOwned(item.itemID) then
                        displayItems[#displayItems + 1] = { itemID = item.itemID, decorID = item.decorID, entry = item.entry, tier = bucketKey }
                    end
                end
            end
        end
    end
    return displayItems
end

-- Collect items from a shopping list (owned-only)
local function CollectShoppingItems(listId)
    if not HDG.Store then return {} end
    local list = HDG.Store.state.vendorShoppingLists[listId]
    if not list or not list.items then return {} end

    local displayItems = {}
    for _, entry in ipairs(list.items) do
        if entry.itemID and IsItemOwned(entry.itemID) then
            local allDecor = HDG_AllDecorDB and HDG_AllDecorDB[entry.itemID]
            displayItems[#displayItems + 1] = {
                itemID = entry.itemID,
                decorID = allDecor and allDecor[1],
                entry = allDecor,
                tier = "neutral",
            }
        end
    end
    return displayItems
end

-- ============================================================================
-- SIDEBAR DATA PROVIDERS (one per mode)
-- ============================================================================

-- Custom styles sidebar: filterFn(def) selects which styles to include
local function GetCustomStyleSidebarByFilter(filterFn)
    if not HDG.Store then return {} end
    local customs, order = HDG.Store:GetCustomStyles()
    if not customs then return {} end

    local items = {}
    local used = {}
    if order then
        for _, key in ipairs(order) do
            local def = customs[key]
            if def and filterFn(def) then
                items[#items + 1] = { value = key, text = def.displayName or key }
                used[key] = true
            end
        end
    end
    for key, def in pairs(customs) do
        if not used[key] and filterFn(def) then
            items[#items + 1] = { value = key, text = def.displayName or key }
        end
    end
    table.sort(items, function(a, b) return a.text:lower() < b.text:lower() end)
    return items
end

local function GetCustomStyleSidebarItems()
    return GetCustomStyleSidebarByFilter(function(def) return def.tier ~= "snapshot" end)
end

local function GetSnapshotSidebarItems()
    return GetCustomStyleSidebarByFilter(function(def) return def.tier == "snapshot" end)
end

-- Shopping: all player shopping lists
local function GetShoppingListSidebarItems()
    if not HDG.Store then return {} end
    local lists = HDG.Store:GetShoppingLists()
    local items = {}
    for _, list in ipairs(lists) do
        local label = (list.name or "List") .. "  (" .. list.count .. ")"
        items[#items + 1] = { value = list.id, text = label }
    end
    table.sort(items, function(a, b) return a.text:lower() < b.text:lower() end)
    return items
end

-- Themes/collections sidebar caches (static data, built once)
local cachedThemesSidebar = nil
local cachedCollectionsSidebar = nil

-- Themes: built-in styles grouped by category
local function GetThemesSidebarItems()
    if cachedThemesSidebar then return cachedThemesSidebar end
    if not HDG_StyleDefinitions then return {} end
    local StyleEngine = privateDB.HDG_StyleEngine

    local groups = { thematic = {}, themed = {}, faction = {} }
    local groupOrder = { "thematic", "themed", "faction" }
    local groupLabels = { thematic = "Room Concepts", themed = "Themed", faction = "Faction" }

    for id, def in pairs(HDG_StyleDefinitions) do
        local t = def.tier or "thematic"
        if groups[t] then
            local count = 0
            if StyleEngine then
                local stats = StyleEngine:GetStyleStats(id)
                if stats then count = stats.totalCount or ((stats.firstCount or 0) + (stats.secondCount or 0)) end
            end
            groups[t][#groups[t] + 1] = { value = id, text = def.displayName or id, count = count }
        end
    end

    for _, g in pairs(groups) do
        table.sort(g, function(a, b) return a.text < b.text end)
    end

    local items = {}
    for _, gKey in ipairs(groupOrder) do
        local g = groups[gKey]
        if #g > 0 then
            items[#items + 1] = { value = nil, text = groupLabels[gKey], isHeader = true }
            for _, entry in ipairs(g) do
                items[#items + 1] = entry
            end
        end
    end
    cachedThemesSidebar = items
    return items
end

-- Collections: auto-resolved item groups from AllDecorDB name patterns
local function GetCollectionsSidebarItems()
    if cachedCollectionsSidebar then return cachedCollectionsSidebar end
    if not HDG_CollectionDefinitions or not HDG_CollectionOrder then return {} end
    local StyleEngine = privateDB.HDG_StyleEngine
    local items = {}
    for _, collectionId in ipairs(HDG_CollectionOrder) do
        local def = HDG_CollectionDefinitions[collectionId]
        if def then
            local count = 0
            if StyleEngine then
                local stats = StyleEngine:GetStyleStats(collectionId)
                if stats then count = stats.curatedCount or 0 end
            end
            items[#items + 1] = {
                value = collectionId,
                text = def.displayName or collectionId,
                count = count,
            }
        end
    end
    cachedCollectionsSidebar = items
    return items
end

-- ============================================================================
-- POPULATE SIDEBAR (shared, single instance)
-- ============================================================================

local function PopulateSidebar(items, selectedValue, onSelect)
    for _, btn in ipairs(sidebarButtons) do btn:Hide() end
    if not sidebarChild then return end
    if not items or #items == 0 then
        sidebarChild:SetHeight(100)
        return
    end

    sidebarChild:SetHeight(math.max(#items * SIDEBAR_BTN_HEIGHT, 100))

    for i, item in ipairs(items) do
        local btn = sidebarButtons[i]
        if not btn then
            btn = CreateFrame("Button", nil, sidebarChild)
            btn:SetSize(SIDEBAR_WIDTH - 14, SIDEBAR_BTN_HEIGHT)

            local label = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            label:SetPoint("LEFT", 4, 0)
            label:SetPoint("RIGHT", -2, 0)
            label:SetJustifyH("LEFT")
            label:SetWordWrap(false)
            btn._label = label

            local hl = btn:CreateTexture(nil, "HIGHLIGHT")
            hl:SetAllPoints()
            hl:SetColorTexture(0.961, 0.722, 0.0, 0.12)

            local sel = btn:CreateTexture(nil, "ARTWORK")
            sel:SetSize(2, SIDEBAR_BTN_HEIGHT - 4)
            sel:SetPoint("LEFT", 1, 0)
            sel:SetColorTexture(0.961, 0.722, 0.0, 1)
            sel:Hide()
            btn._selBar = sel

            sidebarButtons[i] = btn
        end

        btn:ClearAllPoints()
        btn:SetPoint("TOPLEFT", 2, -(i - 1) * SIDEBAR_BTN_HEIGHT)
        btn._value = item.value

        if item.isHeader then
            btn._label:SetText(item.text)
            btn._label:SetTextColor(1, 0.82, 0)
            btn._selBar:Hide()
            btn:SetScript("OnClick", nil)
            btn:EnableMouse(false)
        else
            btn._label:SetText(item.text)
            btn._label:SetTextColor(0.8, 0.78, 0.7)
            btn:EnableMouse(true)

            btn:SetScript("OnClick", function(self)
                for _, b in ipairs(sidebarButtons) do
                    if b:IsShown() and not b._isHeader then
                        b._selBar:Hide()
                        b._label:SetTextColor(0.8, 0.78, 0.7)
                    end
                end
                self._selBar:Show()
                self._label:SetTextColor(1, 0.82, 0)
                if onSelect then onSelect(self._value) end
            end)

            if item.value == selectedValue then
                btn._selBar:Show()
                btn._label:SetTextColor(1, 0.82, 0)
            end
        end
        btn._isHeader = item.isHeader
        btn:Show()
    end
end

-- ============================================================================
-- POPULATE ICON GRID (shared, single instance)
-- ============================================================================

local QUESTION_MARK_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"

-- Resolve icon/name/qty from catalog cache + fallbacks, apply dye overrides
local function ResolveItemDisplay(item)
    local info = GetCachedItemInfo(item.itemID)
    local iconTexture = info and info.iconTexture
    local itemName = info and info.name
    local ownedQty = info and (info.quantity or 0) or 0
    local numPlaced = info and (info.numPlaced or 0) or 0
    local placementCost = info and (info.placementCost or 0) or 0
    local canCustomize = info and (info.canCustomize or false) or false
    local allowIndoors = (info and info.isAllowedIndoors ~= nil) and info.isAllowedIndoors or true
    local allowOutdoors = (info and info.isAllowedOutdoors ~= nil) and info.isAllowedOutdoors or true

    -- Icon fallback chain
    if not iconTexture or iconTexture == 0 then
        iconTexture = C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(item.itemID)
    end
    if not iconTexture or iconTexture == 0 then
        iconTexture = item.decorID and C_HousingDecor and C_HousingDecor.GetDecorIcon and C_HousingDecor.GetDecorIcon(item.decorID)
    end
    if not itemName and item.entry then
        itemName = item.entry.name or item.entry[3] or item.entry[2]
    end

    -- Dyed variant overrides
    if item._isDyedVariant then
        ownedQty = item._dyeQty or 0
        numPlaced = item._dyeNumPlaced or 0
        itemName = (itemName or "") .. " (" .. (item._dyeLabel or "Dyed") .. ")"
    elseif item._isBaseWithVariants then
        ownedQty = item._baseQty or 0
        numPlaced = item._basePlaced or 0
        itemName = (itemName or "") .. " (Undyed)"
    end

    return (iconTexture and iconTexture ~= 0) and iconTexture or QUESTION_MARK_ICON,
        itemName or ("Item " .. tostring(item.itemID)),
        ownedQty, numPlaced, placementCost, canCustomize, allowIndoors, allowOutdoors
end

-- Click handler for grid cells (module-scope, no per-cell closure allocation)
local function GridCell_OnClick(self)
    local entryID = self._dyeEntryID or self._baseEntryID
    local qty = self._ownedQty or 0

    if not entryID then
        local info
        if self._decorID and C_HousingCatalog.GetCatalogEntryInfoByRecordID then
            info = C_HousingCatalog.GetCatalogEntryInfoByRecordID(1, self._decorID, true)
        end
        if not info and self._itemID then
            info = C_HousingCatalog.GetCatalogEntryInfoByItem(self._itemID, true)
        end
        if not info or not info.entryID then
            print("|cFFFF8800[HDG]|r Item not found in catalog."); return
        end
        entryID = info.entryID
        qty = info.quantity or 0
        if C_Housing.IsInsideHouse then
            local isInside = C_Housing.IsInsideHouse()
            if isInside and not self._allowIndoors then
                print("|cFFFF8800[HDG]|r \"" .. (self._itemName or "?") .. "\" is outdoor only."); return
            elseif not isInside and not self._allowOutdoors then
                print("|cFFFF8800[HDG]|r \"" .. (self._itemName or "?") .. "\" is indoor only."); return
            end
        end
        if qty < 1 then
            local msg = (info.numPlaced or 0) > 0 and "All copies of" or "You don't own"
            print("|cFFFF8800[HDG]|r " .. msg .. " \"" .. (self._itemName or "?") .. "\"."); return
        end
    else
        if C_Housing.IsInsideHouse then
            local isInside = C_Housing.IsInsideHouse()
            if isInside and not self._allowIndoors then
                print("|cFFFF8800[HDG]|r \"" .. (self._itemName or "?") .. "\" is outdoor only."); return
            elseif not isInside and not self._allowOutdoors then
                print("|cFFFF8800[HDG]|r \"" .. (self._itemName or "?") .. "\" is indoor only."); return
            end
        end
        if qty < 1 then
            local msg = (self._numPlaced or 0) > 0 and "All copies of" or "You don't own"
            print("|cFFFF8800[HDG]|r " .. msg .. " \"" .. (self._itemName or "?") .. "\"."); return
        end
    end

    placedFromHDGTab = true
    C_Timer.After(0.1, function()
        C_HousingBasicMode.StartPlacingNewDecor(entryID)
    end)
end

local function PopulateGrid(displayItems)
    for _, btn in ipairs(gridButtons) do btn:Hide() end
    if not displayItems or #displayItems == 0 then return end

    lastDisplayItems = displayItems

    -- Kick off catalog scan for dye variant detection (async, re-renders on completion)
    if not catalogReady then
        BuildCatalogCache(function()
            if lastDisplayItems then PopulateGrid(lastDisplayItems) end
        end)
    end

    displayItems = ExpandDyeVariants(displayItems)

    local gridWidth = gridScrollChild and gridScrollChild:GetWidth() or 340
    local cols = math.max(1, math.floor(gridWidth / (ICON_SIZE + ICON_PADDING)))
    local rows = math.ceil(#displayItems / cols)
    if gridScrollChild then
        gridScrollChild:SetHeight(math.max(rows * (ICON_SIZE + ICON_PADDING), 100))
    end

    local playerIndoors = C_Housing and C_Housing.IsInsideHouse and C_Housing.IsInsideHouse()

    for i, item in ipairs(displayItems) do
        local btn = gridButtons[i]
        if not btn then
            btn = CreateIconButton(gridScrollChild, i)
            gridButtons[i] = btn
        end

        local row = math.floor((i - 1) / cols)
        local col = (i - 1) % cols
        btn:ClearAllPoints()
        btn:SetPoint("TOPLEFT", col * (ICON_SIZE + ICON_PADDING), -(row * (ICON_SIZE + ICON_PADDING)))

        local iconTexture, itemName, ownedQty, numPlaced, placementCost, canCustomize, allowIndoors, allowOutdoors
            = ResolveItemDisplay(item)

        btn._icon:SetTexture(iconTexture)
        btn._borderOverlay:SetColorTexture(0, 0, 0, 0.9)
        btn._qty:SetText(ownedQty > 0 and ownedQty or "")
        btn._qty:SetShown(ownedQty > 0)
        btn._itemName = itemName
        btn._ownedQty = ownedQty
        btn._numPlaced = numPlaced
        btn._placementCost = placementCost
        btn._canCustomize = canCustomize
        btn._allowIndoors = allowIndoors
        btn._allowOutdoors = allowOutdoors
        btn._placeLabel = (allowIndoors and not allowOutdoors and "Indoor only")
            or (allowOutdoors and not allowIndoors and "Outdoor only") or nil
        btn._itemID = item.itemID
        btn._decorID = item.decorID
        btn._dyeEntryID = item._dyeEntryID
        btn._baseEntryID = item._baseEntryID
        btn._isBaseWithVariants = item._isBaseWithVariants or false
        btn._dyeLabel = item._dyeLabel
        btn._dyeIcon:SetShown(item._isDyedVariant or false)
        if item._isDyedVariant then btn._dyeIcon:SetAtlas("dye-drop_32") end

        -- Dye color swatches along top edge (after dye icon)
        if not btn._dyeSwatches then btn._dyeSwatches = {} end
        for _, sw in ipairs(btn._dyeSwatches) do sw:Hide() end
        if item._isDyedVariant and item._dyeIDs and C_DyeColor and C_DyeColor.GetDyeColorInfo then
            local swX = 16 -- start after dye icon (14px + 2px gap)
            for si, dyeColorID in ipairs(item._dyeIDs) do
                if dyeColorID and dyeColorID > 0 then
                    local sw = btn._dyeSwatches[si]
                    if not sw then
                        sw = btn:CreateTexture(nil, "OVERLAY")
                        sw:SetSize(8, 8)
                        btn._dyeSwatches[si] = sw
                    end
                    sw:ClearAllPoints()
                    sw:SetPoint("TOPLEFT", swX, -4)
                    local dyeInfo = C_DyeColor.GetDyeColorInfo(dyeColorID)
                    if dyeInfo and dyeInfo.swatchColorStart then
                        local c = dyeInfo.swatchColorStart
                        sw:SetColorTexture(c.r, c.g, c.b, 1)
                    else
                        sw:SetColorTexture(0.5, 0.5, 0.5, 1)
                    end
                    sw:Show()
                    swX = swX + 10
                end
            end
        end

        -- Placement restriction border
        local showBorder = (playerIndoors and allowOutdoors and not allowIndoors)
            or (not playerIndoors and allowIndoors and not allowOutdoors)
        for _, edge in ipairs(btn._placeBorder) do edge:SetShown(showBorder) end

        btn:SetScript("OnClick", GridCell_OnClick)
        btn:Show()
    end
end

-- ============================================================================
-- MODE SWITCHING (repopulates single sidebar + grid)
-- ============================================================================

-- Mode config: sidebar provider, empty text, onSelect handler
local MODE_CONFIG = {
    styles    = { sidebar = GetCustomStyleSidebarItems,  empty = "No custom styles yet.\nCreate styles in the HDG Styles tab.", signatureOnly = true },
    shopping  = { sidebar = GetShoppingListSidebarItems, empty = "No shopping lists.\nAdd items via the Acquisition tab." },
    snapshots = { sidebar = GetSnapshotSidebarItems,     empty = "No house snapshots yet.\nSave one from the HDG Styles tab." },
    themes    = { sidebar = GetThemesSidebarItems },
    collections = { sidebar = GetCollectionsSidebarItems },
}

local function RefreshCurrentMode()
    if not sidebarChild then return end

    local cfg = MODE_CONFIG[currentMode]
    if not cfg then return end

    local sidebarItems = cfg.sidebar()
    local selectedValue, onSelect

    if currentMode == "shopping" then
        selectedValue = currentShoppingListId
        onSelect = function(value)
            currentShoppingListId = value
            PopulateGrid(CollectShoppingItems(value))
        end
    else
        selectedValue = currentStyleId
        onSelect = function(value)
            currentStyleId = value
            PopulateGrid(CollectStyleItems(value, true, cfg.signatureOnly))
        end
    end

    if emptyLabel then
        if #sidebarItems == 0 then
            emptyLabel:SetText(cfg.empty or "Nothing to show.")
            emptyLabel:Show()
            PopulateGrid({})
        else
            emptyLabel:Hide()
        end
    end

    PopulateSidebar(sidebarItems, selectedValue, onSelect)
end

local function SwitchMode(mode)
    currentMode = mode
    currentStyleId = nil
    currentShoppingListId = nil
    PopulateGrid({}) -- clear grid when switching
    RefreshCurrentMode()
end

-- ============================================================================
-- SHOW/HIDE HDG CONTENT
-- ============================================================================

local BLIZZARD_PANELS = { "OptionsContainer", "Categories", "SearchBox", "Filters" }

local function ShowHDGContent(storagePanel)
    for _, key in ipairs(BLIZZARD_PANELS) do
        if storagePanel[key] then storagePanel[key]:Hide() end
    end
    if hdgContentFrame then hdgContentFrame:Show() end
    if not catalogReady then
        BuildCatalogCache(function()
            if lastDisplayItems then PopulateGrid(lastDisplayItems) end
        end)
    end
end

local function HideHDGContent(storagePanel)
    if hdgContentFrame then hdgContentFrame:Hide() end
    for _, key in ipairs(BLIZZARD_PANELS) do
        if storagePanel[key] then storagePanel[key]:Show() end
    end
end

-- ============================================================================
-- INJECT TAB
-- ============================================================================

local function InjectTab()
    if injected then return end
    if InCombatLockdown() then return end

    local storagePanel = HouseEditorFrame and HouseEditorFrame.StoragePanel
    if not storagePanel then
        Log(LOG_LEVEL.WARN, MODULE_NAME, "HouseEditorFrame.StoragePanel not found")
        return
    end

    if not storagePanel.AddNamedTab then
        Log(LOG_LEVEL.WARN, MODULE_NAME, "StoragePanel has no AddNamedTab")
        return
    end

    Log(LOG_LEVEL.INFO, MODULE_NAME, "Injecting HDG tab into HouseEditorStorageFrame...")

    -- Main content container
    hdgContentFrame = CreateFrame("Frame", "HDG_HouseEditorContent", storagePanel)
    hdgContentFrame:SetPoint("TOPLEFT", storagePanel, "TOPLEFT", 8, -40)
    hdgContentFrame:SetPoint("BOTTOMRIGHT", storagePanel, "BOTTOMRIGHT", -8, 30)
    hdgContentFrame:Hide()

    -- Header: icon with gold border + title (parented to storagePanel so they sit in the header area)
    local headerIconBorder = storagePanel:CreateTexture(nil, "ARTWORK")
    headerIconBorder:SetSize(24, 24)
    headerIconBorder:SetPoint("TOPLEFT", hdgContentFrame, "TOPLEFT", 2, 23)
    headerIconBorder:SetColorTexture(0.761, 0.561, 0.0, 1)
    headerIconBorder:Hide()

    local headerIcon = storagePanel:CreateTexture(nil, "OVERLAY")
    headerIcon:SetSize(22, 22)
    headerIcon:SetPoint("CENTER", headerIconBorder, "CENTER")
    headerIcon:SetTexture("Interface\\AddOns\\HousingDecorGuide\\textures\\HousingDecorIcon")
    headerIcon:Hide()

    local headerText = storagePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    headerText:SetPoint("LEFT", headerIcon, "RIGHT", 4, 0)
    headerText:SetText("Housing Decor Guide")
    headerText:SetTextColor(0.8, 0.78, 0.7)
    headerText:Hide()

    -- Show/hide header with content frame
    hdgContentFrame:HookScript("OnShow", function() headerIconBorder:Show(); headerIcon:Show(); headerText:Show() end)
    hdgContentFrame:HookScript("OnHide", function() headerIconBorder:Hide(); headerIcon:Hide(); headerText:Hide() end)

    -- Horizontal RadioGroup below header
    modeFrame = CreateHorizontalRadioGroup(hdgContentFrame, {
        { text = "Your Styles",  value = "styles" },
        { text = "Shopping",     value = "shopping" },
        { text = "Snapshots",    value = "snapshots" },
        { text = "Themes",       value = "themes" },
        { text = "Collections",  value = "collections" },
    }, function(value)
        SwitchMode(value)
    end)
    modeFrame:SetPoint("TOPLEFT", 4, -6)

    -- Single sidebar + grid content area (below RadioGroup)
    local contentArea = CreateFrame("Frame", nil, hdgContentFrame)
    contentArea:SetPoint("TOPLEFT", 0, -46)
    contentArea:SetPoint("BOTTOMRIGHT", 0, 0)

    -- Left sidebar
    local sidebarBg = CreateFrame("Frame", nil, contentArea)
    sidebarBg:SetPoint("TOPLEFT", 0, 0)
    sidebarBg:SetPoint("BOTTOMLEFT", 0, 0)
    sidebarBg:SetWidth(SIDEBAR_WIDTH)

    local bgTex = sidebarBg:CreateTexture(nil, "BACKGROUND")
    bgTex:SetAllPoints()
    bgTex:SetAtlas("house-chest-nav-bg_primary")

    local sidebarScroll = CreateFrame("ScrollFrame", "HDG_HE_SidebarScroll", sidebarBg, "UIPanelScrollFrameTemplate")
    sidebarScroll:SetPoint("TOPLEFT", 0, -2)
    sidebarScroll:SetPoint("BOTTOMRIGHT", -14, 2)

    sidebarChild = CreateFrame("Frame", nil, sidebarScroll)
    sidebarChild:SetWidth(SIDEBAR_WIDTH - 14)
    sidebarChild:SetHeight(400)
    sidebarScroll:SetScrollChild(sidebarChild)

    -- Empty state label
    emptyLabel = contentArea:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    emptyLabel:SetPoint("CENTER", 0, 0)
    emptyLabel:SetTextColor(0.5, 0.5, 0.45)
    emptyLabel:Hide()

    -- Right side: scrollable icon grid
    local gridScroll = CreateFrame("ScrollFrame", "HDG_HE_GridScroll", contentArea, "UIPanelScrollFrameTemplate")
    gridScroll:SetPoint("TOPLEFT", SIDEBAR_WIDTH + 4, 0)
    gridScroll:SetPoint("BOTTOMRIGHT", -22, 4)

    gridScrollChild = CreateFrame("Frame", nil, gridScroll)
    local initW = gridScroll:GetWidth()
    gridScrollChild:SetWidth(initW > 0 and initW or 400)
    gridScrollChild:SetHeight(400)
    gridScroll:SetScrollChild(gridScrollChild)

    gridScroll:SetScript("OnSizeChanged", function(self, width)
        gridScrollChild:SetWidth(width)
    end)

    -- Default to Your Styles mode (radio button index 1)
    modeFrame:SetSelected(1)
    SwitchMode("styles")

    -- Add our tab
    hdgTabID = storagePanel:AddNamedTab("HDG Styles")
    Log(LOG_LEVEL.INFO, MODULE_NAME, "AddNamedTab returned tabID=" .. tostring(hdgTabID))

    -- Tab callbacks
    if storagePanel.SetTabCallback then
        storagePanel:SetTabCallback(hdgTabID, function()
            ShowHDGContent(storagePanel)
        end)
    end

    if storagePanel.SetTabDeselectCallback then
        storagePanel:SetTabDeselectCallback(hdgTabID, function()
            HideHDGContent(storagePanel)
        end)
    end

    -- Hook SetTab for our custom tab
    -- Blizzard's OnShow unconditionally calls SetTab(storageTabID) when the panel
    -- re-shows after placement. We intercept that here: if the HDG tab was active
    -- when placement started (placedFromHDGTab), redirect back to HDG synchronously.
    local originalSetTab = storagePanel.SetTab
    local userTabSwitch = false -- true when SetTab is called via TabSystem (user click)

    storagePanel.SetTab = function(self, tabID, isUserAction)
        if tabID == hdgTabID then
            ShowHDGContent(self)
            return
        end
        -- Intercept Blizzard's automatic OnShow reset: redirect storageTabID back to HDG.
        -- Only intercept automatic resets (not user clicking a tab via TabSystem).
        local hdgWasActive = placedFromHDGTab or (hdgContentFrame and hdgContentFrame:IsShown())
        if hdgWasActive and tabID == self.storageTabID and not userTabSwitch then
            placedFromHDGTab = false
            ShowHDGContent(self)
            if self.TabSystem then self.TabSystem:SetTab(hdgTabID) end
            return
        end
        placedFromHDGTab = false
        HideHDGContent(self)
        if originalSetTab then
            return originalSetTab(self, tabID, isUserAction)
        end
    end

    -- Hook TabSystem callback to route our tab
    if storagePanel.TabSystem then
        local origCallback = storagePanel.TabSystem.tabSelectedCallback
        storagePanel.TabSystem:SetTabSelectedCallback(function(tabID, isUserAction)
            if tabID == hdgTabID then
                storagePanel:SetTab(tabID, isUserAction)
                return
            end
            userTabSwitch = true
            if origCallback then
                origCallback(tabID, isUserAction)
            end
            userTabSwitch = false
        end)
    end

    -- Hook OptionsContainer OnShow: Blizzard re-shows it on storage events
    if storagePanel.OptionsContainer then
        storagePanel.OptionsContainer:HookScript("OnShow", function(self)
            if hdgContentFrame and hdgContentFrame:IsShown() then
                self:Hide()
            end
        end)
    end

    injected = true
    Log(LOG_LEVEL.INFO, MODULE_NAME, "House Editor tab injected, tabID=" .. tostring(hdgTabID))
end

-- ============================================================================
-- EVENT HOOK: Wait for Blizzard_HouseEditor to load
-- ============================================================================

local hookFrame = CreateFrame("Frame")
hookFrame:RegisterEvent("ADDON_LOADED")
hookFrame:RegisterEvent("HOUSE_EDITOR_MODE_CHANGED")
hookFrame:RegisterEvent("HOUSING_DECOR_PLACE_SUCCESS")
hookFrame:RegisterEvent("HOUSING_DECOR_PLACE_FAILURE")
hookFrame:RegisterEvent("HOUSING_STORAGE_UPDATED")
hookFrame:RegisterEvent("HOUSING_DECOR_CUSTOMIZATION_CHANGED")
hookFrame:SetScript("OnEvent", function(self, event, arg1)
    if event == "ADDON_LOADED" and arg1 == "Blizzard_HouseEditor" then
        self:UnregisterEvent("ADDON_LOADED")
        C_Timer.After(0, InjectTab)
    elseif event == "HOUSE_EDITOR_MODE_CHANGED" then
        if not injected and HouseEditorFrame then
            C_Timer.After(0, InjectTab)
        end
    elseif event == "HOUSING_DECOR_PLACE_FAILURE" then
        InvalidateCatalogCache()
        placedFromHDGTab = false -- prevent Storage tab hijack on cancelled placement
    elseif event == "HOUSING_DECOR_PLACE_SUCCESS" then
        InvalidateCatalogCache()
    elseif event == "HOUSING_DECOR_CUSTOMIZATION_CHANGED" then
        -- Dye applied/removed: delay slightly for catalog to update.
        -- Flag prevents HOUSING_STORAGE_UPDATED (which fires in the same frame) from
        -- starting a redundant scan that the 0.2s timer would supersede.
        InvalidateCatalogCache()
        pendingCustomizationRebuild = true
        if injected then
            C_Timer.After(0.2, function()
                pendingCustomizationRebuild = false
                BuildCatalogCache(function()
                    if hdgContentFrame and hdgContentFrame:IsShown() and lastDisplayItems and #lastDisplayItems > 0 then
                        PopulateGrid(lastDisplayItems)
                    end
                end)
            end)
        end
    elseif event == "HOUSING_STORAGE_UPDATED" then
        -- Quantities changed: rebuild cache and re-render if HDG tab is visible.
        -- Skip if a CUSTOMIZATION_CHANGED rebuild is already pending (dye ops fire both events).
        InvalidateCatalogCache()
        if injected and not pendingCustomizationRebuild then
            BuildCatalogCache(function()
                if hdgContentFrame and hdgContentFrame:IsShown() and lastDisplayItems and #lastDisplayItems > 0 then
                    PopulateGrid(lastDisplayItems)
                end
            end)
        end
    end
end)

if C_AddOns.IsAddOnLoaded("Blizzard_HouseEditor") then
    C_Timer.After(0, InjectTab)
end
