local _, privateDB = ...

-- ============================================================================
-- RECIPES TAB MODULE
-- Manages all 3 panels of the Recipes tab:
--   Left: Recipe/Profession list
--   Middle: Crafting Queue
--   Right: Required Materials
-- Lumber Tracker is in separate module (HDG_LumberTracker.lua) for widget mode
-- Extracted from HDG_UI.lua in v2.0.3 for maintainability
-- ============================================================================

local CONFIG = privateDB.CONFIG
local L = HDG_L or {} -- Localization
local HDG_RecipesTab = {}
privateDB.HDG_RecipesTab = HDG_RecipesTab

local mainFrame = nil
local searchDebounceTimer = nil

-- Shared GET_ITEM_INFO_RECEIVED registry: sites that want a tooltip rebuild
-- when async item data arrives register a pending entry keyed by itemID.
-- Owner check on fire ensures we don't rebuild into a stale tooltip that's
-- moved to a different frame or been dismissed.
local pendingTooltipRebuilds = {}  -- [itemID] = { owner, rebuildFn }
local itemInfoListener = CreateFrame("Frame")
itemInfoListener:RegisterEvent("GET_ITEM_INFO_RECEIVED")
itemInfoListener:SetScript("OnEvent", function(_, _, receivedID, success)
    local pending = pendingTooltipRebuilds[receivedID]
    if not pending then return end
    pendingTooltipRebuilds[receivedID] = nil
    if not success then return end
    if GameTooltip:IsShown() and GameTooltip:GetOwner() == pending.owner then
        pending.rebuildFn()
    end
end)

-- Open a profession recipe and run a callback once the profession UI is fully
-- populated. If the tradeskill window is already open, calls OpenRecipe and
-- runs the callback synchronously. Otherwise opens it and waits for
-- TRADE_SKILL_LIST_UPDATE (Blizzard's ready signal) before navigating and
-- invoking the callback. 2s fallback timer runs the callback anyway if the
-- event never fires.
local function OpenRecipeAndThen(spellID, callback)
    if not (C_TradeSkillUI and C_TradeSkillUI.OpenRecipe) then return end

    local ready = C_TradeSkillUI.IsTradeSkillReady and C_TradeSkillUI.IsTradeSkillReady()
    if ready then
        C_TradeSkillUI.OpenRecipe(spellID)
        if callback then callback() end
        return
    end

    C_TradeSkillUI.OpenRecipe(spellID)
    local readyFrame = CreateFrame("Frame")
    local fallbackTimer
    local done = false
    local function finish()
        if done then return end
        done = true
        readyFrame:UnregisterAllEvents()
        readyFrame:SetScript("OnEvent", nil)
        if fallbackTimer then fallbackTimer:Cancel(); fallbackTimer = nil end
        if C_TradeSkillUI and C_TradeSkillUI.OpenRecipe then
            C_TradeSkillUI.OpenRecipe(spellID)
        end
        if callback then callback() end
    end
    readyFrame:RegisterEvent("TRADE_SKILL_LIST_UPDATE")
    readyFrame:SetScript("OnEvent", finish)
    fallbackTimer = C_Timer.NewTimer(2, finish)
end

-- ============================================================================
-- THEME HELPERS (delegating to ThemeEngine canonical helpers)
-- ============================================================================

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


-- Store access helpers for SSoT pattern
local function GetStore()
    return HDG and HDG.Store
end

local function GetState()
    local store = GetStore()
    return store and store:GetState()
end

local function Dispatch(action, payload)
    local store = GetStore()
    if store then
        store:Dispatch(action, payload)
    end
end

-- Convenience accessors
local function GetConfig(key)
    local state = GetState()
    return state and state.config and state.config[key]
end

local function GetUIState(key)
    local state = GetState()
    return state and state.ui and state.ui[key]
end

local function GetCraftingQueue()
    local state = GetState()
    return state and state.craftingQueue or {}
end

local function IsPSLAvailable()
    return C_AddOns and C_AddOns.IsAddOnLoaded("ProfessionShoppingList")
        and SlashCmdList["PROFESSIONSHOPPINGLIST"]
end

-- Queue + materials panels migrated to WowScrollBoxList. No pool tables retained.

-- Forward-declare ScrollBox helpers for queue + materials. Their definitions
-- live below the CreateQueuePanel / CreateMaterialsPanel functions that
-- reference them, so the initializer closures need upvalue bindings rather
-- than stale global reads.
local GetQueueElementExtent
local InitializeQueueFrame
local BindQueueFrame
local GetMaterialsElementExtent
local InitializeMaterialsFrame
local BindMaterialsFrame

-- Recipe list migrated to WowScrollBoxList + DataProvider (see mainFrame.recipeScrollBox
-- in CreateRecipePanel). The old recipeRowPool / tierHeaderPool hand-rolled pools are
-- gone; the ScrollBox owns a single pool of bare "Button" frames, and bind-time logic
-- dispatches on elementData.kind (recipe | profHeader | tierHeader | spacer) to show
-- the right widget subset per row.
local ELEMENT_HEIGHTS = {
    recipe     = 20,
    profHeader = 24,
    tierHeader = 16,
    spacer     = 6, -- default; per-element override via data.height
}

-- Per-refresh CanCraftRecipe cache (cleared at start of each refresh cycle)
local craftabilityCache = {}
local function GetCachedCraftability(recipe)
    local key = recipe.spell or recipe.item or recipe.id
    if not craftabilityCache[key] then
        craftabilityCache[key] = { HDG_Data.CanCraftRecipe(recipe) }
    end
    return unpack(craftabilityCache[key])
end

-- Use centralized Data() accessor from UIHelpers
local function Data()
    return HDG_UIHelpers.Data()
end

-- Get recipe from ProfessionsDB by output itemID (v2 format uses recipeID as key)
local function GetRecipeByItemID(itemID)
    if HDG_PowerCrafting and HDG_PowerCrafting.GetRecipeByItemID then
        return HDG_PowerCrafting.GetRecipeByItemID(itemID)
    end
    return nil
end

-- ============================================================================
-- HELPER FUNCTIONS
-- ============================================================================


local function GetQueueItemColor(item)
    if item.expansion then
        local expColor = HDG_ExpansionData.GetColor(item.expansion)
        return { expColor[1] * 0.5, expColor[2] * 0.5, expColor[3] * 0.5, 0.4 }
    end
    return { 0.08, 0.08, 0.08, 0.85 }
end

-- Track which crafting queue items are expanded to show subsidiary recipes
-- Key format: "itemID" for top-level, "parentID:itemID" for nested
-- Value is true/false
local expandedQueueItems = {}

-- Lazy-built reverse index for legacy queue items missing profession field
local itemToProfession = nil
local function GetProfessionForItem(itemID)
    if not itemID or not HDG_DecorDB then return nil end
    if not itemToProfession then
        itemToProfession = {}
        for _, recipe in pairs(HDG_DecorDB) do
            if recipe.itemID then itemToProfession[recipe.itemID] = recipe.profession end
        end
    end
    return itemToProfession[itemID]
end

-- ============================================================================
-- CRAFT CONFIRMATION DIALOG
-- ============================================================================

local craftConfirmFrame = nil

-- Create the craft confirmation dialog frame (lazy-init, called once)
local function CreateCraftDialog()
    craftConfirmFrame = CreateFrame("Frame", "HDG_CraftConfirmDialog", UIParent, "BackdropTemplate")
    craftConfirmFrame:SetSize(380, 165)
    craftConfirmFrame:SetPoint("CENTER", 0, 100)
    craftConfirmFrame:SetFrameStrata("DIALOG")
    craftConfirmFrame:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        edgeSize = 2,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })
    local cfScheme = GetScheme()
    craftConfirmFrame:SetBackdropColor(cfScheme.bg.r, cfScheme.bg.g, cfScheme.bg.b, 0.95)
    craftConfirmFrame:SetBackdropBorderColor(cfScheme.accent.r, cfScheme.accent.g, cfScheme.accent.b, 1)
    craftConfirmFrame._accentBorder = true
    RegisterWidget(craftConfirmFrame, "Frame")
    craftConfirmFrame:EnableMouse(true)
    craftConfirmFrame:SetMovable(true)
    craftConfirmFrame:RegisterForDrag("LeftButton")
    craftConfirmFrame:SetScript("OnDragStart", craftConfirmFrame.StartMoving)
    craftConfirmFrame:SetScript("OnDragStop", craftConfirmFrame.StopMovingOrSizing)

    -- Title
    local title = craftConfirmFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOP", 0, -12)
    ApplyFont(title, cfScheme, "header")
    title:SetText(HDG.Theme.C_Accent() .. L["RECIPE_CRAFT_CONFIRM"] .. "|r")
    craftConfirmFrame.title = title

    -- Item text
    local itemText = craftConfirmFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    itemText:SetPoint("TOP", 0, -40)
    itemText:SetWidth(360)
    ApplyFont(itemText, cfScheme, "body")
    craftConfirmFrame.itemText = itemText

    -- Info text
    local infoText = craftConfirmFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    infoText:SetPoint("TOP", 0, -62)
    infoText:SetWidth(360)
    if cfScheme then
        infoText:SetTextColor(cfScheme.text_dim.r, cfScheme.text_dim.g, cfScheme.text_dim.b, cfScheme.text_dim.a)
    end
    ApplyFont(infoText, cfScheme, "small")
    craftConfirmFrame.infoText = infoText

    -- API note text
    local apiNote = craftConfirmFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    apiNote:SetPoint("TOP", 0, -80)
    apiNote:SetWidth(360)
    if cfScheme then
        apiNote:SetTextColor(cfScheme.text_dim.r * 0.7, cfScheme.text_dim.g * 0.7, cfScheme.text_dim.b * 0.7, cfScheme.text_dim.a)
    end
    ApplyFont(apiNote, cfScheme, "small")
    apiNote:SetText(L["RECIPE_API_NOTE"])
    craftConfirmFrame.apiNote = apiNote

    -- Helper function to create styled button
    local function CreateStyledButton(parent, width, color, hoverColor)
        local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
        btn:SetSize(width, 28)
        btn:SetBackdrop({
            bgFile = CONFIG.TEXTURES.WHITE_BG,
            edgeFile = CONFIG.TEXTURES.WHITE_BG,
            tile = false,
            edgeSize = 1,
        })
        btn:SetBackdropColor(unpack(color))
        btn:SetBackdropBorderColor(color[1] + 0.1, color[2] + 0.1, color[3] + 0.1, 1)

        local btnText = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        btnText:SetPoint("CENTER", 0, 0)
        ApplyFont(btnText, GetScheme(), "body")
        btn.text = btnText

        btn:SetScript("OnEnter", function(self)
            self:SetBackdropColor(unpack(hoverColor))
        end)
        btn:SetScript("OnLeave", function(self)
            self:SetBackdropColor(unpack(color))
        end)
        return btn
    end

    -- Layout: [Craft N] [-] [+] [Craft Max] [Cancel]
    -- Craft button (green) - crafts selected quantity
    local craftBtn = CreateStyledButton(craftConfirmFrame, 70, { 0.15, 0.7, 0.15, 1 }, { 0.2, 0.85, 0.2, 1 })
    craftBtn:SetPoint("BOTTOMLEFT", 12, 15)
    craftBtn.text:SetText(HDG.Theme.C_Text() .. L["RECIPE_CRAFT_ONE"] .. "|r")
    craftConfirmFrame.craftBtn = craftBtn

    -- Minus button (orange)
    local minusBtn = CreateStyledButton(craftConfirmFrame, 28, { 0.6, 0.35, 0.1, 1 }, { 0.75, 0.45, 0.15, 1 })
    minusBtn:SetPoint("LEFT", craftBtn, "RIGHT", 6, 0)
    minusBtn.text:SetText(HDG.Theme.C_Text() .. "-|r")
    craftConfirmFrame.minusBtn = minusBtn

    -- Plus button (orange)
    local plusBtn = CreateStyledButton(craftConfirmFrame, 28, { 0.6, 0.35, 0.1, 1 }, { 0.75, 0.45, 0.15, 1 })
    plusBtn:SetPoint("LEFT", minusBtn, "RIGHT", 4, 0)
    plusBtn.text:SetText(HDG.Theme.C_Text() .. "+|r")
    craftConfirmFrame.plusBtn = plusBtn

    -- Craft Max button (teal)
    local craftMaxBtn = CreateStyledButton(craftConfirmFrame, 70, { 0.08, 0.45, 0.40, 1 }, { 0.12, 0.55, 0.50, 1 })
    craftMaxBtn:SetPoint("LEFT", plusBtn, "RIGHT", 6, 0)
    craftConfirmFrame.craftMaxBtn = craftMaxBtn

    -- Cancel button (gray)
    local cancelBtn = CreateStyledButton(craftConfirmFrame, 60, { 0.25, 0.25, 0.25, 1 }, { 0.35, 0.35, 0.35, 1 })
    cancelBtn:SetPoint("BOTTOMRIGHT", -12, 15)
    cancelBtn.text:SetText(HDG.Theme.C_Text() .. "Cancel|r")
    cancelBtn:SetScript("OnClick", function()
        craftConfirmFrame:Hide()
    end)

    -- Close on Escape
    craftConfirmFrame:SetScript("OnKeyDown", function(self, key)
        if key == "ESCAPE" then
            self:SetPropagateKeyboardInput(false)
            self:Hide()
        else
            self:SetPropagateKeyboardInput(true)
        end
    end)

    -- Checkbox: Keep profession window open
    local keepOpenCheck = CreateFrame("CheckButton", nil, craftConfirmFrame, "UICheckButtonTemplate")
    keepOpenCheck:SetSize(24, 24)
    keepOpenCheck:SetPoint("TOPLEFT", 12, -95)
    keepOpenCheck:SetChecked(not GetConfig("closeProfWindowAfterCraft"))
    keepOpenCheck:SetScript("OnClick", function(self)
        Dispatch("SET_CONFIG", { key = "closeProfWindowAfterCraft", value = not self:GetChecked() })
    end)
    craftConfirmFrame.keepOpenCheck = keepOpenCheck

    local koScheme = GetScheme()
    local keepOpenLabel = craftConfirmFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    keepOpenLabel:SetPoint("LEFT", keepOpenCheck, "RIGHT", 2, 0)
    keepOpenLabel:SetText(L["RECIPE_KEEP_PROF_OPEN"])
    if koScheme then
        keepOpenLabel:SetTextColor(koScheme.text_dim.r, koScheme.text_dim.g, koScheme.text_dim.b, koScheme.text_dim.a)
    end
    ApplyFont(keepOpenLabel, koScheme, "small")

    craftConfirmFrame:Hide()
end

