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 sidebarScrollBox = nil
local sidebarScrollBar = nil
local sidebarScrollView = nil
local gridScrollBox = nil
local emptyLabel = nil
local currentMode = "styles"
local currentStyleId = nil
local currentShoppingListId = nil
local currentRecentSessionId = nil
-- True when the user has manually clicked a Recent sidebar entry in this editor
-- session. While true, refresh leaves their selection alone. Reset on editor
-- open so a fresh session defaults to "Now".
local recentManualSelection = false
local placedFromHDGTab = false
local recentCapStepper = nil

-- 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 lastDisplayItems = nil -- store pre-expansion list for re-renders
local pendingCustomizationRebuild = false -- dedup CUSTOMIZATION_CHANGED vs STORAGE_UPDATED

-- Catalog reads now go through the central HDG_Data cache, which is
-- populated by a single shared CatalogSearcher and persisted across reloads.
-- This file used to maintain its own catalogCache + searcher; both retired.

local function GetCachedItemInfo(itemID)
    if not itemID then return nil end -- Recent Activity items have decorID but no itemID
    return HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)
end

-- Convert the central row's `variants[subtypeID] = {...}` map to the array
-- shape consumers here expect: { {qty, numPlaced, dyeNames, dyeIDs, entryID}, ... }
local function GetDyeVariants(itemID)
    if not itemID then return nil end
    local row = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)
    if not row or not row.variants then return nil end
    local out = {}
    for _, v in pairs(row.variants) do
        local hasDyes = (v.dyeNames and #v.dyeNames > 0) or (v.dyeIDs and #v.dyeIDs > 0)
        if hasDyes then
            out[#out + 1] = {
                qty = v.quantity or 0,
                numPlaced = v.numPlaced or 0,
                dyeNames = v.dyeNames or {},
                dyeIDs = v.dyeIDs or {},
                entryID = v.entryID,
            }
        end
    end
    return #out > 0 and out or nil
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
            -- baseInfo's quantity is AGGREGATE across all variants (12.0.5 API
            -- change). Pure undyed storage = aggregate - sum(variant numStored).
            -- numPlaced split is unrecoverable: GetAllVariantInfosForEntry
            -- doesn't expose per-variant numPlaced. The base tooltip drops the
            -- placed line entirely (see GridCell tooltip handler).
            local baseInfo = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(item.itemID)
            local variantTotalQty = 0
            for _, v in ipairs(variants) do
                variantTotalQty = variantTotalQty + (v.qty or 0)
            end
            local aggQty = baseInfo and (baseInfo.quantity or 0) or 0
            local pureUndyedQty = math.max(0, aggQty - variantTotalQty)
            -- Row-aggregate numPlaced sums all placed instances across variants.
            -- Per-variant split is unrecoverable (GetAllVariantInfosForEntry returns
            -- numStored only). We pipe the aggregate to all expanded cells so the
            -- tooltip can surface it honestly with a "split unavailable" note.
            local rowTotalPlaced = baseInfo and (baseInfo.numPlaced or 0) or 0
            expanded[#expanded + 1] = {
                itemID = item.itemID, decorID = item.decorID,
                entry = item.entry, tier = item.tier,
                _isBaseWithVariants = true,
                _baseQty = pureUndyedQty,
                _basePlaced = 0, -- can't split aggregate placed across variants
                _baseEntryID = baseInfo and baseInfo.entryID,
                _rowTotalPlaced = rowTotalPlaced,
            }
            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,
                    _rowTotalPlaced = rowTotalPlaced,
                }
            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 ConstructInEditorCell(parent)
    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

    -- Unique-trophy indicator (replaces numeric qty for isUniqueTrophy items).
    -- Matches Blizzard's pattern: info icon in place of quantity (Blizzard_HousingCatalogEntry.lua:249-253).
    local trophyIcon = btn:CreateTexture(nil, "OVERLAY")
    trophyIcon:SetSize(16, 16)
    trophyIcon:SetPoint("BOTTOMRIGHT", -2, 2)
    trophyIcon:SetAtlas("house-chest-trophy-icon")
    trophyIcon:Hide()
    btn._trophyIcon = trophyIcon

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

            -- Recent Activity tooltip: event type + relative time, then session-scoped count
            if self._recentEventType then
                local function relTime(ts)
                    if not ts then return "" end
                    local elapsed = time() - ts
                    if elapsed < 60 then return "just now" end
                    if elapsed < 3600 then return string.format("%dm ago", math.floor(elapsed / 60)) end
                    if elapsed < 86400 then return string.format("%dh ago", math.floor(elapsed / 3600)) end
                    return date("%b %d", ts)
                end
                local action = (self._recentEventType == "placed") and "Placed" or "Removed"
                local when = relTime(self._recentTimestamp)
                GameTooltip:AddLine(action .. " " .. when, 0.7, 0.85, 1.0)

                local live = self._recentLiveCount or 0
                local dead = self._recentDeadCount or 0
                local count = self._recentEventCount or 0
                if self._recentEventType == "placed" then
                    if dead > 0 then
                        GameTooltip:AddLine(live .. " still placed of " .. (live + dead) .. " placed this session", 0.85, 0.85, 0.85)
                    elseif live > 1 then
                        GameTooltip:AddLine(live .. " placed this session", 0.85, 0.85, 0.85)
                    end
                elseif self._recentEventType == "removed" and count > 1 then
                    GameTooltip:AddLine(count .. " removed this session", 0.85, 0.85, 0.85)
                end
            end

            local owned = self._ownedQty or 0
            local placed = self._numPlaced or 0
            local total = owned + placed
            if self._isBaseWithVariants then
                -- Per-variant placed counts aren't exposed by the variant API
                -- (GetAllVariantInfosForEntry returns numStored only). Show
                -- the row-aggregate placed total honestly with a "split unavailable" note.
                GameTooltip:AddLine("Undyed in storage: " .. owned, 0, 1, 0)
                local rowPlaced = self._rowTotalPlaced or 0
                if rowPlaced > 0 then
                    GameTooltip:AddLine(rowPlaced .. " placed across all variants", 0.7, 0.85, 1.0)
                    GameTooltip:AddLine("(per-variant split not available from API)", 0.5, 0.5, 0.5)
                end
            elseif self._isDyedVariant then
                -- Variant cards: only storage count is reliable; numPlaced is
                -- always 0 from the API. Surface the row-aggregate so the user
                -- knows the total — they can see the split is unavailable.
                GameTooltip:AddLine("In storage: " .. owned, 0, 1, 0)
                local rowPlaced = self._rowTotalPlaced or 0
                if rowPlaced > 0 then
                    GameTooltip:AddLine(rowPlaced .. " placed across all variants", 0.7, 0.85, 1.0)
                    GameTooltip:AddLine("(per-variant split not available from API)", 0.5, 0.5, 0.5)
                end
            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. Use LoadItemData so both itemID-based
        -- (Styles/Shopping/etc) and decorID-based (Recent Activity) cells work.
        if self._itemID or self._decorID 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)
                if preview.LoadItemData then
                    preview:LoadItemData({ itemID = self._itemID, decorID = self._decorID })
                else
                    preview:LoadItem(self._itemID)
                end
                preview:Show()
            end
        end
    end)
    btn:SetScript("OnLeave", function()
        GameTooltip:Hide()
        if hoverPreviewFrame then hoverPreviewFrame:Hide() end
    end)

    -- Recent Activity session-count badge (bottom-left). Separate from _qty
    -- which is BOTTOMRIGHT and used by other modes for catalog ownership.
    btn._recentCountBadge = btn:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
    btn._recentCountBadge:SetPoint("BOTTOMLEFT", 4, 4)
    btn._recentCountBadge:SetTextColor(1, 1, 1)
    btn._recentCountBadge:Hide()

    -- Recent Activity overlays (top-right): + for placed, - for removed
    btn._recentEventOverlay = btn:CreateTexture(nil, "OVERLAY")
    btn._recentEventOverlay:SetSize(14, 14)
    btn._recentEventOverlay:SetPoint("TOPRIGHT", -2, -2)
    btn._recentEventOverlay:Hide()

    btn._recentRemovedOverlay = btn:CreateTexture(nil, "OVERLAY")
    btn._recentRemovedOverlay:SetSize(14, 14)
    btn._recentRemovedOverlay:SetPoint("TOPRIGHT", -2, -16) -- stacked below + when both apply
    btn._recentRemovedOverlay:Hide()

    return btn
