--

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

HDG_LumberTracker = {}

-- ============================================================================
-- THEME HELPERS (LumberTracker always uses HDGDefault theme)
-- ============================================================================

local HDG = _G.HousingDecorGuide
-- LumberTracker widget always uses HDGDefault theme regardless of main addon theme
local function GetScheme() return HDG.Colors:GetScheme("HDGDefault") end
local function RegisterWidget(_, _) end  -- No-op: don't register with theme engine (fixed theme)
local ApplyFont = function(fs, scheme, fontType)
    -- Use HDGDefault fonts directly
    local s = scheme or GetScheme()
    if s and s.fonts and s.fonts[fontType] then
        local fontDef = s.fonts[fontType]
        fs:SetFont(fontDef.file, fontDef.size, fontDef.flags or "")
    end
end

-- ============================================================================
-- STORE HELPERS
-- ============================================================================

local function GetStore()
    local HDG = _G.HousingDecorGuide
    return HDG and HDG.Store
end

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

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

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

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

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

local function GetTrackedReagents()
    local state = GetState()
    return state and state.trackedReagents or {}
end

-- Local references
local mainFrame
local pinnedFrame  -- Independent frame parented to UIParent (persists when main window closes)

-- Session-only farming stats (not saved, resets on logout/reload)
local sessionFarmingStats = {}  -- [lumberId] = { startCount, startTime, collected, zone, sessionId, lastCollectTime }
local previousLumberCounts = {}  -- Track previous counts to detect increases
local activeFarmingLumber = nil  -- Currently highlighted lumber ID
local cachedQueueLumber = nil -- Cached result of CalculateCraftingQueueLumber
local cachedAllLumber = nil      -- Cached result of CalculateLumberNeeds
local lumberNeedsDirty = true    -- Invalidation flag for lumber needs cache
local materialsCacheDirty = true       -- Invalidation flag for non-lumber materials cache
local cachedNonLumberMaterials = nil   -- Used by GetNonLumberMaterials (consumed by HDG_MaterialsList)
local cachedRawNonLumberMaterials = nil
local pinnedDisplayState = nil   -- Snapshot of last-displayed state to skip redundant UI rebuilds
local pinnedLumberRows = {}      -- [lumberId] = pooled row frame (reused across refreshes)
local justCollectedLumber = nil  -- Animation: lumber that was just collected (triggers pop animation)
local animationPhase = 0         -- Animation phase: 0=none, 1=grow, 2=shrink
local animationTimer = nil       -- Timer handle for clearing animation state
local farmingTicker = nil        -- 1-second ticker that refreshes pinned widget during active farming

-- Track when item-moving UIs are open to prevent false farming sessions
local uiSuspended = false  -- True when mail, bank, merchant, or trade is open
local isSessionInitialized = false  -- Prevents false collection detection during login
local initTimer = nil  -- Guard against stacking init timers
local initStartTime = 0  -- GetTime() when init was requested (for lazy fallback)

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

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

-- Format elapsed time as H:MM:SS or M: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

-- Reference centralized lumber data
local LUMBER_TYPES = HDG_ExpansionData.LUMBER_TYPES

-- O(1) lookup for ACHIEVEMENT_EARNED filtering
local LUMBER_ACHIEVE_IDS = {}
for _, info in pairs(LUMBER_TYPES) do
    if info.achieveID then LUMBER_ACHIEVE_IDS[info.achieveID] = true end
end

local GetLumberAchievementProgress = HDG_ExpansionData.GetLumberAchievementProgress

-- Public: check if an achievement ID is a lumber mounted-harvesting achievement
function HDG_LumberTracker.IsLumberAchievement(achieveID)
    return LUMBER_ACHIEVE_IDS[achieveID] == true
end

-- Public: invalidate pinned display state to force refresh
function HDG_LumberTracker.InvalidateDisplayState()
    pinnedDisplayState = nil
end

-- ============================================================================
-- SESSION FARMING TRACKING
-- ============================================================================

-- Initialize previous lumber counts (call on addon load)
local function InitializeLumberCounts()
    for lumberId, _ in pairs(LUMBER_TYPES) do
        previousLumberCounts[lumberId] = Data().GetItemCount(lumberId) or 0
    end
end

-- Clear all session state (called on init)
local function ClearAllSessionState()
    activeFarmingLumber = nil
    justCollectedLumber = nil
    animationPhase = 0
    if animationTimer then
        animationTimer:Cancel()
        animationTimer = nil
    end
    if farmingTicker then
        farmingTicker:Cancel()
        farmingTicker = nil
    end
end

-- Start a 1-second ticker to refresh visible lumber widget during active farming (keeps elapsed/perHour live)
local function StartFarmingTicker()
    if farmingTicker then return end -- already running
    farmingTicker = C_Timer.NewTicker(1.0, function()
        if not activeFarmingLumber then
            farmingTicker:Cancel()
            farmingTicker = nil
            return
        end
        if HDG_LumberTracker.IsPinnedVisible() then
            HDG_LumberTracker.RefreshPinned()
        end
    end)
end