-- Show craft confirmation dialog
function HDG_RecipesTab.ShowCraftConfirmation(item, queueIndex)
    if not item or not item.spell then return end

    local queuedQty = item.quantity or 1
    local _, maxCraftable = HDG_Data.CanCraftRecipe(item)
    maxCraftable = maxCraftable or 0
    -- Use the lesser of queued and craftable (can't craft more than you have mats for)
    local totalQty = math.min(queuedQty, maxCraftable)
    if totalQty <= 0 then totalQty = 1 end -- Fallback to 1 if something went wrong

    -- Create the confirmation frame if it doesn't exist
    if not craftConfirmFrame then
        CreateCraftDialog()
    end

    -- Update checkbox state (in case setting changed elsewhere)
    craftConfirmFrame.keepOpenCheck:SetChecked(not GetConfig("closeProfWindowAfterCraft"))

    -- Track current selected quantity
    craftConfirmFrame.selectedQty = 1
    craftConfirmFrame.maxQty = totalQty

    -- Helper to update craft button text
    local function UpdateCraftBtnText()
        craftConfirmFrame.craftBtn.text:SetText(string.format(HDG.Theme.C_Text() .. L["RECIPE_CRAFT_N"] .. "|r", craftConfirmFrame.selectedQty))
    end

    -- Update dialog content
    local localizedItemName = Data().GetLocalizedName(item.id, item.name)
    craftConfirmFrame.itemText:SetText(string.format(HDG.Theme.C_Text() .. "%s|r", localizedItemName))
    -- Show +/- and Max buttons if player can craft more than 1 (based on materials)
    if maxCraftable > 1 then
        -- Show queued and craftable amounts
        local infoStr = string.format("Queued: %d | Can craft: " .. HDG.Theme.C_Success() .. "%d|r", queuedQty, maxCraftable)
        craftConfirmFrame.infoText:SetText(infoStr)
        -- Max button shows how many you CAN craft (not limited by queue)
        craftConfirmFrame.craftMaxBtn.text:SetText(string.format(HDG.Theme.C_Text() .. L["RECIPE_CRAFT_MAX"] .. "|r", maxCraftable))
        craftConfirmFrame.craftMaxBtn:Show()
        craftConfirmFrame.minusBtn:Show()
        craftConfirmFrame.plusBtn:Show()
        -- Update maxQty to be based on craftable, not queued
        craftConfirmFrame.maxQty = maxCraftable
    else
        craftConfirmFrame.infoText:SetText(L["RECIPE_CRAFT_QUESTION"])
        craftConfirmFrame.craftMaxBtn:Hide()
        craftConfirmFrame.minusBtn:Hide()
        craftConfirmFrame.plusBtn:Hide()
        craftConfirmFrame.maxQty = 1
    end

    -- Store item data
    craftConfirmFrame.currentItem = item
    craftConfirmFrame.currentIndex = queueIndex

    -- Reset quantity and update button text
    craftConfirmFrame.selectedQty = 1
    UpdateCraftBtnText()

    -- Set up Craft button click (crafts selected quantity)
    craftConfirmFrame.craftBtn:SetScript("OnClick", function()
        HDG_RecipesTab.ExecuteCraft(item, queueIndex, craftConfirmFrame.selectedQty)
        craftConfirmFrame:Hide()
    end)

    -- Set up minus button (decrement quantity)
    craftConfirmFrame.minusBtn:SetScript("OnClick", function()
        if craftConfirmFrame.selectedQty > 1 then
            craftConfirmFrame.selectedQty = craftConfirmFrame.selectedQty - 1
            UpdateCraftBtnText()
        end
    end)

    -- Set up plus button (increment quantity)
    craftConfirmFrame.plusBtn:SetScript("OnClick", function()
        if craftConfirmFrame.selectedQty < craftConfirmFrame.maxQty then
            craftConfirmFrame.selectedQty = craftConfirmFrame.selectedQty + 1
            UpdateCraftBtnText()
        end
    end)

    -- Set up Craft Max button click (craft all you can based on materials)
    craftConfirmFrame.craftMaxBtn:SetScript("OnClick", function()
        HDG_RecipesTab.ExecuteCraft(item, queueIndex, craftConfirmFrame.maxQty)
        craftConfirmFrame:Hide()
    end)

    craftConfirmFrame:Show()
end

-- Execute the actual crafting
-- quantity parameter allows crafting 1 or all
-- Note: Profession window should already be open when this is called
function HDG_RecipesTab.ExecuteCraft(item, queueIndex, quantity)
    if not item or not item.spell then
        print(HDG.Theme.C_Error() .. L["RECIPE_MSG_INVALID"])
        return
    end

    -- Default to 1 if not specified
    quantity = quantity or 1
    local recipeID = item.spell

    -- Check if C_TradeSkillUI is available
    if not C_TradeSkillUI then
        print(HDG.Theme.C_Error() .. L["RECIPE_MSG_NO_API"])
        return
    end

    -- Check if profession window is open (should be, since we open it before showing confirmation)
    local isTradeSkillOpen = C_TradeSkillUI.IsTradeSkillReady and C_TradeSkillUI.IsTradeSkillReady()
    if not isTradeSkillOpen then
        print(HDG.Theme.C_Warning() .. L["RECIPE_MSG_PROF_NOT_READY"])
        return
    end

    -- Set up pending craft tracking for multi-craft scenarios
    -- This tells the event handler how many crafts to expect before closing window
    if privateDB.pendingCraftInfo then
        privateDB.pendingCraftInfo.itemID = item.id
        privateDB.pendingCraftInfo.remaining = quantity
    end

    -- Craft directly - window is already open
    local success, err = pcall(function()
        C_TradeSkillUI.CraftRecipe(recipeID, quantity, {})
    end)
    if success then
        local localizedCraftName = Data().GetLocalizedName(item.id, item.name)
        print(string.format(HDG.Theme.C_Accent() .. L["RECIPE_MSG_CRAFTING"], quantity, localizedCraftName))
    else
        print(string.format(HDG.Theme.C_Error() .. L["RECIPE_MSG_CRAFT_FAILED"], tostring(err)))
        -- Clear pending tracking on failure
        if privateDB.pendingCraftInfo then
            privateDB.pendingCraftInfo.itemID = nil
            privateDB.pendingCraftInfo.remaining = 0
        end
    end
end

-- ============================================================================
-- INITIALIZATION
-- ============================================================================

function HDG_RecipesTab:Init(frame)
    mainFrame = frame
end


-- ============================================================================
-- RECIPE RENDERING (Left Panel)
-- ============================================================================

local function UpdateProfessionSectionHeader(profName)
    if mainFrame.profSectionHeader and mainFrame.profSectionText then
        mainFrame.profSectionText:SetText(profName)
        mainFrame.profSectionHeader:Show()
    end
end

local function HideProfessionSectionHeader()
    if mainFrame.profSectionHeader then
        mainFrame.profSectionHeader:Hide()
    end
end

-- Recipe row pool: reuse Button frames across refreshes instead of creating new ones
-- ============================================================================
-- RECIPE LIST SCROLLBOX: extent calculator, one-time initializer, per-bind
-- dispatch across recipe / profHeader / tierHeader / spacer element kinds.
-- ============================================================================

local function GetRecipeElementExtent(_, data)
    if data.kind == "spacer" then return data.height or 6 end
    return ELEMENT_HEIGHTS[data.kind] or 20
end

local function HideAllRecipeKindWidgets(frame)
    if frame.profIcon then frame.profIcon:Hide() end
    if frame.collectedIcon then frame.collectedIcon:Hide() end
    if frame.nameText then frame.nameText:Hide() end
    if frame.qtyText then frame.qtyText:Hide() end
    if frame.expText then frame.expText:Hide() end
    if frame.profHeaderLabel then frame.profHeaderLabel:Hide() end
    if frame.profCountLabel then frame.profCountLabel:Hide() end
    if frame.tierHeaderText then frame.tierHeaderText:Hide() end
end

local function InitializeRecipeListRow(frame)
    if frame._hdgInit then return end
    frame._hdgInit = true

    -- Full-frame bg texture (replaces SetBackdrop; ScrollBox-created frames are
    -- bare Button frames with no template).
    frame.bg = frame:CreateTexture(nil, "BACKGROUND")
    frame.bg:SetAllPoints()

    -- === recipe-kind widgets ===
    frame.profIcon = frame:CreateTexture(nil, "ARTWORK")
    frame.profIcon:SetSize(14, 14)
    frame.collectedIcon = frame:CreateTexture(nil, "OVERLAY")
    frame.collectedIcon:SetSize(12, 12)
    frame.nameText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.nameText:SetJustifyH("LEFT")
    frame.nameText:SetWordWrap(false)
    frame.qtyText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.expText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.expText:SetPoint("RIGHT", -5, 0)

    -- === profHeader-kind widgets (inline "All Professions" section label) ===
    frame.profCountLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.profCountLabel:SetPoint("RIGHT", -8, 0)
    frame.profHeaderLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.profHeaderLabel:SetPoint("LEFT", 8, 0)
    frame.profHeaderLabel:SetPoint("RIGHT", frame.profCountLabel, "LEFT", -8, 0)
    frame.profHeaderLabel:SetJustifyH("LEFT")
    frame.profHeaderLabel:SetWordWrap(false)

    -- === tierHeader-kind widgets ===
    frame.tierHeaderText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.tierHeaderText:SetPoint("LEFT", 8, 0)

    frame:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    -- Single OnClick/OnEnter/OnLeave, dispatch on frame._kind
    frame:SetScript("OnClick", function(self)
        if self._kind == "recipe" and self._recipe then
            HDG_RecipesTab:AddToCraftingQueue(self._recipe)
        end
    end)

    frame:SetScript("OnEnter", function(self)
        if self._kind ~= "recipe" or not self._recipe then return end
        local r = self._recipe
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:SetItemByID(r.id)
        GameTooltip:AddLine(" ")
        if self._isAltKnown and self._altWhoKnows then
            GameTooltip:AddLine(HDG.Theme.C_Warning() .. (L["QUEUE_KNOWN_BY"] or "Known by:") .. "|r " .. self._altWhoKnows, 1, 1, 1)
            GameTooltip:AddLine(" ")
        end
        if r.reagents and #r.reagents > 0 then
            GameTooltip:AddLine(HDG.Theme.C_Text() .. L["HEADER_MATERIALS"] .. ":|r")
            for _, reagent in ipairs(r.reagents) do
                local have = HDG_Data.GetItemCountWithVariants(reagent.id)
                local need = reagent.amount or 1
                local reagentName = reagent.name or Data().GetCachedItemName(reagent.id) or ("Item " .. reagent.id)
                local color = have >= need and HDG.Theme.C_Success() or (have > 0 and HDG.Theme.C_Warning() or HDG.Theme.C_Error())
                GameTooltip:AddDoubleLine("  " .. reagentName, string.format("%s%d/%d|r", color, have, need), 1, 1, 1, 1, 1, 1)
            end
        end
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(L["RECIPE_TIP_CLICK_ADD_QUEUE"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
        HDG_UIHelpers.ShowHoverPreview(r.id, mainFrame)
    end)

    frame:SetScript("OnLeave", function(self)
        if self._kind == "recipe" then
            HDG_UIHelpers.HideHoverPreview()
        end
        GameTooltip:Hide()
    end)
end

-- Per-bind: update a frame to display the given recipe element
local function BindRecipeKind(frame, data)
    local recipe = data.recipe
    frame._kind = "recipe"
    frame._recipe = recipe
    frame._isAltKnown = data.isAltKnown
    frame._altWhoKnows = data.altWhoKnows

    local itemName = Data().GetLocalizedName(recipe.id, recipe.name)
    if not itemName or itemName == "Loading..." then itemName = "Item " .. recipe.id end

    local isCollected = Data().IsItemCollected(recipe.id)

    local expColor = HDG_ExpansionData.GetColor(recipe.expansion)
    if expColor then
        frame.bg:SetColorTexture(expColor[1], expColor[2], expColor[3], 0.25)
    else
        local scheme = GetScheme()
        frame.bg:SetColorTexture(scheme.button_normal.r, scheme.button_normal.g, scheme.button_normal.b, 0.2)
    end

    local leftOffset = 3
    if data.showProfessionIcon and recipe._professionName then
        frame.profIcon:ClearAllPoints()
        frame.profIcon:SetPoint("LEFT", leftOffset, 0)
        HDG_ExpansionData.ApplyProfessionIcon(frame.profIcon, recipe._professionName)
        frame.profIcon:Show()
        leftOffset = leftOffset + 16
    end

    frame.collectedIcon:ClearAllPoints()
    frame.collectedIcon:SetPoint("LEFT", leftOffset, 0)
    frame.collectedIcon:SetTexture(isCollected and CONFIG.TEXTURES.CHECKMARK or CONFIG.TEXTURES.XMARK)
    frame.collectedIcon:Show()
    leftOffset = leftOffset + 15

    local btnScheme = GetScheme()
    frame.nameText:ClearAllPoints()
    frame.nameText:SetPoint("LEFT", leftOffset, 0)
    frame.nameText:SetPoint("RIGHT", -55, 0)
    ApplyFont(frame.nameText, btnScheme, "small")

    local recipeColor
    if data.noProfession then
        recipeColor = btnScheme.text_recipe_no_prof or {r=0.56, g=0.56, b=0.56, a=1}
    elseif data.isKnown then
        recipeColor = btnScheme.text_recipe_known or {r=0.80, g=0.29, b=0.09, a=1}
    elseif data.isAltKnown then
        recipeColor = btnScheme.text_recipe_alt or {r=1, g=1, b=1, a=1}
    else
        recipeColor = btnScheme.text_recipe_unknown or {r=0.69, g=0.69, b=0.69, a=1}
    end
    frame.nameText:SetText(itemName)
    frame.nameText:SetTextColor(recipeColor.r, recipeColor.g, recipeColor.b, recipeColor.a)
    frame.nameText:Show()

    if data.showCraftableQty then
        frame.qtyText:ClearAllPoints()
        frame.qtyText:SetPoint("RIGHT", -40, 0)
        ApplyFont(frame.qtyText, btnScheme, "small")
        if recipe._maxCraftable and recipe._maxCraftable > 0 then
            local maxQty = recipe._maxCraftable
            if maxQty > 99 then
                frame.qtyText:SetText(HDG.Theme.C_Success() .. "x99+|r")
            else
                frame.qtyText:SetText(string.format(HDG.Theme.C_Success() .. "x%d|r", maxQty))
            end
            frame.qtyText:Show()
        elseif recipe._materialPercent then
            local pct = recipe._materialPercent
            local color = pct >= 75 and HDG.Theme.C_Success() or (pct >= 50 and HDG.Theme.C_Warning() or HDG.Theme.C_Error())
            frame.qtyText:SetText(string.format("%s%.0f%%|r", color, pct))
            frame.qtyText:Show()
        else
            frame.qtyText:Hide()
        end
    else
        frame.qtyText:Hide()
    end

    local expShort = HDG_ExpansionData.GetShortName(recipe.expansion)
    local expColor2 = HDG_ExpansionData.GetColor(recipe.expansion)
    frame.expText:SetText(expShort)
    frame.expText:SetTextColor(unpack(expColor2))
    ApplyFont(frame.expText, btnScheme, "small")
    frame.expText:Show()
end

local function BindProfHeaderKind(frame, data)
    frame._kind = "profHeader"
    frame._recipe = nil
    local scheme = GetScheme()
    local headerBg = scheme.header_bar or scheme.panel or {r=0.08, g=0.25, b=0.25, a=0.9}
    frame.bg:SetColorTexture(headerBg.r, headerBg.g, headerBg.b, headerBg.a or 0.9)

    ApplyFont(frame.profCountLabel, scheme, "small")
    frame.profCountLabel:SetText(HDG.Theme.C_Dim() .. (data.count or 0) .. " recipes|r")
    frame.profCountLabel:Show()

    ApplyFont(frame.profHeaderLabel, scheme, "body")
    frame.profHeaderLabel:SetText(data.text or "")
    local profColor = data.hasProfession
        and (scheme.text_recipe_known or {r=0.80, g=0.29, b=0.09, a=1})
        or (scheme.text_recipe_no_prof or {r=0.56, g=0.56, b=0.56, a=1})
    frame.profHeaderLabel:SetTextColor(profColor.r, profColor.g, profColor.b, profColor.a or 1)
    frame.profHeaderLabel:Show()
end

local function BindTierHeaderKind(frame, data)
    frame._kind = "tierHeader"
    frame._recipe = nil
    frame.bg:SetColorTexture(0, 0, 0, 0)
    ApplyFont(frame.tierHeaderText, GetScheme(), "small")
    frame.tierHeaderText:SetText((data.color or "") .. (data.label or "") .. "|r " .. HDG.Theme.C_Dim() .. "(" .. (data.count or 0) .. ")|r")
    frame.tierHeaderText:Show()
end

local function BindSpacerKind(frame)
    frame._kind = "spacer"
    frame._recipe = nil
    frame.bg:SetColorTexture(0, 0, 0, 0)
end

local function BindRecipeListRow(frame, data)
    HideAllRecipeKindWidgets(frame)
    local kind = data.kind
    if kind == "recipe" then
        BindRecipeKind(frame, data)
    elseif kind == "profHeader" then
        BindProfHeaderKind(frame, data)
    elseif kind == "tierHeader" then
        BindTierHeaderKind(frame, data)
    else
        BindSpacerKind(frame)
    end
end

-- ============================================================================
-- DATAPROVIDER PUSH HELPERS
-- Replace the old yOffset-accumulating Render* functions. Each Push* function
-- appends zero or more elements to the DataProvider.
-- ============================================================================

-- Precompute recipe render state so bind has no I/O except the tooltip OnEnter.
local function PushRecipe(dp, recipe, showCraftableQty, noProfession, showProfessionIcon)
    local isKnown = Data().PlayerKnowsRecipeBySpellID and Data().PlayerKnowsRecipeBySpellID(recipe.spell) or false
    local isAltKnown = false
    local altWhoKnows = nil
    if not isKnown and recipe.spell and HDG_Data.GetCharactersWhoKnowRecipe then
        local altsWhoKnow = HDG_Data.GetCharactersWhoKnowRecipe(recipe.spell, recipe.profession)
        if altsWhoKnow then
            for _, altInfo in ipairs(altsWhoKnow) do
                if not altInfo.isCurrent then
                    isAltKnown = true
                    altWhoKnows = altInfo.name
                    break
                end
            end
        end
    end

    dp:Insert({
        kind = "recipe",
        recipe = recipe,
        showCraftableQty = showCraftableQty,
        noProfession = noProfession,
        showProfessionIcon = showProfessionIcon,
        isKnown = isKnown,
        isAltKnown = isAltKnown,
        altWhoKnows = altWhoKnows,
    })
end

local function sortByPercent(a, b)
    return (a._materialPercent or 0) > (b._materialPercent or 0)
end

local function PushTierGroup(dp, tier, label, color, showProfessionIcon)
    if #tier == 0 then return end
    dp:Insert({ kind = "tierHeader", label = label, color = color, count = #tier })
    for _, recipe in ipairs(tier) do
        PushRecipe(dp, recipe, true, recipe._noProfession, showProfessionIcon)
    end
    dp:Insert({ kind = "spacer", height = 6 })
end

-- Shared cost display for materials panel (used by RefreshMaterials and RefreshMaterialsByRecipe)
local function UpdateCostDisplay(totalCost, missingPrices)
    if not mainFrame.materialsCostText then return end
    local hasPriceAddon, priceAddonName = Data().HasPriceAddon()
    if not hasPriceAddon then
        mainFrame.materialsCostText:SetText(HDG.Theme.C_Dim() .. (L["STATUS_INSTALL_PRICE_ADDON"] or "Install TSM/Auctionator for price data") .. "|r")
        return
    end
    local costStr = Data().FormatGold(totalCost)
    if missingPrices > 0 then
        mainFrame.materialsCostText:SetText(string.format(
            HDG.Theme.C_Text() .. (L["RECIPE_EST_COST"] or "Est. Cost:") .. "|r " .. HDG.Theme.C_Warning() .. "~%s+|r " .. HDG.Theme.C_Dim() .. "(%d unknown)|r", costStr, missingPrices))
    elseif totalCost > 0 then
        mainFrame.materialsCostText:SetText(string.format(
            HDG.Theme.C_Text() .. (L["RECIPE_EST_COST"] or "Est. Cost:") .. "|r " .. HDG.Theme.C_Warning() .. "~%s|r " .. HDG.Theme.C_Dim() .. "(via %s)|r", costStr, priceAddonName))
    else
        mainFrame.materialsCostText:SetText(HDG.Theme.C_Text() .. (L["RECIPE_EST_COST"] or "Est. Cost:") .. "|r " .. HDG.Theme.C_Success() .. (L["STATUS_HAVE_ALL_MATERIALS"] or "Have all materials!") .. "|r")
    end
end

local function PushProfession(dp, profData, showInlineHeader, skipFixedHeader)
    local internalProfName = profData.name                                          -- English name for lookups
    local profName = HDG_ExpansionData.GetLocalizedProfessionName(internalProfName) -- Localized for display
    local craftableFilterEnabled = HDG_Data.IsCraftableFilterEnabled()

    local preFilteredItems = {}
    for _, recipe in ipairs(profData.items) do
        if HDG_Data.RecipeMatchesFilters(recipe) then
            table.insert(preFilteredItems, recipe)
        end
    end
    if #preFilteredItems == 0 then return end

    local hasProfession = HDG_Data.PlayerHasProfession(internalProfName)

    local function pushInlineHeader(headerText)
        if showInlineHeader then
            dp:Insert({
                kind = "profHeader",
                text = headerText,
                count = #preFilteredItems,
                hasProfession = hasProfession,
            })
        end
    end

    if craftableFilterEnabled then
        local tier100, tier75to99, tier50to74, tierBelow50 = {}, {}, {}, {}
        for _, recipe in ipairs(preFilteredItems) do
            local isKnown = HDG_Data.PlayerKnowsRecipeBySpellID(recipe.spell)
            local canCraft, maxQty, _, matPercent = GetCachedCraftability(recipe)
            recipe._maxCraftable = maxQty
            recipe._materialPercent = matPercent
            recipe._noProfession = not hasProfession
            if hasProfession and isKnown and canCraft and maxQty > 0 then
                table.insert(tier100, recipe)
            elseif matPercent >= 75 then
                table.insert(tier75to99, recipe)
            elseif matPercent >= 50 then
                table.insert(tier50to74, recipe)
            elseif matPercent > 0 then
                table.insert(tierBelow50, recipe)
            end
        end
        table.sort(tier75to99, sortByPercent)
        table.sort(tier50to74, sortByPercent)
        table.sort(tierBelow50, sortByPercent)

        if #tier100 + #tier75to99 + #tier50to74 + #tierBelow50 == 0 then return end

        local notLearnedLabel = L["QUEUE_NEEDS_TRAINING"] or "Not Learned"
        local headerText = profName
        if not hasProfession then
            headerText = profName .. " (" .. notLearnedLabel .. ")"
        end
        pushInlineHeader(headerText)

        if not skipFixedHeader then
            local fixedHeaderText = profName .. " (" .. (L["HEADER_MATERIALS"] or "Materials") .. ")"
            if not hasProfession then
                fixedHeaderText = profName .. " " .. HDG.Theme.C_Dim() .. "(" .. notLearnedLabel .. ")|r"
            end
            UpdateProfessionSectionHeader(fixedHeaderText)
        end

        PushTierGroup(dp, tier100, L["RECIPE_TIER_READY"], HDG.Theme.C_Success())
        PushTierGroup(dp, tier75to99, L["RECIPE_TIER_ALMOST"], HDG.Theme.C_Success())
        PushTierGroup(dp, tier50to74, L["RECIPE_TIER_HALFWAY"], HDG.Theme.C_Warning())
        PushTierGroup(dp, tierBelow50, L["RECIPE_TIER_NEED_MATS"], HDG.Theme.C_Error())

        dp:Insert({ kind = "spacer", height = CONFIG.SPACING.PROFESSION_GAP or 8 })
        return
    end

    pushInlineHeader(profName)
    if not skipFixedHeader then
        UpdateProfessionSectionHeader(profName)
    end
    for _, recipe in ipairs(preFilteredItems) do
        PushRecipe(dp, recipe, false)
    end
    dp:Insert({ kind = "spacer", height = CONFIG.SPACING.PROFESSION_GAP or 8 })
end

-- ============================================================================
-- CREATE RECIPE PANEL (Left)
-- ============================================================================

-- Shared helper: Create a panel header bar with themed title
local function CreatePanelHeader(parent, title, opts)
    opts = opts or {}
    local scheme = GetScheme()
    local headerBg = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    headerBg:SetPoint("TOPLEFT", 1, -1)
    headerBg:SetPoint("TOPRIGHT", -1, -1)
    headerBg:SetHeight(opts.height or 22)
    headerBg:SetBackdrop({ bgFile = CONFIG.TEXTURES.WHITE_BG, tile = false })
    headerBg:SetBackdropColor(scheme.panel_header.r, scheme.panel_header.g, scheme.panel_header.b, scheme.panel_header.a)
    RegisterWidget(headerBg, "PanelHeader")

    local titleFS = headerBg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    if opts.center then
        titleFS:SetPoint("CENTER", 0, 0)
    else
        titleFS:SetPoint("LEFT", 8, 0)
        if opts.rightPad then titleFS:SetPoint("RIGHT", opts.rightPad, 0) end
        if opts.justify then titleFS:SetJustifyH(opts.justify) end
    end
    ApplyFont(titleFS, scheme, "body")
    titleFS:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, scheme.text_header.a)
    titleFS:SetText(title)
    HDG.Theme.StyleHeaderTitle(titleFS, headerBg)

    return headerBg, titleFS
end

-- CreateScrollPane helper retired: all three Recipes tab panels (recipe list,
-- crafting queue, materials list) now construct their own WowScrollBoxList
-- inline. No other file referenced this helper.

function HDG_RecipesTab:CreateRecipePanel()
    local scheme = GetScheme()

    -- Use factory for panel backdrop (reposition for 3-column layout)
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = false,
    })

    local recipeFrame = layout.frame
    recipeFrame:ClearAllPoints()
    recipeFrame:SetPoint("TOPLEFT", CONFIG.SPACING.PANEL_PADDING, -(CONFIG.BANNER_HEIGHT + CONFIG.TAB_HEIGHT + 8))
    recipeFrame:SetSize(CONFIG.LEFT_PANEL_WIDTH, CONFIG.PANEL_HEIGHT)
    mainFrame.recipeFrame = recipeFrame

    -- Header
    local recipeHeaderBg, recipeTitle = CreatePanelHeader(recipeFrame, L["RECIPE_PROFESSIONS"] or "Professions")
    mainFrame.recipeHeaderBg = recipeHeaderBg
    mainFrame.recipeTitle = recipeTitle

    -- Column header icons with tooltips for totals
    local COL_WIDTH = 60
    local HEADER_OFFSET = 7
    local COLLECTION_COL_LEFT = -6 - COL_WIDTH + HEADER_OFFSET
    local RECIPES_COL_LEFT = -68 - COL_WIDTH + HEADER_OFFSET

    -- Recipes column header (book icon + percentage)
    local recipesHeaderFrame = CreateFrame("Frame", nil, recipeHeaderBg)
    recipesHeaderFrame:SetSize(50, 20)
    recipesHeaderFrame:SetPoint("RIGHT", RECIPES_COL_LEFT + 50, 0)
    recipesHeaderFrame:EnableMouse(true)
    mainFrame.recipesHeaderFrame = recipesHeaderFrame

    local headerRecipesIcon = recipesHeaderFrame:CreateTexture(nil, "OVERLAY")
    headerRecipesIcon:SetSize(28, 28)
    headerRecipesIcon:SetPoint("LEFT", -4, 1)
    headerRecipesIcon:SetAtlas("UI-HUD-MicroMenu-SpellbookAbilities-Up")

    local headerRecipesPct = recipesHeaderFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    headerRecipesPct:SetPoint("LEFT", headerRecipesIcon, "RIGHT", 2, 0)
    ApplyFont(headerRecipesPct, scheme, "small")
    mainFrame.headerRecipesPct = headerRecipesPct

    -- Collection column header (crate icon + percentage)
    local collectionHeaderFrame = CreateFrame("Frame", nil, recipeHeaderBg)
    collectionHeaderFrame:SetSize(50, 20)
    collectionHeaderFrame:SetPoint("RIGHT", COLLECTION_COL_LEFT + 50, 0)
    collectionHeaderFrame:EnableMouse(true)
    mainFrame.collectionHeaderFrame = collectionHeaderFrame

    local headerCollectionIcon = collectionHeaderFrame:CreateTexture(nil, "OVERLAY")
    headerCollectionIcon:SetSize(28, 28)
    headerCollectionIcon:SetPoint("LEFT", -4, 0)
    headerCollectionIcon:SetAtlas("GarrMission_CurrencyIcon-Material")

    local headerCollectionPct = collectionHeaderFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    headerCollectionPct:SetPoint("LEFT", headerCollectionIcon, "RIGHT", 2, 0)
    ApplyFont(headerCollectionPct, scheme, "small")
    mainFrame.headerCollectionPct = headerCollectionPct

    -- Search box row (right below header)
    local searchRow = CreateFrame("Frame", nil, recipeFrame)
    searchRow:SetPoint("TOPLEFT", 8, -24)
    searchRow:SetPoint("TOPRIGHT", -8, -24)
    searchRow:SetHeight(20)

    -- Profession tabs container (pushed down by search row)
    local professionTabs = CreateFrame("Frame", nil, recipeFrame)
    professionTabs:SetPoint("TOPLEFT", 8, -46)
    professionTabs:SetPoint("TOPRIGHT", -8, -46)
    professionTabs:SetHeight(188)
    mainFrame.professionTabs = professionTabs

    -- Search box
    local searchBox = CreateFrame("EditBox", nil, searchRow, "BackdropTemplate")
    searchBox:SetPoint("LEFT", 0, 0)
    searchBox:SetSize(100, 20)
    HDG_UIHelpers.ApplyBackdrop(searchBox, "panel",
        {scheme.bg.r, scheme.bg.g, scheme.bg.b, 1},
        {scheme.border.r, scheme.border.g, scheme.border.b, 0.8})
    searchBox:SetAutoFocus(false)
    searchBox:SetTextInsets(6, 20, 0, 0)
    searchBox:SetMaxLetters(50)
    ApplyFont(searchBox, scheme, "small")

    -- Search placeholder text
    local searchPlaceholder = searchBox:CreateFontString(nil, "ARTWORK")
    searchPlaceholder:SetPoint("LEFT", 6, 0)
    ApplyFont(searchPlaceholder, scheme, "small")
    searchPlaceholder:SetText(L["RECIPE_SEARCH"])
    if scheme then
        searchPlaceholder:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, 0.6)
    end
    searchBox.placeholder = searchPlaceholder

    -- Clear button (X) for search
    local searchClear = CreateFrame("Button", nil, searchBox)
    searchClear:SetSize(14, 14)
    searchClear:SetPoint("RIGHT", -3, 0)
    searchClear:Hide()
    local searchClearTex = searchClear:CreateTexture(nil, "ARTWORK")
    searchClearTex:SetAllPoints()
    searchClearTex:SetTexture("Interface\\Buttons\\UI-StopButton")
    searchClear:SetScript("OnClick", function()
        searchBox:SetText("")
        searchBox:ClearFocus()
        HDG_Data.SetSearchFilter("")
        HDG_UI.RefreshUI()
    end)

    searchBox:SetScript("OnTextChanged", function(self, userInput)
        local text = self:GetText()
        if text and text ~= "" then
            searchPlaceholder:Hide()
            searchClear:Show()
        else
            searchPlaceholder:Show()
            searchClear:Hide()
        end
        if userInput then
            HDG_Data.SetSearchFilter(text)
            if searchDebounceTimer then searchDebounceTimer:Cancel() end
            searchDebounceTimer = C_Timer.NewTimer(0.3, function()
                searchDebounceTimer = nil
                HDG_UI.RefreshUI()
            end)
        end
    end)
    searchBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    searchBox:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
    end)
    mainFrame.recipeSearchBox = searchBox
    RegisterWidget(searchBox, "SearchBox")

    -- Uncollected checkbox
    local uncollectedCheck = CreateFrame("CheckButton", nil, searchRow, "UICheckButtonTemplate")
    uncollectedCheck:SetPoint("LEFT", searchBox, "RIGHT", 4, 0)
    uncollectedCheck:SetSize(20, 20)
    uncollectedCheck:SetChecked(HDG_Data.IsUncollectedFilterEnabled())

    local uncollectedLabel = searchRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    uncollectedLabel:SetPoint("LEFT", uncollectedCheck, "RIGHT", 0, 0)
    uncollectedLabel:SetText(HDG.Theme.C_Text() .. "Uncollected" .. "|r") -- English only (layout policy)
    ApplyFont(uncollectedLabel, scheme, "small")

    uncollectedCheck:SetScript("OnClick", function(self)
        HDG_Data.SetUncollectedFilter(self:GetChecked())
        HDG_UI.RefreshUI()
    end)
    uncollectedCheck:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_UNCOLLECTED_TITLE"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_UNCOLLECTED_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    uncollectedCheck:SetScript("OnLeave", function() GameTooltip:Hide() end)
    mainFrame.uncollectedCheck = uncollectedCheck

    -- Search reagents checkbox
    local searchReagentsCheck = CreateFrame("CheckButton", nil, searchRow, "UICheckButtonTemplate")
    searchReagentsCheck:SetPoint("LEFT", uncollectedLabel, "RIGHT", 4, 0)
    searchReagentsCheck:SetSize(20, 20)
    searchReagentsCheck:SetChecked(HDG_Data.IsSearchReagentsEnabled())

    local searchReagentsLabel = searchRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    searchReagentsLabel:SetPoint("LEFT", searchReagentsCheck, "RIGHT", 0, 0)
    searchReagentsLabel:SetText(HDG.Theme.C_Text() .. (L["CHECK_INCLUDE_MATS"] or "+Mats") .. "|r")
    ApplyFont(searchReagentsLabel, scheme, "small")

    searchReagentsCheck:SetScript("OnClick", function(self)
        HDG_Data.SetSearchReagents(self:GetChecked())
        HDG_UI.RefreshUI()
    end)
    searchReagentsCheck:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_SEARCH_MATS"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_SEARCH_MATS_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_SEARCH_MATS_EX"], 0.78, 0.78, 0.78)
        GameTooltip:Show()
    end)
    searchReagentsCheck:SetScript("OnLeave", function() GameTooltip:Hide() end)
    mainFrame.searchReagentsCheck = searchReagentsCheck

    -- Sticky header (offset by search row height)
    local stickyHeader = CreateFrame("Frame", nil, recipeFrame, "BackdropTemplate")
    stickyHeader:SetPoint("TOPLEFT", 8, -258)
    stickyHeader:SetPoint("TOPRIGHT", -26, -258)
    stickyHeader:SetHeight(18)
    HDG_UIHelpers.ApplyBackdrop(stickyHeader, "solid", {scheme.panel.r, scheme.panel.g, scheme.panel.b, 1.0})
    RegisterWidget(stickyHeader, "Panel")
    stickyHeader:Hide()
    mainFrame.stickyHeader = stickyHeader

    local stickyHeaderText = stickyHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    stickyHeaderText:SetPoint("LEFT", 6, 0)
    ApplyFont(stickyHeaderText, scheme, "small")
    stickyHeaderText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
    mainFrame.stickyHeaderText = stickyHeaderText
    RegisterWidget(stickyHeaderText, "Text")

    local stickyHeaderPercentage = stickyHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    stickyHeaderPercentage:SetPoint("RIGHT", -6, 0)
    ApplyFont(stickyHeaderPercentage, scheme, "small")
    stickyHeaderPercentage:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
    mainFrame.stickyHeaderPercentage = stickyHeaderPercentage
    RegisterWidget(stickyHeaderPercentage, "Text")

    -- Profession section header (offset by search row height)
    local profSectionHeader = CreateFrame("Frame", nil, recipeFrame, "BackdropTemplate")
    profSectionHeader:SetPoint("TOPLEFT", 1, -284)
    profSectionHeader:SetPoint("TOPRIGHT", -1, -284)
    profSectionHeader:SetHeight(22)
    HDG_UIHelpers.ApplyBackdrop(profSectionHeader, "panel",
        {scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.8},
        {scheme.border.r, scheme.border.g, scheme.border.b, scheme.border.a})
    profSectionHeader:Hide()
    mainFrame.profSectionHeader = profSectionHeader
    RegisterWidget(profSectionHeader, "Panel")

    local profSectionTextFrame = CreateFrame("Frame", nil, profSectionHeader)
    profSectionTextFrame:SetPoint("LEFT", 8, 0)
    profSectionTextFrame:SetSize(150, 20)

    local profSectionText = profSectionTextFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    profSectionText:SetPoint("LEFT", 0, 0)
    ApplyFont(profSectionText, scheme, "body")
    profSectionText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
    mainFrame.profSectionText = profSectionText
    RegisterWidget(profSectionText, "Text")

    profSectionTextFrame:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_PROF_PROGRESS"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_PROF_PROGRESS_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. L["RECIPE_TIP_WORKFLOW"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_WORKFLOW_1"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_WORKFLOW_2"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_WORKFLOW_3"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    profSectionTextFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- All / Known / Ready toggle (tri-state exclusive; "all" = no filter)
    local btnWidth = 46
    local toggleContainer = CreateFrame("Frame", nil, profSectionHeader)
    toggleContainer:SetPoint("TOPRIGHT", 0, 0)
    toggleContainer:SetPoint("BOTTOMRIGHT", 0, 0)
    toggleContainer:SetWidth(btnWidth * 3)

    local allBtn = CreateFrame("Button", nil, toggleContainer, "BackdropTemplate")
    allBtn:SetPoint("TOPLEFT", 0, 0)
    allBtn:SetPoint("BOTTOMLEFT", 0, 0)
    allBtn:SetWidth(btnWidth)
    allBtn:SetBackdrop({ bgFile = CONFIG.TEXTURES.WHITE_BG, tile = false })

    local allBtnText = allBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    allBtnText:SetPoint("CENTER", 0, 0)
    ApplyFont(allBtnText, scheme, "small")
    allBtnText:SetText(L["BTN_ALL"] or "All")

    local knownBtn = CreateFrame("Button", nil, toggleContainer, "BackdropTemplate")
    knownBtn:SetPoint("TOPLEFT", allBtn, "TOPRIGHT", 0, 0)
    knownBtn:SetPoint("BOTTOMLEFT", allBtn, "BOTTOMRIGHT", 0, 0)
    knownBtn:SetWidth(btnWidth)
    knownBtn:SetBackdrop({ bgFile = CONFIG.TEXTURES.WHITE_BG, tile = false })

    local knownBtnText = knownBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    knownBtnText:SetPoint("CENTER", 0, 0)
    ApplyFont(knownBtnText, scheme, "small")
    knownBtnText:SetText(L["BTN_KNOWN"] or "Known")

    local knownBtnLeftBorder = knownBtn:CreateTexture(nil, "OVERLAY")
    knownBtnLeftBorder:SetTexture(CONFIG.TEXTURES.WHITE_BG)
    knownBtnLeftBorder:SetVertexColor(unpack(HDG_UIHelpers.BORDER_COLOR))
    knownBtnLeftBorder:SetPoint("TOPLEFT", 0, 0)
    knownBtnLeftBorder:SetPoint("BOTTOMLEFT", 0, 0)
    knownBtnLeftBorder:SetWidth(1)
    knownBtnLeftBorder:Hide()

    local readyBtn = CreateFrame("Button", nil, toggleContainer, "BackdropTemplate")
    readyBtn:SetPoint("TOPRIGHT", 0, 0)
    readyBtn:SetPoint("BOTTOMRIGHT", 0, 0)
    readyBtn:SetWidth(btnWidth)
    readyBtn:SetBackdrop({ bgFile = CONFIG.TEXTURES.WHITE_BG, tile = false })

    local readyBtnText = readyBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    readyBtnText:SetPoint("CENTER", 0, 0)
    ApplyFont(readyBtnText, scheme, "small")
    readyBtnText:SetText(L["BTN_READY"] or "Ready")

    local readyBtnLeftBorder = readyBtn:CreateTexture(nil, "OVERLAY")
    readyBtnLeftBorder:SetTexture(CONFIG.TEXTURES.WHITE_BG)
    readyBtnLeftBorder:SetVertexColor(unpack(HDG_UIHelpers.BORDER_COLOR))
    readyBtnLeftBorder:SetPoint("TOPLEFT", 0, 0)
    readyBtnLeftBorder:SetPoint("BOTTOMLEFT", 0, 0)
    readyBtnLeftBorder:SetWidth(1)
    readyBtnLeftBorder:Hide()

    local function CurrentToggleMode()
        if HDG_Data.IsCraftableFilterEnabled() then return "ready" end
        if HDG_Data.IsKnownFilterEnabled() then return "known" end
        return "all"
    end

    local function UpdateCraftableToggle()
        local s = GetScheme()
        local mode = CurrentToggleMode()
        local function applyState(btn, text, isActive)
            btn:SetBackdropColor(
                isActive and s.button_active.r or 0.05,
                isActive and s.button_active.g or 0.05,
                isActive and s.button_active.b or 0.05,
                isActive and 0.9 or 0
            )
            text:SetTextColor(
                isActive and s.text.r or s.text_dim.r,
                isActive and s.text.g or s.text_dim.g,
                isActive and s.text.b or s.text_dim.b,
                isActive and s.text.a or s.text_dim.a
            )
        end
        applyState(allBtn, allBtnText, mode == "all")
        applyState(knownBtn, knownBtnText, mode == "known")
        applyState(readyBtn, readyBtnText, mode == "ready")
        -- Dividers visible only between inactive buttons (match prior "show border when neighbor active" behavior)
        knownBtnLeftBorder:SetShown(mode ~= "all" and mode ~= "known")
        readyBtnLeftBorder:SetShown(mode ~= "known" and mode ~= "ready")
    end

    allBtn:SetScript("OnClick", function()
        if CurrentToggleMode() ~= "all" then
            HDG_Data.SetCraftableFilter(false)
            HDG_Data.SetKnownFilter(false)
            UpdateCraftableToggle()
            HDG_UI.RefreshUI()
        end
    end)

    knownBtn:SetScript("OnClick", function()
        if CurrentToggleMode() ~= "known" then
            HDG_Data.SetCraftableFilter(false)
            HDG_Data.SetKnownFilter(true)
            UpdateCraftableToggle()
            HDG_UI.RefreshUI()
        end
    end)

    readyBtn:SetScript("OnClick", function()
        if CurrentToggleMode() ~= "ready" then
            HDG_Data.SetKnownFilter(false)
            HDG_Data.SetCraftableFilter(true)
            UpdateCraftableToggle()
            HDG_UI.RefreshUI()
        end
    end)

    allBtn:SetScript("OnEnter", function(self)
        if CurrentToggleMode() ~= "all" then
            local s = GetScheme()
            self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.9)
        end
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_SHOW_ALL"] .. "|r", 1, 1, 1)
        GameTooltip:Show()
    end)
    allBtn:SetScript("OnLeave", function() UpdateCraftableToggle(); GameTooltip:Hide() end)

    knownBtn:SetScript("OnEnter", function(self)
        if CurrentToggleMode() ~= "known" then
            local s = GetScheme()
            self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.9)
        end
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_SHOW_KNOWN"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_FILTER_KNOWN"], 1, 1, 1)
        GameTooltip:Show()
    end)
    knownBtn:SetScript("OnLeave", function() UpdateCraftableToggle(); GameTooltip:Hide() end)

    readyBtn:SetScript("OnEnter", function(self)
        if CurrentToggleMode() ~= "ready" then
            local s = GetScheme()
            self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.9)
        end
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_SHOW_READY"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_FILTER_MATS"], 1, 1, 1)
        GameTooltip:Show()
    end)
    readyBtn:SetScript("OnLeave", function() UpdateCraftableToggle(); GameTooltip:Hide() end)

    mainFrame.UpdateCraftableButton = UpdateCraftableToggle
    UpdateCraftableToggle()

    -- Scroll frame
    -- Recipe list scroll: modern WowScrollBoxList + DataProvider. Queue + materials
    -- panels still use CreateScrollPane (one migration at a time).
    local scrollBox = CreateFrame("Frame", nil, recipeFrame, "WowScrollBoxList")
    scrollBox:SetPoint("TOPLEFT", 8, -310)
    scrollBox:SetPoint("BOTTOMRIGHT", -20, 8)

    local scrollBar = CreateFrame("EventFrame", nil, recipeFrame, "MinimalScrollBar")
    scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 2, 0)
    scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 2, 0)

    local view = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    view:SetElementExtentCalculator(GetRecipeElementExtent)
    view:SetElementInitializer("Button", function(frame, elementData)
        InitializeRecipeListRow(frame)
        frame:SetWidth(scrollBox:GetWidth())
        BindRecipeListRow(frame, elementData)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)

    mainFrame.recipeScrollBox = scrollBox
    mainFrame.recipeScrollBar = scrollBar
    mainFrame.recipeScrollView = view

    return recipeFrame