end

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

-- Check ownership via the central HDG_Data signal (decorID-keyed owned set).
local function IsItemOwned(itemID)
    return HDG_Data and HDG_Data.IsItemCollected and HDG_Data.IsItemCollected(itemID) or false
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, WowScrollBoxList)
-- ============================================================================

-- Sidebar selection state (read by the initializer closure)
local sidebarSelectedValue, sidebarOnSelect

local function ConstructSidebarButton(frame)
    frame:SetSize(SIDEBAR_WIDTH - 14, SIDEBAR_BTN_HEIGHT)

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

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

    local sel = frame: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()
    frame._selBar = sel
end

local function HDG_HE_RenderSidebarItem(frame, item)
    if not frame._hdgConstructed then
        ConstructSidebarButton(frame)
        frame._hdgConstructed = true
    end

    frame._value = item.value

    if item.isHeader then
        frame._label:SetText(item.text)
        frame._label:SetTextColor(1, 0.82, 0)
        frame._selBar:Hide()
        frame:SetScript("OnClick", nil)
        frame:EnableMouse(false)
    else
        frame._label:SetText(item.text)
        frame._label:SetTextColor(0.8, 0.78, 0.7)
        frame:EnableMouse(true)
        frame:SetScript("OnClick", function(self)
            sidebarSelectedValue = self._value   -- keep state in sync for frame recycling
            sidebarScrollBox:ForEachFrame(function(b)
                if b._selBar then b._selBar:Hide() end
                if b._label then b._label:SetTextColor(0.8, 0.78, 0.7) end
            end)
            self._selBar:Show()
            self._label:SetTextColor(1, 0.82, 0)
            if sidebarOnSelect then sidebarOnSelect(self._value) end
        end)

        if item.value == sidebarSelectedValue then
            frame._selBar:Show()
            frame._label:SetTextColor(1, 0.82, 0)
        else
            frame._selBar:Hide()
            frame._label:SetTextColor(0.8, 0.78, 0.7)
        end
    end
