
local addonName, privateDB = ...

-- ============================================================================
-- UI HELPER FUNCTIONS MODULE
-- Centralized UI utilities for consistency and reduced code duplication
-- Added in v1.9.5 as part of Phase 1 refactoring
-- ============================================================================

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

-- Module namespace
local UIHelpers = {}
privateDB.UIHelpers = UIHelpers

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

local HDG = _G.HousingDecorGuide
local function GetScheme()
    if HDG and HDG.Theme and HDG.Theme.GetCurrentScheme then
        return HDG.Theme.GetCurrentScheme()
    end
    if HDG and HDG.Colors and HDG.Colors.Schemes then
        return HDG.Colors.Schemes.HDGDefault
    end
    return nil
end
local function RegisterWidget(w, t) HDG.Theme.RegisterWidget(w, t) end

-- Export for other modules
UIHelpers.GetScheme = GetScheme
UIHelpers.RegisterWidget = RegisterWidget

-- ============================================================================
-- TEXT COLOR CONSTANTS
-- Reference centralized CONFIG.TEXT_COLORS (defined in HDG_Config.lua)
-- ============================================================================

-- Direct reference to CONFIG.TEXT_COLORS - single source of truth
UIHelpers.TEXT_COLORS = CONFIG.TEXT_COLORS

-- Shorthand for common color operations
function UIHelpers.ColorText(text, colorKey)
    local color = UIHelpers.TEXT_COLORS[colorKey] or UIHelpers.TEXT_COLORS.WHITE
    return color .. text .. UIHelpers.TEXT_COLORS.RESET
end

-- ============================================================================
-- BACKDROP PRESETS
-- Standard backdrop configurations for consistent panel styling
-- ============================================================================

UIHelpers.BACKDROP_FLAT = {
    bgFile   = "Interface\\Buttons\\WHITE8X8",
    edgeFile = "Interface\\Buttons\\WHITE8X8",
    tile = false, edgeSize = 1,
    insets = { left = 0, right = 0, top = 0, bottom = 0 },
}

-- ============================================================================
-- COLOR CONSTANTS (HDGDefault fallbacks)
-- Used when scheme is not available. Prefer dynamic getters for theme support.
-- ============================================================================

local DEFAULT = _G.HousingDecorGuide.Colors.Schemes.HDGDefault

-- Border colors (fallback - prefer GetBorderColor() for theme support)
UIHelpers.BORDER_COLOR = { DEFAULT.teal_border.r, DEFAULT.teal_border.g, DEFAULT.teal_border.b, 1 }
UIHelpers.BORDER_COLOR_DIM = { DEFAULT.teal_border.r, DEFAULT.teal_border.g, DEFAULT.teal_border.b, 0.7 }
UIHelpers.BORDER_COLOR_BRIGHT = { DEFAULT.teal_border_bright.r, DEFAULT.teal_border_bright.g, DEFAULT.teal_border_bright.b, 1 }

-- State colors (fallback - prefer GetStateActive/Inactive/Hover() for theme support)
UIHelpers.STATE_ACTIVE = { DEFAULT.state_active.r, DEFAULT.state_active.g, DEFAULT.state_active.b, 1.0 }
UIHelpers.STATE_INACTIVE = { DEFAULT.state_inactive.r, DEFAULT.state_inactive.g, DEFAULT.state_inactive.b, 1.0 }
UIHelpers.STATE_HOVER = { DEFAULT.state_hover.r, DEFAULT.state_hover.g, DEFAULT.state_hover.b, 1.0 }

-- ============================================================================
-- SCHEME-AWARE COLOR GETTERS
-- These return colors from the current theme scheme for live theme switching
-- ============================================================================

--- Get border color from current scheme
-- @param alpha Optional alpha override (default 1.0)
-- @return r, g, b, a values
function UIHelpers.GetBorderColor(alpha)
    local scheme = GetScheme()
    if scheme and scheme.border then
        return scheme.border.r, scheme.border.g, scheme.border.b, alpha or scheme.border.a
    end
    return 0.08, 0.72, 0.65, alpha or 1
end

--- Get active button/state color from current scheme
-- @param alpha Optional alpha override
-- @return r, g, b, a values
function UIHelpers.GetActiveColor(alpha)
    local scheme = GetScheme()
    if scheme and scheme.button_active then
        return scheme.button_active.r, scheme.button_active.g, scheme.button_active.b, alpha or scheme.button_active.a
    end
    return 0.08, 0.72, 0.65, alpha or 1
end

--- Get inactive button/state color from current scheme
-- @param alpha Optional alpha override
-- @return r, g, b, a values
function UIHelpers.GetInactiveColor(alpha)
    local scheme = GetScheme()
    if scheme and scheme.button_inactive then
        return scheme.button_inactive.r, scheme.button_inactive.g, scheme.button_inactive.b, alpha or scheme.button_inactive.a
    end
    return 0.4, 0.4, 0.4, alpha or 1
end

--- Get hover color from current scheme
-- @param alpha Optional alpha override
-- @return r, g, b, a values
function UIHelpers.GetHoverColor(alpha)
    local scheme = GetScheme()
    if scheme and scheme.button_hover then
        return scheme.button_hover.r, scheme.button_hover.g, scheme.button_hover.b, alpha or scheme.button_hover.a
    end
    return 0.10, 0.80, 0.72, alpha or 1
end

--- Get text color from current scheme
-- @param isDim If true, returns dimmed text color
-- @param isHeader If true, returns header text color
-- @return r, g, b, a values
function UIHelpers.GetTextColor(isDim, isHeader)
    local scheme = GetScheme()
    if scheme then
        if isHeader then
            return scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, scheme.text_header.a
        elseif isDim then
            return scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a
        else
            return scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a
        end
    end
    -- Fallback
    local C = CONFIG.COLORS
    if isHeader then return unpack(C.TEXT_ACTIVE) end
    if isDim then return unpack(C.TEXT_INACTIVE) end
    return unpack(C.TEXT_BODY)
end

--- Get accent color from current scheme
-- @param alpha Optional alpha override
-- @return r, g, b, a values
function UIHelpers.GetAccentColor(alpha)
    local scheme = GetScheme()
    if scheme then
        return scheme.accent.r, scheme.accent.g, scheme.accent.b, alpha or scheme.accent.a
    end
    return 0.08, 0.72, 0.65, alpha or 1  -- HDG teal fallback
end

--- Get panel background color from current scheme
-- @param alpha Optional alpha override
-- @return r, g, b, a values
function UIHelpers.GetPanelColor(alpha)
    local scheme = GetScheme()
    if scheme then
        return scheme.panel.r, scheme.panel.g, scheme.panel.b, alpha or scheme.panel.a
    end
    return 0.05, 0.05, 0.05, alpha or 0.95
end

--- Get main background color from current scheme
-- @param alpha Optional alpha override
-- @return r, g, b, a values
function UIHelpers.GetBgColor(alpha)
    local scheme = GetScheme()
    if scheme then
        return scheme.bg.r, scheme.bg.g, scheme.bg.b, alpha or scheme.bg.a
    end
    local c = CONFIG.COLORS.PANEL_DARK
    return c[1], c[2], c[3], alpha or 0.95
end

-- Panel background colors
UIHelpers.BG_PANEL = { 0.05, 0.05, 0.05, 0.95 }       -- Main panel background
UIHelpers.BG_PANEL_DARK = { 0.08, 0.08, 0.1, 0.9 }    -- Slightly lighter panel bg
UIHelpers.BG_HEADER = { 0.1, 0.1, 0.1, 0.8 }          -- Section header background
UIHelpers.BG_TRANSPARENT = { 0, 0, 0, 0 }             -- Fully transparent

-- Button colors (non-state buttons like compact, pin)
UIHelpers.BTN_NORMAL = { DEFAULT.button_normal.r, DEFAULT.button_normal.g, DEFAULT.button_normal.b, DEFAULT.button_normal.a }
UIHelpers.BTN_HOVER = { DEFAULT.button_hover.r, DEFAULT.button_hover.g, DEFAULT.button_hover.b, DEFAULT.button_hover.a }

-- Row alternating colors (for lists/tables)
UIHelpers.ROW_EVEN = { DEFAULT.row_alt.r, DEFAULT.row_alt.g, DEFAULT.row_alt.b, DEFAULT.row_alt.a }
UIHelpers.ROW_ODD = { 0.08, 0.08, 0.10, 0.6 }
UIHelpers.ROW_HOVER = { 0.2, 0.2, 0.25, 0.8 }

-- Discord integration colors
UIHelpers.DISCORD_BLURPLE = { 0.34, 0.40, 0.95, 0.9 }
UIHelpers.DISCORD_BLURPLE_HOVER = { 0.44, 0.50, 1.0, 1.0 }

-- Tooltip default anchor (from config)
UIHelpers.TOOLTIP_ANCHOR = CONFIG.TOOLTIP.DEFAULT_ANCHOR

-- ============================================================================
-- DATA ACCESS HELPER
-- Nil-safe accessor for HDG_Data module (may not be loaded yet at file parse time)
-- ============================================================================

function UIHelpers.Data()
    return _G.HDG_Data or {}
end

UIHelpers.BACKDROPS = {
    -- Main panel backdrop (used for recipe, shopping, materials panels)
    -- Modern 1-pixel solid border with dim teal
    panel = {
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,  -- Solid 1px border
        tile = false,
        edgeSize = 1,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    },

    -- Simple solid backdrop (no border)
    solid = {
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
    },

    -- Header backdrop
    header = {
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    },

    -- Button backdrop
    button = {
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
    },

    -- Lumber row backdrop
    lumber = {
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    },

    -- Main frame backdrop (with border)
    mainFrame = {
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,  -- Solid 1px border
        tile = false,
        edgeSize = 1,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    },

    -- Compact panel backdrop
    compact = {
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,  -- Solid 1px border
        tile = false,
        edgeSize = 1,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    },
}

