-- ============================================================================
-- HDG_GoblinTab.lua - Profit Analysis Tab
-- ============================================================================
-- Shows all decor items ranked by profit potential with lumber value analysis.
-- Helps gold-makers identify the most profitable crafts.
-- ============================================================================

local addonName, privateDB = ...

HDG_GoblinTab = {}

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


-- Local references
local CONFIG
local L = HDG_L or {}  -- Localization
local mainFrame
local goblinFrame

-- State
local profitData = {}           -- Calculated profit data for all items
local filteredData = {}         -- After applying filters
local currentSort = "profit"    -- profit, margin, lumber, sell, name
local sortAscending = false     -- false = descending (highest first)
local professionFilter = "All"
local expansionFilter = "All"
local queuedOnly = false
local hideQueued = false

local onlyKnownRecipes = false
local knownByAlts = false
local expandedRowKey = nil      -- Which row is currently expanded (recipeKey)
local searchFilter = ""         -- Search filter text
local scanState = "idle"        -- "idle" | "scanning" | "complete"
local scanAnimTicker = nil      -- Animated dots ticker during scan
local scanAnimStep = 0          -- 0-2 for dot animation
local scanTimeoutTimer = nil    -- Safety timeout if scan callback never fires
local freshnessTimer = nil      -- C_Timer ticker for updating freshness text
local searchDebounceTimer = nil -- Debounce for search input
local knownRecipeIndex = nil    -- [spellID] = true; built once per CalculateProfitData
local onlyMyAuctions = false    -- Filter to items player has listed on AH
local ownedAuctions = {}        -- [itemID] = { qty, buyout, sold } from C_AuctionHouse
local ownedAuctionsStale = true -- true until first successful query
local myAuctionsCheck           -- forward ref for checkbox widget

-- Row pool for efficient scrolling
local rowPool

-- UI elements
local scrollFrame, scrollContent
local scanButton, priceSourceText, itemCountText, exportButton
local expansionPanel            -- The expandable material breakdown panel
local activeDropdownMenu = nil  -- Track active dropdown menu for cleanup
local columnHeaders = {}        -- Track column header buttons for sort indicators

-- Forward declarations
local UpdateScanButton
local ShowExpansionPanel
local GetActivePriceAddonName
local CollectPriceableItemIDs
local StopScanAnim
local expansionPanelItem = nil
local expansionPanelRow = nil

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

function HDG_GoblinTab.Init(frame)
    mainFrame = frame
    CONFIG = privateDB.CONFIG

    -- Restore persisted owned auctions from the Store
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    if store and store.state and type(store.state.ownedAuctions) == "table"
        and next(store.state.ownedAuctions) then
        ownedAuctions = store.state.ownedAuctions
        ownedAuctionsStale = false
    end

    -- Register for AH open/close events to auto-update scan button + owned auctions
    local eventFrame = CreateFrame("Frame")
    eventFrame:RegisterEvent("AUCTION_HOUSE_SHOW")
    eventFrame:RegisterEvent("AUCTION_HOUSE_CLOSED")
    eventFrame:RegisterEvent("OWNED_AUCTIONS_UPDATED")
    eventFrame:SetScript("OnEvent", function(_, event)
        if event == "OWNED_AUCTIONS_UPDATED" then
            HDG_GoblinTab.ProcessOwnedAuctions()
            return
        end
        if event == "AUCTION_HOUSE_SHOW" then
            HDG_GoblinTab.QueryOwnedAuctions()
        end
        if event == "AUCTION_HOUSE_CLOSED" and HDG_PriceIntegration.IsScanInProgress() then
            HDG_PriceIntegration.StopDirectScan()
            StopScanAnim()
            scanState = "idle"
        end
        if goblinFrame and goblinFrame:IsShown() then
            UpdateScanButton()
        end
    end)
end

-- Query the player's owned auctions (requires AH to be open)
function HDG_GoblinTab.QueryOwnedAuctions()
    if C_AuctionHouse and C_AuctionHouse.QueryOwnedAuctions then
        C_AuctionHouse.QueryOwnedAuctions({}) -- triggers OWNED_AUCTIONS_UPDATED
    end
end

-- Process the response from C_AuctionHouse.GetOwnedAuctions()
function HDG_GoblinTab.ProcessOwnedAuctions()
    if not C_AuctionHouse or not C_AuctionHouse.GetOwnedAuctions then return end
    local auctions = C_AuctionHouse.GetOwnedAuctions()
    if not auctions then return end

    ownedAuctions = {}
    for _, info in ipairs(auctions) do
        local itemID = info.itemKey and info.itemKey.itemID
        if itemID then
            local isActive = (info.status == 0) -- Enum.AuctionStatus.Active
            local isSold = (info.status == 1)   -- Enum.AuctionStatus.Sold
            if isActive or isSold then
                local existing = ownedAuctions[itemID]
                if existing then
                    if isActive then
                        existing.qty = existing.qty + (info.quantity or 1)
                        if info.buyoutAmount and (not existing.buyout or info.buyoutAmount < existing.buyout) then
                            existing.buyout = info.buyoutAmount
                        end
                    else
                        existing.sold = existing.sold + (info.quantity or 1)
                    end
                else
                    ownedAuctions[itemID] = {
                        qty = isActive and (info.quantity or 1) or 0,
                        buyout = isActive and info.buyoutAmount or nil,
                        sold = isSold and (info.quantity or 1) or 0,
                    }
                end
            end
        end
    end
    ownedAuctionsStale = false

    -- Persist via Store (debounced save to SavedVariables)
    local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    if store and store.state then
        store.state.ownedAuctions = ownedAuctions
        if store.QueueSave then store:QueueSave() end
    end

    -- Refresh display if goblin tab is visible
    if goblinFrame and goblinFrame:IsShown() then
        HDG_GoblinTab.RefreshDisplay()
    end
end

-- ============================================================================
-- PROFIT CALCULATION
-- ============================================================================

-- Build a flat O(1) index of all known recipe spellIDs across all characters
local function BuildKnownRecipeIndex()
    knownRecipeIndex = {}
    if not HDG.Store.state.characters then return end
    for _, charData in pairs(HDG.Store.state.characters) do
        if charData.professions then
            for _, profData in pairs(charData.professions) do
                if profData.knownRecipes then
                    for spellID in pairs(profData.knownRecipes) do
                        knownRecipeIndex[spellID] = true
                    end
                end
            end
        end
    end
end

-- Check if any alt (including current char) knows a recipe by spellID
local function AnyCharacterKnowsRecipe(spellID)
    if not knownRecipeIndex then BuildKnownRecipeIndex() end
    return spellID and knownRecipeIndex[spellID] or false
end

-- Calculate profit data for all decor items
-- Get correct itemID, preferring ProfessionsDB (more accurate) over DecorDB
local function GetCorrectItemID(recipe)
    -- First try ProfessionsDB using spellID (most accurate source)
    if recipe.spellID and HDG_ProfessionsDB then
        local profData = HDG_ProfessionsDB[recipe.spellID]
        if profData and profData.itemID then
            return profData.itemID
        end
    end
    -- Fallback to DecorDB itemID
    return recipe.itemID
end

local function CalculateProfitData()
    profitData = {}
    BuildKnownRecipeIndex()

    if not HDG_DecorDB then return end
    for recipeKey, recipe in pairs(HDG_DecorDB) do
        local itemID = GetCorrectItemID(recipe)
        if itemID then
            local sellPrice, priceSource = HDG_PriceIntegration.GetItemPrice(itemID)

            -- Calculate material cost
            local materialCost = 0
            local lumberQty = 0
            local lumberCost = 0
            local lumberType = nil  -- Store which lumber type this recipe uses
            local hasAllPrices = true

            if recipe.reagents then
                for reagentID, reagentData in pairs(recipe.reagents) do
                    local qty = reagentData.qty or 1

                    -- Check if this is lumber (BoP, so it has 0 AH price)
                    if HDG_ExpansionData.IsLumber(reagentID) then
                        lumberQty = lumberQty + qty
                        lumberType = reagentID  -- Remember which lumber type
                        -- Lumber is BoP, so we don't add its AH cost
                    elseif HDG_ExpansionData.IsBoPReagent(reagentID) then
                        -- Other BoP reagents (e.g., Primal Spirit) - skip AH cost
                    else
                        local matPrice = HDG_PriceIntegration.GetItemPrice(reagentID)
                        if matPrice then
                            materialCost = materialCost + (matPrice * qty)
                        else
                            hasAllPrices = false
                        end
                    end
                end
            end

            -- Calculate profit metrics
            local profit = sellPrice and (sellPrice - materialCost) or nil
            local margin = (sellPrice and sellPrice > 0) and (profit / sellPrice * 100) or nil

            -- Lumber value: what you "earn" per lumber by crafting this item
            -- Formula: (sell_price - non_lumber_material_cost) / lumber_qty
            local lumberValue = nil
            if lumberQty > 0 and sellPrice then
                lumberValue = (sellPrice - materialCost) / lumberQty
            end

            -- Get normalized expansion name for filtering
            local expansion = HDG_ExpansionData.NormalizeToDisplayName(recipe.expansion)

            -- Check if player knows this recipe
            local isKnown = false
            if _G.HDG_Data and _G.HDG_Data.PlayerKnowsRecipeBySpellID then
                isKnown = _G.HDG_Data.PlayerKnowsRecipeBySpellID(recipe.spellID)
            end

            -- Check if any alt knows this recipe (uses stored knownRecipes data)
            local isKnownByAlts = AnyCharacterKnowsRecipe(recipe.spellID)

            -- Get TSM region sale average (TSM only)
            local regionSaleAvg = HDG_PriceIntegration.GetRegionSaleAvg(itemID)

            -- Calculate TSM margin based on region avg instead of sell price
            local tsmMargin = nil
            if regionSaleAvg and regionSaleAvg > 0 and materialCost then
                tsmMargin = ((regionSaleAvg - materialCost) / regionSaleAvg) * 100
            end

            local quality = 1 -- all names use scheme text color

            table.insert(profitData, {
                recipeKey = recipeKey,
                recipe = recipe,
                itemID = itemID,
                name = HDG_Data.GetLocalizedName(itemID, recipe.name),
                profession = recipe.profession or "Unknown",
                expansion = expansion,
                quality = quality,
                sellPrice = sellPrice,
                materialCost = materialCost,
                profit = profit,
                margin = margin,
                lumberQty = lumberQty,
                lumberType = lumberType,
                lumberValue = lumberValue,
                hasAllPrices = hasAllPrices,
                isKnown = isKnown,
                isKnownByAlts = isKnownByAlts,
                priceSource = priceSource,
                regionSaleAvg = regionSaleAvg,
                tsmMargin = tsmMargin,
            })
        end
    end
end

