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

-- House Editor companion header controls state. Module-local so they're in
-- scope for BindInEditorCell (which references showPlacementCost). Set/cleared
-- by the header search box and the placement-cost icon, respectively.
local gridSearchFilter = ""
local showPlacementCost = true
local ioFilter = "all"  -- "all" | "indoor" | "outdoor"
local recentStripCells = {}

-- 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
local RECENT_STRIP_COUNT = 8
local RECENT_STRIP_CELL_SIZE = 56
local RECENT_STRIP_HEIGHT = RECENT_STRIP_CELL_SIZE + 22  -- room for label row above cells

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

    -- Placement cost badge (BOTTOMLEFT) -- atlas icon + numeric cost. Same
    -- house-decor-budget-icon used in HDG_DecorPreviewTab's detail panel.
    -- Hidden for items with no placement cost data.
    local costLabel = btn:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
    costLabel:SetPoint("BOTTOMLEFT", 2, 2)
    costLabel:SetTextColor(1, 1, 1)
    costLabel:Hide()
    btn._costLabel = costLabel

    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 and hdgContentFrame then
                preview:SetParent(hdgContentFrame)
                preview:SetFrameStrata("DIALOG")
                preview:ClearAllPoints()
                -- Attached to right side of HDG window, aligned to its
                -- bottom edge so it doesn't drift to the screen corner.
                preview:SetPoint("BOTTOMLEFT", 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 and HDG.Store.Collection_ListByType) then return {} end

    local items = {}
    for collID, def in pairs(HDG.Store:Collection_ListByType("style")) do
        local key = collID:sub(7) -- strip "style:" prefix
        if def 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

-- Placement-cost buckets shown at the end of the Collections sidebar. Catalog
-- distribution: ~99% of decor sits at costs 1, 3, 5; cost 6+ items are all
-- structural room pieces (categoryID 9). Buckets are virtual collections:
-- not defined in HDG_CollectionDefinitions, resolved at click time.
local PLACEMENT_COST_BUCKETS = {
    { id = "placementcost:small",  text = "Small (1-2)",  lo = 1, hi = 2  },
    { id = "placementcost:medium", text = "Medium (3-4)", lo = 3, hi = 4  },
    { id = "placementcost:large",  text = "Large (5)",    lo = 5, hi = 5  },
    { id = "placementcost:xlarge", text = "X-Large (6+)", lo = 6, hi = 99 },
}

local function PlacementCostBucketFor(cost)
    if not cost then return nil end
    for _, b in ipairs(PLACEMENT_COST_BUCKETS) do
        if cost >= b.lo and cost <= b.hi then return b end
    end
    return nil
end

local function FindPlacementCostBucket(bucketId)
    for _, b in ipairs(PLACEMENT_COST_BUCKETS) do
        if b.id == bucketId then return b end
    end
    return nil
end

local function CollectPlacementCostItems(bucketId, ownedOnly)
    local bucket = FindPlacementCostBucket(bucketId)
    if not bucket or not HDG_AllDecorDB then return {} end
    local displayItems = {}
    for itemID, entry in pairs(HDG_AllDecorDB) do
        local row = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)
        local cost = row and row.placementCost
        if cost and cost >= bucket.lo and cost <= bucket.hi then
            if not ownedOnly or IsItemOwned(itemID) then
                displayItems[#displayItems + 1] = {
                    itemID = itemID,
                    decorID = entry[1],
                    entry = entry,
                    tier = "neutral",
                }
            end
        end
    end
    return displayItems
end