-- ============================================================================
-- BACKDROP HELPER
-- Apply consistent backdrop styling to frames
-- ============================================================================

--- Apply a predefined backdrop to a frame
-- @param frame The frame to apply backdrop to
-- @param backdropType String key from BACKDROPS table (e.g., "panel", "solid", "header")
-- @param bgColor Optional table {r, g, b, a} for background color
-- @param borderColor Optional table {r, g, b, a} for border color (defaults to dim teal)
function UIHelpers.ApplyBackdrop(frame, backdropType, bgColor, borderColor)
    if not frame then return end

    local backdrop = UIHelpers.BACKDROPS[backdropType]
    if not backdrop then
        backdrop = UIHelpers.BACKDROPS.solid -- Fallback
    end

    frame:SetBackdrop(backdrop)

    if bgColor then
        frame:SetBackdropColor(unpack(bgColor))
    end

    -- Apply border color (default to current scheme's border if not specified)
    if frame.SetBackdropBorderColor then
        if borderColor then
            frame:SetBackdropBorderColor(unpack(borderColor))
        else
            frame:SetBackdropBorderColor(UIHelpers.GetBorderColor())
        end
    end
end

-- ============================================================================
-- TOOLTIP HELPER
-- Simplified tooltip setup for frames
-- ============================================================================

--- Setup standard tooltip behavior for a frame
-- @param frame The frame to add tooltip to
-- @param title Tooltip title (will be colored gold)
-- @param description Optional description line(s) - can be string or table of strings
-- @param anchor Optional anchor point (default from CONFIG.TOOLTIP.DEFAULT_ANCHOR)
function UIHelpers.SetupTooltip(frame, title, description, anchor)
    if not frame then return end

    anchor = anchor or CONFIG.TOOLTIP.DEFAULT_ANCHOR

    frame:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, anchor)
        GameTooltip:AddLine(UIHelpers.TEXT_COLORS.GOLD .. title .. UIHelpers.TEXT_COLORS.RESET, 1, 1, 1)

        if description then
            if type(description) == "table" then
                for _, line in ipairs(description) do
                    GameTooltip:AddLine(line, 1, 1, 1)
                end
            else
                GameTooltip:AddLine(description, 1, 1, 1)
            end
        end

        GameTooltip:Show()
    end)

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

--- Setup tooltip with item information (uses SetItemByID)
-- @param frame The frame to add tooltip to
-- @param itemID The item ID to display
-- @param extraLines Optional table of extra lines to add after item tooltip
-- @param anchor Optional anchor point
function UIHelpers.SetupItemTooltip(frame, itemID, extraLines, anchor)
    if not frame or not itemID then return end

    anchor = anchor or CONFIG.TOOLTIP.DEFAULT_ANCHOR

    frame:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, anchor)
        GameTooltip:SetItemByID(itemID)

        if extraLines then
            GameTooltip:AddLine(" ")
            for _, line in ipairs(extraLines) do
                if type(line) == "table" then
                    GameTooltip:AddLine(line.text, line.r or 1, line.g or 1, line.b or 1)
                else
                    GameTooltip:AddLine(line, 1, 1, 1)
                end
            end
        end

        GameTooltip:Show()
    end)

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

-- ============================================================================
-- HOVER EFFECT HELPER
-- Consistent hover state changes for interactive elements
-- ============================================================================

--- Setup hover effect with color change
-- @param frame The frame to add hover effect to
-- @param normalColor Table {r, g, b, a} for normal state
-- @param hoverColor Table {r, g, b, a} for hover state
-- @param textElement Optional FontString to change text color on hover
-- @param normalTextColor Optional table {r, g, b} for normal text color
-- @param hoverTextColor Optional table {r, g, b} for hover text color
function UIHelpers.SetupHoverEffect(frame, normalColor, hoverColor, textElement, normalTextColor, hoverTextColor)
    if not frame then return end

    normalTextColor = normalTextColor or CONFIG.COLORS.TEXT_BODY
    hoverTextColor = hoverTextColor or CONFIG.COLORS.TEXT_ACTIVE

    frame:SetScript("OnEnter", function(self)
        self:SetBackdropColor(unpack(hoverColor))
        if textElement then
            textElement:SetTextColor(unpack(hoverTextColor))
        end
    end)

    frame:SetScript("OnLeave", function(self)
        self:SetBackdropColor(unpack(normalColor))
        if textElement then
            textElement:SetTextColor(unpack(normalTextColor))
        end
    end)
end

--- Setup combined hover effect with tooltip
-- @param frame The frame
-- @param normalColor Normal backdrop color
-- @param hoverColor Hover backdrop color
-- @param tooltipTitle Tooltip title
-- @param tooltipDesc Optional tooltip description
-- @param anchor Optional tooltip anchor
function UIHelpers.SetupHoverWithTooltip(frame, normalColor, hoverColor, tooltipTitle, tooltipDesc, anchor)
    if not frame then return end

    anchor = anchor or CONFIG.TOOLTIP.DEFAULT_ANCHOR

    frame:SetScript("OnEnter", function(self)
        self:SetBackdropColor(unpack(hoverColor))

        GameTooltip:SetOwner(self, anchor)
        GameTooltip:AddLine(UIHelpers.TEXT_COLORS.GOLD .. tooltipTitle .. UIHelpers.TEXT_COLORS.RESET, 1, 1, 1)

        if tooltipDesc then
            if type(tooltipDesc) == "table" then
                for _, line in ipairs(tooltipDesc) do
                    GameTooltip:AddLine(line, 1, 1, 1)
                end
            else
                GameTooltip:AddLine(tooltipDesc, 1, 1, 1)
            end
        end

        GameTooltip:Show()
    end)

    frame:SetScript("OnLeave", function(self)
        self:SetBackdropColor(unpack(normalColor))
        GameTooltip:Hide()
    end)
end

-- ============================================================================
-- SECTION HEADER HELPER
-- Create consistent section headers
-- ============================================================================

--- Create a section header font string
-- @param parent Parent frame
-- @param text Header text
-- @param yOffset Y offset from top (default -8)
-- @param colorKey Optional color key from TEXT_COLORS (default "TITLE")
-- @return FontString object
function UIHelpers.CreateSectionHeader(parent, text, yOffset, colorKey)
    if not parent then return nil end

    yOffset = yOffset or -8
    colorKey = colorKey or "TITLE"

    local header = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    header:SetPoint("TOP", 0, yOffset)
    header:SetText(UIHelpers.TEXT_COLORS[colorKey] .. text .. UIHelpers.TEXT_COLORS.RESET)
    local scheme = GetScheme()
    if scheme then
        HDG.Theme.ApplyFont(header, scheme, "body")
    end

    -- Register for theme updates
    HDG.Theme.RegisterWidget(header, "SectionHeader")

    return header
end

--- Create a left-aligned header
-- @param parent Parent frame
-- @param text Header text
-- @param xOffset X offset from left
-- @param yOffset Y offset from top
-- @param colorKey Optional color key
-- @return FontString object
function UIHelpers.CreateLeftHeader(parent, text, xOffset, yOffset, colorKey)
    if not parent then return nil end

    colorKey = colorKey or "TITLE"

    local header = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    header:SetPoint("TOPLEFT", xOffset or 8, yOffset or -8)
    header:SetText(UIHelpers.TEXT_COLORS[colorKey] .. text .. UIHelpers.TEXT_COLORS.RESET)
    local scheme = GetScheme()
    if scheme then
        HDG.Theme.ApplyFont(header, scheme, "body")
    end

    return header
end

-- ============================================================================
-- FILTER BUTTON FACTORY
-- Shared filter button creation for GoblinTab and DecorPreviewTab
-- ============================================================================

-- Private: apply backdrop and text colors to a filter button for a given active state
local function ApplyFilterButtonColors(btn, isActive, scheme)
    if isActive then
        if scheme then
            btn:SetBackdropColor(scheme.button_active.r, scheme.button_active.g, scheme.button_active.b, scheme.button_active.a)
            btn:SetBackdropBorderColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 1)
            btn.text:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
        else
            btn:SetBackdropColor(unpack(UIHelpers.STATE_ACTIVE))
            btn:SetBackdropBorderColor(unpack(UIHelpers.BORDER_COLOR_BRIGHT))
            btn.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_ACTIVE))
        end
    else
        if scheme then
            btn:SetBackdropColor(scheme.button_inactive.r, scheme.button_inactive.g, scheme.button_inactive.b, scheme.button_inactive.a)
            btn:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, 0.7)
            btn.text:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
        else
            btn:SetBackdropColor(unpack(UIHelpers.STATE_INACTIVE))
            btn:SetBackdropBorderColor(unpack(UIHelpers.BORDER_COLOR_DIM))
            btn.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_DISABLED))
        end
    end
end