-- Public read-only access for cockpit widgets. Returns up to N profitData
-- rows sorted by lumberValue desc (only items where lumberValue is set).
-- Lazily triggers a calc if profitData is empty so the cockpit can render
-- before the user ever opens the Goblin tab.
function HDG_GoblinTab.GetTopByLumberValue(n)
    if #profitData == 0 then
        CalculateProfitData()
    end
    local out = {}
    for _, item in ipairs(profitData) do
        if item.lumberValue and item.lumberValue > 0 then
            out[#out + 1] = item
        end
    end
    table.sort(out, function(a, b) return a.lumberValue > b.lumberValue end)
    local cap = n or 5
    for i = #out, cap + 1, -1 do out[i] = nil end
    return out
end

-- Apply filters and sorting to profit data
local function ApplyFiltersAndSort()
    filteredData = {}

    -- Build queue lookup once per filter pass: { [itemID] = qty } and { [spellID] = qty }
    -- Used both for the queued-stripe row indicator and the Queued/Hide-queued filters.
    local queueByItem, queueBySpell = {}, {}
    local queue = (HDG.Store and HDG.Store.state and HDG.Store.state.craftingQueue) or {}
    for _, q in ipairs(queue) do
        if q.id then queueByItem[q.id] = q.quantity or 1 end
        if q.spell then queueBySpell[q.spell] = q.quantity or 1 end
    end

    for _, item in ipairs(profitData) do
        local include = true

        -- Annotate queue state on the item (consumed by row render and filter logic below)
        local qty = (item.itemID and queueByItem[item.itemID])
                 or (item.recipe and item.recipe.spellID and queueBySpell[item.recipe.spellID])
        item._queuedQty = qty   -- nil if not queued

        -- Filter by search text
        if searchFilter and searchFilter ~= "" then
            local searchLower = searchFilter:lower()
            if not string.find(item.name:lower(), searchLower, 1, true) then
                include = false
            end
        end

        -- Filter by profession
        if include and professionFilter ~= "All" and item.profession ~= professionFilter then
            include = false
        end

        -- Filter by expansion
        if include and expansionFilter ~= "All" and item.expansion ~= expansionFilter then
            include = false
        end


        -- Filter to only known recipes (current character)
        if include and onlyKnownRecipes and not item.isKnown then
            include = false
        end

        -- Filter to known by alts (any character including current)
        if include and knownByAlts and not item.isKnownByAlts then
            include = false
        end

        -- Filter to only items currently listed or sold on AH
        if include and onlyMyAuctions then
            local ai = ownedAuctions[item.itemID]
            if not ai or (ai.qty == 0 and ai.sold == 0) then include = false end
        end

        -- Filter by crafting-queue state (mutually exclusive checkboxes)
        if include and queuedOnly and not item._queuedQty then include = false end
        if include and hideQueued and item._queuedQty then include = false end

        if include then
            table.insert(filteredData, item)
        end
    end

    -- Sort
    local sortKey = currentSort
    table.sort(filteredData, function(a, b)
        local aVal, bVal

        if sortKey == "profit" then
            aVal = a.profit or -999999999
            bVal = b.profit or -999999999
        elseif sortKey == "margin" then
            aVal = a.margin or -999999999
            bVal = b.margin or -999999999
        elseif sortKey == "lumber" then
            aVal = a.lumberValue or -999999999
            bVal = b.lumberValue or -999999999
        elseif sortKey == "lumberTotal" then
            aVal = (a.lumberValue or 0) * (a.lumberQty or 0)
            bVal = (b.lumberValue or 0) * (b.lumberQty or 0)
            if aVal == 0 then aVal = -999999999 end
            if bVal == 0 then bVal = -999999999 end
        elseif sortKey == "lumberType" then
            -- Sort by lumber type name
            local aInfo = a.lumberType and HDG_ExpansionData.GetLumberInfo(a.lumberType)
            local bInfo = b.lumberType and HDG_ExpansionData.GetLumberInfo(b.lumberType)
            aVal = aInfo and aInfo.name or "zzz"
            bVal = bInfo and bInfo.name or "zzz"
            if sortAscending then
                return aVal < bVal
            else
                return aVal > bVal
            end
        elseif sortKey == "sell" then
            aVal = a.sellPrice or 0
            bVal = b.sellPrice or 0
        elseif sortKey == "cost" then
            aVal = a.materialCost or 0
            bVal = b.materialCost or 0
        elseif sortKey == "name" then
            aVal = a.name or ""
            bVal = b.name or ""
            if sortAscending then
                return aVal < bVal
            else
                return aVal > bVal
            end
        elseif sortKey == "regionSaleAvg" then
            aVal = a.regionSaleAvg or -999999999
            bVal = b.regionSaleAvg or -999999999
        elseif sortKey == "tsmMargin" then
            aVal = a.tsmMargin or -999999999
            bVal = b.tsmMargin or -999999999
        end

        if sortAscending then
            return aVal < bVal
        else
            return aVal > bVal
        end
    end)
end

-- ============================================================================
-- CSV EXPORT
-- ============================================================================

-- Format copper value as gold string for CSV (no color codes)
local function FormatGoldForCSV(copper)
    if not copper then return "" end
    local gold = math.floor(copper / 10000)
    local silver = math.floor((copper % 10000) / 100)
    local copperVal = copper % 100
    return string.format("%d.%02d.%02d", gold, silver, copperVal)
end

-- Generate CSV content from filtered data
local function GenerateCSV()
    -- Header row with all columns
    local csv = "Name,ItemID,Profession,Expansion,Quality,LumberType,LumberQty,LumberValuePer,LumberValueTotal,MaterialCost,SellPrice,Profit,Margin%,RegionAvg,TSMMargin%,KnownByPlayer,KnownByAlts,PriceSource\n"

    for _, item in ipairs(filteredData) do
        -- Escape commas and quotes in name
        local name = item.name or "Unknown"
        name = name:gsub('"', '""')
        if name:find(",") then
            name = '"' .. name .. '"'
        end

        -- Get lumber type name
        local lumberTypeName = ""
        if item.lumberType then
            local lumberInfo = HDG_ExpansionData.GetLumberInfo(item.lumberType)
            lumberTypeName = lumberInfo and lumberInfo.name or ("Lumber " .. item.lumberType)
        end

        -- Calculate total lumber value
        local lumberTotal = ""
        if item.lumberValue and item.lumberQty then
            lumberTotal = FormatGoldForCSV(item.lumberValue * item.lumberQty)
        end

        -- Build CSV row
        local row = string.format("%s,%s,%s,%s,%d,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s",
            name,
            item.itemID or "",
            item.profession or "",
            item.expansion or "",
            item.quality or 1,
            lumberTypeName,
            item.lumberQty or "",
            item.lumberValue and FormatGoldForCSV(item.lumberValue) or "",
            lumberTotal,
            item.materialCost and FormatGoldForCSV(item.materialCost) or "",
            item.sellPrice and FormatGoldForCSV(item.sellPrice) or "",
            item.profit and FormatGoldForCSV(item.profit) or "",
            item.margin and string.format("%.1f", item.margin) or "",
            item.regionSaleAvg and FormatGoldForCSV(item.regionSaleAvg) or "",
            item.tsmMargin and string.format("%.1f", item.tsmMargin) or "",
            item.isKnown and "Yes" or "No",
            item.isKnownByAlts and "Yes" or "No",
            item.priceSource or ""
        )
        csv = csv .. row .. "\n"
    end

    return csv
end

-- Show export window with CSV content
local function ShowCSVExportWindow()
    if #filteredData == 0 then
        print(CONFIG.TEXT_COLORS.WOW_GOLD .. "[HDG]|r " .. (L["GOBLIN_NO_DATA_EXPORT"] or "No data to export. Adjust filters or scan prices first."))
        return
    end

    local csvText = GenerateCSV()
    local rowCount = #filteredData

    if not HDG.csvExportCtrl then
        HDG.csvExportCtrl = HDG_UIHelpers.CreateExportDialog({ width = 600, height = 450 })
    end
    local title = string.format(L["GOBLIN_EXPORT_TITLE"] or "Goblin Export (%d items)", rowCount)
    HDG.csvExportCtrl:Show(csvText, HDG.Theme.C_Accent() .. title .. "|r")
end

-- ============================================================================
-- EXPANSION PANEL (Material Breakdown)
-- ============================================================================

-- Calculate direct and raw materials for a recipe
local function CalculateMaterialBreakdown(item)
    local directMaterials = {}
    local rawMaterials = {}
    local directTotal = 0
    local rawTotal = 0

    if not item or not item.recipe or not item.recipe.reagents then
        return directMaterials, rawMaterials, directTotal, rawTotal
    end

    -- Direct materials from the recipe
    for reagentID, reagentData in pairs(item.recipe.reagents) do
        local qty = reagentData.qty or 1
        local price = HDG_PriceIntegration.GetItemPrice(reagentID)
        local totalPrice = price and (price * qty) or 0
        local name = reagentData.name or ("Item " .. reagentID)

        -- Get name from ReagentsDB if available
        if HDG_ReagentsDB and HDG_ReagentsDB[reagentID] then
            name = HDG_ReagentsDB[reagentID][2] or name
        end

        table.insert(directMaterials, {
            itemID = reagentID,
            name = name,
            qty = qty,
            unitPrice = price,
            totalPrice = totalPrice,
            isLumber = HDG_ExpansionData.IsLumber(reagentID),
        })

        if not HDG_ExpansionData.IsLumber(reagentID) then
            directTotal = directTotal + totalPrice
        end
    end

    -- Raw materials using PowerCrafting if available
    if HDG_PowerCrafting and HDG_PowerCrafting.BuildDependencyGraph then
        local graph = HDG_PowerCrafting.BuildDependencyGraph(item.recipe)
        if graph and graph.rawMaterials then
            for matID, matData in pairs(graph.rawMaterials) do
                local qty = matData.qty or 1
                local price = HDG_PriceIntegration.GetItemPrice(matID)
                local totalPrice = price and (price * qty) or 0
                local name = matData.name or ("Item " .. matID)

                table.insert(rawMaterials, {
                    itemID = matID,
                    name = name,
                    qty = qty,
                    unitPrice = price,
                    totalPrice = totalPrice,
                    isLumber = HDG_ExpansionData.IsLumber(matID),
                })

                if not HDG_ExpansionData.IsLumber(matID) then
                    rawTotal = rawTotal + totalPrice
                end
            end
        end
    end

    -- Sort by total price descending
    table.sort(directMaterials, function(a, b) return (a.totalPrice or 0) > (b.totalPrice or 0) end)
    table.sort(rawMaterials, function(a, b) return (a.totalPrice or 0) > (b.totalPrice or 0) end)

    return directMaterials, rawMaterials, directTotal, rawTotal
end

-- Request item info and schedule refresh if needed


-- Create or update the expansion panel
ShowExpansionPanel = function(parentRow, item)
    if not expansionPanel then
        expansionPanel = CreateFrame("Frame", nil, scrollContent, "BackdropTemplate")
        expansionPanel:SetBackdrop({
            bgFile = CONFIG.TEXTURES.WHITE_BG,
            edgeFile = CONFIG.TEXTURES.WHITE_BG,
            edgeSize = 1,
        })
        local expPanelScheme = GetScheme()
        if expPanelScheme then
            expansionPanel:SetBackdropColor(expPanelScheme.panel.r, expPanelScheme.panel.g, expPanelScheme.panel.b, 0.95)
            expansionPanel:SetBackdropBorderColor(expPanelScheme.accent.r, expPanelScheme.accent.g, expPanelScheme.accent.b, 0.8)
            RegisterWidget(expansionPanel, "Panel")
        else
            expansionPanel:SetBackdropColor(unpack(CONFIG.COLORS.PANEL_DARK))
            expansionPanel:SetBackdropBorderColor(0.2, 0.6, 0.5, 0.8)
        end

        -- Create text elements for materials
        expansionPanel.directTitle = expansionPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        expansionPanel.directTitle:SetPoint("TOPLEFT", 10, -8)
        ApplyFont(expansionPanel.directTitle, expPanelScheme, "small")
        expansionPanel.directTitle:SetText(HDG.Theme.C_Warning() .. L["HEADER_DIRECT_MATERIALS"] .. "|r")

        expansionPanel.directText = expansionPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        expansionPanel.directText:SetPoint("TOPLEFT", 10, -24)
        expansionPanel.directText:SetWidth(260)
        expansionPanel.directText:SetJustifyH("LEFT")
        ApplyFont(expansionPanel.directText, expPanelScheme, "small")

        expansionPanel.rawTitle = expansionPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        expansionPanel.rawTitle:SetPoint("TOPLEFT", 290, -8)
        ApplyFont(expansionPanel.rawTitle, expPanelScheme, "small")
        expansionPanel.rawTitle:SetText(HDG.Theme.C_Warning() .. L["HEADER_RAW_MATERIALS"] .. "|r")

        expansionPanel.rawText = expansionPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        expansionPanel.rawText:SetPoint("TOPLEFT", 290, -24)
        expansionPanel.rawText:SetWidth(260)
        expansionPanel.rawText:SetJustifyH("LEFT")
        ApplyFont(expansionPanel.rawText, expPanelScheme, "small")
    end

    -- Store current item and row for refresh
    expansionPanelItem = item
    expansionPanelRow = parentRow

    -- Calculate materials
    local directMats, rawMats, directTotal, rawTotal = CalculateMaterialBreakdown(item)

    -- Format direct materials
    local directLines = {}
    for _, mat in ipairs(directMats) do
        local priceStr = mat.unitPrice and HDG_PriceIntegration.FormatGoldOnly(mat.totalPrice) or "?"
        local color = mat.isLumber and HDG.Theme.C_Warning() or HDG.Theme.C_Text()
        table.insert(directLines, string.format("%s%dx %s|r  %s", color, mat.qty, mat.name, priceStr))
    end
    table.insert(directLines, HDG.Theme.C_Dim() .. "Total:|r " .. HDG.Theme.C_Success() .. HDG_PriceIntegration.FormatGoldOnly(directTotal) .. "|r")
    expansionPanel.directText:SetText(table.concat(directLines, "\n"))

    -- Format raw materials
    local rawLines = {}
    for _, mat in ipairs(rawMats) do
        local priceStr = mat.unitPrice and HDG_PriceIntegration.FormatGoldOnly(mat.totalPrice) or "?"
        local color = mat.isLumber and HDG.Theme.C_Warning() or HDG.Theme.C_Text()
        table.insert(rawLines, string.format("%s%dx %s|r  %s", color, mat.qty, mat.name, priceStr))
    end
    table.insert(rawLines, HDG.Theme.C_Dim() .. "Total:|r " .. HDG.Theme.C_Success() .. HDG_PriceIntegration.FormatGoldOnly(rawTotal) .. "|r")
    expansionPanel.rawText:SetText(table.concat(rawLines, "\n"))

    -- Calculate height based on content
    local numLines = math.max(#directMats, #rawMats) + 2  -- +2 for title and total
    local panelHeight = 16 + (numLines * 14)
    panelHeight = math.max(panelHeight, 60)
    panelHeight = math.min(panelHeight, 200)

    expansionPanel:SetHeight(panelHeight)
    expansionPanel:SetPoint("TOPLEFT", parentRow, "BOTTOMLEFT", 0, -2)
    expansionPanel:SetPoint("RIGHT", parentRow, "RIGHT", 0, 0)
    expansionPanel:Show()

    return panelHeight + 4  -- Return height for offset calculation
end

local function HideExpansionPanel()
    expandedRowKey = nil  -- Clear key first
    expansionPanelItem = nil
    expansionPanelRow = nil
    if expansionPanel then
        expansionPanel:Hide()
        expansionPanel:ClearAllPoints()
    end
end

-- ============================================================================
-- AUCTIONATOR SCAN INTEGRATION
-- ============================================================================

-- Check if we can trigger a scan/refresh for the active source
function HDG_GoblinTab.CanTriggerScan()
    local source = GetActivePriceAddonName()

    if source == "Direct" then
        if HDG_PriceIntegration.IsScanInProgress() then return false, "Scanning" end
        if not HDG_PriceIntegration.IsAHOpen() then return false, "AH Closed" end
        return true, "Ready"
    elseif source == "TSM" then
        return true, "Ready" -- TSM refresh is always instant
    elseif source == "Auctionator" then
        if not Auctionator or not Auctionator.State then return false, "No Auctionator" end
        if not AuctionHouseFrame or not AuctionHouseFrame:IsShown() then return false, "AH Closed" end
        if not Auctionator.State.FullScanFrameRef then return false, "Not Ready" end
        if Auctionator.State.FullScanFrameRef.CanInitiate then
            if Auctionator.State.FullScanFrameRef:CanInitiate() then
                return true, "Ready"
            else
                if Auctionator.State.FullScanFrameRef.NextScanMessage then
                    return false, Auctionator.State.FullScanFrameRef:NextScanMessage()
                end
                return false, "Cooldown"
            end
        end
        return false, "Unknown"
    end

    return false, "No source"
end

-- Stop scan animation ticker
StopScanAnim = function()
    if scanAnimTicker then
        scanAnimTicker:Cancel()
        scanAnimTicker = nil
    end
    scanAnimStep = 0
end

-- Start scan animation ticker (cycling dots every 0.5s)
local function StartScanAnim()
    StopScanAnim()
    local dots = { ".", "..", "..." }
    scanAnimTicker = C_Timer.NewTicker(0.5, function()
        scanAnimStep = (scanAnimStep % 3) + 1
        if priceSourceText and scanState == "scanning" then
            priceSourceText:SetText(HDG.Theme.C_Accent() .. "Scanning" .. dots[scanAnimStep] .. "|r")
        end
    end)
end

-- Trigger a scan/refresh for the active source
function HDG_GoblinTab.TriggerScan()
    local canScan, reason = HDG_GoblinTab.CanTriggerScan()
    if not canScan then
        print(HDG.Theme.C_Success() .. "Housing Decor Guide:|r " .. string.format(L["GOBLIN_CANNOT_SCAN"] or "Cannot scan: %s", reason or (L["MISC_UNKNOWN"] or "Unknown")))
        return
    end

    local source = GetActivePriceAddonName()

    if source == "Direct" then
        scanState = "scanning"
        HDG_PriceIntegration.ClearDirectCache() -- clear stale prices so rescan fetches fresh data
        local itemIDs = CollectPriceableItemIDs()
        UpdateScanButton()
        -- Safety timeout for entire direct scan (5 min max)
        if scanTimeoutTimer then scanTimeoutTimer:Cancel() end
        scanTimeoutTimer = C_Timer.NewTimer(300, function()
            if scanState == "scanning" then
                HDG_PriceIntegration.StopDirectScan()
                scanState = "idle"
                UpdateScanButton()
            end
            scanTimeoutTimer = nil
        end)
        HDG_PriceIntegration.StartDirectScan(itemIDs,
            function(found, total) -- progress callback
                if priceSourceText and scanState == "scanning" then
                    priceSourceText:SetText(HDG.Theme.C_Accent() .. string.format("Browsing AH... %d found", found) .. "|r")
                end
            end,
            function() -- done callback
                if scanTimeoutTimer then scanTimeoutTimer:Cancel(); scanTimeoutTimer = nil end
                scanState = "idle"
                if goblinFrame and goblinFrame:IsShown() then
                    HDG_GoblinTab.Refresh()
                end
            end
        )

    elseif source == "TSM" then
        -- TSM is instant - just refresh from its DB
        HDG_GoblinTab.Refresh()

    elseif source == "Auctionator" then
        scanState = "scanning"
        Auctionator.State.FullScanFrameRef:InitiateScan()
        StartScanAnim()
        UpdateScanButton()
        if scanTimeoutTimer then scanTimeoutTimer:Cancel() end
        scanTimeoutTimer = C_Timer.NewTimer(60, function()
            if scanState == "scanning" then
                StopScanAnim()
                scanState = "idle"
                UpdateScanButton()
            end
            scanTimeoutTimer = nil
        end)
    end
end

-- Register for scan completion callback
local function RegisterScanCallback()
    if Auctionator and Auctionator.API and Auctionator.API.v1 and Auctionator.API.v1.RegisterForDBUpdate then
        Auctionator.API.v1.RegisterForDBUpdate("HousingDecorGuide", function()
            -- Ignore if this was triggered by our Direct browse scan, not an Auctionator scan
            if GetActivePriceAddonName() == "Direct" then return end
            if scanTimeoutTimer then scanTimeoutTimer:Cancel(); scanTimeoutTimer = nil end
            StopScanAnim()
            scanState = "complete"
            UpdateScanButton()
            C_Timer.After(3, function()
                scanState = "idle"
                UpdateScanButton()
            end)
            -- Delay refresh: Auctionator fires callback before its DB is fully queryable
            C_Timer.After(2, function()
                if goblinFrame and goblinFrame:IsShown() then
                    HDG_GoblinTab.Refresh()
                end
            end)
        end)
    end
end

-- Check if Auctionator actually has price data (probe a common reagent)
local function AuctionatorHasData()
    if not HDG_PriceIntegration.IsAuctionatorAvailable() then return false end
    -- Test a few common trade goods: Linen Cloth (2589), Copper Ore (2770), Light Leather (2318)
    for _, testID in ipairs({ 2589, 2770, 2318 }) do
        local price = Auctionator.API.v1.GetAuctionPriceByItemID("HousingDecorGuide", testID)
        if price and price > 0 then return true end
    end
    return false
end

-- Get time since last Auctionator scan
local function GetAuctionatorScanTime()
    if not Auctionator or not Auctionator.SavedState then
        return nil
    end

    local lastScan = Auctionator.SavedState.TimeOfLastReplicateScan
    if not lastScan then
        return nil
    end

    return time() - lastScan
end

-- Get time since last TSM scan (if available)
local function GetTSMScanTime()
    -- TSM doesn't expose scan time the same way, but we can check if data exists
    -- For now, return nil to indicate "unknown" - TSM syncs data differently
    return nil
end

-- Get time since last scan for the active price addon
local function GetTimeSinceLastScan()
    local preferred = HDG and HDG.Store and HDG.Store.state.config.preferredPriceAddon

    if preferred == "TSM" then
        return GetTSMScanTime()
    elseif preferred == "Auctionator" then
        return GetAuctionatorScanTime()
    end

    -- Default: check based on which addon is available (TSM first)
    if HDG_PriceIntegration.IsTSMAvailable() then
        return GetTSMScanTime()
    end
    return GetAuctionatorScanTime()
end

-- Get the currently active price source name (respecting user preference)
GetActivePriceAddonName = function()
    local preferred = HDG and HDG.Store and HDG.Store.state.config.preferredPriceAddon

    if preferred == "Direct" then return "Direct" end
    if preferred == "TSM" and HDG_PriceIntegration.IsTSMAvailable() then return "TSM" end
    if preferred == "Auctionator" and HDG_PriceIntegration.IsAuctionatorAvailable() then return "Auctionator" end

    -- Default: TSM > Auctionator > Direct
    if HDG_PriceIntegration.IsTSMAvailable() then return "TSM" end
    if HDG_PriceIntegration.IsAuctionatorAvailable() then return "Auctionator" end
    return "Direct"
end

-- Collect all unique item IDs that need pricing (crafted items + reagents)
CollectPriceableItemIDs = function()
    local ids = {}
    local seen = {}
    if not HDG_DecorDB then return ids end

    for _, recipe in pairs(HDG_DecorDB) do
        local include = true
        if professionFilter ~= "All" and recipe.profession ~= professionFilter then include = false end
        if include and expansionFilter ~= "All" then
            local expansion = HDG_ExpansionData.NormalizeToDisplayName(recipe.expansion)
            if expansion ~= expansionFilter then include = false end
        end

        if include then
            local itemID = GetCorrectItemID(recipe)
            if itemID and not seen[itemID] then
                seen[itemID] = true
                table.insert(ids, itemID)
            end
            if recipe.reagents then
                for reagentID, _ in pairs(recipe.reagents) do
                    if not seen[reagentID]
                       and not HDG_ExpansionData.IsLumber(reagentID)
                       and not HDG_ExpansionData.IsBoPReagent(reagentID) then
                        seen[reagentID] = true
                        table.insert(ids, reagentID)
                    end
                end
            end
        end
    end

    return ids
end

-- Format seconds into human-readable string
local function FormatTimeSince(seconds)
    if not seconds then return "Never" end

    if seconds < 60 then
        return "< 1m ago"
    elseif seconds < 3600 then
        return string.format("%dm ago", math.floor(seconds / 60))
    elseif seconds < 86400 then
        local hours = math.floor(seconds / 3600)
        return string.format("%dh ago", hours)
    else
        local days = math.floor(seconds / 86400)
        return string.format("%dd ago", days)
    end
end

-- Update scan button + price freshness text (state machine)
UpdateScanButton = function()
    if not scanButton or not priceSourceText then return end

    local source = GetActivePriceAddonName()
    local scheme = GetScheme()

    -- Scanning in progress (any source)
    if scanState == "scanning" then
        scanButton:Hide()
        -- Direct scan shows progress via its own callback; fallback text here
        if source ~= "Direct" then
            priceSourceText:SetText(HDG.Theme.C_Accent() .. (L["GOBLIN_SCANNING"] or "Scanning...") .. "|r")
        end
        return
    end

    -- Just completed (3s flash)
    if scanState == "complete" then
        scanButton:Hide()
        priceSourceText:SetText(HDG.Theme.C_Success() .. (L["GOBLIN_UPDATED"] or "Updated!") .. "|r")
        return
    end

    -- Idle state: show source info + refresh button
    scanButton:Show()
    local canScan, reason = HDG_GoblinTab.CanTriggerScan()

    if source == "Direct" then
        local cacheCount = HDG_PriceIntegration.GetDirectCacheCount()
        if cacheCount > 0 then
            local cacheTime = HDG_PriceIntegration.GetDirectCacheTime()
            local ageStr = ""
            if cacheTime then
                local age = time() - cacheTime
                if age < 60 then ageStr = "<1m"
                elseif age < 3600 then ageStr = math.floor(age / 60) .. "m"
                elseif age < 86400 then ageStr = math.floor(age / 3600) .. "h"
                else ageStr = math.floor(age / 86400) .. "d" end
            end
            -- Count decor vs reagent items in cache
            local decorSet = {}
            for _, item in ipairs(profitData) do decorSet[item.itemID] = true end
            local decorCached, reagentCached = 0, 0
            local store = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
            local priceCache = store and store.state and store.state.directPriceCache or {}
            for id in pairs(priceCache) do
                if decorSet[id] then decorCached = decorCached + 1 else reagentCached = reagentCached + 1 end
            end
            local countStr = decorCached .. " decor, " .. reagentCached .. " reagents"
            local detail = ageStr ~= "" and (countStr .. ", " .. ageStr .. " ago") or (countStr)
            priceSourceText:SetText(HDG.Theme.C_Accent() .. "Direct" .. "|r " .. HDG.Theme.C_Dim() .. "(" .. detail .. ")" .. "|r")
        else
            priceSourceText:SetText(HDG.Theme.C_Warning() .. "Direct" .. "|r " .. HDG.Theme.C_Dim() .. "(scan needed)" .. "|r")
        end
    elseif source == "TSM" then
        priceSourceText:SetText(HDG.Theme.C_Accent() .. "TSM" .. "|r")
    elseif source == "Auctionator" then
        local timeSince = GetTimeSinceLastScan()
        if not timeSince then
            if AuctionatorHasData() then
                priceSourceText:SetText(HDG.Theme.C_Accent() .. "Auctionator" .. "|r")
            else
                priceSourceText:SetText(HDG.Theme.C_Warning() .. "Auctionator" .. "|r " .. HDG.Theme.C_Dim() .. "(scan needed)" .. "|r")
            end
        else
            local timeStr = FormatTimeSince(timeSince)
            local timeColor
            if timeSince < 3600 then timeColor = HDG.Theme.C_Success()
            elseif timeSince < 86400 then timeColor = HDG.Theme.C_Warning()
            else timeColor = HDG.Theme.C_Error() end
            priceSourceText:SetText(timeColor .. source .. ": " .. timeStr .. "|r")
        end
    end

    -- Refresh button state
    if canScan then
        local label = (source == "TSM") and "Refresh" or "Scan"
        scanButton.text:SetText(HDG.Theme.C_Text() .. label .. "|r")
        scanButton:SetBackdropColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.9)
    elseif reason == "AH Closed" then
        scanButton.text:SetText(HDG.Theme.C_Dim() .. "Open AH|r")
        scanButton:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.9)
    else
        scanButton.text:SetText(HDG.Theme.C_Dim() .. "Cooldown|r")
        scanButton:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.9)
    end