end

local function PopulateSidebar(items, selectedValue, onSelect)
    if not sidebarScrollBox then return end
    sidebarSelectedValue = selectedValue
    sidebarOnSelect = onSelect
    local provider = CreateDataProvider(items or {})
    sidebarScrollBox:SetDataProvider(provider, ScrollBoxConstants.RetainScrollPosition)
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)
    -- Recent Activity items have decorID but no itemID. Look them up via the
    -- decorID-keyed catalog API so the tooltip shows real ownership counts.
    -- Recent Activity items have decorID but no itemID. Look them up via decorID.
    if (not info) and item.decorID and HDG_Data and HDG_Data.GetCatalog then
        info = HDG_Data.GetCatalog(item.decorID)
    end
    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
    local isUniqueTrophy = info and info.isUniqueTrophy or false

    -- Icon fallback chain
    if (not iconTexture or iconTexture == 0) and item.itemID 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

    -- Recent Activity overrides: events carry resolved name/icon directly
    if item._recentIcon then iconTexture = item._recentIcon end
    if item._recentName then itemName = item._recentName end

    return (iconTexture and iconTexture ~= 0) and iconTexture or QUESTION_MARK_ICON,
        itemName or ("Item " .. tostring(item.itemID or item.decorID or "?")),
        ownedQty, numPlaced, placementCost, canCustomize, allowIndoors, allowOutdoors, isUniqueTrophy
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 = (self._decorID and HDG_Data and HDG_Data.GetCatalog and HDG_Data.GetCatalog(self._decorID))
                  or (self._itemID and HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(self._itemID))
        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 name = (self._itemName or "?")
            local msg = (info.numPlaced or 0) > 0
                and ("All copies of \"" .. name .. "\" are already placed.")
                or  ("You don't own \"" .. name .. "\".")
            print("|cFFFF8800[HDG]|r " .. msg); 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 name = (self._itemName or "?")
            -- For dyed variants the variant API doesn't expose numPlaced, so
            -- self._numPlaced is always 0 and we can't tell "all placed" from
            -- "don't own". Show a message that's correct for both cases.
            local msg
            if self._isDyedVariant then
                msg = "No \"" .. name .. "\" available in storage."
            elseif (self._numPlaced or 0) > 0 then
                msg = "All copies of \"" .. name .. "\" are already placed."
            else
                msg = "You don't own \"" .. name .. "\"."
            end
            print("|cFFFF8800[HDG]|r " .. msg); return
        end
    end

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