--- Create a styled filter button
-- @param parent Parent frame
-- @param text Button label text
-- @param width Button width
-- @param isActive Whether button is in active state
-- @param onClick Click handler function
-- @param options Optional table: { height = 16, inactiveTextColor = "B0B0B0" }
-- @return Button frame
function UIHelpers.CreateFilterButton(parent, text, width, isActive, onClick, options)
    options = options or {}
    local height = options.height or 16
    local inactiveTextColor = options.inactiveTextColor or "B0B0B0"

    local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
    btn:SetHeight(height)
    btn:SetWidth(width)
    btn:SetBackdrop(UIHelpers.BACKDROP_FLAT)

    local btnText = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    btnText:SetPoint("LEFT", 4, 0)
    btnText:SetPoint("RIGHT", -4, 0)
    btnText:SetJustifyH("CENTER")
    btnText:SetWordWrap(false)
    btnText:SetText(text)
    btn.text = btnText

    local scheme = GetScheme()
    ApplyFilterButtonColors(btn, isActive, scheme)
    if scheme then HDG.Theme.ApplyFont(btnText, scheme, "small") end

    btn.label = text
    btn.isActive = isActive

    -- Store scheme reference for hover scripts
    btn._scheme = scheme

    btn:SetScript("OnClick", onClick)
    btn:SetScript("OnEnter", function(self)
        if not self.isActive then
            local c = self._scheme or GetScheme()
            if c then
                self:SetBackdropColor(c.button_hover.r, c.button_hover.g, c.button_hover.b, c.button_hover.a)
            else
                self:SetBackdropColor(unpack(UIHelpers.STATE_HOVER))
            end
        end
    end)
    btn:SetScript("OnLeave", function(self)
        if not self.isActive then
            local c = self._scheme or GetScheme()
            if c then
                self:SetBackdropColor(c.button_inactive.r, c.button_inactive.g, c.button_inactive.b, c.button_inactive.a)
            else
                self:SetBackdropColor(unpack(UIHelpers.STATE_INACTIVE))
            end
        end
    end)

    -- Register with theme engine for live updates
    RegisterWidget(btn, "FilterButton")

    return btn
end

-- Update an existing filter button's active state without recreating it
function UIHelpers.UpdateFilterButtonState(btn, isActive, text)
    if not btn then return end
    btn.isActive = isActive
    if text then
        btn.text:SetText(text)
        btn.label = text
    end

    local scheme = GetScheme()
    ApplyFilterButtonColors(btn, isActive, scheme)
    btn._scheme = scheme
end

-- ============================================================================
-- CHIP BUTTON (atlas-based tag/filter pill)
-- Uses auctionhouse-nav-button atlas for a polished chip look
-- ============================================================================

function UIHelpers.CreateChipButton(parent, text, width, isActive, onClick)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(20)
    btn:SetWidth(width)

    btn.bg = btn:CreateTexture(nil, "BACKGROUND")
    btn.bg:SetAllPoints()
    btn.bg:SetAtlas("auctionhouse-nav-button")

    btn.highlight = btn:CreateTexture(nil, "HIGHLIGHT")
    btn.highlight:SetAllPoints()
    btn.highlight:SetAtlas("auctionhouse-nav-button-highlight")

    btn.selected = btn:CreateTexture(nil, "BACKGROUND")
    btn.selected:SetAllPoints()
    btn.selected:SetAtlas("auctionhouse-nav-button-select")
    btn.selected:SetShown(isActive)
    btn.bg:SetShown(not isActive)

    btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    btn.text:SetPoint("CENTER", 0, 2)
    btn.text:SetWordWrap(false)
    btn.text:SetText(text)

    local scheme = GetScheme()
    if scheme then
        HDG.Theme.ApplyFont(btn.text, scheme, "small")
        if isActive then
            btn.text:SetTextColor(1, 1, 1, 1)
        else
            btn.text:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, 1)
        end
    else
        btn.text:SetTextColor(unpack(isActive and CONFIG.COLORS.TEXT_ACTIVE or CONFIG.COLORS.TEXT_DISABLED))
    end

    btn.label = text
    btn.isActive = isActive
    btn._scheme = scheme

    if onClick then btn:SetScript("OnClick", onClick) end

    RegisterWidget(btn, "ChipButton")
    return btn
end

function UIHelpers.UpdateChipButtonState(btn, isActive, text)
    if not btn then return end
    btn.isActive = isActive
    if text then
        btn.text:SetText(text)
        btn.label = text
    end
    if btn.selected then btn.selected:SetShown(isActive) end
    if btn.bg then btn.bg:SetShown(not isActive) end

    local scheme = GetScheme()
    if isActive then
        btn.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_ACTIVE))
    elseif scheme then
        btn.text:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, 1)
    else
        btn.text:SetTextColor(unpack(CONFIG.COLORS.TEXT_DISABLED))
    end
    btn._scheme = scheme
end

-- ============================================================================
-- PANEL CREATION HELPER
-- Simplified panel creation with consistent styling
-- ============================================================================

--- Create a standard panel frame
-- @param parent Parent frame
-- @param config Table with: width, height, position (table), title (optional), scrollable (bool)
-- @return Frame object
function UIHelpers.CreatePanel(parent, config)
    if not parent or not config then return nil end

    local frame = CreateFrame("Frame", nil, parent, "BackdropTemplate")

    -- Position
    if config.position then
        frame:SetPoint(unpack(config.position))
    end

    -- Size
    frame:SetSize(config.width or 200, config.height or 200)

    -- Backdrop (use scheme colors if available)
    local scheme = GetScheme()
    local panelC = scheme and scheme.panel or {r=0.05, g=0.05, b=0.05}
    local borderC = scheme and scheme.border or {r=0.08, g=0.72, b=0.65}
    UIHelpers.ApplyBackdrop(frame, "panel",
        {panelC.r, panelC.g, panelC.b, 0.6},
        {borderC.r, borderC.g, borderC.b, 0.6})
    RegisterWidget(frame, "Panel")

    -- Title
    if config.title then
        frame.title = UIHelpers.CreateSectionHeader(frame, config.title, -8)
    end

    return frame
end

-- ============================================================================
-- UTILITY FUNCTIONS
-- ============================================================================

--- Clear all children and regions from a scroll content frame
-- @param scrollContent The content frame to clear
function UIHelpers.ClearScrollContent(scrollContent)
    if not scrollContent then return end

    local children = { scrollContent:GetChildren() }
    local regions = { scrollContent:GetRegions() }

    for _, child in ipairs(children) do
        for _, grandchild in ipairs({ child:GetChildren() }) do
            grandchild:Hide()
            grandchild:ClearAllPoints()
        end
        child:Hide()
        child:ClearAllPoints()
    end

    for _, region in ipairs(regions) do
        if region:GetObjectType() == "FontString" or region:GetObjectType() == "Texture" then
            region:Hide()
        end
    end
end

--- Add comma separators to a number
-- @param num Number to format
-- @return Formatted string with commas
function UIHelpers.FormatWithCommas(num)
    local formatted = tostring(num)
    local k
    while true do
        formatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", "%1,%2")
        if k == 0 then break end
    end
    return formatted
end

--- Format gold amount for display
-- @param copper Amount in copper
-- @param useTextures Whether to use coin textures (default true)
-- @return Formatted string
function UIHelpers.FormatGold(copper, useTextures)
    if not copper or copper == 0 then return "0g" end

    local gold = math.floor(copper / 10000)
    local silver = math.floor((copper % 10000) / 100)
    local copperRem = copper % 100

    if useTextures ~= false then
        return C_CurrencyInfo.GetCoinTextureString(copper)
    else
        if gold > 0 then
            return string.format("%dg %ds %dc", gold, silver, copperRem)
        elseif silver > 0 then
            return string.format("%ds %dc", silver, copperRem)
        else
            return string.format("%dc", copperRem)
        end
    end
end

--- Print addon message with consistent formatting
-- @param message The message to print
-- @param messageType Optional: "success", "error", "warning", "info" (default "info")
function UIHelpers.PrintMessage(message, messageType)
    local prefix = UIHelpers.TEXT_COLORS.GOLD .. "[Housing Decor Guide]" .. UIHelpers.TEXT_COLORS.RESET

    local coloredMessage = message
    if messageType == "success" then
        coloredMessage = UIHelpers.TEXT_COLORS.SUCCESS .. message .. UIHelpers.TEXT_COLORS.RESET
    elseif messageType == "error" then
        coloredMessage = UIHelpers.TEXT_COLORS.ERROR .. message .. UIHelpers.TEXT_COLORS.RESET
    elseif messageType == "warning" then
        coloredMessage = UIHelpers.TEXT_COLORS.WARNING .. message .. UIHelpers.TEXT_COLORS.RESET
    end

    print(prefix .. " " .. coloredMessage)
end

-- ============================================================================
-- MINIMALIST SCROLLBAR STYLING
-- Modern scrollbar appearance: hide arrows, slim thumb, subtle colors
-- ============================================================================