end

-- Update TSM-only column visibility (Region Avg and TSM Margin headers)
local function UpdateTSMColumnVisibility()
    local isTSMActive = GetActivePriceAddonName() == "TSM"

    -- Show/hide the Region Avg column header (TSM only)
    if columnHeaders["regionSaleAvg"] then
        if isTSMActive then
            columnHeaders["regionSaleAvg"]:Show()
        else
            columnHeaders["regionSaleAvg"]:Hide()
        end
    end

    -- Show/hide the TSM Margin column header (TSM only)
    if columnHeaders["tsmMargin"] then
        if isTSMActive then
            columnHeaders["tsmMargin"]:Show()
        else
            columnHeaders["tsmMargin"]:Hide()
        end
    end
end

-- ============================================================================
-- UI CREATION
-- ============================================================================

-- Create a row from the pool or new
local function CreateRowFactory(scrollContent)
    return HDG_UIHelpers.CreateRowPool(function(parent)
        local rowScheme = GetScheme()
        local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
        row:SetHeight(24)
        row:SetBackdrop({
            bgFile = CONFIG.TEXTURES.WHITE_BG,
        })

        -- Auction status icon (green check / red undercut) - 14px left of name
        row.auctionIcon = row:CreateTexture(nil, "OVERLAY")
        row.auctionIcon:SetSize(12, 12)
        row.auctionIcon:SetPoint("LEFT", 8, 0)
        row.auctionIcon:Hide()

        -- Crafting-queue indicator: 3px stripe on the left edge when this item is in the queue
        row.queuedStripe = row:CreateTexture(nil, "OVERLAY")
        row.queuedStripe:SetPoint("TOPLEFT", 0, 0)
        row.queuedStripe:SetPoint("BOTTOMLEFT", 0, 0)
        row.queuedStripe:SetWidth(3)
        row.queuedStripe:SetTexture(CONFIG.TEXTURES.WHITE_BG)
        row.queuedStripe:Hide()

        -- Item name (left-aligned, shifted right when auction icon visible)
        row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.nameText:SetPoint("LEFT", 8, 0)
        row.nameText:SetWidth(180)
        row.nameText:SetJustifyH("LEFT")
        row.nameText:SetWordWrap(false)
        ApplyFont(row.nameText, rowScheme, "small")

        -- Lumber type (left-aligned, expansion-colored)
        row.lumberTypeText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.lumberTypeText:SetPoint("LEFT", 192, 0)
        row.lumberTypeText:SetWidth(75)
        row.lumberTypeText:SetJustifyH("LEFT")
        ApplyFont(row.lumberTypeText, rowScheme, "small")

        -- Per-lumber value ($/Lum)
        row.lumberText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.lumberText:SetPoint("LEFT", 271, 0)
        row.lumberText:SetWidth(65)
        row.lumberText:SetJustifyH("RIGHT")
        ApplyFont(row.lumberText, rowScheme, "small")

        -- Total lumber value (LumTot)
        row.lumberTotalText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.lumberTotalText:SetPoint("LEFT", 340, 0)
        row.lumberTotalText:SetWidth(65)
        row.lumberTotalText:SetJustifyH("RIGHT")
        ApplyFont(row.lumberTotalText, rowScheme, "small")

        -- Cost
        row.costText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.costText:SetPoint("LEFT", 409, 0)
        row.costText:SetWidth(65)
        row.costText:SetJustifyH("RIGHT")
        ApplyFont(row.costText, rowScheme, "small")

        -- Sell price
        row.sellText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.sellText:SetPoint("LEFT", 478, 0)
        row.sellText:SetWidth(65)
        row.sellText:SetJustifyH("RIGHT")
        ApplyFont(row.sellText, rowScheme, "small")

        -- Profit
        row.profitText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.profitText:SetPoint("LEFT", 547, 0)
        row.profitText:SetWidth(70)
        row.profitText:SetJustifyH("RIGHT")
        ApplyFont(row.profitText, rowScheme, "small")

        -- Margin
        row.marginText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.marginText:SetPoint("LEFT", 621, 0)
        row.marginText:SetWidth(55)
        row.marginText:SetJustifyH("RIGHT")
        ApplyFont(row.marginText, rowScheme, "small")

        -- Region Avg (TSM only - visibility controlled by UpdateTSMColumnVisibility)
        row.regionAvgText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.regionAvgText:SetPoint("LEFT", 680, 0)
        row.regionAvgText:SetWidth(90)
        row.regionAvgText:SetJustifyH("RIGHT")
        ApplyFont(row.regionAvgText, rowScheme, "small")

        -- TSM Margin (TSM only - visibility controlled by UpdateTSMColumnVisibility)
        row.tsmMarginText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.tsmMarginText:SetPoint("LEFT", 774, 0)
        row.tsmMarginText:SetWidth(55)
        ApplyFont(row.tsmMarginText, rowScheme, "small")
        row.tsmMarginText:SetJustifyH("RIGHT")

        -- Auction status text (listed/sold counts) - shares space with TSM columns
        row.auctionText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.auctionText:SetPoint("LEFT", 680, 0)
        row.auctionText:SetWidth(120)
        row.auctionText:SetJustifyH("LEFT")
        ApplyFont(row.auctionText, rowScheme, "small")

        -- Click handler for expand/collapse, add to queue, AH search
        row:SetScript("OnClick", function(self)
            if IsShiftKeyDown() then
                -- Shift-click: Search AH for this item
                if AuctionHouseFrame and AuctionHouseFrame:IsShown() then
                    local searchBox = AuctionHouseFrame.SearchBar and AuctionHouseFrame.SearchBar.SearchBox
                    if searchBox then
                        local itemName = self.item and self.item.name or ""
                        searchBox:SetText(itemName)
                        -- Trigger the search
                        if AuctionHouseFrame.SearchBar.SearchButton then
                            AuctionHouseFrame.SearchBar.SearchButton:Click()
                        end
                    end
                else
                    print(HDG.Theme.C_Success() .. "Housing Decor Guide:|r " .. (L["GOBLIN_OPEN_AH_TO_SEARCH"] or "Open the Auction House to search"))
                end
            elseif IsControlKeyDown() then
                -- Ctrl-click: Add to crafting queue
                if self.item and HDG_UI and HDG_UI.AddToCraftingQueue then
                    local item = self.item
                    local recipe = {
                        id = item.itemID,
                        name = item.name,
                        spell = item.recipe.spellID,
                        expansion = item.expansion,
                        profession = item.profession,
                        reagents = {},
                    }
                    if item.recipe.reagents then
                        for reagentID, reagentData in pairs(item.recipe.reagents) do
                            table.insert(recipe.reagents, {
                                id = reagentID,
                                qty = reagentData.qty,
                            })
                        end
                    end
                    HDG_UI.AddToCraftingQueue(recipe)
                    print(HDG.Theme.C_Success() .. "Housing Decor Guide:|r " .. string.format(L["GOBLIN_ADDED_TO_QUEUE"] or "Added %s to crafting queue", item.name))
                end
            else
                -- Normal click: Toggle material breakdown expansion
                if self.item then
                    if expandedRowKey == self.item.recipeKey then
                        -- Collapse if clicking the same row
                        HideExpansionPanel()
                        HDG_GoblinTab.RefreshDisplay()  -- Re-render to remove expansion space
                    else
                        -- Expand this row
                        expandedRowKey = self.item.recipeKey
                        HDG_GoblinTab.RefreshDisplay()  -- Re-render to show expansion
                    end
                end
            end
        end)

        -- Hover effects
        row:SetScript("OnEnter", function(self)
            local hoverScheme = GetScheme()
            if hoverScheme then
                self:SetBackdropColor(hoverScheme.button_hover.r, hoverScheme.button_hover.g, hoverScheme.button_hover.b, 0.8)
            else
                self:SetBackdropColor(0.25, 0.25, 0.27, 0.8)
            end
            GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
            if self.itemID then
                GameTooltip:SetItemByID(self.itemID)
            end
            GameTooltip:AddLine(" ")
            if self.queuedQty then
                GameTooltip:AddLine(HDG.Theme.C_Success() .. "Queued \195\151" .. self.queuedQty .. "|r " .. HDG.Theme.C_Dim() .. "(in crafting queue)|r", 0.7, 0.9, 0.7)
                GameTooltip:AddLine(" ")
            end
            GameTooltip:AddLine(HDG.Theme.C_Accent() .. "Click|r " .. L["TIP_CLICK_EXPAND"], 0.7, 0.7, 0.7)
            GameTooltip:AddLine(HDG.Theme.C_Accent() .. "Ctrl-Click|r " .. L["TIP_CTRL_QUEUE"], 0.7, 0.7, 0.7)
            GameTooltip:AddLine(HDG.Theme.C_Accent() .. "Shift-Click|r " .. L["TIP_SHIFT_AH"], 0.7, 0.7, 0.7)
            GameTooltip:Show()
            if self.itemID then
                HDG_UIHelpers.ShowHoverPreview(self.itemID, mainFrame)
            end
        end)
        row:SetScript("OnLeave", function(self)
            local leaveScheme = GetScheme()
            if self.bgColor then
                self:SetBackdropColor(unpack(self.bgColor))
            elseif leaveScheme then
                self:SetBackdropColor(leaveScheme.panel.r, leaveScheme.panel.g, leaveScheme.panel.b, 0.5)
            else
                local c = CONFIG.COLORS.PANEL_ROW; self:SetBackdropColor(c[1], c[2], c[3], 0.5)
            end
            GameTooltip:Hide()
            HDG_UIHelpers.HideHoverPreview()
        end)

        return row
    end)