-- BindInEditorCell: per-cell binder for WowScrollBoxList rows.
-- Mirrors the legacy PopulateGrid loop body exactly; called by InitializeRow.
local function BindInEditorCell(cell, item, cfg) -- luacheck: ignore cfg (unused, required by helper sig)
    if not item then cell:Hide(); return end

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

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

    btn._icon:SetTexture(iconTexture)
    btn._borderOverlay:SetColorTexture(0, 0, 0, 0.9)
    if isUniqueTrophy then
        -- Unique trophy: show atlas icon instead of a "1" badge (Blizzard parity).
        btn._qty:Hide()
        btn._trophyIcon:SetShown(ownedQty > 0)
    else
        btn._qty:SetText(ownedQty > 0 and ownedQty or "")
        btn._qty:SetShown(ownedQty > 0)
        btn._trophyIcon:Hide()
    end
    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._isDyedVariant = item._isDyedVariant or false
    btn._dyeLabel = item._dyeLabel
    btn._rowTotalPlaced = item._rowTotalPlaced or 0
    -- Recent Activity fields propagated for OnEnter tooltip and click handling
    btn._recentName       = item._recentName
    btn._recentEventType  = item._recentEventType
    btn._recentLiveCount  = item._recentLiveCount
    btn._recentDeadCount  = item._recentDeadCount
    btn._recentEventCount = item._recentEventCount
    btn._recentTimestamp  = item._recentTimestamp
    btn._dyeIcon:SetShown(item._isDyedVariant or false)
    -- Palette atlas matches Blizzard's "is dyed" indicator
    -- (Blizzard_HousingTemplates/Blizzard_HousingCatalogEntry.xml: housing-dyable-palette-icon).
    if item._isDyedVariant then btn._dyeIcon:SetAtlas("housing-dyable-palette-icon") end

    -- Dye color droplets along top-right edge, stacked left. Mirrors
    -- HousingCatalogDyeDisplayMixin (Blizzard_HousingTemplates/Blizzard_HousingCatalogEntry.lua:37-53):
    -- atlas-textured droplet shape, tinted via SetVertexColor with swatchColorStart.
    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 swOffset = -2 -- start at TOPRIGHT, tight margin
        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, 10) -- droplet shape: narrow, slight vertical
                    btn._dyeSwatches[si] = sw
                end
                sw:ClearAllPoints()
                sw:SetPoint("TOPRIGHT", swOffset, -2)
                sw:SetAtlas("dye-drop_32") -- white droplet, tint via SetVertexColor
                local dyeInfo = C_DyeColor.GetDyeColorInfo(dyeColorID)
                if dyeInfo and dyeInfo.swatchColorStart then
                    local c = dyeInfo.swatchColorStart
                    sw:SetVertexColor(c.r, c.g, c.b, 1)
                    sw:SetAlpha(1)
                else
                    sw:SetVertexColor(1, 1, 1, 1)
                    sw:SetAlpha(0.2)
                end
                sw:Show()
                swOffset = swOffset - 7 -- tight stacking left
            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

    -- Recent Activity rendering (runs last; wins over ownedQty set above).
    -- Session count goes in the BOTTOM-LEFT badge so it doesn't collide with
    -- _qty (catalog ownership, BOTTOM-RIGHT) used by other modes.
    if item._recentEventType then
        local placedAtlas  = "communities-chat-icon-plus"
        local removedAtlas = "communities-chat-icon-minus"
        local et = item._recentEventType
        local liveCount = item._recentLiveCount or 0
        local deadCount = item._recentDeadCount or 0

        -- Hide the catalog-ownership BOTTOM-RIGHT badge in Recent mode; the
        -- session count goes in the BOTTOM-LEFT badge instead.
        btn._qty:Hide()
        btn._trophyIcon:Hide()

        local function setSessionCount(n)
            btn._recentCountBadge:SetText(n > 1 and ("x" .. n) or "")
            btn._recentCountBadge:SetShown(n > 1)
        end

        if et == "placed" and liveCount > 0 then
            btn._recentEventOverlay:SetAtlas(placedAtlas); btn._recentEventOverlay:Show()
            btn._recentRemovedOverlay:Hide()
            btn._icon:SetDesaturated(false)
            btn:SetAlpha(1.0)
            setSessionCount(liveCount)
        elseif et == "placed" and liveCount == 0 then
            -- Placed this session, since removed: stacked +/- desaturated
            btn._recentEventOverlay:SetAtlas(placedAtlas); btn._recentEventOverlay:Show()
            btn._recentRemovedOverlay:SetAtlas(removedAtlas)
            btn._recentRemovedOverlay:ClearAllPoints()
            btn._recentRemovedOverlay:SetPoint("TOPRIGHT", -2, -16)
            btn._recentRemovedOverlay:Show()
            btn._icon:SetDesaturated(true)
            btn:SetAlpha(0.7)
            setSessionCount(deadCount)
        elseif et == "removed" then
            btn._recentEventOverlay:Hide()
            btn._recentRemovedOverlay:SetAtlas(removedAtlas)
            btn._recentRemovedOverlay:ClearAllPoints()
            btn._recentRemovedOverlay:SetPoint("TOPRIGHT", -2, -2) -- solo removed, no stacking
            btn._recentRemovedOverlay:Show()
            btn._icon:SetDesaturated(true)
            btn:SetAlpha(0.7)
            setSessionCount(item._recentEventCount or 1)
        end
    else
        -- Non-recent mode: clear any leftover overlays/badges from a previous render
        btn._recentEventOverlay:Hide()
        btn._recentRemovedOverlay:Hide()
        btn._recentRemovedOverlay:ClearAllPoints()
        btn._recentRemovedOverlay:SetPoint("TOPRIGHT", -2, -16)
        btn._recentCountBadge:Hide()
        btn._icon:SetDesaturated(false)
        btn:SetAlpha(1.0)
    end

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