--- Style a scroll frame to have minimalist scrollbars
-- @param scrollFrame The scroll frame using UIPanelScrollFrameTemplate
-- @param thumbColor Optional thumb color table {r, g, b, a} (default: dim teal)
-- @param trackColor Optional track color table {r, g, b, a} (default: very dark)
function UIHelpers.StyleScrollBar(scrollFrame, thumbColor, trackColor)
    if not scrollFrame then return end

    -- Theme-aware default colors
    local scheme = HDG and HDG.Theme and HDG.Theme.GetCurrentScheme and HDG.Theme.GetCurrentScheme()
    if not thumbColor then
        if scheme and scheme.accent then
            thumbColor = { scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.7 }
        else
            thumbColor = { 0, 0.4, 0.4, 0.7 }  -- Fallback teal
        end
    end
    if not trackColor then
        if scheme and scheme.isLight then
            trackColor = { 0.6, 0.6, 0.6, 0.2 }  -- Light track for light themes
        else
            trackColor = { 0.1, 0.1, 0.1, 0.3 }  -- Dark track for dark themes
        end
    end

    -- Get the scrollbar (named ScrollBar for UIPanelScrollFrameTemplate)
    local scrollBar = scrollFrame.ScrollBar or _G[scrollFrame:GetName() .. "ScrollBar"]
    if not scrollBar then return end

    -- Hide the up/down arrow buttons
    local scrollUpButton = scrollBar.ScrollUpButton or _G[scrollBar:GetName() .. "ScrollUpButton"]
    local scrollDownButton = scrollBar.ScrollDownButton or _G[scrollBar:GetName() .. "ScrollDownButton"]

    if scrollUpButton then
        scrollUpButton:SetAlpha(0)
        scrollUpButton:EnableMouse(false)
        scrollUpButton:SetSize(1, 1)
    end

    if scrollDownButton then
        scrollDownButton:SetAlpha(0)
        scrollDownButton:EnableMouse(false)
        scrollDownButton:SetSize(1, 1)
    end

    -- Style the thumb (the draggable part)
    local thumbTexture = scrollBar.ThumbTexture or scrollBar:GetThumbTexture()
    if thumbTexture then
        if scheme and scheme.atlas and scheme.atlas.scrollThumb then
            thumbTexture:SetAtlas(scheme.atlas.scrollThumb)
            thumbTexture:SetVertexColor(1, 1, 1, 1)  -- Reset for atlas
        else
            thumbTexture:SetTexture(CONFIG.TEXTURES.WHITE_BG)
            thumbTexture:SetVertexColor(unpack(thumbColor))
        end
        thumbTexture:SetSize(6, 40)  -- Slim width, reasonable height
    end

    -- Make scrollbar narrower
    scrollBar:SetWidth(8)

    -- Create a subtle track background if it doesn't exist
    if not scrollBar.trackBg then
        local track = scrollBar:CreateTexture(nil, "BACKGROUND")
        track:SetTexture(CONFIG.TEXTURES.WHITE_BG)
        track:SetVertexColor(unpack(trackColor))
        track:SetAllPoints(scrollBar)
        scrollBar.trackBg = track
    end

    -- Add hover effect to thumb
    local originalThumbColor = { unpack(thumbColor) }
    local hoverThumbColor = { thumbColor[1] + 0.1, thumbColor[2] + 0.1, thumbColor[3] + 0.1, 0.9 }

    scrollBar:HookScript("OnEnter", function()
        if thumbTexture then
            thumbTexture:SetVertexColor(unpack(hoverThumbColor))
        end
    end)

    scrollBar:HookScript("OnLeave", function()
        if thumbTexture then
            thumbTexture:SetVertexColor(unpack(originalThumbColor))
        end
    end)
end

-- ============================================================================
-- LUMBER ROW HELPER
-- Shared function for creating lumber rows (used by embedded and pinned panels)
-- ============================================================================

-- Helper: Format elapsed time as mm:ss or hh:mm:ss
local function FormatElapsedTime(seconds)
    if not seconds or seconds < 0 then seconds = 0 end
    local hours = math.floor(seconds / 3600)
    local mins = math.floor((seconds % 3600) / 60)
    local secs = math.floor(seconds % 60)
    if hours > 0 then
        return string.format("%d:%02d:%02d", hours, mins, secs)
    else
        return string.format("%d:%02d", mins, secs)
    end
end

-- Helper: Get progress bar color based on percentage (teal gradient)
-- Exported via UIHelpers.GetProgressBarColor for use by other modules
local function GetProgressBarColor(percentage)
    local scheme = GetScheme()
    local teal = scheme and scheme.accent and {scheme.accent.r, scheme.accent.g, scheme.accent.b} or CONFIG.COLORS.PANEL_DARK_BORDER_TEAL
    local tealFull = scheme and scheme.success and {scheme.success.r, scheme.success.g, scheme.success.b} or {0.20, 0.80, 0.20}

    if percentage >= 100 then
        return tealFull[1], tealFull[2], tealFull[3], 1.0
    elseif percentage >= 1 then
        local opacity = 0.3 + (percentage / 100) * 0.4
        return teal[1], teal[2], teal[3], opacity
    else
        return teal[1], teal[2], teal[3], 0.15
    end
end
UIHelpers.GetProgressBarColor = GetProgressBarColor

local GetLumberAchievementProgress = HDG_ExpansionData.GetLumberAchievementProgress

-- Helper: Create a progress bar for lumber display (StatusBar-based like v2.1.0)
local function CreateLumberProgressBar(parent, current, max, xOffset, yOffset, showText)
    if not parent then return nil end

    -- Handle case where max is 0 (no need) - still create the bar but empty
    local needed = max or 0
    local have = current or 0

    local bar = CreateFrame("StatusBar", nil, parent, "BackdropTemplate")
    bar:SetStatusBarTexture(CONFIG.TEXTURES.STATUSBAR)
    bar:SetMinMaxValues(0, math.max(1, needed))
    bar:SetValue(math.min(have, math.max(1, needed)))

    local percentage = needed > 0 and (have / needed * 100) or 0
    bar:SetStatusBarColor(GetProgressBarColor(percentage))

    -- Background (use scheme colors if available)
    local scheme = GetScheme()
    local bg = bar:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(bar)
    bg:SetTexture(CONFIG.TEXTURES.STATUSBAR)
    if scheme and scheme.bg then
        bg:SetVertexColor(scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.7)
    else
        local c = CONFIG.COLORS.PANEL_DARK; bg:SetVertexColor(c[1], c[2], c[3], 0.7)
    end
    bar.bg = bg

    -- Overlay for visual polish
    local overlay = bar:CreateTexture(nil, "OVERLAY")
    overlay:SetAllPoints(bar)
    overlay:SetTexture(CONFIG.TEXTURES.STATUSBAR)
    if scheme then
        overlay:SetVertexColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.15)
    else
        overlay:SetVertexColor(0.06, 0.73, 0.51, 0.15)
    end
    overlay:SetDrawLayer("OVERLAY", 1)
    bar.overlay = overlay

    if showText then
        local text = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        text:SetPoint("CENTER", 0, 0)
        text:SetJustifyH("CENTER")
        text:SetDrawLayer("OVERLAY", 2)
        if scheme then
            text:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
            HDG.Theme.ApplyFont(text, scheme, "small")
        end

        if have >= needed and needed > 0 then
            local extra = have - needed
            text:SetText(extra > 0 and string.format("%d/%d+%d", needed, needed, extra) or
                string.format("%d/%d", needed, needed))
        else
            text:SetText(string.format("%d/%d", have, needed))
        end
        bar.text = text
    end

    return bar
end