end

-- Close any active dropdown menu
local function CloseActiveDropdown()
    if activeDropdownMenu and activeDropdownMenu:IsShown() then
        activeDropdownMenu:Hide()
    end
    activeDropdownMenu = nil
end

-- Create filter dropdown
local function CreateDropdown(parent, label, items, width, onChange)
    local container = CreateFrame("Frame", nil, parent)
    container:SetSize(width + 50, 22)

    local ddScheme = GetScheme()
    local labelText = container:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    labelText:SetPoint("LEFT", 0, 0)
    ApplyFont(labelText, ddScheme, "small")
    labelText:SetText(label)

    local dropdown = CreateFrame("Button", nil, container, "BackdropTemplate")
    dropdown:SetSize(width, 20)
    dropdown:SetPoint("LEFT", labelText, "RIGHT", 4, 0)
    dropdown:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        edgeSize = 1,
    })
    if ddScheme then
        dropdown:SetBackdropColor(ddScheme.panel.r, ddScheme.panel.g, ddScheme.panel.b, 0.9)
        dropdown:SetBackdropBorderColor(ddScheme.border.r, ddScheme.border.g, ddScheme.border.b, 1)
    else
        dropdown:SetBackdropColor(0.15, 0.15, 0.16, 0.9)
        dropdown:SetBackdropBorderColor(unpack(CONFIG.COLORS.PANEL_DARK_BORDER_COOL))
    end
    RegisterWidget(dropdown, "Panel")

    dropdown.text = dropdown:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    dropdown.text:SetPoint("LEFT", 6, 0)
    ApplyFont(dropdown.text, ddScheme, "small")
    dropdown.text:SetText(items[1] or "All")

    dropdown.arrow = dropdown:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    dropdown.arrow:SetPoint("RIGHT", -6, 0)
    ApplyFont(dropdown.arrow, ddScheme, "small")
    dropdown.arrow:SetText(HDG.Theme.C_Dim() .. "v|r")

    dropdown.items = items
    dropdown.selected = items[1] or "All"
    dropdown.onChange = onChange

    dropdown:SetScript("OnClick", function(self)
        -- Close any existing dropdown first
        CloseActiveDropdown()

        -- Reuse cached menu frame or create new one
        local menu = self._cachedMenu
        if menu then
            -- Reuse existing menu, just re-anchor and show
            menu:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 0, -2)
            menu:Show()
            activeDropdownMenu = menu
            return
        end
        menu = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
        self._cachedMenu = menu
        menu:SetFrameStrata("FULLSCREEN_DIALOG")  -- Above dialogs, not conflicting with GameTooltip
        menu:SetBackdrop({
            bgFile = CONFIG.TEXTURES.WHITE_BG,
            edgeFile = CONFIG.TEXTURES.WHITE_BG,
            edgeSize = 1,
        })
        local menuScheme = GetScheme()
        if menuScheme then
            menu:SetBackdropColor(menuScheme.bg.r, menuScheme.bg.g, menuScheme.bg.b, 0.98)
            menu:SetBackdropBorderColor(menuScheme.border.r, menuScheme.border.g, menuScheme.border.b, 1)
        else
            local c = CONFIG.COLORS.PANEL_ROW; menu:SetBackdropColor(c[1], c[2], c[3], 0.98)
            menu:SetBackdropBorderColor(unpack(CONFIG.COLORS.PANEL_DARK_BORDER_COOL))
        end

        local menuHeight = #self.items * 20 + 4
        menu:SetSize(width, menuHeight)
        menu:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 0, -2)

        -- Track this as the active dropdown
        activeDropdownMenu = menu

        for i, item in ipairs(self.items) do
            local btn = CreateFrame("Button", nil, menu)
            btn:SetSize(width - 4, 18)
            btn:SetPoint("TOPLEFT", 2, -2 - (i-1) * 20)

            btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            btn.text:SetPoint("LEFT", 4, 0)
            ApplyFont(btn.text, menuScheme, "small")
            btn.text:SetText(item)

            btn:SetScript("OnEnter", function(b)
                local btnScheme = GetScheme()
                if btnScheme then
                    b.text:SetTextColor(btnScheme.warning.r, btnScheme.warning.g, btnScheme.warning.b)
                else
                    b.text:SetTextColor(unpack(CONFIG.COLORS.ACHIEVEMENT_GOLD))
                end
            end)
            btn:SetScript("OnLeave", function(b)
                local btnScheme = GetScheme()
                if btnScheme then
                    b.text:SetTextColor(btnScheme.text.r, btnScheme.text.g, btnScheme.text.b)
                else
                    b.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_ACTIVE))
                end
            end)
            btn:SetScript("OnClick", function()
                self.selected = item
                self.text:SetText(item)
                menu:Hide()
                activeDropdownMenu = nil
                if self.onChange then
                    self.onChange(item)
                end
            end)
        end

        -- Close menu when clicking elsewhere (check every 0.2s instead of every frame)
        local dismissTicker = nil
        menu:SetScript("OnShow", function()
            if dismissTicker then dismissTicker:Cancel() end
            dismissTicker = C_Timer.NewTicker(0.2, function()
                if not MouseIsOver(menu) and not MouseIsOver(self) then
                    if IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") then
                        menu:Hide()
                        activeDropdownMenu = nil
                    end
                end
            end)
        end)

        menu:SetScript("OnHide", function()
            if dismissTicker then dismissTicker:Cancel(); dismissTicker = nil end
            if activeDropdownMenu == menu then
                activeDropdownMenu = nil
            end
        end)
    end)

    return container, dropdown