-- 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,
-- plus virtual placement-cost buckets at the end (resolved at click time).
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

    -- Placement-cost virtual collections. Single AllDecorDB walk, bin items
    -- into the matching bucket via PlacementCostBucketFor; counts are nil
    -- for items whose catalog row hasn't streamed yet (cold first-session).
    local bucketCounts = {}
    for _, b in ipairs(PLACEMENT_COST_BUCKETS) do bucketCounts[b.id] = 0 end
    if HDG_AllDecorDB then
        for itemID, _ in pairs(HDG_AllDecorDB) do
            local row = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)
            local b = row and PlacementCostBucketFor(row.placementCost)
            if b then bucketCounts[b.id] = bucketCounts[b.id] + 1 end
        end
    end
    items[#items + 1] = { value = nil, text = "By Placement Cost", isHeader = true }
    for _, b in ipairs(PLACEMENT_COST_BUCKETS) do
        items[#items + 1] = {
            value = b.id,
            text = b.text,
            count = bucketCounts[b.id] or 0,
        }
    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

    -- Underline for header rows (Themes / Collections section dividers).
    -- Hidden by default; HDG_HE_RenderSidebarItem shows it for isHeader items.
    local underline = frame:CreateTexture(nil, "ARTWORK")
    underline:SetHeight(1)
    underline:SetPoint("BOTTOMLEFT", 4, 1)
    underline:SetPoint("BOTTOMRIGHT", -2, 1)
    underline:SetColorTexture(1, 0.82, 0, 0.5)
    underline:Hide()
    frame._underline = underline
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._underline:Show()
        frame:SetScript("OnClick", nil)
        frame:EnableMouse(false)
    else
        frame._label:SetText(item.text)
        frame._label:SetTextColor(0.8, 0.78, 0.7)
        frame._underline:Hide()
        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)
    -- Gray out the icon when there are no copies left to place (all placed or
    -- all scrapped). Player still owns the item but can't drop another copy
    -- without retrieving one from the house first.
    if (ownedQty or 0) == 0 then
        btn._icon:SetVertexColor(0.4, 0.4, 0.4, 1)
        btn._icon:SetDesaturated(true)
    else
        btn._icon:SetVertexColor(1, 1, 1, 1)
        btn._icon:SetDesaturated(false)
    end
    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
    if showPlacementCost and placementCost and placementCost > 0 then
        btn._costLabel:SetText(CreateAtlasMarkup("house-decor-budget-icon", 12, 12) .. placementCost)
        btn._costLabel:Show()
    else
        btn._costLabel:Hide()
    end
    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 {})

    if ioFilter ~= "all" then
        local filtered = {}
        for _, item in ipairs(displayItems) do
            local row = item.itemID and HDG_Data and HDG_Data.GetCatalogByItem
                and HDG_Data.GetCatalogByItem(item.itemID)
            local pass = false
            if row then
                if ioFilter == "indoor" then pass = row.isAllowedIndoors
                elseif ioFilter == "outdoor" then pass = row.isAllowedOutdoors
                end
            else
                -- Recent items / catalog miss: show through (don't strip data we can't classify)
                pass = true
            end
            if pass then filtered[#filtered + 1] = item end
        end
        displayItems = filtered
    end

    if gridSearchFilter ~= "" then
        local filtered = {}
        for _, item in ipairs(displayItems) do
            local name = item.itemID and HDG_Data and HDG_Data.GetCachedItemName
                and HDG_Data.GetCachedItemName(item.itemID)
            local recentName = item._recentName
            if (name and name:lower():find(gridSearchFilter, 1, true))
                or (recentName and recentName:lower():find(gridSearchFilter, 1, true)) then
                filtered[#filtered + 1] = item
            end
        end
        displayItems = filtered
    end

    -- 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
            if value and type(value) == "string" and value:sub(1, 14) == "placementcost:" then
                PopulateGrid(CollectPlacementCostItems(value, true))
            else
                PopulateGrid(CollectStyleItems(value, true, cfg.signatureOnly))
            end
        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 and HDG.Store and HDG.Store.state.recentActivityCap) or HDG_DB.recentActivityCap or 10
        label:SetText("Keep " .. cap .. " sessions")
    end
    refreshLabel()

    minus:SetScript("OnClick", function()
        local current = (HDG and HDG.Store and HDG.Store.state.recentActivityCap) or HDG_DB.recentActivityCap 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 and HDG.Store and HDG.Store.state.recentActivityCap) or HDG_DB.recentActivityCap 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

-- ============================================================================
-- STANDALONE HE COMPANION WINDOW + LAUNCHER BUTTON
-- ============================================================================
--
-- v2.39.1 / 2026-05-08: Two failed attempts to inject a tab into
-- HouseEditorStoragePanel before settling on this approach. Both crashed
-- Blizzard's Catalog tab via productInfo-nil races -- the 12.0.5 patch
-- (commit d0877949) introduced a new path where Blizzard's
-- GetFeaturedSmallProducts returns entries with .productID, those land in
-- the Catalog ScrollBox, and the per-product CatalogShop fetch races with
-- the synchronous SetCatalogData call. Any addon that participates in the
-- storage panel's tab dispatch -- even a bare AddNamedTab + SetTabCallback --
-- triggers the race and gets taint-blamed via secureexecuterange.
--
-- This implementation does NOT touch HouseEditorStoragePanel at all. Zero
-- AddNamedTab, zero SetTabCallback, zero hooksecurefunc, zero HookScript on
-- any Blizzard panel. We render the HDG editor as a standalone window and
-- expose it via a small launcher button (mirroring Blizzard's storage-panel
-- expand button at LEFT y=150). The launcher shows only while the housing
-- editor is open; clicking it toggles the window.
local function InjectTab()
    if injected then return end
    if InCombatLockdown() then return end

    if not HouseEditorFrame then
        Log(LOG_LEVEL.WARN, MODULE_NAME, "HouseEditorFrame not found")
        return
    end

    Log(LOG_LEVEL.INFO, MODULE_NAME, "Building standalone HE companion window...")

    -- Standalone PortraitFrame window. Parented to HouseEditorFrame so it
    -- inherits its scale/strata and auto-hides via Blizzard's parent cascade
    -- when the editor closes. We deliberately do NOT touch StoragePanel,
    -- AddNamedTab, or any TabSystem callback -- those caused the v2.39.0
    -- Catalog tab regressions.
    -- BackdropTemplate frame with Blizzard's chest atlases inside + a dark
    -- dialog-style edge so it doesn't look amputated when floating away from
    -- the editor's left edge. StoragePanel is 600x651 but the inline grid had
    -- ~1 card width of dead space per row; trim by ICON_SIZE + ICON_PADDING
    -- so the 5-card grid fills the row.
    local FULL_HEIGHT = 651
    local HEADER_HEIGHT = 68
    hdgContentFrame = CreateFrame("Frame", "HDG_HouseEditorContent", HouseEditorFrame, "BackdropTemplate")
    hdgContentFrame:SetSize(600 - (ICON_SIZE + ICON_PADDING), FULL_HEIGHT)
    -- StoragePanel is frameStrata="HIGH" -- match it so we sit on the same
    -- layer. Within HIGH, Blizzard's StoragePanel children sit at frame
    -- levels in the hundreds; bump ours above that and mark toplevel so
    -- dragging keeps us on top.
    hdgContentFrame:SetFrameStrata("HIGH")
    hdgContentFrame:SetFrameLevel(2000)
    hdgContentFrame:SetToplevel(true)
    hdgContentFrame:SetMovable(true)
    hdgContentFrame:EnableMouse(true)
    hdgContentFrame:SetClampedToScreen(true)
    hdgContentFrame:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        edgeSize = 16,
        insets   = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    hdgContentFrame:SetBackdropColor(0, 0, 0, 1)
    hdgContentFrame:SetBackdropBorderColor(1, 1, 1, 1)
    hdgContentFrame:Hide()

    -- Container background (matches Blizzard StoragePanel.Background).
    local containerBg = hdgContentFrame:CreateTexture(nil, "BACKGROUND", nil, 0)
    containerBg:SetAtlas("house-chest-container-bg")
    containerBg:SetPoint("TOPLEFT", 4, -4)
    containerBg:SetPoint("BOTTOMRIGHT", -4, 4)

    -- Header background (matches Blizzard StoragePanel.HeaderBackground).
    local headerBg = hdgContentFrame:CreateTexture(nil, "BACKGROUND", nil, 1)
    headerBg:SetAtlas("house-chest-header-bg")
    headerBg:SetHeight(HEADER_HEIGHT)
    headerBg:SetPoint("TOPLEFT", 4, -4)
    headerBg:SetPoint("TOPRIGHT", -4, -4)

    -- Top-right corner ornament (matches Blizzard StoragePanel.CornerBorder).
    local cornerBorder = hdgContentFrame:CreateTexture(nil, "ARTWORK", nil, 0)
    cornerBorder:SetAtlas("house-chest-corbel-topright")
    cornerBorder:SetSize(74, 49)
    cornerBorder:SetPoint("TOPRIGHT", -6, -6)

    -- Title text on the chest header.
    local title = hdgContentFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 20, -22)
    title:SetText("Housing Decor Guide")
    title:SetTextColor(1, 0.82, 0)

    -- Placement-cost display toggle (right of title). Atlas tinted full when
    -- on, grey when off. Click flips state and rebinds the visible grid so
    -- existing cells refresh their cost badges immediately.
    local costToggle = CreateFrame("Button", nil, hdgContentFrame)
    costToggle:SetSize(18, 18)
    costToggle:SetPoint("LEFT", title, "RIGHT", 8, 0)
    local costToggleTex = costToggle:CreateTexture(nil, "ARTWORK")
    costToggleTex:SetAllPoints()
    costToggleTex:SetAtlas("house-decor-budget-icon")
    costToggle._tex = costToggleTex
    local function applyCostToggleVisual()
        if showPlacementCost then
            costToggleTex:SetVertexColor(1, 1, 1, 1)
        else
            costToggleTex:SetVertexColor(0.45, 0.45, 0.45, 1)
        end
    end
    costToggle:SetScript("OnClick", function()
        showPlacementCost = not showPlacementCost
        applyCostToggleVisual()
        if lastDisplayItems then PopulateGrid(lastDisplayItems) end
    end)
    costToggle:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(showPlacementCost and "Hide placement cost" or "Show placement cost", 1, 1, 1)
        GameTooltip:Show()
    end)
    costToggle:SetScript("OnLeave", function() GameTooltip:Hide() end)
    applyCostToggleVisual()

    -- Indoor/Outdoor cycle button. Three-state: All -> Indoor -> Outdoor.
    -- Sits right of the placement-cost toggle.
    local ioToggle = CreateFrame("Button", nil, hdgContentFrame, "BackdropTemplate")
    ioToggle:SetSize(58, 18)
    ioToggle:SetPoint("LEFT", costToggle, "RIGHT", 28, 0)
    ioToggle:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
    ioToggle:SetBackdropColor(0, 0, 0, 0.45)
    local ioToggleText = ioToggle:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    ioToggleText:SetPoint("CENTER")
    local ioToggleHl = ioToggle:CreateTexture(nil, "HIGHLIGHT")
    ioToggleHl:SetAllPoints()
    ioToggleHl:SetColorTexture(1, 1, 1, 0.1)
    local function applyIoVisual()
        if ioFilter == "all" then
            ioToggleText:SetText("Indoor + Outdoor")
            ioToggleText:SetTextColor(0.8, 0.78, 0.7)
        elseif ioFilter == "indoor" then
            ioToggleText:SetText("Indoor only")
            ioToggleText:SetTextColor(1, 0.82, 0)
        else
            ioToggleText:SetText("Outdoor only")
            ioToggleText:SetTextColor(1, 0.82, 0)
        end
        ioToggle:SetWidth(ioToggleText:GetStringWidth() + 16)
    end
    ioToggle:SetScript("OnClick", function()
        if ioFilter == "all" then ioFilter = "indoor"
        elseif ioFilter == "indoor" then ioFilter = "outdoor"
        else ioFilter = "all"
        end
        applyIoVisual()
        if lastDisplayItems then PopulateGrid(lastDisplayItems) end
    end)
    applyIoVisual()

    -- Search box: under the title, in the header band. Filters the grid by
    -- item name across whatever view is currently selected. Persists across
    -- mode/sidebar switches; Esc clears it.
    local searchBox = CreateFrame("EditBox", nil, hdgContentFrame, "InputBoxTemplate")
    searchBox:SetSize(220, 20)
    searchBox:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4)
    searchBox:SetAutoFocus(false)
    searchBox:SetFontObject("GameFontHighlightSmall")

    local searchPlaceholder = hdgContentFrame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    searchPlaceholder:SetPoint("LEFT", searchBox, "LEFT", 4, 0)
    searchPlaceholder:SetText("Search...")

    -- Clear-search button (right of the search box). Shown only when search
    -- has text; click clears the filter and hides itself.
    local searchClear = CreateFrame("Button", nil, hdgContentFrame)
    searchClear:SetSize(16, 16)
    searchClear:SetPoint("LEFT", searchBox, "RIGHT", 6, -1)
    local searchClearTex = searchClear:CreateTexture(nil, "ARTWORK")
    searchClearTex:SetAllPoints()
    searchClearTex:SetAtlas("perks-dropdown-clear")
    searchClear:Hide()

    local function refreshSearchUI()
        local empty = (searchBox:GetText() or "") == ""
        searchPlaceholder:SetShown(empty)
        searchClear:SetShown(not empty)
    end

    searchBox:SetScript("OnTextChanged", function(self, userInput)
        refreshSearchUI()
        if not userInput then return end
        gridSearchFilter = (self:GetText() or ""):lower()
        if lastDisplayItems then PopulateGrid(lastDisplayItems) end
    end)
    searchBox:SetScript("OnEscapePressed", function(self)
        self:SetText("")
        self:ClearFocus()
        gridSearchFilter = ""
        refreshSearchUI()
        if lastDisplayItems then PopulateGrid(lastDisplayItems) end
    end)
    searchBox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)

    searchClear:SetScript("OnClick", function()
        searchBox:SetText("")
        searchBox:ClearFocus()
        gridSearchFilter = ""
        refreshSearchUI()
        if lastDisplayItems then PopulateGrid(lastDisplayItems) end
    end)

    refreshSearchUI()

    -- Close button (top-right). Bumped above corner ornament + dragRegion.
    local closeBtn = CreateFrame("Button", nil, hdgContentFrame, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", -8, -8)
    closeBtn:SetFrameLevel(hdgContentFrame:GetFrameLevel() + 20)

    -- Drag region across the header (the chest atlas can't receive mouse).
    local dragRegion = CreateFrame("Frame", nil, hdgContentFrame)
    dragRegion:SetPoint("TOPLEFT", 4, -4)
    dragRegion:SetPoint("TOPRIGHT", -44, -4)
    dragRegion:SetHeight(40)
    dragRegion:EnableMouse(true)
    dragRegion:RegisterForDrag("LeftButton")
    dragRegion:SetScript("OnDragStart", function() hdgContentFrame:StartMoving() end)
    dragRegion:SetScript("OnDragStop", function()
        hdgContentFrame:StopMovingOrSizing()
        hdgContentFrame.userPositioned = true
    end)

    -- Body container -- holds the mode toggle, sidebar and grid. Always
    -- shown; the launcher button governs window visibility (no in-window
    -- collapse toggle anymore -- the launcher does that role).
    local body = CreateFrame("Frame", nil, hdgContentFrame)
    body:SetPoint("TOPLEFT", 8, -(HEADER_HEIGHT + 4))
    body:SetPoint("BOTTOMRIGHT", -8, 8)

    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

    hdgContentFrame:HookScript("OnShow", function(self)
        if not self.userPositioned then
            self:ClearAllPoints()
            if HouseEditorFrame and HouseEditorFrame:IsShown() then
                -- Sit just to the right of the launcher / Blizzard storage
                -- panel area so it doesn't overlap them.
                self:SetPoint("TOPLEFT", HouseEditorFrame, "TOPLEFT", 80, -50)
            else
                self:SetPoint("CENTER")
            end
        end
        RefreshRecentMode()
    end)

    if HDG.EventBus and HDG.EventBus.Register then
        HDG.EventBus:Register("HDG_RECENT_ACTIVITY_UPDATED", RefreshRecentMode)
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            if hdgContentFrame and hdgContentFrame:IsShown() and lastDisplayItems and #lastDisplayItems > 0 then
                PopulateGrid(lastDisplayItems)
            end
        end)
    end

    local refreshFrame = CreateFrame("Frame")
    refreshFrame:RegisterEvent("HOUSE_EDITOR_MODE_CHANGED")
    refreshFrame:SetScript("OnEvent", function()
        local nowActive = C_HouseEditor and C_HouseEditor.IsHouseEditorActive
            and C_HouseEditor.IsHouseEditorActive()
        if nowActive then
            recentManualSelection = false
            currentRecentSessionId = nil
        end
        RefreshRecentMode()
    end)

    -- Mode toggle below title.
    modeFrame = CreateHorizontalRadioGroup(body, {
        { 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, -2)

    local contentArea = CreateFrame("Frame", nil, body)
    contentArea:SetPoint("TOPLEFT", 0, -42)
    contentArea:SetPoint("BOTTOMRIGHT", 0, 0)

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

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

    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)

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

    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, RECENT_STRIP_HEIGHT + 4)

    -- Recent placements strip (bottom of contentArea, full width). Newest
    -- placement enters in the rightmost slot; older slide left; oldest falls
    -- off the left edge. Reuses ConstructInEditorCell + BindInEditorCell so
    -- tooltips, cost badges, dye markers all work identically to main grid.
    local recentStrip = CreateFrame("Frame", nil, contentArea, "BackdropTemplate")
    recentStrip:SetHeight(RECENT_STRIP_HEIGHT)
    recentStrip:SetPoint("BOTTOMLEFT", 0, 0)
    recentStrip:SetPoint("BOTTOMRIGHT", -4, 0)
    recentStrip:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
    recentStrip:SetBackdropColor(0, 0, 0, 0.35)

    -- Thin separator line above the strip, full width.
    local stripSeparator = recentStrip:CreateTexture(nil, "OVERLAY")
    stripSeparator:SetHeight(1)
    stripSeparator:SetPoint("TOPLEFT", 0, 0)
    stripSeparator:SetPoint("TOPRIGHT", 0, 0)
    stripSeparator:SetColorTexture(1, 0.82, 0, 0.5)

    local stripLabel = recentStrip:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    stripLabel:SetPoint("TOPLEFT", 6, -2)
    stripLabel:SetText("Recent placements")

    -- Cells right-anchored: slot 8 (newest) hugs the right edge; slot 1 (oldest)
    -- sits leftmost. Cumulative offset from RIGHT.
    for i = 1, RECENT_STRIP_COUNT do
        local cell = ConstructInEditorCell(recentStrip)
        cell:SetSize(RECENT_STRIP_CELL_SIZE, RECENT_STRIP_CELL_SIZE)
        cell:ClearAllPoints()
        local rightOffset = -(6 + (i - 1) * (RECENT_STRIP_CELL_SIZE + 4))
        cell:SetPoint("BOTTOMRIGHT", recentStrip, "BOTTOMRIGHT", rightOffset, 2)
        cell:Hide()
        recentStripCells[i] = cell
    end

    local function RefreshRecentStrip()
        local houseGUID = C_Housing and C_Housing.GetCurrentHouseInfo
            and (C_Housing.GetCurrentHouseInfo() or {}).houseGUID
        local sessionID = HDG.RecentActivity and HDG.RecentActivity.GetCurrentSessionID
            and HDG.RecentActivity:GetCurrentSessionID()
        local placed = {}
        if houseGUID and sessionID and HDG.RecentActivity and HDG.RecentActivity.GetEventGroups then
            local groups = HDG.RecentActivity:GetEventGroups(houseGUID, sessionID)
            for _, g in ipairs(groups) do
                if g.eventType == "placed" then placed[#placed + 1] = g end
            end
            table.sort(placed, function(a, b) return (a.mostRecent or 0) > (b.mostRecent or 0) end)
        end
        -- Slot 1 = newest (rightmost). i is the slot index from right.
        for i = 1, RECENT_STRIP_COUNT do
            local cell = recentStripCells[i]
            if not cell then break end
            local g = placed[i]
            if g then
                BindInEditorCell(cell, {
                    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,
                })
                cell:Show()
            else
                cell:Hide()
            end
        end
    end

    if HDG.EventBus and HDG.EventBus.Register then
        HDG.EventBus:Register("HDG_RECENT_ACTIVITY_UPDATED", RefreshRecentStrip)
    end
    hdgContentFrame:HookScript("OnShow", RefreshRecentStrip)

    modeFrame:SetSelected(1)
    SwitchMode("styles")

    -- ====== LAUNCHER BUTTON ======
    -- Forked from Blizzard's HouseEditorStorageButtonTemplate. Identical
    -- chrome to Blizzard's expand button (same chest atlas, same hover
    -- overlay, same size 64x72) so it sits visually consistent with the
    -- housing UI. We OWN this button -- it's our object, not theirs --
    -- but visually it mirrors Blizzard's exactly so the player perceives
    -- it as part of the housing chrome rather than an addon afterthought.
    --
    -- Differentiated from Blizzard's by position (stacked ABOVE: theirs
    -- LEFT y=150, ours LEFT y=240) and by what clicking it does (toggle
    -- HDG window, not Blizzard's storage panel). Parented to
    -- HouseEditorFrame so it auto-hides via parent cascade when the
    -- editor closes. Zero contact with HouseEditorStoragePanel.
    local launcher = CreateFrame("Button", "HDG_HouseEditorLauncher", HouseEditorFrame)
    launcher:SetSize(64, 72)
    launcher:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 0, -30)
    -- MEDIUM strata (one below Blizzard's storage panel HIGH) so users
    -- can drag the launcher behind Blizzard's tabs/storage panel and have
    -- the panel draw on top -- effectively hiding the launcher when
    -- overlapped. Window itself stays HIGH so opening it draws above the
    -- launcher and Blizzard panel.
    launcher:SetFrameStrata("MEDIUM")
    launcher:SetFrameLevel(1)
    launcher:SetClampedToScreen(true)
    launcher:SetMovable(true)
    launcher:RegisterForDrag("LeftButton")
    launcher:RegisterForClicks("LeftButtonUp")

    -- Chrome layer: try the frame-only variant of the drawer button atlas.
    -- The pair `house-chest-drawer-button-open/close` (note word order) is
    -- the slot-frame-only version vs `house-chest-open-drawer-button` which
    -- bakes in the chest icon. If this atlas is the frame alone we get
    -- Blizzard's wooden chrome around our icon. If it doesn't render right
    -- the texture will simply be blank/missing and we'll switch back.
    local launcherFrame = launcher:CreateTexture(nil, "BACKGROUND")
    launcherFrame:SetAtlas("house-chest-drawer-button-open")
    launcherFrame:SetPoint("TOPLEFT", -4, 0)
    launcherFrame:SetPoint("BOTTOMRIGHT", -4, 0)

    -- Primary face: HDG icon, sized to sit inside the slot frame.
    local launcherIcon = launcher:CreateTexture(nil, "ARTWORK")
    launcherIcon:SetTexture("Interface\\AddOns\\HousingDecorGuide\\textures\\HousingDecorIcon")
    launcherIcon:SetSize(40, 40)
    launcherIcon:SetPoint("CENTER", launcher, "CENTER", -4, 0)

    -- Hover-fade overlay -- mirrors Blizzard's OverlayIcon pattern: same
    -- frame atlas, BlendMode=ADD, alpha=0.5, hidden until hovered.
    local launcherHover = launcher:CreateTexture(nil, "OVERLAY")
    launcherHover:SetAtlas("house-chest-drawer-button-open")
    launcherHover:SetBlendMode("ADD")
    launcherHover:SetAlpha(0.5)
    launcherHover:SetAllPoints(launcherFrame)
    launcherHover:Hide()

    launcher:SetScript("OnEnter", function(self)
        launcherHover:Show()
        if GameTooltip then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText("Housing Decor Guide", 1, 0.82, 0)
            GameTooltip:AddLine("Click to toggle the HDG editor window.", 1, 1, 1, true)
            GameTooltip:AddLine("Drag to move.", 0.7, 0.7, 0.7, true)
            GameTooltip:Show()
        end
    end)
    launcher:SetScript("OnLeave", function(self)
        launcherHover:Hide()
        if GameTooltip then GameTooltip:Hide() end
    end)
    launcher:SetScript("OnDragStart", function(self) self:StartMoving() end)
    launcher:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
    launcher:SetScript("OnClick", function()
        if hdgContentFrame:IsShown() then
            hdgContentFrame:Hide()
        else
            hdgContentFrame:Show()
        end
    end)

    -- Editor open/close: launcher visibility tracks via parent cascade.
    -- On editor open: show launcher, hide window (user opts in via click).
    HouseEditorFrame:HookScript("OnShow", function()
        launcher:Show()
        hdgContentFrame:Hide()
    end)
    if HouseEditorFrame:IsShown() then
        launcher:Show()
        hdgContentFrame:Hide()
    end

    injected = true
    Log(LOG_LEVEL.INFO, MODULE_NAME, "HE companion window + launcher built")
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