--- Create a lumber row for tracking lumber needs
-- @param parent Parent frame
-- @param lumberId ItemID of the lumber
-- @param lumberInfo Table with name, expansion
-- @param queueData Optional table with have, needed for crafting queue
-- @param allData Optional table with have, needed for all uncollected recipes
-- @param yOffset Y offset for positioning
-- @param farmingOptions Optional table: { stats, isActive, isJustCollected, animationPhase, onResetSession, scheme }
-- @return nextYOffset
function UIHelpers.CreateLumberRow(parent, lumberId, lumberInfo, queueData, allData, yOffset, farmingOptions)
    if not parent or not lumberId or not lumberInfo then return yOffset end

    local isActiveFarming = farmingOptions and farmingOptions.isActive
    local isJustCollected = farmingOptions and farmingOptions.isJustCollected
    local animationPhase = farmingOptions and farmingOptions.animationPhase or 0
    local farmingStats = farmingOptions and farmingOptions.stats
    local schemeOverride = farmingOptions and farmingOptions.scheme  -- Fixed scheme override
    local function RowGetScheme() return schemeOverride or GetScheme() end

    local rowHeight = isActiveFarming and 62 or 18
    local contentYOffset = isActiveFarming and 18 or 0

    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetSize(CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2 + CONFIG.LUMBER_COL_3 + 4, rowHeight)
    row:SetPoint("TOPLEFT", 0, yOffset)
    row:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })

    local rowInitScheme = RowGetScheme()
    row:SetBackdropColor(unpack(UIHelpers.BG_TRANSPARENT))

    -- Store data for tooltip
    row.lumberInfo = lumberInfo
    row.queueData = queueData
    row.allData = allData
    row.isActiveFarming = isActiveFarming

    -- Preload item data to ensure tooltip displays correctly on first hover
    C_Item.RequestLoadItemDataByID(lumberId)

    -- Tooltip on hover (use scheme colors if available)
    local rowScheme = RowGetScheme()
    local hoverC = rowScheme and rowScheme.button_hover or {r=0.10, g=0.80, b=0.72}
    row:SetScript("OnEnter", function(self)
        if not self.isActiveFarming then
            self:SetBackdropColor(hoverC.r, hoverC.g, hoverC.b, 0.6)
        end

        GameTooltip:SetOwner(self, UIHelpers.TOOLTIP_ANCHOR)
        -- Use SetHyperlink instead of SetItemByID for more reliable tooltip display
        -- SetItemByID can clear custom lines when item data loads asynchronously
        local itemLink = select(2, C_Item.GetItemInfo(lumberId))
        if itemLink then
            GameTooltip:SetHyperlink(itemLink)
        else
            -- Fallback: use item name from lumberInfo if item not cached yet
            local itemName = self.lumberInfo.name or "Lumber"
            GameTooltip:SetText(itemName, 1, 1, 1)
        end
        GameTooltip:AddLine(" ")

        local expColor = HDG_ExpansionData.GetColor(self.lumberInfo.expansion)
        GameTooltip:AddDoubleLine(L["TIP_EXPANSION_LABEL"], self.lumberInfo.expansion,
            1, 1, 1, expColor[1], expColor[2], expColor[3])
        GameTooltip:AddLine(" ")

        if self.queueData then
            GameTooltip:AddLine("|cFFfcd34d" .. L["HEADER_CRAFTING_QUEUE"] .. ":|r", 1, 1, 1)
            GameTooltip:AddDoubleLine("  " .. L["TIP_ACCOUNT_STOCK"],
                BreakUpLargeNumbers(self.queueData.have), 0.8, 0.8, 0.8, 1, 1, 1)
            -- Show per-character breakdown if available
            if self.queueData.breakdown and #self.queueData.breakdown > 0 then
                for _, charInfo in ipairs(self.queueData.breakdown) do
                    local prefix = charInfo.isCurrent and "    * " or "    "
                    GameTooltip:AddDoubleLine(prefix .. charInfo.name .. ":",
                        BreakUpLargeNumbers(charInfo.count), 0.6, 0.6, 0.6, 0.8, 0.8, 0.8)
                end
            end
            GameTooltip:AddDoubleLine("  " .. L["TIP_NEEDED_LABEL"],
                BreakUpLargeNumbers(self.queueData.needed), 0.8, 0.8, 0.8, 1, 1, 1)
            if self.queueData.have >= self.queueData.needed then
                GameTooltip:AddLine("  |cFF88FF88" .. L["TIP_HAVE_ENOUGH_QUEUE"] .. "|r", 0.5, 1, 0.5)
            else
                local shortfall = self.queueData.needed - self.queueData.have
                GameTooltip:AddLine(string.format("  |cFFFF5555" .. L["TIP_NEED_N_MORE"] .. "|r",
                    BreakUpLargeNumbers(shortfall)), 1, 0.3, 0.3)
            end
        else
            GameTooltip:AddLine("|cFF888888" .. L["MISC_NOT_NEEDED_FOR_QUEUE"] .. "|r", 0.6, 0.6, 0.6)
        end

        GameTooltip:AddLine(" ")

        if self.allData then
            if self.allData.needed > 0 then
                GameTooltip:AddLine("|cFFfcd34d" .. L["TIP_UNCOLLECTED_DECOR_NEEDS"] .. "|r", 1, 1, 1)
                GameTooltip:AddDoubleLine("  " .. L["TIP_ACCOUNT_STOCK"],
                    BreakUpLargeNumbers(self.allData.have), 0.8, 0.8, 0.8, 1, 1, 1)
                -- Show per-character breakdown if available
                if self.allData.breakdown and #self.allData.breakdown > 0 then
                    for _, charInfo in ipairs(self.allData.breakdown) do
                        local prefix = charInfo.isCurrent and "    * " or "    "
                        GameTooltip:AddDoubleLine(prefix .. charInfo.name .. ":",
                            BreakUpLargeNumbers(charInfo.count), 0.6, 0.6, 0.6, 0.8, 0.8, 0.8)
                    end
                end
                GameTooltip:AddDoubleLine("  " .. L["MISC_TOTAL_NEEDED"] .. ":",
                    BreakUpLargeNumbers(self.allData.needed), 0.8, 0.8, 0.8, 1, 1, 1)
                if self.allData.have >= self.allData.needed then
                    GameTooltip:AddLine("  |cFF88FF88" .. L["TIP_HAVE_ENOUGH_ALL"] .. "|r", 0.5, 1, 0.5)
                else
                    local shortfall = self.allData.needed - self.allData.have
                    GameTooltip:AddLine(string.format("  |cFFFF5555" .. L["TIP_NEED_N_MORE"] .. "|r",
                        BreakUpLargeNumbers(shortfall)), 1, 0.3, 0.3)
                end
            else
                GameTooltip:AddLine("|cFF888888" .. L["MISC_ALL_RECIPES_COLLECTED"] .. "|r", 0.6, 0.6, 0.6)
            end
        else
            GameTooltip:AddLine("|cFF888888" .. L["MISC_ALL_RECIPES_COLLECTED"] .. "|r", 0.6, 0.6, 0.6)
        end

        local achQty, achReq, achDone = GetLumberAchievementProgress(lumberId)
        if achQty then
            GameTooltip:AddLine(" ")
            if achDone then
                GameTooltip:AddLine(L["TIP_MOUNTED_UNLOCKED"], 0.53, 1, 0.53)
            else
                GameTooltip:AddDoubleLine(L["TIP_MOUNTED_LABEL"],
                    string.format("%d / %d", achQty, achReq), 0.8, 0.8, 0.8, 1, 0.82, 0.2)
                GameTooltip:AddLine(L["TIP_MOUNTED_HINT"], 0.6, 0.6, 0.6)
            end
        else
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(L["MISC_LUMBER_MUST_BE_FARMED"], 1, 0.67, 0)
        end
        GameTooltip:Show()
    end)

    row:SetScript("OnLeave", function(self)
        if not self.isActiveFarming then
            self:SetBackdropColor(unpack(UIHelpers.BG_TRANSPARENT))
        end
        GameTooltip:Hide()
    end)

    -- Column 1: Lumber name
    local nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    nameText:SetPoint("LEFT", 0, contentYOffset)
    nameText:SetWidth(CONFIG.LUMBER_COL_1)
    nameText:SetJustifyH("LEFT")
    nameText:SetWordWrap(false)
    if rowInitScheme then
        HDG.Theme.ApplyFont(nameText, rowInitScheme, "small")
    end

    local expColor = HDG_ExpansionData.GetColor(lumberInfo.expansion)
    -- Get localized material name (strips "Lumber/Madeira/Holz/etc." from localized name)
    local displayName = HDG_ExpansionData.GetLumberMaterialName(lumberId) or lumberInfo.shortName or lumberInfo.name

    -- Achievement suffix: green * = unlocked, red * = not yet unlocked
    local achSuffix = ""
    local _, _, achDone = GetLumberAchievementProgress(lumberId)
    if achDone then
        achSuffix = " |cFF88FF88*|r"
    else
        achSuffix = " |cFFe05252*|r"
    end

    -- Animation effects for farming
    if isJustCollected and animationPhase == 1 then
        nameText:SetFont(nameText:GetFont(), 14, "")
        nameText:SetText(string.format("|cFFFFFFFF%s|r%s", displayName, achSuffix))
    elseif isJustCollected and animationPhase == 2 then
        nameText:SetFont(nameText:GetFont(), 12, "")
        nameText:SetText(string.format("|cFFFFD700%s|r%s", displayName, achSuffix))
    elseif isActiveFarming then
        nameText:SetFont(nameText:GetFont(), 11, "")
        nameText:SetText(string.format("|cFFfcd34d%s|r%s", displayName, achSuffix))
    else
        nameText:SetText(string.format("|cFF%02x%02x%02x%s|r%s",
            math.min(255, expColor[1] * 255 * 1.3),
            math.min(255, expColor[2] * 255 * 1.3),
            math.min(255, expColor[3] * 255 * 1.3),
            displayName, achSuffix))
    end

    -- Column 2: Queue/Needed progress bar
    if queueData then
        local queueBar = CreateLumberProgressBar(row, queueData.have, queueData.needed, 0, 0, true)
        if queueBar then
            queueBar:SetSize(CONFIG.LUMBER_COL_2 - 4, 12)
            queueBar:ClearAllPoints()
            queueBar:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + 2, contentYOffset)
            if queueBar.text then
                queueBar.text:SetWidth(76)
                queueBar.text:SetWordWrap(false)
            end
        end
    else
        local emptyText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        emptyText:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + 2, contentYOffset)
        emptyText:SetWidth(CONFIG.LUMBER_COL_2)
        emptyText:SetJustifyH("CENTER")
        emptyText:SetText("-")
        if rowInitScheme then
            emptyText:SetTextColor(rowInitScheme.text_dim.r, rowInitScheme.text_dim.g, rowInitScheme.text_dim.b, rowInitScheme.text_dim.a)
            HDG.Theme.ApplyFont(emptyText, rowInitScheme, "small")
        end
    end

    -- Column 3: Stock/needed or just stock count
    if allData and allData.needed > 0 then
        -- Show progress bar with have/needed when there are uncollected recipes
        local allBar = CreateLumberProgressBar(row, allData.have, allData.needed, 0, 0, true)
        if allBar then
            allBar:SetSize(CONFIG.LUMBER_COL_3 - 4, 12)
            allBar:ClearAllPoints()
            allBar:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2 + 4, contentYOffset)
            if allBar.text then
                allBar.text:SetWidth(76)
                allBar.text:SetWordWrap(false)
            end
        end
    else
        -- No uncollected recipes need this lumber - show just stock count in teal
        local stockText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        stockText:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2 + 4, contentYOffset)
        stockText:SetWidth(CONFIG.LUMBER_COL_3)
        stockText:SetJustifyH("CENTER")
        if rowInitScheme then
            HDG.Theme.ApplyFont(stockText, rowInitScheme, "small")
        end
        if allData then
            stockText:SetText(string.format("|cFF14b8a6%s|r", BreakUpLargeNumbers(allData.have)))
        else
            -- allData is nil (no uncollected recipes) - still show account-wide stock count
            local accountTotal = HDG_Data and HDG_Data.GetAccountWideLumberCount and HDG_Data.GetAccountWideLumberCount(lumberId) or 0
            stockText:SetText(string.format("|cFF14b8a6%s|r", BreakUpLargeNumbers(accountTotal)))
        end
    end

    -- Farming stats row (only when actively farming)
    if isActiveFarming and farmingStats then
        local statsText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        statsText:SetPoint("LEFT", 4, 0)
        statsText:SetPoint("RIGHT", -4, 0)
        statsText:SetJustifyH("LEFT")
        if rowInitScheme then
            HDG.Theme.ApplyFont(statsText, rowInitScheme, "small")
        end

        local rateStr = string.format("%d", math.floor(farmingStats.perHour or 0))  -- No decimals
        local timeStr = FormatElapsedTime(farmingStats.elapsed or 0)
        local fontFile = statsText:GetFont()
        statsText:SetFont(fontFile, 11, "")
        statsText:SetText(string.format("|cFF88FF88+%d this session|r  |cFFe2e8f0Rate:|r |cFFfcd34d%s/hr|r  |cFF888888(%s)|r",
            farmingStats.collected or 0, rateStr, timeStr))

        -- Flipbook animation centered on the "+N" portion (plays on collection)
        if isJustCollected and animationPhase == 1 then
            local flipbookTexture = row:CreateTexture(nil, "BACKGROUND")
            flipbookTexture:SetSize(32, 28)  -- Scaled down from native 113x99
            -- Position centered on "+N" text
            flipbookTexture:SetPoint("LEFT", statsText, "LEFT", -7, 0)
            flipbookTexture:SetAtlas("housing-dashboard-fillbar-icon-fx-flipbook", false)
            flipbookTexture:SetAlpha(0.9)

            local animGroup = flipbookTexture:CreateAnimationGroup()
            animGroup:SetLooping("NONE")

            local flip = animGroup:CreateAnimation("Flipbook")
            flip:SetDuration(1.0)
            flip:SetFlipBookColumns(5)
            flip:SetFlipBookRows(8)
            flip:SetFlipBookFrames(38)

            local fadeOut = animGroup:CreateAnimation("Alpha")
            fadeOut:SetStartDelay(0.7)
            fadeOut:SetDuration(0.3)
            fadeOut:SetFromAlpha(0.9)
            fadeOut:SetToAlpha(0)

            animGroup:Play()
        end

        -- Zone display
        local zoneText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        zoneText:SetPoint("BOTTOMLEFT", 4, 6)
        zoneText:SetPoint("RIGHT", row, "RIGHT", -4, 0)
        zoneText:SetJustifyH("LEFT")
        if rowInitScheme then
            HDG.Theme.ApplyFont(zoneText, rowInitScheme, "small", 9)
        end

        local zoneName = GetZoneText() or "Unknown"
        zoneText:SetText(string.format("|cFFe2e8f0Zone:|r |cFF87CEEB%s|r", zoneName))
    end

    return yOffset - rowHeight