end

-- Create checkbox
local function CreateCheckbox(parent, label, initialValue, onChange)
    local container = CreateFrame("Button", nil, parent)
    container:SetSize(120, 18)

    local box = container:CreateTexture(nil, "ARTWORK")
    box:SetSize(14, 14)
    box:SetPoint("LEFT", 0, 0)
    box:SetTexture(CONFIG.TEXTURES.WHITE_BG)
    local cbScheme = GetScheme()
    if cbScheme then
        box:SetVertexColor(cbScheme.panel.r, cbScheme.panel.g, cbScheme.panel.b, 1)
    else
        box:SetVertexColor(0.2, 0.2, 0.22, 1)
    end
    container.box = box

    -- Border around checkbox box
    local borderSize = 1
    local border = {}
    for _, side in ipairs({"TOP", "BOTTOM", "LEFT", "RIGHT"}) do
        local tex = container:CreateTexture(nil, "BORDER")
        tex:SetTexture(CONFIG.TEXTURES.WHITE_BG)
        if cbScheme then
            tex:SetVertexColor(cbScheme.border.r, cbScheme.border.g, cbScheme.border.b, 1)
        else
            tex:SetVertexColor(0.4, 0.4, 0.4, 1)
        end
        border[side] = tex
    end
    border.TOP:SetPoint("TOPLEFT", box, "TOPLEFT", -borderSize, borderSize)
    border.TOP:SetPoint("TOPRIGHT", box, "TOPRIGHT", borderSize, borderSize)
    border.TOP:SetHeight(borderSize)
    border.BOTTOM:SetPoint("BOTTOMLEFT", box, "BOTTOMLEFT", -borderSize, -borderSize)
    border.BOTTOM:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", borderSize, -borderSize)
    border.BOTTOM:SetHeight(borderSize)
    border.LEFT:SetPoint("TOPLEFT", box, "TOPLEFT", -borderSize, 0)
    border.LEFT:SetPoint("BOTTOMLEFT", box, "BOTTOMLEFT", -borderSize, 0)
    border.LEFT:SetWidth(borderSize)
    border.RIGHT:SetPoint("TOPRIGHT", box, "TOPRIGHT", borderSize, 0)
    border.RIGHT:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", borderSize, 0)
    border.RIGHT:SetWidth(borderSize)
    container.boxBorder = border

    local check = container:CreateTexture(nil, "OVERLAY")
    check:SetSize(12, 12)
    check:SetPoint("CENTER", box, "CENTER", 0, 0)
    check:SetTexture(CONFIG.TEXTURES.CHECKMARK)
    if cbScheme then
        check:SetVertexColor(cbScheme.success.r, cbScheme.success.g, cbScheme.success.b, 1)
    else
        check:SetVertexColor(0.2, 0.8, 0.5, 1)
    end
    container.check = check

    local text = container:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    text:SetPoint("LEFT", box, "RIGHT", 4, 0)
    ApplyFont(text, cbScheme, "small")
    text:SetText(label)
    container.text = text  -- Store for theme engine

    container.checked = initialValue
    check:SetShown(initialValue)

    function container:SetChecked(value)
        self.checked = value
        self.check:SetShown(value)
    end

    container:SetScript("OnClick", function(self)
        self.checked = not self.checked
        self.check:SetShown(self.checked)
        if onChange then
            onChange(self.checked)
        end
    end)

    -- Register with theme engine
    RegisterWidget(container, "Checkbox")

    return container
end

-- Update sort indicator (text color) on column headers
local function UpdateSortIndicators()
    local sortScheme = GetScheme()
    for _, header in pairs(columnHeaders) do
        if header.sortKey == currentSort then
            -- Active sort column: accent color
            if sortScheme then
                header.text:SetTextColor(sortScheme.accent.r, sortScheme.accent.g, sortScheme.accent.b)
            else
                header.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_ACTIVE))
            end
        else
            -- Inactive columns: neutral column header color (Base01 for Solarized)
            header.text:SetTextColor(HDG.Theme.GetColumnHeaderColor())
        end
    end
end

-- Create column header button
local function CreateColumnHeader(parent, text, sortKey, x, width, isLeftAligned)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(width, 20)
    btn:SetPoint("LEFT", x, 0)

    local headerScheme = GetScheme()
    btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    ApplyFont(btn.text, headerScheme, "small")
    btn.text:SetText(text)
    -- Use neutral column header color (Base01 for Solarized)
    btn.text:SetTextColor(HDG.Theme.GetColumnHeaderColor())

    if isLeftAligned then
        btn.text:SetPoint("LEFT", 0, 0)
    else
        btn.text:SetPoint("RIGHT", 0, 0)
    end

    btn.sortKey = sortKey

    btn:SetScript("OnClick", function(self)
        if currentSort == self.sortKey then
            sortAscending = not sortAscending
        else
            currentSort = self.sortKey
            sortAscending = false
        end
        UpdateSortIndicators()
        HDG_GoblinTab.RefreshDisplay()
    end)

    btn:SetScript("OnEnter", function(self)
        if self.sortKey ~= currentSort then
            local enterScheme = GetScheme()
            if enterScheme then
                self.text:SetTextColor(enterScheme.text_dim.r, enterScheme.text_dim.g, enterScheme.text_dim.b)
            else
                self.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_BODY))  -- Light gray on hover (inactive columns)
            end
        end
    end)
    btn:SetScript("OnLeave", function(self)
        local leaveScheme = GetScheme()
        if self.sortKey == currentSort then
            if leaveScheme then
                self.text:SetTextColor(leaveScheme.accent.r, leaveScheme.accent.g, leaveScheme.accent.b)
            else
                self.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_ACTIVE))
            end
        else
            -- Use neutral column header color (Base01 for Solarized)
            self.text:SetTextColor(HDG.Theme.GetColumnHeaderColor())
        end
    end)

    -- Track this header for sort indicator updates
    columnHeaders[sortKey] = btn

    return btn
end

