-- HDG_StylesAudit.lua
-- Audit-mode view for Style Creator: triage items not in any user style,
-- fan them out into existing styles, view existing style contents.
-- Loaded after HDG_StylesEditor.lua. Hosted inside the editor frame.

local addonName, privateDB = ...
local HDG = _G.HousingDecorGuide
local L = HDG_L or {}
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

-- Theme helpers — unpack a scheme color table to r,g,b,a.
local function rgba(c) if not c then return 1, 1, 1, 1 end return c.r, c.g, c.b, c.a end
-- Same but force alpha for textures that are always opaque (or override).
local function rgb(c, a) if not c then return 1, 1, 1, a or 1 end return c.r, c.g, c.b, a or c.a end

-- Engine + central catalog accessors. StyleEngine lives on the private
-- namespace (`privateDB.HDG_StyleEngine`); HDG_Data is a global table
-- whose API uses dot-call functions (HDG_Data.GetCatalog(decorID)).
-- Engine ref is captured lazily so file-load order (TOC) doesn't matter.
local StyleEngine
local function ensureEngine()
    if not StyleEngine then StyleEngine = privateDB.HDG_StyleEngine end
    return StyleEngine
end
local function _GetCatalog()
    return (HDG_Data and HDG_Data.GetAllCatalog and HDG_Data.GetAllCatalog()) or {}
end

HDG_StylesAudit = HDG_StylesAudit or {}
local Audit = HDG_StylesAudit
privateDB.HDG_StylesAudit = Audit