end

-- ============================================================================
-- GALLERY CELL POOL + FACTORY
-- Shared atlas-card grid cells (Blizzard's BaseHousingCatalogEntryTemplate style)
-- Used by StylesTab and AcquisitionTab galleries
-- ============================================================================

local GALLERY_ATLAS_DEFAULT = "house-chest-list-Item-default"
local GALLERY_ATLAS_ACTIVE  = "house-chest-list-Item-active"

UIHelpers.GALLERY_ATLAS_DEFAULT = GALLERY_ATLAS_DEFAULT
UIHelpers.GALLERY_ATLAS_ACTIVE  = GALLERY_ATLAS_ACTIVE

-- ============================================================================
-- ROW POOL (generic acquire/release for scrollable list rows)
-- ============================================================================
function UIHelpers.CreateRowPool(factoryFn)
    local pool = { free = {}, active = {} }

    function pool:Acquire(parent)
        local row = table.remove(self.free)
        if not row then
            row = factoryFn(parent)
        end
        if row:GetParent() ~= parent then row:SetParent(parent) end
        row:ClearAllPoints()
        row.inUse = true
        row:Show()
        table.insert(self.active, row)
        return row
    end

    function pool:ReleaseAll(resetFn)
        for _, row in ipairs(self.active) do
            row.inUse = false
            row:Hide()
            row:ClearAllPoints()
            if resetFn then resetFn(row) end
            table.insert(self.free, row)
        end
        wipe(self.active)
    end

    function pool:GetActive() return self.active end
    function pool:GetCount() return #self.active end

    return pool
end

function UIHelpers.CreateGalleryCellPool()
    return { all = {}, active = {}, free = {}, timers = {} }
end

function UIHelpers.GetPooledGalleryCell(pool, parent)
    local cell = table.remove(pool.free)
    if cell then
        cell.inUse = true
        if cell:GetParent() ~= parent then cell:SetParent(parent) end
        cell:ClearAllPoints()
        table.insert(pool.active, cell)
        return cell
    end
    return nil
end

function UIHelpers.ReleaseAllGalleryCells(pool)
    for _, timer in ipairs(pool.timers) do
        timer:Cancel()
    end
    wipe(pool.timers)
    for _, cell in ipairs(pool.active) do
        cell.inUse = false
        cell:Hide()
        if cell.modelScene then
            local actor = cell.modelScene:GetActorByTag("decor")
            if actor then actor:ClearModel() end
            cell.modelScene:Hide()
        end
        table.insert(pool.free, cell)
    end
    wipe(pool.active)
end

--- Create or recycle a gallery cell with atlas background, hover overlay,
--- ModelScene, fallback icon, checkmark, and quantity badge.
-- @param pool Pool table from CreateGalleryCellPool
-- @param parent Parent frame
-- @param cellSize Width/height of cell
-- @param cfg Optional table { statusIconSize, checkmarkTexture }
-- @return cell frame (caller sets position, data, scripts)
function UIHelpers.CreateGalleryCell(pool, parent, cellSize, cfg)
    cfg = cfg or {}
    local iconSize = cfg.statusIconSize or 12
    local checkTex = cfg.checkmarkTexture or "Interface\\RAIDFRAME\\ReadyCheck-Ready"

    local cell = UIHelpers.GetPooledGalleryCell(pool, parent)
    if not cell then
        cell = CreateFrame("Button", nil, parent)
        cell:SetSize(cellSize, cellSize)
        cell.inUse = true
        table.insert(pool.all, cell)
        table.insert(pool.active, cell)

        -- Atlas background (Blizzard's catalog card)
        cell.bg = cell:CreateTexture(nil, "BACKGROUND")
        cell.bg:SetAllPoints()
        cell.bg:SetAtlas(GALLERY_ATLAS_DEFAULT)

        -- Hover overlay (additive blend, hidden)
        cell.hoverBg = cell:CreateTexture(nil, "BACKGROUND", nil, 1)
        cell.hoverBg:SetAllPoints()
        cell.hoverBg:SetAtlas(GALLERY_ATLAS_DEFAULT)
        cell.hoverBg:SetAlpha(0.75)
        cell.hoverBg:SetBlendMode("ADD")
        cell.hoverBg:Hide()

        -- ModelScene 80x80 centered (template needed for GetActorByTag)
        cell.modelScene = CreateFrame("ModelScene", nil, cell, "PanningModelSceneMixinTemplate")
        cell.modelScene:SetSize(80, 80)
        cell.modelScene:SetPoint("CENTER", 0, 0)
        cell.modelScene:EnableMouse(false)
        cell.modelScene:EnableMouseWheel(false)

        -- Fallback icon texture
        cell.fallbackIcon = cell:CreateTexture(nil, "ARTWORK")
        cell.fallbackIcon:SetSize(cellSize - 16, cellSize - 16)
        cell.fallbackIcon:SetPoint("CENTER", 0, 0)
        cell.fallbackIcon:Hide()

        -- Collected checkmark at BOTTOMLEFT
        cell.checkmark = cell:CreateTexture(nil, "OVERLAY", nil, 2)
        cell.checkmark:SetSize(iconSize, iconSize)
        cell.checkmark:SetPoint("BOTTOMLEFT", 4, 4)
        cell.checkmark:SetTexture(checkTex)
        cell.checkmark:SetVertexColor(unpack(CONFIG.COLORS.COLLECTED))

        -- Not-collected red X at BOTTOMLEFT (same corner as checkmark)
        cell.redX = cell:CreateTexture(nil, "OVERLAY", nil, 2)
        cell.redX:SetSize(iconSize, iconSize)
        cell.redX:SetPoint("BOTTOMLEFT", 4, 4)
        cell.redX:SetTexture("Interface\\RAIDFRAME\\ReadyCheck-NotReady")
        cell.redX:SetVertexColor(unpack(CONFIG.COLORS.UNCOLLECTED))

        -- Quantity badge (Blizzard InfoText: BOTTOMRIGHT)
        cell.infoText = cell:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        cell.infoText:SetPoint("BOTTOMRIGHT", -9, 7)
        cell.infoText:SetHeight(20)
    end

    -- Reset state for recycled cells
    cell:SetSize(cellSize, cellSize)
    cell:SetAlpha(1)
    cell.bg:SetAtlas(GALLERY_ATLAS_DEFAULT)
    cell.hoverBg:Hide()
    cell.fallbackIcon:Hide()
    cell.modelScene:Hide()
    cell.checkmark:Hide()
    cell.redX:Hide()
    cell.infoText:Hide()

    return cell
end

--- Apply visual priority: iconTexture > iconAtlas > 3D asset > fallback question mark.
--- Desaturate + dim if uncollected.
function UIHelpers.UpdateGalleryCellVisuals(cell, decorInfo, isCollected, sceneID)
    cell.fallbackIcon:Hide()
    cell.modelScene:Hide()

    if decorInfo and (decorInfo.iconTexture or decorInfo.iconAtlas) then
        if decorInfo.iconTexture then
            cell.fallbackIcon:SetTexture(decorInfo.iconTexture)
        else
            cell.fallbackIcon:SetAtlas(decorInfo.iconAtlas)
        end
        cell.fallbackIcon:SetDesaturated(false)
        cell.fallbackIcon:SetAlpha(1)
        cell.fallbackIcon:Show()
    elseif decorInfo and decorInfo.asset then
        local sOk = pcall(function()
            cell.modelScene:TransitionToModelSceneID(sceneID or 691,
                CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD, true)
        end)
        if sOk then
            local actor = cell.modelScene:GetActorByTag("decor")
            if actor then
                actor:SetModelByFileID(decorInfo.asset)
                actor:SetDesaturation(0)
                actor:SetAlpha(1)
            end
            cell.modelScene:Show()
        end
    else
        cell.fallbackIcon:SetTexture(134400) -- QuestionMark
        cell.fallbackIcon:SetDesaturated(false)
        cell.fallbackIcon:SetAlpha(1)
        cell.fallbackIcon:Show()
    end
end

--- Fetch catalog info and show quantity badge if applicable.
-- @return catInfo table (or nil), reusable as decorInfo for UpdateGalleryCellVisuals
function UIHelpers.UpdateGalleryCellQuantity(cell, decorID)
    if not C_HousingCatalog or not C_HousingCatalog.GetCatalogEntryInfoByRecordID then
        cell.infoText:Hide()
        return nil
    end
    local ok, catInfo = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID,
        Enum.HousingCatalogEntryType and Enum.HousingCatalogEntryType.Decor or 1,
        decorID, true)
    if ok and catInfo then
        local qty = (catInfo.quantity or 0) + (catInfo.remainingRedeemable or 0)
        if catInfo.showQuantity and qty > 0 then
            cell.infoText:SetText(qty)
            if qty <= 0 then
                cell.infoText:SetTextColor(DISABLED_FONT_COLOR:GetRGB())
            else
                cell.infoText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB())
            end
            cell.infoText:Show()
        else
            cell.infoText:Hide()
        end
        return catInfo
    end
    cell.infoText:Hide()
    return nil