-- Save lumber gathering coordinates for later analysis (heatmaps, route optimization, etc.)
local function SaveLumberCoordinates(lumberId, quantity)
    -- Get current position
    local mapID = C_Map.GetBestMapForUnit("player")
    if not mapID then return end

    local position = C_Map.GetPlayerMapPosition(mapID, "player")
    if not position then return end

    local x, y = position:GetXY()
    if not x or not y then return end

    -- Get zone info
    local zone = GetZoneText() or "Unknown"
    local subzone = GetSubZoneText() or ""

    -- Create coordinate entry and dispatch to Store (persists to HDG_DB)
    local entry = {
        lumberId = lumberId,
        quantity = quantity or 1,
        mapID = mapID,
        x = math.floor(x * 10000) / 10000,  -- 4 decimal places (0.01% precision)
        y = math.floor(y * 10000) / 10000,
        zone = zone,
        subzone = subzone,
        timestamp = time(),
        character = UnitName("player"),
        realm = GetRealmName()
    }

    Dispatch("ADD_LUMBER_COORDINATE", { node = entry })

    -- Batch prune when 20+ over limit
    local state = GetState()
    local coords = state and state.lumberCoordinates
    if coords then
        local maxCoords = CONFIG.MAX_LUMBER_COORDINATES or 500
        if #coords > maxCoords + 20 then
            local newCoords = {}
            for i = #coords - maxCoords + 1, #coords do
                newCoords[#newCoords + 1] = coords[i]
            end
            Dispatch("SET_LUMBER_COORDINATES", { coordinates = newCoords })
        end
    end

    -- Notify LumberHUD of new gathering location (auto-show compass)
    if privateDB.HDG_LumberHUD and privateDB.HDG_LumberHUD.OnLocationAdded then
        privateDB.HDG_LumberHUD.OnLocationAdded(x, y, mapID)
    end
end

-- Save or update a farming session to history (upsert)
local function SaveFarmingSession(lumberId)
    local stats = sessionFarmingStats[lumberId]
    if not stats or stats.collected == 0 then return end

    local lumberInfo = LUMBER_TYPES[lumberId]
    if not lumberInfo then return end

    local now = GetTime()
    local duration = now - stats.startTime
    local perHour = duration > 0 and (stats.collected / duration) * 3600 or 0

    -- Get HDG_DataTab to save/update the session
    local HDG_DataTab = privateDB and privateDB.HDG_DataTab
    if HDG_DataTab and HDG_DataTab.SaveFarmingSession then
        HDG_DataTab.SaveFarmingSession({
            sessionId = stats.sessionId,
            lumberId = lumberId,
            lumberName = HDG_ExpansionData.GetLumberMaterialName(lumberId) or lumberInfo.shortName or lumberInfo.name,
            collected = stats.collected,
            duration = duration,
            perHour = perHour,
            zone = stats.zone or "Unknown",
            timestamp = time(),
            character = UnitName("player"),
            realm = GetRealmName()
        })
    end
end

-- Speaker color lookup for lumber quotes (replaces inline if/elseif chain)
local SPEAKER_COLORS = {
    GOBLIN   = { name = "Gazlowe", color = "FFD100" },
    SQUIRREL = { name = "Squirrel", color = "A0522D" },
}
local SPEAKER_DEFAULT = { name = "Vamoose", color = "0070DE" }

-- Refresh whichever lumber widget is currently visible
local function RefreshVisibleWidget()
    if HDG_LumberTracker.IsPinnedVisible() then
        HDG_LumberTracker.RefreshPinned()
    end
end

-- Auto-show pinned widget on collection (if setting enabled)
-- Pinned widget handles collapse mode natively (only shows active row when farming)
local function ApplyAutoShowBehavior()
    local autoShowEnabled = (GetConfig("lumberAutoShow") == true)
    if not autoShowEnabled then return end
    if not HDG_LumberTracker.IsPinnedVisible() then
        HDG_LumberTracker.ShowPinned()
    end
end

-- Fire a Vamoose quote on lumber collection (if quotes enabled)
local function TriggerLumberQuote(lumberId, increase)
    local quotesEnabled = GetConfig("quotesEnabled")
    if quotesEnabled == nil then quotesEnabled = true end
    local lumberQuotesEnabled = GetConfig("lumberQuotesEnabled")
    if lumberQuotesEnabled == nil then lumberQuotesEnabled = true end
    local chattiness = GetConfig("vamooseChattiness") or 30
    if not (quotesEnabled and lumberQuotesEnabled and chattiness > 0 and privateDB.HDG_Vamoose) then return end

    local quote, speaker = privateDB.HDG_Vamoose.OnLumberCollected(lumberId, increase, chattiness)
    if not quote then return end
    local onlyChat = GetConfig("quotesOnlyChat") or false
    if not onlyChat then
        privateDB.HDG_Vamoose.DisplayQuote(quote, speaker)
    end
    if onlyChat then
        local sp = SPEAKER_COLORS[speaker] or SPEAKER_DEFAULT
        print(string.format("|cFF%s[%s]|r %s", sp.color, sp.name, quote))
    end
end

-- Run the 3-phase collection animation (grow -> hold -> shrink)
local function TriggerCollectionAnimation(lumberId)
    justCollectedLumber = lumberId
    animationPhase = 1
    if animationTimer then animationTimer:Cancel() end
    RefreshVisibleWidget()
    animationTimer = C_Timer.NewTimer(0.2, function()
        animationPhase = 2
        RefreshVisibleWidget()
        animationTimer = C_Timer.NewTimer(0.15, function()
            justCollectedLumber = nil
            animationPhase = 0
            RefreshVisibleWidget()
        end)
    end)
end

-- Check for lumber collection increases and update farming stats
-- Sessions only start fresh on reload/login - zone changes don't split sessions
local function CheckLumberChanges()
    -- Skip if session tracking hasn't been initialized yet (prevents false detection on login)
    if not isSessionInitialized then
        -- Lazy init fallback: if 4+ seconds have passed since init was requested,
        -- the timer may have been lost (rare edge case) — force init now
        if initStartTime > 0 and (GetTime() - initStartTime) > 4 then
            InitializeLumberCounts()
            isSessionInitialized = true
        else
            return nil
        end
    end

    -- Skip farming tracking if lumber came from mail, bank, merchant, or trade
    if uiSuspended then
        -- Still update previous counts so we don't trigger on close
        for lumberId, _ in pairs(LUMBER_TYPES) do
            previousLumberCounts[lumberId] = Data().GetItemCount(lumberId) or 0
        end
        return nil
    end

    local now = GetTime()
    local changedLumber = nil
    local currentZone = GetZoneText() or "Unknown"

    for lumberId, _ in pairs(LUMBER_TYPES) do
        local currentCount = Data().GetItemCount(lumberId) or 0
        local previousCount = previousLumberCounts[lumberId] or 0

        if currentCount > previousCount then
            local increase = currentCount - previousCount
            changedLumber = lumberId

            -- Initialize session if this is first collection of this lumber type this session
            if not sessionFarmingStats[lumberId] then
                sessionFarmingStats[lumberId] = {
                    startCount = previousCount,
                    startTime = now,
                    collected = 0,
                    zone = currentZone,
                    sessionId = lumberId .. "_" .. time(),
                    lastCollectTime = now
                }
            end

            -- Update stats
            sessionFarmingStats[lumberId].collected = sessionFarmingStats[lumberId].collected + increase
            sessionFarmingStats[lumberId].lastCollectTime = now
            sessionFarmingStats[lumberId].zone = currentZone

            SaveFarmingSession(lumberId)
            SaveLumberCoordinates(lumberId, increase)
            activeFarmingLumber = lumberId
            StartFarmingTicker()
            ApplyAutoShowBehavior()
            TriggerLumberQuote(lumberId, increase)
            TriggerCollectionAnimation(lumberId)
        end

        -- Update previous count
        previousLumberCounts[lumberId] = currentCount
    end

    return changedLumber
end

-- Get farming stats for a lumber type
local function GetFarmingStats(lumberId)
    local stats = sessionFarmingStats[lumberId]
    if not stats or stats.collected == 0 then
        return nil
    end

    local now = GetTime()
    local elapsed = now - stats.startTime
    local perHour = 0

    if elapsed > 0 then
        perHour = (stats.collected / elapsed) * 3600  -- Convert to per hour
    end

    return {
        collected = stats.collected,
        elapsed = elapsed,
        perHour = perHour
    }
end

-- ============================================================================
-- LUMBER CALCULATION FUNCTIONS
-- These are the primary implementations; HDG_Data delegates to these
-- ============================================================================

-- Ensure a lumber entry exists in the table, using account-wide counts
local function EnsureLumberEntry(tbl, reagentID)
    if not tbl[reagentID] then
        local accountTotal, breakdown = Data().GetAccountWideLumberCount(reagentID)
        tbl[reagentID] = {
            id = reagentID,
            name = LUMBER_TYPES[reagentID].name,
            expansion = LUMBER_TYPES[reagentID].expansion,
            needed = 0,
            have = accountTotal,
            breakdown = breakdown,
        }
    end
    return tbl[reagentID]
end

-- Ensure a non-lumber reagent entry exists in the table
local function EnsureReagentEntry(tbl, reagentID, expansion)
    if not tbl[reagentID] then
        local reagentName = Data().GetCachedItemName(reagentID) or "Unknown"
        local reagentSource = (HDG_ReagentsDB and HDG_ReagentsDB[reagentID] and HDG_ReagentsDB[reagentID][1]) or "Unknown"
        tbl[reagentID] = {
            id = reagentID,
            name = reagentName,
            source = reagentSource,
            expansion = expansion,
            needed = 0,
            have = Data().GetItemCountWithVariants(reagentID)
        }
    end
    return tbl[reagentID]
end

-- Accumulate direct (non-lumber) materials from a queue item's reagents
local function AccumulateDirectMaterials(materials, item)
    for reagentKey, reagent in pairs(item.reagents) do
        local reagentID = reagent.id or reagentKey
        if not LUMBER_TYPES[reagentID] then
            EnsureReagentEntry(materials, reagentID, item.expansion)
            local reagentAmount = reagent.amount or reagent.qty or reagent.count or 1
            materials[reagentID].needed = materials[reagentID].needed + (reagentAmount * (item.quantity or 1))
        end
    end
end

-- Calculate lumber needs from all uncollected recipes
-- Uses account-wide lumber counts (summed across all characters)
function HDG_LumberTracker.CalculateLumberNeeds(professions)
    local lumberNeeds = {}

    for _, profData in ipairs(professions) do
        for _, recipe in ipairs(profData.items) do
            if not Data().IsItemCollected(recipe.id) and recipe.reagents then
                for reagentKey, reagent in pairs(recipe.reagents) do
                    local reagentID = reagent.id or reagentKey
                    if LUMBER_TYPES[reagentID] then
                        EnsureLumberEntry(lumberNeeds, reagentID)
                        lumberNeeds[reagentID].needed = lumberNeeds[reagentID].needed +
                            (reagent.amount or reagent.qty or 1)
                    end
                end
            end
        end
    end

    return lumberNeeds
end

-- Calculate lumber needs from crafting queue only
-- Uses account-wide lumber counts (summed across all characters)
function HDG_LumberTracker.CalculateCraftingQueueLumber(queue)
    local lumberNeeds = {}

    for i, item in ipairs(queue) do
        if item.reagents then
            for reagentKey, reagent in pairs(item.reagents) do
                local reagentID = reagent.id or reagentKey
                if LUMBER_TYPES[reagentID] then
                    local entry = EnsureLumberEntry(lumberNeeds, reagentID)
                    if not entry.sources then entry.sources = {} end
                    local reagentAmount = reagent.amount or reagent.qty or reagent.count or 1
                    entry.needed = entry.needed + (reagentAmount * item.quantity)
                    entry.sources[i] = true
                end
            end
        end
    end

    return lumberNeeds
end

function HDG_LumberTracker.InvalidateLumberNeeds()
    lumberNeedsDirty = true
    materialsCacheDirty = true
end

function HDG_LumberTracker.GetCachedLumberData()
    return cachedQueueLumber, cachedAllLumber
end

-- Handle end session callback for farming tracking
local function HandleEndSession(lumberId)
    local stats = sessionFarmingStats[lumberId]
    if stats and stats.collected > 0 then
        local duration = (stats.lastCollectTime or GetTime()) - stats.startTime
        local perHour = duration > 0 and (stats.collected / duration) * 3600 or 0
        local lumberInfo = LUMBER_TYPES[lumberId]
        local lumberName = HDG_ExpansionData.GetLumberMaterialName(lumberId) or (lumberInfo and lumberInfo.shortName) or (lumberInfo and lumberInfo.name) or "Lumber"
        local expColor = HDG_ExpansionData.GetColor(lumberInfo and lumberInfo.expansion)
        local colorHex = string.format("%02x%02x%02x",
            math.min(255, expColor[1] * 255 * 1.3),
            math.min(255, expColor[2] * 255 * 1.3),
            math.min(255, expColor[3] * 255 * 1.3))
        print(string.format("|cFFfcd34d[Housing Decor Guide]|r |cFF%s%s|r farming session ended: |cFF88FF88%d|r collected in %s |cFF888888(first to last)|r (|cFFfcd34d%.1f/hr|r)",
            colorHex, lumberName, stats.collected, FormatElapsedTime(duration), perHour))
    end
    -- Clear session stats and all session state (including timeout timer)
    sessionFarmingStats[lumberId] = nil
    ClearAllSessionState()
    -- Hide the lumber radar when session ends
    local HDG_LumberHUD = privateDB and privateDB.HDG_LumberHUD
    if HDG_LumberHUD and HDG_LumberHUD.Hide then
        HDG_LumberHUD.Hide()
    end
    -- Refresh pinned widget to reflect session ended
    if HDG_LumberTracker.IsPinnedVisible() then
        HDG_LumberTracker.RefreshPinned()
    end
    local HDG_DataTab = privateDB and privateDB.HDG_DataTab
    if HDG_DataTab and HDG_DataTab.RefreshFarmingDisplay then
        HDG_DataTab.RefreshFarmingDisplay()
    end
end

-- Helper: Create a lumber row using shared UIHelpers function
-- Returns: nextYOffset (accounts for expanded row height if farming active)
local function CreateLumberRow(parent, lumberId, lumberInfo, queueData, allData, yOffset)
    local isActiveFarming = (activeFarmingLumber == lumberId)
    local isJustCollected = (justCollectedLumber == lumberId)
    local farmingStats = GetFarmingStats(lumberId)

    -- Build farming options for the shared helper
    -- Always pass scheme override so LumberTracker widget uses HDGDefault theme
    local farmingOptions = {
        scheme = GetScheme(),  -- Uses HDGDefault (fixed in this module)
        isActive = isActiveFarming,
        isJustCollected = isJustCollected,
        animationPhase = animationPhase,
        stats = (isActiveFarming or isJustCollected) and farmingStats and {
            collected = farmingStats.collected,
            perHour = farmingStats.perHour,
            elapsed = farmingStats.elapsed
        } or nil
    }

    return HDG_UIHelpers.CreateLumberRow(parent, lumberId, lumberInfo, queueData, allData, yOffset, farmingOptions)
end

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

function HDG_LumberTracker.Init(frame)
    mainFrame = frame
end

-- Initialize lumber tracking on addon load (called from main entry point)
function HDG_LumberTracker.InitializeSessionTracking()
    -- Clear any stale session state from previous session
    -- This ensures fresh start on login/reload
    ClearAllSessionState()
    sessionFarmingStats = {}
    uiSuspended = false  -- Reset suspend state on fresh init
    isSessionInitialized = false  -- Block CheckLumberChanges until counts are initialized
    initStartTime = GetTime()

    -- Cancel any previous init timer to prevent stacking
    if initTimer then
        initTimer:Cancel()
        initTimer = nil
    end

    -- Initialize lumber counts for session tracking
    -- Delay to ensure bags/bank data is fully loaded (matches SaveCharacterLumberInventory timing)
    -- This prevents false "collection" detection on first login when bags load
    initTimer = C_Timer.NewTimer(3.5, function()
        initTimer = nil
        InitializeLumberCounts()
        isSessionInitialized = true  -- Now safe to detect lumber changes
    end)
end

-- Save all active farming sessions (called on logout/reload)
function HDG_LumberTracker.SaveAllSessions()
    for lumberId, stats in pairs(sessionFarmingStats) do
        if stats and stats.collected > 0 then
            SaveFarmingSession(lumberId)
        end
    end
end

-- Check for lumber changes and return true if any lumber was collected
-- Called from QueueInventoryUpdate in HousingDecorGuide.lua
function HDG_LumberTracker.CheckForLumberChanges()
    return CheckLumberChanges()
end

-- Set UI suspended state (called from main event handler when mail/bank/merchant/trade opens/closes)
function HDG_LumberTracker.SetUISuspended(suspended)
    uiSuspended = suspended
    -- When un-suspending, re-snapshot counts so we don't false-trigger
    if not suspended then
        for lumberId, _ in pairs(LUMBER_TYPES) do
            previousLumberCounts[lumberId] = Data().GetItemCount(lumberId) or 0
        end
    end
end

-- Get active farming lumber (for debug)
function HDG_LumberTracker.GetActiveFarmingLumber()
    return activeFarmingLumber
end

-- Get session farming stats (for debug)
function HDG_LumberTracker.GetSessionStats()
    -- Calculate perHour for each active session
    local result = {}
    local now = GetTime()
    for lumberId, stats in pairs(sessionFarmingStats) do
        if stats then
            local elapsed = now - (stats.startTime or now)
            local perHour = 0
            if elapsed > 0 and stats.collected > 0 then
                perHour = (stats.collected / elapsed) * 3600
            end
            result[lumberId] = {
                collected = stats.collected or 0,
                perHour = perHour,
                zone = stats.zone,
                startTime = stats.startTime,
            }
        end
    end
    return result
end

-- Get previous lumber counts (for debug)
function HDG_LumberTracker.GetPreviousCounts()
    return previousLumberCounts
end

-- Manually start a farming session for a specific lumber type
-- This allows users to pre-start a session before they begin gathering
function HDG_LumberTracker.StartManualFarmingSession(lumberId)
    if not lumberId or not LUMBER_TYPES[lumberId] then return end

    local now = GetTime()
    local currentZone = GetZoneText() or "Unknown"
    local currentCount = Data().GetItemCount(lumberId) or 0

    -- Initialize session if not already active
    if not sessionFarmingStats[lumberId] then
        sessionFarmingStats[lumberId] = {
            startCount = currentCount,
            startTime = now,
            collected = 0,
            zone = currentZone,
            sessionId = lumberId .. "_" .. time(),
            lastCollectTime = now
        }
    end

    -- Set as active farming lumber
    activeFarmingLumber = lumberId

    -- Update previous count baseline
    previousLumberCounts[lumberId] = currentCount

    -- Refresh pinned widget to show active farming state
    if HDG_LumberTracker.IsPinnedVisible() then
        HDG_LumberTracker.RefreshPinned()
    end

    -- Notify user
    local lumberInfo = LUMBER_TYPES[lumberId]
    local lumberName = HDG_ExpansionData.GetLumberMaterialName(lumberId) or (lumberInfo and lumberInfo.shortName) or (lumberInfo and lumberInfo.name) or "Lumber"
    print(string.format("|cFFfcd34d[Housing Decor Guide]|r Started farming session for |cFF88FF88%s|r", lumberName))
end
-- ============================================================================
-- PINNED LUMBER WIDGET (Independent frame parented to UIParent)
-- Note: Embedded lumber frame is created in HDG_RecipesTab.lua (CreateQueuePanel)
-- Uses FLOATING_WIDGET constants from HDG_Config.lua
-- ============================================================================

-- Convenience accessor for FLOATING_WIDGET config
local function FW()
    return CONFIG.FLOATING_WIDGET or {}
end

-- Calculate lumber panel height dynamically based on number of lumber types
local function GetLumberCount()
    return #HDG_ExpansionData.LUMBER_ORDER
end

local function GetLumberContentHeight()
    return GetLumberCount() * (FW().ROW_HEIGHT or 18)
end

local function GetLumberPanelHeight()
    return (FW().LUMBER_HEADER_HEIGHT or 18) + GetLumberContentHeight() + 4
end

local function GetCompactHeight()
    return (FW().HEADER_HEIGHT or 34) + GetLumberPanelHeight() + (FW().FOOTER_PADDING or 8)
end

-- Helper to create standard button
local function CreateHeaderButton(parent, size, tooltip)
    local scheme = GetScheme()

    local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
    btn:SetSize(size, size)
    btn:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        edgeSize = 1,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })

    if scheme then
        btn:SetBackdropColor(scheme.button_normal.r, scheme.button_normal.g, scheme.button_normal.b, scheme.button_normal.a)
        btn:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, scheme.border.a)
        btn._scheme = scheme
    else
        btn:SetBackdropColor(unpack(HDG_UIHelpers.BTN_NORMAL))
        btn:SetBackdropBorderColor(unpack(HDG_UIHelpers.BORDER_COLOR))
    end

    btn:SetScript("OnEnter", function(self)
        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(HDG_UIHelpers.BTN_HOVER))
        end
        if tooltip then
            GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
            GameTooltip:SetText(tooltip, 1, 1, 1)
            GameTooltip:Show()
        end
    end)
    btn:SetScript("OnLeave", function(self)
        local c = self._scheme or GetScheme()
        if c then
            self:SetBackdropColor(c.button_normal.r, c.button_normal.g, c.button_normal.b, c.button_normal.a)
        else
            self:SetBackdropColor(unpack(HDG_UIHelpers.BTN_NORMAL))
        end
        GameTooltip:Hide()
    end)

    -- Register with Theme Engine
    RegisterWidget(btn, "Button")

    return btn
