-- HDG_StylesTab.lua
-- Style tab UI: landing page (style card grid) + detail view (item list by tier).

local addonName, privateDB = ...

local HDG = _G.HousingDecorGuide
local function GetScheme() return HDG.Theme.GetCurrentScheme() end
local function RegisterWidget(w, t) HDG.Theme.RegisterWidget(w, t) end
local function ApplyFont(fs, s, ft) HDG.Theme.ApplyFont(fs, s, ft) end
local L = HDG_L or {}

-- Shared Styles state (single source of truth across screen modules).
-- Loaded via TOC before this file; see UI/Tabs/Styles/HDG_ST_State.lua.
local State     = privateDB.HDG_ST.State
local ItemPanel = privateDB.HDG_ST.ItemPanel
local Landing   = privateDB.HDG_ST.Landing
local Detail    = privateDB.HDG_ST.Detail

-- Module namespace
local StylesTab = {}
privateDB.HDG_StylesTab = StylesTab

StylesTab.initialized = false

-- Local references (set during Init)
local CONFIG
local mainFrame
local StyleEngine

-- Deep copy utility (must be defined early, used by card buttons + editor)
local function DeepCopyTable(orig)
    if type(orig) ~= "table" then return orig end
    local copy = {}
    for k, v in pairs(orig) do
        copy[k] = DeepCopyTable(v)
    end
    return copy
end
StylesTab.DeepCopyTable = DeepCopyTable

-- Persist a style def via the Collections API. Mirrors the old
-- HDG.Store:SaveCustomStyle contract: returns the bare styleId, generating
-- a new one if `key` is nil. Deep-copies the def so the live record
-- doesn't share table references with the caller's working copy.
local function SaveStyleCollection(key, def)
    if not key then
        key = "custom-" .. time() .. "-" .. math.random(1000, 9999)
    end
    local coll = HDG.Store.DeepCopy(def)
    coll.id = "style:" .. key
    coll.type = "style"
    HDG.Store:Collection_Upsert(coll)
    return key
end

local function DeleteStyleCollection(key)
    if not key then return end
    HDG.Store:Collection_Delete("style:" .. key)
end

-- UI frames
local panel           -- main tab panel
local landingFrame    -- card grid view
local detailFrame     -- single style detail view

-- Serializer reference (set during Init)
local StyleSerializer

-- IsItemHidden + ConstructItemListRow + SOURCE_FILTER_DEFS + ROW_HEIGHT +
-- ItemHasRepRequirement + ItemCostsEndeavor migrated to HDG_ST_Detail.lua.

-- Module-level stats cache (dirty-flagged; only rebuilt on collection changes)
local statsCache = {}
local statsCacheDirty = true

local listFrame

-- Landing-page grid uses taller rows than the item list; tier headers are
-- intermediate. Declared at module scope so the ExtentCalculator callback
-- below resolves the name from its closure, not _G.
local ROW_HEIGHT_TIER_HEADER = 22
local ROW_HEIGHT_LANDING = 32  -- was 43; 25% shorter (test)

-- Subcategory button pool (for thematic styles); chip-row populated lazily by
-- Detail:Render. Held at module scope so CreateDetail can pass it into
-- Detail:Init by reference.
local subcatButtons = {}

-- Source-type filter buttons (for thematic styles, replaces room buttons).
local sourceButtons = {}

-- Lookup style def from engine's merged table (built-in + custom)
local function GetStyleDef(styleId)
    if StyleEngine and StyleEngine.GetAllStyleDefs then
        local defs = StyleEngine:GetAllStyleDefs()
        return defs and defs[styleId]
    end
    return nil
end

-- Gallery cell pool
local cardGridFrame, cardGridScrollBox, cardGridScrollBar
-- Cell interaction handlers live on the Detail module (Detail.OnCellEnter/etc.).

-- ============================================================================
-- INIT / LIFECYCLE
-- ============================================================================

function StylesTab:Init(cfg, mf, pdb)
    CONFIG = cfg or privateDB.CONFIG
    mainFrame = mf
    StyleEngine = privateDB.HDG_StyleEngine
    StyleSerializer = privateDB.HDG_StyleSerializer
    StylesTab.initialized = true
    StylesTab._config = CONFIG
    StylesTab._styleEngine = StyleEngine

    -- Load persisted collapse state, defaulting all to collapsed
    if HDG and HDG.Store and type(HDG.Store.state.ui.stylesCollapsed) == "table" and HDG.Store.state.ui.stylesCollapsed._initialized then
        for k, v in pairs(HDG.Store.state.ui.stylesCollapsed) do
            if k ~= "_initialized" then State.collapsedTiers[k] = v end
        end
    else
        -- First time: default all sections to collapsed
        State.collapsedTiers["_shoppinglist"] = true
        State.collapsedTiers["collection"] = true
        if HDG_StyleTiers then
            for _, ti in ipairs(HDG_StyleTiers) do
                State.collapsedTiers[ti.key] = true
            end
        end
        if HDG and HDG.Store then
            local saved = { _initialized = true }
            for k, v in pairs(State.collapsedTiers) do saved[k] = v end
            HDG.Store:SetUI("stylesCollapsed", saved)
        end
    end

    -- Re-render when the catalog searcher finishes. RefreshAll runs at window
    -- open and kicks off an async catalog search; this listener picks up the
    -- result and re-renders so the visible view updates with fresh stats.
    -- Mirrors the equivalent listener in HDG_RecipesTab.
    if HDG.EventBus then
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            statsCacheDirty = true
            if panel and panel:IsShown() then StylesTab:Refresh() end
        end)
    end
end

function StylesTab:SetSelectedStyle(v) State.selectedStyle = v end

function StylesTab:Show()
    local HDG_Debug = privateDB.HDG_Debug
    local Timed = (HDG_Debug and HDG_Debug.Timed) or function(_, fn) return fn() end

    if not panel then Timed("Styles.Create", function() self:Create() end) end
    -- Ensure cache is built on first show
    if StyleEngine and not StyleEngine:IsCacheReady() then
        Timed("Styles.BuildCache", function() StyleEngine:BuildCache() end)
    end
    if State.selectedShoppingListId then
        Timed("Styles.ShowShoppingListDetail", function() self:ShowShoppingListDetail(State.selectedShoppingListId) end)
    elseif State.selectedSessionData then
        Timed("Styles.ShowSessionDetail", function()
            self:ShowSessionDetail(State.selectedSessionData.houseGUID, State.selectedSessionData.sessionID, State.selectedSessionData.displayName)
        end)
    elseif State.selectedStyle then
        Timed("Styles.ShowDetail", function() self:ShowDetail(State.selectedStyle) end)
    else
        Timed("Styles.ShowLanding", function() self:ShowLanding() end)
    end
    panel:Show()
end

function StylesTab:Hide()
    if panel then panel:Hide() end
end

function StylesTab:Refresh()
    if not panel or not panel:IsShown() then return end
    if State.selectedShoppingListId then
        self:RefreshShoppingListDetail()
    elseif State.selectedSessionData then
        self:RefreshSessionDetail()
    elseif State.selectedStyle then
        self:RefreshDetail()
    else
        self:RefreshLanding()
    end
end

-- ============================================================================
-- CREATE (called once)
-- ============================================================================

function StylesTab:Create()
    local scheme = GetScheme()

    -- Main panel using TabPanel factory
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = false,
    })
    panel = layout.frame
    panel:Hide()

    -- Landing frame (card grid)
    landingFrame = CreateFrame("Frame", nil, panel)
    landingFrame:SetAllPoints()

    -- Detail frame (style browser)
    detailFrame = CreateFrame("Frame", nil, panel)
    detailFrame:SetAllPoints()
    detailFrame:Hide()

    StylesTab._panel = panel
    StylesTab._landingFrame = landingFrame
    StylesTab._detailFrame = detailFrame

    self:CreateLanding()
    self:CreateDetail()
end

-- ============================================================================
-- LANDING PAGE (style card grid)
-- ============================================================================

-- State.landingSearchText / State.landingSearchTimer migrated to State.* (see HDG_ST_State).
local landingScrollBox, landingScrollBar, landingScrollView
local landingCatButtons = {}

-- LANDING_CAT_DEFS lives on the Landing module; alias for the chip strip below.
local LANDING_CAT_DEFS = Landing.LANDING_CAT_DEFS

-- Forward declaration (defined after CreateLanding)
local ConstructLandingRow

-- ============================================================================
-- SHOPPING LIST CARD POOL
-- ============================================================================

-- (Shopping list cards reuse the row pool pattern)