end

--- Swap atlas between active/default based on selection state.
function UIHelpers.UpdateGalleryCellBackground(cell, isSelected)
    cell.bg:SetAtlas(isSelected and GALLERY_ATLAS_ACTIVE or GALLERY_ATLAS_DEFAULT)
end

-- ============================================================================
-- BLIZZARD-STYLE 3D DECOR PREVIEW FACTORY
-- Reusable atlas-bg + PanningModelSceneMixin + controls + corbels
-- ============================================================================

--- Create a Blizzard-style 3D decor preview frame.
-- @param parent Frame to parent to
-- @param config table {squareAspect, showOverlays, showControls, showCorbels, sceneInsets, placeholderText}
-- @return frame with .modelScene, .controls, .overlays, .unavailText, .placeholder, :LoadItem(), :LoadItemData(), :Clear()
function UIHelpers.CreateDecorPreview(parent, config)
    config = config or {}
    local squareAspect = config.squareAspect ~= false
    local showOverlays = config.showOverlays ~= false
    local showControls = config.showControls ~= false
    local showCorbels = config.showCorbels ~= false
    local insets = config.sceneInsets or { top = 30, right = 10, bottom = 25, left = 10 }
    local placeholderStr = config.placeholderText or "Select an item to preview"

    local frame = CreateFrame("Frame", nil, parent)

    -- Atlas background
    local bgTex = frame:CreateTexture(nil, "BACKGROUND", nil, 1)
    bgTex:SetAllPoints()
    bgTex:SetAtlas("catalog-list-preview-bg")

    -- Square aspect enforcement
    if squareAspect then
        frame:SetScript("OnSizeChanged", function(self, w, h)
            if h > 0 and math.abs(w - h) > 1 then self:SetWidth(h) end
        end)
    end

    -- ModelScene (PanningModelSceneMixin: left-drag rotate, scroll zoom, right-drag pan)
    local modelScene = CreateFrame("ModelScene", nil, frame, "PanningModelSceneMixinTemplate")
    modelScene:SetPoint("TOPLEFT", insets.left, -insets.top)
    modelScene:SetPoint("BOTTOMRIGHT", -insets.right, insets.bottom)
    modelScene:Hide()
    frame.modelScene = modelScene

    -- Model controls (zoom/rotate/reset buttons)
    local controls = nil
    if showControls then
        local ctrlOk, ctrl = pcall(CreateFrame, "Frame", nil, frame, "ModelSceneControlFrameTemplate")
        if ctrlOk and ctrl then
            ctrl:SetPoint("BOTTOM", frame, "BOTTOM", 0, 8)
            pcall(ctrl.SetModelScene, ctrl, modelScene)
            ctrl:Hide()
            controls = ctrl
        end
    end
    frame.controls = controls

    -- Corbels
    if showCorbels then
        local corbelL = frame:CreateTexture(nil, "OVERLAY")
        corbelL:SetSize(66, 50)
        corbelL:SetPoint("BOTTOMLEFT", -2, -2)
        corbelL:SetAtlas("catalog-corbel-bottom-left")

        local corbelR = frame:CreateTexture(nil, "OVERLAY")
        corbelR:SetSize(66, 50)
        corbelR:SetPoint("BOTTOMRIGHT", 2, -2)
        corbelR:SetAtlas("catalog-corbel-bottom-right")
    end

    -- Overlays (name, collected badge, cost, owned count)
    local overlays = nil
    if showOverlays then
        local name = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        name:SetPoint("TOPLEFT", 12, -8)
        name:SetPoint("RIGHT", frame, "RIGHT", -50, 0)
        name:SetJustifyH("LEFT")
        name:SetWordWrap(true)

        local collected = frame:CreateTexture(nil, "OVERLAY")
        collected:SetSize(16, 16)
        collected:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -10, -10)
        collected:SetAtlas("common-icon-checkmark")
        collected:Hide()

        local cost = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        cost:SetPoint("RIGHT", collected, "LEFT", -4, 0)
        cost:SetJustifyH("RIGHT")

        local owned = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        owned:SetPoint("TOPLEFT", name, "BOTTOMLEFT", 0, -2)
        owned:SetJustifyH("LEFT")

        overlays = { name = name, collected = collected, cost = cost, owned = owned }
    end
    frame.overlays = overlays

    -- "Preview unavailable" fallback text
    local unavailText = frame:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
    unavailText:SetPoint("CENTER")
    unavailText:SetText(L["PREVIEW_UNAVAILABLE"])
    unavailText:Hide()
    frame.unavailText = unavailText

    -- Placeholder text
    local placeholder = frame:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
    placeholder:SetPoint("CENTER")
    placeholder:SetText(placeholderStr)
    frame.placeholder = placeholder

    -- Default scene ID fallback
    local function GetDefaultSceneID()
        if Constants and Constants.HousingCatalogConsts and Constants.HousingCatalogConsts.HOUSING_CATALOG_DECOR_MODELSCENEID_DEFAULT then
            return Constants.HousingCatalogConsts.HOUSING_CATALOG_DECOR_MODELSCENEID_DEFAULT
        end
        return CONFIG and CONFIG.ACQUISITION_TAB and CONFIG.ACQUISITION_TAB.DEFAULT_SCENE_ID or 859
    end

    -- Internal: load a decorInfo into the model scene. Returns true on success.
    local function LoadDecorInfo(decorInfo)
        frame.placeholder:Hide()
        modelScene:Hide()
        if controls then controls:Hide() end
        unavailText:Hide()

        if not decorInfo or not decorInfo.asset then
            unavailText:Show()
            return false
        end

        local sceneID = decorInfo.uiModelSceneID or GetDefaultSceneID()
        local sOk = pcall(function()
            modelScene:TransitionToModelSceneID(sceneID, CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD, true)
        end)
        if sOk then
            -- Enable 3D rotation: left-drag Y = pitch (default is NOTHING = yaw-only turntable)
            -- Invert pitch so drag-up tilts to look from above (matches Blizzard housing preview)
            local camera = modelScene:GetActiveCamera()
            if camera then
                camera:SetLeftMouseButtonYMode(ORBIT_CAMERA_MOUSE_MODE_PITCH_ROTATION, true)
                if not camera._hdgPitchHooked then
                    local origHandle = camera.HandleMouseMovement
                    camera.HandleMouseMovement = function(self, mode, delta, snap)
                        if mode == ORBIT_CAMERA_MOUSE_MODE_PITCH_ROTATION then
                            delta = -delta
                        end
                        return origHandle(self, mode, delta, snap)
                    end
                    camera._hdgPitchHooked = true
                end
            end
            local actor = modelScene:GetActorByTag("decor")
            if actor then
                actor:SetPreferModelCollisionBounds(true)
                actor:SetModelByFileID(decorInfo.asset)
            end
            modelScene:Show()
            if controls then controls:Show() end
            return true
        else
            unavailText:Show()
            return false
        end
    end

    --- Load a model by itemID. Returns (success, decorInfo).
    function frame:LoadItem(itemID)
        if not itemID or not C_HousingCatalog or not C_HousingCatalog.GetCatalogEntryInfoByItem then
            self.placeholder:Hide()
            self.unavailText:Show()
            if self.modelScene then self.modelScene:Hide() end
            if self.controls then self.controls:Hide() end
            return false, nil
        end
        local decorInfo = C_HousingCatalog.GetCatalogEntryInfoByItem(itemID, true)
        local ok = LoadDecorInfo(decorInfo)
        return ok, decorInfo
    end

    --- Load a model from itemData table (tries .itemID then .decorID fallback).
    function frame:LoadItemData(itemData)
        if not itemData then return false, nil end
        local decorInfo = nil
        if itemData.itemID and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem then
            decorInfo = C_HousingCatalog.GetCatalogEntryInfoByItem(itemData.itemID, true)
        end
        if not decorInfo and itemData.decorID and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByRecordID then
            local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID, 1, itemData.decorID, true)
            if ok and info then decorInfo = info end
        end
        local ok = LoadDecorInfo(decorInfo)
        return ok, decorInfo
    end

    --- Clear the preview, show placeholder.
    function frame:Clear()
        self.modelScene:Hide()
        if self.controls then self.controls:Hide() end
        self.unavailText:Hide()
        self.placeholder:Show()
        if self.overlays then
            self.overlays.name:SetText("")
            self.overlays.collected:Hide()
            self.overlays.cost:SetText("")
            self.overlays.owned:SetText("")
        end
    end

    return frame
end

-- ============================================================================
-- THEME ENGINE INTEGRATION
-- Wrapper for registering widgets with the Theme Engine
-- ============================================================================