-- Read all style collections, returning (stylesByBareKey, sortedKeys).
-- Sort key is displayName then bare styleId for stable ordering across
-- reloads (replaces the legacy customStyleOrder insertion-order array).
local function _GetStyles()
    local out, list = {}, {}
    if not (HDG.Store and HDG.Store.Collection_ListByType) then return out, list end
    for collID, def in pairs(HDG.Store:Collection_ListByType("style")) do
        local sid = collID:sub(7) -- strip "style:" prefix
        out[sid] = def
        list[#list + 1] = sid
    end
    table.sort(list, function(a, b)
        local na = (out[a].displayName or a):lower()
        local nb = (out[b].displayName or b):lower()
        if na == nb then return a < b end
        return na < nb
    end)
    return out, list
end

-- Persist a style def via the Collections API. Mirrors the old SaveCustomStyle
-- contract: returns the bare styleId, generating a new one if `key` is nil.
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

-- Source dropdown shows EVERY style (any tier) — viewing is non-mutating.

-- Target list shows only curated styles — Add/Move/Remove mutations require
-- curated tier (smart styles are rule-defined; you can't directly file into
-- them).
local function isCuratedStyle(_sid, def) return def and def.tier == "curated" end

-- Build a styleId -> display-name map with " (1) / (2) / ..." suffixes appended
-- ONLY where the underlying displayName collides. Clean names stay clean.
-- Caller passes the customStyles table + customStyleOrder array AND an
-- optional filter(sid, def) predicate — only styles passing the filter are
-- counted AND emitted, so suffix numbering matches the visible rows (no
-- gaps from filtered-out duplicates).
local function buildDisplayNames(customs, order, filter)
    local nameCounts, seen, out = {}, {}, {}
    local function included(sid, def)
        if not def then return false end
        if filter and not filter(sid, def) then return false end
        return true
    end
    for _, sid in ipairs(order or {}) do
        local def = customs and customs[sid]
        if included(sid, def) then
            local name = def.displayName or sid
            nameCounts[name] = (nameCounts[name] or 0) + 1
        end
    end
    local function emit(sid, def)
        local name = def.displayName or sid
        if (nameCounts[name] or 0) > 1 then
            seen[name] = (seen[name] or 0) + 1
            out[sid] = name .. " (" .. seen[name] .. ")"
        else
            out[sid] = name
        end
    end
    for _, sid in ipairs(order or {}) do
        local def = customs and customs[sid]
        if included(sid, def) then emit(sid, def) end
    end
    return out
end

-- Convenience: look up the display name for one styleId, computing
-- disambiguators on the fly. Returns the styleId itself as a last-resort
-- fallback so callers never get nil. Used by undo strip + dropdown text.
local function displayNameFor(sid)
    if not sid or not HDG.Store then return sid end
    local customs, order = _GetStyles()
    -- displayNameFor is used by undo + selection bar, which only ever
    -- name curated targets (mutations require curated). Use the
    -- curated-only filter so suffix numbering matches the target list.
    local map = buildDisplayNames(customs, order, isCuratedStyle)
    return map[sid] or sid
end

-- Mounted UI root (created lazily on first Show, parented to the editorFrame)
local auditRoot = nil

-- Audit state (used by Tasks 11-17 to render columns/grid/undo)
-- Source value: nil = Unassigned, "__all__" = All Collected Decor (virtual),
-- "__uncollected__" = Wishlist (uncollected universe, virtual; pre-file items
-- you don't own yet into your styles), otherwise a curated styleId.
-- Move/Remove are only meaningful when source is a real curated style — see
-- isRealStyleSource() below.
local SOURCE_ALL = "__all__"
local SOURCE_UNCOLLECTED = "__uncollected__"
local sourceStyleId = nil

local function isVirtualSourceId(sid)
    return sid == SOURCE_ALL or sid == SOURCE_UNCOLLECTED
end

local function isRealStyleSource()
    return sourceStyleId ~= nil and not isVirtualSourceId(sourceStyleId)
end
local targetStyleId = nil   -- the active "File into" target
local clickMode = "copy"    -- "copy" | "move" | "remove"
local CLICK_MODE_LABEL = {
    copy   = L["STYLE_CURATOR_COPY"]   or "Copy",
    move   = L["STYLE_CURATOR_MOVE"]   or "Move",
    remove = L["STYLE_CURATOR_REMOVE"] or "Remove",
}
local activeParentCat = nil -- numeric Blizzard categoryID or nil for All
local activeSubCat = nil    -- numeric subcategoryID or nil for All-of-parent
local selectedItems = {}    -- [itemID] = true
local selectedCount = 0
local undoStack = {}        -- ring buffer of last 10 ops
local UNDO_CAP = 10
-- True during a bulk-apply loop. The HDG_STYLE_MEMBERSHIP_CHANGED listener
-- skips its render while this is set; _ApplyBulkSelection clears the flag
-- and fires one render after the loop completes.
local _bulkApplyInProgress = false

-- Coverage cache: collected-decor total only changes on HDG_COLLECTION_READY.
-- The "assigned" count is recomputed each render (cheap O(catalog) walk that
-- piggybacks on the IsUnassigned check).
local cachedCoverageTotal = 0
local cachedCoverageTotalDirty = true

-- Per-Show baseline of curated style item counts. We snapshot at Show()
-- time so the target list can highlight (green count) any style whose
-- membership changed during the current edit session. Cleared on Hide.
local sessionBaselineCounts = {}

local function snapshotSessionBaseline()
    wipe(sessionBaselineCounts)
    if not HDG.Store then return end
    local customs = _GetStyles()
    for sid, def in pairs(customs) do
        if def.tier == "curated" then
            local count = 0
            if def.curatedItems then
                for _ in pairs(def.curatedItems) do count = count + 1 end
            end
            sessionBaselineCounts[sid] = count
        end
    end
end

-- ============================================================================
-- Column 2 grid constants
-- ============================================================================
local TILE_SIZE = 46  -- 48→46 to fit one more tile per row at current col2 width
local TILE_GAP = 6
local TILE_ROW_H = TILE_SIZE + TILE_GAP  -- height of one ScrollBox row in the grid

-- ============================================================================
-- Column 2 helpers (chip pools — separate parent and sub so widths don't clash)
-- ============================================================================
local chipPool = { parent = {}, sub = {} }
local function acquireChip(parent, kind)
    local pool = chipPool[kind]
    local chip = table.remove(pool)
    if not chip then
        chip = HDG_UIHelpers.CreateChipButton(parent, "", 60, false, nil)
    end
    chip:SetParent(parent); chip:Show()
    return chip
end
local function releaseChip(chip, kind)
    chip:Hide(); chip:ClearAllPoints()
    chip:SetScript("OnClick", nil)  -- clear stale closure
    table.insert(chipPool[kind], chip)
end

function Audit:Show(editorFrame)
    ensureEngine()  -- captures privateDB.HDG_StyleEngine on first show
    if not auditRoot then self:_Build(editorFrame) end
    auditRoot:Show()
    snapshotSessionBaseline()  -- baseline counts for the green-on-changed indicator
    self:_PopulateSourceDropdown()
    self:_Render()
end

function Audit:Hide()
    if auditRoot then auditRoot:Hide() end
end

function Audit:_Build(parent)
    auditRoot = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    auditRoot:SetAllPoints(parent)
    -- Paint a SOLID scheme.bg over the host so the HDG main-window wood/atlas
    -- texture (which the editor frame inherits) doesn't bleed through into
    -- the Curator surface. The "Panel" skinner can't be used here because in
    -- HousingTheme it stamps the wood atlas on top of any backdrop, and in
    -- color themes it paints with c.panel (semi-transparent). Repaint is
    -- driven by our own HDG_THEME_UPDATE listener below.
    auditRoot:SetBackdrop({ bgFile = "Interface/Buttons/WHITE8X8" })
    local function _repaintAuditRootBg()
        local s = GetScheme()
        if s and s.bg and auditRoot.SetBackdropColor then
            auditRoot:SetBackdropColor(s.bg.r, s.bg.g, s.bg.b, 1)  -- force opaque
        end
    end
    _repaintAuditRootBg()
    auditRoot._repaintBg = _repaintAuditRootBg

    -- ============================================================================
    -- Top bar: Back + title
    -- ============================================================================
    local topBar = CreateFrame("Frame", nil, auditRoot)
    topBar:SetPoint("TOPLEFT", 0, 0)
    topBar:SetPoint("TOPRIGHT", 0, 0)
    topBar:SetHeight(28)
    auditRoot.topBar = topBar

    local backBtn = CreateFrame("Button", nil, topBar, "UIPanelButtonTemplate")
    backBtn:SetSize(60, 22)
    backBtn:SetPoint("LEFT", 4, 0)
    backBtn:SetText(L["STYLE_BACK"] or "< Back")
    backBtn:SetScript("OnClick", function()
        local StylesTab = privateDB.HDG_StylesTab
        if StylesTab and StylesTab.ShowLanding then StylesTab:ShowLanding() end
    end)
    auditRoot.backBtn = backBtn

    local title = topBar:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("LEFT", backBtn, "RIGHT", 12, 0)
    title:SetText(L["STYLE_CURATOR_TITLE"] or "Style Curator")
    auditRoot.title = title

    -- ============================================================================
    -- Column 1: Source picker (top) + Target list (bottom)
    -- ============================================================================
    local scheme = GetScheme()
    local col1 = CreateFrame("Frame", nil, auditRoot, "BackdropTemplate")
    col1:SetWidth(210)
    col1:SetPoint("TOPLEFT", topBar, "BOTTOMLEFT", 0, -4)
    col1:SetPoint("BOTTOMLEFT", 0, 42)  -- 42 = coverage strip (40) + 2px gap
    HDG_UIHelpers.ApplyBackdrop(col1, "panel", { rgba(scheme.panel) }, { rgba(scheme.border) })
    RegisterWidget(col1, "Panel")
    auditRoot.col1 = col1

    -- Col1 source section header: text_header color + 1px rule below
    local srcLabel = col1:CreateFontString(nil, "OVERLAY")
    ApplyFont(srcLabel, scheme, "xsmall")
    srcLabel:SetPoint("TOPLEFT", 8, -8)
    srcLabel:SetText(L["STYLE_CURATOR_VIEWING"] or "VIEWING (SOURCE)")
    srcLabel:SetTextColor(rgba(scheme.text_header))  -- was text_dim

    local srcLabelRule = col1:CreateTexture(nil, "ARTWORK")
    srcLabelRule:SetHeight(1)
    srcLabelRule:SetPoint("TOPLEFT",  srcLabel, "BOTTOMLEFT",  -4, -2)
    srcLabelRule:SetPoint("TOPRIGHT", col1,     "TOPRIGHT",     -4, 0)
    srcLabelRule:SetColorTexture(rgba(scheme.border))

    -- Themed dropdown — hand-rolled because HDG.UI:Dropdown internally uses a
    -- legacy scroll template that violates the addon-wide scroll rule. No
    -- Blizzard UIDropDownMenuTemplate dependency. API:
    --   dd:SetItems({ {key=, label=}, ... }) — populate menu
    --   dd:SetSelected(key, data) — set the visible label
    --   dd:RepaintTheme()         — re-apply scheme paint (called on theme switch)
    -- onSelect(key, data) fires on item click.
    auditRoot._dropdowns = auditRoot._dropdowns or {}  -- list to walk on theme change
    local function makeThemedDropdown(parent, opts)
        opts = opts or {}
        local width  = opts.width  or 180
        local height = opts.height or 24

        local container = CreateFrame("Button", nil, parent)
        container:SetSize(width, height)
        container:EnableMouse(true)

        container.bg = container:CreateTexture(nil, "BACKGROUND")
        container.bg:SetAllPoints()

        -- 1px border via 4 edge textures
        container.borders = {}
        for _, edge in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
            local b = container:CreateTexture(nil, "OVERLAY")
            if edge == "TOP" then b:SetPoint("TOPLEFT", 0, 0); b:SetPoint("TOPRIGHT", 0, 0); b:SetHeight(1)
            elseif edge == "BOTTOM" then b:SetPoint("BOTTOMLEFT", 0, 0); b:SetPoint("BOTTOMRIGHT", 0, 0); b:SetHeight(1)
            elseif edge == "LEFT" then b:SetPoint("TOPLEFT", 0, 0); b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(1)
            else b:SetPoint("TOPRIGHT", 0, 0); b:SetPoint("BOTTOMRIGHT", 0, 0); b:SetWidth(1) end
            container.borders[#container.borders + 1] = b
        end

        container.text = container:CreateFontString(nil, "OVERLAY")
        container.text:SetPoint("LEFT", 8, 0)
        container.text:SetPoint("RIGHT", -20, 0)
        container.text:SetJustifyH("LEFT")
        container.text:SetWordWrap(false)

        -- Atlas chevron — atlas tint is theme-independent.
        container.arrow = container:CreateTexture(nil, "OVERLAY")
        container.arrow:SetSize(12, 12)
        container.arrow:SetPoint("RIGHT", -4, 0)
        container.arrow:SetAtlas("housing-floor-arrow-down-pressed")

        -- Floating menu (TOOLTIP strata) with its own bg. Uses a WowScrollBoxList
        -- inside so menus that overflow the viewport get a scrollbar instead of
        -- spilling off the screen (the source-style dropdown grows with the
        -- player's curated style count — easily 80+ entries).
        local menu = CreateFrame("Frame", nil, container)
        menu:SetPoint("TOPLEFT", container, "BOTTOMLEFT", 0, -1)
        menu:SetWidth(width)
        menu:SetFrameStrata("TOOLTIP")
        menu:Hide()
        menu.bg = menu:CreateTexture(nil, "BACKGROUND")
        menu.bg:SetAllPoints()
        container.menu = menu
        container.onSelect = opts.onSelect
        local ITEM_H = 20
        local MAX_VISIBLE = 18  -- cap → 18*20+4 = 364px

        -- Track button frames the scroll-view recycles, for theme repaints.
        container._menuItemFrames = {}

        local scrollBox = CreateFrame("Frame", nil, menu, "WowScrollBoxList")
        scrollBox:SetPoint("TOPLEFT", 2, -2)
        scrollBox:SetPoint("BOTTOMRIGHT", -14, 2)  -- room for scrollbar on right
        menu.scrollBox = scrollBox

        local scrollBar = CreateFrame("EventFrame", nil, menu, "MinimalScrollBar")
        scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 1, 0)
        scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 1, 0)
        menu.scrollBar = scrollBar

        local view = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
        view:SetElementExtentCalculator(function(_, _ed) return ITEM_H end)
        view:SetElementInitializer("Button", function(item, ed)
            local s = GetScheme()
            if not item.text then
                item.text = item:CreateFontString(nil, "OVERLAY")
                item.text:SetPoint("LEFT", 6, 0)
                item:SetScript("OnEnter", function(self_)
                    self_.text:SetTextColor(rgba(GetScheme().accent))
                end)
                item:SetScript("OnLeave", function(self_)
                    self_.text:SetTextColor(rgba(GetScheme().text))
                end)
                container._menuItemFrames[#container._menuItemFrames + 1] = item
            end
            ApplyFont(item.text, s, "small")
            item.text:SetText(ed.label or ed.key)
            item.text:SetTextColor(rgba(s.text))
            item.key, item.data = ed.key, ed
            item:SetWidth(scrollBox:GetWidth())
            item:SetScript("OnClick", function(self_)
                container:SetSelected(self_.key, self_.data)
                container.menu:Hide()
                if container.onSelect then container.onSelect(self_.key, self_.data) end
            end)
        end)
        ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)

        function container:SetItems(list)
            local desiredH = #list * ITEM_H + 4
            local cappedH  = math.min(desiredH, MAX_VISIBLE * ITEM_H + 4)
            self.menu:SetHeight(cappedH)
            self.menu.scrollBox:SetDataProvider(CreateDataProvider(list))
        end
        function container:SetSelected(key, data)
            self.selectedKey = key
            self.text:SetText(data and data.label or key or "")
        end
        function container:GetSelected() return self.selectedKey end

        -- Apply current theme to all scheme-dependent textures + fonts. Called
        -- from build (initial paint) and from the HDG_THEME_UPDATE listener.
        function container:RepaintTheme()
            local s = GetScheme()
            self.bg:SetColorTexture(rgba(s.button_normal))
            for _, b in ipairs(self.borders) do b:SetColorTexture(rgba(s.border)) end
            ApplyFont(self.text, s, "small")
            self.text:SetTextColor(rgba(s.text))
            self.menu.bg:SetColorTexture(rgb(s.bg, 0.97))
            for _, item in ipairs(self._menuItemFrames) do
                if item.text then
                    ApplyFont(item.text, s, "small")
                    item.text:SetTextColor(rgba(s.text))
                end
            end
        end

        container:SetScript("OnMouseDown", function(self_)
            if self_.menu:IsShown() then self_.menu:Hide() else self_.menu:Show() end
        end)
        container:SetScript("OnEnter", function(self_)
            self_.bg:SetColorTexture(rgba(GetScheme().button_hover))
        end)
        container:SetScript("OnLeave", function(self_)
            self_.bg:SetColorTexture(rgba(GetScheme().button_normal))
        end)
        -- Click-outside close
        menu:SetScript("OnUpdate", function(self_)
            if self_:IsShown() and IsMouseButtonDown("LeftButton") then
                if not container:IsMouseOver() and not self_:IsMouseOver() then self_:Hide() end
            end
        end)

        container:RepaintTheme()
        auditRoot._dropdowns[#auditRoot._dropdowns + 1] = container
        return container
    end

    local srcDropdown = makeThemedDropdown(col1, { width = 178, height = 24 })
    srcDropdown:SetPoint("TOPLEFT", srcLabel, "BOTTOMLEFT", 0, -4)
    srcDropdown:SetPoint("RIGHT", col1, "RIGHT", -8, 0)
    auditRoot.sourceDropdown = srcDropdown

    -- Source row: shows the currently-viewed style with its item count.
    -- Mirrors the target rows visually so Synthea can see the source's
    -- current count + green indicator when membership changed this session.
    -- Hidden when source = Unassigned (no count to show for the universe).
    -- Source row: panel_header bg gives visual elevation over the target list.
    -- Source row mirrors the target-row layout: 24px top zone for label/count,
    -- with an inline Rename/Duplicate/Delete action bar shown only when the
    -- source is a real curated style (not Unassigned, not All Collected).
    local SRC_TOP_H, SRC_ACTION_H = 24, 26
    local srcRow = CreateFrame("Frame", nil, col1)
    srcRow:SetPoint("TOPLEFT", srcDropdown, "BOTTOMLEFT", 0, -2)
    srcRow:SetPoint("RIGHT", col1, "RIGHT", -20, 0)  -- match target list right edge
    srcRow:SetHeight(SRC_TOP_H)
    srcRow.bg = srcRow:CreateTexture(nil, "BACKGROUND")
    srcRow.bg:SetAllPoints()
    srcRow.bg:SetColorTexture(rgba(scheme.panel_header))
    -- Accent strip: 3px amber bar on the left edge of the top zone only.
    srcRow.accent = srcRow:CreateTexture(nil, "OVERLAY")
    srcRow.accent:SetPoint("TOPLEFT", 0, 0)
    srcRow.accent:SetSize(3, SRC_TOP_H)
    srcRow.accent:SetColorTexture(rgba(scheme.warning))
    srcRow.label = srcRow:CreateFontString(nil, "OVERLAY")
    ApplyFont(srcRow.label, scheme, "small")
    srcRow.label:SetPoint("TOPLEFT", 8, -(SRC_TOP_H/2 - 6))
    srcRow.count = srcRow:CreateFontString(nil, "OVERLAY")
    ApplyFont(srcRow.count, scheme, "xsmall")
    srcRow.count:SetPoint("TOPRIGHT", -8, -(SRC_TOP_H/2 - 6))

    srcRow.actionBar = CreateFrame("Frame", nil, srcRow)
    srcRow.actionBar:SetPoint("TOPLEFT", srcRow, "TOPLEFT", 2, -SRC_TOP_H - 2)
    srcRow.actionBar:SetPoint("TOPRIGHT", srcRow, "TOPRIGHT", -2, -SRC_TOP_H - 2)
    srcRow.actionBar:SetHeight(SRC_ACTION_H - 4)
    srcRow.actionBar:Hide()
    srcRow.btnRename = HDG_UIHelpers.CreateChipButton(srcRow.actionBar, L["STYLE_CURATOR_RENAME"]    or "Rename",    56, false, nil)
    srcRow.btnRename:SetPoint("LEFT", 0, 0)
    srcRow.btnDup    = HDG_UIHelpers.CreateChipButton(srcRow.actionBar, L["STYLE_CURATOR_DUPLICATE"] or "Duplicate", 64, false, nil)
    srcRow.btnDup:SetPoint("CENTER", 0, 0)
    srcRow.btnDel    = HDG_UIHelpers.CreateChipButton(srcRow.actionBar, L["STYLE_CURATOR_DELETE"]    or "Delete",    50, false, nil)
    srcRow.btnDel:SetPoint("RIGHT", 0, 0)
    srcRow.btnRename:SetScript("OnClick", function() if isRealStyleSource() then Audit:_PromptRenameStyle(sourceStyleId) end end)
    srcRow.btnDup:SetScript("OnClick",    function() if isRealStyleSource() then Audit:_DuplicateStyle(sourceStyleId)    end end)
    srcRow.btnDel:SetScript("OnClick",    function() if isRealStyleSource() then Audit:_PromptDeleteStyle(sourceStyleId) end end)

    srcRow.SRC_TOP_H, srcRow.SRC_ACTION_H = SRC_TOP_H, SRC_ACTION_H
    srcRow:Hide()
    auditRoot.srcRow = srcRow

    -- Target label: text_header color + 1px rule below
    local tgtLabel = col1:CreateFontString(nil, "OVERLAY")
    ApplyFont(tgtLabel, scheme, "xsmall")
    tgtLabel:SetPoint("TOPLEFT", srcDropdown, "BOTTOMLEFT", 0, -10)
    tgtLabel:SetText(L["STYLE_CURATOR_FILE_INTO"] or "FILE INTO (TARGET)")
    tgtLabel:SetTextColor(rgba(scheme.text_header))  -- was scheme.accent
    auditRoot.tgtLabel = tgtLabel

    local tgtLabelRule = col1:CreateTexture(nil, "ARTWORK")
    tgtLabelRule:SetHeight(1)
    tgtLabelRule:SetPoint("TOPLEFT",  tgtLabel, "BOTTOMLEFT",  0, -2)
    tgtLabelRule:SetPoint("TOPRIGHT", col1,     "TOPRIGHT",     -4, 0)
    tgtLabelRule:SetColorTexture(rgba(scheme.border))
    auditRoot.tgtLabelRule = tgtLabelRule

    -- Target list: WowScrollBoxList + MinimalScrollBar
    local tgtScrollBox = CreateFrame("Frame", nil, col1, "WowScrollBoxList")
    tgtScrollBox:SetPoint("TOPLEFT", tgtLabel, "BOTTOMLEFT", 0, -4)
    tgtScrollBox:SetPoint("BOTTOMRIGHT", col1, "BOTTOMRIGHT", -20, 36)
    auditRoot.targetScrollBox = tgtScrollBox

    local tgtScrollBar = CreateFrame("EventFrame", nil, col1, "MinimalScrollBar")
    tgtScrollBar:SetPoint("TOPLEFT", tgtScrollBox, "TOPRIGHT", 2, 0)
    tgtScrollBar:SetPoint("BOTTOMLEFT", tgtScrollBox, "BOTTOMRIGHT", 2, 0)

    -- Row layout:
    --   24px top zone: label + count + accent strip (always shown)
    --   26px action bar:  Rename | Duplicate | Delete (shown only when isTarget)
    -- Variable extent grows the row when it becomes the active target so the
    -- inline action bar appears beneath it.
    local ROW_TOP_H, ROW_ACTION_H = 24, 26
    local tgtView = CreateScrollBoxListLinearView(0, 0, 0, 0, 2)
    tgtView:SetElementExtentCalculator(function(_, elementData)
        return elementData.isTarget and (ROW_TOP_H + ROW_ACTION_H) or ROW_TOP_H
    end)
    tgtView:SetElementInitializer("Button", function(frame, elementData)
        local s = GetScheme()  -- re-fetch each init so theme switches propagate
        if not frame.label then
            frame:RegisterForClicks("LeftButtonUp")
            frame.bg = frame:CreateTexture(nil, "BACKGROUND")
            frame.bg:SetAllPoints()
            -- Accent strip on left edge of the top zone — visible only when this
            -- row is the active fan-out target. Mirrors the source row's amber strip.
            frame.accent = frame:CreateTexture(nil, "OVERLAY")
            frame.accent:SetPoint("TOPLEFT", 0, 0)
            frame.accent:SetSize(3, ROW_TOP_H)
            frame.accent:Hide()
            -- Count first (right-anchored to top zone), then label anchors RIGHT
            -- to count's LEFT so long names truncate instead of overlapping the
            -- count. Both anchored from TOP so they stay put when the row grows.
            frame.count = frame:CreateFontString(nil, "OVERLAY")
            frame.count:SetPoint("TOPRIGHT", -6, -(ROW_TOP_H/2 - 6))
            frame.label = frame:CreateFontString(nil, "OVERLAY")
            frame.label:SetPoint("TOPLEFT", 8, -(ROW_TOP_H/2 - 6))
            frame.label:SetPoint("RIGHT", frame.count, "LEFT", -8, 0)
            frame.label:SetJustifyH("LEFT")
            frame.label:SetWordWrap(false)

            -- Inline action bar — full row width (matches the row's bg edges so
            -- buttons get all the horizontal real estate). Buttons anchored
            -- LEFT / CENTER / RIGHT so they distribute across whatever width
            -- the col1 ends up at.
            frame.actionBar = CreateFrame("Frame", nil, frame)
            frame.actionBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -ROW_TOP_H - 2)
            frame.actionBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -2, -ROW_TOP_H - 2)
            frame.actionBar:SetHeight(ROW_ACTION_H - 4)
            frame.actionBar:Hide()

            frame.btnRename = HDG_UIHelpers.CreateChipButton(frame.actionBar, L["STYLE_CURATOR_RENAME"]    or "Rename",    56, false, nil)
            frame.btnRename:SetPoint("LEFT", 0, 0)
            frame.btnDup    = HDG_UIHelpers.CreateChipButton(frame.actionBar, L["STYLE_CURATOR_DUPLICATE"] or "Duplicate", 64, false, nil)
            frame.btnDup:SetPoint("CENTER", 0, 0)
            frame.btnDel    = HDG_UIHelpers.CreateChipButton(frame.actionBar, L["STYLE_CURATOR_DELETE"]    or "Delete",    50, false, nil)
            frame.btnDel:SetPoint("RIGHT", 0, 0)
        end
        ApplyFont(frame.label, s, "small")
        ApplyFont(frame.count, s, "xsmall")
        -- Source-row chips don't get a per-render font override, so target
        -- rows match by skipping the override here too. CreateChipButton's
        -- default font size keeps both action bars consistent.
        -- Full-row fill: button_active when this is the active target, button_normal otherwise.
        if elementData.isTarget then
            frame.bg:SetColorTexture(rgba(s.button_active))
        else
            frame.bg:SetColorTexture(rgba(s.button_normal))
        end
        frame.accent:SetColorTexture(rgba(s.accent))
        frame:SetWidth(tgtScrollBox:GetWidth())
        frame.label:SetText(elementData.displayName)
        frame.count:SetText(tostring(elementData.count))
        if elementData.isTarget then
            frame.label:SetTextColor(rgba(s.accent))
            frame.accent:Show()
            frame.actionBar:Show()
        else
            frame.label:SetTextColor(rgba(s.text))
            frame.accent:Hide()
            frame.actionBar:Hide()
        end
        if elementData.isChanged then
            frame.count:SetTextColor(rgba(s.success))
        else
            frame.count:SetTextColor(rgba(s.text_dim))
        end
        local sid = elementData.styleId
        frame._sid = sid  -- exposed for filing-animation target lookup
        frame:SetScript("OnClick", function() Audit:_SetTarget(sid) end)
        frame.btnRename:SetScript("OnClick", function() Audit:_PromptRenameStyle(sid) end)
        frame.btnDup:SetScript("OnClick",    function() Audit:_DuplicateStyle(sid) end)
        frame.btnDel:SetScript("OnClick",    function() Audit:_PromptDeleteStyle(sid) end)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(tgtScrollBox, tgtScrollBar, tgtView)
    auditRoot.tgtView = tgtView

    -- "+ New style" ghost button: transparent fill, border outline.
    -- "+" colored success, rest of label dim. Hover fills with button_hover.
    local newBtn = CreateFrame("Button", nil, col1, "BackdropTemplate")
    newBtn:SetPoint("BOTTOMLEFT", col1, "BOTTOMLEFT", 8, 8)
    newBtn:SetPoint("BOTTOMRIGHT", col1, "BOTTOMRIGHT", -8, 8)
    newBtn:SetHeight(22)
    HDG_UIHelpers.ApplyBackdrop(newBtn, "panel",
        { rgb(scheme.bg, 0.4) }, { rgba(scheme.border) })  -- ghost: near-transparent fill
    RegisterWidget(newBtn, "Panel")
    local newLbl = newBtn:CreateFontString(nil, "OVERLAY")
    ApplyFont(newLbl, scheme, "small")
    newLbl:SetPoint("CENTER")
    -- Compose label: "+" in success color, rest in text_dim via color escape
    local successHex = string.format("|cff%02x%02x%02x",
        math.floor(scheme.success.r*255), math.floor(scheme.success.g*255), math.floor(scheme.success.b*255))
    local dimHex = string.format("|cff%02x%02x%02x",
        math.floor(scheme.text_dim.r*255), math.floor(scheme.text_dim.g*255), math.floor(scheme.text_dim.b*255))
    local newStyleText = L["STYLE_CURATOR_NEW_STYLE"] or "New style..."
    newLbl:SetText(successHex .. "+|r " .. dimHex .. newStyleText .. "|r")
    auditRoot.newStyleBtn = newBtn
    auditRoot.newStyleLbl = newLbl
    newBtn:SetScript("OnClick", function() Audit:_PromptNewStyle() end)
    newBtn:SetScript("OnEnter", function()
        local s = GetScheme()
        HDG_UIHelpers.ApplyBackdrop(newBtn, "panel",
            { rgba(s.button_hover) }, { rgba(s.border) })
        local sh = string.format("|cff%02x%02x%02x", math.floor(s.success.r*255), math.floor(s.success.g*255), math.floor(s.success.b*255))
        newLbl:SetText(sh .. "+|r " .. (L["STYLE_CURATOR_NEW_STYLE"] or "New style..."))
        newLbl:SetTextColor(rgba(s.text))
    end)
    newBtn:SetScript("OnLeave", function()
        local s = GetScheme()
        HDG_UIHelpers.ApplyBackdrop(newBtn, "panel",
            { rgb(s.bg, 0.4) }, { rgba(s.border) })
        local sh = string.format("|cff%02x%02x%02x", math.floor(s.success.r*255), math.floor(s.success.g*255), math.floor(s.success.b*255))
        local dh = string.format("|cff%02x%02x%02x", math.floor(s.text_dim.r*255), math.floor(s.text_dim.g*255), math.floor(s.text_dim.b*255))
        newLbl:SetText(sh .. "+|r " .. dh .. (L["STYLE_CURATOR_NEW_STYLE"] or "New style...") .. "|r")
        newLbl:SetTextColor(1, 1, 1, 1)  -- color is inline via escape codes
    end)

    -- ============================================================================
    -- Column 2: Filter chips + grid header + grid + bulk action bar
    -- ============================================================================
    local col2 = CreateFrame("Frame", nil, auditRoot, "BackdropTemplate")
    col2:SetPoint("TOPLEFT", col1, "TOPRIGHT", 6, 0)
    -- Full width down to the coverage bar; col3 is overlaid on the right
    -- starting at gridScrollBox's top, so the chip band + click-mode pill +
    -- selection bar get the entire horizontal span for filter chips.
    col2:SetPoint("BOTTOMRIGHT", auditRoot, "BOTTOMRIGHT", 0, 42)  -- 42 = coverage strip + 2px gap
    HDG_UIHelpers.ApplyBackdrop(col2, "panel", { rgba(scheme.panel) }, { rgba(scheme.border) })
    RegisterWidget(col2, "Panel")
    auditRoot.col2 = col2

    -- Parent chip row
    local parentChipRow = CreateFrame("Frame", nil, col2)
    parentChipRow:SetPoint("TOPLEFT", 4, -4)
    parentChipRow:SetPoint("TOPRIGHT", -4, -4)
    parentChipRow:SetHeight(22)  -- initial height; layoutChipsWrapping in _RenderChips resizes after laying out
    auditRoot.parentChipRow = parentChipRow

    -- Sub chip row
    -- 4px vertical gap from parent row
    local subChipRow = CreateFrame("Frame", nil, col2)
    subChipRow:SetPoint("TOPLEFT", parentChipRow, "BOTTOMLEFT", 0, -4)
    subChipRow:SetPoint("TOPRIGHT", parentChipRow, "BOTTOMRIGHT", 0, -4)
    subChipRow:SetHeight(20)  -- initial height; layoutChipsWrapping in _RenderChips resizes after laying out
    subChipRow:Hide()  -- only shown when a parent is selected
    auditRoot.subChipRow = subChipRow

    -- Click-mode pill: dedicated 24px header bar above the grid.
    -- Background: scheme.panel_header. Left: click-mode pill. Right: item count.
    local modeBar = CreateFrame("Frame", nil, col2, "BackdropTemplate")
    modeBar:SetPoint("TOPLEFT", subChipRow, "BOTTOMLEFT", 0, -4)
    modeBar:SetPoint("TOPRIGHT", subChipRow, "BOTTOMRIGHT", 0, -4)
    modeBar:SetHeight(24)  -- promoted from 20
    HDG_UIHelpers.ApplyBackdrop(modeBar, "panel",
        { rgba(scheme.panel_header) }, { rgba(scheme.border) })
    RegisterWidget(modeBar, "Panel")
    auditRoot.modeBar = modeBar

    -- Pre-create all 3 buttons; visibility + anchor order is recomputed
    -- per-source by _RefreshClickModeButtons (only Copy is valid for
    -- virtual sources; Move/Remove appear when source is a real curated style).
    -- The first visible button anchors directly to modeBar:LEFT — no label
    -- prefix; the chip text alone reads as the verb.
    auditRoot.clickModeBtns = {}
    local order = { "copy", "move", "remove" }
    for _, key in ipairs(order) do
        local btn = HDG_UIHelpers.CreateChipButton(modeBar, CLICK_MODE_LABEL[key], 50, key == "copy", nil)
        btn:SetScript("OnClick", function() Audit:_SetClickMode(key) end)
        btn.modeKey = key
        auditRoot.clickModeBtns[key] = btn
    end

    -- Inline hint: tells the user how to multi-select. Anchored to the right
    -- of the last visible click-mode button by _RefreshClickModeButtons (which
    -- knows which button ended up rightmost).
    local modeHint = modeBar:CreateFontString(nil, "OVERLAY")
    ApplyFont(modeHint, scheme, "xsmall")
    modeHint:SetTextColor(rgba(scheme.text_dim))
    modeHint:SetText(L["STYLE_CURATOR_MULTISELECT_HINT"] or "ctrl-click to multi-select")
    auditRoot.modeHint = modeHint

    -- Selection count text lives on the RIGHT of the header bar.
    local selCount = modeBar:CreateFontString(nil, "OVERLAY")
    ApplyFont(selCount, scheme, "xsmall")
    selCount:SetPoint("RIGHT", -4, 0)
    selCount:SetText(string.format(L["STYLE_CURATOR_SHOWN_FMT"] or "%d shown", 0))
    selCount:SetTextColor(rgba(scheme.text_dim))
    auditRoot.selCountText = selCount

    -- Bulk-action selection bar (shown only when ≥1 item selected). Whole bar
    -- remains a hot zone (legacy click target), but the right-edge apply chip
    -- is the discoverable affordance — its label tracks the active verb +
    -- count ("Move 10", "Copy 10", "Remove 10").
    local selBar = CreateFrame("Button", nil, col2, "BackdropTemplate")
    selBar:SetPoint("TOPLEFT", modeBar, "BOTTOMLEFT", 0, -2)
    selBar:SetPoint("TOPRIGHT", modeBar, "BOTTOMRIGHT", 0, -2)
    selBar:SetHeight(22)
    HDG_UIHelpers.ApplyBackdrop(selBar, "panel", { rgb(scheme.accent, 0.18) }, { rgba(scheme.accent) })
    RegisterWidget(selBar, "Panel")
    selBar:Hide()
    selBar.applyBtn = HDG_UIHelpers.CreateChipButton(selBar, "Move", 80, true,
        function() Audit:_ApplyBulkSelection() end)
    selBar.applyBtn:SetPoint("RIGHT", -4, 0)
    -- Bar itself is a hot zone; same handler as the apply chip.
    selBar:RegisterForClicks("LeftButtonUp")
    selBar:SetScript("OnClick", function() Audit:_ApplyBulkSelection() end)

    -- Clear-selection X immediately left of the apply chip. Discoverability
    -- prompt beyond the ctrl-click-tile toggle (which only unselects one).
    selBar.clearBtn = HDG_UIHelpers.CreateAtlasIconButton(
        selBar, "common-icon-redx", 16,
        L["STYLE_CURATOR_CLEAR_SELECTION"] or "Clear selection",
        function()
            wipe(selectedItems); selectedCount = 0
            Audit:_RenderSelectionBar()
            Audit:_RenderGrid()
        end)
    selBar.clearBtn:SetPoint("RIGHT", selBar.applyBtn, "LEFT", -4, 0)

    selBar.label = selBar:CreateFontString(nil, "OVERLAY")
    ApplyFont(selBar.label, scheme, "xsmall")
    selBar.label:SetPoint("LEFT", 8, 0)
    selBar.label:SetPoint("RIGHT", selBar.clearBtn, "LEFT", -8, 0)
    selBar.label:SetJustifyH("LEFT")
    auditRoot.selBar = selBar

    -- Grid: WowScrollBoxList + MinimalScrollBar
    -- Each ScrollBox element = one full-width row of N tiles.
    local gridScrollBox = CreateFrame("Frame", nil, col2, "WowScrollBoxList")
    gridScrollBox:SetPoint("TOPLEFT", selBar, "BOTTOMLEFT", 0, -6)
    -- Stop short of col3 (200px) + scrollbar room (20px) + 6px gap = 226px from col2's right edge
    gridScrollBox:SetPoint("BOTTOMRIGHT", -226, 4)
    auditRoot.gridScrollBox = gridScrollBox

    local gridScrollBar = CreateFrame("EventFrame", nil, col2, "MinimalScrollBar")
    gridScrollBar:SetPoint("TOPLEFT", gridScrollBox, "TOPRIGHT", 2, 0)
    gridScrollBar:SetPoint("BOTTOMLEFT", gridScrollBox, "BOTTOMRIGHT", 2, 0)

    -- 2px top + left padding gives breathing room for tile borders that
    -- extend 1px outside the tile (selBorder + the faint always-on border
    -- both anchor at -1/+1 outset). Without this the topmost row's top edge
    -- and leftmost column's left edge get clipped by the scroll viewport.
    local gridView = CreateScrollBoxListLinearView(2, 0, 2, 0, 0)
    gridView:SetElementExtentCalculator(function(_, _elementData) return TILE_ROW_H end)
    gridView:SetElementInitializer("Frame", function(rowFrame, elementData)
        rowFrame:SetWidth(gridScrollBox:GetWidth())
        -- Initialize per-row tile cache on first use
        rowFrame.tiles = rowFrame.tiles or {}
        local items = elementData.items
        local n = #items
        -- Ensure enough tile frames exist in this row
        for t = #rowFrame.tiles + 1, n do
            local tile = CreateFrame("Button", nil, rowFrame)
            tile:SetSize(TILE_SIZE, TILE_SIZE)

            -- Hover background (hidden until OnEnter)
            tile.hoverBg = tile:CreateTexture(nil, "BACKGROUND", nil, 1)
            tile.hoverBg:SetAllPoints(tile)
            tile.hoverBg:Hide()

            -- Selected fill behind icon
            tile.selFill = tile:CreateTexture(nil, "BACKGROUND", nil, 2)
            tile.selFill:SetAllPoints(tile)
            tile.selFill:Hide()

            tile.icon = tile:CreateTexture(nil, "ARTWORK")
            tile.icon:SetAllPoints(tile)
            tile.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

            -- Faint always-on border around each card (4 edge textures so the
            -- border is exactly 1px on each side, not a 4px ring of overlapping
            -- corners). Uses scheme.border at 0.25 alpha — themed + subtle.
            tile.border = {}
            for _, edge in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
                local t = tile:CreateTexture(nil, "OVERLAY", nil, -2)
                if edge == "TOP" then
                    t:SetPoint("TOPLEFT", -1, 1); t:SetPoint("TOPRIGHT", 1, 1); t:SetHeight(1)
                elseif edge == "BOTTOM" then
                    t:SetPoint("BOTTOMLEFT", -1, -1); t:SetPoint("BOTTOMRIGHT", 1, -1); t:SetHeight(1)
                elseif edge == "LEFT" then
                    t:SetPoint("TOPLEFT", -1, 1); t:SetPoint("BOTTOMLEFT", -1, -1); t:SetWidth(1)
                else
                    t:SetPoint("TOPRIGHT", 1, 1); t:SetPoint("BOTTOMRIGHT", 1, -1); t:SetWidth(1)
                end
                tile.border[#tile.border + 1] = t
            end

            -- Selection border: 4 edge textures forming a proper 1px ring
            -- (was a single fill that covered the icon at OVERLAY layer —
            -- "selBorder" by name only, "selRect" in actual behaviour).
            tile.selBorder = {}
            for _, edge in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
                local t = tile:CreateTexture(nil, "OVERLAY", nil, 1)
                if edge == "TOP" then
                    t:SetPoint("TOPLEFT", -1, 1); t:SetPoint("TOPRIGHT", 1, 1); t:SetHeight(1)
                elseif edge == "BOTTOM" then
                    t:SetPoint("BOTTOMLEFT", -1, -1); t:SetPoint("BOTTOMRIGHT", 1, -1); t:SetHeight(1)
                elseif edge == "LEFT" then
                    t:SetPoint("TOPLEFT", -1, 1); t:SetPoint("BOTTOMLEFT", -1, -1); t:SetWidth(1)
                else
                    t:SetPoint("TOPRIGHT", 1, 1); t:SetPoint("BOTTOMRIGHT", 1, -1); t:SetWidth(1)
                end
                t:Hide()
                tile.selBorder[#tile.selBorder + 1] = t
            end

            -- Membership badge: themed number with a 1px black shadow for
            -- legibility on busy icons. No background disc — text alone.
            tile.badge = tile:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            tile.badge:SetPoint("TOPLEFT", 2, -2)
            tile.badge:SetShadowOffset(1, -1)
            tile.badge:SetShadowColor(0, 0, 0, 1)

            rowFrame.tiles[t] = tile
        end
        -- Apply themed colors to all tiles each render (covers theme switches).
        local _gs = GetScheme()
        for _, tile in ipairs(rowFrame.tiles) do
            if tile.selBorder then
                for _, edgeTex in ipairs(tile.selBorder) do
                    edgeTex:SetColorTexture(rgb(_gs.accent, 0.95))
                end
            end
            tile.badge:SetTextColor(rgba(_gs.accent))
            tile.hoverBg:SetColorTexture(rgba(_gs.button_hover))
            -- Selection fill: low-alpha accent tint behind icon for the
            -- "this is selected" anchor without obscuring the art.
            tile.selFill:SetColorTexture(rgb(_gs.accent, 0.28))
            if tile.border then
                for _, edgeTex in ipairs(tile.border) do
                    edgeTex:SetColorTexture(rgb(_gs.border, 0.25))
                end
            end
        end
        -- Bind each tile to its item; hide extras for partial last row
        for t, tile in ipairs(rowFrame.tiles) do
            if t <= n then
                local item = items[t]
                tile:ClearAllPoints()
                tile:SetPoint("TOPLEFT", (t - 1) * (TILE_SIZE + TILE_GAP), 0)
                tile:Show()
                tile.itemID  = item.itemID
                tile.decorID = item.decorID

                if item.row.iconAtlas then
                    tile.icon:SetAtlas(item.row.iconAtlas)
                elseif item.row.iconTexture then
                    tile.icon:SetTexture(item.row.iconTexture)
                else
                    tile.icon:SetTexture(134400)  -- INV_Misc_QuestionMark
                end

                local memberships = StyleEngine:GetUserStyleMembership(item.decorID)
                local nbadge = 0
                for _, sid in ipairs(memberships) do
                    if sid ~= sourceStyleId then nbadge = nbadge + 1 end
                end
                if nbadge > 0 then
                    tile.badge:SetText(nbadge >= 5 and "5+" or tostring(nbadge))
                else
                    tile.badge:SetText("")
                end

                if selectedItems[item.itemID] then
                    if tile.selBorder then
                        for _, edgeTex in ipairs(tile.selBorder) do edgeTex:Show() end
                    end
                    tile.selFill:Show()
                else
                    if tile.selBorder then
                        for _, edgeTex in ipairs(tile.selBorder) do edgeTex:Hide() end
                    end
                    tile.selFill:Hide()
                end

                tile:RegisterForClicks("LeftButtonUp", "RightButtonUp")
                tile:SetScript("OnClick", function(self_, _mouseBtn)
                    if IsControlKeyDown() then
                        Audit:_ToggleSelection(self_.itemID)
                    else
                        Audit:_HandleTileClick(self_.itemID, self_)
                    end
                end)
                -- Hover effect + memberships panel update.
                local _itemRow = item.row
                tile:SetScript("OnEnter", function(self_)
                    self_.hoverBg:Show()
                    Audit:_ShowMemberships(self_.decorID, _itemRow and _itemRow.name)
                end)
                tile:SetScript("OnLeave", function(self_) self_.hoverBg:Hide() end)
            else
                tile:Hide()
                tile:SetScript("OnClick", nil)
                tile:SetScript("OnEnter", nil)
                tile:SetScript("OnLeave", nil)
            end
        end
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(gridScrollBox, gridScrollBar, gridView)
    auditRoot.gridView = gridView

    -- ============================================================================
    -- Column 3: Undo strip (last 10 ops, individually reversible)
    -- ============================================================================
    -- col3 starts at the grid's top (so the chip band can use full width).
    -- Anchored to gridScrollBox.TOPRIGHT (cross-frame is fine in WoW; siblings
    -- of col2 resolve at draw time).
    local col3 = CreateFrame("Frame", nil, auditRoot, "BackdropTemplate")
    col3:SetPoint("TOPLEFT", gridScrollBox, "TOPRIGHT", 6, 0)
    col3:SetPoint("BOTTOMRIGHT", auditRoot, "BOTTOMRIGHT", 0, 42)  -- 42 = coverage strip + 2px gap
    HDG_UIHelpers.ApplyBackdrop(col3, "panel", { rgba(scheme.panel) }, { rgba(scheme.border) })
    RegisterWidget(col3, "Panel")
    auditRoot.col3 = col3

    local undoLabel = col3:CreateFontString(nil, "OVERLAY")
    ApplyFont(undoLabel, scheme, "xsmall")
    undoLabel:SetPoint("TOPLEFT", 8, -8)
    undoLabel:SetText(L["STYLE_CURATOR_RECENT"] or "RECENT (UNDO)")
    undoLabel:SetTextColor(rgba(scheme.text_dim))

    -- Empty-state hint: two centered lines shown when undoStack is empty.
    local undoEmptyHint = col3:CreateFontString(nil, "OVERLAY")
    ApplyFont(undoEmptyHint, scheme, "xsmall")
    undoEmptyHint:SetPoint("TOPLEFT", undoLabel, "BOTTOMLEFT", 0, -12)
    undoEmptyHint:SetPoint("TOPRIGHT", col3, "TOPRIGHT", -8, 0)
    undoEmptyHint:SetJustifyH("CENTER")
    undoEmptyHint:SetText(L["STYLE_CURATOR_UNDO_EMPTY"] or "No recent actions.\nOps appear here as you file decor.")
    undoEmptyHint:SetTextColor(rgba(scheme.text_dim))
    auditRoot.undoEmptyHint = undoEmptyHint

    -- Undo strip: WowScrollBoxList + MinimalScrollBar — TOP HALF of col3.
    -- Bottom half hosts the memberships hover panel (added below).
    -- The bottom is pinned to col3's vertical center via two SetPoint calls
    -- (BOTTOM→CENTER for Y, RIGHT→RIGHT for X) — clearer than the prior
    -- "BOTTOMRIGHT, col3, RIGHT" which silently used col3's mid-right anchor.
    local undoScrollBox = CreateFrame("Frame", nil, col3, "WowScrollBoxList")
    undoScrollBox:SetPoint("TOPLEFT", undoLabel, "BOTTOMLEFT", 0, -4)
    undoScrollBox:SetPoint("BOTTOM",  col3, "CENTER", 0, 4)
    undoScrollBox:SetPoint("RIGHT",   col3, "RIGHT", -20, 0)
    auditRoot.undoScrollBox = undoScrollBox

    local undoScrollBar = CreateFrame("EventFrame", nil, col3, "MinimalScrollBar")
    undoScrollBar:SetPoint("TOPLEFT", undoScrollBox, "TOPRIGHT", 2, 0)
    undoScrollBar:SetPoint("BOTTOMLEFT", undoScrollBox, "BOTTOMRIGHT", 2, 0)

    local undoView = CreateScrollBoxListLinearView(0, 0, 0, 0, 2)
    undoView:SetElementExtentCalculator(function(_, _elementData) return 28 end)
    undoView:SetElementInitializer("Frame", function(frame, elementData)
        local s = GetScheme()
        if not frame.text then
            frame.bg = frame:CreateTexture(nil, "BACKGROUND")
            frame.bg:SetAllPoints()
            frame.text = frame:CreateFontString(nil, "OVERLAY")
            frame.text:SetPoint("LEFT", 6, 0)
            frame.text:SetPoint("RIGHT", -32, 0)
            frame.text:SetJustifyH("LEFT")
            frame.undoBtn = CreateFrame("Button", nil, frame)
            frame.undoBtn:SetSize(28, 16)
            frame.undoBtn:SetPoint("RIGHT", -4, 0)
            frame.undoBtnText = frame.undoBtn:CreateFontString(nil, "OVERLAY")
            frame.undoBtnText:SetPoint("CENTER")
        end
        -- ApplyFont MUST run before any SetText on the FontString — without
        -- a font set, SetText errors with 'Font not set'.
        ApplyFont(frame.text, s, "xsmall")
        ApplyFont(frame.undoBtnText, s, "xsmall")
        frame.bg:SetColorTexture(rgb(s.button_normal, 0.4))
        frame.text:SetTextColor(rgba(s.text))
        frame.undoBtnText:SetTextColor(rgba(s.warning))
        frame.undoBtnText:SetText(L["STYLE_CURATOR_UNDO"] or "undo")
        frame.text:SetText(elementData.displayText)
        frame:SetWidth(undoScrollBox:GetWidth())
        local idx = elementData.index
        frame.undoBtn:SetScript("OnClick", function() Audit:_UndoEntry(idx) end)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(undoScrollBox, undoScrollBar, undoView)
    auditRoot.undoView = undoView

    -- ============================================================================
    -- Memberships hover panel — BOTTOM HALF of col3.
    -- Shows the styles the currently-hovered grid tile belongs to. Updated
    -- by tile OnEnter handler in _RenderGrid; persists between hovers so
    -- the user can move their cursor away to read it.
    -- ============================================================================
    local memHeader = col3:CreateFontString(nil, "OVERLAY")
    ApplyFont(memHeader, scheme, "xsmall")
    memHeader:SetPoint("TOPLEFT", col3, "LEFT", 8, -4)  -- just below col3 vertical center
    memHeader:SetText(L["STYLE_CURATOR_MEMBERSHIPS"] or "MEMBERSHIPS")
    memHeader:SetTextColor(rgba(scheme.text_header))

    local memHeaderRule = col3:CreateTexture(nil, "ARTWORK")
    memHeaderRule:SetPoint("TOPLEFT", memHeader, "BOTTOMLEFT", 0, -2)
    memHeaderRule:SetPoint("TOPRIGHT", col3, "RIGHT", -8, 0)
    memHeaderRule:SetHeight(1)
    memHeaderRule:SetColorTexture(rgba(scheme.border))

    -- Hover-target name (the item being hovered)
    local memItemName = col3:CreateFontString(nil, "OVERLAY")
    ApplyFont(memItemName, scheme, "small")
    memItemName:SetPoint("TOPLEFT", memHeaderRule, "BOTTOMLEFT", 0, -6)
    memItemName:SetPoint("TOPRIGHT", memHeaderRule, "BOTTOMRIGHT", 0, -6)
    memItemName:SetJustifyH("LEFT")
    memItemName:SetWordWrap(true)
    memItemName:SetTextColor(rgba(scheme.warning))
    memItemName:SetText("")
    auditRoot.memItemName = memItemName

    -- Membership list (one FontString grows as needed). For most items this
    -- is 0-3 styles; capping at ~10 lines fits the half-column.
    local memList = col3:CreateFontString(nil, "OVERLAY")
    ApplyFont(memList, scheme, "small")
    memList:SetPoint("TOPLEFT", memItemName, "BOTTOMLEFT", 0, -4)
    memList:SetPoint("BOTTOMRIGHT", col3, "BOTTOMRIGHT", -8, 8)
    memList:SetJustifyH("LEFT")
    memList:SetJustifyV("TOP")
    memList:SetWordWrap(true)
    memList:SetTextColor(rgba(scheme.text_dim))
    memList:SetText(L["STYLE_CURATOR_MEMBERSHIPS_HINT"] or "Hover an item to see\nits style memberships.")
    auditRoot.memList = memList

    -- ============================================================================
    -- Coverage progress bar (bottom)
    -- ============================================================================
    -- Coverage bar: 40px tall, 12px thick fill bar, labels above.
    local coverage = CreateFrame("Frame", nil, auditRoot, "BackdropTemplate")
    coverage:SetPoint("BOTTOMLEFT", 0, 0)
    coverage:SetPoint("BOTTOMRIGHT", 0, 0)
    coverage:SetHeight(40)  -- was 28
    HDG_UIHelpers.ApplyBackdrop(coverage, "panel", { rgba(scheme.panel) }, { rgba(scheme.border) })
    RegisterWidget(coverage, "Panel")
    auditRoot.coverage = coverage

    -- Bar track and fill (12px tall, at bottom with 4px inset)
    coverage.barBg = coverage:CreateTexture(nil, "ARTWORK")
    coverage.barBg:SetPoint("BOTTOMLEFT", 8, 4)
    coverage.barBg:SetPoint("BOTTOMRIGHT", -8, 4)
    coverage.barBg:SetHeight(12)  -- was 5
    coverage.barBg:SetColorTexture(rgba(scheme.border))

    coverage.barFill = coverage:CreateTexture(nil, "OVERLAY")
    coverage.barFill:SetPoint("BOTTOMLEFT", coverage.barBg, "BOTTOMLEFT", 0, 0)
    coverage.barFill:SetHeight(12)  -- was 5
    coverage.barFill:SetColorTexture(rgba(scheme.accent))

    -- Labels sit ABOVE the bar (anchored to barBg top)
    coverage.text = coverage:CreateFontString(nil, "OVERLAY")
    ApplyFont(coverage.text, scheme, "small")
    coverage.text:SetPoint("BOTTOMLEFT", coverage.barBg, "TOPLEFT", 0, 3)
    coverage.text:SetTextColor(rgba(scheme.text))

    coverage.right = coverage:CreateFontString(nil, "OVERLAY")
    ApplyFont(coverage.right, scheme, "small")
    coverage.right:SetPoint("BOTTOMRIGHT", coverage.barBg, "TOPRIGHT", 0, 3)
    coverage.right:SetTextColor(rgba(scheme.text_dim))  -- overridden in _RenderCoverage

    if HDG.EventBus then
        -- Membership change: refresh row counts + grid; do NOT rebuild the
        -- source dropdown (the style set hasn't changed).
        HDG.EventBus:Register("HDG_STYLE_MEMBERSHIP_CHANGED", function()
            if _bulkApplyInProgress then return end  -- batched; one render after the loop
            Audit:_Render()
        end)
        -- Generic state change can include a new/deleted style — rebuild
        -- the dropdown defensively.
        HDG.EventBus:Register("HDG_STATE_CHANGED", function()
            Audit:_PopulateSourceDropdown()
            Audit:_Render()
        end)
        -- Collection ready: total may have grown; invalidate cached total.
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            cachedCoverageTotalDirty = true
            Audit:_Render()
        end)
        -- Theme switch: re-paint the surface bg (we don't use the Panel
        -- skinner because it stamps the wood atlas) and the dropdown chrome
        -- (also outside the skinner registry).
        HDG.EventBus:Register("HDG_THEME_UPDATE", function()
            if auditRoot._repaintBg then auditRoot._repaintBg() end
            for _, dd in ipairs(auditRoot._dropdowns or {}) do
                if dd.RepaintTheme then dd:RepaintTheme() end
            end
        end)
    end

    -- 1px vertical divider lines at col1/col2 and col2/col3 boundaries.
    -- Each runs from the topBar bottom to the coverage bar top (full content height).
    local divider12 = auditRoot:CreateTexture(nil, "ARTWORK")
    divider12:SetWidth(1)
    divider12:SetPoint("TOPLEFT",    col2, "TOPLEFT",    0, 0)
    divider12:SetPoint("BOTTOMLEFT", coverage, "TOPLEFT", 0, 0)
    divider12:SetColorTexture(rgba(scheme.border))

    local divider23 = auditRoot:CreateTexture(nil, "ARTWORK")
    divider23:SetWidth(1)
    divider23:SetPoint("TOPLEFT",    col3, "TOPLEFT",    0, 0)
    divider23:SetPoint("BOTTOMLEFT", coverage, "TOPLEFT", 0, 0)
    divider23:SetColorTexture(rgba(scheme.border))

    -- Initial layout for the click-mode pill — buttons created above have
    -- no anchor yet; this assigns them based on current source validity
    -- (Copy only for virtual sources; Copy+Move+Remove for curated sources).
    Audit:_RefreshClickModeButtons()
end

function Audit:_PopulateSourceDropdown()
    local dropdown = auditRoot and auditRoot.sourceDropdown
    if not dropdown or not dropdown.SetItems then return end

    -- Build flat item list — Unassigned, All Collected Decor, Wishlist, then curated styles.
    local items = {
        { key = "__unassigned__",   label = L["STYLE_CURATOR_UNASSIGNED"]    or "Unassigned",            sid = nil },
        { key = SOURCE_ALL,         label = L["STYLE_CURATOR_ALL_COLLECTED"] or "All Collected Decor",   sid = SOURCE_ALL },
        { key = SOURCE_UNCOLLECTED, label = L["STYLE_CURATOR_UNCOLLECTED"]   or "Wishlist - Uncollected", sid = SOURCE_UNCOLLECTED },
    }
    local customs, order = _GetStyles()
    local names = buildDisplayNames(customs, order, isCuratedStyle)
    local emitted = {}
    local function addEntry(sid, def)
        if emitted[sid] or not isCuratedStyle(sid, def) then return end
        emitted[sid] = true
        items[#items + 1] = { key = sid, label = names[sid] or def.displayName or sid, sid = sid }
    end
    for _, sid in ipairs(order) do
        local def = customs[sid]
        if def then addEntry(sid, def) end
    end

    -- Wire onSelect (overwrites previous closure each populate so the latest
    -- customStyles map is captured — important after a style is added/deleted).
    dropdown.onSelect = function(key, data)
        sourceStyleId = data.sid  -- nil for Unassigned, sentinel for All, else styleId
        Audit:_OnSourceChanged()
    end
    dropdown:SetItems(items)

    -- Set the visible label to match current sourceStyleId.
    local labelText
    if sourceStyleId == SOURCE_ALL then
        labelText = L["STYLE_CURATOR_ALL_COLLECTED"] or "All Collected Decor"
    elseif sourceStyleId == SOURCE_UNCOLLECTED then
        labelText = L["STYLE_CURATOR_UNCOLLECTED"] or "Wishlist - Uncollected"
    elseif sourceStyleId then
        labelText = displayNameFor(sourceStyleId)
    else
        labelText = L["STYLE_CURATOR_UNASSIGNED"] or "Unassigned"
    end
    dropdown:SetSelected(sourceStyleId or "__unassigned__", { label = labelText })
end

function Audit:_OnSourceChanged()
    -- Selected items may not exist in the new view; clear selection.
    wipe(selectedItems); selectedCount = 0
    self:_RefreshClickModeButtons()  -- valid verbs depend on source kind
    self:_Render()  -- already refreshes the target list internally
end

function Audit:_RefreshStyleList()
    if not auditRoot then return end
    local scrollBox = auditRoot.targetScrollBox
    if not scrollBox then return end

    -- Reset source/target if they reference deleted styles (defensive).
    -- Virtual sentinel sources are never "deleted".
    local customs, order = _GetStyles()
    if targetStyleId and not customs[targetStyleId] then targetStyleId = nil end
    if sourceStyleId and not isVirtualSourceId(sourceStyleId) and not customs[sourceStyleId] then
        sourceStyleId = nil
        wipe(selectedItems); selectedCount = 0
    end

    -- Target list is curated-only. Smart styles aren't fileable (they're
    -- tag rules, not lists) and they live under their own surface in the
    -- Styles landing — no need to pollute the Curator with greyed rows.
    -- buildDisplayNames runs over the same curated subset so suffix numbers
    -- match the visible rows.
    local names = buildDisplayNames(customs, order, isCuratedStyle)
    local rows, emitted = {}, {}
    local function addRow(sid, def)
        if emitted[sid] or not isCuratedStyle(sid, def) then return end
        if sid == sourceStyleId then return end  -- can't file into the style we're viewing
        emitted[sid] = true
        local count = 0
        if def.curatedItems then
            for _ in pairs(def.curatedItems) do count = count + 1 end
        end
        local baseline = sessionBaselineCounts[sid]  -- nil if style was created mid-session
        rows[#rows + 1] = {
            styleId     = sid,
            displayName = names[sid] or def.displayName or sid,
            count       = count,
            isTarget    = (sid == targetStyleId),
            isChanged   = (baseline ~= nil and baseline ~= count) or (baseline == nil and count > 0),
        }
    end
    for _, sid in ipairs(order) do addRow(sid, customs[sid]) end

    -- Preserve scroll position across the SetDataProvider swap (count badges
    -- update on every file action; the user shouldn't get yanked back to the
    -- top of their style list each time).
    local savedPct = scrollBox.GetScrollPercentage and scrollBox:GetScrollPercentage() or 0
    scrollBox:SetDataProvider(CreateDataProvider(rows))
    if savedPct > 0 and scrollBox.SetScrollPercentage then
        scrollBox:SetScrollPercentage(savedPct, ScrollBoxConstants and ScrollBoxConstants.NoScrollInterpolation)
    end
end

function Audit:_SetTarget(sid)
    targetStyleId = sid
    self:_RefreshStyleList()
    self:_RenderSelectionBar()  -- bar's "Move to X" label tracks target
end

function Audit:_Render()
    if not auditRoot or not auditRoot:IsShown() then return end
    self:_RefreshSourceRow()
    self:_RefreshStyleList()  -- target row counts must repaint on every mutation event
    self:_RenderChips()
    self:_RenderGrid()
    self:_RenderUndo()
    self:_RenderCoverage()
    self:_RenderSelectionBar()
end

-- Update the bottom-half memberships panel in col3 with the styles a
-- given decor is in. Persists between hovers (no clear on tile OnLeave)
-- so the user can move their cursor away to read the list.
function Audit:_ShowMemberships(decorID, itemName)
    if not auditRoot or not auditRoot.memList then return end
    local s = GetScheme()
    auditRoot.memItemName:SetText(itemName or ("Item " .. tostring(decorID or "?")))
    auditRoot.memItemName:SetTextColor(rgba(s.warning))

    local memberships = StyleEngine:GetUserStyleMembership(decorID)
    if #memberships == 0 then
        auditRoot.memList:SetText("|cff888888" .. (L["STYLE_CURATOR_MEMBERSHIPS_NONE"] or "Not in any of your styles.") .. "|r")
        return
    end
    -- Sort alphabetically by display name for stable presentation.
    local names = {}
    for _, sid in ipairs(memberships) do
        names[#names + 1] = displayNameFor(sid) or sid
    end
    table.sort(names, function(a, b) return a:lower() < b:lower() end)
    -- Bullet each line in the active accent color.
    local accentHex = string.format("|cff%02x%02x%02x",
        math.floor(s.accent.r * 255),
        math.floor(s.accent.g * 255),
        math.floor(s.accent.b * 255))
    local lines = {}
    for _, name in ipairs(names) do
        lines[#lines + 1] = accentHex .. "* |r" .. name
    end
    auditRoot.memList:SetText(table.concat(lines, "\n"))
    auditRoot.memList:SetTextColor(rgba(s.text))
end

function Audit:_RefreshSourceRow()
    if not auditRoot then return end
    local row = auditRoot.srcRow
    local tgtLabel = auditRoot.tgtLabel
    if not row or not tgtLabel then return end

    -- Default: collapsed (top zone only). Real curated styles override below.
    row:SetHeight(row.SRC_TOP_H)
    row.actionBar:Hide()

    if not sourceStyleId then
        row:Hide()
        tgtLabel:ClearAllPoints()
        tgtLabel:SetPoint("TOPLEFT", auditRoot.sourceDropdown, "BOTTOMLEFT", 0, -10)
        return
    end

    -- Virtual sources (All Collected, Wishlist Uncollected): count = size of
    -- their respective universe slice. No baseline tracking (the universe
    -- doesn't change as user files items).
    local s = GetScheme()
    if sourceStyleId == SOURCE_ALL or sourceStyleId == SOURCE_UNCOLLECTED then
        local catalog = _GetCatalog()
        local wantCollected = (sourceStyleId == SOURCE_ALL)
        local total = 0
        for decorID, catRow in pairs(catalog) do
            -- Skip non-item entries (rooms etc) so the count matches the grid.
            if catRow and catRow.itemID then
                local isColl = HDG_Data and HDG_Data.IsDecorCollected and HDG_Data.IsDecorCollected(decorID) or false
                if isColl == wantCollected then total = total + 1 end
            end
        end
        if sourceStyleId == SOURCE_ALL then
            row.label:SetText(L["STYLE_CURATOR_ALL_COLLECTED"] or "All Collected Decor")
        else
            row.label:SetText(L["STYLE_CURATOR_UNCOLLECTED"] or "Wishlist - Uncollected")
        end
        row.label:SetTextColor(rgba(s.warning))  -- amber accent — distinguishes source from target teal
        row.count:SetText(tostring(total))
        row.count:SetTextColor(rgba(s.text))
        row:Show()
        tgtLabel:ClearAllPoints()
        tgtLabel:SetPoint("TOPLEFT", row, "BOTTOMLEFT", 0, -10)
        return
    end

    local def = HDG.Store and HDG.Store:Collection_Get("style:" .. sourceStyleId)
    if not def then
        row:Hide()
        tgtLabel:ClearAllPoints()
        tgtLabel:SetPoint("TOPLEFT", auditRoot.sourceDropdown, "BOTTOMLEFT", 0, -10)
        return
    end

    local count = 0
    if def.curatedItems then
        for _ in pairs(def.curatedItems) do count = count + 1 end
    end
    row.label:SetText(displayNameFor(sourceStyleId))
    row.label:SetTextColor(rgba(s.warning))  -- amber accent — distinguishes source from target teal
    row.count:SetText(tostring(count))

    local baseline = sessionBaselineCounts[sourceStyleId]
    local changed  = (baseline ~= nil and baseline ~= count)
                  or (baseline == nil and count > 0)
    if changed then
        row.count:SetTextColor(rgba(s.success))
    else
        row.count:SetTextColor(rgba(s.text))
    end

    -- Real curated style → grow row + reveal action bar.
    row:SetHeight(row.SRC_TOP_H + row.SRC_ACTION_H)
    row.actionBar:Show()

    row:Show()
    tgtLabel:ClearAllPoints()
    tgtLabel:SetPoint("TOPLEFT", row, "BOTTOMLEFT", 0, -10)
end

function Audit:_RenderChips()
    if not auditRoot then return end

    -- Discover the (catID, subID) set in the current source view by walking
    -- the source set and counting per-cat / per-(cat,sub) occurrences.
    local cats, subsByCat = {}, {}
    local wantUncollected = (sourceStyleId == SOURCE_UNCOLLECTED)
    local function visit(decorID, row)
        if not row or not row.categoryID then return end
        -- Drop catalog entries without a real itemID (room-frame placements
        -- and similar non-item universe entries). Style filing is itemID-keyed
        -- so these can't be filed regardless of source.
        if not row.itemID then return end
        -- Audit-mode is scoped to the player's collection EXCEPT for the
        -- Wishlist - Uncollected source, which inverts the universe to
        -- everything the player doesn't yet own.
        if not wantUncollected and HDG_Data and HDG_Data.IsDecorCollected and not HDG_Data.IsDecorCollected(decorID) then return end
        cats[row.categoryID] = (cats[row.categoryID] or 0) + 1
        if row.subcategoryID then
            subsByCat[row.categoryID] = subsByCat[row.categoryID] or {}
            subsByCat[row.categoryID][row.subcategoryID] = (subsByCat[row.categoryID][row.subcategoryID] or 0) + 1
        end
    end

    if sourceStyleId == nil then
        for decorID, row in StyleEngine:IterateUnassigned() do visit(decorID, row) end
    elseif sourceStyleId == SOURCE_ALL then
        local catalog = _GetCatalog()
        for decorID, row in pairs(catalog) do visit(decorID, row) end
    elseif sourceStyleId == SOURCE_UNCOLLECTED then
        local catalog = _GetCatalog()
        for decorID, row in pairs(catalog) do
            if not (HDG_Data and HDG_Data.IsDecorCollected and HDG_Data.IsDecorCollected(decorID)) then
                visit(decorID, row)
            end
        end
    else
        local items = StyleEngine:GetStyleItems(sourceStyleId)
        if items and items.curated then
            for _, it in ipairs(items.curated) do
                local row = HDG_Data and HDG_Data.GetCatalog and HDG_Data.GetCatalog(it.decorID)
                visit(it.decorID, row)
            end
        end
    end

    -- Lay out a list of chips inside `row`, wrapping to multiple physical rows
    -- when they overflow the row's width. Returns the number of physical rows
    -- used so the caller can size the row frame.
    local CHIP_GAP_X, CHIP_GAP_Y = 4, 2
    local CHIP_H = 18  -- chip button height (matches HDG_UIHelpers.CreateChipButton default)
    local function layoutChipsWrapping(row, chips, paddingLeft)
        local rowW = row:GetWidth()
        if not rowW or rowW <= 0 then rowW = 600 end  -- pre-layout fallback
        local x, y, rowsUsed = paddingLeft or 4, 0, 1
        for _, chip in ipairs(chips) do
            local w = chip:GetWidth()
            if x > (paddingLeft or 4) and (x + w) > (rowW - 4) then
                x = paddingLeft or 4
                y = y - (CHIP_H + CHIP_GAP_Y)
                rowsUsed = rowsUsed + 1
            end
            chip:ClearAllPoints()
            chip:SetPoint("TOPLEFT", x, y)
            x = x + w + CHIP_GAP_X
        end
        return rowsUsed
    end

    -- Recycle previous parent chips
    if auditRoot.parentChipRow.activeChips then
        for _, c in ipairs(auditRoot.parentChipRow.activeChips) do releaseChip(c, "parent") end
    end
    auditRoot.parentChipRow.activeChips = {}

    -- "All" chip — always present
    local allChip = acquireChip(auditRoot.parentChipRow, "parent")
    if allChip.text then allChip.text:SetText(L["STYLE_CURATOR_ALL"] or "All") end
    allChip:SetWidth(40)
    allChip:SetScript("OnClick", function() Audit:_SetParentCat(nil) end)
    HDG_UIHelpers.UpdateChipButtonState(allChip, activeParentCat == nil)
    auditRoot.parentChipRow.activeChips[#auditRoot.parentChipRow.activeChips + 1] = allChip

    -- Sort catIDs for stable display order
    local catList = {}
    for catID in pairs(cats) do catList[#catList + 1] = catID end
    table.sort(catList)

    for _, catID in ipairs(catList) do
        local info = C_HousingCatalog and C_HousingCatalog.GetCatalogCategoryInfo
                     and C_HousingCatalog.GetCatalogCategoryInfo(catID)
        local name = (info and info.name) or ("Cat " .. catID)
        local chip = acquireChip(auditRoot.parentChipRow, "parent")
        if chip.text then chip.text:SetText(name) end
        local w = 60
        if chip.text and chip.text.GetStringWidth then
            w = math.max(60, chip.text:GetStringWidth() + 18)
        end
        chip:SetWidth(w)
        chip:SetScript("OnClick", function() Audit:_SetParentCat(catID) end)
        HDG_UIHelpers.UpdateChipButtonState(chip, catID == activeParentCat)
        chip.catID = catID
        auditRoot.parentChipRow.activeChips[#auditRoot.parentChipRow.activeChips + 1] = chip
    end

    local parentRows = layoutChipsWrapping(auditRoot.parentChipRow, auditRoot.parentChipRow.activeChips, 4)
    auditRoot.parentChipRow:SetHeight(parentRows * (CHIP_H + CHIP_GAP_Y))

    -- Sub-chip row: only when parent selected AND that parent has subs
    if auditRoot.subChipRow.activeChips then
        for _, c in ipairs(auditRoot.subChipRow.activeChips) do releaseChip(c, "sub") end
    end
    auditRoot.subChipRow.activeChips = {}

    if activeParentCat and subsByCat[activeParentCat] then
        auditRoot.subChipRow:Show()

        -- "All [parent]" sub chip
        local allSub = acquireChip(auditRoot.subChipRow, "sub")
        if allSub.text then allSub.text:SetText(L["STYLE_CURATOR_ALL"] or "All") end
        allSub:SetWidth(40)
        allSub:SetScript("OnClick", function() Audit:_SetSubCat(nil) end)
        HDG_UIHelpers.UpdateChipButtonState(allSub, activeSubCat == nil)
        auditRoot.subChipRow.activeChips[#auditRoot.subChipRow.activeChips + 1] = allSub

        local subList = {}
        for subID in pairs(subsByCat[activeParentCat]) do subList[#subList + 1] = subID end
        table.sort(subList)
        for _, subID in ipairs(subList) do
            local info = C_HousingCatalog and C_HousingCatalog.GetCatalogSubcategoryInfo
                         and C_HousingCatalog.GetCatalogSubcategoryInfo(subID)
            local name = (info and info.name) or ("Sub " .. subID)
            local chip = acquireChip(auditRoot.subChipRow, "sub")
            if chip.text then chip.text:SetText(name) end
            local w = 50
            if chip.text and chip.text.GetStringWidth then
                w = math.max(50, chip.text:GetStringWidth() + 16)
            end
            chip:SetWidth(w)
            chip:SetScript("OnClick", function() Audit:_SetSubCat(subID) end)
            HDG_UIHelpers.UpdateChipButtonState(chip, subID == activeSubCat)
            auditRoot.subChipRow.activeChips[#auditRoot.subChipRow.activeChips + 1] = chip
        end

        local subRows = layoutChipsWrapping(auditRoot.subChipRow, auditRoot.subChipRow.activeChips, 4)
        auditRoot.subChipRow:SetHeight(subRows * (CHIP_H + CHIP_GAP_Y))
    else
        auditRoot.subChipRow:Hide()
    end
end

function Audit:_SetParentCat(catID)
    activeParentCat = catID
    activeSubCat = nil  -- reset sub when parent changes
    self:_Render()
end

function Audit:_SetSubCat(subID)
    activeSubCat = subID
    self:_Render()
end

function Audit:_SetClickMode(mode)
    local valid = self:_ValidClickModes()
    if not valid[mode] then return end
    clickMode = mode
    self:_RefreshClickModeButtons()
    self:_RenderSelectionBar()  -- bar's verb label tracks click mode
end

-- Verbs valid for the current source:
--   Unassigned        : Move only (filing items out of unassigned IS the
--                       workflow; engine-wise same as Add, but the user
--                       sees the item leave the unassigned view)
--   All Collected Decor: Copy only (universe; nothing to remove from)
--   Wishlist Uncollected: Copy only (uncollected universe; pre-file items
--                         you don't own yet — same Copy semantics as All)
--   Curated style     : Copy + Move + Remove (full ledger ops)
function Audit:_ValidClickModes()
    if sourceStyleId == nil then
        return { move = true }
    elseif isVirtualSourceId(sourceStyleId) then
        return { copy = true }
    else
        return { copy = true, move = true, remove = true }
    end
end

-- Show/hide and re-anchor pill buttons based on the verbs valid for the
-- current source. If the active mode is no longer valid (e.g. user
-- switched from a curated source where Remove was active to All Decor),
-- fall back to the first valid verb in canonical order.
function Audit:_RefreshClickModeButtons()
    if not auditRoot or not auditRoot.clickModeBtns then return end
    local valid = self:_ValidClickModes()
    if not valid[clickMode] then
        for _, k in ipairs({ "copy", "move", "remove" }) do
            if valid[k] then clickMode = k; break end
        end
    end
    local prev  -- nil for first visible button → anchor to modeBar:LEFT
    for _, key in ipairs({ "copy", "move", "remove" }) do
        local btn = auditRoot.clickModeBtns[key]
        if btn then
            if valid[key] then
                btn:ClearAllPoints()
                if prev then
                    btn:SetPoint("LEFT", prev, "RIGHT", 4, 0)
                else
                    btn:SetPoint("LEFT", auditRoot.modeBar, "LEFT", 4, 0)
                end
                btn:Show()
                HDG_UIHelpers.UpdateChipButtonState(btn, key == clickMode)
                prev = btn
            else
                btn:Hide()
            end
        end
    end
    -- Anchor the multi-select hint to the right of the rightmost visible
    -- button. Falls back to the left of selCount if no buttons are visible
    -- (shouldn't happen in practice — _ValidClickModes always returns ≥1).
    local hint = auditRoot.modeHint
    if hint then
        hint:ClearAllPoints()
        if prev then
            hint:SetPoint("LEFT", prev, "RIGHT", 10, 0)
        else
            hint:SetPoint("LEFT", auditRoot.modeBar, "LEFT", 10, 0)
        end
        if auditRoot.selCountText then
            hint:SetPoint("RIGHT", auditRoot.selCountText, "LEFT", -10, 0)
            hint:SetJustifyH("LEFT")
            hint:SetWordWrap(false)
        end
    end
end

function Audit:_RenderGrid()
    if not auditRoot then return end

    -- Build the visible item list, applying chip filters as a predicate.
    local list = {}
    local wantUncollected = (sourceStyleId == SOURCE_UNCOLLECTED)
    local function visit(decorID, row, itemID)
        if not row then return end
        -- Drop catalog entries without a real itemID (room-frame placements
        -- and similar non-item universe entries). Style filing is itemID-keyed
        -- so these can't be filed regardless of source.
        if not (itemID or row.itemID) then return end
        -- Audit-mode is scoped to the player's collection EXCEPT for the
        -- Wishlist - Uncollected source, which inverts the universe to
        -- everything the player doesn't yet own.
        if not wantUncollected and HDG_Data and HDG_Data.IsDecorCollected and not HDG_Data.IsDecorCollected(decorID) then return end
        if activeParentCat and row.categoryID ~= activeParentCat then return end
        if activeSubCat and row.subcategoryID ~= activeSubCat then return end
        list[#list + 1] = { decorID = decorID, itemID = itemID or row.itemID, row = row }
    end

    if sourceStyleId == nil then
        for decorID, row in StyleEngine:IterateUnassigned() do
            visit(decorID, row, row.itemID)
        end
    elseif sourceStyleId == SOURCE_ALL then
        local catalog = _GetCatalog()
        for decorID, row in pairs(catalog) do
            visit(decorID, row, row.itemID)
        end
    elseif sourceStyleId == SOURCE_UNCOLLECTED then
        local catalog = _GetCatalog()
        for decorID, row in pairs(catalog) do
            if not (HDG_Data and HDG_Data.IsDecorCollected and HDG_Data.IsDecorCollected(decorID)) then
                visit(decorID, row, row.itemID)
            end
        end
    else
        local items = StyleEngine:GetStyleItems(sourceStyleId)
        if items and items.curated then
            for _, it in ipairs(items.curated) do
                local row = HDG_Data and HDG_Data.GetCatalog and HDG_Data.GetCatalog(it.decorID)
                if row then visit(it.decorID, row, it.itemID) end
            end
        end
    end

    -- Determine how many tiles fit per row based on current scrollBox width.
    local scrollBox = auditRoot.gridScrollBox
    local boxW = scrollBox:GetWidth()
    if not boxW or boxW < 10 then boxW = 400 end  -- pre-layout fallback
    local cols = math.max(1, math.floor((boxW + TILE_GAP) / (TILE_SIZE + TILE_GAP)))

    -- Split the flat item list into row-shaped element data records.
    local rowData = {}
    local i = 1
    while i <= #list do
        local rowItems = {}
        for c = 1, cols do
            if list[i] then
                rowItems[#rowItems + 1] = list[i]
                i = i + 1
            end
        end
        rowData[#rowData + 1] = { items = rowItems }
    end

    -- SetDataProvider resets scroll to top. Capture/restore the scroll
    -- percentage so successive file-actions don't yank the user back to row 1.
    local savedPct = scrollBox.GetScrollPercentage and scrollBox:GetScrollPercentage() or 0

    if #rowData == 0 then
        -- Empty: push a sentinel so the empty-hint font string is visible.
        -- We rely on the existing emptyHint pattern below; just give ScrollBox nothing.
        scrollBox:SetDataProvider(CreateDataProvider({}))
        -- Show/create the empty hint on the scrollBox frame itself.
        if not scrollBox.emptyHint then
            scrollBox.emptyHint = scrollBox:CreateFontString(nil, "OVERLAY")
            ApplyFont(scrollBox.emptyHint, GetScheme(), "small")
            scrollBox.emptyHint:SetPoint("CENTER")
        end
        scrollBox.emptyHint:SetTextColor(rgba(GetScheme().text_dim))
        scrollBox.emptyHint:SetText(L["STYLE_CURATOR_GRID_EMPTY"] or "Nothing to show. Try clearing the chip filter or switching source.")
        scrollBox.emptyHint:Show()
    else
        if scrollBox.emptyHint then scrollBox.emptyHint:Hide() end
        scrollBox:SetDataProvider(CreateDataProvider(rowData))
        if savedPct > 0 and scrollBox.SetScrollPercentage then
            scrollBox:SetScrollPercentage(savedPct, ScrollBoxConstants and ScrollBoxConstants.NoScrollInterpolation)
        end
    end

    -- Update the count text in the click-mode pill bar
    if auditRoot.selCountText then
        local prefix = (selectedCount > 0) and (selectedCount .. " selected · ") or ""
        auditRoot.selCountText:SetText(prefix .. #list .. " shown")
    end
end

function Audit:_ToggleSelection(itemID)
    if selectedItems[itemID] then
        selectedItems[itemID] = nil; selectedCount = selectedCount - 1
    else
        selectedItems[itemID] = true; selectedCount = selectedCount + 1
    end
    self:_RenderGrid()           -- repaint borders + count
    self:_RenderSelectionBar()   -- show/hide the bulk-apply bar
end

-- Find the visible target-row frame matching the active targetStyleId. Used
-- to anchor the filing animation. Returns nil if the row is scrolled off,
-- which gracefully falls through to no animation.
local function findTargetRowFrame()
    local sb = auditRoot and auditRoot.targetScrollBox
    if not (sb and sb.GetFrames) then return nil end
    for _, frame in ipairs(sb:GetFrames()) do
        if frame._sid == targetStyleId then return frame end
    end
    return nil
end

-- Find the visible tile frame for a given itemID. The grid scrollbox returns
-- row frames; each row's `tiles` array holds the actual tile buttons.
local function findTileForItemID(itemID)
    local sb = auditRoot and auditRoot.gridScrollBox
    if not (sb and sb.GetFrames) then return nil end
    for _, rowFrame in ipairs(sb:GetFrames()) do
        if rowFrame.tiles then
            for _, tile in ipairs(rowFrame.tiles) do
                if tile.itemID == itemID and tile:IsVisible() then return tile end
            end
        end
    end
    return nil
end

function Audit:_HandleTileClick(itemID, sourceTile)
    -- Add and Move both require a target style; Remove uses the current source.
    if clickMode ~= "remove" and not targetStyleId then return end

    -- Pre-empt the engine's silent "already in target" no-op so the user
    -- gets explicit feedback instead of reading the lack of response as a
    -- broken click. Affects mostly the "All Collected Decor" source where
    -- the grid shows items already filed elsewhere.
    if (clickMode == "copy" or clickMode == "move") and itemID and targetStyleId then
        local tgtDef = HDG.Store and HDG.Store:Collection_Get("style:" .. targetStyleId)
        if tgtDef and tgtDef.curatedItems and tgtDef.curatedItems[itemID] then
            if UIErrorsFrame and UIErrorsFrame.AddMessage then
                UIErrorsFrame:AddMessage(string.format(
                    L["STYLE_CURATOR_ALREADY_IN_FMT"] or "Already in %s",
                    displayNameFor(targetStyleId) or targetStyleId),
                    1.0, 0.7, 0.4, 1, 2)
            end
            return
        end
    end

    local didFile = false
    if clickMode == "copy" then
        local ok = StyleEngine:AddItemToStyle(targetStyleId, itemID)
        if ok then
            self:_PushUndo({ op = "copy", styleId = targetStyleId, itemIDs = { itemID } })
            didFile = true
        end
    elseif clickMode == "move" then
        -- Move from a real curated source: explicit remove + add (two engine calls).
        -- Move from Unassigned (virtual): just add — the "remove" is implicit
        -- because the item gains a membership and so leaves the unassigned filter.
        if isRealStyleSource() then
            StyleEngine:RemoveItemFromStyle(sourceStyleId, itemID)
        end
        local ok = StyleEngine:AddItemToStyle(targetStyleId, itemID)
        if ok then
            self:_PushUndo({ op = "move", from = sourceStyleId, to = targetStyleId, itemIDs = { itemID } })
            didFile = true
        end
    elseif clickMode == "remove" then
        if not isRealStyleSource() then return end  -- nothing to remove from
        local ok = StyleEngine:RemoveItemFromStyle(sourceStyleId, itemID)
        if ok then self:_PushUndo({ op = "remove", styleId = sourceStyleId, itemIDs = { itemID } }) end
    end

    -- Filing animation: ghost icon flies from the clicked tile into the
    -- active target row. Only for copy/move (remove has no flight target).
    -- Acknowledged minor: the engine work above triggers a synchronous
    -- re-render that may recycle the source tile to a different item, so
    -- the ghost can launch from a slightly off position. Visually
    -- imperceptible at typical refresh speeds; not worth the snapshot
    -- complexity to fix.
    if didFile and sourceTile and HDG_UIHelpers and HDG_UIHelpers.AnimateFiling then
        local targetRow = findTargetRowFrame()
        if targetRow then
            HDG_UIHelpers.AnimateFiling(sourceTile, targetRow, {
                iconAtlas   = sourceTile.icon and sourceTile.icon:GetAtlas(),
                iconTexture = sourceTile.icon and sourceTile.icon:GetTexture(),
                size        = TILE_SIZE,
            })
        end
    end
    -- Re-render is triggered automatically by HDG_STYLE_MEMBERSHIP_CHANGED
    -- subscription set up in _Build (Task 10).
end

function Audit:_PushUndo(entry)
    table.insert(undoStack, 1, entry)
    while #undoStack > UNDO_CAP do table.remove(undoStack) end
    self:_RenderUndo()
end

function Audit:_UndoEntry(idx)
    local entry = table.remove(undoStack, idx)
    if not entry then return end

    if entry.op == "copy" or entry.op == "bulk-copy" or entry.op == "add" or entry.op == "bulk-add" then  -- legacy "add"/"bulk-add" entries reverse identically
        for _, itemID in ipairs(entry.itemIDs) do
            StyleEngine:RemoveItemFromStyle(entry.styleId, itemID)
        end
    elseif entry.op == "remove" then
        for _, itemID in ipairs(entry.itemIDs) do
            StyleEngine:AddItemToStyle(entry.styleId, itemID)
        end
    elseif entry.op == "move" then
        -- Reverse: remove from target. Re-add to source ONLY if it was a
        -- real curated style (entry.from is a styleId). For Unassigned-source
        -- moves (entry.from == nil) the item naturally falls back to
        -- Unassigned once we remove its target membership.
        for _, itemID in ipairs(entry.itemIDs) do
            StyleEngine:RemoveItemFromStyle(entry.to, itemID)
            if entry.from and not isVirtualSourceId(entry.from) then
                StyleEngine:AddItemToStyle(entry.from, itemID)
            end
        end
    end

    self:_RenderUndo()
    -- Grid + style counts repaint via HDG_STYLE_MEMBERSHIP_CHANGED subscription.
end

function Audit:_RenderUndo()
    if not auditRoot then return end
    local scrollBox = auditRoot.undoScrollBox
    if not scrollBox then return end

    local rows = {}
    for i, entry in ipairs(undoStack) do
        local n = #entry.itemIDs
        local displayText
        if entry.op == "remove" then
            -- "Remove" reads the source style off entry.styleId (the style
            -- the items were removed from). Phrase it as "−N from <Source>"
            -- so the source is explicit, not just where they ended up.
            local src = entry.styleId and displayNameFor(entry.styleId) or "?"
            displayText = string.format("-%d from %s", n, src)
        elseif entry.op == "move" then
            -- entry.from = nil means moved from Unassigned (the virtual source).
            local from = entry.from and displayNameFor(entry.from) or (L["STYLE_CURATOR_UNASSIGNED"] or "Unassigned")
            local to   = entry.to   and displayNameFor(entry.to)   or "?"
            displayText = string.format("%d: %s -> %s", n, from, to)
        else
            -- copy / bulk-copy (and legacy add / bulk-add)
            local tgt = entry.styleId and displayNameFor(entry.styleId) or "?"
            displayText = string.format("+%d -> %s", n, tgt)
        end
        rows[#rows + 1] = {
            index       = i,
            displayText = displayText,
        }
    end

    scrollBox:SetDataProvider(CreateDataProvider(rows))
    -- Show empty hint when no ops, hide when ops exist
    if auditRoot.undoEmptyHint then
        if #rows == 0 then
            auditRoot.undoEmptyHint:Show()
        else
            auditRoot.undoEmptyHint:Hide()
        end
    end
end

function Audit:_RenderCoverage()
    if not auditRoot then return end
    local cov = auditRoot.coverage
    if not cov then return end

    -- Coverage is scoped to the player's collection. Total only changes on
    -- HDG_COLLECTION_READY, so cache it; assigned must be recomputed each
    -- render (membership shifts move items between assigned/unassigned).
    local catalog = _GetCatalog()
    local total, assigned = cachedCoverageTotal, 0
    if cachedCoverageTotalDirty then
        total = 0
        for decorID in pairs(catalog) do
            if HDG_Data and HDG_Data.IsDecorCollected and HDG_Data.IsDecorCollected(decorID) then
                total = total + 1
                if not StyleEngine:IsUnassigned(decorID) then assigned = assigned + 1 end
            end
        end
        cachedCoverageTotal, cachedCoverageTotalDirty = total, false
    else
        for decorID in pairs(catalog) do
            if HDG_Data and HDG_Data.IsDecorCollected and HDG_Data.IsDecorCollected(decorID) then
                if not StyleEngine:IsUnassigned(decorID) then assigned = assigned + 1 end
            end
        end
    end

    -- Coverage: warning-color "%" highlight, error color for unassigned > 0.
    local s = GetScheme()
    local pct = (total > 0) and math.floor((assigned / total) * 100) or 0
    local pctColor = string.format("|cff%02x%02x%02x", math.floor(s.warning.r*255), math.floor(s.warning.g*255), math.floor(s.warning.b*255))
    cov.text:SetText(string.format(L["STYLE_CURATOR_COVERAGE_FMT"] or "Coverage: %d of %d in 1+ styles (%s%d%%%s)",
        assigned, total, pctColor, pct, "|r"))
    cov.text:SetTextColor(rgba(s.text))
    local unassigned = total - assigned
    cov.right:SetText(string.format(L["STYLE_CURATOR_UNASSIGNED_FMT"] or "%d unassigned", unassigned))
    if unassigned > 0 then
        cov.right:SetTextColor(rgba(s.error))
    else
        cov.right:SetTextColor(rgba(s.text_dim))
    end

    local fullW = cov.barBg:GetWidth()
    if not fullW or fullW <= 0 then fullW = 1 end  -- pre-layout fallback
    local denom = (total > 0) and total or 1
    cov.barFill:SetWidth(math.max(1, fullW * (assigned / denom)))
end

function Audit:_ApplyBulkSelection()
    if selectedCount == 0 then return end
    if clickMode ~= "remove" and not targetStyleId then return end
    local ids = {}
    for itemID in pairs(selectedItems) do ids[#ids + 1] = itemID end

    -- Capture animation context before the engine work clears selection +
    -- triggers a re-render. Source = first selected tile (visually grounds
    -- the action in the grid); falls back to the apply chip if the first
    -- selected item happens to be scrolled off-screen.
    local animSrc, animTgt, animAtlas, animTex
    if clickMode ~= "remove" and HDG_UIHelpers and HDG_UIHelpers.AnimateFiling then
        local firstID = ids[1]
        animSrc = (firstID and findTileForItemID(firstID))
            or (auditRoot and auditRoot.selBar and auditRoot.selBar.applyBtn)
        animTgt = findTargetRowFrame()
        if firstID and HDG_Data and HDG_Data.GetCatalogByItem then
            local row = HDG_Data.GetCatalogByItem(firstID)
            if row then
                animAtlas = row.iconAtlas
                animTex   = row.iconTexture
            end
        end
    end

    -- Suppress the per-call HDG_STYLE_MEMBERSHIP_CHANGED → _Render cascade
    -- during the loop. Without this, a bulk apply of N items triggers N
    -- full re-renders (each running every panel + a SetDataProvider on the
    -- grid scroll box), which both stutters and corrupts the scroll-position
    -- preservation logic (each intermediate render saves/restores against a
    -- mid-loop state). One explicit _Render() runs after the loop.
    _bulkApplyInProgress = true
    local applied = 0
    if clickMode == "copy" then
        for _, itemID in ipairs(ids) do
            if StyleEngine:AddItemToStyle(targetStyleId, itemID) then applied = applied + 1 end
        end
        if applied > 0 then
            self:_PushUndo({ op = "bulk-copy", styleId = targetStyleId, itemIDs = ids })
        end
    elseif clickMode == "move" then
        for _, itemID in ipairs(ids) do
            if isRealStyleSource() then
                StyleEngine:RemoveItemFromStyle(sourceStyleId, itemID)
            end
            if StyleEngine:AddItemToStyle(targetStyleId, itemID) then applied = applied + 1 end
        end
        if applied > 0 then
            self:_PushUndo({ op = "move", from = sourceStyleId, to = targetStyleId, itemIDs = ids })
        end
    elseif clickMode == "remove" then
        if not isRealStyleSource() then
            _bulkApplyInProgress = false
            return
        end
        for _, itemID in ipairs(ids) do
            if StyleEngine:RemoveItemFromStyle(sourceStyleId, itemID) then applied = applied + 1 end
        end
        if applied > 0 then
            self:_PushUndo({ op = "remove", styleId = sourceStyleId, itemIDs = ids })
        end
    end
    _bulkApplyInProgress = false
    -- Fire animation BEFORE the wipe + bar hide. AnimateFiling captures
    -- source/target screen positions at call time and parents the ghost to
    -- UIParent, so it runs independently of the source bar's subsequent
    -- visibility. Firing after the wipe used to no-op (applyBtn hidden →
    -- IsVisible() false → AnimateFiling early-return).
    if applied > 0 and animSrc and animTgt then
        HDG_UIHelpers.AnimateFiling(animSrc, animTgt, {
            iconAtlas   = animAtlas,
            iconTexture = animTex,
            size        = 32,
        })
    end

    wipe(selectedItems); selectedCount = 0
    -- One render to catch up everything that the suppressed event would
    -- have repainted (source row, target list counts, grid, coverage,
    -- undo strip). Selection wipe is also reflected here — tile borders
    -- drop because selectedItems is now empty.
    self:_Render()
end

function Audit:_RenderSelectionBar()
    if not auditRoot then return end
    local bar = auditRoot.selBar
    if not bar then return end

    if selectedCount == 0 then
        bar:Hide()
        return
    end
    bar:Show()

    local targetText = targetStyleId and displayNameFor(targetStyleId) or nil

    -- Bulk action label tracks the current click-mode verb so the bar's
    -- promise matches what the click actually does. (Bulk Move/Copy from
    -- a virtual source = same engine call; bulk Move from a curated source
    -- = remove from source + add to target per item.)
    local verb
    if clickMode == "move" then
        verb = L["STYLE_CURATOR_MOVE"] or "Move"
    elseif clickMode == "remove" then
        verb = string.format(L["STYLE_CURATOR_REMOVE_FROM_FMT"] or "Remove from %s",
            sourceStyleId and displayNameFor(sourceStyleId) or "?")
    else
        verb = L["STYLE_CURATOR_COPY"] or "Copy"
    end
    local parts = { selectedCount .. " selected" }
    if clickMode == "remove" then
        parts[#parts + 1] = verb
    elseif targetText then
        parts[#parts + 1] = verb .. " to " .. targetText
    else
        parts[#parts + 1] = "(set a target ->)"
    end
    bar.label:SetText(table.concat(parts, "  |  "))

    -- Apply chip: short verb + count ("Move 10"). Active when click is
    -- valid (target set OR remove mode); dim otherwise. OnClick is wired
    -- once in _Build (see selBar.applyBtn construction); only label + state
    -- update here.
    if bar.applyBtn then
        local short = (clickMode == "remove") and (L["STYLE_CURATOR_REMOVE"] or "Remove")
                   or (clickMode == "move") and (L["STYLE_CURATOR_MOVE"] or "Move")
                   or (L["STYLE_CURATOR_COPY"] or "Copy")
        local actionable = (clickMode == "remove") or (targetStyleId ~= nil)
        HDG_UIHelpers.UpdateChipButtonState(bar.applyBtn, actionable,
            short .. " " .. selectedCount)
    end
end

function Audit:_PromptNewStyle()
    StaticPopupDialogs["HDG_CURATOR_NEW_STYLE"] = {
        text    = L["STYLE_CURATOR_NEW_PROMPT"] or "Name your new style:",
        button1 = L["STYLE_CURATOR_NEW_BTN"]    or "Create",
        button2 = L["STYLE_CANCEL"]             or "Cancel",
        hasEditBox = true,
        maxLetters = 40,
        OnAccept = function(self_)
            -- Modern Blizzard StaticPopup capitalises EditBox; the legacy
            -- `editBox` field was removed.
            local eb = self_.EditBox or self_.editBox
            local name = (eb and eb:GetText()) or ""
            if name == "" then return end
            local key = SaveStyleCollection(nil, {
                tier = "curated",
                displayName = name,
                curatedItems = {},
            })
            if StyleEngine.InvalidateCustomStyle then
                StyleEngine:InvalidateCustomStyle(key)
            end
            targetStyleId = key
            Audit:_PopulateSourceDropdown()  -- new style appears in dropdown
            Audit:_Render()
        end,
        EditBoxOnEnterPressed = function(self_)
            local p = self_:GetParent()
            local btn = p and ((p.Buttons and p.Buttons[1]) or p.button1)
            if btn then btn:Click() end
        end,
        EditBoxOnEscapePressed = function(self_) self_:GetParent():Hide() end,
        timeout = 0,
        whileDead = true,
        hideOnEscape = true,
    }
    StaticPopup_Show("HDG_CURATOR_NEW_STYLE")
end

-- ============================================================================
-- Target row context menu: Rename / Duplicate / Delete
-- ============================================================================

local function _GetCuratedDef(sid)
    if not (sid and HDG and HDG.Store) then return nil end
    local def = HDG.Store:Collection_Get("style:" .. sid)
    if def and def.tier == "curated" then return def end
    return nil
end

function Audit:_PromptRenameStyle(sid)
    local def = _GetCuratedDef(sid)
    if not def then return end
    StaticPopupDialogs["HDG_CURATOR_RENAME_STYLE"] = {
        text    = L["STYLE_CURATOR_RENAME_PROMPT"] or "Rename style:",
        button1 = L["STYLE_CURATOR_RENAME_BTN"]    or "Save",
        button2 = L["STYLE_CANCEL"]                or "Cancel",
        hasEditBox = true,
        maxLetters = 40,
        OnShow = function(self_)
            local eb = self_.EditBox or self_.editBox
            if eb then eb:SetText(def.displayName or "") eb:HighlightText() end
        end,
        OnAccept = function(self_)
            local eb = self_.EditBox or self_.editBox
            local newName = (eb and eb:GetText()) or ""
            if newName == "" or newName == def.displayName then return end
            -- Direct field update via the Collections API — no need to copy
            -- the def, Collection_SetField mutates the live record + queues save.
            HDG.Store:Collection_SetField("style:" .. sid, "displayName", newName)
            if StyleEngine.InvalidateCustomStyle then
                StyleEngine:InvalidateCustomStyle(sid)
            end
            Audit:_PopulateSourceDropdown()  -- name change shows in dropdown
            Audit:_Render()
        end,
        EditBoxOnEnterPressed = function(self_)
            local p = self_:GetParent()
            local btn = p and ((p.Buttons and p.Buttons[1]) or p.button1)
            if btn then btn:Click() end
        end,
        EditBoxOnEscapePressed = function(self_) self_:GetParent():Hide() end,
        timeout = 0,
        whileDead = true,
        hideOnEscape = true,
    }
    StaticPopup_Show("HDG_CURATOR_RENAME_STYLE")
end

-- Public: switch the curator's source view to a specific styleId. Called by
-- StylesTab's per-row Edit (E) action to deep-link from My Styles → Curator
-- with the clicked style already loaded as the working source. Selection from
-- the prior source is dropped (selected items wouldn't apply to the new view).
function Audit:SetSource(sid)
    sourceStyleId = sid
    wipe(selectedItems); selectedCount = 0
    if auditRoot and auditRoot:IsShown() then
        self:_PopulateSourceDropdown()
        self:_OnSourceChanged()
    end
end

function Audit:_DuplicateStyle(sid)
    local def = _GetCuratedDef(sid)
    if not def then return end
    -- Deep-copy curatedItems so the new style is independent
    local copy = { tier = "curated", displayName = (def.displayName or sid) .. " (Copy)", curatedItems = {} }
    if def.curatedItems then
        for id, v in pairs(def.curatedItems) do copy.curatedItems[id] = v end
    end
    if def.description then copy.description = def.description end
    local newKey = SaveStyleCollection(nil, copy)
    if StyleEngine.InvalidateCustomStyle then
        StyleEngine:InvalidateCustomStyle(newKey)
    end
    targetStyleId = newKey
    Audit:_PopulateSourceDropdown()  -- duplicate appears in dropdown
    Audit:_Render()
end

function Audit:_PromptDeleteStyle(sid)
    local def = _GetCuratedDef(sid)
    if not def then return end
    local name = def.displayName or sid
    StaticPopupDialogs["HDG_CURATOR_DELETE_STYLE"] = {
        text    = string.format(L["STYLE_CURATOR_DELETE_PROMPT_FMT"] or 'Delete custom style "%s"?\n\nItem assignments will be lost. This cannot be undone.', name),
        button1 = L["STYLE_CURATOR_DELETE"] or "Delete",
        button2 = L["STYLE_CANCEL"]         or "Cancel",
        OnAccept = function()
            DeleteStyleCollection(sid)
            if StyleEngine.InvalidateCustomStyle then
                StyleEngine:InvalidateCustomStyle(sid)
            end
            -- Clear source/target if they pointed at the deleted style
            if sourceStyleId == sid then sourceStyleId = nil end
            if targetStyleId == sid then targetStyleId = nil end
            Audit:_PopulateSourceDropdown()  -- deleted style leaves dropdown
            Audit:_Render()
        end,
        timeout = 0,
        whileDead = true,
        hideOnEscape = true,
        showAlert = true,
    }
    StaticPopup_Show("HDG_CURATOR_DELETE_STYLE")
end