end

-- ============================================================================
-- CREATE QUEUE PANEL (Middle - Crafting Queue)
-- ============================================================================

function HDG_RecipesTab:CreateQueuePanel()
    local scheme = GetScheme()

    -- Use factory for panel backdrop (reposition for 3-column layout)
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = false,
    })

    local queueFrame = layout.frame
    queueFrame:ClearAllPoints()
    queueFrame:SetPoint("TOPLEFT", mainFrame.recipeFrame, "TOPRIGHT", CONFIG.SPACING.PANEL_GAP, 0)
    queueFrame:SetSize(CONFIG.MIDDLE_PANEL_WIDTH, CONFIG.PANEL_HEIGHT)
    mainFrame.queueFrame = queueFrame

    -- Header
    local craftingQueueLabel = L["HEADER_CRAFTING_QUEUE"] or "Crafting Queue"
    local queueHeaderBg, queueTitle = CreatePanelHeader(queueFrame, craftingQueueLabel, { center = true })
    mainFrame.queueHeaderBg = queueHeaderBg
    mainFrame.queueTitle = queueTitle
    mainFrame.craftingQueueLabel = craftingQueueLabel

    queueHeaderBg:EnableMouse(true)
    queueHeaderBg:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. "" .. craftingQueueLabel .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_QUEUE_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. L["RECIPE_TIP_QUEUE_HOW"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_QUEUE_ADD"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_QUEUE_REMOVE"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    queueHeaderBg:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Clear button
    local clearBtn = CreateFrame("Button", nil, queueFrame)
    clearBtn:SetSize(20, 20)
    clearBtn:SetPoint("TOPRIGHT", -4, -2)
    clearBtn:SetFrameLevel(queueFrame:GetFrameLevel() + 10)
    clearBtn:RegisterForClicks("AnyUp")
    local clearIcon = clearBtn:CreateTexture(nil, "ARTWORK")
    clearIcon:SetAllPoints()
    clearIcon:SetAtlas("common-icon-delete")
    clearBtn:SetScript("OnClick", function()
        Dispatch("CLEAR_CRAFTING_QUEUE", {})
        HDG_UI.RefreshUI()
    end)
    clearBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. "" .. (L["BTN_CLEAR"] or "Clear") .. " " .. craftingQueueLabel .. "|r", 1, 1, 1)
        GameTooltip:AddLine("Remove all items from crafting queue", 1, 1, 1)
        GameTooltip:Show()
    end)
    clearBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Send to PSL button (Profession Shopping List integration)
    local pslBtn = CreateFrame("Button", nil, queueFrame)
    pslBtn:SetSize(20, 20)
    pslBtn:SetPoint("TOPRIGHT", -28, -2)
    pslBtn:SetFrameLevel(queueFrame:GetFrameLevel() + 10)
    pslBtn:RegisterForClicks("AnyUp")
    local pslIcon = pslBtn:CreateTexture(nil, "ARTWORK")
    pslIcon:SetAllPoints()
    pslIcon:SetAtlas("Professions-Icon-Queue")
    pslBtn:SetScript("OnClick", function()
        local list = GetCraftingQueue()
        local sent, skipped = 0, 0
        for _, item in ipairs(list) do
            if item.spell then
                SlashCmdList["PROFESSIONSHOPPINGLIST"]("track " .. item.spell .. " " .. (item.quantity or 1))
                sent = sent + 1
            else
                skipped = skipped + 1
            end
        end
        local msg = HDG.Theme.C_Accent() .. "[HDG]|r Sent " .. sent .. " recipe(s) to Profession Shopping List"
        if skipped > 0 then msg = msg .. " (" .. skipped .. " skipped)" end
        print(msg)
    end)
    pslBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. L["RECIPE_TIP_SEND_PSL"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_SEND_PSL_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    pslBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    pslBtn:SetShown(IsPSLAvailable() and #GetCraftingQueue() > 0)
    mainFrame.pslBtn = pslBtn

    -- Queue list scroll: modern WowScrollBoxList + DataProvider. Bottom adjusts
    -- based on lumber frame visibility (see HDG_UI.UpdateLumberLayout which
    -- repositions mainFrame.queueScrollBox by BOTTOMRIGHT SetPoint).
    local lumberReservedHeight = 24 + (#HDG_ExpansionData.LUMBER_ORDER * 18) + 8 + 8

    local queueScrollBox = CreateFrame("Frame", nil, queueFrame, "WowScrollBoxList")
    queueScrollBox:SetPoint("TOPLEFT", 8, -28)
    queueScrollBox:SetPoint("BOTTOMRIGHT", -20, lumberReservedHeight)

    local queueScrollBar = CreateFrame("EventFrame", nil, queueFrame, "MinimalScrollBar")
    queueScrollBar:SetPoint("TOPLEFT", queueScrollBox, "TOPRIGHT", 2, 0)
    queueScrollBar:SetPoint("BOTTOMLEFT", queueScrollBox, "BOTTOMRIGHT", 2, 0)

    local queueView = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    queueView:SetElementExtentCalculator(GetQueueElementExtent)
    queueView:SetElementInitializer("Button", function(frame, elementData)
        InitializeQueueFrame(frame)
        frame:SetWidth(queueScrollBox:GetWidth())
        BindQueueFrame(frame, elementData)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(queueScrollBox, queueScrollBar, queueView)

    mainFrame.queueScrollBox = queueScrollBox
    mainFrame.queueScrollBar = queueScrollBar
    mainFrame.queueScrollView = queueView

    -- Embedded Lumber Frame (shown when NOT pinned)
    local lumberFrame = CreateFrame("Frame", nil, queueFrame, "BackdropTemplate")
    lumberFrame:SetPoint("BOTTOMLEFT", 8, 8)
    lumberFrame:SetPoint("BOTTOMRIGHT", -8, 8)
    -- Dynamic height: header (24) + rows (lumber count * 18) + padding (8)
    local lumberCount = #HDG_ExpansionData.LUMBER_ORDER
    local embeddedLumberHeight = 24 + (lumberCount * 18) + 8
    lumberFrame:SetHeight(embeddedLumberHeight)
    HDG_UIHelpers.ApplyBackdrop(lumberFrame, "panel",
        {scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.8},
        {scheme.border.r, scheme.border.g, scheme.border.b, scheme.border.a})
    RegisterWidget(lumberFrame, "Panel")
    mainFrame.lumberFrame = lumberFrame

    -- Pin button in lumber frame header (top-right) — expand atlas (click to pin/expand out)
    local pinBtn = CreateFrame("Button", nil, lumberFrame)
    pinBtn:SetSize(20, 20)
    pinBtn:SetPoint("TOPRIGHT", -4, -4)

    local pinNormal = pinBtn:CreateTexture(nil, "ARTWORK")
    pinNormal:SetAllPoints()
    pinNormal:SetAtlas("RedButton-Expand")
    local pinPushed = pinBtn:CreateTexture(nil, "ARTWORK")
    pinPushed:SetAllPoints()
    pinPushed:SetAtlas("RedButton-Expand-Pressed")
    local pinHighlight = pinBtn:CreateTexture(nil, "HIGHLIGHT")
    pinHighlight:SetAllPoints()
    pinHighlight:SetAtlas("RedButton-Expand")
    pinHighlight:SetAlpha(0.3)
    pinBtn:SetNormalTexture(pinNormal)
    pinBtn:SetPushedTexture(pinPushed)
    pinBtn:SetHighlightTexture(pinHighlight)

    pinBtn:SetScript("OnClick", function()
        Dispatch("SET_LUMBER_PINNED", { pinned = true })
        if HDG_LumberTracker then
            HDG_LumberTracker.ShowPinned()
        end
        if HDG_UI and HDG_UI.UpdateLumberLayout then
            HDG_UI.UpdateLumberLayout()
        end
        if HDG_ConfigTab and HDG_ConfigTab.UpdateLumberPinCheckbox then
            HDG_ConfigTab.UpdateLumberPinCheckbox()
        end
        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r " .. L["RECIPE_MSG_LUMBER_PINNED"])
    end)

    pinBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. L["RECIPE_TIP_PIN_LUMBER"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_PIN_LUMBER_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_PIN_LUMBER_NOTE"], 0.5, 0.8, 0.5)
        GameTooltip:Show()
    end)

    pinBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    lumberFrame.pinBtn = pinBtn

    -- Lumber headers (smaller font) - localized
    local lumberHeader = lumberFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    lumberHeader:SetPoint("TOP", 0, -6)
    lumberHeader:SetPoint("LEFT", 4, 0)
    lumberHeader:SetWidth(CONFIG.LUMBER_COL_1)
    lumberHeader:SetJustifyH("LEFT")
    lumberHeader:SetText(HDG.Theme.C_Text() .. (L["COL_TYPE"] or "Type") .. "|r")
    ApplyFont(lumberHeader, scheme, "small")

    local queueHeader = lumberFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    queueHeader:SetPoint("TOP", 0, -6)
    queueHeader:SetPoint("LEFT", 4 + CONFIG.LUMBER_COL_1, 0)
    queueHeader:SetWidth(CONFIG.LUMBER_COL_2)
    queueHeader:SetJustifyH("CENTER")
    queueHeader:SetText(HDG.Theme.C_Text() .. (L["COL_NEEDED"] or "Queue") .. "|r")
    ApplyFont(queueHeader, scheme, "small")

    local allHeader = lumberFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    allHeader:SetPoint("TOP", 0, -6)
    allHeader:SetPoint("LEFT", 4 + CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2, 0)
    allHeader:SetWidth(CONFIG.LUMBER_COL_3)
    allHeader:SetJustifyH("CENTER")
    allHeader:SetText(HDG.Theme.C_Text() .. (L["COL_IN_STOCK"] or "Uncollected") .. "|r")
    ApplyFont(allHeader, scheme, "small")

    local lumberContent = CreateFrame("Frame", nil, lumberFrame)
    lumberContent:SetPoint("TOPLEFT", 4, -24)
    lumberContent:SetPoint("BOTTOMRIGHT", -4, 4)
    mainFrame.lumberContent = lumberContent

    return queueFrame
end

-- ============================================================================
-- CREATE MATERIALS PANEL (Right)
-- ============================================================================

function HDG_RecipesTab:CreateMaterialsPanel()
    local scheme = GetScheme()

    -- Use factory for panel backdrop (reposition for 3-column layout)
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = false,
    })

    local materialsFrame = layout.frame
    materialsFrame:ClearAllPoints()
    materialsFrame:SetPoint("TOPLEFT", mainFrame.queueFrame, "TOPRIGHT", CONFIG.SPACING.PANEL_GAP, 0)
    materialsFrame:SetSize(CONFIG.RIGHT_PANEL_WIDTH, CONFIG.PANEL_HEIGHT)
    mainFrame.materialsFrame = materialsFrame

    -- Header
    local requiredMatsLabel = L["HEADER_REQUIRED_MATERIALS"] or "Required Materials"
    local rawMatsLabel = L["HEADER_RAW_MATERIALS"] or "Raw Materials"
    local directLabel = L["BTN_DIRECT"] or "Direct"
    local rawLabel = L["BTN_RAW"] or "Raw"

    local materialsHeaderBg, materialsTitle = CreatePanelHeader(materialsFrame, requiredMatsLabel, { rightPad = -170, justify = "LEFT" })
    mainFrame.materialsHeaderBg = materialsHeaderBg
    mainFrame.materialsTitle = materialsTitle

    -- Sort Mode toggle button (Totals | By Recipe)
    local sortModeBtn = CreateFrame("Button", nil, materialsHeaderBg, "BackdropTemplate")
    sortModeBtn:SetSize(65, 18)
    sortModeBtn:SetPoint("RIGHT", -96, 0) -- Position left of toggleContainer
    sortModeBtn:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        edgeSize = 1,
        tile = false
    })
    sortModeBtn:SetBackdropColor(scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.8)
    sortModeBtn:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, scheme.border.a)
    RegisterWidget(sortModeBtn, "Button")

    local sortModeText = sortModeBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    sortModeText:SetPoint("CENTER", 0, 0)
    ApplyFont(sortModeText, scheme, "small")
    mainFrame.sortModeBtn = sortModeBtn
    mainFrame.sortModeText = sortModeText

    local function UpdateSortModeAppearance()
        local isByRecipe = (GetConfig("materialSortMode") or "totals") == "byRecipe"
        local s = GetScheme()
        local bg = isByRecipe and s.button_hover or s.panel
        local border = isByRecipe and s.accent or s.border
        local text = isByRecipe and s.text or s.text_dim
        sortModeBtn:SetBackdropColor(bg.r, bg.g, bg.b, isByRecipe and bg.a or 0.8)
        sortModeBtn:SetBackdropBorderColor(border.r, border.g, border.b, isByRecipe and 0.8 or border.a)
        sortModeText:SetText(isByRecipe and L["RECIPE_SORT_BY_RECIPE"] or L["RECIPE_SORT_TOTALS"])
        sortModeText:SetTextColor(text.r, text.g, text.b, text.a)
    end

    local function ShowSortModeTooltip(btn)
        local currentMode = GetConfig("materialSortMode") or "totals"
        GameTooltip:SetOwner(btn, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:ClearLines()
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_SORT_MODE"] .. "|r", 1, 1, 1)
        if currentMode == "totals" then
            GameTooltip:AddLine(L["RECIPE_TIP_SORT_TOTALS_DESC"], 0.7, 0.7, 0.7)
            GameTooltip:AddLine(L["RECIPE_TIP_SORT_SWITCH_RECIPE"], 0.5, 0.8, 0.5)
        else
            GameTooltip:AddLine(L["RECIPE_TIP_SORT_RECIPE_DESC"], 0.7, 0.7, 0.7)
            GameTooltip:AddLine(L["RECIPE_TIP_SORT_SWITCH_TOTALS"], 0.5, 0.8, 0.5)
        end
        GameTooltip:Show()
    end

    sortModeBtn:SetScript("OnClick", function(self)
        local currentMode = GetConfig("materialSortMode") or "totals"
        local newMode = currentMode == "totals" and "byRecipe" or "totals"
        Dispatch("SET_CONFIG", { key = "materialSortMode", value = newMode })
        UpdateSortModeAppearance()
        HDG_RecipesTab:RefreshMaterials()
        -- Update tooltip if still hovering
        if GameTooltip:IsOwned(self) then
            ShowSortModeTooltip(self)
        end
    end)

    sortModeBtn:SetScript("OnEnter", function(self)
        ShowSortModeTooltip(self)
    end)
    sortModeBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    mainFrame.sortModeUpdate = UpdateSortModeAppearance
    UpdateSortModeAppearance()

    -- Power Crafting toggle button (Direct | Raw)
    local toggleContainer = CreateFrame("Frame", nil, materialsHeaderBg, "BackdropTemplate")
    toggleContainer:SetSize(84, 18)
    toggleContainer:SetPoint("RIGHT", -6, 0)
    toggleContainer:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        edgeSize = 1,
        tile = false
    })
    toggleContainer:SetBackdropColor(scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.8)
    toggleContainer:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, scheme.border.a)
    RegisterWidget(toggleContainer, "Panel")

    local directBtn = CreateFrame("Button", nil, toggleContainer, "BackdropTemplate")
    directBtn:SetSize(40, 16)
    directBtn:SetPoint("LEFT", 1, 0)
    directBtn:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        edgeSize = 1,
        tile = false
    })
    directBtn:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, 0.5)

    local directText = directBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    directText:SetPoint("CENTER", 0, 0)
    ApplyFont(directText, scheme, "small")
    directText:SetText(directLabel)

    local rawBtn = CreateFrame("Button", nil, toggleContainer, "BackdropTemplate")
    rawBtn:SetSize(40, 16)
    rawBtn:SetPoint("LEFT", directBtn, "RIGHT", 1, 0)
    rawBtn:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        edgeSize = 1,
        tile = false
    })
    rawBtn:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, 0.5)

    local rawText = rawBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    rawText:SetPoint("CENTER", 0, 0)
    ApplyFont(rawText, scheme, "small")
    rawText:SetText(rawLabel)

    -- Update toggle button appearance based on state
    local function UpdateToggleAppearance()
        local showRaw = GetConfig("showRawMaterials")
        local s = GetScheme()
        local activeBtn, inactiveBtn = showRaw and rawBtn or directBtn, showRaw and directBtn or rawBtn
        local activeText, inactiveText = showRaw and rawText or directText, showRaw and directText or rawText
        activeBtn:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, s.button_hover.a)
        activeBtn:SetBackdropBorderColor(s.accent.r, s.accent.g, s.accent.b, 0.8)
        inactiveBtn:SetBackdropColor(s.panel.r, s.panel.g, s.panel.b, 0.8)
        inactiveBtn:SetBackdropBorderColor(s.border.r, s.border.g, s.border.b, 0.5)
        activeText:SetTextColor(s.text.r, s.text.g, s.text.b, s.text.a)
        inactiveText:SetTextColor(s.text_dim.r, s.text_dim.g, s.text_dim.b, s.text_dim.a)
        materialsTitle:SetText(showRaw and rawMatsLabel or requiredMatsLabel)
    end

    directBtn:SetScript("OnClick", function()
        Dispatch("SET_CONFIG", { key = "showRawMaterials", value = false })
        UpdateToggleAppearance()
        HDG_RecipesTab:RefreshMaterials()
        -- Also update pinned lumber widget if visible
        if HDG_LumberTracker and HDG_LumberTracker.IsPinnedVisible and HDG_LumberTracker.IsPinnedVisible() then
            HDG_LumberTracker.RefreshPinned()
        end
    end)

    rawBtn:SetScript("OnClick", function()
        Dispatch("SET_CONFIG", { key = "showRawMaterials", value = true })
        UpdateToggleAppearance()
        HDG_RecipesTab:RefreshMaterials()
        -- Also update pinned lumber widget if visible
        if HDG_LumberTracker and HDG_LumberTracker.IsPinnedVisible and HDG_LumberTracker.IsPinnedVisible() then
            HDG_LumberTracker.RefreshPinned()
        end
    end)

    directBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_DIRECT_MATS"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_DIRECT_MATS_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_DIRECT_AS_IS"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    directBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    rawBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. L["RECIPE_TIP_RAW_MATS"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(L["RECIPE_TIP_RAW_MATS_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_RAW_GATHER"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    rawBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    mainFrame.materialsToggleUpdate = UpdateToggleAppearance
    HDG_RecipesTab.materialsToggleUpdate = UpdateToggleAppearance
    UpdateToggleAppearance()

    materialsHeaderBg:EnableMouse(true)
    materialsHeaderBg:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:AddLine(HDG.Theme.C_Text() .. "Required Materials|r", 1, 1, 1)
        GameTooltip:AddLine("All materials for your Crafting Queue", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. "Direct vs Raw:|r", 1, 1, 1)
        GameTooltip:AddLine("Direct: Materials as listed in recipes", 0.7, 0.7, 0.7)
        GameTooltip:AddLine("Raw: Expand crafted reagents to base materials", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. "How to use:|r", 1, 1, 1)
        GameTooltip:AddLine("Right-click materials to search Auction House", 0.7, 0.7, 0.7)
        GameTooltip:AddLine("Use 'Add All to Auctionator' for bulk search", 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    materialsHeaderBg:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Materials list scroll: modern WowScrollBoxList + DataProvider. Last
    -- tab-level consumer of the old CreateScrollPane/UIPanelScrollFrameTemplate
    -- pattern in this file; CreateScrollPane helper has been retired with
    -- this migration.
    local matScrollBox = CreateFrame("Frame", nil, materialsFrame, "WowScrollBoxList")
    matScrollBox:SetPoint("TOPLEFT", 8, -28)
    matScrollBox:SetPoint("BOTTOMRIGHT", -20, 68)

    local matScrollBar = CreateFrame("EventFrame", nil, materialsFrame, "MinimalScrollBar")
    matScrollBar:SetPoint("TOPLEFT", matScrollBox, "TOPRIGHT", 2, 0)
    matScrollBar:SetPoint("BOTTOMLEFT", matScrollBox, "BOTTOMRIGHT", 2, 0)

    local matView = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    matView:SetElementExtentCalculator(GetMaterialsElementExtent)
    matView:SetElementInitializer("Button", function(frame, elementData)
        InitializeMaterialsFrame(frame)
        frame:SetWidth(matScrollBox:GetWidth())
        BindMaterialsFrame(frame, elementData)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(matScrollBox, matScrollBar, matView)

    mainFrame.materialsScrollBox = matScrollBox
    mainFrame.materialsScrollBar = matScrollBar
    mainFrame.materialsScrollView = matView

    -- Cost display
    local costText = materialsFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    costText:SetPoint("BOTTOM", 0, 10)
    ApplyFont(costText, scheme, "body")
    costText:SetText(HDG.Theme.C_Dim() .. "No price data|r")
    mainFrame.materialsCostText = costText

    -- Add All to Auctionator button
    local addAllBar = CreateFrame("StatusBar", nil, materialsFrame, "BackdropTemplate")
    addAllBar:SetSize(280, 25)
    addAllBar:SetPoint("BOTTOM", costText, "TOP", 0, 5)
    addAllBar:SetStatusBarTexture(CONFIG.TEXTURES.STATUSBAR)
    addAllBar:SetStatusBarColor(unpack(HDG_UIHelpers.STATE_ACTIVE))
    addAllBar:SetMinMaxValues(0, 1)
    addAllBar:SetValue(1)
    HDG_UIHelpers.ApplyBackdrop(addAllBar, "solid",
        {scheme.button_active.r * 0.3, scheme.button_active.g * 0.3, scheme.button_active.b * 0.3, 0.3})

    local addAllLabel = L["BTN_ADD_ALL_AUCTIONATOR"] or "Add All to Auctionator"
    local buttonText = addAllBar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    buttonText:SetPoint("CENTER", 0, 0)
    ApplyFont(buttonText, scheme, "body")
    buttonText:SetText(addAllLabel)
    buttonText:SetShadowOffset(0, 0)
    if scheme then
        buttonText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
    end
    mainFrame.addAllAuctionatorText = buttonText -- Store reference for updating

    local clickButton = CreateFrame("Button", nil, addAllBar)
    clickButton:SetAllPoints(addAllBar)
    clickButton:RegisterForClicks("LeftButtonUp")
    clickButton:SetScript("OnClick", function()
        HDG_RecipesTab:AddAllMaterialsToAuctionator()
    end)
    clickButton:SetScript("OnEnter", function(self)
        addAllBar:SetStatusBarColor(0.15, 0.40, 0.40, 1.0) -- Brighter hover
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:SetText(HDG.Theme.C_Warning() .. addAllLabel .. "|r", 1, 1, 1)
        GameTooltip:AddLine("Creates/replaces Auctionator 'Housing Decor' list", 1, 1, 1)
        GameTooltip:AddLine("with materials needed (minus what you own)", 1, 1, 1)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. "Click|r to add all materials at once", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(HDG.Theme.C_Dim() .. "Note: This will replace the existing list|r", 0.78, 0.78, 0.78)
        GameTooltip:Show()
    end)
    clickButton:SetScript("OnLeave", function()
        addAllBar:SetStatusBarColor(unpack(HDG_UIHelpers.STATE_ACTIVE))
        GameTooltip:Hide()
    end)

    mainFrame.addAllAuctionatorButton = addAllBar
    if not HDG_PriceIntegration.IsAuctionatorAvailable() then
        addAllBar:Hide()
    end
    return materialsFrame
end

-- ============================================================================
-- CREATE ALL PANELS
-- ============================================================================

function HDG_RecipesTab:Create()
    self:CreateRecipePanel()
    self:CreateQueuePanel()
    self:CreateMaterialsPanel()
end

-- ============================================================================
-- CRAFTING QUEUE FUNCTIONS
-- ============================================================================

function HDG_RecipesTab:AddToCraftingQueue(recipe, skipRefresh)
    Dispatch("ADD_TO_CRAFTING_QUEUE", {
        item = {
            id = recipe.id,
            name = recipe.name,
            spell = recipe.spell,
            reagents = recipe.reagents,
            expansion = recipe.expansion or "Unknown",
            profession = recipe.profession,
            quantity = 1
        }
    })
    if not skipRefresh then
        HDG_UI.RefreshUI()
    end
end

-- ============================================================================
-- ROW POOL HELPERS (avoids frame churn on BAG_UPDATE every 0.3s)
-- ============================================================================

-- (queue row pool helpers retired — replaced by ScrollBox initializer/bind below)

-- ============================================================================
-- MATERIALS SCROLLBOX: element kinds, extent calculator, initializer, binders
-- Element kinds (one Button frame template, kind-dispatched on bind):
--   matHeader          - collapsible category / recipe / intermediates header
--   matSubHeader       - non-collapsible category sub-divider (ByRecipe mode)
--   matRow             - reagent row with progress StatusBar + clickable area
--   matIntermediate    - intermediate parent row (Totals + Raw mode)
--   matChildRow        - intermediate child reagent row (indented)
--   spacer             - trailing gap
--   emptyState         - "queue empty" message when no content
-- ============================================================================

local MAT_ELEMENT_HEIGHTS = {
    matHeader       = (CONFIG.SPACING and CONFIG.SPACING.HEADER_HEIGHT or 24) + 2,
    matSubHeader    = 20,
    matRow          = (CONFIG.MATERIAL_ITEM_HEIGHT or 22) + 1,
    matIntermediate = (CONFIG.MATERIAL_ITEM_HEIGHT or 22) + 1,
    matChildRow     = (CONFIG.MATERIAL_ITEM_HEIGHT or 22) - 1,
    spacer          = 6,
    emptyState      = 80,
}

function GetMaterialsElementExtent(_, data)
    if data.kind == "spacer" then return data.height or 6 end
    if data.kind == "matHeader" and data.isRecipeHeader then
        return (CONFIG.SPACING and CONFIG.SPACING.HEADER_HEIGHT or 24) + 4
    end
    return MAT_ELEMENT_HEIGHTS[data.kind] or 22
end

local function HideAllMaterialsWidgets(frame)
    if frame.matCollapseIcon then frame.matCollapseIcon:Hide() end
    if frame.matIcon then frame.matIcon:Hide() end
    if frame.matHeaderText then frame.matHeaderText:Hide() end
    if frame.matHeaderQtyText then frame.matHeaderQtyText:Hide() end
    if frame.matSubIcon then frame.matSubIcon:Hide() end
    if frame.matSubText then frame.matSubText:Hide() end
    if frame.matNameText then frame.matNameText:Hide() end
    if frame.matCountText then frame.matCountText:Hide() end
    if frame.matProfIcon then frame.matProfIcon:Hide() end
    if frame.matProgressBar then frame.matProgressBar:Hide() end
    if frame.matEmptyText then frame.matEmptyText:Hide() end
end

function InitializeMaterialsFrame(frame)
    if frame._hdgMatInit then return end
    frame._hdgMatInit = true

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

    frame:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    frame:EnableMouse(true)

    -- === matHeader widgets ===
    frame.matCollapseIcon = CreateFrame("Button", nil, frame)
    frame.matCollapseIcon:SetSize(14, 14)
    frame.matCollapseIcon:SetPoint("LEFT", 4, 0)
    frame.matCollapseIcon:SetHighlightAtlas("common-button-collapseExpand-hover")
    frame.matCollapseIcon:SetScript("OnClick", function()
        if frame._matCollapseKey then
            Dispatch("SET_COLLAPSED_MATERIAL", { category = frame._matCollapseKey, collapsed = not frame._matIsCollapsed })
            HDG_RecipesTab:RefreshMaterials()
        end
    end)

    frame.matIcon = frame:CreateTexture(nil, "ARTWORK")
    frame.matIcon:SetSize(14, 14)

    frame.matHeaderText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.matHeaderText:SetJustifyH("LEFT")
    frame.matHeaderText:SetWordWrap(false)

    frame.matHeaderQtyText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.matHeaderQtyText:SetPoint("RIGHT", -8, 0)

    -- === matSubHeader widgets ===
    frame.matSubIcon = frame:CreateTexture(nil, "ARTWORK")
    frame.matSubIcon:SetSize(12, 12)
    frame.matSubText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.matSubText:SetJustifyH("LEFT")
    frame.matSubText:SetWordWrap(false)

    -- === matRow / matIntermediate / matChildRow widgets ===
    frame.matProgressBar = CreateFrame("StatusBar", nil, frame)
    frame.matProgressBar:SetAllPoints()
    frame.matProgressBar:SetStatusBarTexture(CONFIG.TEXTURES.STATUSBAR)
    frame.matProgressBar:SetFrameLevel(frame:GetFrameLevel()) -- stays under widgets below

    frame.matProfIcon = frame:CreateTexture(nil, "ARTWORK")
    frame.matProfIcon:SetSize(14, 14)

    frame.matNameText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.matNameText:SetJustifyH("LEFT")
    frame.matNameText:SetWordWrap(false)

    frame.matCountText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.matCountText:SetJustifyH("RIGHT")

    -- === emptyState widgets ===
    frame.matEmptyText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.matEmptyText:SetPoint("CENTER", 0, 0)
    frame.matEmptyText:SetJustifyH("CENTER")

    -- Frame-level click handler dispatches on kind (matHeader toggles collapse;
    -- matRow handles AH search / lumber track / auctionator list).
    frame:SetScript("OnClick", function(self, button)
        if self._kind == "matHeader" then
            if self._matCollapseKey then
                Dispatch("SET_COLLAPSED_MATERIAL", { category = self._matCollapseKey, collapsed = not self._matIsCollapsed })
                HDG_RecipesTab:RefreshMaterials()
            end
        elseif self._kind == "matRow" and self._matClicksEnabled then
            local mat = self._mat
            local itemName = self._matName
            if not mat or not itemName then return end
            if button == "LeftButton" and IsShiftKeyDown() then
                if HDG_LumberTracker and HDG_LumberTracker.TrackReagent then
                    HDG_LumberTracker.TrackReagent(mat.id, itemName, mat.needed)
                end
            elseif button == "LeftButton" then
                if not AuctionHouseFrame or not AuctionHouseFrame:IsShown() then
                    print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Please open the Auction House first")
                elseif AuctionHouseFrame.SearchBar and AuctionHouseFrame.SearchBar.SearchBox then
                    AuctionHouseFrame.SearchBar.SearchBox:SetText(itemName)
                    print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Set AH search text to:", itemName)
                end
            elseif button == "RightButton" then
                if C_AddOns.IsAddOnLoaded("Auctionator") and Auctionator and Auctionator.API and Auctionator.API.v1 then
                    local callerID = "HousingDecorGuide"
                    local listName = "Housing Decor"
                    local existingItems = {}
                    if Auctionator.API.v1.GetCraftingQueueItems then
                        local success, items = pcall(Auctionator.API.v1.GetCraftingQueueItems, callerID, listName)
                        if success and items then existingItems = items end
                    end
                    local exists = false
                    for _, existingItem in ipairs(existingItems) do
                        if existingItem == itemName then exists = true; break end
                    end
                    if not exists then table.insert(existingItems, itemName) end
                    if Auctionator.API.v1.CreateShoppingList then
                        Auctionator.API.v1.CreateShoppingList(callerID, listName, existingItems)
                        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Added '" .. itemName .. "' to Auctionator search list: " .. listName)
                    end
                else
                    print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Please install Auctionator for search list integration")
                end
            end
        end
    end)

    frame:SetScript("OnEnter", function(self)
        if self._matHoverBgColor then
            local c = self._matHoverBgColor
            self.bg:SetColorTexture(c[1], c[2], c[3], c[4] or 1)
        end
        if self._kind == "matHeader" and self._matIntermediatesTooltip then
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:AddLine(HDG.Theme.C_Accent() .. L["RECIPE_TIP_CRAFT_FIRST"] .. "|r", 1, 1, 1)
            GameTooltip:AddLine(L["RECIPE_TIP_CRAFT_FIRST_1"], 0.7, 0.7, 0.7)
            GameTooltip:AddLine(L["RECIPE_TIP_CRAFT_FIRST_2"], 0.7, 0.7, 0.7)
            GameTooltip:Show()
        elseif self._kind == "matRow" and self._mat then
            local mat = self._mat
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:SetItemByID(mat.id)
            GameTooltip:AddLine(" ")
            if self._matMode == "byrecipe" then
                GameTooltip:AddLine(HDG.Theme.C_Warning() .. "For: " .. (self._matRecipeName or "") .. " x" .. (self._matRecipeQty or 1) .. "|r", 0.9, 0.9, 0.9)
                GameTooltip:AddLine("Per craft: x" .. (mat.perCraft or 1), 0.7, 0.7, 0.7)
            else
                GameTooltip:AddLine("Used in:", 1, 1, 1)
                if mat.sources then
                    for recipeName, data in pairs(mat.sources) do
                        GameTooltip:AddDoubleLine(recipeName, string.format("%d (x%d)", data.count, data.perCraft), 0.8, 0.8, 0.8, 1, 1, 1)
                    end
                end
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine(HDG.Theme.C_Success() .. "Click|r to set AH search text", 0.7, 0.7, 0.7)
                GameTooltip:AddLine(HDG.Theme.C_Success() .. "Shift-Click|r to track reagent", 0.7, 0.7, 0.7)
                GameTooltip:AddLine(HDG.Theme.C_Success() .. "Right-Click|r to add to Auctionator search list", 0.7, 0.7, 0.7)
            end
            GameTooltip:Show()
        elseif self._kind == "matIntermediate" and self._matInter then
            local inter = self._matInter
            local interRecipe = self._matInterRecipe
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:SetItemByID(inter.id)
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(HDG.Theme.C_Accent() .. "Craft " .. inter.needed .. "x|r", 1, 1, 1)
            if HDG_PowerCrafting and HDG_PowerCrafting.GetAllRecipesForItem then
                local allRecipes = HDG_PowerCrafting.GetAllRecipesForItem(inter.id)
                if #allRecipes > 0 then
                    local preferred = allRecipes[1]
                    if preferred then
                        local profColor = HDG.Theme.C_Warning()
                        GameTooltip:AddLine(" ")
                        GameTooltip:AddLine("Via: " .. (preferred.name or "Unknown") .. " " .. profColor .. "(" .. (preferred.profession or "?") .. ")|r", 0.9, 0.9, 0.9)
                        if preferred.slots then
                            for _, slot in ipairs(preferred.slots) do
                                if slot.type == "basic" and slot.itemID then
                                    local matN = slot.name or HDG_PowerCrafting.GetItemName(slot.itemID) or ("Item #" .. slot.itemID)
                                    GameTooltip:AddDoubleLine("  " .. matN, "x" .. (slot.qty or 1), 0.7, 0.7, 0.7, 0.8, 0.8, 0.8)
                                end
                            end
                        end
                    end
                    if #allRecipes > 1 then
                        GameTooltip:AddLine(" ")
                        GameTooltip:AddLine(L["RECIPE_TIP_ALT_RECIPES"], 0.6, 0.8, 0.6)
                        for i = 2, #allRecipes do
                            local alt = allRecipes[i]
                            GameTooltip:AddLine("  " .. (alt.name or "Unknown") .. " " .. HDG.Theme.C_Warning() .. "(" .. (alt.profession or "?") .. ")|r", 0.6, 0.6, 0.6)
                        end
                    end
                end
            elseif inter.children and next(inter.children) then
                GameTooltip:AddLine(L["RECIPE_TIP_REQUIRES"], 0.7, 0.7, 0.7)
                for _, childData in pairs(inter.children) do
                    if childData.source ~= "Ignore" then
                        local totalNeeded = childData.perCraft * inter.needed
                        GameTooltip:AddDoubleLine("  " .. childData.name, "x" .. totalNeeded, 0.8, 0.8, 0.8, 1, 1, 1)
                    end
                end
            end
            if interRecipe and interRecipe.recipeID then
                local knowers = HDG_Data.GetCharactersWhoKnowRecipe(interRecipe.recipeID, interRecipe.profession)
                if knowers and #knowers > 0 then
                    local names = {}
                    for _, k in ipairs(knowers) do table.insert(names, k.name) end
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine("Known by: " .. table.concat(names, ", "), 0.4, 0.8, 0.4)
                end
            end
            GameTooltip:Show()
        elseif self._kind == "matChildRow" and self._matChildTooltipID then
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            if type(self._matChildTooltipID) == "number" and self._matChildTooltipID > 0 then
                GameTooltip:SetItemByID(self._matChildTooltipID)
            else
                GameTooltip:SetText(self._matChildName or "Unknown Item", 1, 1, 1)
            end
            GameTooltip:Show()
        end
    end)

    frame:SetScript("OnLeave", function(self)
        if self._matBgColor then
            local c = self._matBgColor
            self.bg:SetColorTexture(c[1], c[2], c[3], c[4] or 1)
        end
        GameTooltip:Hide()
    end)
end

-- ============================================================================
-- BIND helpers
-- ============================================================================

local function BindMatHeaderKind(frame, data, scheme)
    frame._kind = "matHeader"
    frame._matCollapseKey = data.collapseKey
    frame._matIsCollapsed = data.isCollapsed
    frame._matIntermediatesTooltip = data.showIntermediatesTooltip

    local bg = data.bgColor or { scheme.panel.r, scheme.panel.g, scheme.panel.b, 1.0 }
    local hover = data.hoverBgColor or { scheme.button_hover.r, scheme.button_hover.g, scheme.button_hover.b, 1.0 }
    frame._matBgColor = bg
    frame._matHoverBgColor = hover
    frame.bg:SetColorTexture(bg[1], bg[2], bg[3], bg[4] or 1)

    frame.matCollapseIcon:Show()
    if data.isCollapsed then
        frame.matCollapseIcon:SetNormalAtlas("common-button-collapseExpand-up")
        frame.matCollapseIcon:SetPushedAtlas("common-button-collapseExpand-up-pressed")
    else
        frame.matCollapseIcon:SetNormalAtlas("common-button-collapseExpand-down")
        frame.matCollapseIcon:SetPushedAtlas("common-button-collapseExpand-down-pressed")
    end

    if data.icon then
        frame.matIcon:ClearAllPoints()
        frame.matIcon:SetPoint("LEFT", 20, 0)
        frame.matIcon:SetTexture(data.icon)
        frame.matIcon:Show()
        frame.matHeaderText:ClearAllPoints()
        frame.matHeaderText:SetPoint("LEFT", 40, 0)
    else
        frame.matHeaderText:ClearAllPoints()
        frame.matHeaderText:SetPoint("LEFT", 22, 0)
    end

    ApplyFont(frame.matHeaderText, scheme, "small")
    frame.matHeaderText:SetWidth(data.textWidth or 170)
    frame.matHeaderText:SetText(data.text or "")
    if data.textColor then
        frame.matHeaderText:SetTextColor(data.textColor.r, data.textColor.g, data.textColor.b, data.textColor.a or 1)
    elseif scheme.text_header then
        frame.matHeaderText:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, 1)
    end
    frame.matHeaderText:Show()

    if data.qtyText then
        ApplyFont(frame.matHeaderQtyText, scheme, "small")
        frame.matHeaderQtyText:SetText(data.qtyText)
        frame.matHeaderQtyText:Show()
    end
end

local function BindMatSubHeaderKind(frame, data, scheme)
    frame._kind = "matSubHeader"
    local panel = scheme.panel
    frame._matBgColor = { panel.r, panel.g, panel.b, 0.7 }
    frame.bg:SetColorTexture(panel.r, panel.g, panel.b, 0.7)

    if data.icon then
        frame.matSubIcon:ClearAllPoints()
        frame.matSubIcon:SetPoint("LEFT", 12, 0)
        frame.matSubIcon:SetTexture(data.icon)
        frame.matSubIcon:Show()
        frame.matSubText:ClearAllPoints()
        frame.matSubText:SetPoint("LEFT", 28, 0)
    else
        frame.matSubText:ClearAllPoints()
        frame.matSubText:SetPoint("LEFT", 12, 0)
    end
    ApplyFont(frame.matSubText, scheme, "small")
    frame.matSubText:SetText(HDG.Theme.C_Dim() .. (data.text or "") .. "|r")
    frame.matSubText:Show()
end

local function BindMatRowKind(frame, data, scheme)
    local mat = data.mat
    frame._kind = "matRow"
    frame._mat = mat
    frame._matMode = data.mode or "totals"
    frame._matRecipeName = data.recipeName
    frame._matRecipeQty = data.recipeQty
    frame._matClicksEnabled = (data.mode ~= "byrecipe")

    local expColor = HDG_ExpansionData.GetColor(mat.expansion)
    local bgAlpha = data.mode == "byrecipe" and 0.4 or 0.5
    local bg
    if expColor then
        bg = { expColor[1], expColor[2], expColor[3], 0.15 }
    else
        bg = { scheme.panel.r, scheme.panel.g, scheme.panel.b, bgAlpha }
    end
    frame._matBgColor = bg
    frame._matHoverBgColor = bg
    frame.bg:SetColorTexture(bg[1], bg[2], bg[3], bg[4])

    frame.matProgressBar:Show()
    frame.matProgressBar:SetMinMaxValues(0, mat.needed)
    frame.matProgressBar:SetValue(math.min(mat.have, mat.needed))
    local pct = mat.needed > 0 and (mat.have / mat.needed * 100) or 0
    frame.matProgressBar:SetStatusBarColor(HDG_UIHelpers.GetProgressBarColor(pct))

    local matName = Data().GetCachedItemName(mat.id) or ("Item #" .. mat.id)
    frame._matName = matName

    frame.matNameText:ClearAllPoints()
    frame.matNameText:SetPoint("LEFT", 4, 0)
    frame.matNameText:SetWidth(data.mode == "byrecipe" and 130 or 150)
    ApplyFont(frame.matNameText, scheme, "small")
    frame.matNameText:SetText(matName)
    frame.matNameText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a or 1)
    frame.matNameText:Show()

    local countStr
    if data.mode == "byrecipe" then
        if mat.have >= mat.needed then
            countStr = HDG.Theme.C_Success() .. mat.needed .. "/" .. mat.needed .. "|r"
        elseif mat.have > 0 then
            countStr = HDG.Theme.C_Warning() .. mat.have .. "/" .. mat.needed .. "|r"
        else
            countStr = HDG.Theme.C_Error() .. "0/" .. mat.needed .. "|r"
        end
        frame.matCountText:ClearAllPoints()
        frame.matCountText:SetPoint("RIGHT", -6, 0)
    else
        if mat.have > mat.needed then
            local excess = mat.have - mat.needed
            countStr = string.format("%s/%s+" .. HDG.Theme.C_Success() .. "%s|r",
                BreakUpLargeNumbers(mat.needed), BreakUpLargeNumbers(mat.needed), BreakUpLargeNumbers(excess))
        elseif mat.have == mat.needed then
            countStr = HDG.Theme.C_Success() .. string.format("%s/%s", BreakUpLargeNumbers(mat.have), BreakUpLargeNumbers(mat.needed)) .. "|r"
        elseif mat.have > 0 then
            countStr = HDG.Theme.C_Warning() .. string.format("%s/%s", BreakUpLargeNumbers(mat.have), BreakUpLargeNumbers(mat.needed)) .. "|r"
        else
            countStr = HDG.Theme.C_Error() .. string.format("0/%s", BreakUpLargeNumbers(mat.needed)) .. "|r"
        end
        frame.matCountText:ClearAllPoints()
        frame.matCountText:SetPoint("RIGHT", -10, 0)
    end
    ApplyFont(frame.matCountText, scheme, "small")
    frame.matCountText:SetText(countStr)
    frame.matCountText:Show()
end

local function BindMatIntermediateKind(frame, data, scheme)
    local inter = data.inter
    frame._kind = "matIntermediate"
    frame._matInter = inter
    frame._matInterRecipe = data.interRecipe

    local bg = { scheme.panel.r, scheme.panel.g * 1.2, scheme.panel.b * 1.2, 0.6 }
    frame._matBgColor = bg
    frame._matHoverBgColor = { 0.15, 0.3, 0.3, 0.8 }
    frame.bg:SetColorTexture(bg[1], bg[2], bg[3], bg[4])

    frame.matProfIcon:ClearAllPoints()
    frame.matProfIcon:SetPoint("LEFT", 8, 0)
    HDG_ExpansionData.ApplyProfessionIcon(frame.matProfIcon, data.interProfession)
    frame.matProfIcon:Show()

    local interName = inter.name or ("Item #" .. inter.id)
    local isKnown = data.isKnown
    frame.matNameText:ClearAllPoints()
    frame.matNameText:SetPoint("LEFT", 8 + 14 + 4, 0)
    frame.matNameText:SetWidth(145)
    ApplyFont(frame.matNameText, scheme, "small")
    if isKnown then
        frame.matNameText:SetText(interName)
        frame.matNameText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a or 1)
    else
        frame.matNameText:SetText(interName .. " (unknown)")
        frame.matNameText:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a or 1)
    end
    frame.matNameText:Show()

    frame.matCountText:ClearAllPoints()
    frame.matCountText:SetPoint("RIGHT", -10, 0)
    ApplyFont(frame.matCountText, scheme, "small")
    frame.matCountText:SetText(string.format("x%d", inter.needed))
    frame.matCountText:SetTextColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, scheme.accent.a or 1)
    frame.matCountText:Show()