end

-- Save pinned frame position
local function SavePinnedPosition()
    if not pinnedFrame then return end
    local point, _, relativePoint, xOfs, yOfs = pinnedFrame:GetPoint()
    Dispatch("SET_LUMBER_POSITION", { position = { point, relativePoint, xOfs, yOfs } })
end

-- Force recreate the pinned frame (destroys existing and creates new)
function HDG_LumberTracker.ResetPinnedFrame()
    if pinnedFrame then
        SavePinnedPosition()
        pinnedFrame:Hide()
        pinnedFrame:SetParent(nil)
        pinnedFrame = nil
    end
    return HDG_LumberTracker.CreatePinnedFrame()
end

-- Create the independent pinned lumber frame
function HDG_LumberTracker.CreatePinnedFrame()
    if pinnedFrame then return pinnedFrame end

    local scheme = GetScheme()

    -- Create frame parented to UIParent (independent of main addon window)
    pinnedFrame = CreateFrame("Frame", "HDG_PinnedLumberFrame", UIParent, "BackdropTemplate")
    pinnedFrame:SetSize(FW().LUMBER_WIDTH or 280, GetCompactHeight())
    pinnedFrame:SetFrameStrata("DIALOG")  -- Show above main window and its close button
    pinnedFrame:SetToplevel(true)  -- Raise to top when clicked
    pinnedFrame:SetFrameLevel(100)
    pinnedFrame:SetClampedToScreen(true)
    pinnedFrame:SetMovable(true)
    pinnedFrame:EnableMouse(true)
    pinnedFrame:RegisterForDrag("LeftButton")

    -- Restore position or default to center-right
    local pos = GetUIState("pinnedLumberPosition")
    if pos and pos[1] and pos[3] and pos[4] then
        pinnedFrame:SetPoint(pos[1], UIParent, pos[2] or pos[1], pos[3], pos[4])
    else
        pinnedFrame:SetPoint("RIGHT", UIParent, "RIGHT", -50, 0)
    end

    pinnedFrame:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        edgeSize = 1,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })
    pinnedFrame:SetBackdropColor(scheme.bg.r, scheme.bg.g, scheme.bg.b, scheme.bg.a)
    pinnedFrame:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, scheme.border.a)

    -- Register with Theme Engine
    RegisterWidget(pinnedFrame, "Frame")

    -- Dragging
    pinnedFrame:SetScript("OnDragStart", function(self) self:StartMoving() end)
    pinnedFrame:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        SavePinnedPosition()
    end)

    -- Title button (clickable to start manual farming session)
    local titleBtn = CreateFrame("Button", nil, pinnedFrame)
    titleBtn:SetSize(160, 30)
    titleBtn:SetPoint("LEFT", 4, 0)
    titleBtn:SetPoint("TOP", 0, 0)

    local titleTexture = titleBtn:CreateTexture(nil, "ARTWORK")
    titleTexture:SetAllPoints()
    titleTexture:SetTexture(CONFIG.ADDON_TEXTURES.TRACKER_TITLE)
    titleBtn.texture = titleTexture

    -- Title is decorative only — pass drag events through to parent for moving
    titleBtn:RegisterForDrag("LeftButton")
    titleBtn:SetScript("OnDragStart", function() pinnedFrame:StartMoving() end)
    titleBtn:SetScript("OnDragStop", function()
        pinnedFrame:StopMovingOrSizing()
        SavePinnedPosition()
    end)

    pinnedFrame.titleBtn = titleBtn
    pinnedFrame.titleTexture = titleTexture

    -- (Achievement badge removed -- per-row progress shown during farming instead)

    -- Unpin button - atlas-based condense/expand icon
    local unpinBtn = CreateFrame("Button", nil, pinnedFrame)
    unpinBtn:SetSize(24, 24)
    unpinBtn:SetPoint("TOPRIGHT", -6, -6)

    local unpinNormal = unpinBtn:CreateTexture(nil, "ARTWORK")
    unpinNormal:SetAllPoints()
    local unpinPushed = unpinBtn:CreateTexture(nil, "ARTWORK")
    unpinPushed:SetAllPoints()
    local unpinHighlight = unpinBtn:CreateTexture(nil, "HIGHLIGHT")
    unpinHighlight:SetAllPoints()
    unpinHighlight:SetAlpha(0.3)

    -- Default state: pinned = condense (click to unpin/condense back)
    unpinNormal:SetAtlas("RedButton-Condense")
    unpinPushed:SetAtlas("RedButton-Condense-Pressed")
    unpinHighlight:SetAtlas("RedButton-Condense")
    unpinBtn:SetNormalTexture(unpinNormal)
    unpinBtn:SetPushedTexture(unpinPushed)
    unpinBtn:SetHighlightTexture(unpinHighlight)
    unpinBtn._atlasNormal = "RedButton-Condense"
    unpinBtn._atlasPushed = "RedButton-Condense-Pressed"

    unpinBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:SetText(L["TIP_UNPIN_TRACKER"], 1, 1, 1)
        GameTooltip:Show()
    end)
    unpinBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    unpinBtn:SetScript("OnClick", function()
        Dispatch("SET_LUMBER_PINNED", { pinned = false })
        HDG_LumberTracker.HidePinned()
        if HDG_UI and HDG_UI.UpdateLumberLayout then
            HDG_UI.UpdateLumberLayout()
        end
        if HDG_ConfigTab and HDG_ConfigTab.UpdateLumberPinCheckbox then
            HDG_ConfigTab.UpdateLumberPinCheckbox()
        end
        print("|cFFfcd34d[Housing Decor Guide]|r Lumber tracker unpinned")
    end)
    pinnedFrame.unpinBtn = unpinBtn

    -- Expand button (+/-) - toggles main window
    local expandBtn = CreateHeaderButton(pinnedFrame, 24)  -- No static tooltip
    expandBtn:SetPoint("TOPRIGHT", unpinBtn, "TOPLEFT", -4, 0)
    local expandText = expandBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    expandText:SetPoint("CENTER", 0, 0)
    ApplyFont(expandText, scheme, "body")
    expandText:SetText("|cFFe2e8f0+|r")
    expandBtn.text = expandText

    -- Helper to check if main window is visible
    local function IsMainWindowVisible()
        return mainFrame and mainFrame:IsShown() or (HDG_UI and HDG_UI.IsVisible and HDG_UI.IsVisible())
    end

    -- Update button appearance based on main window state
    local function UpdateExpandButton()
        if IsMainWindowVisible() then
            expandText:SetText("|cFFe2e8f0-|r")
        else
            expandText:SetText("|cFFe2e8f0+|r")
        end
    end
    expandBtn.UpdateState = UpdateExpandButton

    -- Dynamic tooltip
    local function ShowExpandTooltip(btn)
        GameTooltip:SetOwner(btn, HDG_UIHelpers.TOOLTIP_ANCHOR)
        if IsMainWindowVisible() then
            GameTooltip:SetText(L["TIP_CLOSE_MAIN_WINDOW"] or "Close Main Window", 1, 1, 1)
        else
            GameTooltip:SetText(L["TIP_OPEN_MAIN_WINDOW"] or "Open Main Window", 1, 1, 1)
        end
        GameTooltip:Show()
    end

    expandBtn:SetScript("OnEnter", function(self)
        local hoverScheme = scheme
        if hoverScheme then
            self:SetBackdropColor(hoverScheme.button_hover.r, hoverScheme.button_hover.g, hoverScheme.button_hover.b, hoverScheme.button_hover.a)
        else
            self:SetBackdropColor(unpack(HDG_UIHelpers.BTN_HOVER))
        end
        ShowExpandTooltip(self)
    end)
    expandBtn:SetScript("OnClick", function(self)
        -- Use HDG_UI.Toggle which handles window creation if needed
        if HDG_UI and HDG_UI.Toggle then
            HDG_UI.Toggle()
        end
        -- Update button state after toggle
        C_Timer.After(0.05, function()
            UpdateExpandButton()
            -- Update tooltip if still hovering
            if GameTooltip:IsOwned(self) then
                ShowExpandTooltip(self)
            end
        end)
    end)
    pinnedFrame.expandBtn = expandBtn

    -- Radar toggle button (show/hide the HUD radar)
    local radarBtn = CreateFrame("Button", nil, pinnedFrame)
    radarBtn:SetSize(24, 24)
    radarBtn:SetPoint("TOPRIGHT", expandBtn, "TOPLEFT", -4, 0)
    local radarIcon = radarBtn:CreateTexture(nil, "ARTWORK")
    radarIcon:SetSize(16, 16)
    radarIcon:SetPoint("CENTER")
    radarIcon:SetAtlas("GarrMission_EncounterBar-CheckMarkCircleSwipe")
    radarBtn._icon = radarIcon

    local function UpdateRadarIcon()
        local hud = privateDB and privateDB.HDG_LumberHUD
        local visible = hud and hud.IsVisible and hud.IsVisible()
        radarIcon:SetAlpha(visible and 1 or 0.4)
    end
    radarBtn.UpdateIcon = UpdateRadarIcon

    radarBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        local hud = privateDB and privateDB.HDG_LumberHUD
        local visible = hud and hud.IsVisible and hud.IsVisible()
        if visible then
            GameTooltip:SetText(L["TIP_HIDE_RADAR"], 1, 1, 1)
            GameTooltip:AddLine(L["TIP_HIDE_RADAR_DESC"], 0.7, 0.7, 0.7)
        else
            GameTooltip:SetText(L["TIP_SHOW_RADAR"], 1, 1, 1)
            GameTooltip:AddLine(L["TIP_SHOW_RADAR_DESC"], 0.7, 0.7, 0.7)
        end
        GameTooltip:Show()
    end)
    radarBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    radarBtn:SetScript("OnClick", function()
        local hud = privateDB and privateDB.HDG_LumberHUD
        if hud and hud.Toggle then
            hud.Toggle()
        end
        UpdateRadarIcon()
    end)
    UpdateRadarIcon()
    pinnedFrame.radarBtn = radarBtn

    -- Collapse button - toggles between showing all lumber rows vs only active farming row
    local collapseBtn = CreateFrame("Button", nil, pinnedFrame)
    collapseBtn:SetSize(16, 16)
    collapseBtn:SetPoint("TOPRIGHT", radarBtn, "TOPLEFT", -4, -4)
    collapseBtn:SetNormalAtlas("common-button-collapseExpand-up")
    collapseBtn:SetPushedAtlas("common-button-collapseExpand-up")

    local function UpdateCollapseIcon()
        if not activeFarmingLumber then
            collapseBtn:Hide()
            return
        end
        local atlas = GetConfig("lumberCollapsed") and "common-button-collapseExpand-up" or "common-button-collapseExpand-down"
        collapseBtn:SetNormalAtlas(atlas)
        collapseBtn:SetPushedAtlas(atlas)
        collapseBtn:Show()
    end
    collapseBtn.UpdateIcon = UpdateCollapseIcon

    collapseBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        if GetConfig("lumberCollapsed") then
            GameTooltip:SetText(L["TIP_SHOW_ALL_LUMBER"], 1, 1, 1)
        else
            GameTooltip:SetText(L["TIP_SHOW_ONLY_ACTIVE"], 1, 1, 1)
        end
        GameTooltip:Show()
    end)

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

    collapseBtn:SetScript("OnClick", function()
        Dispatch("SET_CONFIG", { key = "lumberCollapsed", value = not GetConfig("lumberCollapsed") })
        UpdateCollapseIcon()
        HDG_LumberTracker.RefreshPinned()
    end)

    collapseBtn:Hide() -- hidden until farming starts
    pinnedFrame.collapseBtn = collapseBtn

    -- Static lumber section (not scrollable)
    local lumberPanel = CreateFrame("Frame", nil, pinnedFrame, "BackdropTemplate")
    lumberPanel:SetPoint("TOPLEFT", 6, -34)
    lumberPanel:SetPoint("TOPRIGHT", -6, -34)
    lumberPanel:SetHeight(GetLumberPanelHeight())
    lumberPanel:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        edgeFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        edgeSize = 1,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })
    lumberPanel:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, scheme.panel.a)
    lumberPanel:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, scheme.border.a)
    pinnedFrame.lumberPanel = lumberPanel

    -- Register with Theme Engine
    RegisterWidget(lumberPanel, "Panel")

    -- Column header row (Type, Needed, In Stock)
    local headerRow = CreateFrame("Frame", nil, lumberPanel)
    headerRow:SetPoint("TOPLEFT", 4, -2)
    headerRow:SetPoint("TOPRIGHT", -4, -2)
    headerRow:SetHeight(14)

    local headerScheme = scheme
    local headerColor = headerScheme and headerScheme.text_dim or {r=0.6, g=0.6, b=0.6, a=1}

    local typeHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    typeHeader:SetPoint("LEFT", 0, 0)
    typeHeader:SetWidth(CONFIG.LUMBER_COL_1)
    typeHeader:SetJustifyH("LEFT")
    ApplyFont(typeHeader, headerScheme, "body")
    typeHeader:SetText(L["COL_TYPE"])
    typeHeader:SetTextColor(headerColor.r, headerColor.g, headerColor.b, headerColor.a)

    local neededHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    neededHeader:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + 2, 0)
    neededHeader:SetWidth(CONFIG.LUMBER_COL_2)
    neededHeader:SetJustifyH("CENTER")
    ApplyFont(neededHeader, headerScheme, "body")
    neededHeader:SetText(L["LT_COL_QUEUE"])
    neededHeader:SetTextColor(headerColor.r, headerColor.g, headerColor.b, headerColor.a)

    local stockHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    stockHeader:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2 + 4, 0)
    stockHeader:SetWidth(CONFIG.LUMBER_COL_3)
    stockHeader:SetJustifyH("CENTER")
    ApplyFont(stockHeader, headerScheme, "body")
    stockHeader:SetText(L["LT_COL_OWNED"] or "Owned")
    stockHeader:SetTextColor(headerColor.r, headerColor.g, headerColor.b, headerColor.a)

    pinnedFrame.lumberHeaderRow = headerRow

    -- Lumber content area (static, no scroll) - positioned below header
    local lumberContent = CreateFrame("Frame", nil, lumberPanel)
    local lumberHeaderH = -(FW().LUMBER_HEADER_HEIGHT or 18)
    lumberContent:SetPoint("TOPLEFT", 4, lumberHeaderH)
    lumberContent:SetPoint("TOPRIGHT", -4, lumberHeaderH)
    lumberContent:SetHeight(GetLumberContentHeight())
    pinnedFrame.lumberContent = lumberContent

    pinnedFrame:Hide()  -- Start hidden
    return pinnedFrame