-- Create the Goblin tab UI
function HDG_GoblinTab.Create()
    if goblinFrame then return end

    -- Use factory for panel (no header/scroll - custom filter rows above scroll)
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = false,
    })

    goblinFrame = layout.frame
    goblinFrame:Hide()
    mainFrame.goblinFrame = goblinFrame

    local scheme = GetScheme()

    -- Row 1: Profession filter buttons
    local profRow = CreateFrame("Frame", nil, goblinFrame)
    profRow:SetPoint("TOPLEFT", 8, -6)
    profRow:SetPoint("TOPRIGHT", -8, -6)
    profRow:SetHeight(20)
    goblinFrame.profRow = profRow

    local profLabel = profRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    profLabel:SetPoint("LEFT", 0, 0)
    ApplyFont(profLabel, scheme, "small")
    profLabel:SetText(HDG.Theme.C_Dim() .. "Prof:|r")

    -- Profession buttons container (will be populated by RefreshFilterButtons)
    goblinFrame.profButtons = {}

    -- Row 2: Expansion filter buttons
    local expRow = CreateFrame("Frame", nil, goblinFrame)
    expRow:SetPoint("TOPLEFT", 8, -26)
    expRow:SetPoint("TOPRIGHT", -8, -26)
    expRow:SetHeight(20)
    goblinFrame.expRow = expRow

    local expLabel = expRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    expLabel:SetPoint("LEFT", 0, 0)
    ApplyFont(expLabel, scheme, "small")
    expLabel:SetText(HDG.Theme.C_Dim() .. "Exp:|r")

    -- Expansion buttons container (will be populated by RefreshFilterButtons)
    goblinFrame.expButtons = {}

    -- Function to refresh filter buttons (creates on first call, updates state on subsequent)
    local filterButtonsCreated = false

    local function RefreshFilterButtons()
        if not filterButtonsCreated then
            -- First call: create all buttons once
            filterButtonsCreated = true

            local professions = {"All", "Alchemy", "Blacksmithing", "Cooking", "Enchanting", "Engineering", "Inscription", "Jewelcrafting", "Leatherworking", "Tailoring"}
            local profShortNames = { Blacksmithing = "Blacksmith", Jewelcrafting = "Jewelcraft", Leatherworking = "Leatherwork" }
            local profWidths = {42, 66, 62, 58, 90, 74, 76, 62, 68, 66}
            local xOffset = 30
            for i, prof in ipairs(professions) do
                local isActive = (professionFilter == prof)
                local displayName = profShortNames[prof] or prof -- English only (layout policy)
                local btn = HDG_UIHelpers.CreateChipButton(profRow, displayName, profWidths[i], isActive, function()
                    professionFilter = prof
                    RefreshFilterButtons()
                    HDG_GoblinTab.RefreshDisplay()
                end)
                btn:SetPoint("LEFT", xOffset, 0)
                btn.fullName = prof
                table.insert(goblinFrame.profButtons, btn)
                xOffset = xOffset + profWidths[i] + 4
            end

            -- Expansion buttons
            local EXP_BTN_WIDTH = 37
            xOffset = 30
            local allBtn = HDG_UIHelpers.CreateChipButton(expRow, "All", 33, (expansionFilter == "All"), function()
                expansionFilter = "All"
                RefreshFilterButtons()
                HDG_GoblinTab.RefreshDisplay()
            end)
            allBtn:SetPoint("LEFT", xOffset, 0)
            allBtn.fullName = "All"
            table.insert(goblinFrame.expButtons, allBtn)
            xOffset = xOffset + 34

            for _, exp in ipairs(HDG_ExpansionData.EXPANSION_ORDER) do
                local abbrev = HDG_ExpansionData.GetShortName(exp) or exp:sub(1,3)
                local isActive = (expansionFilter == exp)
                local expColor = HDG_ExpansionData.GetColor(exp)

                local btn = HDG_UIHelpers.CreateChipButton(expRow, abbrev, EXP_BTN_WIDTH, isActive, function()
                    expansionFilter = exp
                    RefreshFilterButtons()
                    HDG_GoblinTab.RefreshDisplay()
                end)
                btn:SetPoint("LEFT", xOffset, 0)
                btn.fullName = exp
                btn.expColor = expColor

                -- Tooltip with full expansion name
                btn:SetScript("OnEnter", function(self)
                    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                    GameTooltip:AddLine(exp, expColor[1], expColor[2], expColor[3])
                    GameTooltip:Show()
                end)
                btn:SetScript("OnLeave", function(self)
                    GameTooltip:Hide()
                end)

                table.insert(goblinFrame.expButtons, btn)
                xOffset = xOffset + EXP_BTN_WIDTH + 1
            end
        end

        -- Update visual state for all profession buttons
        for _, btn in ipairs(goblinFrame.profButtons) do
            local isActive = (professionFilter == btn.fullName)
            HDG_UIHelpers.UpdateChipButtonState(btn, isActive)
        end

        -- Update visual state for all expansion buttons
        for _, btn in ipairs(goblinFrame.expButtons) do
            local isActive = (expansionFilter == btn.fullName)
            HDG_UIHelpers.UpdateChipButtonState(btn, isActive)

            -- Re-apply expansion color text (not handled by generic state update)
            if btn.expColor then
                local expColor = btn.expColor
                local abbrev = btn.text:GetText():gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "") -- strip existing color codes
                local colorHex = string.format("%02x%02x%02x", expColor[1] * 255, expColor[2] * 255, expColor[3] * 255)
                if isActive then
                    btn.text:SetText("|cFF" .. colorHex .. abbrev .. "|r")
                else
                    local dimColor = string.format("%02x%02x%02x", math.min(255, expColor[1] * 220), math.min(255, expColor[2] * 220), math.min(255, expColor[3] * 220))
                    btn.text:SetText("|cFF" .. dimColor .. abbrev .. "|r")
                end
            end
        end
    end

    -- Store reference and call initial refresh
    goblinFrame.RefreshFilterButtons = RefreshFilterButtons
    RefreshFilterButtons()

    -- Price source selector (Direct / Auc / TSM) - radio buttons on filter row after My Auctions
    -- (created here, re-parented and anchored after filterRow checkboxes are built below)
    local priceToggleContainer = CreateFrame("Frame", nil, profRow)
    priceToggleContainer:SetSize(145, 20)
    local ptScheme = GetScheme()

    local UpdatePriceToggle   -- forward declaration for OnClick closures
    local UpdateTSMModeToggle -- forward declaration for TSM mode radios
    local sourceButtons = {}
    local sourceDefs = {
        { key = "Direct",      label = "Direct", tipTitle = L["TIP_USE_DIRECT"] or "Use Direct AH Prices", tipDesc = L["TIP_DIRECT_DESC"] or "Scans the Auction House directly. No addons required." },
        { key = "Auctionator", label = "Auc",    tipTitle = L["TIP_USE_AUC"],    tipDesc = L["TIP_AUC_DESC"] },
        { key = "TSM",         label = "TSM",    tipTitle = L["TIP_USE_TSM"],     tipDesc = L["TIP_TSM_DESC"] },
    }

    local RADIO_SIZE = 12
    local prevRadio = nil
    for _, def in ipairs(sourceDefs) do
        local radio = CreateFrame("Button", nil, priceToggleContainer)
        radio:SetSize(RADIO_SIZE + 4 + 30, 16)  -- circle + gap + label space
        if prevRadio then
            radio:SetPoint("LEFT", prevRadio, "RIGHT", 4, 0)
        else
            radio:SetPoint("LEFT", 0, 0)
        end

        -- Circle background (always shown)
        radio.circle = radio:CreateTexture(nil, "ARTWORK")
        radio.circle:SetAtlas("common-radiobutton-circle")
        radio.circle:SetSize(RADIO_SIZE, RADIO_SIZE)
        radio.circle:SetPoint("LEFT", 0, 0)

        -- Dot overlay (shown when selected)
        radio.dot = radio:CreateTexture(nil, "OVERLAY")
        radio.dot:SetAtlas("common-radiobutton-dot")
        radio.dot:SetSize(RADIO_SIZE, RADIO_SIZE)
        radio.dot:SetPoint("CENTER", radio.circle, "CENTER", 0, 0)
        radio.dot:Hide()

        -- Label text
        radio.label = radio:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        radio.label:SetPoint("LEFT", radio.circle, "RIGHT", 3, 0)
        ApplyFont(radio.label, ptScheme, "small")
        radio.label:SetText(def.label)
        radio.sourceKey = def.key

        -- Resize hit area to fit label
        radio:SetScript("OnShow", function(self)
            local textW = self.label:GetStringWidth() or 30
            self:SetWidth(RADIO_SIZE + 3 + textW + 2)
        end)

        radio:SetScript("OnClick", function()
            local isAvailable = (def.key == "Direct") or
                (def.key == "TSM" and HDG_PriceIntegration.IsTSMAvailable()) or
                (def.key == "Auctionator" and HDG_PriceIntegration.IsAuctionatorAvailable())
            if isAvailable then
                HDG.Store:SetConfig("preferredPriceAddon", def.key)
                UpdatePriceToggle()
                UpdateTSMModeToggle()
                UpdateTSMColumnVisibility()
                HDG_GoblinTab.Refresh()
            end
        end)
        radio:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:AddLine(def.tipTitle)
            GameTooltip:AddLine(def.tipDesc, 1, 1, 1, true)
            local isAvailable = (def.key == "Direct") or
                (def.key == "TSM" and HDG_PriceIntegration.IsTSMAvailable()) or
                (def.key == "Auctionator" and HDG_PriceIntegration.IsAuctionatorAvailable())
            if not isAvailable then
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine(L["TIP_ADDON_NOT_INSTALLED"] or "Addon not installed", 1, 0.3, 0.3)
            end
            GameTooltip:Show()
        end)
        radio:SetScript("OnLeave", function() GameTooltip:Hide() end)

        sourceButtons[def.key] = radio
        prevRadio = radio
    end

    -- Update radio appearance based on current preference
    UpdatePriceToggle = function()
        local active = GetActivePriceAddonName()
        local toggleScheme = GetScheme()
        if not toggleScheme then return end

        for _, def in ipairs(sourceDefs) do
            local radio = sourceButtons[def.key]
            local isAvailable = (def.key == "Direct") or
                (def.key == "TSM" and HDG_PriceIntegration.IsTSMAvailable()) or
                (def.key == "Auctionator" and HDG_PriceIntegration.IsAuctionatorAvailable())
            local isActive = (active == def.key)

            if isActive then
                radio.dot:Show()
                radio.circle:SetDesaturated(false)
                radio.circle:SetVertexColor(toggleScheme.accent.r, toggleScheme.accent.g, toggleScheme.accent.b)
                radio.dot:SetVertexColor(toggleScheme.accent.r, toggleScheme.accent.g, toggleScheme.accent.b)
                radio.label:SetTextColor(toggleScheme.text.r, toggleScheme.text.g, toggleScheme.text.b)
            elseif not isAvailable then
                radio.dot:Hide()
                radio.circle:SetDesaturated(true)
                radio.circle:SetVertexColor(0.4, 0.4, 0.4)
                radio.label:SetTextColor(toggleScheme.text_dim.r * 0.5, toggleScheme.text_dim.g * 0.5, toggleScheme.text_dim.b * 0.5, 0.6)
            else
                radio.dot:Hide()
                radio.circle:SetDesaturated(false)
                radio.circle:SetVertexColor(toggleScheme.text_dim.r, toggleScheme.text_dim.g, toggleScheme.text_dim.b)
                radio.label:SetTextColor(toggleScheme.text_dim.r, toggleScheme.text_dim.g, toggleScheme.text_dim.b)
            end
        end
    end

    -- TSM price mode toggle: [Server] / [Region] - only shown when TSM is active
    local tsmModeContainer = CreateFrame("Frame", nil, profRow)
    tsmModeContainer:SetSize(120, 20)
    local tmScheme = GetScheme()

    local tsmModeButtons = {}
    local tsmModeDefs = {
        { key = "server", label = "Server", tipTitle = "Server Prices", tipDesc = "Uses DBMinBuyout - the lowest current buyout on your server's AH." },
        { key = "region", label = "Region", tipTitle = "Region Average", tipDesc = "Uses DBRegionSaleAvg - the average sale price across all servers in your region." },
    }

    local prevTsmRadio = nil
    for _, def in ipairs(tsmModeDefs) do
        local radio = CreateFrame("Button", nil, tsmModeContainer)
        radio:SetSize(RADIO_SIZE + 4 + 45, 16)
        if prevTsmRadio then
            radio:SetPoint("LEFT", prevTsmRadio, "RIGHT", 4, 0)
        else
            radio:SetPoint("LEFT", 0, 0)
        end

        radio.circle = radio:CreateTexture(nil, "ARTWORK")
        radio.circle:SetAtlas("common-radiobutton-circle")
        radio.circle:SetSize(RADIO_SIZE, RADIO_SIZE)
        radio.circle:SetPoint("LEFT", 0, 0)

        radio.dot = radio:CreateTexture(nil, "OVERLAY")
        radio.dot:SetAtlas("common-radiobutton-dot")
        radio.dot:SetSize(RADIO_SIZE, RADIO_SIZE)
        radio.dot:SetPoint("CENTER", radio.circle, "CENTER", 0, 0)
        radio.dot:Hide()

        radio.label = radio:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        radio.label:SetPoint("LEFT", radio.circle, "RIGHT", 3, 0)
        ApplyFont(radio.label, tmScheme, "small")
        radio.label:SetText(def.label)
        radio.modeKey = def.key

        radio:SetScript("OnShow", function(self)
            local textW = self.label:GetStringWidth() or 45
            self:SetWidth(RADIO_SIZE + 3 + textW + 2)
        end)

        radio:SetScript("OnClick", function()
            HDG.Store:SetConfig("tsmPriceMode", def.key)
            UpdateTSMModeToggle()
            HDG_GoblinTab.Refresh()
        end)
        HDG_UIHelpers.SetupTooltip(radio, {
            title = def.tipTitle,
            desc  = def.tipDesc,
            anchor = "ANCHOR_RIGHT",
        })

        tsmModeButtons[def.key] = radio
        prevTsmRadio = radio
    end

    UpdateTSMModeToggle = function()
        local active = GetActivePriceAddonName()
        local isTSM = (active == "TSM")
        local toggleScheme = GetScheme()
        if not toggleScheme then return end

        -- Only show when TSM is active source
        if isTSM then
            tsmModeContainer:Show()
        else
            tsmModeContainer:Hide()
            return
        end

        local currentMode = (HDG and HDG.Store and HDG.Store.state.config.tsmPriceMode) or "server"
        for _, def in ipairs(tsmModeDefs) do
            local radio = tsmModeButtons[def.key]
            local isActive = (currentMode == def.key)
            if isActive then
                radio.dot:Show()
                radio.circle:SetDesaturated(false)
                radio.circle:SetVertexColor(toggleScheme.accent.r, toggleScheme.accent.g, toggleScheme.accent.b)
                radio.dot:SetVertexColor(toggleScheme.accent.r, toggleScheme.accent.g, toggleScheme.accent.b)
                radio.label:SetTextColor(toggleScheme.text.r, toggleScheme.text.g, toggleScheme.text.b)
            else
                radio.dot:Hide()
                radio.circle:SetDesaturated(false)
                radio.circle:SetVertexColor(toggleScheme.text_dim.r, toggleScheme.text_dim.g, toggleScheme.text_dim.b)
                radio.label:SetTextColor(toggleScheme.text_dim.r, toggleScheme.text_dim.g, toggleScheme.text_dim.b)
            end
        end
    end

    goblinFrame.UpdateTSMModeToggle = UpdateTSMModeToggle

    -- Store reference for refresh
    goblinFrame.UpdatePriceToggle = UpdatePriceToggle

    -- Initial update
    UpdatePriceToggle()
    UpdateTSMModeToggle()

    -- Refresh/Scan button - created here, re-parented to filterRow after radio buttons below
    local scanScheme = GetScheme()
    scanButton = CreateFrame("Button", nil, expRow, "BackdropTemplate")
    scanButton:SetSize(80, 18)
    scanButton:SetPoint("RIGHT", expRow, "RIGHT", -8, 0) -- temporary anchor, re-set below
    scanButton:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        edgeSize = 1,
    })
    scanButton:SetBackdropColor(scanScheme.button_normal.r, scanScheme.button_normal.g, scanScheme.button_normal.b, 0.9)
    scanButton:SetBackdropBorderColor(scanScheme.accent.r, scanScheme.accent.g, scanScheme.accent.b, 1)
    RegisterWidget(scanButton, "Button")
    scanButton.text = scanButton:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    scanButton.text:SetPoint("CENTER", 0, 0)
    ApplyFont(scanButton.text, scanScheme, "small")
    scanButton.text:SetText(HDG.Theme.C_Text() .. "Scan|r")

    -- Price freshness text - right-aligned on expRow
    priceSourceText = expRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    priceSourceText:SetPoint("RIGHT", expRow, "RIGHT", -8, 0)
    priceSourceText:SetJustifyH("RIGHT")
    ApplyFont(priceSourceText, scanScheme, "small")
    priceSourceText:SetText(HDG.Theme.C_Dim() .. L["STATUS_LOADING"] .. "|r")

    scanButton:SetScript("OnClick", function()
        local canScan, reason = HDG_GoblinTab.CanTriggerScan()
        if canScan then
            HDG_GoblinTab.TriggerScan()
        else
            print(CONFIG.TEXT_COLORS.WOW_GOLD .. "[HDG]|r " .. string.format(L["GOBLIN_CANNOT_SCAN"] or "Cannot scan: %s", reason or "Not ready"))
        end
    end)
    scanButton:SetScript("OnEnter", function(self)
        self:SetBackdropColor(GetScheme().button_hover.r, GetScheme().button_hover.g, GetScheme().button_hover.b, 1)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        local source = GetActivePriceAddonName()
        local canScan, reason = HDG_GoblinTab.CanTriggerScan()
        if canScan then
            if source == "Direct" then
                GameTooltip:AddLine(L["TIP_SCAN_DIRECT"] or "Scan Auction House")
                GameTooltip:AddLine(L["TIP_SCAN_DIRECT_DESC"] or "Queries AH prices directly for all items in the current filter. The AH search view will update during scanning.", 1, 1, 1, true)
                local cacheCount = HDG_PriceIntegration.GetDirectCacheCount()
                if cacheCount > 0 then
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine(string.format("%d items cached this session", cacheCount), 0.7, 0.7, 0.7)
                end
            elseif source == "Auctionator" then
                GameTooltip:AddLine(L["TIP_SCAN_AH"])
                GameTooltip:AddLine(L["TIP_SCAN_AH_DESC"], 1, 1, 1, true)
                local timeSince = GetTimeSinceLastScan()
                if timeSince then
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine(string.format(L["TIP_LAST_SCAN"], FormatTimeSince(timeSince)), 0.7, 0.7, 0.7)
                end
            elseif source == "TSM" then
                GameTooltip:AddLine(L["TIP_REFRESH_TSM"] or "Refresh from TSM")
                GameTooltip:AddLine(L["TIP_REFRESH_TSM_DESC"] or "Recalculates profit data using current TSM market values.", 1, 1, 1, true)
            end
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(L["TIP_CLICK_SCAN"], 0.5, 1, 0.5)
        elseif reason == "AH Closed" then
            GameTooltip:AddLine(L["TIP_AH_REQUIRED"])
            GameTooltip:AddLine(L["TIP_AH_REQUIRED_DESC"], 1, 1, 1, true)
        else
            GameTooltip:AddLine(L["TIP_SCAN_COOLDOWN"])
            GameTooltip:AddLine(reason or L["TIP_SCAN_COOLDOWN_DESC"], 1, 0.82, 0, true)
        end
        GameTooltip:Show()
    end)
    scanButton:SetScript("OnLeave", function()
        UpdateScanButton()
        GameTooltip:Hide()
    end)

    -- Filter row (search box + checkboxes)
    local filterRow = CreateFrame("Frame", nil, goblinFrame)
    filterRow:SetPoint("TOPLEFT", 8, -48)
    filterRow:SetPoint("TOPRIGHT", -8, -48)
    filterRow:SetHeight(20)

    -- Search box
    local searchBox = CreateFrame("EditBox", nil, filterRow, "BackdropTemplate")
    searchBox:SetPoint("LEFT", 0, 0)
    searchBox:SetSize(115, 20)
    searchBox:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false, edgeSize = 1,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })
    local searchScheme = GetScheme()
    if searchScheme then
        searchBox:SetBackdropColor(searchScheme.bg.r, searchScheme.bg.g, searchScheme.bg.b, 1)
        searchBox:SetBackdropBorderColor(searchScheme.border.r, searchScheme.border.g, searchScheme.border.b, 0.8)
    else
        searchBox:SetBackdropColor(0.05, 0.05, 0.07, 1)
        local bc = CONFIG.COLORS.PANEL_DARK_BORDER_COOL; searchBox:SetBackdropBorderColor(bc[1], bc[2], bc[3], 0.8)
    end
    -- Apply themed font to editbox
    if searchScheme and searchScheme.fonts and searchScheme.fonts.small then
        local fontFile = searchScheme.fonts.small.file or "Fonts\\FRIZQT__.TTF"
        local fontSize = searchScheme.fonts.small.size or 10
        searchBox:SetFont(fontFile, fontSize, "")
    else
        searchBox:SetFontObject("GameFontHighlightSmall")
    end
    searchBox:SetAutoFocus(false)
    searchBox:SetTextInsets(6, 20, 0, 0)
    searchBox:SetMaxLetters(50)

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

    -- Search clear button (X)
    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()
        searchFilter = ""
        HDG_GoblinTab.RefreshDisplay()
    end)

    -- Search text change handler
    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
            searchFilter = text
            if searchDebounceTimer then searchDebounceTimer:Cancel() end
            searchDebounceTimer = C_Timer.NewTimer(0.3, function()
                searchDebounceTimer = nil
                HDG_GoblinTab.RefreshDisplay()
            end)
        end
    end)
    searchBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    searchBox:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
    end)
    RegisterWidget(searchBox, "SearchBox")

    -- Export to CSV button (icon only) - right of search box
    exportButton = CreateFrame("Button", nil, filterRow)
    exportButton:SetSize(16, 16)
    exportButton:SetPoint("LEFT", searchBox, "RIGHT", 6, 0)
    local exportIcon = exportButton:CreateTexture(nil, "ARTWORK")
    exportIcon:SetAllPoints()
    exportIcon:SetAtlas("housing-neighborhood-invite-icon")
    exportIcon:SetVertexColor(0.85, 0.85, 0.85)
    exportButton.icon = exportIcon
    exportButton:SetScript("OnEnter", function(self)
        self.icon:SetVertexColor(1, 1, 1)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText(L["TIP_EXPORT_CSV"])
        GameTooltip:AddLine(L["TIP_EXPORT_CSV_DESC"], 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    exportButton:SetScript("OnLeave", function(self)
        self.icon:SetVertexColor(0.85, 0.85, 0.85)
        GameTooltip:Hide()
    end)
    exportButton:SetScript("OnClick", ShowCSVExportWindow)

    -- Only known recipes checkbox (for current character) -- mutually exclusive with knownByAltsCheck
    local onlyKnownCheck, knownByAltsCheck
    onlyKnownCheck = CreateCheckbox(filterRow, "My Recipes", false, function(checked) -- English only (layout policy)
        onlyKnownRecipes = checked
        if checked and knownByAltsCheck then knownByAltsCheck:SetChecked(false); knownByAlts = false end
        HDG_GoblinTab.RefreshDisplay()
    end)
    onlyKnownCheck:SetPoint("LEFT", exportButton, "RIGHT", 8, 0)
    onlyKnownCheck:SetSize(78, 18)

    -- Known by alts checkbox (any character) -- mutually exclusive with onlyKnownCheck
    knownByAltsCheck = CreateCheckbox(filterRow, "All Known Recipes", false, function(checked) -- English only (layout policy)
        knownByAlts = checked
        if checked and onlyKnownCheck then onlyKnownCheck:SetChecked(false); onlyKnownRecipes = false end
        HDG_GoblinTab.RefreshDisplay()
    end)
    knownByAltsCheck:SetPoint("LEFT", onlyKnownCheck, "RIGHT", 4, 0)
    knownByAltsCheck:SetSize(118, 18)

    -- My Auctions checkbox (filter to items player has listed on AH)
    myAuctionsCheck = CreateCheckbox(filterRow, "My Auctions", false, function(checked) -- English only (layout policy)
        onlyMyAuctions = checked
        if checked and ownedAuctionsStale then
            -- Try to query owned auctions if AH is open
            HDG_GoblinTab.QueryOwnedAuctions()
        end
        HDG_GoblinTab.RefreshDisplay()
    end)
    myAuctionsCheck:SetPoint("LEFT", knownByAltsCheck, "RIGHT", 4, 0)
    myAuctionsCheck:SetSize(86, 18)

    -- Crafting-queue filters: mutually exclusive (Queued Only vs Hide Queued)
    local queuedOnlyCheck, hideQueuedCheck
    queuedOnlyCheck = CreateCheckbox(filterRow, "Queued Only", false, function(checked) -- English only (layout policy)
        queuedOnly = checked
        if checked and hideQueuedCheck then hideQueuedCheck:SetChecked(false); hideQueued = false end
        HDG_GoblinTab.RefreshDisplay()
    end)
    queuedOnlyCheck:SetPoint("LEFT", myAuctionsCheck, "RIGHT", 4, 0)
    queuedOnlyCheck:SetSize(86, 18)

    hideQueuedCheck = CreateCheckbox(filterRow, "Hide Queued", false, function(checked) -- English only (layout policy)
        hideQueued = checked
        if checked and queuedOnlyCheck then queuedOnlyCheck:SetChecked(false); queuedOnly = false end
        HDG_GoblinTab.RefreshDisplay()
    end)
    hideQueuedCheck:SetPoint("LEFT", queuedOnlyCheck, "RIGHT", 4, 0)
    hideQueuedCheck:SetSize(88, 18)

    -- Re-parent scan button and price source radios onto filterRow, right-aligned
    -- Layout (right to left): [Scan] | [Direct Auc TSM] | [Server Region] |
    scanButton:SetParent(filterRow)
    scanButton:ClearAllPoints()
    scanButton:SetPoint("RIGHT", filterRow, "RIGHT", -8, 0)
    priceToggleContainer:SetParent(filterRow)
    priceToggleContainer:SetPoint("RIGHT", scanButton, "LEFT", -6, 0)

    -- Right divider (between price toggle and TSM mode)
    local divR = filterRow:CreateTexture(nil, "ARTWORK")
    divR:SetSize(1, 12)
    divR:SetColorTexture(0.4, 0.4, 0.4, 0.6)
    divR:SetPoint("RIGHT", priceToggleContainer, "LEFT", -5, 0)

    tsmModeContainer:SetParent(filterRow)
    tsmModeContainer:SetPoint("RIGHT", divR, "LEFT", -5, 0)

    -- Left divider (left edge of TSM mode group)
    local divL = tsmModeContainer:CreateTexture(nil, "ARTWORK")
    divL:SetSize(1, 12)
    divL:SetColorTexture(0.4, 0.4, 0.4, 0.6)
    divL:SetPoint("RIGHT", tsmModeContainer, "LEFT", -5, 0)

    -- Item count text (shows filtered/total) - centered in main window status bar
    local itemCountScheme = GetScheme()
    itemCountText = mainFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    itemCountText:SetPoint("BOTTOM", mainFrame, "BOTTOM", 0, 8)
    itemCountText:SetJustifyH("CENTER")
    ApplyFont(itemCountText, itemCountScheme, "small")
    itemCountText:SetText("")
    itemCountText:Hide()

    -- Column headers
    local columnHeaderRow = CreateFrame("Frame", nil, goblinFrame, "BackdropTemplate")
    columnHeaderRow:SetPoint("TOPLEFT", 8, -72)
    columnHeaderRow:SetPoint("TOPRIGHT", -8, -72)
    columnHeaderRow:SetHeight(22)
    columnHeaderRow:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
    })
    local colScheme = GetScheme()
    if colScheme then
        columnHeaderRow:SetBackdropColor(colScheme.panel.r, colScheme.panel.g, colScheme.panel.b, 1)
    else
        columnHeaderRow:SetBackdropColor(0.15, 0.15, 0.17, 1)
    end
    RegisterWidget(columnHeaderRow, "Panel")
    goblinFrame.columnHeaderRow = columnHeaderRow  -- Store reference for theme refresh

    -- Panel width ~826px usable (870 - 16 padding - 28 scrollbar)
    CreateColumnHeader(columnHeaderRow, "Item Name", "name", 8, 180, true)       -- Left-aligned
    CreateColumnHeader(columnHeaderRow, "Lumber", "lumberType", 192, 75, true)   -- Left-aligned (lumber type)

    -- Divider before $/Lum
    local divider1 = columnHeaderRow:CreateTexture(nil, "ARTWORK")
    local divScheme = GetScheme()
    if divScheme then
        divider1:SetColorTexture(divScheme.border.r, divScheme.border.g, divScheme.border.b, 0.6)
    else
        divider1:SetColorTexture(0.4, 0.4, 0.4, 0.6)
    end
    divider1:SetSize(1, 18)
    divider1:SetPoint("LEFT", 267, 0)

    CreateColumnHeader(columnHeaderRow, "$/Lum", "lumber", 271, 65, false)       -- Per-lumber value
    CreateColumnHeader(columnHeaderRow, "Total", "lumberTotal", 340, 65, false)  -- Total lumber value

    -- Divider after Total
    local divider2 = columnHeaderRow:CreateTexture(nil, "ARTWORK")
    if divScheme then
        divider2:SetColorTexture(divScheme.border.r, divScheme.border.g, divScheme.border.b, 0.6)
    else
        divider2:SetColorTexture(0.4, 0.4, 0.4, 0.6)
    end
    divider2:SetSize(1, 18)
    divider2:SetPoint("LEFT", 407, 0)

    CreateColumnHeader(columnHeaderRow, "Cost", "cost", 409, 65, false)          -- Right-aligned
    CreateColumnHeader(columnHeaderRow, "Sell", "sell", 478, 65, false)          -- Right-aligned
    CreateColumnHeader(columnHeaderRow, "Profit", "profit", 547, 70, false)      -- Right-aligned
    CreateColumnHeader(columnHeaderRow, "%", "margin", 621, 55, false)           -- Right-aligned

    -- Divider after %
    local divider3 = columnHeaderRow:CreateTexture(nil, "ARTWORK")
    if divScheme then
        divider3:SetColorTexture(divScheme.border.r, divScheme.border.g, divScheme.border.b, 0.6)
    else
        divider3:SetColorTexture(0.4, 0.4, 0.4, 0.6)
    end
    divider3:SetSize(1, 18)
    divider3:SetPoint("LEFT", 678, 0)

    -- TSM-only columns (visibility controlled by UpdateTSMColumnVisibility)
    local regionAvgHeader = CreateColumnHeader(columnHeaderRow, "Region Avg", "regionSaleAvg", 680, 90, false)
    columnHeaders["regionSaleAvg"] = regionAvgHeader

    local tsmMarginHeader = CreateColumnHeader(columnHeaderRow, "TSM %", "tsmMargin", 774, 55, false)
    columnHeaders["tsmMargin"] = tsmMarginHeader

    -- Set initial sort indicator
    UpdateSortIndicators()

    -- Set initial TSM column visibility
    UpdateTSMColumnVisibility()

    -- Scroll frame for item list
    scrollFrame = CreateFrame("ScrollFrame", "HDG_GoblinScrollFrame", goblinFrame, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", 8, -96)
    scrollFrame:SetPoint("BOTTOMRIGHT", -28, 8)

    scrollContent = CreateFrame("Frame", nil, scrollFrame)
    scrollContent:SetSize(scrollFrame:GetWidth(), 1)
    scrollFrame:SetScrollChild(scrollContent)

    -- Initialize row pool
    rowPool = CreateRowFactory(scrollContent)

    -- Apply themed scrollbar styling
    HDG_UIHelpers.StyleScrollBar(scrollFrame)

    -- Register for Auctionator scan callbacks
    RegisterScanCallback()

end

-- ============================================================================
-- REFRESH / RENDER
-- ============================================================================

-- Render-only refresh: re-filter, re-sort, re-draw without recalculating prices
function HDG_GoblinTab.RefreshDisplay()
    if not goblinFrame or not goblinFrame:IsShown() then return end
    UpdateScanButton()
    ApplyFiltersAndSort()

    -- Update item count
    if itemCountText then
        local filteredCount = #filteredData
        local totalCount = #profitData
        local countScheme = GetScheme()
        ApplyFont(itemCountText, countScheme, "small")
        local accentHex = "14b8a6"
        local textHex = "FFFFFF"
        if countScheme then
            accentHex = string.format("%02x%02x%02x", countScheme.accent.r * 255, countScheme.accent.g * 255, countScheme.accent.b * 255)
            textHex = string.format("%02x%02x%02x", countScheme.text.r * 255, countScheme.text.g * 255, countScheme.text.b * 255)
        end
        if filteredCount == totalCount then
            itemCountText:SetText(string.format("|cFF%s%d items|r", accentHex, totalCount))
        else
            itemCountText:SetText(string.format("|cFF%s%d|r |cFF%s/|r |cFF%s%d items|r", textHex, filteredCount, textHex, accentHex, totalCount))
        end
    end

    -- Release old rows and hide expansion panel temporarily
    rowPool:ReleaseAll()
    if expansionPanel then
        expansionPanel:Hide()
    end

    -- Render rows
    local rowScheme = GetScheme()
    local yOffset = 0
    for i, item in ipairs(filteredData) do
        local row = rowPool:Acquire(scrollContent)
        row:SetPoint("TOPLEFT", 0, yOffset)
        row:SetPoint("RIGHT", 0, 0)

        -- Alternate row colors
        local bgColor
        if rowScheme then
            if (i % 2 == 0) then
                bgColor = {rowScheme.panel.r, rowScheme.panel.g, rowScheme.panel.b, 0.7}
            else
                bgColor = {rowScheme.bg.r, rowScheme.bg.g, rowScheme.bg.b, 0.5}
            end
        else
            bgColor = (i % 2 == 0) and {0.14, 0.14, 0.16, 0.7} or {0.12, 0.12, 0.14, 0.5}
        end
        row.bgColor = bgColor
        row:SetBackdropColor(unpack(bgColor))

        row.itemID = item.itemID
        row.item = item

        -- Crafting-queue indicator: stripe on left edge if this item is in the queue.
        -- _queuedQty is annotated up in ApplyFiltersAndSort so we don't re-walk the queue per row.
        row.queuedQty = item._queuedQty
        if row.queuedQty then
            if rowScheme and rowScheme.accent then
                row.queuedStripe:SetVertexColor(rowScheme.accent.r, rowScheme.accent.g, rowScheme.accent.b, 1)
            else
                row.queuedStripe:SetVertexColor(0.52, 0.60, 0.0, 1)
            end
            row.queuedStripe:Show()
        else
            row.queuedStripe:Hide()
        end

        -- Color name by item quality (theme-aware: Olive Green for Uncommon on light themes)
        local nameColor = HDG.Theme.GetQualityHex(item.quality or 1)
        local profIconPrefix = ""
        if professionFilter == "All" and item.profession then
            local profIconData = HDG_ExpansionData.PROFESSION_ICONS[item.profession]
            if profIconData and profIconData.atlas then
                profIconPrefix = CreateAtlasMarkup(profIconData.atlas, 12, 12) .. " "
            end
        end
        row.nameText:SetText(profIconPrefix .. nameColor .. item.name .. "|r")

        -- Auction status icon: green check (cheapest), amber ~ (matched/LIFO risk), red X (undercut)
        local auctionInfo = ownedAuctions[item.itemID]
        if auctionInfo and auctionInfo.qty > 0 then
            local undercut = "none" -- "none" = cheapest, "matched" = same price (LIFO risk), "undercut" = strictly cheaper
            if auctionInfo.buyout and item.sellPrice and item.sellPrice > 0 then
                if item.sellPrice < auctionInfo.buyout then
                    undercut = "undercut"
                elseif item.sellPrice == auctionInfo.buyout then
                    undercut = "matched" -- same price, but LIFO means newer listings sell first
                end
            end
            if undercut == "undercut" then
                row.auctionIcon:SetAtlas("common-icon-redx")
                row.auctionIcon:SetVertexColor(1, 1, 1, 1)
            elseif undercut == "matched" then
                row.auctionIcon:SetAtlas("common-radiobutton-dot")
                row.auctionIcon:SetVertexColor(1, 0.82, 0, 1) -- amber dot: same price, LIFO risk
            else
                row.auctionIcon:SetAtlas("common-icon-checkmark")
                row.auctionIcon:SetVertexColor(0.2, 0.9, 0.2, 1) -- green: cheapest
            end
            row.auctionIcon:Show()
            row.nameText:SetPoint("LEFT", 22, 0)
        elseif auctionInfo and auctionInfo.sold > 0 then
            -- Sold but not collected: gold coin icon
            row.auctionIcon:SetAtlas("coin-gold")
            row.auctionIcon:SetVertexColor(1, 1, 1, 1)
            row.auctionIcon:Show()
            row.nameText:SetPoint("LEFT", 22, 0)
        else
            row.auctionIcon:Hide()
            row.nameText:SetPoint("LEFT", 8, 0)
        end

        -- Lumber type (expansion-colored localized name + inventory count)
        if item.lumberType then
            local lumberInfo = HDG_ExpansionData.GetLumberInfo(item.lumberType)
            if lumberInfo then
                local lumberExpColor = HDG_ExpansionData.GetColor(lumberInfo.expansion)
                local lumberColorHex = string.format("|cFF%02x%02x%02x",
                    lumberExpColor[1] * 255, lumberExpColor[2] * 255, lumberExpColor[3] * 255)
                -- Get localized material name (strips lumber/wood words)
                local lumberDisplayName = HDG_ExpansionData.GetLumberMaterialName(item.lumberType) or lumberInfo.shortName or lumberInfo.name
                -- Get inventory count
                local invCount = _G.HDG_Data and _G.HDG_Data.GetItemCount(item.lumberType) or 0
                -- Green count when the player has enough lumber for at least one
                -- craft of this row's recipe; dim grey otherwise.
                local countText = ""
                if invCount > 0 then
                    local enough = item.lumberQty and invCount >= item.lumberQty
                    local countColor = enough and HDG.Theme.C_Success() or HDG.Theme.C_Dim()
                    countText = " " .. countColor .. "(" .. invCount .. ")|r"
                end
                row.lumberTypeText:SetText(lumberColorHex .. lumberDisplayName .. "|r" .. countText)
            else
                row.lumberTypeText:SetText(HDG.Theme.C_Dim() .. "-|r")
            end
        else
            row.lumberTypeText:SetText(HDG.Theme.C_Dim() .. "-|r")
        end

        -- Sell price (Solarized Yellow for currency)
        if item.sellPrice then
            row.sellText:SetText(HDG.Theme.C_Currency() .. HDG_PriceIntegration.FormatGoldOnly(item.sellPrice) .. "|r")
        else
            row.sellText:SetText(HDG.Theme.C_Dim() .. "?|r")
        end

        -- Material cost (Solarized Yellow for currency)
        if item.materialCost and item.materialCost > 0 then
            row.costText:SetText(HDG.Theme.C_Currency() .. HDG_PriceIntegration.FormatGoldOnly(item.materialCost) .. "|r")
        else
            row.costText:SetText(HDG.Theme.C_Dim() .. "-|r")
        end

        -- Profit (colored)
        if item.profit then
            local profitColor = item.profit >= 0 and (HDG.Theme.C_Success() .. "+") or HDG.Theme.C_Error()
            row.profitText:SetText(profitColor .. HDG_PriceIntegration.FormatGoldOnly(math.abs(item.profit)) .. "|r")
        else
            row.profitText:SetText(HDG.Theme.C_Dim() .. "?|r")
        end

        -- Margin
        if item.margin then
            local marginColor = item.margin >= 0 and HDG.Theme.C_Success() or HDG.Theme.C_Error()
            row.marginText:SetText(marginColor .. string.format("%.0f%%", item.margin) .. "|r")
        else
            row.marginText:SetText(HDG.Theme.C_Dim() .. "-|r")
        end

        -- Region Avg (TSM only)
        local isTSMActive = GetActivePriceAddonName() == "TSM"
        if isTSMActive then
            if item.regionSaleAvg then
                row.regionAvgText:SetText(HDG.Theme.C_Accent() .. HDG_PriceIntegration.FormatGoldOnly(item.regionSaleAvg) .. "|r")
            else
                row.regionAvgText:SetText(HDG.Theme.C_Dim() .. "-|r")
            end
            row.regionAvgText:Show()

            -- TSM Margin (based on region avg) - same color as Region Avg column
            if item.tsmMargin then
                row.tsmMarginText:SetText(HDG.Theme.C_Accent() .. string.format("%.0f%%", item.tsmMargin) .. "|r")
            else
                row.tsmMarginText:SetText(HDG.Theme.C_Dim() .. "-|r")
            end
            row.tsmMarginText:Show()
        else
            row.regionAvgText:Hide()
            row.tsmMarginText:Hide()
        end

        -- Auction status text (listed qty / sold indicator)
        local auctionInfo = ownedAuctions[item.itemID]
        if auctionInfo and not isTSMActive then
            local parts = {}
            if auctionInfo.qty > 0 then
                table.insert(parts, HDG.Theme.C_Success() .. "x" .. auctionInfo.qty .. " listed|r")
            end
            if auctionInfo.sold > 0 then
                table.insert(parts, HDG.Theme.C_Currency() .. "x" .. auctionInfo.sold .. " sold|r")
            end
            if #parts > 0 then
                row.auctionText:SetText(table.concat(parts, " "))
                row.auctionText:Show()
            else
                row.auctionText:Hide()
            end
        else
            row.auctionText:Hide()
        end

        -- Per-lumber value ($/Lum)
        if item.lumberValue and item.lumberQty > 0 then
            local lumberColor = item.lumberValue >= 0 and HDG.Theme.C_Warning() or HDG.Theme.C_Error()
            row.lumberText:SetText(lumberColor .. HDG_PriceIntegration.FormatGoldOnly(item.lumberValue) .. "|r")
        else
            row.lumberText:SetText(HDG.Theme.C_Dim() .. "-|r")
        end

        -- Total lumber value (LumTot = per-lumber × qty)
        if item.lumberValue and item.lumberQty > 0 then
            local totalLumberValue = item.lumberValue * item.lumberQty
            local lumberColor = totalLumberValue >= 0 and HDG.Theme.C_Warning() or HDG.Theme.C_Error()
            row.lumberTotalText:SetText(lumberColor .. HDG_PriceIntegration.FormatGoldOnly(totalLumberValue) .. "|r")
        else
            row.lumberTotalText:SetText(HDG.Theme.C_Dim() .. "-|r")
        end

        yOffset = yOffset - 26

        -- Show expansion panel if this row is expanded
        if expandedRowKey == item.recipeKey then
            local panelHeight = ShowExpansionPanel(row, item)
            yOffset = yOffset - panelHeight
        end
    end

    -- Hide expansion panel if expanded row is no longer visible
    if expansionPanel and expandedRowKey then
        local found = false
        for _, item in ipairs(filteredData) do
            if item.recipeKey == expandedRowKey then
                found = true
                break
            end
        end
        if not found then
            HideExpansionPanel()
        end
    end

    -- Update scroll content height
    scrollContent:SetHeight(math.abs(yOffset) + 10)
end

-- Full refresh: recalculate all prices then render
function HDG_GoblinTab.Refresh()
    if not goblinFrame or not goblinFrame:IsShown() then return end
    CalculateProfitData()
    HDG_GoblinTab.RefreshDisplay()
end

-- Show the tab
function HDG_GoblinTab.Show()
    if not goblinFrame then
        HDG_GoblinTab.Create()
    end
    goblinFrame:Show()
    if itemCountText then itemCountText:Show() end
    -- Restart freshness ticker when tab becomes visible
    if not freshnessTimer then
        freshnessTimer = C_Timer.NewTicker(60, function()
            if goblinFrame and goblinFrame:IsShown() and scanState == "idle" then
                UpdateScanButton()
            end
        end)
    end
    HDG_GoblinTab.Refresh()
end

-- Hide the tab
function HDG_GoblinTab.Hide()
    -- Close any open dropdown menus
    CloseActiveDropdown()
    -- Hide expansion panel
    HideExpansionPanel()
    -- Stop freshness ticker when tab is hidden
    if freshnessTimer then
        freshnessTimer:Cancel()
        freshnessTimer = nil
    end
    if goblinFrame then
        goblinFrame:Hide()
    end
    if itemCountText then itemCountText:Hide() end
end

-- ============================================================================
-- EVENT BUS HANDLERS
-- ============================================================================

local function RegisterEventBusHandlers()
    if not HDG or not HDG.EventBus then return end
    -- Refresh on theme/font changes to recalculate row heights
    HDG.EventBus:Register("HDG_THEME_UPDATE", function()
        if goblinFrame and goblinFrame:IsShown() then
            HDG_GoblinTab.RefreshDisplay()
        end
    end)
    -- Refresh when the crafting queue changes so the queued-stripe stays accurate
    HDG.EventBus:Register("HDG_STATE_CHANGED", function()
        if goblinFrame and goblinFrame:IsShown() then
            HDG_GoblinTab.RefreshDisplay()
        end
    end)
end

RegisterEventBusHandlers()

-- Export to privateDB for HDG_UI access
privateDB.HDG_GoblinTab = HDG_GoblinTab