end

local function BindMatChildRowKind(frame, data, scheme)
    frame._kind = "matChildRow"
    frame._matChildTooltipID = data.tooltipItemID
    frame._matChildName = data.name

    local bg = { 0.08, 0.12, 0.12, 0.4 }
    frame._matBgColor = bg
    frame._matHoverBgColor = { 0.12, 0.18, 0.18, 0.6 }
    frame.bg:SetColorTexture(bg[1], bg[2], bg[3], bg[4])

    frame.matNameText:ClearAllPoints()
    frame.matNameText:SetPoint("LEFT", 20, 0)
    frame.matNameText:SetWidth(140)
    ApplyFont(frame.matNameText, scheme, "small")
    frame.matNameText:SetText("- " .. (data.name or "Unknown"))
    frame.matNameText:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a or 1)
    frame.matNameText:Show()

    frame.matCountText:ClearAllPoints()
    frame.matCountText:SetPoint("RIGHT", -8, 0)
    ApplyFont(frame.matCountText, scheme, "small")
    frame.matCountText:SetText("x" .. (data.totalNeeded or 0))
    frame.matCountText:SetTextColor(scheme.text_dim.r * 0.8, scheme.text_dim.g * 0.8, scheme.text_dim.b * 0.8, scheme.text_dim.a or 1)
    frame.matCountText:Show()