end

-- Show the pinned lumber widget
function HDG_LumberTracker.ShowPinned()
    if not pinnedFrame then
        HDG_LumberTracker.CreatePinnedFrame()
    end

    Dispatch("SET_LUMBER_PINNED", { pinned = true })
    pinnedFrame:Show()
    HDG_LumberTracker.RefreshPinned()
    local hud = privateDB and privateDB.HDG_LumberHUD
    if hud and hud.UpdateAnchor then
        hud.UpdateAnchor()
    end
end

-- Hide the pinned lumber widget
function HDG_LumberTracker.HidePinned()
    if pinnedFrame then
        pinnedFrame:Hide()
    end
    local hud = privateDB and privateDB.HDG_LumberHUD
    if hud and hud.UpdateAnchor then
        hud.UpdateAnchor()
    end
end

-- Toggle pinned lumber tracker visibility
function HDG_LumberTracker.TogglePinned()
    if HDG_LumberTracker.IsPinnedVisible() then
        Dispatch("SET_LUMBER_PINNED", { pinned = false })
        HDG_LumberTracker.HidePinned()
    else
        HDG_LumberTracker.ShowPinned()
    end
end

-- Check if pinned widget is visible
function HDG_LumberTracker.IsPinnedVisible()
    return pinnedFrame and pinnedFrame:IsShown()