local function PopulateGrid(displayItems)
    if not gridScrollBox then return end

    lastDisplayItems = displayItems -- pre-expansion, for async re-renders

    -- Central catalog cache is warmed by HDG_Data; re-renders happen via the
    -- HDG_COLLECTION_READY subscription (see EnsureEventBusSubscription below).
    -- If the cache isn't ready yet, ExpandDyeVariants returns an unexpanded list
    -- and the grid will repopulate when the event fires.

    displayItems = ExpandDyeVariants(displayItems or {})
    -- Retain scroll across live Recent Activity refreshes so placing/removing
    -- decor doesn't yank the user back to the top. Mode switches reset via
    -- SwitchMode's explicit PopulateGrid({}) + fresh PopulateGrid call, which
    -- goes through an empty provider first.
    HDG.StyleCardRow.SetItems(gridScrollBox, displayItems, true)

    if emptyLabel then emptyLabel:SetShown(#displayItems == 0) end
end

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

local function FormatSessionLabel(session)
    local now = time()
    local count = session.eventCount
    local ts = session.endedAt or session.startedAt or now

    if session.isActive then
        return "Now (" .. count .. ")"
    end

    local elapsed = now - ts
    if elapsed < 0 then elapsed = 0 end

    if elapsed < 60 then
        return "Just now (" .. count .. ")"
    elseif elapsed < 3600 then
        return string.format("%dm ago (%d)", math.floor(elapsed / 60), count)
    elseif elapsed < 86400 then
        return string.format("%dh ago (%d)", math.floor(elapsed / 3600), count)
    elseif elapsed < 7 * 86400 then
        local s = date("%a %I:%M %p", ts):gsub(" 0", " ")
        s = s:gsub(" AM$", " am"):gsub(" PM$", " pm")  -- spec wants lowercase am/pm
        return s .. " (" .. count .. ")"
    else
        return date("%b %d", ts) .. " (" .. count .. ")"
    end
end

local function GetRecentSessionsSidebar()
    if not HDG.RecentActivity then return {} end
    if not HDG.RecentActivity.GetSessions then return {} end
    local houseGUID = C_Housing and C_Housing.GetCurrentHouseInfo and (C_Housing.GetCurrentHouseInfo() or {}).houseGUID
    if not houseGUID then return {} end
    local sessions = HDG.RecentActivity:GetSessions(houseGUID)
    local items = {}
    for _, s in ipairs(sessions) do
        local label = FormatSessionLabel(s)
        table.insert(items, { value = s.sessionID, text = label })
    end
    return items
end

-- 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 },
    recent      = { sidebar = GetRecentSessionsSidebar,    empty = "No recent placements.\nPlace decor while in the editor to start tracking." },
}