end

local function BindSpacerKind(frame)
    frame._kind = "spacer"
    frame._matBgColor = { 0, 0, 0, 0 }
    frame.bg:SetColorTexture(0, 0, 0, 0)
end

local function BindEmptyStateKind(frame, data, scheme)
    frame._kind = "emptyState"
    frame._matBgColor = { 0, 0, 0, 0 }
    frame.bg:SetColorTexture(0, 0, 0, 0)
    ApplyFont(frame.matEmptyText, scheme, "body")
    frame.matEmptyText:SetText(HDG.Theme.C_Dim() .. (data.text or L["RECIPE_EMPTY_QUEUE_HINT"] or "Queue is empty") .. "|r")
    frame.matEmptyText:Show()
end

function BindMaterialsFrame(frame, data)
    HideAllMaterialsWidgets(frame)
    local scheme = GetScheme()
    local kind = data.kind
    if kind == "matHeader" then
        BindMatHeaderKind(frame, data, scheme)
    elseif kind == "matSubHeader" then
        BindMatSubHeaderKind(frame, data, scheme)
    elseif kind == "matRow" then
        BindMatRowKind(frame, data, scheme)
    elseif kind == "matIntermediate" then
        BindMatIntermediateKind(frame, data, scheme)
    elseif kind == "matChildRow" then
        BindMatChildRowKind(frame, data, scheme)
    elseif kind == "emptyState" then
        BindEmptyStateKind(frame, data, scheme)
    else
        BindSpacerKind(frame)
    end