end

-- Restore pinned state on login (called from HDG_UI after settings load)
function HDG_LumberTracker.RestorePinnedState()
    if GetUIState("lumberPinned") then
        HDG_LumberTracker.ShowPinned()
    end
end

-- ============================================================================
-- REFRESH FUNCTIONS
-- ============================================================================

-- Calculate non-lumber materials from crafting queue
local function CalculateNonLumberMaterials(queue)
    local materials = {}
    for _, item in ipairs(queue or {}) do
        if item.reagents then
            AccumulateDirectMaterials(materials, item)
        end
    end
    return materials
end

-- Calculate RAW non-lumber materials from crafting queue (uses HDG_PowerCrafting)
local function CalculateRawNonLumberMaterials(queue)
    local materials = {}

    for _, item in ipairs(queue or {}) do
        if item.reagents and HDG_PowerCrafting then
            -- Convert crafting queue reagents format to HDG_DecorDB format for Power Crafting
            local reagentsForGraph = {}
            for reagentKey, reagent in pairs(item.reagents) do
                local reagentID = reagent.id or reagentKey
                local reagentQty = reagent.amount or reagent.qty or 1
                reagentsForGraph[reagentID] = { name = reagent.name, qty = reagentQty }
            end

            -- Build a pseudo-recipe for the graph builder
            local pseudoRecipe = { reagents = reagentsForGraph }

            -- Build dependency graph to get raw materials
            local graph = HDG_PowerCrafting.BuildDependencyGraph(pseudoRecipe)
            if graph and graph.rawMaterials then
                for rawID, rawData in pairs(graph.rawMaterials) do
                    -- Skip lumber types
                    if not LUMBER_TYPES[rawID] then
                        if not materials[rawID] then
                            materials[rawID] = {
                                id = rawID,
                                name = rawData.name or "Unknown",
                                source = rawData.source or "Unknown",
                                expansion = item.expansion,
                                needed = 0,
                                have = Data().GetItemCountWithVariants(rawID)
                            }
                        end
                        materials[rawID].needed = materials[rawID].needed + (rawData.qty * (item.quantity or 1))
                    end
                end
            end
        elseif item.reagents then
            -- Fallback to direct materials if PowerCrafting not available
            AccumulateDirectMaterials(materials, item)
        end
    end

    return materials
end

function HDG_LumberTracker.GetNonLumberMaterials()
    local queue = GetCraftingQueue()
    if materialsCacheDirty then
        cachedNonLumberMaterials = CalculateNonLumberMaterials(queue)
        cachedRawNonLumberMaterials = CalculateRawNonLumberMaterials(queue)
        materialsCacheDirty = false
    end
    local direct = cachedNonLumberMaterials or {}
    local raw = cachedRawNonLumberMaterials or {}
    for id, mat in pairs(direct) do mat.have = Data().GetItemCountWithVariants(id) end
    for id, mat in pairs(raw) do mat.have = Data().GetItemCountWithVariants(id) end
    return direct, raw