function StylesTab:CreateLanding()
    local scheme = GetScheme()

    -- Header
    local header = landingFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    header:SetPoint("TOPLEFT", 10, -8)
    if scheme then ApplyFont(header, scheme, "header") end
    header:SetText(L["STYLE_BROWSE_HEADER"] .. "  |cFFB0A080" .. L["STYLE_TAGLINE"] .. "|r")
    landingFrame.header = header

    -- Hero strip — primary workflow CTAs (Style Creator + Style Curator)
    -- promoted to a full-width row directly under the header so they
    -- aren't visually equivalent to utility buttons (Save Placed Decor
    -- / Import) which keep the smaller top-right cluster slot.
    -- Hero CTA card factory — pulls colors from the active scheme so the
    -- cards re-tint on theme switch via the registered "Panel" widget.
    local function makeHeroCard(parent, title, subtitle, onClick)
        local function rgba(c) return c.r, c.g, c.b, c.a end
        local function rgb(c, a) return c.r, c.g, c.b, a or c.a end

        local card = CreateFrame("Button", nil, parent, "BackdropTemplate")
        card:SetHeight(56)

        -- Re-tint helper: called once at create + via Theme registry on switch
        card.applyTheme = function(self_)
            local s = HDG.Theme.GetCurrentScheme()
            if not s then return end
            if self_.SetBackdrop then
                self_:SetBackdrop({
                    bgFile   = "Interface/Buttons/WHITE8X8",
                    edgeFile = "Interface/Buttons/WHITE8X8",
                    edgeSize = 1,
                })
                self_:SetBackdropColor(rgb(s.panel, 0.95))
                self_:SetBackdropBorderColor(rgb(s.accent, 0.6))
            end
            if self_.titleStr then HDG.Theme.ApplyFont(self_.titleStr, s, "header"); self_.titleStr:SetTextColor(rgba(s.accent)) end
            if self_.subStr   then HDG.Theme.ApplyFont(self_.subStr,   s, "small");  self_.subStr:SetTextColor(rgba(s.text_dim)) end
        end

        card.titleStr = card:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        card.titleStr:SetPoint("TOPLEFT", 14, -10)
        card.titleStr:SetText(title)
        -- Subtitle wraps to up to 2 lines; bound right edge so the FontString
        -- knows where to wrap. Card height already fits 2 lines beneath the title.
        card.subStr = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        card.subStr:SetPoint("TOPLEFT", card.titleStr, "BOTTOMLEFT", 0, -4)
        card.subStr:SetPoint("RIGHT", card, "RIGHT", -14, 0)
        card.subStr:SetJustifyH("LEFT")
        card.subStr:SetJustifyV("TOP")
        card.subStr:SetWordWrap(true)
        card.subStr:SetText(subtitle)
        card:applyTheme()

        card:SetScript("OnEnter", function(self_)
            local s = HDG.Theme.GetCurrentScheme()
            if s then
                self_:SetBackdropColor(rgb(s.panel_header or s.panel, 0.95))
                self_:SetBackdropBorderColor(rgba(s.accent))
            end
        end)
        card:SetScript("OnLeave", function(self_) self_:applyTheme() end)
        card:SetScript("OnClick", onClick)

        -- Register so the Theme engine re-tints on switch.
        HDG.Theme.RegisterWidget(card, "Panel")
        -- Override the default Panel skinner with our card-specific re-tint.
        card:HookScript("OnShow", function(self_) self_:applyTheme() end)
        return card
    end

    -- Curator is the primary workflow (manage existing sets), so it sits on the
    -- left where the eye lands first. Creator (build new) on the right.
    local curatorCard = makeHeroCard(landingFrame,
        (L["STYLE_CURATOR"] or "Style Curator"),
        L["STYLE_CURATOR_SUB"] or "Build, browse, and reshuffle handpicked decor sets. File items between styles, see at a glance what's filed where.",
        function() StylesTab:ShowAudit() end)
    curatorCard:SetPoint("TOPLEFT", 10, -36)
    landingFrame.curatorCard = curatorCard

    local creatorCard = makeHeroCard(landingFrame,
        (L["STYLE_CREATOR"] or "Smart Set Builder"),
        L["STYLE_CREATOR_SUB"] or "Build a smart filter that auto-matches items by tag rules -- room, mood, material, color, and more.",
        function() StylesTab:ShowEditor(nil) end)
    creatorCard:SetPoint("TOPLEFT", curatorCard, "TOPRIGHT", 8, 0)
    landingFrame.creatorCard = creatorCard
    -- Equal-width split: each card takes ~half the available landing width
    -- minus the 18px combined padding/gap. Recomputed if landing resizes.
    local function sizeHeroCards()
        local w = landingFrame:GetWidth()
        if not w or w < 200 then return end
        local each = math.floor((w - 28) / 2)
        creatorCard:SetWidth(each)
        curatorCard:SetWidth(each)
    end
    landingFrame:HookScript("OnSizeChanged", sizeHeroCards)
    sizeHeroCards()

    -- Import button (top-right utility cluster — kept smaller)
    local importBtn = HDG_UIHelpers.CreateChipButton(landingFrame, "", 60, false, nil)
    importBtn.text:SetText(L["STYLE_IMPORT"])
    importBtn:SetWidth(importBtn.text:GetStringWidth() + 16)
    importBtn:SetPoint("TOPRIGHT", -28, -8)
    importBtn:SetScript("OnClick", function()
        StylesTab:ShowImportDialog()
    end)
    landingFrame.importBtn = importBtn

    -- Snapshot button (captures all placed decor account-wide)
    local snapshotBtn = HDG_UIHelpers.CreateChipButton(landingFrame, "", 60, false, nil)
    snapshotBtn.text:SetText(L["STYLE_SAVE_PLACED_DECOR"])
    snapshotBtn:SetWidth(snapshotBtn.text:GetStringWidth() + 20)
    snapshotBtn:SetPoint("RIGHT", importBtn, "LEFT", -4, 0)
    snapshotBtn:SetScript("OnClick", function()
        StylesTab:TakeSnapshot()
    end)
    landingFrame.snapshotBtn = snapshotBtn

    -- Category switcher tabs (pushed below the hero strip — header at -8,
    -- hero cards at -36 with 56px height, so chips start at -100)
    local catX = 10
    for _, cd in ipairs(LANDING_CAT_DEFS) do
        local btn = HDG_UIHelpers.CreateChipButton(landingFrame, "", 60, false, nil)
        btn.text:SetText(cd.label)
        btn:SetWidth(btn.text:GetStringWidth() + 16)
        btn:SetPoint("TOPLEFT", catX, -100)
        btn.catKey = cd.key
        btn:SetScript("OnClick", function()
            State.selectedLandingCategory = cd.key
            for _, b in ipairs(landingCatButtons) do
                HDG_UIHelpers.UpdateChipButtonState(b, b.catKey == State.selectedLandingCategory)
            end
            self:RefreshLanding()
        end)
        HDG_UIHelpers.UpdateChipButtonState(btn, cd.key == State.selectedLandingCategory)
        landingCatButtons[#landingCatButtons + 1] = btn
        catX = catX + btn:GetWidth() + 2
    end

    -- Modern WowScrollBoxList for landing tier rows. Virtualization is native
    -- (only viewport frames exist). Header rows and tier rows are interleaved
    -- in the same DataProvider; the initializer dispatches on elementData.type.
    landingScrollBox = CreateFrame("Frame", nil, landingFrame, "WowScrollBoxList")
    landingScrollBox:SetPoint("TOPLEFT", 4, -126)  -- below hero strip (-36..-92) + chip strip (-100..-122)
    landingScrollBox:SetPoint("BOTTOMRIGHT", -24, 4)

    landingScrollBar = CreateFrame("EventFrame", nil, landingFrame, "MinimalScrollBar")
    landingScrollBar:SetPoint("TOPLEFT", landingScrollBox, "TOPRIGHT", 2, 0)
    landingScrollBar:SetPoint("BOTTOMLEFT", landingScrollBox, "BOTTOMRIGHT", 2, 0)

    landingScrollView = CreateScrollBoxListLinearView(0, 0, 0, 0, 2)
    landingScrollView:SetElementExtentCalculator(function(_, elementData)
        if elementData.type == "header" then return ROW_HEIGHT_TIER_HEADER or 22 end
        return ROW_HEIGHT_LANDING or 43
    end)
    landingScrollView:SetElementInitializer("Button", function(frame, elementData)
        StylesTab:_RenderLandingElement(frame, elementData)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(landingScrollBox, landingScrollBar, landingScrollView)

    -- Landing search box
    local searchBox = CreateFrame("EditBox", nil, landingFrame, "SearchBoxTemplate")
    searchBox:SetSize(150, 20)
    -- Aligned with the chip filter strip (y=-100) so it doesn't overlap the
    -- new hero strip cards (y=-36..-92).
    searchBox:SetPoint("TOPRIGHT", -28, -100)
    searchBox:SetAutoFocus(false)
    searchBox:HookScript("OnTextChanged", function(self)
        local text = self:GetText() or ""
        if text == State.landingSearchText then return end
        State.landingSearchText = text
        if State.landingSearchTimer then State.landingSearchTimer:Cancel() end
        State.landingSearchTimer = C_Timer.NewTimer(0.2, function()
            State.landingSearchTimer = nil
            StylesTab:RefreshLanding()
        end)
    end)
    landingFrame.searchBox = searchBox

    -- Collection count (left of search box)
    landingFrame.collectionCount = landingFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    landingFrame.collectionCount:SetPoint("RIGHT", searchBox, "LEFT", -8, 0)
    landingFrame.collectionCount:SetJustifyH("RIGHT")
    landingFrame.collectionCount:SetShadowOffset(1, -1)
    landingFrame.collectionCount:SetShadowColor(0, 0, 0, 0.8)

end

-- ============================================================================
-- LANDING ROW HANDLERS (module-scope, no per-row closures)
-- ============================================================================

local function StyleRow_OnClick(self)
    local rd = self._rowData
    if not rd then return end
    if rd.isRecentSession then
        StylesTab:ShowSessionDetail(rd.houseGUID, rd.sessionID, rd.displayName)
        return
    end
    if rd.tier == "_shoppinglist" then
        StylesTab:ShowShoppingListDetail(rd.listId)
    else
        StylesTab:ShowDetail(rd.styleId)
    end
end

local function StyleRow_OnEnter(self)
    if self.glowTex then self.glowTex:Show() end
end

local function StyleRow_OnLeave(self)
    if self.glowTex then self.glowTex:Hide() end
    GameTooltip:Hide()
end

-- ============================================================================
-- LANDING ROW FACTORY + POPULATE
-- ============================================================================

-- ROW_HEIGHT_LANDING is now declared at module scope above ConstructItemListRow
-- so the ExtentCalculator closure sees it as an upvalue.

ConstructLandingRow = function(parent)
    -- ScrollBoxList passes the pool-allocated frame as `parent`. Populate it
    -- in place rather than creating a child — children would orphan the
    -- ScrollBox's anchoring + click handling. Height is managed by the view's
    -- extentCalculator; no SetHeight/SetPoint needed here.
    local row = parent

    -- Row background — wood sign atlas (same as tab bar, stretches horizontally)
    row.bgTex = row:CreateTexture(nil, "BACKGROUND", nil, 1)
    row.bgTex:SetPoint("TOPLEFT", 0, 0)
    row.bgTex:SetPoint("BOTTOMRIGHT", 0, 0)
    row.bgTex:SetAtlas("housing-woodsign")
    row.bgTex:SetAlpha(0.5)

    -- Hover glow (brighter wood)
    row.glowTex = row:CreateTexture(nil, "BACKGROUND", nil, 2)
    row.glowTex:SetPoint("TOPLEFT", 0, 0)
    row.glowTex:SetPoint("BOTTOMRIGHT", 0, 0)
    row.glowTex:SetAtlas("housing-woodsign")
    row.glowTex:SetAlpha(0.3)
    row.glowTex:Hide()

    -- Icon (36x36)
    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(36, 36)
    row.icon:SetPoint("TOPLEFT", 6, 2)  -- moved up 3px from -1; centers the 36px icon vertically in the 32px row (≈2px overflow each side)

    -- Name text (flex width, stops 300px from right edge)
    row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.nameText:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 8, -1)
    row.nameText:SetPoint("RIGHT", row, "RIGHT", -300, 0)
    row.nameText:SetJustifyH("LEFT")
    row.nameText:SetWordWrap(false)
    row.nameText:SetShadowOffset(1, -1)
    row.nameText:SetShadowColor(0, 0, 0, 0.8)

    -- Description text (dim, below name)
    row.descText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightExtraSmall")
    -- URL link icon (shown left of description for rows with a URL)
    row.urlIcon = row:CreateTexture(nil, "OVERLAY")
    row.urlIcon:SetSize(12, 12)
    row.urlIcon:SetPoint("TOPLEFT", row.nameText, "BOTTOMLEFT", 0, -2)
    row.urlIcon:SetTexture("Interface\\AddOns\\HousingDecorGuide\\textures\\url_link")
    row.urlIcon:Hide()

    row.descText:SetPoint("TOPLEFT", row.urlIcon, "TOPRIGHT", 2, 1)
    row.descText:SetPoint("RIGHT", row.nameText, "RIGHT", 0, 0)
    row.descText:SetJustifyH("LEFT")
    row.descText:SetWordWrap(false)
    row.descText:SetMaxLines(1)
    row.descText:SetShadowOffset(1, -1)
    row.descText:SetShadowColor(0, 0, 0, 0.6)

    -- Inline EditBox for URL copy (replaces descText on click, hidden by default)
    row.urlEditBox = CreateFrame("EditBox", nil, row)
    row.urlEditBox:SetPoint("TOPLEFT", row.urlIcon, "TOPRIGHT", 2, 1)
    row.urlEditBox:SetPoint("RIGHT", row.nameText, "RIGHT", 0, 0)
    row.urlEditBox:SetHeight(14)
    row.urlEditBox:SetFontObject("GameFontHighlightExtraSmall")
    row.urlEditBox:SetAutoFocus(false)
    row.urlEditBox:EnableMouse(true)
    row.urlEditBox:Hide()
    row.urlEditBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
        self:Hide()
        row.descText:Show()
    end)
    row.urlEditBox:SetScript("OnEditFocusLost", function(self)
        self:Hide()
        row.descText:Show()
    end)

    -- Clickable overlay for URL (covers icon + desc area)
    row.urlBtn = CreateFrame("Button", nil, row)
    row.urlBtn:SetPoint("TOPLEFT", row.urlIcon, "TOPLEFT", 0, 0)
    row.urlBtn:SetPoint("BOTTOMRIGHT", row.descText, "BOTTOMRIGHT", 0, 0)
    row.urlBtn:SetFrameLevel(row:GetFrameLevel() + 3)
    row.urlBtn:SetScript("OnEnter", function(self)
        if self._url then
            GameTooltip:SetOwner(self, "ANCHOR_TOP")
            GameTooltip:AddLine("Click to copy URL", 1, 1, 1)
            GameTooltip:Show()
            row.descText:SetTextColor(0.4, 0.8, 1, 1)
        end
    end)
    row.urlBtn:SetScript("OnLeave", function()
        GameTooltip:Hide()
        local s = row._scheme
        if s then row.descText:SetTextColor(s.text_dim.r, s.text_dim.g, s.text_dim.b, 0.8) end
    end)
    row.urlBtn:SetScript("OnClick", function(self)
        if not self._url then return end
        row.descText:Hide()
        row.urlEditBox:SetText(self._url)
        row.urlEditBox:Show()
        row.urlEditBox:SetFocus()
        row.urlEditBox:HighlightText()
    end)
    row.urlBtn:Hide()

    -- Preview icons (6 x 24x24, anchored from right, 2px gaps)
    -- Total width: 6*24 + 5*2 = 154px. Offset -212 from row right gives ~16px
    -- breathing room before the action button cluster (was -188 which had the
    -- last preview icon overlapping the leftmost action button by 4px).
    row.previewIcons = {}
    for i = 1, 6 do
        local tex = row:CreateTexture(nil, "ARTWORK")
        tex:SetSize(24, 24)
        if i == 1 then
            tex:SetPoint("RIGHT", row, "RIGHT", -212, 0)
        else
            tex:SetPoint("LEFT", row.previewIcons[i - 1], "RIGHT", 2, 0)
        end
        tex:Hide()
        row.previewIcons[i] = tex
    end

    -- Count text (right of preview icons area). Y=+4 nudges the count up so
    -- the count + 8px progress bar group is vertically centered in the row
    -- (group height ~20, row 32 → ~6px top offset; count anchor is RIGHT
    -- which sits at vertical-center, so +4 moves it ~4px above center to
    -- give the progress bar room to land within the row's lower half).
    row.countText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.countText:SetWidth(46)
    row.countText:SetPoint("RIGHT", row.previewIcons[1], "LEFT", -8, 4)
    row.countText:SetJustifyH("RIGHT")
    row.countText:SetShadowOffset(1, -1)
    row.countText:SetShadowColor(0, 0, 0, 0.8)

    -- Progress bar (journeys renown style, aligned under count text)
    row.progressBg = row:CreateTexture(nil, "ARTWORK")
    row.progressBg:SetHeight(8)
    row.progressBg:SetPoint("BOTTOMLEFT", row.countText, "BOTTOMLEFT", -2, -8)
    row.progressBg:SetPoint("BOTTOMRIGHT", row.countText, "BOTTOMRIGHT", 2, -8)
    row.progressBg:SetAtlas("UI-Journeys-renown-progressbar-BG")

    row.progressFill = row:CreateTexture(nil, "OVERLAY")
    row.progressFill:SetHeight(8)
    row.progressFill:SetPoint("BOTTOMLEFT", row.progressBg, "BOTTOMLEFT", 0, 0)
    row.progressFill:SetAtlas("UI-Journeys-renown-progressbar-fill")
    row.progressFill:SetVertexColor(0.2, 0.8, 0.6, 1)

    row.progressFrame = row:CreateTexture(nil, "OVERLAY", nil, 1)
    row.progressFrame:SetHeight(8)
    row.progressFrame:SetAllPoints(row.progressBg)
    row.progressFrame:SetAtlas("UI-Journeys-renown-progressbar-frame")

    -- Action buttons: single row of atlas-icon buttons anchored to the row's
    -- right edge. Order (left → right): Edit, Export, Delete. Copy+Edit was
    -- removed in favour of the Style Curator's own Duplicate-source flow.
    local actionDefs = {
        { key = "edit",     atlas = "common-icon-zoomin",       tip = "Edit" },
        { key = "export",   atlas = "common-icon-forwardarrow", tip = "Export" },
        { key = "delete",   atlas = "common-icon-delete",       tip = "Delete" },
    }
    row._actionBtns = {}
    local btnSize, btnGap = 18, 2
    local btnYOff = -((ROW_HEIGHT_LANDING - btnSize) / 2)
    for i, ad in ipairs(actionDefs) do
        local ab = HDG_UIHelpers.CreateAtlasIconButton(row, ad.atlas, btnSize, ad.tip, nil)
        local xOff = -(4 + (#actionDefs - i) * (btnSize + btnGap))
        ab:SetPoint("TOPRIGHT", row, "TOPRIGHT", xOff, btnYOff)
        ab:SetFrameLevel(row:GetFrameLevel() + 5)
        ab:Hide()
        ab._active = false
        row._actionBtns[ad.key] = ab
    end

    row:SetScript("OnClick", StyleRow_OnClick)
    row:SetScript("OnEnter", StyleRow_OnEnter)
    row:SetScript("OnLeave", StyleRow_OnLeave)

    -- Track per-row widgets so they can be hidden when showing the header layer.
    -- bgTex/glowTex are deliberately omitted: bgTex is the wood background that
    -- should also paint the header rows; glowTex is hover state and stays under
    -- StyleRow_OnEnter/OnLeave control which works fine for both row + header.
    row._rowWidgets = {
        row.icon, row.nameText, row.descText,
        row.urlIcon, row.urlEditBox, row.urlBtn, row.countText,
        row.progressBg, row.progressFill, row.progressFrame,
    }
    for _, tex in ipairs(row.previewIcons) do
        table.insert(row._rowWidgets, tex)
    end
    for _, ab in pairs(row._actionBtns) do
        table.insert(row._rowWidgets, ab)
    end

    -- Header layer: hidden by default; shown when elementData.type == "header"
    local headerLayer = CreateFrame("Frame", nil, row)
    headerLayer:SetAllPoints()
    local headerLabel = headerLayer:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    headerLabel:SetPoint("LEFT", 8, 0)
    headerLabel:SetTextColor(1, 0.82, 0)
    headerLayer.label = headerLabel

    local headerCount = headerLayer:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    headerCount:SetPoint("RIGHT", -8, 0)
    headerCount:SetTextColor(0.7, 0.7, 0.7)
    headerLayer.count = headerCount

    headerLayer:Hide()
    row._headerLayer = headerLayer

    return row
end

local function UpdateStyleRow(row, rowData, scheme)
    row._rowData = rowData
    row._scheme = scheme

    -- Icon — shopping lists use their explicit icon (wowhead logo or bag);
    -- everything else uses the first preview item's decor icon
    if rowData.tier == "_shoppinglist" then
        if rowData.icon then
            row.icon:SetTexture(rowData.icon)
        elseif rowData.iconAtlas then
            row.icon:SetAtlas(rowData.iconAtlas)
        else
            row.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
        end
    else
        local previews = rowData.previewIcons or {}
        local firstPick = previews[1]
        local ic = firstPick and StyleEngine and StyleEngine:GetItemInfo(firstPick.itemID)
        if ic and ic.iconAtlas then
            row.icon:SetAtlas(ic.iconAtlas)
        elseif ic and ic.iconTexture then
            row.icon:SetTexture(ic.iconTexture)
        elseif rowData.iconAtlas then
            row.icon:SetAtlas(rowData.iconAtlas)
        elseif rowData.icon then
            row.icon:SetTexture(rowData.icon)
        else
            row.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
        end
    end

    -- Name (lighten accent color so it's always readable on dark wood)
    row.nameText:SetText(rowData.displayName or "")
    if rowData.color then
        local c = rowData.color
        local lift = 0.4 -- blend 40% towards white
        row.nameText:SetTextColor(c.r + (1 - c.r) * lift, c.g + (1 - c.g) * lift, c.b + (1 - c.b) * lift, 1)
    elseif scheme then
        row.nameText:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, 1)
    end
    if scheme then ApplyFont(row.nameText, scheme, "normal") end

    -- Description + URL
    local desc = rowData.description or ""
    row.descText:SetText(desc)
    row.descText:SetShown(desc ~= "")
    local hasUrl = rowData.url and rowData.url ~= ""
    row.urlIcon:SetShown(hasUrl and desc ~= "")
    row.urlBtn._url = hasUrl and rowData.url or nil
    row.urlBtn:SetShown(hasUrl and desc ~= "")
    -- Pool rebind hygiene: if the prior owner of this row clicked to copy, the
    -- EditBox was left visible with their URL. Reset on every bind so recycled
    -- rows always start as static text, not EditBox.
    if row.urlEditBox:IsShown() then
        row.urlEditBox:ClearFocus()
        row.urlEditBox:Hide()
    end
    -- Re-anchor desc: if URL icon visible, offset from icon; else from name
    row.descText:ClearAllPoints()
    if hasUrl and desc ~= "" then
        row.descText:SetPoint("TOPLEFT", row.urlIcon, "TOPRIGHT", 2, 1)
    else
        row.descText:SetPoint("TOPLEFT", row.nameText, "BOTTOMLEFT", 0, -1)
    end
    row.descText:SetPoint("RIGHT", row.nameText, "RIGHT", 0, 0)
    if scheme then
        row.descText:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, 0.8)
    end

    -- Preview icons
    local previews = rowData.previewIcons or {}
    for i = 1, 6 do
        local tex = row.previewIcons[i]
        local pick = previews[i]
        if pick then
            local ic = pick.itemID and StyleEngine and StyleEngine:GetItemInfo(pick.itemID)
            local hasIcon = false
            if ic and ic.iconAtlas then
                tex:SetAtlas(ic.iconAtlas)
                hasIcon = true
            elseif ic and ic.iconTexture then
                tex:SetAtlas("") -- clear any prior atlas
                tex:SetTexture(ic.iconTexture)
                hasIcon = true
            elseif pick.iconID then
                tex:SetAtlas("") -- clear any prior atlas
                tex:SetTexture(pick.iconID)
                hasIcon = true
            end
            if not hasIcon then
                tex:Hide()
            else
                tex:SetDesaturated(not pick.isCollected)
                tex:SetAlpha(pick.isCollected and 1 or 0.5)
                tex:Show()
            end
        else
            tex:Hide()
        end
    end

    -- Count text
    local collected = rowData.collected or 0
    local total = rowData.total or 0
    if rowData.isSnapshot then
        row.countText:SetText(total .. " placed")
    elseif rowData.tier == "_shoppinglist" then
        row.countText:SetText(total .. " items")
    else
        row.countText:SetText(collected .. "/" .. total)
    end
    if scheme then
        ApplyFont(row.countText, scheme, "small")
        row.countText:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, 1)
    end

    -- Progress bar
    local pct = (total > 0 and not rowData.isSnapshot) and (collected / total) or 0
    row.progressFill:SetWidth(math.max(1, (row.countText:GetWidth() or 46) * pct))
    row.progressFill:SetShown(pct > 0)
    row.progressBg:SetShown(not rowData.isSnapshot and rowData.tier ~= "_shoppinglist")
    if rowData.color then
        row.progressFill:SetVertexColor(rowData.color.r, rowData.color.g, rowData.color.b, 1)
    end

    -- Completed state
    local isCompleted = (pct >= 1.0 and total > 0)
    -- Completed: show glow permanently + gold progress bar
    if row.glowTex then row.glowTex:SetShown(isCompleted) end
    if isCompleted then
        row.progressFill:SetVertexColor(1, 0.84, 0, 1)
    end

    -- Action buttons: show active ones, hide inactive
    local btns = row._actionBtns
    btns.edit._active = rowData.canEdit or false
    btns.export._active = rowData.canExport or false
    btns.delete._active = rowData.canDelete or false
    for _, ab in pairs(btns) do ab:SetShown(ab._active) end

    -- Wire action button OnClick. Edit deep-links into the Style Curator with
    -- the row's style preselected as the source for curated-tier styles;
    -- smart styles (rule-based) still fall back to the Smart Set Builder.
    if rowData.canEdit then
        btns.edit:SetScript("OnClick", function()
            if rowData.tier == "curated" and HDG_StylesAudit and HDG_StylesAudit.SetSource then
                StylesTab:ShowAudit()
                HDG_StylesAudit:SetSource(rowData.styleId)
            else
                StylesTab:ShowEditor(rowData.styleId)
            end
        end)
    end
    if rowData.canExport then
        btns.export:SetScript("OnClick", function()
            if not StyleSerializer then return end
            local def = GetStyleDef(rowData.styleId)
            if def then
                local encoded
                if rowData.isSnapshot then
                    encoded = StyleSerializer:ExportSnapshot(def)
                else
                    encoded = StyleSerializer:Export(def)
                end
                if encoded then StylesTab:ShowExportDialog(encoded, rowData.isSnapshot and def or nil) end
            end
        end)
    end
    if rowData.canDelete then
        btns.delete:SetScript("OnClick", function()
            StaticPopup_Show("HDG_DELETE_CUSTOM_STYLE", rowData.displayName or rowData.styleId, nil, { key = rowData.styleId })
        end)
    end

    -- Reset hover state
    if row.glowTex then row.glowTex:Hide() end
end

-- buildRecentSessionRows + BuildLandingData migrated to UI/Tabs/Styles/HDG_ST_Landing.lua.
-- StylesTab:RefreshLanding now calls Landing:BuildData{ statsCache = statsCache }.

-- ============================================================================
-- REFRESH LANDING (WowScrollBoxList version)
-- ============================================================================

-- Render dispatcher for the landing ScrollBoxList. Called by the view's
-- element initializer for each visible frame. Mutates `frame` in place.
-- elementData has shape { type = "header"|"row", ... } -- see RefreshLanding.
function StylesTab:_RenderLandingElement(frame, elementData)
    frame:SetWidth(landingScrollBox:GetWidth())

    if not frame._hdgConstructed then
        ConstructLandingRow(frame)
        frame._hdgConstructed = true
    end

    if elementData.type == "row" then
        if frame._headerLayer then frame._headerLayer:Hide() end
        if frame._rowWidgets then
            for _, w in ipairs(frame._rowWidgets) do w:Show() end
        end
        if frame.bgTex then frame.bgTex:SetAlpha(0.5) end  -- standard row dimness
        UpdateStyleRow(frame, elementData.rowData, GetScheme())
        frame:SetScript("OnClick", StyleRow_OnClick)
        frame:EnableMouse(true)

    elseif elementData.type == "header" then
        if frame._rowWidgets then
            for _, w in ipairs(frame._rowWidgets) do w:Hide() end
        end
        if frame.bgTex then frame.bgTex:SetAlpha(0.95) end  -- brighter than content rows
        if frame._headerLayer then
            frame._headerLayer:Show()
            frame._headerLayer.label:SetText(elementData.tierLabel)
            frame._headerLayer.count:SetText(elementData.count .. " styles")
        end
        -- Click-to-toggle expand/collapse for this tier. State persists in
        -- HDG_Settings.stylesCollapsed; refresh re-runs RefreshLanding to
        -- show/hide the affected rows.
        frame._headerTier = elementData.tier
        frame:EnableMouse(true)
        frame:SetScript("OnClick", function(self)
            local tier = self._headerTier
            if not tier then return end
            State.collapsedTiers[tier] = not State.collapsedTiers[tier]
            if HDG and HDG.Store then
                HDG.Store.state.ui.stylesCollapsed = HDG.Store.state.ui.stylesCollapsed or { _initialized = true }
                HDG.Store.state.ui.stylesCollapsed[tier] = State.collapsedTiers[tier]
                HDG.Store.state.ui.stylesCollapsed._initialized = true
                HDG.Store:QueueSave()
            end
            StylesTab:RefreshLanding()
        end)
    end
end

function StylesTab:RefreshLanding()
    if not landingScrollBox then return end
    if not StyleEngine or not StyleEngine:IsCacheReady() then return end

    -- Warm collection cache if needed; defer render until warm completes
    if not HDG_Data.IsCollectionCacheWarmed() then
        HDG_Data.WarmCollectionCache(nil, function()
            statsCacheDirty = true
            StylesTab:RefreshLanding()
        end)
        return
    end

    -- Rebuild stats cache if dirty
    if statsCacheDirty then
        wipe(statsCache)
        for _, styleId in ipairs(HDG_StyleOrder) do
            statsCache[styleId] = StyleEngine:GetStyleCollectionStats(styleId, State.hideUnreleased)
        end
        if HDG_CollectionOrder then
            for _, cid in ipairs(HDG_CollectionOrder) do
                statsCache[cid] = StyleEngine:GetStyleCollectionStats(cid, State.hideUnreleased)
            end
        end
        if HDG.Store and HDG.Store.Collection_ListByType then
            for collID in pairs(HDG.Store:Collection_ListByType("style")) do
                local sid = collID:sub(7) -- strip "style:" prefix
                if not statsCache[sid] then
                    statsCache[sid] = StyleEngine:GetStyleCollectionStats(sid, State.hideUnreleased)
                end
            end
        end
        statsCacheDirty = false
    end

    -- Build flat data array. Headers are interleaved between tier groups
    -- when the "All" landing category is selected (showHeaders).
    local rows = Landing:BuildData({ statsCache = statsCache })
    local showHeaders = (State.selectedLandingCategory == nil)

    local tierCounts = {}
    for _, rd in ipairs(rows) do
        tierCounts[rd.tier] = (tierCounts[rd.tier] or 0) + 1
    end

    local elements = {}
    local lastTier = nil
    for _, rowData in ipairs(rows) do
        if showHeaders and rowData.tier ~= lastTier then
            table.insert(elements, {
                type      = "header",
                tier      = rowData.tier,
                tierLabel = rowData.tierLabel or rowData.tier,
                count     = tierCounts[rowData.tier] or 0,
            })
            lastTier = rowData.tier
        end
        if not (showHeaders and State.collapsedTiers[rowData.tier]) then
            table.insert(elements, { type = "row", rowData = rowData })
        end
    end

    local provider = CreateDataProvider(elements)
    landingScrollBox:SetDataProvider(provider, ScrollBoxConstants.RetainScrollPosition)

    -- Update collection count display (preserve exact formatting from prior implementation)
    if landingFrame.collectionCount then
        local totalDecor = 0
        local collectedDecor = 0
        if HDG_AllDecorDB then
            for itemID in pairs(HDG_AllDecorDB) do
                totalDecor = totalDecor + 1
                if HDG_Data and HDG_Data.IsItemCollected(itemID) then
                    collectedDecor = collectedDecor + 1
                end
            end
        end
        local pct = totalDecor > 0 and math.floor(collectedDecor / totalDecor * 100) or 0
        local scheme = GetScheme()
        local accentHex = scheme and HDG.Theme.C_Accent() or "|cFF14b8a6"
        landingFrame.collectionCount:SetText(
            accentHex .. collectedDecor .. "|r" ..
            HDG.Theme.C_Dim() .. "/" .. totalDecor .. " (" .. pct .. "%)|r"
        )
    end
end

-- ============================================================================
-- DETAIL VIEW (single style browser)
-- ============================================================================

local detailHeader, detailDesc, detailBackBtn
local tierButtons = {}
local filterBar, searchBox
local roomButtons = {}
local itemListScrollBox, itemListScrollBar, itemListScrollView
local detailPanel -- right-side item detail

function StylesTab:CreateDetail()
    local scheme = GetScheme()

    -- Back button
    detailBackBtn = CreateFrame("Button", nil, detailFrame, "UIPanelButtonTemplate")
    detailBackBtn:SetSize(60, 20)
    detailBackBtn:SetPoint("TOPLEFT", 6, -6)
    detailBackBtn:SetText(L["STYLE_BACK"])
    detailBackBtn:SetScript("OnClick", function()
        State.selectedStyle = nil
        StylesTab:ShowLanding()
    end)

    -- Style name header
    detailHeader = detailFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    detailHeader:SetPoint("LEFT", detailBackBtn, "RIGHT", 8, 0)
    if scheme then ApplyFont(detailHeader, scheme, "header") end

    -- Type pill (small dim caps next to title) -- "CURATED" / "SMART SET" / etc.
    -- Shared widget; tells you at a glance which kind of style you're inspecting.
    local typePill = privateDB.HDG_W_TypePill:Create(detailFrame)
    typePill.fs:SetPoint("LEFT", detailHeader, "RIGHT", 10, 0)
    detailFrame.typePill = typePill

    -- Description (subtitle line — used for item count + optional flavor)
    detailDesc = detailFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightExtraSmall")
    -- Align subtitle's left with the header text (which sits 8px right of the
    -- back button), not the frame edge -- otherwise the subtitle hugs the
    -- panel border and reads as clipped.
    detailDesc:SetPoint("TOPLEFT", detailHeader, "BOTTOMLEFT", 0, -2)
    detailDesc:SetPoint("RIGHT", -6, 0)
    detailDesc:SetJustifyH("LEFT")
    if scheme then
        ApplyFont(detailDesc, scheme, "xsmall")
        detailDesc:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, 1)
    end

    -- Tier sub-tabs
    local tierDefs = {
        { key = "first",   label = L["STYLE_TIER_SIGNATURE"] },
        { key = "second",  label = L["STYLE_TIER_ACCENT"] },
        { key = "neutral", label = L["STYLE_TIER_VERSATILE"] },
        { key = "clash",   label = L["STYLE_TIER_CLASHING"] },
    }
    local tierX = 6
    for _, td in ipairs(tierDefs) do
        local btn = HDG_UIHelpers.CreateChipButton(detailFrame, "", 60, false, nil)
        btn:SetSize(95, 22)
        btn:SetPoint("TOPLEFT", tierX, -48)

        btn.text:SetText(td.label)

        btn.tierKey = td.key
        btn:SetScript("OnClick", function()
            State.selectedTier = td.key
            State.selectedItemID = nil
            self:RefreshDetail()
        end)

        tierButtons[td.key] = btn
        tierX = tierX + 99
    end

    -- Filter bar
    filterBar = CreateFrame("Frame", nil, detailFrame)
    filterBar:SetPoint("TOPLEFT", 6, -74)
    filterBar:SetPoint("RIGHT", -6, 0)
    filterBar:SetHeight(22)

    -- Collection-state segmented control: All / Have / Need.
    -- Replaces the previous Uncollected + Collected checkbox pair.
    local collectionSeg = privateDB.HDG_W_SegmentedChip:Create(filterBar, {
        segments = {
            { key = "all",  label = L["STYLE_FILTER_ALL"]  },
            { key = "have", label = L["STYLE_FILTER_HAVE"] },
            { key = "need", label = L["STYLE_FILTER_NEED"] },
        },
        onSelect = function(key)
            State:SetCollectionFilter(key)
            StylesTab:RefreshDetail()
        end,
    })
    collectionSeg.frame:SetPoint("LEFT", 0, 0)
    -- Initial active state from existing module flags.
    collectionSeg:SetActive(State.collectionFilter)
    detailFrame.collectionSeg = collectionSeg
    local segAnchor = collectionSeg.frame

    -- Craftable checkbox
    local craftCheck = CreateFrame("CheckButton", nil, filterBar, "UICheckButtonTemplate")
    craftCheck:SetSize(20, 20)
    craftCheck:SetPoint("LEFT", segAnchor, "RIGHT", 10, 0)
    craftCheck:SetChecked(State.craftableOnly)
    craftCheck:SetScript("OnClick", function(self)
        State.craftableOnly = self:GetChecked()
        StylesTab:RefreshDetail()
    end)
    local craftLabel = filterBar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    craftLabel:SetPoint("LEFT", craftCheck, "RIGHT", 2, 0)
    craftLabel:SetText(L["STYLE_FILTER_CRAFTABLE"])
    if scheme then ApplyFont(craftLabel, scheme, "small") end

    -- Hide Unreleased checkbox
    local releasedCheck = CreateFrame("CheckButton", nil, filterBar, "UICheckButtonTemplate")
    releasedCheck:SetSize(20, 20)
    releasedCheck:SetPoint("LEFT", craftLabel, "RIGHT", 10, 0)
    releasedCheck:SetChecked(State.hideUnreleased)
    releasedCheck:SetScript("OnClick", function(self)
        State.hideUnreleased = self:GetChecked()
        StylesTab:RefreshDetail()
    end)
    local releasedLabel = filterBar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    releasedLabel:SetPoint("LEFT", releasedCheck, "RIGHT", 2, 0)
    releasedLabel:SetText(L["ACQ_HIDE_UNRELEASED"])
    if scheme then ApplyFont(releasedLabel, scheme, "small") end

    -- Search box
    searchBox = CreateFrame("EditBox", nil, filterBar, "SearchBoxTemplate")
    searchBox:SetSize(140, 20)
    searchBox:SetPoint("RIGHT", -4, 0)
    searchBox:SetAutoFocus(false)
    searchBox:SetScript("OnTextChanged", function(self)
        State.searchText = self:GetText():lower()
        if State.searchDebounceTimer then State.searchDebounceTimer:Cancel() end
        State.searchDebounceTimer = C_Timer.NewTimer(0.2, function()
            State.searchDebounceTimer = nil
            StylesTab:RefreshDetail()
        end)
    end)

    -- Room filter buttons
    local roomDefs = {
        { key = nil,          label = "All" },
        { key = "great-hall", label = "Great Hall" },
        { key = "kitchen",    label = "Kitchen" },
        { key = "study",      label = "Study" },
        { key = "bedroom",    label = "Bedroom" },
        { key = "garden",     label = "Garden" },
        { key = "workshop",   label = "Workshop" },
        { key = "trophy",     label = "Trophy" },
    }
    local roomX = 6
    for _, rd in ipairs(roomDefs) do
        local btn = HDG_UIHelpers.CreateChipButton(detailFrame, "", 60, false, nil)
        btn:SetPoint("TOPLEFT", roomX, -96)
        btn.text:SetText(rd.label)

        btn:SetWidth(btn.text:GetStringWidth() + 16)
        btn.roomKey = rd.key
        btn.baseLabel = rd.label
        btn:SetScript("OnClick", function()
            State.selectedRoom = rd.key
            self:RefreshDetail()
        end)

        roomButtons[#roomButtons + 1] = btn
    end

    -- Item list (left side)
    listFrame = CreateFrame("Frame", nil, detailFrame, "BackdropTemplate")
    listFrame:SetPoint("TOPLEFT", 4, -118)
    listFrame:SetPoint("BOTTOMLEFT", 4, 4)
    listFrame:SetWidth(280)
    HDG_UIHelpers.ApplyBackdrop(listFrame, "panel",
        {0.08, 0.08, 0.08, 0.6}, {0.2, 0.2, 0.2, 0.4})
    RegisterWidget(listFrame, "Panel")

    itemListScrollBox = CreateFrame("Frame", nil, listFrame, "WowScrollBoxList")
    itemListScrollBox:SetPoint("TOPLEFT", 2, -2)
    itemListScrollBox:SetPoint("BOTTOMRIGHT", -18, 2)

    itemListScrollBar = CreateFrame("EventFrame", nil, listFrame, "MinimalScrollBar")
    itemListScrollBar:SetPoint("TOPLEFT", itemListScrollBox, "TOPRIGHT", 2, 0)
    itemListScrollBar:SetPoint("BOTTOMLEFT", itemListScrollBox, "BOTTOMRIGHT", 2, 0)

    itemListScrollView = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    itemListScrollView:SetElementExtentCalculator(function() return Detail.ROW_HEIGHT end)
    itemListScrollView:SetElementInitializer("Button", function(frame, elementData)
        Detail:_RenderItemListRow(frame, elementData)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(itemListScrollBox, itemListScrollBar, itemListScrollView)

    -- Right-side item-detail panel: build via the extracted ItemPanel module.
    detailPanel = ItemPanel:Build(detailFrame, listFrame, scheme)

    -- Export button (custom styles only)
    local detailExportBtn = HDG_UIHelpers.CreateChipButton(detailFrame, "", 60, false, nil)
    detailExportBtn.text:SetText(L["BTN_EXPORT"])
    detailExportBtn:SetWidth(detailExportBtn.text:GetStringWidth() + 20)
    detailExportBtn:SetPoint("TOPRIGHT", -28, -6)
    detailExportBtn:SetScript("OnClick", function()
        if not State.selectedStyle or not StyleSerializer then return end
        local def = GetStyleDef(State.selectedStyle)
        if def then
            local encoded = StyleSerializer:Export(def)
            if encoded then StylesTab:ShowExportDialog(encoded) end
        end
    end)
    detailExportBtn:Hide()
    detailFrame.detailExportBtn = detailExportBtn

    -- View toggle button — top-right utility cluster, anchored left of Export.
    local viewToggleBtn = HDG_UIHelpers.CreateChipButton(detailFrame, "", 60, false, nil)
    viewToggleBtn:SetSize(60, 22)
    viewToggleBtn:SetPoint("RIGHT", detailExportBtn, "LEFT", -4, 0)
    viewToggleBtn.text:SetText(L["STYLE_VIEW_LIST"])

    viewToggleBtn:SetScript("OnClick", function()
        if State.viewMode == "gallery" then
            State.viewMode = "list"
            viewToggleBtn.text:SetText(L["STYLE_VIEW_GRID"])
        else
            State.viewMode = "gallery"
            viewToggleBtn.text:SetText(L["STYLE_VIEW_LIST"])
        end
        StylesTab:RefreshDetail()
    end)
    detailFrame.viewToggleBtn = viewToggleBtn

    -- Card grid container (hidden by default)
    cardGridFrame = CreateFrame("Frame", nil, detailFrame, "BackdropTemplate")
    cardGridFrame:SetPoint("TOPLEFT", 4, -118)
    cardGridFrame:SetPoint("BOTTOMRIGHT", -4, 4)
    HDG_UIHelpers.ApplyBackdrop(cardGridFrame, "panel",
        {0.08, 0.08, 0.08, 0.6}, {0.2, 0.2, 0.2, 0.4})
    RegisterWidget(cardGridFrame, "Panel")
    cardGridFrame:Hide()

    local ST = CONFIG.STYLES_TAB
    cardGridScrollBox, cardGridScrollBar = HDG.StyleCardRow.Create(cardGridFrame, {
        cellSize         = ST.CELL_SIZE,
        statusIconSize   = ST.STATUS_ICON_SIZE,
        checkmarkTexture = CONFIG.TEXTURES.CHECKMARK,
        sceneID          = ST.DEFAULT_SCENE_ID,
        onEnter          = Detail.OnCellEnter,
        onLeave          = Detail.OnCellLeave,
        onClick          = Detail.OnCellClick,
    })
    cardGridScrollBox:SetPoint("TOPLEFT", 4, -4)
    cardGridScrollBox:SetPoint("BOTTOMRIGHT", -20, 4)

    -- Hand widget refs to the Detail module so its helpers can read them.
    -- Tables are passed by reference so subsequent population (subcat/source
    -- buttons created on demand by RefreshDetail) is visible to Detail too.
    Detail:Init({
        tierButtons       = tierButtons,
        subcatButtons     = subcatButtons,
        roomButtons       = roomButtons,
        sourceButtons     = sourceButtons,
        listFrame         = listFrame,
        detailPanel       = detailPanel,
        cardGridFrame     = cardGridFrame,
        cardGridScrollBox = cardGridScrollBox,
        itemListScrollBox = itemListScrollBox,
        filterBar         = filterBar,
        detailFrame       = detailFrame,
        detailHeader      = detailHeader,
        detailDesc        = detailDesc,
    })
end

-- HideDetailFilterButtons + SortByDecorName migrated to UI/Tabs/Styles/HDG_ST_Detail.lua.
-- Call sites use Detail:HideFilterButtons() / Detail.SortByDecorName(filtered).

-- ============================================================================
-- SHOW / REFRESH
-- ============================================================================

function StylesTab:ShowLanding()
    State.selectedShoppingListId = nil
    State.selectedSessionData    = nil
    detailFrame:Hide()
    if StylesTab._editorFrame then StylesTab._editorFrame:Hide() end
    if HDG_StylesAudit and HDG_StylesAudit.Hide then HDG_StylesAudit:Hide() end
    landingFrame:Show()
    statsCacheDirty = true
    self:RefreshLanding()
end

function StylesTab:ShowAudit()
    if not StylesTab._panel then StylesTab:Create() end
    landingFrame:Hide()
    detailFrame:Hide()
    if StylesTab._editorFrame then StylesTab._editorFrame:Hide() end
    if HDG_StylesAudit and HDG_StylesAudit.Show then
        HDG_StylesAudit:Show(StylesTab._panel)
    end
    StylesTab._panel:Show()
end

function StylesTab:ShowDetail(styleId)
    local def = GetStyleDef(styleId)
    State:ResetForStyle(styleId, def)
    landingFrame:Hide()
    if StylesTab._editorFrame then StylesTab._editorFrame:Hide() end
    detailFrame:Show()
    self:RefreshDetail()
end

function StylesTab:RefreshDetail()  Detail:Render() end
function StylesTab:RenderCardGrid(items, scheme) Detail:RenderCardGrid(items, scheme) end
function StylesTab:RenderItemList(items)         Detail:RenderItemList(items) end
function StylesTab:_RenderItemListRow(frame, ed) Detail:_RenderItemListRow(frame, ed) end

-- ============================================================================
-- ITEM DETAIL PANEL
-- ============================================================================

-- LoadPreviewModel + RefreshItemDetail moved to UI/Tabs/Styles/HDG_ST_ItemPanel.
-- Thin shims keep the old StylesTab method names callable from internal sites
-- and external dispatchers; both forward to the ItemPanel module.
function StylesTab:LoadPreviewModel(decorID) ItemPanel:LoadModel(decorID) end
function StylesTab:RefreshItemDetail(scheme) ItemPanel:Render(scheme)     end


-- Delete confirmation popup
StaticPopupDialogs["HDG_DELETE_CUSTOM_STYLE"] = {
    text = L["STYLE_CONFIRM_DELETE"],
    button1 = L["STYLE_DELETE"],
    button2 = L["STYLE_CANCEL"],
    OnAccept = function(self, data)
        if data and data.key and HDG.Store then
            DeleteStyleCollection(data.key)
            if StyleEngine then
                StyleEngine:RemoveCustomStyle(data.key)
            end
            if State.selectedStyle == data.key then
                State.selectedStyle = nil
            end
            StylesTab:ShowLanding()
        end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

-- ============================================================================
-- SNAPSHOT DETAIL VIEW
-- ============================================================================

-- RefreshFlatGalleryDetail migrated to UI/Tabs/Styles/HDG_ST_Detail.lua as
-- Detail:RenderFlatGallery. Snapshot + ShoppingList branches call it directly.

-- RefreshSnapshotDetail body migrated to UI/Tabs/Styles/HDG_ST_Detail.lua
-- as Detail:RenderSnapshot(def). Kept as a shim for the dispatcher in
-- RefreshDetail.
function StylesTab:RefreshSnapshotDetail(def, scheme) Detail:RenderSnapshot(def) end

-- ============================================================================
-- SHOPPING LIST DETAIL VIEW
-- ============================================================================

function StylesTab:ShowShoppingListDetail(listId)
    State.selectedShoppingListId = listId
    State.selectedStyle          = nil
    State.selectedSessionData    = nil
    State.selectedItemID         = nil
    landingFrame:Hide()
    if StylesTab._editorFrame then StylesTab._editorFrame:Hide() end
    detailFrame:Show()
    self:RefreshShoppingListDetail()
end

-- RefreshShoppingListDetail body migrated to UI/Tabs/Styles/HDG_ST_Detail.lua
-- as Detail:RenderShoppingList(). Kept as a shim for the dispatcher in
-- RefreshDetail.
function StylesTab:RefreshShoppingListDetail() Detail:RenderShoppingList() end

-- ============================================================================
-- SNAPSHOT PLACEMENT COST
-- ============================================================================

function StylesTab:ComputeSnapshotCost(def)
    if not def or not def.snapshot or not def.snapshot.items then return end
    if not (HDG_Data and HDG_Data.GetCatalog) then return end
    if not HDG_AllDecorDB then return end
    local cost = 0
    for itemID, counts in pairs(def.snapshot.items) do
        local entry = HDG_AllDecorDB[itemID]
        if entry then
            local info = HDG_Data.GetCatalog(entry[1])
            if info and info.placementCost then
                cost = cost + info.placementCost * (counts.placed or 0)
            end
        end
    end
    def.snapshot.totalPlacementCost = cost
end

-- ============================================================================
-- SNAPSHOT CAPTURE
-- ============================================================================

function StylesTab:TakeSnapshot()
    -- Collect all itemIDs to scan
    local allItemIDs = {}
    for itemID in pairs(HDG_AllDecorDB) do
        allItemIDs[#allItemIDs + 1] = itemID
    end
    table.sort(allItemIDs)

    local snapshotItems = {}
    local totalCost = 0
    local idx = 0
    local total = #allItemIDs
    local BATCH_SIZE = 200
    local dbgOk, dbgNil, dbgHit = 0, 0, 0

    -- Update button text to show scanning
    if landingFrame and landingFrame.snapshotBtn then
        landingFrame.snapshotBtn.text:SetText(L["GOBLIN_SCANNING"])
        landingFrame.snapshotBtn:Disable()
    end

    local function ProcessBatch()
        local batchEnd = math.min(idx + BATCH_SIZE, total)
        for i = idx + 1, batchEnd do
            local itemID = allItemIDs[i]
            local entry = HDG_AllDecorDB[itemID]
            if entry then
                local decorID = entry[1]
                local catInfo = HDG_Data and HDG_Data.GetCatalog and HDG_Data.GetCatalog(decorID)
                if catInfo then
                    dbgOk = dbgOk + 1
                    local placed = catInfo.numPlaced or 0
                    if placed > 0 then
                        dbgHit = dbgHit + 1
                        local stored = catInfo.quantity or 0
                        snapshotItems[itemID] = { placed = placed, stored = stored }
                        if catInfo.placementCost then totalCost = totalCost + catInfo.placementCost * placed end
                    end
                else
                    dbgNil = dbgNil + 1
                end
            end
        end
        idx = batchEnd

        if idx < total then
            C_Timer.After(0, ProcessBatch)
        else
            print("|cFF14b8a6[HDG Snapshot]|r Catalog scan: " .. total .. " items, " .. dbgOk .. " ok, " .. dbgNil .. " nil, " .. dbgHit .. " placed (account-wide)")
            StylesTab:FinishSnapshot(snapshotItems, totalCost)
        end
    end

    ProcessBatch()
end

function StylesTab:FinishSnapshot(snapshotItems, totalCost)
    -- Restore button
    if landingFrame and landingFrame.snapshotBtn then
        landingFrame.snapshotBtn.text:SetText(L["STYLE_SAVE_PLACED_DECOR"])
        landingFrame.snapshotBtn:Enable()
    end

    -- Check if we found anything
    local itemCount = 0
    local totalPlaced = 0
    for _, counts in pairs(snapshotItems) do
        itemCount = itemCount + 1
        totalPlaced = totalPlaced + (counts.placed or 0)
    end

    if itemCount == 0 then
        print("|cFFFF4444[HDG]|r No placed or stored decor found")
        return
    end

    -- Show name dialog
    StaticPopupDialogs["HDG_SNAPSHOT_NAME"] = StaticPopupDialogs["HDG_SNAPSHOT_NAME"] or {
        text = "Name your snapshot (all placed decor, account-wide):",
        button1 = "Save",
        button2 = "Cancel",
        hasEditBox = true,
        editBoxWidth = 250,
        OnShow = function(self)
            self.EditBox:SetText("All Placed Decor")
            self.EditBox:HighlightText()
            self.EditBox:SetFocus()
        end,
        OnAccept = function(self)
            local name = self.EditBox:GetText()
            if not name or name == "" then name = "All Placed Decor" end
            StylesTab:SaveSnapshot(name, self.data.items, self.data.totalCost)
        end,
        timeout = 0,
        whileDead = true,
        hideOnEscape = true,
        preferredIndex = 3,
    }

    StaticPopup_Show("HDG_SNAPSHOT_NAME", nil, nil, { items = snapshotItems, totalCost = totalCost or 0 })
end

function StylesTab:SaveSnapshot(name, snapshotItems, totalCost)
    local totalPlaced = 0
    for _, counts in pairs(snapshotItems) do
        totalPlaced = totalPlaced + (counts.placed or 0)
    end

    local def = {
        displayName = name,
        iconAtlas = "decor-controls-decoratemode-active",
        tier = "snapshot",
        color = { r = 0.4, g = 0.7, b = 1.0 },
        description = totalPlaced .. " placed -- " .. date("%b %d, %Y"),
        snapshot = {
            capturedAt = time(),
            items = snapshotItems,
            totalPlacementCost = totalCost or 0,
        },
    }

    local key = "snapshot-" .. time() .. "-" .. math.random(1000, 9999)
    SaveStyleCollection(key, def)
    if StyleEngine then
        StyleEngine:InvalidateCustomStyle(key)
    end

    print("|cFF00FF00[HDG]|r Snapshot saved: " .. name .. " (" .. totalPlaced .. " items placed)")

    if panel and panel:IsShown() then
        self:ShowLanding()
    end
end

-- ============================================================================
-- EXPORT DIALOG
-- ============================================================================

local exportCtrl

function StylesTab:ShowExportDialog(encodedStr, snapshotDef)
    if not exportCtrl then
        exportCtrl = HDG_UIHelpers.CreateExportDialog({ width = 500, height = 220, bottomPad = 65 })
        -- Decor ID checkbox (shown only for snapshot exports)
        local decorCB = CreateFrame("CheckButton", nil, exportCtrl.frame, "UICheckButtonTemplate")
        decorCB:SetSize(22, 22)
        decorCB:SetPoint("BOTTOMLEFT", 12, 32)
        local decorLabel = exportCtrl.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        decorLabel:SetPoint("LEFT", decorCB, "RIGHT", 2, 0)
        decorLabel:SetText(L["EXPORT_USE_DECOR_IDS"])
        local scheme = HDG_UIHelpers.GetScheme()
        if scheme then HDG.Theme.ApplyFont(decorLabel, scheme, "small") end
        decorCB:Hide()
        exportCtrl.decorCB = decorCB
    end

    exportCtrl:Show(encodedStr, "|cFF14b8a6" .. L["STYLE_EXPORT_TITLE"] .. "|r")

    -- Show decorID checkbox only for snapshot exports
    if snapshotDef and snapshotDef.snapshot and StyleSerializer then
        exportCtrl.decorCB:SetChecked(false)
        exportCtrl.decorCB:Show()
        exportCtrl.decorCB:SetScript("OnClick", function(self)
            local useDecor = self:GetChecked()
            local reEncoded = StyleSerializer:ExportSnapshot(snapshotDef, useDecor)
            if reEncoded then
                exportCtrl.editBox:SetText(reEncoded)
                exportCtrl.editBox:HighlightText()
                exportCtrl.editBox:SetFocus()
            end
        end)
    else
        exportCtrl.decorCB:Hide()
    end
end

-- ============================================================================
-- IMPORT DIALOG
-- ============================================================================

StaticPopupDialogs["HDG_IMPORT_STYLE"] = {
    text = L["STYLE_IMPORT_PROMPT"],
    button1 = L["STYLE_IMPORT"],
    button2 = L["STYLE_CANCEL"],
    hasEditBox = true,
    editBoxWidth = 350,
    OnShow = function(self)
        self.EditBox:SetMaxLetters(0) -- unlimited (default 255 truncates long HDGVL strings)
        self.EditBox:SetText("")
        self.EditBox:SetFocus()
    end,
    OnAccept = function(self)
        local text = self.EditBox:GetText()
        if not text or text == "" then return end
        if not StyleSerializer then return end

        local styleDef, err = StyleSerializer:Import(text)
        if not styleDef then
            print("|cFFFF4444[HDG]|r " .. string.format(L["STYLE_MSG_IMPORT_FAILED"], err or "unknown error"))
            return
        end

        -- Compute placement cost for snapshot imports
        if styleDef.tier == "snapshot" and styleDef.snapshot and not styleDef.snapshot.totalPlacementCost then
            StylesTab:ComputeSnapshotCost(styleDef)
        end

        -- Save (use snapshot key prefix for snapshot imports)
        local key
        if styleDef.tier == "snapshot" then
            key = "snapshot-" .. time() .. "-" .. math.random(1000, 9999)
        end
        key = SaveStyleCollection(key, styleDef)
        if StyleEngine then
            StyleEngine:InvalidateCustomStyle(key)
        end

        print("|cFF00FF00[HDG]|r " .. string.format(L["STYLE_MSG_IMPORTED"], styleDef.displayName))
        if StylesTab and panel and panel:IsShown() then
            StylesTab:ShowLanding()
        end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

function StylesTab:ShowImportDialog()
    StaticPopup_Show("HDG_IMPORT_STYLE")
end

-- ============================================================================
-- RECENT SESSION DETAIL VIEW
-- ============================================================================

function StylesTab:ShowSessionDetail(houseGUID, sessionID, displayName)
    if not (houseGUID and sessionID) then return end
    State.selectedSessionData    = { houseGUID = houseGUID, sessionID = sessionID, displayName = displayName }
    State.selectedShoppingListId = nil
    State.selectedStyle          = nil
    State.selectedItemID         = nil
    landingFrame:Hide()
    if StylesTab._editorFrame then StylesTab._editorFrame:Hide() end
    detailFrame:Show()
    self:RefreshSessionDetail()
end

-- RefreshSessionDetail body migrated to UI/Tabs/Styles/HDG_ST_Detail.lua as
-- Detail:RenderSession(). Kept as a shim for ShowSessionDetail and external
-- dispatch (Show()'s "resume into prior session" path).
function StylesTab:RefreshSessionDetail() Detail:RenderSession() end