end

-- ============================================================================
-- QUEUE SCROLLBOX: element kinds, extent calculator, initializer, bind dispatch
-- Element kinds (all share the same Button frame template):
--   charHeader    - "Character - Realm (you)" section label
--   queueRow      - Main item row with expand icon, prof icon, name, craft button
--   queueSubLabel - "Craft Order:" label row (expanded view)
--   queueSubStep  - Individual craft step (expanded view)
-- ============================================================================

local SUB_INDENT = 16
local SUB_HEIGHT = 18

function GetQueueElementExtent(_, data)
    local kind = data.kind
    if kind == "charHeader" then
        return 18
    elseif kind == "queueRow" then
        return CONFIG.QUEUE_ITEM_HEIGHT + 2
    elseif kind == "queueSubLabel" or kind == "queueSubStep" then
        return SUB_HEIGHT + 1
    end
    return 20
end

local function HideAllQueueKindWidgets(frame)
    if frame.charHeaderText then frame.charHeaderText:Hide() end
    if frame.expandIcon then frame.expandIcon:Hide() end
    if frame.profIcon then frame.profIcon:Hide() end
    if frame.nameText then frame.nameText:Hide() end
    if frame.craftBtn then frame.craftBtn:Hide() end
    if frame.subProfIcon then frame.subProfIcon:Hide() end
    if frame.subNameText then frame.subNameText:Hide() end
    if frame.subQtyText then frame.subQtyText:Hide() end
end