-- Build a displayItems array compatible with PopulateGrid for the Recent mode.
local function BuildRecentDisplayItems(houseGUID, sessionID)
    if not (HDG.RecentActivity and HDG.RecentActivity.GetEventGroups and houseGUID and sessionID) then return {} end
    local groups = HDG.RecentActivity:GetEventGroups(houseGUID, sessionID)
    local out = {}
    for _, g in ipairs(groups) do
        table.insert(out, {
            decorID              = g.decorID,
            _recentEventType     = g.eventType,
            _recentLiveCount     = g.liveCount,
            _recentDeadCount     = g.deadCount,
            _recentEventCount    = g.eventCount,
            _recentSessionID     = g.sessionID,
            _recentName          = g.name,
            _recentIcon          = g.icon,
            _recentTimestamp     = g.mostRecent,
        })
    end
    return out
end

local function RefreshCurrentMode()
    if not sidebarScrollBox 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
    elseif currentMode == "recent" then
        -- Auto-select the active "Now" session when one exists, unless the user
        -- has explicitly clicked another session during this editor session.
        -- Fall back to sidebar[1] (newest-first) when no active session yet.
        local activeID = HDG.RecentActivity and HDG.RecentActivity.GetCurrentSessionID
            and HDG.RecentActivity:GetCurrentSessionID()
        if activeID and not recentManualSelection then
            currentRecentSessionId = activeID
        elseif not currentRecentSessionId and sidebarItems[1] then
            currentRecentSessionId = sidebarItems[1].value
        end
        if currentRecentSessionId then
            local houseGUID = C_Housing and C_Housing.GetCurrentHouseInfo
                and (C_Housing.GetCurrentHouseInfo() or {}).houseGUID
            PopulateGrid(BuildRecentDisplayItems(houseGUID, currentRecentSessionId))
        end
        selectedValue = currentRecentSessionId
        onSelect = function(value)
            currentRecentSessionId = value
            recentManualSelection = true  -- user made an explicit choice; stop auto-snapping
            local houseGUID = C_Housing and C_Housing.GetCurrentHouseInfo
                and (C_Housing.GetCurrentHouseInfo() or {}).houseGUID
            PopulateGrid(BuildRecentDisplayItems(houseGUID, 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)

    if recentCapStepper then
        local showStepper = (currentMode == "recent")
        recentCapStepper:SetShown(showStepper)
        if recentCapStepper.refreshLabel then recentCapStepper.refreshLabel() end
        if sidebarScrollBox then
            sidebarScrollBox:ClearAllPoints()
            sidebarScrollBox:SetPoint("TOPLEFT", 0, showStepper and -22 or -2)
            sidebarScrollBox:SetPoint("BOTTOMRIGHT", -14, 2)
        end
    end
end

local function SwitchMode(mode)
    currentMode = mode
    currentStyleId = nil
    currentShoppingListId = nil
    currentRecentSessionId = 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
    -- Catalog is owned by HDG_Data; if not ready, GetCatalogByItem returns nil
    -- and the HDG_COLLECTION_READY subscription will repaint when it lands.
    if HDG_Data and HDG_Data.WarmCollectionCache and not HDG_Data.IsLiveCatalogReady() then
        HDG_Data.WarmCollectionCache()
    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

-- ============================================================================
-- RECENT CAP STEPPER
-- ============================================================================

-- Build a +/- 10 stepper for the Recent mode session cap. Only shown when
-- currentMode == "recent" (toggled by RefreshCurrentMode).
local function CreateRecentCapStepper(parent)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(20)
    row:SetPoint("TOPLEFT", 0, 0)
    row:SetPoint("TOPRIGHT", 0, 0)

    local minus = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    minus:SetSize(20, 18)
    minus:SetText("-")
    minus:SetPoint("LEFT", 2, 0)

    local plus = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    plus:SetSize(20, 18)
    plus:SetText("+")
    plus:SetPoint("RIGHT", -2, 0)

    local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label:SetPoint("CENTER")
    label:SetTextColor(0.85, 0.85, 0.85)

    local function refreshLabel()
        local cap = (HDG_Settings and HDG_Settings.recentActivity and HDG_Settings.recentActivity.sessionCap) or 10
        label:SetText("Keep " .. cap .. " sessions")
    end
    refreshLabel()

    minus:SetScript("OnClick", function()
        local current = (HDG_Settings and HDG_Settings.recentActivity and HDG_Settings.recentActivity.sessionCap) or 10
        local newCap = math.max(10, current - 10)
        if HDG.RecentActivity and HDG.RecentActivity.SetSessionCap then
            HDG.RecentActivity:SetSessionCap(newCap)
        end
        refreshLabel()
    end)

    plus:SetScript("OnClick", function()
        local current = (HDG_Settings and HDG_Settings.recentActivity and HDG_Settings.recentActivity.sessionCap) or 10
        local newCap = current + 10
        if HDG.RecentActivity and HDG.RecentActivity.SetSessionCap then
            HDG.RecentActivity:SetSessionCap(newCap)
        end
        refreshLabel()
        -- Soft warning when crossing 200 sessions: SavedVariables file growth
        -- and login load time may become noticeable above this threshold.
        if newCap >= 200 and current < 200 then
            print("|cFFFFD100[HDG]|r Recent Activity now keeps " .. newCap .. " sessions per house. " ..
                "If load times suffer, lower the cap or run /hdg recent clear.")
        end
    end)

    row.refreshLabel = refreshLabel
    row:Hide()  -- shown via RefreshCurrentMode when currentMode == "recent"
    return row
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()

    -- Helper: rebuild the Recent sidebar + grid from current state. Cheap and
    -- idempotent; safe to call any time, even when not visible (next show
    -- inherits fresh state).
    local function RefreshRecentMode()
        if currentMode ~= "recent" then return end
        RefreshCurrentMode()
        if currentRecentSessionId then
            local houseGUID = C_Housing and C_Housing.GetCurrentHouseInfo
                and (C_Housing.GetCurrentHouseInfo() or {}).houseGUID
            PopulateGrid(BuildRecentDisplayItems(houseGUID, currentRecentSessionId))
        end
    end

    -- Show/hide header with content frame; also refresh Recent so coming back
    -- to the HDG tab after being on Storage shows the latest captured events.
    hdgContentFrame:HookScript("OnShow", function()
        headerIconBorder:Show(); headerIcon:Show(); headerText:Show()
        RefreshRecentMode()
    end)
    hdgContentFrame:HookScript("OnHide", function() headerIconBorder:Hide(); headerIcon:Hide(); headerText:Hide() end)

    -- Auto-refresh Recent mode when the tracker reports new activity. Drop the
    -- visibility gate — RefreshCurrentMode is cheap, and skipping while hidden
    -- means we don't recover when the tab becomes visible again.
    if HDG.EventBus and HDG.EventBus.Register then
        HDG.EventBus:Register("HDG_RECENT_ACTIVITY_UPDATED", RefreshRecentMode)

        -- Central catalog cache delta — repaint grid when ownership/counts change.
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            if hdgContentFrame and hdgContentFrame:IsShown() and lastDisplayItems and #lastDisplayItems > 0 then
                PopulateGrid(lastDisplayItems)
            end
        end)
    end

    -- Refresh on editor open/close so session boundary changes (new "Now"
    -- starting, previous one sealing) reflect immediately without needing an
    -- event-triggered rebuild.
    local refreshFrame = CreateFrame("Frame")
    refreshFrame:RegisterEvent("HOUSE_EDITOR_MODE_CHANGED")
    refreshFrame:SetScript("OnEvent", function()
        -- Editor state transition: reset the manual-selection flag so the next
        -- Recent-mode refresh auto-snaps to the active "Now" session if one
        -- exists (and the user's previous explicit choice doesn't linger).
        local nowActive = C_HouseEditor and C_HouseEditor.IsHouseEditorActive
            and C_HouseEditor.IsHouseEditorActive()
        if nowActive then
            recentManualSelection = false
            currentRecentSessionId = nil
        end
        RefreshRecentMode()
    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" },
        { text = "Recent",       value = "recent" },
    }, 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")

    -- Recent mode cap stepper (hidden by default; toggled in RefreshCurrentMode)
    recentCapStepper = CreateRecentCapStepper(sidebarBg)

    sidebarScrollBox = CreateFrame("Frame", nil, sidebarBg, "WowScrollBoxList")
    sidebarScrollBox:SetPoint("TOPLEFT", 0, -2)
    sidebarScrollBox:SetPoint("BOTTOMRIGHT", -14, 2)

    sidebarScrollBar = CreateFrame("EventFrame", nil, sidebarBg, "MinimalScrollBar")
    sidebarScrollBar:SetPoint("TOPLEFT", sidebarScrollBox, "TOPRIGHT", 2, 0)
    sidebarScrollBar:SetPoint("BOTTOMLEFT", sidebarScrollBox, "BOTTOMRIGHT", 2, 0)

    sidebarScrollView = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    sidebarScrollView:SetElementExtentCalculator(function() return SIDEBAR_BTN_HEIGHT end)
    sidebarScrollView:SetElementInitializer("Button", function(frame, elementData)
        HDG_HE_RenderSidebarItem(frame, elementData)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(sidebarScrollBox, sidebarScrollBar, sidebarScrollView)

    -- 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 (WowScrollBoxList via shared helper)
    local gridScrollBar
    gridScrollBox, gridScrollBar = HDG.StyleCardRow.Create(contentArea, {
        cellConstructor = ConstructInEditorCell,
        cellBinder      = BindInEditorCell,
        cellsPerRow     = 5,
        cellSize        = ICON_SIZE,
    })
    gridScrollBox:SetPoint("TOPLEFT", SIDEBAR_WIDTH + 4, 0)
    gridScrollBox:SetPoint("BOTTOMRIGHT", -22, 4)

    -- 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:RegisterEvent("HOUSING_DECOR_REMOVED")
hookFrame:RegisterEvent("HOUSE_DECOR_ADDED_TO_CHEST")
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
        if HDG_Data and HDG_Data.RefreshCatalog then HDG_Data.RefreshCatalog() end
        placedFromHDGTab = false -- prevent Storage tab hijack on cancelled placement
    elseif event == "HOUSING_DECOR_PLACE_SUCCESS" then
        if HDG_Data and HDG_Data.RefreshCatalog then HDG_Data.RefreshCatalog() end
    elseif event == "HOUSING_DECOR_CUSTOMIZATION_CHANGED" then
        -- Dye applied/removed: delay 0.2s so Blizzard's catalog has time to settle
        -- before we kick the central refresh. The HDG_COLLECTION_READY subscription
        -- handles the re-render when the searcher's hash diff produces a delta.
        pendingCustomizationRebuild = true
        if injected then
            C_Timer.After(0.2, function()
                pendingCustomizationRebuild = false
                if HDG_Data and HDG_Data.RefreshCatalog then HDG_Data.RefreshCatalog() end
            end)
        end
    elseif event == "HOUSING_STORAGE_UPDATED" then
        -- HDG_Data already kicks the central searcher on HOUSING_STORAGE_UPDATED
        -- (see HDG_Data storage-event handler), so we don't need to refresh again.
        -- Skip if a CUSTOMIZATION_CHANGED rebuild is pending to avoid double-fire.
    elseif event == "HOUSING_DECOR_REMOVED" or event == "HOUSE_DECOR_ADDED_TO_CHEST" then
        -- Decor picked up and returned to storage: storage count goes up, numPlaced
        -- goes down. HOUSING_STORAGE_UPDATED may not fire reliably for this flow,
        -- so kick the central refresh manually after a short settle delay.
        if injected then
            C_Timer.After(0.2, function()
                if HDG_Data and HDG_Data.RefreshCatalog then HDG_Data.RefreshCatalog() end
            end)
        end
    end
end)

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