end

-- ============================================================================
-- POOLED PINNED LUMBER ROWS (avoid frame churn on every refresh)
-- Rows are created once per lumber type and updated in-place.
-- ============================================================================

local function CreatePooledProgressBar(parent, scheme)
    local bar = CreateFrame("StatusBar", nil, parent, "BackdropTemplate")
    bar:SetStatusBarTexture(CONFIG.TEXTURES.STATUSBAR)
    local bg = bar:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(bar)
    bg:SetTexture(CONFIG.TEXTURES.STATUSBAR)
    bg:SetVertexColor(scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.7)
    bar._bg = bg
    local overlay = bar:CreateTexture(nil, "OVERLAY")
    overlay:SetAllPoints(bar)
    overlay:SetTexture(CONFIG.TEXTURES.STATUSBAR)
    overlay:SetVertexColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.15)
    overlay:SetDrawLayer("OVERLAY", 1)
    bar._overlay = overlay
    local text = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    text:SetPoint("CENTER", 0, 0)
    text:SetJustifyH("CENTER")
    text:SetDrawLayer("OVERLAY", 2)
    text:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
    HDG.Theme.ApplyFont(text, scheme, "small")
    text:SetWidth(76)
    text:SetWordWrap(false)
    bar._text = text
    return bar
end

local function UpdateProgressBar(bar, have, needed, xPos, yOffset)
    local maxVal = math.max(1, needed)
    bar:SetMinMaxValues(0, maxVal)
    bar:SetValue(math.min(have, maxVal))
    local pct = needed > 0 and (have / needed * 100) or 0
    bar:SetStatusBarColor(HDG_UIHelpers.GetProgressBarColor(pct))
    bar:ClearAllPoints()
    bar:SetPoint("LEFT", xPos, yOffset)
    if have >= needed and needed > 0 then
        local extra = have - needed
        bar._text:SetText(extra > 0 and string.format("%d/%d+%d", needed, needed, extra) or
            string.format("%d/%d", needed, needed))
    else
        bar._text:SetText(string.format("%d/%d", have, needed))
    end
    bar:Show()
end