function InitializeQueueFrame(frame)
    if frame._hdgInit then return end
    frame._hdgInit = true

    -- Full-frame bg texture (replaces SetBackdrop)
    frame.bg = frame:CreateTexture(nil, "BACKGROUND")
    frame.bg:SetAllPoints()

    frame:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    -- === charHeader widgets ===
    frame.charHeaderText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.charHeaderText:SetPoint("LEFT", 2, 0)
    frame.charHeaderText:SetJustifyH("LEFT")

    -- === queueRow widgets ===
    frame.expandIcon = CreateFrame("Button", nil, frame)
    frame.expandIcon:SetSize(14, 14)
    frame.expandIcon:SetPoint("LEFT", 2, 0)
    frame.expandIcon:SetHighlightAtlas("common-button-collapseExpand-hover")
    frame.expandIcon:SetScript("OnClick", function()
        local item = frame._item
        if item then
            expandedQueueItems[item.id] = not expandedQueueItems[item.id]
            HDG_UI.RefreshUI()
        end
    end)

    frame.profIcon = frame:CreateTexture(nil, "ARTWORK")
    frame.profIcon:SetSize(16, 16)
    frame.nameText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.nameText:SetJustifyH("LEFT")

    -- Craft button (sub-Button with manual 3D bevel textures)
    frame.craftBtn = CreateFrame("Button", nil, frame, "BackdropTemplate")
    frame.craftBtn:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    frame.craftBtn.highlight = frame.craftBtn:CreateTexture(nil, "ARTWORK")
    frame.craftBtn.highlight:SetTexture("Interface\\Buttons\\WHITE8x8")
    frame.craftBtn.highlight:SetPoint("TOPLEFT", 1, -1)
    frame.craftBtn.highlight:SetPoint("TOPRIGHT", -1, -1)
    frame.craftBtn.highlight:SetHeight(6)
    frame.craftBtn.shadow = frame.craftBtn:CreateTexture(nil, "ARTWORK")
    frame.craftBtn.shadow:SetTexture("Interface\\Buttons\\WHITE8x8")
    frame.craftBtn.shadow:SetPoint("BOTTOMLEFT", 1, 1)
    frame.craftBtn.shadow:SetPoint("BOTTOMRIGHT", -1, 1)
    frame.craftBtn.shadow:SetHeight(4)
    frame.craftBtn.glow = frame.craftBtn:CreateTexture(nil, "BACKGROUND", nil, -1)
    frame.craftBtn.glow:SetTexture("Interface\\Buttons\\WHITE8x8")
    frame.craftBtn.glow:SetPoint("TOPLEFT", -2, 2)
    frame.craftBtn.glow:SetPoint("BOTTOMRIGHT", 2, -2)
    frame.craftBtn.btnText = frame.craftBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")

    frame.craftBtn:SetScript("OnEnter", function(self)
        local item = frame._item
        if not item then return end
        self:SetBackdropColor(0.2, 0.85, 0.2, 1)
        self:SetBackdropBorderColor(0.15, 0.65, 0.15, 1)
        self.glow:SetVertexColor(0.4, 1, 0.4, 0.5)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:SetText(HDG.Theme.C_Success() .. L["RECIPE_TIP_CRAFT_NOW"] .. "|r", 1, 1, 1)
        GameTooltip:AddLine(string.format("%s", frame._localizedName or ""), 1, 1, 1)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. string.format(L["RECIPE_TIP_QUEUED"], item.quantity) .. "|r", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(HDG.Theme.C_Success() .. string.format(L["RECIPE_TIP_CAN_CRAFT"], frame._maxCraftable or 0) .. "|r", 0.7, 0.7, 0.7)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(L["RECIPE_TIP_CLICK_CONFIRM"], 0.7, 0.7, 0.7)
        GameTooltip:AddLine(L["RECIPE_TIP_SHIFT_OPEN_PROF"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    frame.craftBtn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(0.15, 0.7, 0.15, 1)
        self:SetBackdropBorderColor(0.1, 0.5, 0.1, 1)
        self.glow:SetVertexColor(0.3, 1, 0.3, 0.3)
        GameTooltip:Hide()
    end)
    frame.craftBtn:SetScript("OnMouseDown", function(self)
        self:SetBackdropColor(0.1, 0.5, 0.1, 1)
        self.highlight:SetGradient("VERTICAL", CreateColor(0, 0.3, 0, 0.4), CreateColor(0.1, 0.5, 0.1, 0))
        self.shadow:SetGradient("VERTICAL", CreateColor(0.3, 0.8, 0.3, 0.3), CreateColor(0.1, 0.5, 0.1, 0))
        self.btnText:SetPoint("CENTER", 0, -1)
    end)
    frame.craftBtn:SetScript("OnMouseUp", function(self)
        self:SetBackdropColor(0.2, 0.85, 0.2, 1)
        self.highlight:SetGradient("VERTICAL", CreateColor(0.5, 1, 0.5, 0.4), CreateColor(0.2, 0.8, 0.2, 0))
        self.shadow:SetGradient("VERTICAL", CreateColor(0, 0.3, 0, 0.6), CreateColor(0.1, 0.5, 0.1, 0))
        self.btnText:SetPoint("CENTER", 0, 0)
    end)
    frame.craftBtn:SetScript("OnClick", function()
        local item = frame._item
        local idx = frame._itemIndex
        if not item or not item.spell then return end
        if IsShiftKeyDown() then
            OpenRecipeAndThen(item.spell)
        else
            OpenRecipeAndThen(item.spell, function()
                HDG_RecipesTab.ShowCraftConfirmation(item, idx)
            end)
        end
    end)

    -- === queueSub widgets (shared between subLabel and subStep) ===
    frame.subProfIcon = frame:CreateTexture(nil, "ARTWORK")
    frame.subProfIcon:SetSize(14, 14)
    frame.subProfIcon:SetPoint("LEFT", 4 + SUB_INDENT, 0)
    frame.subNameText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.subNameText:SetPoint("LEFT", 22 + SUB_INDENT, 0)
    frame.subNameText:SetJustifyH("LEFT")
    frame.subQtyText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.subQtyText:SetPoint("RIGHT", -4, 0)
    frame.subQtyText:SetJustifyH("RIGHT")

    -- Shared frame click handler (dispatches on kind). charHeader/subLabel kinds
    -- leave OnClick a no-op; queueRow handles add/remove/toggle/open.
    frame:SetScript("OnClick", function(self, button)
        if self._kind ~= "queueRow" then return end
        local item = self._item
        if not item then return end
        if button == "RightButton" then
            if IsShiftKeyDown() then
                Dispatch("REMOVE_FROM_CRAFTING_QUEUE", { itemID = item.id })
                expandedQueueItems[item.id] = nil
                HDG_UI.RefreshUI()
            elseif self._hasSubsidiaries then
                expandedQueueItems[item.id] = not expandedQueueItems[item.id]
                HDG_UI.RefreshUI()
            else
                Dispatch("REMOVE_FROM_CRAFTING_QUEUE", { itemID = item.id })
                HDG_UI.RefreshUI()
            end
        elseif IsShiftKeyDown() then
            local newQty = item.quantity - 1
            Dispatch("UPDATE_CRAFTING_QUEUE_ITEM_QTY", { itemID = item.id, quantity = newQty })
            if newQty <= 0 then expandedQueueItems[item.id] = nil end
            HDG_UI.RefreshUI()
        elseif item.spell then
            OpenRecipeAndThen(item.spell)
        end
    end)

    frame:SetScript("OnEnter", function(self)
        if self._kind == "queueRow" then
            local item = self._item
            if not item then return end
            self.bg:SetColorTexture(0.3, 0.3, 0.35, 0.8)
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)

            local function BuildTooltipContent()
                if item.reagents and #item.reagents > 0 then
                    GameTooltip:AddLine(HDG.Theme.C_Text() .. string.format(L["RECIPE_TIP_MATERIALS_N"], item.quantity) .. "|r")
                    for _, reagent in ipairs(item.reagents) do
                        local have = HDG_Data.GetItemCountWithVariants(reagent.id)
                        local need = (reagent.amount or 1) * item.quantity
                        local reagentName = reagent.name or Data().GetCachedItemName(reagent.id) or ("Item " .. reagent.id)
                        local color = have >= need and HDG.Theme.C_Success() or (have > 0 and HDG.Theme.C_Warning() or HDG.Theme.C_Error())
                        GameTooltip:AddDoubleLine("  " .. reagentName, string.format("%s%d/%d|r", color, have, need), 1, 1, 1, 1, 1, 1)
                    end
                    GameTooltip:AddLine(" ")
                end
                if item.spell then
                    GameTooltip:AddLine(L["RECIPE_TIP_LEFT_OPEN_PROF"], 0.7, 0.7, 0.7)
                end
                GameTooltip:AddLine(L["RECIPE_TIP_SHIFT_REMOVE_ONE"], 0.7, 0.7, 0.7)
                if self._hasSubsidiaries then
                    GameTooltip:AddLine(HDG.Theme.C_Warning() .. "Right-click:|r " .. (self._isExpanded and "Collapse" or "Show craft order"), 0.7, 0.7, 0.7)
                    GameTooltip:AddLine(L["RECIPE_TIP_SHIFT_RIGHT_REMOVE_ALL"], 0.7, 0.7, 0.7)
                else
                    GameTooltip:AddLine(L["RECIPE_TIP_RIGHT_REMOVE_ALL"], 0.7, 0.7, 0.7)
                end
                GameTooltip:Show()
            end

            if item.id then
                local _, itemLink = C_Item.GetItemInfo(item.id)
                if itemLink then
                    GameTooltip:SetHyperlink(itemLink)
                    GameTooltip:AddLine(" ")
                    BuildTooltipContent()
                else
                    GameTooltip:SetItemByID(item.id)
                    C_Item.RequestLoadItemDataByID(item.id)
                    local owner = self
                    local itemID = item.id
                    pendingTooltipRebuilds[itemID] = {
                        owner = owner,
                        rebuildFn = function()
                            local _, link = C_Item.GetItemInfo(itemID)
                            if link then GameTooltip:SetHyperlink(link) end
                            GameTooltip:AddLine(" ")
                            BuildTooltipContent()
                            GameTooltip:Show()
                        end,
                    }
                end
            else
                GameTooltip:SetText(HDG.Theme.C_Accent() .. (item.name or "Unknown") .. "|r")
                BuildTooltipContent()
            end
        elseif self._kind == "queueSubStep" then
            local step = self._subStep
            local recipe = self._subRecipe
            self.bg:SetColorTexture(0.2, 0.2, 0.25, 0.9)
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:SetText(step and step.name or "", 1, 1, 1)
            if recipe then
                local profLine = recipe.profession or ""
                if recipe.expansion then profLine = profLine .. " - " .. recipe.expansion end
                GameTooltip:AddLine(profLine, 0.7, 0.7, 0.7)
                if recipe.slots and #recipe.slots > 0 then
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine(L["RECIPE_TIP_REAGENTS_PER_CRAFT"], 0.98, 0.84, 0.34)
                    for _, slot in ipairs(recipe.slots) do
                        if slot.type == "basic" and slot.itemID then
                            local reagentName = slot.name or HDG_PowerCrafting.GetItemName(slot.itemID) or ("Item " .. slot.itemID)
                            GameTooltip:AddDoubleLine("  " .. reagentName, "x" .. (slot.qty or 1), 0.8, 0.8, 0.8, 0.98, 0.84, 0.34)
                        end
                    end
                end
                if recipe.recipeID then
                    local knowers = HDG_Data.GetCharactersWhoKnowRecipe(recipe.recipeID, recipe.profession)
                    if knowers and #knowers > 0 then
                        local names = {}
                        for _, k in ipairs(knowers) do table.insert(names, k.name) end
                        GameTooltip:AddLine(" ")
                        GameTooltip:AddLine("Known by: " .. table.concat(names, ", "), 0.4, 0.8, 0.4)
                    end
                end
            end
            GameTooltip:Show()
        end
    end)

    frame:SetScript("OnLeave", function(self)
        if self._kind == "queueRow" and self._item then
            local c = GetQueueItemColor(self._item)
            self.bg:SetColorTexture(c[1], c[2], c[3], c[4])
        elseif self._kind == "queueSubStep" then
            local cPR = CONFIG.COLORS.PANEL_ROW
            self.bg:SetColorTexture(cPR[1], cPR[2], cPR[3], 0.7)
        end
        GameTooltip:Hide()
    end)
end

-- Per-bind: char header section label
local function BindCharHeaderKind(frame, data, scheme)
    frame._kind = "charHeader"
    frame._item = nil
    frame.bg:SetColorTexture(0, 0, 0, 0)
    frame.charHeaderText:Show()
    ApplyFont(frame.charHeaderText, scheme, "small")
    frame.charHeaderText:SetText(data.text or "")
end

-- Per-bind: main queue row
local function BindQueueRowKind(frame, data, scheme)
    local item = data.item
    frame._kind = "queueRow"
    frame._item = item
    frame._itemIndex = data.index
    frame._hasSubsidiaries = data.hasSubsidiaries
    frame._isExpanded = data.isExpanded
    frame._maxCraftable = data.maxCraftable
    frame._localizedName = data.localizedName

    local c = GetQueueItemColor(item)
    frame.bg:SetColorTexture(c[1], c[2], c[3], c[4])

    local CRAFT_BTN_WIDTH = 44
    local PROF_ICON_SIZE = 16
    local EXPAND_ICON_WIDTH = data.hasSubsidiaries and 14 or 0

    -- Layout order: profession icon → expand arrow (if any) → name. The arrow
    -- after the prof icon makes the row read as "profession + this expandable
    -- recipe + name", which is visually less alarming than a leading yellow
    -- indicator that mimics a warning glyph.
    local x = 4
    frame.profIcon:ClearAllPoints()
    frame.profIcon:SetPoint("LEFT", x, 0)
    HDG_ExpansionData.ApplyProfessionIcon(frame.profIcon, data.profession)
    frame.profIcon:Show()
    x = x + PROF_ICON_SIZE + 2

    if data.hasSubsidiaries then
        frame.expandIcon:ClearAllPoints()
        frame.expandIcon:SetPoint("LEFT", x, 0)
        frame.expandIcon:Show()
        -- common-icon-rotateright for both states; tint shifts on expand.
        frame.expandIcon:SetNormalAtlas("common-icon-rotateright")
        frame.expandIcon:SetPushedAtlas("common-icon-rotateright")
        if data.isExpanded then
            frame.expandIcon:GetNormalTexture():SetVertexColor(0.6, 1, 0.6, 1) -- green-ish when open
        else
            frame.expandIcon:GetNormalTexture():SetVertexColor(1, 1, 1, 1) -- neutral when closed
        end
        x = x + EXPAND_ICON_WIDTH + 2
    else
        frame.expandIcon:Hide()
    end

    frame.nameText:ClearAllPoints()
    frame.nameText:SetPoint("LEFT", x, 0)
    frame.nameText:SetPoint("RIGHT", -CRAFT_BTN_WIDTH, 0)
    ApplyFont(frame.nameText, scheme, "small")
    if scheme then
        frame.nameText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
    end
    frame.nameText:SetText(string.format("%dx %s", item.quantity, data.localizedName))
    frame.nameText:Show()

    if data.isKnown and data.canCraft then
        local maxCraftable = data.maxCraftable or 0
        local craftText = maxCraftable > 99 and "99+" or tostring(maxCraftable)
        local btnWidth = 34 + (string.len(craftText) > 1 and 6 or 0) + (string.len(craftText) > 2 and 4 or 0)

        local craftBtn = frame.craftBtn
        craftBtn:Show()
        craftBtn:ClearAllPoints()
        craftBtn:SetSize(btnWidth, 16)
        craftBtn:SetPoint("RIGHT", -2, 0)

        if scheme then
            craftBtn:SetBackdropColor(scheme.success.r, scheme.success.g, scheme.success.b, 1)
            craftBtn:SetBackdropBorderColor(scheme.success.r * 0.7, scheme.success.g * 0.7, scheme.success.b * 0.7, 1)
        else
            craftBtn:SetBackdropColor(0.15, 0.7, 0.15, 1)
            craftBtn:SetBackdropBorderColor(0.1, 0.5, 0.1, 1)
        end
        craftBtn.highlight:SetGradient("VERTICAL", CreateColor(0.5, 1, 0.5, 0.4), CreateColor(0.2, 0.8, 0.2, 0))
        craftBtn.shadow:SetGradient("VERTICAL", CreateColor(0, 0.3, 0, 0.6), CreateColor(0.1, 0.5, 0.1, 0))
        craftBtn.glow:SetVertexColor(0.3, 1, 0.3, 0.3)

        local btnText = craftBtn.btnText
        btnText:ClearAllPoints()
        ApplyFont(btnText, scheme, "small", 9)
        btnText:SetPoint("CENTER", 0, 0)
        btnText:SetText(string.format(HDG.Theme.C_Text() .. "%s|r", craftText))
    else
        frame.craftBtn:Hide()
    end
end

-- Per-bind: "Craft Order:" section label
local function BindQueueSubLabelKind(frame, data, scheme)
    frame._kind = "queueSubLabel"
    frame._item = nil
    frame.bg:SetColorTexture(0, 0, 0, 0)
    frame.subNameText:Show()
    ApplyFont(frame.subNameText, scheme, "small")
    frame.subNameText:SetText(HDG.Theme.C_Accent() .. L["RECIPE_CRAFT_ORDER"] .. "|r")
    frame.subNameText:SetTextColor(1, 1, 1, 1)
end

-- Per-bind: individual craft-order step
local function BindQueueSubStepKind(frame, data, scheme)
    local step = data.step
    local recipe = data.recipe
    frame._kind = "queueSubStep"
    frame._item = nil
    frame._subStep = step
    frame._subRecipe = recipe

    local cPR = CONFIG.COLORS.PANEL_ROW
    frame.bg:SetColorTexture(cPR[1], cPR[2], cPR[3], 0.7)

    frame.subProfIcon:Show()
    HDG_ExpansionData.ApplyProfessionIcon(frame.subProfIcon, recipe and recipe.profession)

    ApplyFont(frame.subNameText, scheme, "small")
    frame.subNameText:SetText((step.step or "?") .. ". " .. (step.name or ("Item " .. step.itemID)))
    if data.isStepKnown and scheme then
        frame.subNameText:SetTextColor(scheme.success.r, scheme.success.g, scheme.success.b, 1)
    elseif scheme then
        frame.subNameText:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
    end
    frame.subNameText:Show()

    ApplyFont(frame.subQtyText, scheme, "small")
    frame.subQtyText:SetText("x" .. (step.qty or 0))
    if scheme then
        frame.subQtyText:SetTextColor(scheme.warning.r, scheme.warning.g, scheme.warning.b, scheme.warning.a)
    end
    frame.subQtyText:Show()
end

function BindQueueFrame(frame, data)
    HideAllQueueKindWidgets(frame)
    local scheme = GetScheme()
    local kind = data.kind
    if kind == "charHeader" then
        BindCharHeaderKind(frame, data, scheme)
    elseif kind == "queueRow" then
        BindQueueRowKind(frame, data, scheme)
    elseif kind == "queueSubLabel" then
        BindQueueSubLabelKind(frame, data, scheme)
    elseif kind == "queueSubStep" then
        BindQueueSubStepKind(frame, data, scheme)
    end
end

-- ============================================================================
-- DataProvider push helpers for queue contents
-- ============================================================================

local function PushQueueItem(dp, itemData)
    local item = itemData.item
    local i = itemData.index
    local hasSubsidiaries = HDG_PowerCrafting.HasSubsidiaryRecipes(item)
    local isExpanded = hasSubsidiaries and expandedQueueItems[item.id] or false

    local isKnown = item.spell and Data().PlayerKnowsRecipeBySpellID and Data().PlayerKnowsRecipeBySpellID(item.spell) or false
    local canCraft, maxCraftable = HDG_Data.CanCraftRecipe(item)
    maxCraftable = maxCraftable or 0
    local profession = item.profession or GetProfessionForItem(item.id)
    local localizedName = Data().GetLocalizedName(item.id, item.name)

    dp:Insert({
        kind = "queueRow",
        item = item,
        index = i,
        hasSubsidiaries = hasSubsidiaries,
        isExpanded = isExpanded,
        isKnown = isKnown,
        canCraft = canCraft,
        maxCraftable = maxCraftable,
        profession = profession,
        localizedName = localizedName,
    })

    if isExpanded then
        local decorRecipe = { reagents = {} }
        for _, r in ipairs(item.reagents or {}) do
            decorRecipe.reagents[r.id] = { name = r.name, qty = (r.amount or r.qty or 1) * item.quantity }
        end
        local craftingSteps = HDG_PowerCrafting.GetCraftingOrder(decorRecipe)

        if #craftingSteps > 0 then
            dp:Insert({ kind = "queueSubLabel" })
            for _, step in ipairs(craftingSteps) do
                local recipe = HDG_PowerCrafting.GetRecipeByItemID(step.itemID)
                local isStepKnown = recipe and HDG_Data.PlayerKnowsRecipeBySpellID(recipe.recipeID) or false
                dp:Insert({
                    kind = "queueSubStep",
                    step = step,
                    recipe = recipe,
                    isStepKnown = isStepKnown,
                })
            end
        end
    end
end


local function GroupQueueByCharacter(craftingQueue)
    local currentCharKey = HDG_Data.GetCharacterKey()
    local currentCharShort = currentCharKey:match("^([^-]+)") or currentCharKey
    local itemsByCharacter = {}
    local characterOrder = {} -- Track order: current char first, then alts alphabetically

    for i, item in ipairs(craftingQueue) do
        local crafters = {}
        if item.spell then
            crafters = HDG_Data.GetCharactersWhoKnowRecipe(item.spell, item.profession)
        end

        -- Determine primary crafter (current char > first alt > unknown)
        local primaryCrafter = "Unknown"
        if #crafters > 0 then
            -- Current character takes priority
            for _, c in ipairs(crafters) do
                if c.isCurrent then
                    primaryCrafter = currentCharKey
                    break
                end
            end
            -- If not current, use first alt
            if primaryCrafter == "Unknown" then
                primaryCrafter = crafters[1].name
            end
        end

        -- Add to group
        if not itemsByCharacter[primaryCrafter] then
            itemsByCharacter[primaryCrafter] = {}
            table.insert(characterOrder, primaryCrafter)
        end
        table.insert(itemsByCharacter[primaryCrafter], { item = item, index = i })
    end

    -- Sort character order: current char first, then alphabetically, "Unknown" last
    table.sort(characterOrder, function(a, b)
        if a == currentCharKey then return true end
        if b == currentCharKey then return false end
        if a == "Unknown" then return false end
        if b == "Unknown" then return true end
        return a < b
    end)

    return currentCharKey, currentCharShort, itemsByCharacter, characterOrder
end

function HDG_RecipesTab:RefreshCraftingQueue()
    if not mainFrame or not mainFrame.queueScrollBox then return end

    local queue = GetCraftingQueue()
    if mainFrame.queueTitle and mainFrame.craftingQueueLabel then
        local count = #queue
        if count > 0 then
            mainFrame.queueTitle:SetText(mainFrame.craftingQueueLabel .. " " .. HDG.Theme.C_Accent() .. "(" .. count .. ")|r")
        else
            mainFrame.queueTitle:SetText(mainFrame.craftingQueueLabel)
        end
    end

    if mainFrame.pslBtn then
        mainFrame.pslBtn:SetShown(IsPSLAvailable() and #queue > 0)
    end

    local dp = CreateDataProvider()
    if #queue == 0 then
        mainFrame.queueScrollBox:SetDataProvider(dp, ScrollBoxConstants.RetainScrollPosition)
        return
    end

    local currentCharKey, _, itemsByCharacter, characterOrder = GroupQueueByCharacter(queue)

    for _, charKey in ipairs(characterOrder) do
        local items = itemsByCharacter[charKey]
        local charName = charKey:match("^([^-]+)") or charKey
        local realmName = charKey:match("-(.+)$") or ""
        local headerText
        if charKey == currentCharKey then
            headerText = HDG.Theme.C_Success() .. charName .. "|r " .. HDG.Theme.C_Dim() .. "- " .. realmName .. "|r " .. HDG.Theme.C_Warning() .. (L["QUEUE_YOU"] or "(you)") .. "|r"
        elseif charKey == "Unknown" then
            headerText = HDG.Theme.C_Error() .. (L["QUEUE_NOT_KNOWN"] or "Not Known") .. "|r"
        else
            headerText = HDG.Theme.C_Warning() .. charName .. "|r " .. HDG.Theme.C_Dim() .. "- " .. realmName .. "|r"
        end
        dp:Insert({ kind = "charHeader", text = headerText })
        for _, itemData in ipairs(items) do
            PushQueueItem(dp, itemData)
        end
    end

    mainFrame.queueScrollBox:SetDataProvider(dp, ScrollBoxConstants.RetainScrollPosition)
end

-- ============================================================================
-- MATERIALS: DataProvider push helpers (replace old Render* functions)
-- ============================================================================

local function PushMatCategory(dp, catKey, category, collapsedMaterials, scheme)
    local isCollapsed = collapsedMaterials[catKey] or false
    dp:Insert({
        kind = "matHeader",
        collapseKey = catKey,
        isCollapsed = isCollapsed,
        icon = category.icon,
        text = category.name,
        textColor = scheme.text_header,
        bgColor = { scheme.panel.r, scheme.panel.g, scheme.panel.b, 1.0 },
        hoverBgColor = { scheme.button_hover.r, scheme.button_hover.g, scheme.button_hover.b, 1.0 },
    })
    if isCollapsed then return end

    for _, mat in ipairs(category.items) do
        -- Precompute per-row price for cost rollup; stored on mat so the cost
        -- calculation outside the loop can read it (matches pre-migration behavior).
        local matPrice = Data().GetItemPrice(mat.id)
        if matPrice then
            mat.estimatedCost = matPrice * math.max(0, mat.needed - mat.have)
        end
        dp:Insert({ kind = "matRow", mat = mat, mode = "totals" })
    end
    dp:Insert({ kind = "spacer", height = 6 })
end

local function PushIntermediatesSection(dp, intermediates, collapsedMaterials, scheme)
    local interArray = {}
    for _, inter in pairs(intermediates) do
        if inter.needed > 0 then table.insert(interArray, inter) end
    end
    if #interArray == 0 then return end
    table.sort(interArray, function(a, b) return (a.name or "") < (b.name or "") end)

    local isCollapsed = collapsedMaterials["intermediates"] or false
    local bg
    if scheme then
        bg = { scheme.button_active.r * 0.5, scheme.button_active.g * 0.6, scheme.button_active.b * 0.6, 0.9 }
    else
        bg = { 0.15, 0.25, 0.25, 0.9 }
    end
    local hover
    if scheme then
        hover = { scheme.button_hover.r, scheme.button_hover.g, scheme.button_hover.b, 0.9 }
    else
        hover = { 0.2, 0.35, 0.35, 0.9 }
    end

    dp:Insert({
        kind = "matHeader",
        collapseKey = "intermediates",
        isCollapsed = isCollapsed,
        icon = "Interface\\Icons\\Trade_Engineering",
        text = HDG.Theme.C_Accent() .. "Craft These First|r",
        bgColor = bg,
        hoverBgColor = hover,
        showIntermediatesTooltip = true,
    })
    if isCollapsed then return end

    for _, inter in ipairs(interArray) do
        local interRecipe = GetRecipeByItemID(inter.id)
        local interProfession = interRecipe and interRecipe.profession or nil
        local isKnown = HDG_PowerCrafting and HDG_PowerCrafting.CanPlayerCraft(inter.id) or false
        dp:Insert({
            kind = "matIntermediate",
            inter = inter,
            interRecipe = interRecipe,
            interProfession = interProfession,
            isKnown = isKnown,
        })
        if inter.children and next(inter.children) then
            for childID, childData in pairs(inter.children) do
                if childData.source ~= "Ignore" then
                    local totalNeeded = (tonumber(childData.perCraft) or 1) * (tonumber(inter.needed) or 1)
                    dp:Insert({
                        kind = "matChildRow",
                        name = childData.name or ("Item #" .. childID),
                        totalNeeded = totalNeeded,
                        tooltipItemID = childData.id,
                    })
                end
            end
        end
    end
    dp:Insert({ kind = "spacer", height = 6 })
end

local function PushMaterialsByRecipe(dp, queue, scheme)
    local recipes
    if GetConfig("showRawMaterials") and HDG_PowerCrafting then
        recipes = HDG_PowerCrafting.AggregateMaterialsByRecipeRaw(queue)
    else
        recipes = HDG_PowerCrafting.AggregateMaterialsByRecipe(queue)
    end
    local collapsedMaterials = GetUIState("collapsedMaterials") or {}
    local categoryOrder = { "vendor", "gather", "craft", "unknown" }
    local anyContent = false

    for _, recipeEntry in ipairs(recipes) do
        anyContent = true
        local recipeName = recipeEntry.recipeName
        local recipeQty = recipeEntry.quantity
        local recipeKey = "recipe_" .. (recipeEntry.recipeId or recipeName)
        local isRecipeCollapsed = collapsedMaterials[recipeKey] or false

        local accentBg = { scheme.accent.r * 0.3, scheme.accent.g * 0.3, scheme.accent.b * 0.3, 0.9 }
        local accentHover = { scheme.accent.r * 0.5, scheme.accent.g * 0.5, scheme.accent.b * 0.5, 1.0 }

        dp:Insert({
            kind = "matHeader",
            isRecipeHeader = true,
            collapseKey = recipeKey,
            isCollapsed = isRecipeCollapsed,
            text = HDG.Theme.C_Text() .. recipeName .. "|r",
            qtyText = HDG.Theme.C_Warning() .. "x" .. recipeQty .. "|r",
            bgColor = accentBg,
            hoverBgColor = accentHover,
            textWidth = 170,
        })

        if not isRecipeCollapsed then
            local categorized = HDG_PowerCrafting.CategorizeMaterialsForRecipe(recipeEntry.materials)
            for _, catKey in ipairs(categoryOrder) do
                local category = categorized[catKey]
                if category and #category.items > 0 then
                    dp:Insert({ kind = "matSubHeader", icon = category.icon, text = category.name })
                    for _, mat in ipairs(category.items) do
                        dp:Insert({
                            kind = "matRow",
                            mat = mat,
                            mode = "byrecipe",
                            recipeName = recipeName,
                            recipeQty = recipeQty,
                        })
                    end
                    dp:Insert({ kind = "spacer", height = 4 })
                end
            end
            dp:Insert({ kind = "spacer", height = 4 })
        end
    end

    return anyContent, recipes
end

-- ============================================================================
-- MATERIALS REFRESH (orchestrator — builds DataProvider for both modes)
-- ============================================================================

function HDG_RecipesTab:RefreshMaterials()
    if not mainFrame or not mainFrame.materialsScrollBox then return end

    if mainFrame.addAllAuctionatorText then
        if GetConfig("showRawMaterials") and HDG_PowerCrafting then
            mainFrame.addAllAuctionatorText:SetText(L["BTN_ADD_ALL_AUCTIONATOR_RAW"] or "Add All to Auctionator (Raw Mats)")
        else
            mainFrame.addAllAuctionatorText:SetText(L["BTN_ADD_ALL_AUCTIONATOR"] or "Add All to Auctionator")
        end
    end

    local scheme = GetScheme()
    local queue = GetCraftingQueue()
    local sortMode = GetConfig("materialSortMode") or "totals"
    local dp = CreateDataProvider()
    local hasContent = false

    if sortMode == "byRecipe" then
        local anyContent, recipes = PushMaterialsByRecipe(dp, queue, scheme)
        hasContent = anyContent
        if hasContent then
            local totalCost, missingPrices = 0, 0
            if Data().HasPriceAddon() then
                for _, recipeEntry in ipairs(recipes) do
                    for _, mat in pairs(recipeEntry.materials) do
                        local matPrice = Data().GetItemPrice(mat.id)
                        if matPrice then
                            totalCost = totalCost + (matPrice * math.max(0, mat.needed - mat.have))
                        elseif mat.have < mat.needed then
                            missingPrices = missingPrices + 1
                        end
                    end
                end
            end
            UpdateCostDisplay(totalCost, missingPrices)
        end
    else
        local materials, intermediates
        if GetConfig("showRawMaterials") and HDG_PowerCrafting then
            materials, intermediates = HDG_PowerCrafting.AggregateRawMaterials(queue)
        else
            materials = HDG_PowerCrafting.AggregateMaterials(queue)
            intermediates = nil
        end
        local sortedMaterials = HDG_PowerCrafting.FilterAndSortMaterials(materials)
        local categorized = HDG_PowerCrafting.CategorizeMaterials(sortedMaterials)

        local collapsedMaterials = GetUIState("collapsedMaterials") or {}
        local categoryOrder = { "vendor", "gather", "craft", "unknown" }
        for _, catKey in ipairs(categoryOrder) do
            local category = categorized[catKey]
            if category and #category.items > 0 then
                PushMatCategory(dp, catKey, category, collapsedMaterials, scheme)
                hasContent = true
            end
        end
        if intermediates and GetConfig("showRawMaterials") then
            for _, inter in pairs(intermediates) do
                if inter.needed > 0 then
                    PushIntermediatesSection(dp, intermediates, collapsedMaterials, scheme)
                    hasContent = true
                    break
                end
            end
        end

        if hasContent then
            local totalCost, missingPrices = 0, 0
            if Data().HasPriceAddon() then
                for _, catKey in ipairs(categoryOrder) do
                    local category = categorized[catKey]
                    if category and #category.items > 0 then
                        for _, mat in ipairs(category.items) do
                            if mat.estimatedCost then
                                totalCost = totalCost + mat.estimatedCost
                            elseif mat.have < mat.needed then
                                missingPrices = missingPrices + 1
                            end
                        end
                    end
                end
            end
            UpdateCostDisplay(totalCost, missingPrices)
        end
    end

    if not hasContent then
        dp:Insert({ kind = "emptyState", text = L["RECIPE_EMPTY_QUEUE_HINT"] })
        if mainFrame.materialsCostText then
            mainFrame.materialsCostText:SetText("")
        end
    end

    mainFrame.materialsScrollBox:SetDataProvider(dp, ScrollBoxConstants.RetainScrollPosition)
end

-- ============================================================================
-- ADD ALL MATERIALS TO AUCTIONATOR
-- ============================================================================

function HDG_RecipesTab:AddAllMaterialsToAuctionator()
    if not C_AddOns.IsAddOnLoaded("Auctionator") or not Auctionator or not Auctionator.API or not Auctionator.API.v1 then
        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Please install Auctionator for search list integration")
        return
    end

    local materials
    local modeLabel
    local queue = GetCraftingQueue()

    -- Check which mode we're in (Direct vs Raw)
    if GetConfig("showRawMaterials") and HDG_PowerCrafting then
        -- Raw mode: get raw materials only (not intermediates)
        -- In Raw mode, we want the base materials, not crafted sub-components
        materials = HDG_PowerCrafting.AggregateRawMaterials(queue)
        modeLabel = "raw"
    else
        -- Direct mode: get materials as listed in recipes
        materials = HDG_PowerCrafting.AggregateMaterials(queue)
        modeLabel = "direct"
    end

    if not materials or next(materials) == nil then
        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r " .. L["STATUS_NO_MATERIALS_QUEUED"])
        return
    end

    local callerID = "HousingDecorGuide"
    local listName = "Housing Decor"

    -- Check if ConvertToSearchString is available for quantity support
    local hasQuantitySupport = Auctionator.API.v1.ConvertToSearchString ~= nil

    local sortedMaterials = HDG_PowerCrafting.FilterAndSortMaterials(materials)
    local categorized = HDG_PowerCrafting.CategorizeMaterials(sortedMaterials)
    local searchStrings = {}
    local addedCount = 0

    -- In Raw mode, skip "craft" category since those are intermediates the player will craft
    -- In Direct mode, include all categories
    local categoryOrder
    if modeLabel == "raw" then
        categoryOrder = { "vendor", "gather", "unknown" } -- Exclude "craft" in raw mode
    else
        categoryOrder = { "vendor", "gather", "craft", "unknown" }
    end

    local skippedCount = 0 -- Track items skipped because player has enough
    for _, catKey in ipairs(categoryOrder) do
        local category = categorized[catKey]
        if category and #category.items > 0 then
            for _, mat in ipairs(category.items) do
                local itemName = Data().GetCachedItemName(mat.id)
                if itemName then
                    -- Calculate how many more the player needs (subtract what they have)
                    local totalNeeded = mat.needed or mat.total or 1
                    local playerHas = Data().GetItemCountWithVariants(mat.id) or 0
                    local stillNeeded = math.max(0, totalNeeded - playerHas)

                    -- Only add to list if player still needs some
                    if stillNeeded > 0 then
                        local searchString
                        if hasQuantitySupport then
                            -- Use the new API with quantity support
                            local term = {
                                searchString = itemName,
                                isExact = true,
                                quantity = stillNeeded,
                            }
                            searchString = Auctionator.API.v1.ConvertToSearchString(callerID, term)
                        else
                            -- Fallback to plain item name
                            searchString = itemName
                        end
                        table.insert(searchStrings, searchString)
                        addedCount = addedCount + 1
                    else
                        skippedCount = skippedCount + 1
                    end
                end
            end
        end
    end

    if Auctionator.API.v1.CreateShoppingList and addedCount > 0 then
        -- CreateShoppingList replaces the list entirely, so we always create fresh with current quantities
        Auctionator.API.v1.CreateShoppingList(callerID, listName, searchStrings)
        local modeText = modeLabel == "raw" and " (raw materials)" or ""
        local qtyText = hasQuantitySupport and " (adjusted for inventory)" or ""
        local skippedText = skippedCount > 0 and string.format(", %d already owned", skippedCount) or ""
        print(string.format(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Added %d material(s)%s to Auctionator search list: %s%s%s",
            addedCount, qtyText, listName, modeText, skippedText))
    elseif addedCount == 0 then
        if skippedCount > 0 then
            print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r You already have all the materials needed!")
        else
            print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r No materials to add to search list")
        end
    end
end

-- ============================================================================
-- REFRESH PROFESSION TABS
-- ============================================================================

local professionList = { "Alchemy", "Blacksmithing", "Cooking", "Enchanting", "Engineering",
    "Inscription", "Jewelcrafting", "Leatherworking", "Tailoring" }

local COLLECTION_COL_RIGHT = -6
local RECIPES_COL_RIGHT = -68
local COL_WIDTH = 60

-- Enumerate expansions that have at least one loaded recipe, in canonical EXPANSION_ORDER.
local function GetAvailableRecipeExpansions()
    local present = {}
    if HDG_ProfessionsDB then
        for _, recipe in pairs(HDG_ProfessionsDB) do
            local norm = HDG_ExpansionData.NormalizeToDisplayName(recipe.expansion)
            if norm and norm ~= "Unknown" then
                present[norm] = true
            end
        end
    end
    local ordered = {}
    for _, name in ipairs(HDG_ExpansionData.EXPANSION_ORDER or {}) do
        if present[name] then
            table.insert(ordered, name)
        end
    end
    return ordered
end

local function EnsureProfessionTabFrames()
    if mainFrame.profTabsCreated then return end

    local scheme = GetScheme()
    local yOffset = 0

    -- Expansion filter dropdown (first row, above "All Professions")
    local expansionDropdown = HDG.UI:Dropdown(mainFrame.professionTabs, {
        width = CONFIG.LEFT_PANEL_WIDTH - 16,
        items = GetAvailableRecipeExpansions(),
        currentValue = HDG_Data.GetExpansionFilter(),
        allLabel = L["FILTER_ALL_EXPANSIONS"] or "All Expansions",
        colorFn = function(v)
            if not v or v == "All" then return nil end
            return HDG_ExpansionData.GetColor(v)
        end,
        onChange = function(v)
            HDG_Data.SetExpansionFilter(v or "All")
            HDG_UI.RefreshUI()
        end,
    })
    expansionDropdown:SetPoint("TOPLEFT", 0, yOffset)
    mainFrame.expansionDropdown = expansionDropdown
    -- Tint the current value if a specific expansion is selected
    do
        local current = HDG_Data.GetExpansionFilter()
        if current and current ~= "All" and expansionDropdown._text then
            local c = HDG_ExpansionData.GetColor(current)
            if c then expansionDropdown._text:SetTextColor(c[1], c[2], c[3], 1) end
        end
    end
    yOffset = yOffset - 26

    -- "All Professions" button
    local allBtn = CreateFrame("Button", nil, mainFrame.professionTabs, "BackdropTemplate")
    allBtn:SetSize(CONFIG.LEFT_PANEL_WIDTH - 16, 18)
    allBtn:SetPoint("TOPLEFT", 0, yOffset)
    allBtn:SetBackdrop({ bgFile = CONFIG.TEXTURES.WHITE_BG, tile = false })
    allBtn:SetBackdropColor(scheme.button_normal.r, scheme.button_normal.g, scheme.button_normal.b, 0.2)

    local allHighlight = allBtn:CreateTexture(nil, "HIGHLIGHT")
    allHighlight:SetAllPoints()
    allHighlight:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
    allHighlight:SetBlendMode("ADD")
    allHighlight:Hide()

    local allText = allBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    allText:SetPoint("LEFT", 6, 0)
    ApplyFont(allText, scheme, "small")

    allBtn.highlight = allHighlight
    allBtn.textLabel = allText
    mainFrame.allProfBtn = allBtn

    allBtn:SetScript("OnClick", function()
        HDG_Data.SetProfessionFilter("All")
        HDG_UI.RefreshUI()
    end)
    allBtn:SetScript("OnEnter", function(self)
        if not self.isSelected then
            local s = GetScheme()
            self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.4)
        end
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        local label = L["FILTER_ALL_PROFESSIONS"] or "All Professions"
        GameTooltip:AddLine(label, 1, 0.84, 0)
        GameTooltip:AddLine(HDG.Theme.C_Dim() .. "Show recipes from all professions|r")
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(HDG.Theme.C_Warning() .. "Tip:|r Use search to find recipes", 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    allBtn:SetScript("OnLeave", function(self)
        local s = GetScheme()
        if self.isSelected then
            self:SetBackdropColor(s.accent.r, s.accent.g, s.accent.b, 0.3)
        else
            self:SetBackdropColor(s.button_normal.r, s.button_normal.g, s.button_normal.b, 0.2)
        end
        GameTooltip:Hide()
    end)

    yOffset = yOffset - 20

    -- Separator line after "All Professions"
    local separator = mainFrame.professionTabs:CreateTexture(nil, "ARTWORK")
    separator:SetHeight(1)
    separator:SetPoint("TOPLEFT", 0, yOffset - 2)
    separator:SetPoint("TOPRIGHT", 0, yOffset - 2)
    separator:SetColorTexture(0.3, 0.3, 0.35, 0.5)
    mainFrame.profSeparator = separator
    yOffset = yOffset - 6

    -- Per-profession buttons
    mainFrame.profBtns = {}
    for _, profName in ipairs(professionList) do
        local btn = CreateFrame("Button", nil, mainFrame.professionTabs, "BackdropTemplate")
        btn:SetSize(CONFIG.LEFT_PANEL_WIDTH - 16, 18)
        btn:SetPoint("TOPLEFT", 0, yOffset)
        btn:SetBackdrop({ bgFile = CONFIG.TEXTURES.WHITE_BG, tile = false })
        btn:SetBackdropColor(scheme.button_normal.r, scheme.button_normal.g, scheme.button_normal.b, 0.2)

        local highlight = btn:CreateTexture(nil, "HIGHLIGHT")
        highlight:SetAllPoints()
        highlight:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
        highlight:SetBlendMode("ADD")
        highlight:Hide()

        local text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        text:SetPoint("LEFT", 6, 0)
        ApplyFont(text, scheme, "small")

        local collectionBadge = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        collectionBadge:SetPoint("RIGHT", COLLECTION_COL_RIGHT, 0)
        collectionBadge:SetWidth(COL_WIDTH)
        collectionBadge:SetJustifyH("LEFT")
        ApplyFont(collectionBadge, scheme, "small")

        local recipesBadge = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        recipesBadge:SetPoint("RIGHT", RECIPES_COL_RIGHT, 0)
        recipesBadge:SetWidth(COL_WIDTH)
        recipesBadge:SetJustifyH("LEFT")
        ApplyFont(recipesBadge, scheme, "small")

        -- Open profession window button (right edge, shown only if player has the profession)
        local openBtn = CreateFrame("Button", nil, btn)
        openBtn:SetSize(14, 14)
        openBtn:SetPoint("RIGHT", btn, "RIGHT", -2, 0)
        local openIcon = openBtn:CreateTexture(nil, "ARTWORK")
        openIcon:SetAllPoints()
        openIcon:SetAtlas("Profession")
        openIcon:SetAlpha(0.6)
        openBtn:SetScript("OnClick", function(self)
            local parent = self:GetParent()
            HDG_Data.OpenProfessionAtRecipe(parent.professionName)
        end)
        openBtn:SetScript("OnEnter", function(self)
            openIcon:SetAlpha(1.0)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:AddLine("Open Profession Window", 1, 0.84, 0)
            GameTooltip:Show()
        end)
        openBtn:SetScript("OnLeave", function(self)
            openIcon:SetAlpha(0.6)
            GameTooltip:Hide()
        end)
        openBtn:Hide()
        btn.openProfBtn = openBtn

        btn.highlight = highlight
        btn.textLabel = text
        btn.collectionBadge = collectionBadge
        btn.recipesBadge = recipesBadge
        btn.professionName = profName

        btn:SetScript("OnClick", function(self)
            HDG_Data.SetProfessionFilter(self.professionName)
            HDG_UI.RefreshUI()
        end)
        btn:SetScript("OnEnter", function(self)
            if not self.isSelected then
                local s = GetScheme()
                self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.4)
            end
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:AddLine(self.localizedProfName or profName, 1, 0.84, 0)
            GameTooltip:AddLine(self.hasProf and (HDG.Theme.C_Success() .. "You have this profession|r") or
                HDG.Theme.C_Dim() .. "You don't have this profession|r")
            GameTooltip:AddLine(" ")
            if self.hasProf then
                GameTooltip:AddLine(
                    string.format(HDG.Theme.C_Accent() .. "Recipes Known:|r %d / %d (%.0f%%)", self.recipesKnown or 0, self.totalCount or 0,
                        self.recipesPercentage or 0), 1, 1, 1)
                if self.altKnown and self.altKnown > (self.recipesKnown or 0) then
                    GameTooltip:AddLine(string.format(HDG.Theme.C_Dim() .. "Known by alts: %d/%d|r", self.altKnown, self.altTotal or 0), 0.78, 0.78, 0.78)
                end
            elseif self.altCharacter then
                GameTooltip:AddLine(HDG.Theme.C_Dim() .. "Known by alts|r", 0.78, 0.78, 0.78)
            end
            GameTooltip:AddLine(
                string.format(HDG.Theme.C_Warning() .. "Decor Collected:|r %d / %d (%.0f%%)", self.collectedCount or 0, self.totalCount or 0,
                    self.collectionPercentage or 0), 1, 1, 1)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function(self)
            local s = GetScheme()
            if self.isSelected then
                self:SetBackdropColor(s.accent.r, s.accent.g, s.accent.b, 0.3)
            else
                self:SetBackdropColor(s.button_normal.r, s.button_normal.g, s.button_normal.b, 0.2)
            end
            GameTooltip:Hide()
        end)

        mainFrame.profBtns[profName] = btn
        yOffset = yOffset - 20
    end

    mainFrame.profTabsCreated = true
end

function HDG_RecipesTab:RefreshProfessionTabs()
    if not mainFrame or not mainFrame.professionTabs then return end

    EnsureProfessionTabFrames()

    local currentFilter = HDG_Data.GetProfessionFilter()
    local filtescheme = GetScheme()
    local allProfsLabel = L["FILTER_ALL_PROFESSIONS"] or "All Professions"

    -- Update "All Professions" button
    local allBtn = mainFrame.allProfBtn
    local isAllSelected = (currentFilter == "All")
    allBtn.isSelected = isAllSelected
    if isAllSelected then
        allBtn:SetBackdropColor(filtescheme.accent.r, filtescheme.accent.g, filtescheme.accent.b, 0.3)
        allBtn.highlight:SetVertexColor(1, 1, 1, 0.5); allBtn.highlight:Show()
        allBtn.textLabel:SetText(HDG.Theme.C_Warning() .. allProfsLabel .. "|r")
    else
        allBtn:SetBackdropColor(filtescheme.button_normal.r, filtescheme.button_normal.g, filtescheme.button_normal.b, 0.2)
        allBtn.highlight:Hide()
        allBtn.textLabel:SetText(HDG.Theme.C_Text() .. allProfsLabel .. "|r")
    end

    -- Update per-profession buttons (also accumulate grand totals in one pass)
    local grandTotalRecipes, grandKnownRecipes, grandTotalDecor, grandCollectedDecor = 0, 0, 0, 0
    local D = Data()
    for _, profName in ipairs(professionList) do
        local btn = mainFrame.profBtns[profName]
        local localizedProfName = HDG_ExpansionData.GetLocalizedProfessionName(profName)
        local hasProf = D.PlayerHasProfession(profName)
        local recipesKnown, collectedCount, totalCount = 0, 0, 0

        for _, profData in ipairs(privateDB.professions or {}) do
            if profData.name == profName and profData.items then
                for _, recipe in ipairs(profData.items) do
                    totalCount = totalCount + 1
                    if D.PlayerKnowsRecipeBySpellID and D.PlayerKnowsRecipeBySpellID(recipe.spell) then
                        recipesKnown = recipesKnown + 1
                    end
                    if D.IsItemCollected(recipe.id) then
                        collectedCount = collectedCount + 1
                    end
                end
                break
            end
        end
        grandTotalRecipes = grandTotalRecipes + totalCount
        grandTotalDecor = grandTotalDecor + totalCount
        grandCollectedDecor = grandCollectedDecor + collectedCount
        local bestAltKnown = D.GetBestAltRecipeCount(profName)
        grandKnownRecipes = grandKnownRecipes + math.max(recipesKnown, bestAltKnown)

        local recipesPercentage = totalCount > 0 and (recipesKnown / totalCount * 100) or 0
        local collectionPercentage = totalCount > 0 and (collectedCount / totalCount * 100) or 0
        local isSelected = (currentFilter == profName)
        btn.isSelected = isSelected

        if isSelected then
            btn:SetBackdropColor(filtescheme.accent.r, filtescheme.accent.g, filtescheme.accent.b, 0.3)
            btn.highlight:SetVertexColor(1, 1, 1, 0.5); btn.highlight:Show()
        else
            btn:SetBackdropColor(filtescheme.button_normal.r, filtescheme.button_normal.g, filtescheme.button_normal.b, 0.2)
            btn.highlight:Hide()
        end

        btn.textLabel:SetText(profName) -- English only (layout policy)

        local collectionColor = collectionPercentage >= 100 and HDG.Theme.C_Success() or HDG.Theme.C_Warning()
        btn.collectionBadge:SetText(string.format("%s%d/%d|r", collectionColor, collectedCount, totalCount))

        local altKnown, altTotal, altChar = D.GetBestAltRecipeCount(profName)
        if hasProf and altKnown > recipesKnown then
            -- Alt knows more: show alt count in white
            btn.recipesBadge:SetText(string.format(HDG.Theme.C_Text() .. "%d/%d|r", altKnown, altTotal))
            btn.altCharacter = altChar
        elseif hasProf then
            local recipesColor = recipesPercentage >= 100 and HDG.Theme.C_Success() or HDG.Theme.C_Accent()
            btn.recipesBadge:SetText(string.format("%s%d/%d|r", recipesColor, recipesKnown, totalCount))
            btn.altCharacter = nil
        elseif altKnown > 0 then
            btn.recipesBadge:SetText(string.format(HDG.Theme.C_Text() .. "%d/%d|r", altKnown, altTotal))
            btn.altCharacter = altChar
        else
            btn.recipesBadge:SetText(HDG.Theme.C_Dim() .. "--|r")
            btn.altCharacter = nil
        end

        local profColor
        if isSelected or hasProf then
            profColor = filtescheme.text_recipe_known or {r=0.80, g=0.29, b=0.09, a=1}
        elseif altKnown > 0 then
            profColor = filtescheme.text_recipe_alt or {r=1, g=1, b=1, a=1}
        else
            profColor = filtescheme.text_recipe_no_prof or {r=0.56, g=0.56, b=0.56, a=1}
        end
        btn.textLabel:SetTextColor(profColor.r, profColor.g, profColor.b, profColor.a)

        btn.localizedProfName = localizedProfName
        btn.hasProf = hasProf
        if btn.openProfBtn then btn.openProfBtn:SetShown(hasProf) end
        btn.recipesKnown = recipesKnown
        btn.collectedCount = collectedCount
        btn.totalCount = totalCount
        btn.recipesPercentage = recipesPercentage
        btn.collectionPercentage = collectionPercentage
        btn.altKnown = altKnown
        btn.altTotal = altTotal
    end

    local collectionPct = grandTotalDecor > 0 and (grandCollectedDecor / grandTotalDecor * 100) or 0
    local recipesPct = grandTotalRecipes > 0 and (grandKnownRecipes / grandTotalRecipes * 100) or 0

    -- Update header percentages
    if mainFrame.headerCollectionPct then
        mainFrame.headerCollectionPct:SetText(string.format("%s%.0f%%|r",
            collectionPct >= 100 and HDG.Theme.C_Success() or HDG.Theme.C_Warning(), collectionPct))
    end
    if mainFrame.headerRecipesPct then
        mainFrame.headerRecipesPct:SetText(string.format("%s%.0f%%|r", recipesPct >= 100 and HDG.Theme.C_Success() or HDG.Theme.C_Accent(),
            recipesPct))
    end

    -- Set up tooltips on header frames with totals
    if mainFrame.recipesHeaderFrame then
        mainFrame.recipesHeaderFrame:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:AddLine(HDG.Theme.C_Accent() .. "Recipes Known|r", 1, 1, 1)
            GameTooltip:AddLine(
            string.format("Total: %d / %d (%.0f%%)", grandKnownRecipes, grandTotalRecipes, recipesPct), 0.9, 0.9, 0.9)
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Includes recipes known by you and alts", 0.6, 0.6, 0.6)
            GameTooltip:Show()
        end)
        mainFrame.recipesHeaderFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    if mainFrame.collectionHeaderFrame then
        mainFrame.collectionHeaderFrame:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:AddLine(HDG.Theme.C_Warning() .. "Decor Collected|r", 1, 1, 1)
            GameTooltip:AddLine(
            string.format("Total: %d / %d (%.0f%%)", grandCollectedDecor, grandTotalDecor, collectionPct), 0.9, 0.9, 0.9)
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Account-wide decor collection progress", 0.6, 0.6, 0.6)
            GameTooltip:Show()
        end)
        mainFrame.collectionHeaderFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    -- Update title based on selection
    if mainFrame.recipeTitle then
        if HDG_Data.GetProfessionFilter() == "All" then
            mainFrame.recipeTitle:SetText(allProfsLabel)
        else
            mainFrame.recipeTitle:SetText(L["RECIPE_PROFESSIONS"] or "Professions")
        end
    end