-- Register a widget with the Theme Engine for automatic skinning
-- @param widget: The frame/widget to register
-- @param widgetType: The type of widget (matches skinners in HDG_ThemeEngine.lua)
-- Valid types: Frame, Panel, Button, Text, ProgressBar, RecipeRow, MaterialRow,
--              LumberRow, ShoppingItem, TabButton, ProfessionHeader, ScrollFrame,
--              Checkbox, FilterButton
function UIHelpers.RegisterWidget(widget, widgetType)
    HDG.Theme:Register(widget, widgetType)
end

-- Get the current color scheme from Theme Engine
-- Falls back to SolarizedDark if Theme Engine not initialized
function UIHelpers.GetScheme()
    if HDG.Theme and HDG.Theme.GetScheme then
        return HDG.Theme:GetScheme()
    end
    return HDG.Colors.Schemes.SolarizedDark
end

-- ============================================================================
-- CART BUTTONS (Add/Remove from Shopping List)
-- Shared [+][-] atlas buttons for vendor shopping list interaction
-- ============================================================================

function UIHelpers.CreateCartButtons(parent, size)
    local gap = 2
    local container = CreateFrame("Frame", nil, parent)
    container:SetSize(size * 2 + gap, size)

    -- [+] Add button — full atlas states
    local addBtn = CreateFrame("Button", nil, container)
    addBtn:SetSize(size, size)
    addBtn:SetPoint("LEFT", 0, 0)
    addBtn:SetNormalAtlas("128-RedButton-Cart-Add")
    addBtn:SetPushedAtlas("128-RedButton-Cart-Add-Pressed")
    addBtn:SetHighlightAtlas("128-RedButton-Cart-Add-Highlight")
    addBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(L["TIP_ADD_TO_SHOPPING_LIST"])
        GameTooltip:Show()
    end)
    addBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    container.addBtn = addBtn

    -- [-] Remove button — full atlas states
    local removeBtn = CreateFrame("Button", nil, container)
    removeBtn:SetSize(size, size)
    removeBtn:SetPoint("LEFT", addBtn, "RIGHT", gap, 0)
    removeBtn:SetNormalAtlas("128-RedButton-Cart-Minus")
    removeBtn:SetPushedAtlas("128-RedButton-Cart-Minus-Pressed")
    removeBtn:SetHighlightAtlas("128-RedButton-Cart-Minus-Highlight")
    removeBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(L["TIP_REMOVE_FROM_SHOPPING_LIST"])
        GameTooltip:Show()
    end)
    removeBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    removeBtn:Hide()
    container.removeBtn = removeBtn

    -- Refresh remove button visibility
    local function RefreshRemoveBtn()
        if container._itemID and HDG and HDG.Store and HDG.Store:IsOnVendorShoppingList(container._itemID, container._npcID) then
            removeBtn:Show()
        else
            removeBtn:Hide()
        end
    end

    -- Post-click: refresh shopping list + badge + optional callback
    local function PostClick()
        RefreshRemoveBtn()
        if HDG_VendorShoppingList and HDG_VendorShoppingList.IsShown and HDG_VendorShoppingList.IsShown() then
            HDG_VendorShoppingList.Refresh()
        end
        if HDG_UI and HDG_UI.UpdateShopListBadge then HDG_UI.UpdateShopListBadge() end
        if container.onPostClick then container.onPostClick() end
    end

    addBtn:SetScript("OnClick", function()
        if not container._itemID or not HDG or not HDG.Store then return end
        HDG.Store:AddToVendorShoppingList(container._itemID, container._npcID)
        PostClick()
    end)

    removeBtn:SetScript("OnClick", function()
        if not container._itemID or not HDG or not HDG.Store then return end
        HDG.Store:RemoveFromVendorShoppingList(container._itemID, container._npcID)
        PostClick()
    end)

    function container:SetItem(itemID, npcID)
        container._itemID = itemID
        container._npcID = npcID
        RefreshRemoveBtn()
    end

    return container
end

-- ============================================================================
-- EXPORT DIALOG FACTORY
-- Themed, movable, singleton-cached dialog with scroll+editbox for copy/paste
-- ============================================================================

function UIHelpers.CreateExportDialog(opts)
    opts = opts or {}
    local width = opts.width or 500
    local height = opts.height or 350
    local bottomPad = opts.bottomPad or 45

    local f = CreateFrame("Frame", opts.frameName, UIParent, "BackdropTemplate")
    f:SetSize(width, height)
    f:SetPoint("CENTER")
    f:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        tile = false, edgeSize = 2,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })
    local scheme = GetScheme()
    if scheme then
        f:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.95)
        f:SetBackdropBorderColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 1)
        RegisterWidget(f, "Panel")
    else
        local c = CONFIG.COLORS.PANEL_DARK; f:SetBackdropColor(c[1], c[2], c[3], 0.95)
        f:SetBackdropBorderColor(unpack(CONFIG.COLORS.PANEL_DARK_BORDER_TEAL))
    end
    f:SetFrameStrata("DIALOG")
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:Hide()

    -- Atlas wood frame border (matches main window Housing Theme)
    local borderFrame = CreateFrame("Frame", nil, f)
    borderFrame:SetAllPoints()
    borderFrame:SetFrameLevel(f:GetFrameLevel() + 10)
    local borderTex = borderFrame:CreateTexture(nil, "OVERLAY")
    borderTex:SetAllPoints()
    local borderAtlas = scheme and scheme.atlas and scheme.atlas.windowBorder
    borderTex:SetAtlas(borderAtlas or "housing-simple-wood-frame")
    borderFrame.tex = borderTex
    f.borderFrame = borderFrame
    if borderAtlas then
        borderFrame:Show()
    else
        borderFrame:Hide()
    end

    local titleFS = borderFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    titleFS:SetPoint("TOP", f, "TOP", 0, -10)
    if scheme then HDG.Theme.ApplyFont(titleFS, scheme, "header") end

    -- Black inset behind the editbox area
    local ebBg = CreateFrame("Frame", nil, f, "BackdropTemplate")
    ebBg:SetPoint("TOPLEFT", 12, -32)
    ebBg:SetPoint("BOTTOMRIGHT", -14, bottomPad - 3)
    ebBg:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        tile = false, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 }
    })
    ebBg:SetBackdropColor(0, 0, 0, 0.6)
    local wb = CONFIG.COLORS.PANEL_DARK_BORDER_WARM; ebBg:SetBackdropBorderColor(wb[1], wb[2], wb[3], 0.7) -- warm wood-tone edge

    local sf = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    sf:SetPoint("TOPLEFT", 15, -35)
    sf:SetPoint("BOTTOMRIGHT", -35, bottomPad)

    local eb = CreateFrame("EditBox", nil, sf)
    eb:SetMultiLine(true)
    if scheme and scheme.fonts and scheme.fonts.small then
        eb:SetFont(scheme.fonts.small.file or "Fonts\\FRIZQT__.TTF", scheme.fonts.small.size or 10, "")
    else
        eb:SetFontObject("GameFontHighlightSmall")
    end
    eb:SetWidth(width - 60)
    eb:SetAutoFocus(false)
    eb:SetMaxLetters(0)
    eb:EnableMouse(true)
    eb:EnableKeyboard(true)
    eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    sf:SetScrollChild(eb)

    local selectAllBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    selectAllBtn:SetPoint("BOTTOMLEFT", 15, 10)
    selectAllBtn:SetSize(90, 22)
    selectAllBtn:SetText(L["BTN_SELECT_ALL"])
    selectAllBtn:SetScript("OnClick", function() eb:HighlightText(); eb:SetFocus() end)

    local closeBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    closeBtn:SetPoint("BOTTOMRIGHT", -15, 10)
    closeBtn:SetSize(80, 22)
    closeBtn:SetText(L["BTN_CLOSE"])
    closeBtn:SetScript("OnClick", function() f:Hide() end)

    local ctrl = {
        frame = f,
        editBox = eb,
        titleFS = titleFS,
        selectAllBtn = selectAllBtn,
        closeBtn = closeBtn,
    }
    function ctrl:Show(text, title)
        if title then titleFS:SetText(title) end
        eb:SetText(text or "")
        eb:HighlightText()
        eb:SetFocus()
        f:Show()
    end
    function ctrl:Hide() f:Hide() end

    return ctrl
end

-- ============================================================================
-- HOVER PREVIEW (shared 3D model popup for item hover)
-- ============================================================================

local hoverPreview = nil

local function GetHoverPreview()
    if hoverPreview then return hoverPreview end
    hoverPreview = UIHelpers.CreateDecorPreview(UIParent, {
        squareAspect = true,
        showOverlays = false,
        showControls = false,
        showCorbels = false,
    })
    hoverPreview:SetSize(150, 150)
    hoverPreview:SetFrameStrata("TOOLTIP")
    hoverPreview:Hide()
    hoverPreview.placeholder:Hide()
    return hoverPreview
end

--- Show a 3D decor preview anchored to the right of an anchor frame
-- @param itemID number - the item ID to preview
-- @param anchor Frame - frame to anchor the preview to (preview appears at TOPRIGHT)
function UIHelpers.ShowHoverPreview(itemID, anchor)
    local preview = GetHoverPreview()
    preview:ClearAllPoints()
    preview:SetPoint("TOPLEFT", anchor, "TOPRIGHT", 4, 0)
    preview:LoadItem(itemID)
    preview:Show()
end

function UIHelpers.HideHoverPreview()
    if hoverPreview then hoverPreview:Hide() end
end

-- ============================================================================
-- EXPORT TO GLOBAL
-- Make helpers accessible from other modules
-- ============================================================================

_G.HDG_UIHelpers = UIHelpers

return UIHelpers