local function CreatePinnedLumberRow(parent, lumberId, lumberInfo)
    local scheme = GetScheme()
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetBackdrop({
        bgFile = CONFIG.TEXTURES.WHITE_BG,
        tile = false,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })
    row:SetBackdropColor(unpack(HDG_UIHelpers.BG_TRANSPARENT))

    C_Item.RequestLoadItemDataByID(lumberId)

    -- Tooltip reads live data from row._td
    row:SetScript("OnEnter", function(self)
        local td = self._td or {}
        if not td.isActiveFarming then
            self:SetBackdropColor(scheme.button_hover.r, scheme.button_hover.g, scheme.button_hover.b, 0.6)
        end
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        local itemLink = select(2, C_Item.GetItemInfo(lumberId))
        if itemLink then GameTooltip:SetHyperlink(itemLink)
        else GameTooltip:SetText(lumberInfo.name or "Lumber", 1, 1, 1) end
        GameTooltip:AddLine(" ")
        local expColor = HDG_ExpansionData.GetColor(lumberInfo.expansion)
        GameTooltip:AddDoubleLine(L["TIP_EXPANSION_LABEL"], lumberInfo.expansion,
            1, 1, 1, expColor[1], expColor[2], expColor[3])
        GameTooltip:AddLine(" ")
        if td.queueData then
            GameTooltip:AddLine("|cFFfcd34d" .. L["HEADER_CRAFTING_QUEUE"] .. ":|r", 1, 1, 1)
            GameTooltip:AddDoubleLine("  " .. L["TIP_ACCOUNT_STOCK"],
                BreakUpLargeNumbers(td.queueData.have), 0.8, 0.8, 0.8, 1, 1, 1)
            if td.queueData.breakdown and #td.queueData.breakdown > 0 then
                for _, ci in ipairs(td.queueData.breakdown) do
                    local pfx = ci.isCurrent and "    * " or "    "
                    GameTooltip:AddDoubleLine(pfx .. ci.name .. ":",
                        BreakUpLargeNumbers(ci.count), 0.6, 0.6, 0.6, 0.8, 0.8, 0.8)
                end
            end
            GameTooltip:AddDoubleLine("  " .. L["TIP_NEEDED_LABEL"],
                BreakUpLargeNumbers(td.queueData.needed), 0.8, 0.8, 0.8, 1, 1, 1)
            if td.queueData.have >= td.queueData.needed then
                GameTooltip:AddLine("  |cFF88FF88" .. L["TIP_HAVE_ENOUGH_QUEUE"] .. "|r", 0.5, 1, 0.5)
            else
                local shortfall = td.queueData.needed - td.queueData.have
                GameTooltip:AddLine(string.format("  |cFFFF5555" .. L["MISC_NEED_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 td.allData then
            if td.allData.needed > 0 then
                GameTooltip:AddLine("|cFFfcd34d" .. L["TIP_UNCOLLECTED_DECOR_NEEDS"] .. "|r", 1, 1, 1)
                GameTooltip:AddDoubleLine("  " .. L["TIP_ACCOUNT_STOCK"],
                    BreakUpLargeNumbers(td.allData.have), 0.8, 0.8, 0.8, 1, 1, 1)
                if td.allData.breakdown and #td.allData.breakdown > 0 then
                    for _, ci in ipairs(td.allData.breakdown) do
                        local pfx = ci.isCurrent and "    * " or "    "
                        GameTooltip:AddDoubleLine(pfx .. ci.name .. ":",
                            BreakUpLargeNumbers(ci.count), 0.6, 0.6, 0.6, 0.8, 0.8, 0.8)
                    end
                end
                GameTooltip:AddDoubleLine("  " .. L["MISC_TOTAL_NEEDED"] .. ":",
                    BreakUpLargeNumbers(td.allData.needed), 0.8, 0.8, 0.8, 1, 1, 1)
                if td.allData.have >= td.allData.needed then
                    GameTooltip:AddLine("  |cFF88FF88" .. L["TIP_HAVE_ENOUGH_ALL"] .. "|r", 0.5, 1, 0.5)
                else
                    local shortfall = td.allData.needed - td.allData.have
                    GameTooltip:AddLine(string.format("  |cFFFF5555" .. L["MISC_NEED_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(td.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._td and self._td.isActiveFarming) then
            self:SetBackdropColor(unpack(HDG_UIHelpers.BG_TRANSPARENT))
        end
        GameTooltip:Hide()
    end)

    -- Column 1: Name
    row._nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row._nameText:SetWidth(CONFIG.LUMBER_COL_1)
    row._nameText:SetJustifyH("LEFT")
    row._nameText:SetWordWrap(false)
    HDG.Theme.ApplyFont(row._nameText, scheme, "small")

    -- Column 2: Queue bar + empty text (one shown at a time)
    row._queueBar = CreatePooledProgressBar(row, scheme)
    row._queueBar:SetSize(CONFIG.LUMBER_COL_2 - 4, 12)
    row._queueEmpty = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row._queueEmpty:SetWidth(CONFIG.LUMBER_COL_2)
    row._queueEmpty:SetJustifyH("CENTER")
    row._queueEmpty:SetText("-")
    row._queueEmpty:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
    HDG.Theme.ApplyFont(row._queueEmpty, scheme, "small")

    -- Column 3: All bar + stock text (one shown at a time)
    row._allBar = CreatePooledProgressBar(row, scheme)
    row._allBar:SetSize(CONFIG.LUMBER_COL_3 - 4, 12)
    row._stockText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row._stockText:SetWidth(CONFIG.LUMBER_COL_3)
    row._stockText:SetJustifyH("CENTER")
    HDG.Theme.ApplyFont(row._stockText, scheme, "small")

    -- Farming section (pre-created, hidden when not farming)
    row._statsText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row._statsText:SetPoint("LEFT", 4, 0)
    row._statsText:SetPoint("RIGHT", -4, 0)
    row._statsText:SetJustifyH("LEFT")
    HDG.Theme.ApplyFont(row._statsText, scheme, "small")
    row._statsText:Hide()

    row._zoneText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row._zoneText:SetJustifyH("LEFT")
    HDG.Theme.ApplyFont(row._zoneText, scheme, "small", 9)
    row._zoneText:Hide()

    row._achieveProgress = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row._achieveProgress:SetJustifyH("RIGHT")
    HDG.Theme.ApplyFont(row._achieveProgress, scheme, "small", 9)
    row._achieveProgress:Hide()

    -- Flipbook animation texture (reused)
    row._flipbook = row:CreateTexture(nil, "BACKGROUND")
    row._flipbook:SetSize(32, 28)
    row._flipbook:SetAtlas("housing-dashboard-fillbar-icon-fx-flipbook", false)
    row._flipbook:SetAlpha(0)
    row._flipbook:Hide()
    local ag = row._flipbook:CreateAnimationGroup()
    ag:SetLooping("NONE")
    local flip = ag:CreateAnimation("Flipbook")
    flip:SetDuration(1.0)
    flip:SetFlipBookColumns(5)
    flip:SetFlipBookRows(8)
    flip:SetFlipBookFrames(38)
    local fadeOut = ag:CreateAnimation("Alpha")
    fadeOut:SetStartDelay(0.7)
    fadeOut:SetDuration(0.3)
    fadeOut:SetFromAlpha(0.9)
    fadeOut:SetToAlpha(0)
    row._flipbookAnim = ag

    -- End session button (small red stop, inside the farming row bottom-right)
    row._endBtn = CreateFrame("Button", nil, row)
    row._endBtn:SetSize(14, 14)
    local endIcon = row._endBtn:CreateTexture(nil, "ARTWORK")
    endIcon:SetSize(12, 12)
    endIcon:SetPoint("CENTER")
    endIcon:SetTexture("Interface\\BUTTONS\\UI-StopButton")
    endIcon:SetVertexColor(0.9, 0.3, 0.3)
    row._endBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers.TOOLTIP_ANCHOR)
        GameTooltip:SetText("End Farming Session", 1, 0.3, 0.3)
        GameTooltip:AddLine("Stop tracking and show summary", 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    row._endBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    row._endBtn:SetScript("OnClick", function()
        if activeFarmingLumber then
            HandleEndSession(activeFarmingLumber)
        end
    end)
    row._endBtn:Hide()

    row._lumberId = lumberId
    return row
end

local function UpdatePinnedLumberRow(row, lumberId, lumberInfo, queueData, allData, yOffset, farmingOptions)
    local isActiveFarming = farmingOptions and farmingOptions.isActive
    local isJustCollected = farmingOptions and farmingOptions.isJustCollected
    local animPhase = farmingOptions and farmingOptions.animationPhase or 0
    local farmingStats = farmingOptions and farmingOptions.stats
    local rowHeight = isActiveFarming and 62 or 18
    local contentYOffset = isActiveFarming and 18 or 0
    local scheme = GetScheme()

    row:SetSize(CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2 + CONFIG.LUMBER_COL_3 + 4, rowHeight)
    row:ClearAllPoints()
    row:SetPoint("TOPLEFT", 0, yOffset)

    -- Tooltip data (read by OnEnter)
    row._td = { lumberId = lumberId, queueData = queueData, allData = allData, isActiveFarming = isActiveFarming }

    -- Active farming backdrop highlight
    if isActiveFarming then
        row:SetBackdropColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.3)
    else
        row:SetBackdropColor(unpack(HDG_UIHelpers.BG_TRANSPARENT))
    end

    -- Column 1: Name
    local expColor = HDG_ExpansionData.GetColor(lumberInfo.expansion)
    local displayName = HDG_ExpansionData.GetLumberMaterialName(lumberId) or lumberInfo.shortName or lumberInfo.name

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

    local nameFont = row._nameText:GetFont()
    if isJustCollected and animPhase == 1 then
        row._nameText:SetFont(nameFont, 14, "")
        row._nameText:SetText(string.format("|cFFFFFFFF%s|r%s", displayName, achSuffix))
    elseif isJustCollected and animPhase == 2 then
        row._nameText:SetFont(nameFont, 12, "")
        row._nameText:SetText(string.format("|cFFFFD700%s|r%s", displayName, achSuffix))
    elseif isActiveFarming then
        row._nameText:SetFont(nameFont, 11, "")
        row._nameText:SetText(string.format("|cFFfcd34d%s|r%s", displayName, achSuffix))
    else
        row._nameText:SetFont(nameFont, 11, "")
        row._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
    row._nameText:ClearAllPoints()
    row._nameText:SetPoint("LEFT", 0, contentYOffset)

    -- Column 2: Queue data
    if queueData then
        UpdateProgressBar(row._queueBar, queueData.have, queueData.needed,
            CONFIG.LUMBER_COL_1 + 2, contentYOffset)
        row._queueEmpty:Hide()
    else
        row._queueBar:Hide()
        row._queueEmpty:ClearAllPoints()
        row._queueEmpty:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + 2, contentYOffset)
        row._queueEmpty:Show()
    end

    -- Column 3: All recipes data
    if allData and allData.needed > 0 then
        UpdateProgressBar(row._allBar, allData.have, allData.needed,
            CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2 + 4, contentYOffset)
        row._stockText:Hide()
    else
        row._allBar:Hide()
        row._stockText:ClearAllPoints()
        row._stockText:SetPoint("LEFT", CONFIG.LUMBER_COL_1 + CONFIG.LUMBER_COL_2 + 4, contentYOffset)
        local stockCount = allData and allData.have or (Data().GetAccountWideLumberCount(lumberId) or 0)
        row._stockText:SetText(string.format("|cFF14b8a6%s|r", BreakUpLargeNumbers(stockCount)))
        row._stockText:Show()
    end

    -- Farming section
    if isActiveFarming and farmingStats then
        local rateStr = string.format("%d", math.floor(farmingStats.perHour or 0))
        local timeStr = FormatElapsedTime(farmingStats.elapsed or 0)
        row._statsText:SetFont(row._statsText:GetFont(), 11, "")
        row._statsText:SetText(string.format("|cFF88FF88+%d this session|r  |cFFe2e8f0Rate:|r |cFFfcd34d%s/hr|r  |cFF888888(%s)|r",
            farmingStats.collected or 0, rateStr, timeStr))
        row._statsText:Show()

        -- Flipbook animation on collection
        if isJustCollected and animPhase == 1 then
            row._flipbook:ClearAllPoints()
            row._flipbook:SetPoint("LEFT", row._statsText, "LEFT", -7, 0)
            row._flipbook:SetAlpha(0.9)
            row._flipbook:Show()
            row._flipbookAnim:Stop()
            row._flipbookAnim:Play()
        end

        local zoneName = GetZoneText() or "Unknown"
        row._zoneText:ClearAllPoints()
        row._zoneText:SetPoint("BOTTOMLEFT", 4, 6)
        row._zoneText:SetText(string.format("|cFFe2e8f0Zone:|r |cFF87CEEB%s|r", zoneName))
        row._zoneText:Show()

        -- End session button (bottom-right of farming row)
        row._endBtn:ClearAllPoints()
        row._endBtn:SetPoint("BOTTOMRIGHT", -4, 4)
        row._endBtn:Show()

        if not achDone and achQty and achReq then
            row._achieveProgress:ClearAllPoints()
            row._achieveProgress:SetPoint("RIGHT", row._endBtn, "LEFT", -4, 0)
            row._achieveProgress:SetText(string.format("|cFFe05252%d/%d|r", achQty, achReq))
            row._achieveProgress:Show()
        else
            row._achieveProgress:Hide()
        end
    else
        row._statsText:Hide()
        row._zoneText:Hide()
        row._achieveProgress:Hide()
        row._flipbook:Hide()
        row._endBtn:Hide()
    end

    row:Show()
    return yOffset - rowHeight
end

-- Render lumber rows into the pinned widget; returns lumberPanelHeight
local function RefreshPinnedLumberRows(queueLumber, allLumber, collapseMode)
    local expansionOrder = HDG_ExpansionData.LUMBER_ORDER
    local yOffset = 0

    -- Hide all pooled rows, then show/update visible ones
    for _, row in pairs(pinnedLumberRows) do
        row:Hide()
    end

    for _, lumberId in ipairs(expansionOrder) do
        if collapseMode and lumberId ~= activeFarmingLumber then
            -- Skip this lumber row
        else
            local lumberInfo = LUMBER_TYPES[lumberId]
            if lumberInfo then
                local row = pinnedLumberRows[lumberId]
                if not row then
                    row = CreatePinnedLumberRow(pinnedFrame.lumberContent, lumberId, lumberInfo)
                    pinnedLumberRows[lumberId] = row
                end

                local queueData = queueLumber[lumberId]
                local allData = allLumber[lumberId]
                local farmingStats = GetFarmingStats(lumberId)
                local farmingOptions = {
                    scheme = GetScheme(),
                    isActive = (activeFarmingLumber == lumberId),
                    isJustCollected = (justCollectedLumber == lumberId),
                    animationPhase = animationPhase,
                    stats = ((activeFarmingLumber == lumberId) or (justCollectedLumber == lumberId)) and farmingStats and {
                        collected = farmingStats.collected,
                        perHour = farmingStats.perHour,
                        elapsed = farmingStats.elapsed
                    } or nil
                }
                yOffset = UpdatePinnedLumberRow(row, lumberId, lumberInfo, queueData, allData, yOffset, farmingOptions)
            end
        end
    end

    local lumberContentHeight = math.abs(yOffset)
    local fw = FW()
    local headerH = collapseMode and 0 or (fw.LUMBER_HEADER_HEIGHT or 18)
    local lumberPanelHeight = headerH + lumberContentHeight + 4
    if pinnedFrame.lumberContent then
        pinnedFrame.lumberContent:SetHeight(lumberContentHeight)
        pinnedFrame.lumberContent:SetPoint("TOPLEFT", 4, collapseMode and -2 or -(fw.LUMBER_HEADER_HEIGHT or 18))
    end
    if pinnedFrame.lumberPanel then pinnedFrame.lumberPanel:SetHeight(lumberPanelHeight) end
    return lumberPanelHeight
end

-- Update footer buttons and frame height
local function RefreshPinnedFooter(lumberPanelHeight)
    if pinnedFrame.radarBtn and pinnedFrame.radarBtn.UpdateIcon then
        pinnedFrame.radarBtn.UpdateIcon()
    end
    if pinnedFrame.collapseBtn and pinnedFrame.collapseBtn.UpdateIcon then
        pinnedFrame.collapseBtn.UpdateIcon()
    end
    local fw = FW()
    pinnedFrame:SetHeight((fw.HEADER_HEIGHT or 34) + lumberPanelHeight + (fw.FOOTER_PADDING or 8))
end

-- Refresh the pinned lumber widget (orchestrator)
function HDG_LumberTracker.RefreshPinned()
    if not pinnedFrame or not pinnedFrame.lumberContent then return end

    -- Build/refresh caches
    if lumberNeedsDirty then
        cachedQueueLumber = HDG_LumberTracker.CalculateCraftingQueueLumber(GetCraftingQueue())
        cachedAllLumber = HDG_LumberTracker.CalculateLumberNeeds(privateDB.professions or {})
        lumberNeedsDirty = false
    end
    -- Update only the "have" counts — single pass to deduplicate GetAccountWideLumberCount calls
    local lumberCounts = {}
    for lumberId in pairs(cachedQueueLumber) do
        if not lumberCounts[lumberId] then
            local accountTotal, breakdown = Data().GetAccountWideLumberCount(lumberId)
            lumberCounts[lumberId] = { total = accountTotal, breakdown = breakdown }
        end
    end
    for lumberId in pairs(cachedAllLumber) do
        if not lumberCounts[lumberId] then
            local accountTotal, breakdown = Data().GetAccountWideLumberCount(lumberId)
            lumberCounts[lumberId] = { total = accountTotal, breakdown = breakdown }
        end
    end
    for lumberId, data in pairs(cachedQueueLumber) do
        local cached = lumberCounts[lumberId]
        data.have = cached.total
        data.breakdown = cached.breakdown
    end
    for lumberId, data in pairs(cachedAllLumber) do
        local cached = lumberCounts[lumberId]
        data.have = cached.total
        data.breakdown = cached.breakdown
    end

    -- Build snapshot for change detection
    local currentState = {}
    local stateIdx = 0
    for lumberId, data in pairs(cachedQueueLumber) do
        stateIdx = stateIdx + 1; currentState[stateIdx] = lumberId
        stateIdx = stateIdx + 1; currentState[stateIdx] = data.have
        stateIdx = stateIdx + 1; currentState[stateIdx] = data.needed
    end
    for lumberId, data in pairs(cachedAllLumber) do
        stateIdx = stateIdx + 1; currentState[stateIdx] = lumberId
        stateIdx = stateIdx + 1; currentState[stateIdx] = data.have
        stateIdx = stateIdx + 1; currentState[stateIdx] = data.needed
    end
    stateIdx = stateIdx + 1; currentState[stateIdx] = GetConfig("lumberCollapsed") and 1 or 0
    stateIdx = stateIdx + 1; currentState[stateIdx] = activeFarmingLumber or 0
    stateIdx = stateIdx + 1; currentState[stateIdx] = justCollectedLumber or 0
    stateIdx = stateIdx + 1; currentState[stateIdx] = animationPhase
    stateIdx = stateIdx + 1; currentState[stateIdx] = GetZoneText() or "" -- zone change detection
    if activeFarmingLumber and sessionFarmingStats[activeFarmingLumber] then
        local st = sessionFarmingStats[activeFarmingLumber]
        stateIdx = stateIdx + 1; currentState[stateIdx] = math.floor(GetTime() - st.startTime)
        stateIdx = stateIdx + 1; currentState[stateIdx] = st.collected
    end

    -- Skip UI rebuild if nothing visually changed
    if pinnedDisplayState then
        local unchanged = (#currentState == #pinnedDisplayState)
        if unchanged then
            for i = 1, #currentState do
                if currentState[i] ~= pinnedDisplayState[i] then
                    unchanged = false
                    break
                end
            end
        end
        if unchanged then return end
    end
    pinnedDisplayState = currentState

    -- Render
    local isFarming = (activeFarmingLumber ~= nil)
    local collapseMode = GetConfig("lumberCollapsed") and isFarming
    if pinnedFrame.lumberHeaderRow then pinnedFrame.lumberHeaderRow:SetShown(not collapseMode) end
    local lumberPanelHeight = RefreshPinnedLumberRows(cachedQueueLumber, cachedAllLumber, collapseMode)
    RefreshPinnedFooter(lumberPanelHeight)
end

-- ============================================================================
-- FRAME ACCESSORS
-- ============================================================================

function HDG_LumberTracker.GetPinnedFrame()
    return pinnedFrame
end

-- ============================================================================
-- GATHERING TRACKER API
-- ============================================================================

--- Add a reagent to tracked list
-- @param itemID The item ID to track
-- @param name Optional item name (will be looked up if not provided)
-- @param targetQty Optional target quantity (defaults to crafting queue need or 0)
function HDG_LumberTracker.TrackReagent(itemID, name, targetQty)
    if not itemID then return false end

    -- Don't track lumber (already tracked separately)
    if LUMBER_TYPES[itemID] then
        print("|cFFfcd34d[Housing Decor Guide]|r " .. L["LT_ALREADY_LUMBER"])
        return false
    end

    -- Check if already tracked
    local trackedReagents = GetTrackedReagents()
    if trackedReagents[itemID] then
        print("|cFFfcd34d[Housing Decor Guide]|r " .. L["LT_ALREADY_TRACKING"])
        return false
    end

    -- Get item name if not provided
    local itemName = name
    if not itemName then
        itemName = Data().GetCachedItemName and Data().GetCachedItemName(itemID) or "Unknown"
    end

    -- Add to tracked list via Store
    Dispatch("ADD_TRACKED_REAGENT", {
        itemID = itemID,
        name = itemName,
        targetQty = targetQty or 0,
        addedTime = time()
    })

    print("|cFFfcd34d[Housing Decor Guide]|r " .. string.format(L["LT_NOW_TRACKING"], itemName))

    -- Refresh pinned widget if visible
    if HDG_LumberTracker.IsPinnedVisible() then
        HDG_LumberTracker.RefreshPinned()
    end

    -- Refresh Materials List if open
    if HDG_MaterialsList and HDG_MaterialsList.Refresh then
        HDG_MaterialsList.Refresh()
    end

    return true
end

--- Remove a reagent from tracked list
-- @param itemID The item ID to stop tracking
function HDG_LumberTracker.UntrackReagent(itemID)
    if not itemID then return false end

    local trackedReagents = GetTrackedReagents()
    local tracked = trackedReagents[itemID]
    if not tracked then return false end

    local itemName = tracked.name or "Unknown"
    Dispatch("REMOVE_TRACKED_REAGENT", { itemID = itemID })

    print("|cFFfcd34d[Housing Decor Guide]|r " .. string.format(L["LT_STOPPED_TRACKING"], itemName))

    -- Refresh pinned widget if visible
    if HDG_LumberTracker.IsPinnedVisible() then
        HDG_LumberTracker.RefreshPinned()
    end

    -- Refresh Materials List if open
    if HDG_MaterialsList and HDG_MaterialsList.Refresh then
        HDG_MaterialsList.Refresh()
    end

    return true
end

--- Check if a reagent is being tracked
-- @param itemID The item ID to check
-- @return boolean
function HDG_LumberTracker.IsReagentTracked(itemID)
    if not itemID then return false end
    return GetTrackedReagents()[itemID] ~= nil
end

--- Get all tracked reagents
-- @return table of tracked reagents
function HDG_LumberTracker.GetAllTrackedReagents()
    return GetTrackedReagents()
end

--- Update target quantity for a tracked reagent
-- @param itemID The item ID
-- @param targetQty The new target quantity
function HDG_LumberTracker.UpdateTrackedTarget(itemID, targetQty)
    if not itemID then return false end
    local trackedReagents = GetTrackedReagents()
    if not trackedReagents[itemID] then return false end

    Dispatch("UPDATE_TRACKED_REAGENT", { itemID = itemID, targetQty = targetQty or 0 })

    -- Refresh pinned widget if visible
    if HDG_LumberTracker.IsPinnedVisible() then
        HDG_LumberTracker.RefreshPinned()
    end

    return true
end

-- Recalculate lumber needs when collection data arrives (async catalog search)
-- Without this, CalculateLumberNeeds runs before IsItemCollected works, counting ALL recipes as uncollected
if HDG and HDG.EventBus then
    HDG.EventBus:Register("HDG_COLLECTION_READY", function()
        HDG_LumberTracker.InvalidateLumberNeeds()
        if HDG_LumberTracker.IsPinnedVisible() then
            HDG_LumberTracker.RefreshPinned()
        end
        if HDG_UI and HDG_UI.RefreshLumberTracking then
            HDG_UI.RefreshLumberTracking()
        end
    end)
end