end

-- ============================================================================
-- REFRESH RECIPES (content area)
-- ============================================================================

function HDG_RecipesTab:RefreshRecipes()
    if not mainFrame or not mainFrame.recipeScrollBox then return end

    if mainFrame.UpdateCraftableButton then mainFrame.UpdateCraftableButton() end

    -- Per-refresh CanCraftRecipe cache cleared before building the DataProvider,
    -- not inside the initializer (the latter would rerun on every scroll-in).
    craftabilityCache = {}
    HideProfessionSectionHeader()
    self:RefreshProfessionTabs()

    local dp = CreateDataProvider()
    local playerProfessions = Data().GetPlayerProfessions()
    local isAllMode = (HDG_Data.GetProfessionFilter() == "All")
    local isCraftableMode = HDG_Data.IsCraftableFilterEnabled()

    if #playerProfessions == 1 then
        UpdateProfessionSectionHeader(playerProfessions[1].name)
        if mainFrame.stickyHeader then mainFrame.stickyHeader:Hide() end
    else
        local allProfsLabel = L["FILTER_ALL_PROFESSIONS"] or "All Professions"
        UpdateProfessionSectionHeader(allProfsLabel)
        if mainFrame.stickyHeader then mainFrame.stickyHeader:Hide() end
    end

    if isAllMode and isCraftableMode then
        -- Unified tier list across all professions; no per-profession headers.
        local tier100, tier75to99, tier50to74, tierBelow50 = {}, {}, {}, {}
        for _, profData in ipairs(playerProfessions) do
            local profName = profData.name
            local hasProfession = HDG_Data.PlayerHasProfession(profName)
            for _, recipe in ipairs(profData.items) do
                if HDG_Data.RecipeMatchesFilters(recipe) then
                    local isKnown = HDG_Data.PlayerKnowsRecipeBySpellID(recipe.spell)
                    local canCraft, maxQty, _, matPercent = GetCachedCraftability(recipe)
                    recipe._maxCraftable = maxQty
                    recipe._materialPercent = matPercent
                    recipe._noProfession = not hasProfession
                    recipe._professionName = profName
                    if hasProfession and isKnown and canCraft and maxQty > 0 then
                        table.insert(tier100, recipe)
                    elseif matPercent >= 75 then
                        table.insert(tier75to99, recipe)
                    elseif matPercent >= 50 then
                        table.insert(tier50to74, recipe)
                    elseif matPercent > 0 then
                        table.insert(tierBelow50, recipe)
                    end
                end
            end
        end
        table.sort(tier75to99, sortByPercent)
        table.sort(tier50to74, sortByPercent)
        table.sort(tierBelow50, sortByPercent)

        PushTierGroup(dp, tier100, L["RECIPE_TIER_READY"], HDG.Theme.C_Success(), true)
        PushTierGroup(dp, tier75to99, L["RECIPE_TIER_ALMOST"], HDG.Theme.C_Success(), true)
        PushTierGroup(dp, tier50to74, L["RECIPE_TIER_HALFWAY"], HDG.Theme.C_Warning(), true)
        PushTierGroup(dp, tierBelow50, L["RECIPE_TIER_NEED_MATS"], HDG.Theme.C_Error(), true)
    else
        for _, profData in ipairs(playerProfessions) do
            local showInlineHeader = isAllMode and not isCraftableMode
            local skipFixedHeader = isAllMode
            PushProfession(dp, profData, showInlineHeader, skipFixedHeader)
        end
    end

    -- RetainScrollPosition so click-to-add-to-queue (which cascades to
    -- HDG_UI.RefreshUI -> RefreshRecipes) doesn't jump the list back to top.
    -- ScrollBox clamps to data bounds automatically if the list shrinks.
    mainFrame.recipeScrollBox:SetDataProvider(dp, ScrollBoxConstants.RetainScrollPosition)
end

-- ============================================================================
-- MAIN REFRESH (called by HDG_UI.RefreshUI)
-- ============================================================================

function HDG_RecipesTab:Refresh()
    self:RefreshRecipes()
    self:RefreshCraftingQueue()
    self:RefreshMaterials()
end

-- ============================================================================
-- SHOW/HIDE
-- ============================================================================

function HDG_RecipesTab:Show()
    if mainFrame.recipeFrame then mainFrame.recipeFrame:Show() end
    if mainFrame.queueFrame then mainFrame.queueFrame:Show() end
    if mainFrame.materialsFrame then mainFrame.materialsFrame:Show() end
    -- Initialize lumber layout based on pin state
    if HDG_UI and HDG_UI.UpdateLumberLayout then
        HDG_UI.UpdateLumberLayout()
    end
    self:Refresh()
end

function HDG_RecipesTab:Hide()
    if mainFrame.recipeFrame then mainFrame.recipeFrame:Hide() end
    if mainFrame.queueFrame then mainFrame.queueFrame:Hide() end
    if mainFrame.materialsFrame then mainFrame.materialsFrame:Hide() end
end

-- ============================================================================
-- EVENTBUS SUBSCRIPTIONS
-- Decouple UI refresh from direct function calls
-- ============================================================================

local function RegisterEventBusHandlers()
    if not HDG or not HDG.EventBus then return end

    HDG.EventBus:Register("HDG_STATE_CHANGED", function(payload)
        if not payload or not payload.action then return end

        local action = payload.action

        -- Crafting queue changes: refresh queue and materials
        if action == "ADD_TO_CRAFTING_QUEUE" or
           action == "REMOVE_FROM_CRAFTING_QUEUE" or
           action == "UPDATE_CRAFTING_QUEUE_ITEM_QTY" or
           action == "CLEAR_CRAFTING_QUEUE" then
            HDG_RecipesTab:RefreshCraftingQueue()
            HDG_RecipesTab:RefreshMaterials()
        end

        -- Config changes that affect recipe display
        if action == "SET_CONFIG" then
            -- Could check payload for specific config keys if needed
            HDG_RecipesTab:RefreshRecipes()
        end

        -- UI filter changes
        if action == "SET_UI" then
            HDG_RecipesTab:RefreshRecipes()
        end
    end)

    -- Collection data ready (async catalog search completed) — refresh visible tab
    HDG.EventBus:Register("HDG_COLLECTION_READY", function()
        if mainFrame and mainFrame:IsShown() then
            HDG_RecipesTab:RefreshRecipes()
        end
    end)
end

RegisterEventBusHandlers()

-- ============================================================================
-- EXPORT TO GLOBAL
-- ============================================================================

_G.HDG_RecipesTab = HDG_RecipesTab

return HDG_RecipesTab
