-- HousingDecorGuide/UI/Tabs/HDG_MogulTab.lua
-- Mogul tab: best craft for gold or collection given lumber budget.

local _, privateDB = ...
local L = HDG_L or {}
local HDG = _G.HousingDecorGuide

HDG_MogulTab = HDG_MogulTab or {}

local mainFrame    -- set by Init
local mogulFrame   -- top-level panel for tab content
local headerFrame  -- plain table of header widget references (not a WoW Frame)
local planListFrame -- table: { listHost, scrollBox, scrollBar, scrollView, summaryTitle, summaryLine1, summaryLine2 }
local matsPanelFrame -- table: { host, title, sendBtn, rows, currentSources }
local actionRowFrame -- table: { container } (buttons moved to section headers + mats title)

local function GetScheme() return HDG.Theme and HDG.Theme.GetCurrentScheme and HDG.Theme.GetCurrentScheme() end

local function GetMogulState()
    local state = HDG.Store:GetState()
    return state and state.mogul or {}
end

-- Per-type lumber budgets pulled from live LumberTracker counts. Each housing
-- recipe binds to a specific lumber tier so the planner needs the breakdown,
-- not a single fungible total. v1 always reads live counts; the editable
-- override from the original spec was incoherent without per-type input and
-- is deferred to v1.5.
local function GetLumberBudgets()
    if HDG_LumberTracker and HDG_LumberTracker.GetLumberCounts then
        return HDG_LumberTracker.GetLumberCounts()
    end
    return {}
end

local function SumBudgets(budgets)
    local s = 0
    for _, n in pairs(budgets) do s = s + n end
    return s
end

-- Gold formatter -- prefers Auctionator/TSM via PriceIntegration, falls back to a
-- bare "%dg" so the tab still renders without a price addon installed.
local function FmtGold(c)
    if HDG_PriceIntegration and HDG_PriceIntegration.FormatGoldOnly then
        return HDG_PriceIntegration.FormatGoldOnly(c)
    end
    return string.format("%dg", math.floor((c or 0) / 10000))
end

function HDG_MogulTab.Init(frame)
    mainFrame = frame
end

-- Toggle-style button using Blizzard's UIPanelButtonTemplate (matches the
-- action-row buttons). Auto-sizes width to text + 24px chrome padding so
-- callers don't need per-locale tuning. Active state: highlight locked + gold
-- text. Inactive: standard look.
local function CreateModeBtn(parent, text)
    local btn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    btn:SetText(text or "")
    local fs = btn:GetFontString()
    local textW = (fs and fs:GetStringWidth()) or 60
    btn:SetSize(math.max(40, math.ceil(textW) + 24), 22)
    function btn:SetActive(isActive)
        local fstr = self:GetFontString()
        if isActive then
            self:LockHighlight()
            if fstr then fstr:SetTextColor(1.0, 0.82, 0.0) end
        else
            self:UnlockHighlight()
            if fstr then fstr:SetTextColor(1, 1, 1) end
        end
    end
    return btn
end

-- ============================================================================
-- HEADER: two-row controls (mode / view / optimize / budget)
-- ============================================================================

local function DispatchAndRefresh(action, payload)
    if HDG and HDG.Store then HDG.Store:Dispatch(action, payload) end
    HDG_MogulTab.Refresh()
end

local function BuildHeader(parent)
    if headerFrame then return end  -- idempotent
    headerFrame = {}

    local container = CreateFrame("Frame", nil, parent)
    container:SetPoint("TOPLEFT",  parent, "TOPLEFT",   8, -8)
    container:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -8, -8)
    container:SetHeight(56)
    headerFrame.container = container

    -- Divider under the toolbar rows: 1px hairline that separates the control
    -- band (mode/view/optimize/algo) from the data band (supply impact +
    -- summary). Anchored to container bottom so it follows any future row
    -- additions without manual y-tuning.
    local divider = parent:CreateTexture(nil, "OVERLAY")
    divider:SetPoint("TOPLEFT",  container, "BOTTOMLEFT",  0, -2)
    divider:SetPoint("TOPRIGHT", container, "BOTTOMRIGHT", 0, -2)
    divider:SetHeight(1)
    divider:SetColorTexture(0.4, 0.4, 0.4, 0.5)
    headerFrame.divider = divider

    -- Row 1: Mode toggles then View toggles
    local row1 = CreateFrame("Frame", nil, container)
    row1:SetPoint("TOPLEFT",  container, "TOPLEFT",  0, 0)
    row1:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 0)
    row1:SetHeight(24)

    local modeProfit = CreateModeBtn(row1, L["MOGUL_MODE_PROFIT"] or "Profit", false)
    modeProfit:SetPoint("LEFT", row1, "LEFT", 0, 0)
    modeProfit:SetScript("OnClick", function() DispatchAndRefresh("MOGUL_SET_MODE", { mode = "profit" }) end)
    headerFrame.modeProfit = modeProfit

    local modeCollection = CreateModeBtn(row1, L["MOGUL_MODE_COLLECTION"] or "Collection", false)
    modeCollection:SetPoint("LEFT", modeProfit, "RIGHT", 4, 0)
    modeCollection:SetScript("OnClick", function() DispatchAndRefresh("MOGUL_SET_MODE", { mode = "collection" }) end)
    headerFrame.modeCollection = modeCollection

    -- Divider gap then View buttons
    local viewChar = CreateModeBtn(row1, L["MOGUL_VIEW_CHAR"] or "Active char", false)
    viewChar:SetPoint("LEFT", modeCollection, "RIGHT", 12, 0)
    viewChar:SetScript("OnClick", function() DispatchAndRefresh("MOGUL_SET_VIEW", { viewMode = "char" }) end)
    headerFrame.viewChar = viewChar

    local viewAcct = CreateModeBtn(row1, L["MOGUL_VIEW_ACCOUNT"] or "Account", false)
    viewAcct:SetPoint("LEFT", viewChar, "RIGHT", 4, 0)
    viewAcct:SetScript("OnClick", function() DispatchAndRefresh("MOGUL_SET_VIEW", { viewMode = "account" }) end)
    headerFrame.viewAcct = viewAcct

    -- Row 2: Optimize toggles + lumber budget editbox
    local row2 = CreateFrame("Frame", nil, container)
    row2:SetPoint("TOPLEFT",  container, "TOPLEFT",  0, -28)
    row2:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -28)
    row2:SetHeight(24)

    -- Single pill toggle (matches Greedy/LP, Active char/Account visual
    -- language). Active = "I'll buy mats from AH" => optimizeBy = "lumberPlusMats"
    -- (rank by net profit). Inactive = "I have my own mats" => optimizeBy =
    -- "lumberOnly" (rank by gross revenue, ignoring mat cost). Click toggles.
    local optBuyMatsCB = CreateModeBtn(row2, L["MOGUL_OPT_BUY_MATS"] or "I'll buy mats from AH", false)
    optBuyMatsCB:SetPoint("LEFT", row2, "LEFT", 0, 0)
    optBuyMatsCB:SetScript("OnClick", function()
        local cur = (GetMogulState().optimizeBy) or "lumberOnly"
        local newMode = (cur == "lumberPlusMats") and "lumberOnly" or "lumberPlusMats"
        DispatchAndRefresh("MOGUL_SET_OPTIMIZE_BY", { optimizeBy = newMode })
    end)
    optBuyMatsCB:HookScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, HDG_UIHelpers and HDG_UIHelpers.TOOLTIP_ANCHOR or "ANCHOR_RIGHT")
        GameTooltip:SetText(L["MOGUL_OPT_BUY_MATS"] or "I'll buy mats from AH", 1, 1, 1)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Inactive: \"Spend my lumber, don't make me think about mats.\" Plan ranks recipes by gross revenue per lumber, ignoring mat costs.", 0.85, 0.85, 0.85, true)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Active: \"Give me the most actual gold, including the AH cost of mats I don't already own.\" Plan ranks by net profit per lumber.", 0.85, 0.85, 0.85, true)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Owned mats are always free in the spend math. The toggle only changes which recipes the optimizer picks -- the Revenue and Net profit numbers in the summary are computed the same way either mode.", 0.7, 0.7, 0.7, true)
        GameTooltip:Show()
    end)
    optBuyMatsCB:HookScript("OnLeave", function() GameTooltip:Hide() end)
    headerFrame.optBuyMatsCB = optBuyMatsCB

    -- Algorithm toggle: greedy (default) vs LP relaxation. Side-by-side for A/B
    -- comparison; the LP module is loaded separately and exposed via the engine
    -- dispatcher. Tooltips explain the trade-offs since the difference only
    -- shows up on multi-tier or shared-mat instances.
    local function AddAlgoTooltip(button, title, lines)
        button:HookScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, HDG_UIHelpers and HDG_UIHelpers.TOOLTIP_ANCHOR or "ANCHOR_RIGHT")
            GameTooltip:SetText(title, 1, 1, 1)
            for _, line in ipairs(lines) do
                GameTooltip:AddLine(line, 0.85, 0.85, 0.85, true)
            end
            GameTooltip:Show()
        end)
        button:HookScript("OnLeave", function() GameTooltip:Hide() end)
    end

    -- "Planner:" prefix label so the Greedy/LP pair reads as a single labelled
    -- group instead of floating buttons. Matches how the tooltip already names
    -- them ("Greedy planner" / "LP planner").
    local plannerLabel = row2:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    plannerLabel:SetPoint("LEFT", optBuyMatsCB, "RIGHT", 12, 0)
    plannerLabel:SetText(L["MOGUL_PLANNER_LABEL"] or "Planner:")
    plannerLabel:SetTextColor(1, 1, 1)
    headerFrame.plannerLabel = plannerLabel

    local algoGreedy = CreateModeBtn(row2, L["MOGUL_ALGO_GREEDY"] or "Greedy", false)
    algoGreedy:SetPoint("LEFT", plannerLabel, "RIGHT", 6, 0)
    algoGreedy:SetScript("OnClick", function() DispatchAndRefresh("MOGUL_SET_ALGORITHM", { algorithm = "greedy" }) end)
    AddAlgoTooltip(algoGreedy, "Greedy planner", {
        "Default planner. Picks recipes one at a time by best profit-per-lumber, re-evaluating after each craft as owned mats deplete.",
        "Fast and near-optimal for typical single-tier recipe sets. Can underperform on multi-tier or shared-mat scenarios where LP solves better.",
    })
    headerFrame.algoGreedy = algoGreedy

    local algoLP = CreateModeBtn(row2, L["MOGUL_ALGO_LP"] or "LP", false)
    algoLP:SetPoint("LEFT", algoGreedy, "RIGHT", 4, 0)
    algoLP:SetScript("OnClick", function() DispatchAndRefresh("MOGUL_SET_ALGORITHM", { algorithm = "lp" }) end)
    AddAlgoTooltip(algoLP, "LP planner", {
        "Linear-programming relaxation: solves a continuous version of the per-tier lumber budget then rounds to integer crafts.",
        "Equivalent to Greedy on single-tier scenarios. Can produce a better mix when recipes span multiple lumber tiers or share mats. Always solve sub-millisecond on the housing recipe size.",
    })
    -- When LP is disabled (Supply impact = Smooth), append an explanatory line
    -- to its tooltip. AddAlgoTooltip's hook fires first; this hook fires after
    -- and adds context about why LP isn't selectable right now.
    algoLP:HookScript("OnEnter", function(self)
        if not self:IsEnabled() then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Disabled while Supply impact = Smooth. Smooth's per-unit decay is non-linear, so the planner falls back to Greedy. Switch to Off or Cap to use LP.", 1, 0.5, 0.3, true)
            GameTooltip:Show()
        end
    end)
    headerFrame.algoLP = algoLP

end

-- ============================================================================
-- LUMBER PANEL: per-tier budget table (top-right of Mogul frame)
--
-- Two columns of "<tier name (expansion-colored)>  <leftover>/<budget>" rows,
-- one entry per lumber tier with budget > 0. Replaces the single-scalar
-- "Lumber: N" readout because the sum hides the fungibility illusion -- a
-- player can have 39 leftover lumber that's stranded across multiple tiers
-- and unusable. Surfacing per-tier residual makes that constraint legible.
-- ============================================================================

local lumberPanelFrame  -- table { host, rows = { tierID -> {nameText, numText} } }
local LUMBER_PANEL_ROW_HEIGHT = 16
local LUMBER_PANEL_COL_WIDTH  = 195

local LUMBER_PANEL_PAD_X = 10
local LUMBER_PANEL_PAD_Y = 8
local LUMBER_PANEL_INTER_COL_GAP = 10

local function BuildLumberPanel(parent)
    if lumberPanelFrame then return end
    -- Borderless panel: visible background tint, no edge chrome.
    local outer = CreateFrame("Frame", nil, parent)
    outer:SetSize(
        LUMBER_PANEL_COL_WIDTH * 2 + LUMBER_PANEL_INTER_COL_GAP + LUMBER_PANEL_PAD_X * 2,
        80)
    outer:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -8, -6)
    local bg = outer:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.10, 0.10, 0.13, 0.85)

    -- Match the column-header border: 1px edge on all four sides.
    local edgeColor = { 0.4, 0.4, 0.4, 0.6 }
    local function makeEdge(point1, point2, isHorizontal)
        local tex = outer:CreateTexture(nil, "OVERLAY")
        tex:SetPoint(point1, outer, point1, 0, 0)
        tex:SetPoint(point2, outer, point2, 0, 0)
        if isHorizontal then tex:SetHeight(1) else tex:SetWidth(1) end
        tex:SetColorTexture(edgeColor[1], edgeColor[2], edgeColor[3], edgeColor[4])
        return tex
    end
    makeEdge("TOPLEFT",     "TOPRIGHT",    true)   -- top
    makeEdge("BOTTOMLEFT",  "BOTTOMRIGHT", true)   -- bottom
    makeEdge("TOPLEFT",     "BOTTOMLEFT",  false)  -- left
    makeEdge("TOPRIGHT",    "BOTTOMRIGHT", false)  -- right

    -- Inner host: the row layout area, padded inside the outer chrome.
    local host = CreateFrame("Frame", nil, outer)
    host:SetPoint("TOPLEFT",     outer, "TOPLEFT",      LUMBER_PANEL_PAD_X, -LUMBER_PANEL_PAD_Y)
    host:SetPoint("BOTTOMRIGHT", outer, "BOTTOMRIGHT", -LUMBER_PANEL_PAD_X,  LUMBER_PANEL_PAD_Y)

    lumberPanelFrame = { outer = outer, host = host, rows = {} }
end

local function GetOrCreateLumberRow(host, tierID)
    if lumberPanelFrame.rows[tierID] then return lumberPanelFrame.rows[tierID] end
    local row = CreateFrame("Frame", nil, host)
    row:SetHeight(LUMBER_PANEL_ROW_HEIGHT)
    row:SetWidth(LUMBER_PANEL_COL_WIDTH)
    row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.nameText:SetPoint("LEFT",  row, "LEFT",  0, 0)
    row.nameText:SetWidth(82)
    row.nameText:SetJustifyH("LEFT")
    row.nameText:SetWordWrap(false)

    -- Three-cell number layout so arrows align across rows regardless of
    -- digit count. Arrow has a fixed X position; sub-text grows leftward
    -- (right-justified), rem-text grows rightward (left-justified). Wrap
    -- disabled on all three so a too-wide value (e.g. "443 - 440") clips
    -- instead of wrapping to a second line.
    row.arrowText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.arrowText:SetSize(16, 16)
    row.arrowText:SetJustifyH("CENTER")
    row.arrowText:SetWordWrap(false)
    row.arrowText:SetPoint("CENTER", row, "RIGHT", -42, 0)

    row.subText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.subText:SetWidth(76)
    row.subText:SetJustifyH("RIGHT")
    row.subText:SetWordWrap(false)
    row.subText:SetPoint("RIGHT", row.arrowText, "LEFT", -2, 0)

    row.remText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.remText:SetWidth(34)
    row.remText:SetJustifyH("LEFT")
    row.remText:SetWordWrap(false)
    row.remText:SetPoint("LEFT", row.arrowText, "RIGHT", 2, 0)

    local s = GetScheme()
    if s then
        HDG.Theme.ApplyFont(row.nameText,  s, "body", 12)
        HDG.Theme.ApplyFont(row.subText,   s, "body", 12)
        HDG.Theme.ApplyFont(row.arrowText, s, "body", 12)
        HDG.Theme.ApplyFont(row.remText,   s, "body", 12)
    end
    lumberPanelFrame.rows[tierID] = row
    return row
end

-- Compute per-tier consumption from a plan's rows. Returns map { tierID -> qty used }.
local function ComputeLumberUsed(plan)
    local used = {}
    if not (plan and plan.rows) then return used end
    for _, row in ipairs(plan.rows) do
        local lr = row.score and row.score.lumberReagents
        if lr then
            for _, l in ipairs(lr) do
                used[l.id] = (used[l.id] or 0) + row.crafts * l.qty
            end
        end
    end
    return used
end

function HDG_MogulTab.RenderLumberPanel(lumberBudgets, plan)
    if not lumberPanelFrame then return end

    -- Hide all rows up front (we'll re-show only tiers with budget > 0).
    for _, row in pairs(lumberPanelFrame.rows) do row:Hide() end

    local used = ComputeLumberUsed(plan)
    local order = (HDG_ExpansionData and HDG_ExpansionData.LUMBER_ORDER) or {}
    local tiers = {}
    -- Show every lumber tier (including 0-budget ones) so the full progression
    -- is always visible -- helps players recall what tier comes from where.
    for _, tierID in ipairs(order) do
        local budget = lumberBudgets[tierID] or 0
        tiers[#tiers + 1] = { id = tierID, budget = budget, used = used[tierID] or 0 }
    end

    -- Distribute into two columns: first half left column, second half right.
    -- Even split rounds up so left column is always >= right.
    local total = #tiers
    local leftCount = math.ceil(total / 2)
    for i, tier in ipairs(tiers) do
        local col = (i <= leftCount) and 0 or 1
        local rowIdx = (i <= leftCount) and (i - 1) or (i - leftCount - 1)
        local row = GetOrCreateLumberRow(lumberPanelFrame.host, tier.id)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT",
            lumberPanelFrame.host,
            "TOPLEFT",
            col * (LUMBER_PANEL_COL_WIDTH + LUMBER_PANEL_INTER_COL_GAP),
            -rowIdx * LUMBER_PANEL_ROW_HEIGHT)
        row:SetWidth(LUMBER_PANEL_COL_WIDTH)

        local info = HDG_ExpansionData and HDG_ExpansionData.GetLumberInfo
                     and HDG_ExpansionData.GetLumberInfo(tier.id)
        local shortName = (info and info.shortName) or ("Tier " .. tostring(tier.id))
        local expColor = info and info.expansion
                         and HDG_ExpansionData.GetColor and HDG_ExpansionData.GetColor(info.expansion)

        row.nameText:SetText(shortName)
        if expColor then
            row.nameText:SetTextColor(expColor[1], expColor[2], expColor[3])
        else
            row.nameText:SetTextColor(0.85, 0.85, 0.85)
        end

        local leftover = tier.budget - tier.used
        -- Layout: [<have> - <used>] [arrow] [<remaining>] -- arrow at fixed X
        -- so the arrows line up across rows regardless of digit count.
        row.subText:SetText(string.format("%d - %d", tier.budget, tier.used))
        row.arrowText:SetText("|A:services-yellowarrow:14:14|a")
        row.remText:SetText(tostring(leftover))

        local function colorAll(r, g, b)
            row.subText:SetTextColor(r, g, b)
            row.arrowText:SetTextColor(r, g, b)
            row.remText:SetTextColor(r, g, b)
        end

        -- Numbers: green if the plan touched this tier, grey otherwise. Names
        -- keep their expansion colour regardless (set above).
        row.nameText:SetAlpha(1.0)
        if tier.used > 0 then
            colorAll(0.40, 0.85, 0.40)
        else
            colorAll(0.55, 0.55, 0.55)
        end

        row:Show()
    end

    -- Resize the outer chrome to wrap the rows + padding (host's height is
    -- anchor-bound to outer, not set directly).
    local contentH = math.max(leftCount, 1) * LUMBER_PANEL_ROW_HEIGHT
    lumberPanelFrame.outer:SetHeight(contentH + LUMBER_PANEL_PAD_Y * 2)
end

-- Update header widget states to reflect current Store values.
function HDG_MogulTab.RenderHeader(lumberBudgets)
    if not headerFrame then return end
    local m = GetMogulState()
    local mode       = m.mode or "profit"
    local viewMode   = m.viewMode or "char"
    local optimizeBy = m.optimizeBy or "lumberOnly"
    local algorithm  = m.algorithm or "greedy"

    headerFrame.modeProfit:SetActive(mode == "profit")
    headerFrame.modeCollection:SetActive(mode == "collection")

    headerFrame.viewChar:SetActive(viewMode == "char")
    headerFrame.viewAcct:SetActive(viewMode == "account")

    -- Optimize row: hide in collection mode (collection has no optimize axis;
    -- LP also has no advantage over greedy there, so the algo pair is hidden too).
    local showOpt = (mode == "profit")
    if headerFrame.optBuyMatsCB then headerFrame.optBuyMatsCB:SetShown(showOpt) end
    if headerFrame.plannerLabel then headerFrame.plannerLabel:SetShown(showOpt) end
    if headerFrame.algoGreedy   then headerFrame.algoGreedy:SetShown(showOpt)   end
    if headerFrame.algoLP       then headerFrame.algoLP:SetShown(showOpt)       end
    if showOpt then
        headerFrame.optBuyMatsCB:SetActive(optimizeBy == "lumberPlusMats")
        -- Smooth supply-impact forces greedy regardless of saved algorithm pref
        -- (per-unit decay isn't linear). Disable the LP button, paint Greedy as
        -- the effective algo so the highlight matches what's actually running.
        -- The user's saved algorithm preference is preserved -- when they leave
        -- Smooth (Off / Cap), the highlight returns to their pick.
        local supplyMode = (m.supplyImpact and m.supplyImpact.mode) or "smooth"
        local lpDisabled = (supplyMode == "smooth")
        if lpDisabled then
            headerFrame.algoGreedy:SetActive(true)
            headerFrame.algoLP:SetActive(false)
            headerFrame.algoLP:Disable()
        else
            headerFrame.algoGreedy:SetActive(algorithm == "greedy")
            headerFrame.algoLP:SetActive(algorithm == "lp")
            headerFrame.algoLP:Enable()
        end
    end

    -- Per-tier breakdown lives in the LumberPanel (top-right of the frame),
    -- replacing the old single-scalar "Lumber: N" readout.
end

-- ============================================================================
-- PLAN LIST: summary banner + WowScrollBoxList rows
-- ============================================================================

local function BuildPlanList(parent)
    if planListFrame then return end  -- idempotent

    -- Summary banner: anchored below the header. Right edge constrained to
    -- leave room for the lumber panel (top-right of the frame), so the panels
    -- sit side-by-side instead of one pushing the other down.
    -- Summary block sits below the header divider (header bottom -64, divider
    -- -65/-66, summary starts -72). Height widened from 44 -> 60 so the supply
    -- impact row sits at the top and the Revenue line sits at the bottom (just
    -- above the column header strip), with breathing room between them.
    local summaryContainer = CreateFrame("Frame", nil, parent)
    summaryContainer:SetPoint("TOPLEFT",  parent, "TOPLEFT",   8, -72)
    summaryContainer:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -410, -72)
    summaryContainer:SetHeight(60)

    -- Supply-impact row replaces the BEST PLAN header. Three radios (Off / Smooth /
    -- Cap) using the same atlas pattern as the Direct/Auc/TSM selector in the
    -- Goblin tab; the active mode's value is editable inline. Smooth applies a
    -- per-unit decay to the optimizer's marginal-value calculation; Cap clips at
    -- a fixed per-recipe ceiling. Off restores the original "stack the best
    -- recipe" behavior. See HDG_Mogul.lua scorer for the engine side.
    local supplyRow = CreateFrame("Frame", nil, summaryContainer)
    supplyRow:SetPoint("TOPLEFT",  summaryContainer, "TOPLEFT",  0, 0)
    supplyRow:SetPoint("TOPRIGHT", summaryContainer, "TOPRIGHT", 0, 0)
    supplyRow:SetHeight(16)

    local supplyLabel = supplyRow:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    supplyLabel:SetPoint("LEFT", supplyRow, "LEFT", 0, 0)
    supplyLabel:SetText(L["MOGUL_SI_HEADER"] or "Supply impact:")

    local RADIO_SIZE = 12
    local supplyDefs = {
        { key = "off",    label = L["MOGUL_SI_OFF"]    or "Off",
          tipTitle = "Supply impact: Off",
          tipDesc  = "Optimizer stacks the highest gold-per-lumber recipe until lumber runs out. Best for one-shot personal crafting or markets you intend to flood." },
        { key = "smooth", label = L["MOGUL_SI_SMOOTH"] or "Smooth:",
          tipTitle = "Supply impact: Smooth",
          tipDesc  = "Each successive craft of the same recipe is worth less in the ranking, so the plan diversifies. The percentage is the per-unit haircut. 0% = Off behaviour. 7% means unit 10 is worth ~52% of unit 1." },
        { key = "cap",    label = L["MOGUL_SI_CAP"]    or "Cap:",
          tipTitle = "Supply impact: Cap",
          tipDesc  = "Hard ceiling per recipe. Optimizer never plans more than N of any single recipe; freed lumber redistributes to runners-up. Set this to whatever your realm comfortably absorbs in a few days." },
    }

    local supplyButtons = {}
    local supplyEdits = {}
    local UpdateSupplyImpact -- forward declaration

    -- Fixed slot widths so layout stays stable regardless of which mode is
    -- active. Each radio's button frame holds the circle + dot + label only;
    -- the inline EditBox is parented to supplyRow so it doesn't intercept
    -- clicks meant for the next radio.
    local SLOT_GAP_AFTER_RADIO_NO_EDIT = 14   -- after the Off radio
    local SLOT_GAP_AFTER_EDIT          = 12   -- after smooth%, after cap value
    local EDIT_WIDTH                   = 32
    local SUFFIX_WIDTH                 = 12   -- "%" suffix budget

    local function MakeRadio(parent, def)
        local radio = CreateFrame("Button", nil, parent)
        radio.circle = radio:CreateTexture(nil, "ARTWORK")
        radio.circle:SetAtlas("common-radiobutton-circle")
        radio.circle:SetSize(RADIO_SIZE, RADIO_SIZE)
        radio.circle:SetPoint("LEFT", radio, "LEFT", 0, 0)

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

        radio.label = radio:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        radio.label:SetPoint("LEFT", radio.circle, "RIGHT", 4, 0)
        radio.label:SetText(def.label)

        -- Size the radio frame to fit just the circle + label so the EditBox can
        -- anchor cleanly to the right of the label without overlapping the hit area.
        radio:SetScript("OnShow", function(self)
            local textW = self.label:GetStringWidth() or 30
            self:SetSize(RADIO_SIZE + 4 + math.ceil(textW), 16)
        end)

        radio:SetScript("OnClick", function()
            DispatchAndRefresh("MOGUL_SET_SUPPLY_IMPACT", { mode = def.key })
        end)
        if HDG_UIHelpers and HDG_UIHelpers.SetupTooltip then
            HDG_UIHelpers.SetupTooltip(radio, {
                title  = def.tipTitle,
                desc   = def.tipDesc,
                anchor = "ANCHOR_RIGHT",
            })
        end
        radio.modeKey = def.key
        return radio
    end

    -- Off radio: anchored after the header label.
    local offRadio = MakeRadio(supplyRow, supplyDefs[1])
    offRadio:SetSize(RADIO_SIZE + 4 + 22, 16)  -- pre-size; OnShow will tighten
    offRadio:SetPoint("LEFT", supplyLabel, "RIGHT", 14, 0)
    supplyButtons.off = offRadio

    -- Smooth radio + inline % EditBox + "%" suffix.
    local smoothRadio = MakeRadio(supplyRow, supplyDefs[2])
    smoothRadio:SetSize(RADIO_SIZE + 4 + 50, 16)
    smoothRadio:SetPoint("LEFT", offRadio, "RIGHT", SLOT_GAP_AFTER_RADIO_NO_EDIT, 0)
    supplyButtons.smooth = smoothRadio

    local smoothEdit = CreateFrame("EditBox", nil, supplyRow, "InputBoxTemplate")
    smoothEdit:SetSize(EDIT_WIDTH, 16)
    smoothEdit:SetPoint("LEFT", smoothRadio, "RIGHT", 4, 0)
    smoothEdit:SetAutoFocus(false)
    smoothEdit:SetMaxLetters(4)
    smoothEdit:SetJustifyH("CENTER")
    smoothEdit:SetFontObject("GameFontNormal")
    supplyEdits.smooth = smoothEdit

    local smoothSuffix = supplyRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    smoothSuffix:SetPoint("LEFT", smoothEdit, "RIGHT", 1, 0)
    smoothSuffix:SetWidth(SUFFIX_WIDTH)
    smoothSuffix:SetText("%")
    smoothEdit.suffix = smoothSuffix

    -- Apply smooth-decay changes 0.5s after the user stops typing (debounced).
    -- The Ticker is cancelled on every keystroke; Enter cancels and applies
    -- immediately. userInput guard skips SetText echoes from UpdateSupplyImpact.
    local function ApplySmooth(v)
        if v < 0 then v = 0 elseif v > 99 then v = 99 end
        DispatchAndRefresh("MOGUL_SET_SUPPLY_IMPACT", { decay = v / 100 })
    end
    smoothEdit:SetScript("OnTextChanged", function(self, userInput)
        if not userInput then return end
        local v = tonumber(self:GetText() or "")
        if not v then return end
        if self.debounceTimer then self.debounceTimer:Cancel() end
        self.debounceTimer = C_Timer.NewTimer(0.5, function()
            self.debounceTimer = nil
            ApplySmooth(v)
        end)
    end)
    smoothEdit:SetScript("OnEnterPressed", function(self)
        if self.debounceTimer then self.debounceTimer:Cancel(); self.debounceTimer = nil end
        local v = tonumber(self:GetText() or "")
        self:ClearFocus()
        if v then ApplySmooth(v) else UpdateSupplyImpact() end
    end)
    smoothEdit:SetScript("OnEscapePressed", function(self)
        if self.debounceTimer then self.debounceTimer:Cancel(); self.debounceTimer = nil end
        self:ClearFocus(); UpdateSupplyImpact()
    end)
    smoothEdit:SetScript("OnEditFocusLost", function(self)
        if self.debounceTimer then self.debounceTimer:Cancel(); self.debounceTimer = nil end
        local v = tonumber(self:GetText() or "")
        if v then ApplySmooth(v) else UpdateSupplyImpact() end
    end)

    -- Cap radio + inline integer EditBox.
    local capRadio = MakeRadio(supplyRow, supplyDefs[3])
    capRadio:SetSize(RADIO_SIZE + 4 + 30, 16)
    capRadio:SetPoint("LEFT", smoothSuffix, "RIGHT", SLOT_GAP_AFTER_EDIT, 0)
    supplyButtons.cap = capRadio

    local capEdit = CreateFrame("EditBox", nil, supplyRow, "InputBoxTemplate")
    capEdit:SetSize(EDIT_WIDTH, 16)
    capEdit:SetPoint("LEFT", capRadio, "RIGHT", 4, 0)
    capEdit:SetAutoFocus(false)
    capEdit:SetNumeric(true)
    capEdit:SetMaxLetters(4)
    capEdit:SetJustifyH("CENTER")
    capEdit:SetFontObject("GameFontNormal")
    supplyEdits.cap = capEdit

    local function ApplyCap(v)
        if v < 1 then v = 1 elseif v > 9999 then v = 9999 end
        DispatchAndRefresh("MOGUL_SET_SUPPLY_IMPACT", { cap = math.floor(v) })
    end
    capEdit:SetScript("OnTextChanged", function(self, userInput)
        if not userInput then return end
        local v = tonumber(self:GetText() or "")
        if not v then return end
        if self.debounceTimer then self.debounceTimer:Cancel() end
        self.debounceTimer = C_Timer.NewTimer(0.5, function()
            self.debounceTimer = nil
            ApplyCap(v)
        end)
    end)
    capEdit:SetScript("OnEnterPressed", function(self)
        if self.debounceTimer then self.debounceTimer:Cancel(); self.debounceTimer = nil end
        local v = tonumber(self:GetText() or "")
        self:ClearFocus()
        if v then ApplyCap(v) else UpdateSupplyImpact() end
    end)
    capEdit:SetScript("OnEscapePressed", function(self)
        if self.debounceTimer then self.debounceTimer:Cancel(); self.debounceTimer = nil end
        self:ClearFocus(); UpdateSupplyImpact()
    end)
    capEdit:SetScript("OnEditFocusLost", function(self)
        if self.debounceTimer then self.debounceTimer:Cancel(); self.debounceTimer = nil end
        local v = tonumber(self:GetText() or "")
        if v then ApplyCap(v) else UpdateSupplyImpact() end
    end)

    local SUPPLY_HINTS = {
        off    = L["MOGUL_SI_HINT_OFF"]    or "Stacks the highest-margin recipe until lumber runs out. Best for solo crafting or markets you intend to flood.",
        smooth = L["MOGUL_SI_HINT_SMOOTH"] or "Each repeat craft of the same recipe is worth slightly less in the ranking, so the plan diversifies on its own.",
        cap    = L["MOGUL_SI_HINT_CAP"]    or "Hard ceiling per recipe. Freed lumber redistributes to runners-up. Set this to what your realm absorbs in a few days.",
    }
    UpdateSupplyImpact = function()
        local m = GetMogulState()
        local si = m.supplyImpact or {}
        local mode = m.mode or "profit"
        -- Collection mode ignores supply-impact entirely (BuildCollectionPlan
        -- doesn't read it). Paint the radios as if Off is active so the UI
        -- doesn't lie about what the planner is doing -- but don't mutate the
        -- saved state, so the user's profit-mode preference returns when they
        -- toggle back to Profit.
        local active = (mode == "collection") and "off" or (si.mode or "smooth")
        local toggleScheme = GetScheme()
        if not toggleScheme then return end

        -- Mode swap: in profit mode the supply-impact row + small hint render;
        -- in collection mode they hide and a title-styled "Best use of lumber
        -- for uncollected, crafted decor." takes their place. Reach FontStrings
        -- through supplyEdits (captured upvalue) -- the local declarations
        -- below this closure aren't visible at parse time.
        local hintFS    = supplyEdits and supplyEdits.hint
        local titleFS   = supplyEdits and supplyEdits.collectionTitle
        local isCollection = (mode == "collection")
        if hintFS then
            hintFS:SetShown(not isCollection)
            if not isCollection then
                hintFS:SetText(SUPPLY_HINTS[active] or "")
            end
        end
        if titleFS then
            titleFS:SetShown(isCollection)
            local s = GetScheme()
            if s and HDG and HDG.Theme and HDG.Theme.ApplyFont then
                HDG.Theme.ApplyFont(titleFS, s, "title", 13)
            end
        end
        supplyRow:SetShown(not isCollection)

        for _, def in ipairs(supplyDefs) do
            local r = supplyButtons[def.key]
            local isActive = (active == def.key)
            if isActive then
                r.dot:Show()
                r.circle:SetVertexColor(toggleScheme.accent.r, toggleScheme.accent.g, toggleScheme.accent.b)
                r.dot:SetVertexColor(toggleScheme.accent.r, toggleScheme.accent.g, toggleScheme.accent.b)
                r.label:SetTextColor(toggleScheme.text.r, toggleScheme.text.g, toggleScheme.text.b)
            else
                r.dot:Hide()
                r.circle:SetVertexColor(toggleScheme.text_dim.r, toggleScheme.text_dim.g, toggleScheme.text_dim.b)
                r.label:SetTextColor(toggleScheme.text_dim.r, toggleScheme.text_dim.g, toggleScheme.text_dim.b)
            end
        end

        -- Smooth EditBox: always visible (so the player can see the value), but
        -- only editable when smooth is the active mode. Inactive = dim.
        -- Skip SetText while focused so we don't yank characters out from under
        -- the user mid-type (debounced apply will round-trip through state once
        -- typing settles).
        local smoothActive = (active == "smooth")
        if not smoothEdit:HasFocus() then
            smoothEdit:SetText(string.format("%d", math.floor((si.decay or 0.07) * 100 + 0.5)))
        end
        smoothEdit:EnableKeyboard(smoothActive)
        smoothEdit:EnableMouse(smoothActive)
        smoothEdit:SetTextColor(
            smoothActive and 1 or toggleScheme.text_dim.r,
            smoothActive and 1 or toggleScheme.text_dim.g,
            smoothActive and 1 or toggleScheme.text_dim.b)
        smoothSuffix:SetTextColor(
            smoothActive and 1 or toggleScheme.text_dim.r,
            smoothActive and 1 or toggleScheme.text_dim.g,
            smoothActive and 1 or toggleScheme.text_dim.b)

        local capActive = (active == "cap")
        if not capEdit:HasFocus() then
            capEdit:SetText(tostring(si.cap or 10))
        end
        capEdit:EnableKeyboard(capActive)
        capEdit:EnableMouse(capActive)
        capEdit:SetTextColor(
            capActive and 1 or toggleScheme.text_dim.r,
            capActive and 1 or toggleScheme.text_dim.g,
            capActive and 1 or toggleScheme.text_dim.b)
    end

    -- Short explanation line under the supply impact row (profit mode only).
    -- Re-painted in UpdateSupplyImpact based on active mode (Off / Smooth /
    -- Cap). Smaller + dimmer font so it reads as a hint, not a stat line.
    local supplyHint = summaryContainer:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    supplyHint:SetPoint("TOPLEFT", supplyRow, "BOTTOMLEFT", 0, -2)
    supplyHint:SetPoint("RIGHT",   summaryContainer, "RIGHT", 0, 0)
    supplyHint:SetJustifyH("LEFT")
    supplyHint:SetWordWrap(false)
    supplyEdits.hint = supplyHint  -- stash on the same table the updater reads

    -- Collection-mode title sits where the supply impact row sits in profit
    -- mode. Same vertical position so the layout doesn't reflow on toggle.
    -- Title-styled (matches "Supply impact:" weight) so it reads as a header,
    -- not body text.
    local collectionTitle = summaryContainer:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    collectionTitle:SetPoint("TOPLEFT", summaryContainer, "TOPLEFT", 0, 0)
    collectionTitle:SetPoint("RIGHT",   summaryContainer, "RIGHT",   0, 0)
    collectionTitle:SetJustifyH("LEFT")
    collectionTitle:SetWordWrap(false)
    collectionTitle:SetText(L["MOGUL_COLLECTION_HINT"]
        or "Best use of lumber for uncollected, crafted decor.")
    collectionTitle:Hide()
    supplyEdits.collectionTitle = collectionTitle

    -- Revenue + Net profit anchored to the BOTTOM of the summary block so the
    -- line sits just above the column header strip (visual prelude to the
    -- recipe table). summaryLine2 is unused in current modes (kept for layout
    -- symmetry / future use) and parks above line1.
    local summaryLine1 = summaryContainer:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    summaryLine1:SetPoint("BOTTOMLEFT", summaryContainer, "BOTTOMLEFT", 0, 2)
    summaryLine1:SetPoint("RIGHT",      summaryContainer, "RIGHT",       0, 0)
    summaryLine1:SetJustifyH("LEFT")

    local summaryLine2 = summaryContainer:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    summaryLine2:SetPoint("BOTTOMLEFT", summaryLine1, "TOPLEFT", 0, 2)
    summaryLine2:SetPoint("RIGHT",      summaryContainer, "RIGHT", 0, 0)
    summaryLine2:SetJustifyH("LEFT")

    local s = GetScheme()
    if s then
        HDG.Theme.ApplyFont(supplyLabel, s, "title", 13)
        HDG.Theme.ApplyFont(summaryLine1, s, "body",  11)
        HDG.Theme.ApplyFont(summaryLine2, s, "body",  11)
    end

    -- Two-frame header layout:
    --   * `headerStrip` holds the visible chrome (bg + border) and matches the
    --     listHost outer width (parent-inset 8/-8) so the header band reads as
    --     the same width as the bordered scroll panel directly below.
    --   * `headerContent` is a transparent inner frame matching the *scrollBox
    --     content* rectangle (4/-18 inside listHost) so the column FontStrings
    --     line up with the row columns underneath, even though the visible
    --     border extends past them on both sides.
    local headerStrip = CreateFrame("Frame", nil, parent)
    headerStrip:SetPoint("TOPLEFT",  parent, "TOPLEFT",   8, -138)
    headerStrip:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -8, -138)
    headerStrip:SetHeight(16)

    local headerContent = CreateFrame("Frame", nil, headerStrip)
    headerContent:SetPoint("TOPLEFT",     headerStrip, "TOPLEFT",      4, 0)
    headerContent:SetPoint("BOTTOMRIGHT", headerStrip, "BOTTOMRIGHT", -18, 0)

    -- Dark backdrop for the header strip so it reads as a column-label band.
    local headerBg = headerStrip:CreateTexture(nil, "BACKGROUND")
    headerBg:SetAllPoints()
    headerBg:SetColorTexture(0.10, 0.10, 0.13, 0.85)

    local function MakeHeaderCell(parentFrame, anchor)
        local fs = parentFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        local s = GetScheme()
        -- Use the same `title` font category as the BEST PLAN heading so the
        -- column header band feels like the same UI level, just smaller.
        if s then HDG.Theme.ApplyFont(fs, s, "title", 11) end
        fs:SetTextColor(0.85, 0.85, 0.85)
        fs:SetPoint(anchor.point, anchor.relTo or parentFrame, anchor.relPoint, anchor.x, anchor.y)
        if anchor.point2 then
            fs:SetPoint(anchor.point2, anchor.relTo2 or parentFrame, anchor.relPoint2, anchor.x2, anchor.y2)
        end
        if anchor.width then fs:SetWidth(anchor.width) end
        if anchor.justify then fs:SetJustifyH(anchor.justify) end
        return fs
    end

    -- Cells anchor to headerContent (matches scrollBox content edges), so the
    -- visible border extends beyond the cells on both sides without misaligning
    -- column labels relative to row columns.
    local headerExp    = MakeHeaderCell(headerContent, { point="RIGHT", relPoint="RIGHT", x=-6, y=0, width=32, justify="RIGHT" })
    headerExp:SetText("Exp")
    local headerProfit = MakeHeaderCell(headerContent, { point="RIGHT", relTo=headerExp, relPoint="LEFT", x=-8, y=0, width=150, justify="RIGHT" })
    headerProfit:SetText("Revenue / Net")
    local headerGPerL  = MakeHeaderCell(headerContent, { point="RIGHT", relTo=headerProfit, relPoint="LEFT", x=-8, y=0, width=130, justify="RIGHT" })
    headerGPerL:SetText("gold/|A:Lumber_Tracking:14:14|a")
    local headerLumber = MakeHeaderCell(headerContent, { point="RIGHT", relTo=headerGPerL, relPoint="LEFT", x=-8, y=0, width=56, justify="RIGHT" })
    headerLumber:SetText("|A:Lumber_Tracking:14:14|a/each")
    local headerCrafts = MakeHeaderCell(headerContent, { point="LEFT",  relPoint="LEFT", x=20, y=0, width=34, justify="LEFT" })
    headerCrafts:SetText("Qty")
    local headerChar   = MakeHeaderCell(headerContent, { point="LEFT",  relTo=headerCrafts, relPoint="RIGHT", x=4, y=0, width=72, justify="LEFT" })
    headerChar:SetText("Crafter")
    local headerName   = MakeHeaderCell(headerContent, {
        point="LEFT",   relTo=headerChar,   relPoint="RIGHT", x=6, y=0,
        point2="RIGHT", relTo2=headerLumber, relPoint2="LEFT", x2=-8, y2=0,
        justify="LEFT",
    })
    headerName:SetText("Recipe")

    -- 1px border around the header strip (top + left + right + bottom).
    local edgeColor = { 0.4, 0.4, 0.4, 0.6 }
    local edgeTop = headerStrip:CreateTexture(nil, "OVERLAY")
    edgeTop:SetPoint("TOPLEFT",  headerStrip, "TOPLEFT",  0, 0)
    edgeTop:SetPoint("TOPRIGHT", headerStrip, "TOPRIGHT", 0, 0)
    edgeTop:SetHeight(1)
    edgeTop:SetColorTexture(edgeColor[1], edgeColor[2], edgeColor[3], edgeColor[4])

    local edgeLeft = headerStrip:CreateTexture(nil, "OVERLAY")
    edgeLeft:SetPoint("TOPLEFT",    headerStrip, "TOPLEFT",    0, 0)
    edgeLeft:SetPoint("BOTTOMLEFT", headerStrip, "BOTTOMLEFT", 0, 0)
    edgeLeft:SetWidth(1)
    edgeLeft:SetColorTexture(edgeColor[1], edgeColor[2], edgeColor[3], edgeColor[4])

    local edgeRight = headerStrip:CreateTexture(nil, "OVERLAY")
    edgeRight:SetPoint("TOPRIGHT",    headerStrip, "TOPRIGHT",    0, 0)
    edgeRight:SetPoint("BOTTOMRIGHT", headerStrip, "BOTTOMRIGHT", 0, 0)
    edgeRight:SetWidth(1)
    edgeRight:SetColorTexture(edgeColor[1], edgeColor[2], edgeColor[3], edgeColor[4])

    local edgeBottom = headerStrip:CreateTexture(nil, "OVERLAY")
    edgeBottom:SetPoint("BOTTOMLEFT",  headerStrip, "BOTTOMLEFT",  0, 0)
    edgeBottom:SetPoint("BOTTOMRIGHT", headerStrip, "BOTTOMRIGHT", 0, 0)
    edgeBottom:SetHeight(1)
    edgeBottom:SetColorTexture(edgeColor[1], edgeColor[2], edgeColor[3], edgeColor[4])

    -- List host: anchored below header, bottom reserved for mats panel + action row.
    local listHost = CreateFrame("Frame", nil, parent, "InsetFrameTemplate")
    listHost:SetPoint("TOPLEFT",     parent, "TOPLEFT",     8,  -156)
    listHost:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -8,  200)

    local scrollBox = CreateFrame("Frame", nil, listHost, "WowScrollBoxList")
    scrollBox:SetPoint("TOPLEFT",     listHost, "TOPLEFT",     4, -4)
    scrollBox:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -18, 4)

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

    local view = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    view:SetElementExtent(22)

    view:SetElementInitializer("Button", function(frame, elementData)
        -- First-time setup (idempotent guard). All scripts register once;
        -- per-bind we just re-stamp data and re-color. No per-scroll closures.
        if not frame._mogulInit then
            frame._mogulInit = true
            frame:SetHeight(22)

            -- Full-frame bg texture for expansion-color tint.
            frame.bg = frame:CreateTexture(nil, "BACKGROUND")
            frame.bg:SetAllPoints()

            -- Collected/uncollected indicator (12x12, left edge).
            frame.collectedIcon = frame:CreateTexture(nil, "OVERLAY")
            frame.collectedIcon:SetSize(12, 12)
            frame.collectedIcon:SetPoint("LEFT", frame, "LEFT", 4, 0)

            -- Columnar layout: each cell has fixed width, anchored from edges
            -- so they line up regardless of recipe-name length. Right side
            -- columns anchor inward from the expansion tag.
            -- Right edge: exp tag.
            frame.expText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.expText:SetPoint("RIGHT", frame, "RIGHT", -6, 0)
            frame.expText:SetWidth(32)
            frame.expText:SetJustifyH("RIGHT")

            -- Profit column (totals for planned, per-craft for runners-up).
            frame.profitText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.profitText:SetPoint("RIGHT", frame.expText, "LEFT", -8, 0)
            frame.profitText:SetWidth(150)
            frame.profitText:SetJustifyH("RIGHT")

            -- g/L column (constant per recipe, displayed as rev/L / net/L).
            frame.gPerLText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.gPerLText:SetPoint("RIGHT", frame.profitText, "LEFT", -8, 0)
            frame.gPerLText:SetWidth(130)
            frame.gPerLText:SetJustifyH("RIGHT")

            -- Lumber/ea column.
            frame.lumberText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.lumberText:SetPoint("RIGHT", frame.gPerLText, "LEFT", -8, 0)
            frame.lumberText:SetWidth(56)
            frame.lumberText:SetJustifyH("RIGHT")

            -- Left columns: crafts, char, recipe name. Anchored from LEFT side
            -- with name flexing into the gap between char and lumber.
            frame.craftsText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.craftsText:SetPoint("LEFT", frame, "LEFT", 20, 0)
            frame.craftsText:SetWidth(34)
            frame.craftsText:SetJustifyH("LEFT")

            frame.charText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.charText:SetPoint("LEFT", frame.craftsText, "RIGHT", 4, 0)
            frame.charText:SetWidth(72)
            frame.charText:SetJustifyH("LEFT")
            frame.charText:SetWordWrap(false)

            frame.nameText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.nameText:SetPoint("LEFT",  frame.charText,   "RIGHT", 6, 0)
            frame.nameText:SetPoint("RIGHT", frame.lumberText, "LEFT", -8, 0)
            frame.nameText:SetJustifyH("LEFT")
            frame.nameText:SetWordWrap(false)

            local fs = GetScheme()
            if fs then
                for _, fstr in ipairs({
                    frame.craftsText, frame.charText, frame.nameText,
                    frame.lumberText, frame.gPerLText, frame.profitText,
                    frame.expText,
                }) do
                    HDG.Theme.ApplyFont(fstr, fs, "small", 10)
                end
            end
            -- Section-header text (used when elementData.section is set;
            -- spans the full row, hides the column cells).
            frame.sectionText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            frame.sectionText:SetPoint("LEFT",  frame, "LEFT",  10, 0)
            frame.sectionText:SetPoint("RIGHT", frame, "RIGHT", -8, 0)
            frame.sectionText:SetJustifyH("LEFT")
            frame.sectionText:Hide()
            local sfs = GetScheme()
            if sfs then HDG.Theme.ApplyFont(frame.sectionText, sfs, "title", 11) end

            -- Section underline -- thin full-width line at the bottom of section
            -- rows, doubles as the visual separator the user asked for.
            frame.sectionUnderline = frame:CreateTexture(nil, "OVERLAY")
            frame.sectionUnderline:SetPoint("BOTTOMLEFT",  frame, "BOTTOMLEFT",  0, 0)
            frame.sectionUnderline:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)
            frame.sectionUnderline:SetHeight(1)
            frame.sectionUnderline:Hide()

            -- Vertical accent bar on the left of plan rows -- visual cue that
            -- this row is part of the active plan (vs runner-up rows, which
            -- are dimmed and skip the bar).
            frame.planAccent = frame:CreateTexture(nil, "OVERLAY")
            frame.planAccent:SetPoint("TOPLEFT",    frame, "TOPLEFT",    0, 0)
            frame.planAccent:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
            frame.planAccent:SetWidth(3)
            frame.planAccent:Hide()

            -- Section action buttons -- one per section type, both children
            -- of the section frame. Visibility/text switched in the section
            -- binder based on elementData.kind. Frame:RegisterForClicks pass-
            -- through is fine because the buttons sit on top with their own
            -- mouse handling.
            frame.sectionSendPlanBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
            frame.sectionSendPlanBtn:SetSize(200, 16)
            frame.sectionSendPlanBtn:SetPoint("RIGHT", frame, "RIGHT", -8, 0)
            frame.sectionSendPlanBtn:SetText(L["MOGUL_BTN_SEND_PLAN"] or "Send entire plan to crafting queue")
            frame.sectionSendPlanBtn:SetNormalFontObject("GameFontNormalSmall")
            frame.sectionSendPlanBtn:SetHighlightFontObject("GameFontHighlightSmall")
            frame.sectionSendPlanBtn:SetDisabledFontObject("GameFontDisableSmall")
            frame.sectionSendPlanBtn:SetScript("OnClick", function()
                if HDG_MogulTab.SendPlanToQueue then HDG_MogulTab.SendPlanToQueue() end
            end)
            frame.sectionSendPlanBtn:Hide()

            frame.sectionCandidatesBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
            frame.sectionCandidatesBtn:SetSize(130, 16)
            frame.sectionCandidatesBtn:SetPoint("RIGHT", frame, "RIGHT", -8, 0)
            frame.sectionCandidatesBtn:SetNormalFontObject("GameFontNormalSmall")
            frame.sectionCandidatesBtn:SetHighlightFontObject("GameFontHighlightSmall")
            frame.sectionCandidatesBtn:SetDisabledFontObject("GameFontDisableSmall")
            frame.sectionCandidatesBtn:SetScript("OnClick", function()
                local Store = HDG and HDG.Store
                if Store and Store.Dispatch then
                    local current = Store.state and Store.state.mogul
                                    and Store.state.mogul.showCandidates
                    Store:Dispatch("MOGUL_SET_SHOW_CANDIDATES",
                        { showCandidates = not current })
                end
                if HDG_MogulTab.RenderPlanList and HDG_MogulTab.lastPlan then
                    HDG_MogulTab.RenderPlanList(HDG_MogulTab.lastPlan)
                end
            end)
            frame.sectionCandidatesBtn:Hide()

            HDG.Theme.RegisterWidget(frame, "Frame")
            frame:RegisterForClicks("LeftButtonUp")
            frame:SetScript("OnMouseDown", function(self)
                if self._isSection then return end
                HDG_MogulTab.OnRowSelected(self._row)
            end)
            frame:SetScript("OnEnter", function(self)
                if self._isSection then return end
                local r = self._row and self._row.recipe
                if not r or not r.itemID then return end
                GameTooltip:SetOwner(self, HDG_UIHelpers and HDG_UIHelpers.TOOLTIP_ANCHOR or "ANCHOR_RIGHT")
                GameTooltip:SetItemByID(r.itemID)
                GameTooltip:AddLine(" ")
                if r.bestAlt then
                    GameTooltip:AddLine(
                        HDG.Theme.C_Warning() .. (L["QUEUE_KNOWN_BY"] or "Known by:") .. "|r " .. r.bestAlt,
                        1, 1, 1)
                    GameTooltip:AddLine(" ")
                end
                if r.reagents and #r.reagents > 0 then
                    GameTooltip:AddLine(HDG.Theme.C_Text() .. (L["HEADER_MATERIALS"] or "Materials") .. ":|r")
                    for _, reagent in ipairs(r.reagents) do
                        local have = (HDG_Data and HDG_Data.GetItemCountWithVariants
                                      and HDG_Data.GetItemCountWithVariants(reagent.id)) or 0
                        local need = reagent.qty or 1
                        local nm = (HDG_Data and HDG_Data.GetLocalizedName
                                    and HDG_Data.GetLocalizedName(reagent.id, reagent.name))
                                   or reagent.name or ("Item " .. reagent.id)
                        local color = have >= need and HDG.Theme.C_Success()
                                      or (have > 0 and HDG.Theme.C_Warning() or HDG.Theme.C_Error())
                        GameTooltip:AddDoubleLine(
                            "  " .. nm,
                            string.format("%s%d/%d|r", color, have, need),
                            1, 1, 1, 1, 1, 1)
                    end
                end
                GameTooltip:Show()
                if HDG_UIHelpers and HDG_UIHelpers.ShowHoverPreview then
                    HDG_UIHelpers.ShowHoverPreview(r.itemID, mainFrame)
                end
            end)
            frame:SetScript("OnLeave", function()
                GameTooltip:Hide()
                if HDG_UIHelpers and HDG_UIHelpers.HideHoverPreview then
                    HDG_UIHelpers.HideHoverPreview()
                end
            end)
        end

        -- Section-header binding: hide all column cells, show centered label,
        -- paint a darker bg as a visual divider between plan and runners-up.
        if elementData.section then
            frame._isSection = true
            frame._row = nil
            frame.collectedIcon:Hide()
            frame.craftsText:Hide(); frame.charText:Hide(); frame.nameText:Hide()
            frame.lumberText:Hide(); frame.gPerLText:Hide(); frame.profitText:Hide()
            frame.expText:Hide()
            frame.planAccent:Hide()
            frame.sectionText:Show()
            frame.sectionText:SetText(elementData.section)
            frame.bg:SetColorTexture(0.12, 0.12, 0.16, 0.85)
            local s = GetScheme()
            local ar, ag, ab = 0.95, 0.78, 0.39
            if s and s.accent then
                ar, ag, ab = s.accent.r, s.accent.g, s.accent.b
            end
            frame.sectionText:SetTextColor(ar, ag, ab)
            -- Full-width underline in accent color so the divider reads at a glance.
            frame.sectionUnderline:SetColorTexture(ar, ag, ab, 0.65)
            frame.sectionUnderline:Show()

            -- Section action button switching. Plan section gets the
            -- send-to-queue button; runners-up section gets the show/hide
            -- toggle (which also remains usable when runners are collapsed,
            -- since the section header is rendered either way).
            local kind = elementData.kind
            if kind == "plan" then
                frame.sectionSendPlanBtn:SetEnabled(elementData.hasRows == true)
                frame.sectionSendPlanBtn:Show()
                frame.sectionCandidatesBtn:Hide()
            elseif kind == "candidates" then
                local showing = HDG and HDG.Store and HDG.Store.state
                                and HDG.Store.state.mogul
                                and HDG.Store.state.mogul.showCandidates
                frame.sectionCandidatesBtn:SetText(showing
                    and (L["MOGUL_BTN_HIDE_CANDIDATES"] or "Hide runners-up")
                    or  (L["MOGUL_BTN_SHOW_CANDIDATES"] or "Show runners-up"))
                frame.sectionCandidatesBtn:Show()
                frame.sectionSendPlanBtn:Hide()
            else
                frame.sectionSendPlanBtn:Hide()
                frame.sectionCandidatesBtn:Hide()
            end
            return
        end

        -- Row binding: re-show column cells (frame might have been a section
        -- on prior bind during scrolling).
        frame._isSection = false
        frame.sectionText:Hide()
        frame.sectionUnderline:Hide()
        frame.sectionSendPlanBtn:Hide()
        frame.sectionCandidatesBtn:Hide()
        frame.collectedIcon:Show()
        frame.craftsText:Show(); frame.charText:Show(); frame.nameText:Show()
        frame.lumberText:Show(); frame.gPerLText:Show(); frame.profitText:Show()
        frame.expText:Show()

        -- Plan rows get a vertical accent bar on the left edge; runners-up don't.
        if elementData.dimmed then
            frame.planAccent:Hide()
        else
            local s = GetScheme()
            local ar, ag, ab = 0.95, 0.78, 0.39
            if s and s.accent then
                ar, ag, ab = s.accent.r, s.accent.g, s.accent.b
            end
            frame.planAccent:SetColorTexture(ar, ag, ab, 0.85)
            frame.planAccent:Show()
        end

        local row = elementData.row
        frame._row = row
        local recipe     = row.recipe or {}
        local lumberPer  = (row.score and row.score.lumberCost) or 0

        -- Background tint by expansion (subtle alpha so text stays readable).
        local expColor = HDG_ExpansionData and HDG_ExpansionData.GetColor and HDG_ExpansionData.GetColor(recipe.expansion)
        if expColor then
            frame.bg:SetColorTexture(expColor[1], expColor[2], expColor[3], elementData.dimmed and 0.10 or 0.20)
        else
            frame.bg:SetColorTexture(0, 0, 0, 0)
        end

        -- Collected/uncollected check; runner-ups in collection mode only matter
        -- here, but we paint both states so the visual cue is consistent.
        if recipe.uncollected == 1 then
            frame.collectedIcon:SetTexture(CONFIG and CONFIG.TEXTURES and CONFIG.TEXTURES.XMARK)
        else
            frame.collectedIcon:SetTexture(CONFIG and CONFIG.TEXTURES and CONFIG.TEXTURES.CHECKMARK)
        end
        frame.collectedIcon:Show()

        -- Gold-per-lumber efficiency (constant per recipe; divisor is total L/ea):
        --   revPerL = revenue per craft / lumberCost  -- yield if mats are free
        --   netPerL = netProfit per craft / lumberCost -- net yield with mats bought at AH
        local revPerCraft = (row.score and row.score.revenuePerCraft) or 0
        local netPerCraft = (row.score and row.score.netProfitPerCraft) or 0
        local revPerL = lumberPer > 0 and revPerCraft / lumberPer or 0
        local netPerL = lumberPer > 0 and netPerCraft / lumberPer or 0

        -- Char column: bestAlt if known on an alt, otherwise the active player.
        local charDisplay = recipe.bestAlt or (UnitName and UnitName("player")) or ""

        -- Crafts column: planned shows "N x", runner-ups show a dash.
        local craftsCell
        if elementData.dimmed then
            craftsCell = "-"
        else
            craftsCell = string.format("%d x", row.crafts)
        end

        -- Profit column: planned shows row totals (crafts * per-craft); runner-ups
        -- show per-craft potential (crafts == 0 would be uninformative).
        local profitRev, profitNet
        if elementData.dimmed then
            profitRev, profitNet = revPerCraft, netPerCraft
        else
            profitRev = row.crafts * revPerCraft
            profitNet = row.crafts * netPerCraft
        end

        -- Stamp every column.
        frame.craftsText:SetText(craftsCell)
        frame.charText:SetText(charDisplay)
        frame.nameText:SetText(recipe.name or "?")
        frame.lumberText:SetText(lumberPer > 0 and tostring(lumberPer) or "")
        frame.gPerLText:SetText(string.format("%s / %s", FmtGold(revPerL), FmtGold(netPerL)))
        frame.profitText:SetText(string.format("+%s / +%s", FmtGold(profitRev), FmtGold(profitNet)))

        -- Expansion short tag (right edge), expansion-colored.
        local expShort = HDG_ExpansionData and HDG_ExpansionData.GetShortName
                         and HDG_ExpansionData.GetShortName(recipe.expansion) or ""
        frame.expText:SetText(expShort)
        if expColor then
            frame.expText:SetTextColor(expColor[1], expColor[2], expColor[3])
        else
            frame.expText:SetTextColor(0.7, 0.7, 0.7)
        end

        -- Color: dimmed gets the theme dim color, planned stays full white.
        local r, g, b = 1, 1, 1
        if elementData.dimmed then r, g, b = HDG.Theme.RGB_Dim() end
        for _, fstr in ipairs({
            frame.craftsText, frame.charText, frame.nameText,
            frame.lumberText, frame.gPerLText, frame.profitText,
        }) do
            fstr:SetTextColor(r, g, b)
        end
        -- Char column gets a subtle accent so alt names are scannable in account view.
        if recipe.bestAlt then
            frame.charText:SetTextColor(0.95, 0.78, 0.39)   -- gold-ish accent
        end
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)

    planListFrame = {
        listHost            = listHost,
        scrollBox           = scrollBox,
        scrollBar           = scrollBar,
        scrollView          = view,
        summaryLine1        = summaryLine1,
        summaryLine2        = summaryLine2,
        UpdateSupplyImpact  = UpdateSupplyImpact,
    }

    -- Initial paint of the radio appearance + edit-box state.
    UpdateSupplyImpact()
end

-- ============================================================================
-- MATS PANEL: aggregated shopping list + Send to Auctionator
-- ============================================================================

local function BuildMatsPanel(parent)
    if matsPanelFrame then return end  -- idempotent

    local matsHost = CreateFrame("Frame", nil, parent, "InsetFrameTemplate")
    matsHost:SetPoint("TOPLEFT",     planListFrame.listHost, "BOTTOMLEFT",  0, -8)
    matsHost:SetPoint("BOTTOMRIGHT", parent,                 "BOTTOMRIGHT", -8, 16)

    local title = matsHost:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOPLEFT", matsHost, "TOPLEFT", 8, -8)
    title:SetText(L["MOGUL_HEADER_MATS_TO_BUY"] or "MATS TO BUY")
    local s = GetScheme()
    if s then HDG.Theme.ApplyFont(title, s, "title", 12) end

    -- Auctionator button on the same row as the title, right-anchored.
    -- Visibility is wired by RenderMatsPanel based on whether the current
    -- shopping list / per-row mats are non-empty.
    local sendAuctionatorBtn = CreateFrame("Button", nil, matsHost, "UIPanelButtonTemplate")
    sendAuctionatorBtn:SetSize(160, 16)
    sendAuctionatorBtn:SetPoint("TOPRIGHT", matsHost, "TOPRIGHT", -6, -6)
    sendAuctionatorBtn:SetText(L["MOGUL_BTN_SEND_AUCTIONATOR"] or "Send mats to Auctionator")
    sendAuctionatorBtn:SetNormalFontObject("GameFontNormalSmall")
    sendAuctionatorBtn:SetHighlightFontObject("GameFontHighlightSmall")
    sendAuctionatorBtn:SetDisabledFontObject("GameFontDisableSmall")
    sendAuctionatorBtn:SetScript("OnClick", function() HDG_MogulTab.SendMatsToAuctionator() end)

    -- Send-to-Auctionator button now lives on the action row (BuildActionRow).
    -- Mats scrollBox extends to matsHost bottom, since no button is reserved here.
    local scrollBox = CreateFrame("Frame", nil, matsHost, "WowScrollBoxList")
    scrollBox:SetPoint("TOPLEFT",     matsHost, "TOPLEFT",     4, -28)
    scrollBox:SetPoint("BOTTOMRIGHT", matsHost, "BOTTOMRIGHT", -18,  4)

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

    local view = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    view:SetElementExtent(20)
    view:SetElementInitializer("StatusBar", function(bar, elementData)
        if not bar._mogulMatsInit then
            bar._mogulMatsInit = true
            bar:SetStatusBarTexture(CONFIG and CONFIG.TEXTURES and CONFIG.TEXTURES.STATUSBAR
                                    or "Interface\\TargetingFrame\\UI-StatusBar")

            -- Backdrop tint texture (expansion color).
            bar.bg = bar:CreateTexture(nil, "BACKGROUND")
            bar.bg:SetAllPoints()

            bar.nameText = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            bar.nameText:SetPoint("LEFT",  bar, "LEFT", 6, 0)
            bar.nameText:SetJustifyH("LEFT")
            bar.nameText:SetWordWrap(false)

            bar.countText = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            bar.countText:SetPoint("RIGHT", bar, "RIGHT", -6, 0)
            bar.countText:SetJustifyH("RIGHT")

            -- Width-bound the name so countText doesn't overlap.
            bar.nameText:SetPoint("RIGHT", bar.countText, "LEFT", -6, 0)

            local fs = GetScheme()
            if fs then
                HDG.Theme.ApplyFont(bar.nameText,  fs, "small", 11)
                HDG.Theme.ApplyFont(bar.countText, fs, "small", 11)
            end
            HDG.Theme.RegisterWidget(bar, "Frame")

            -- Click overlay -- StatusBar swallows clicks itself, but we need
            -- per-button dispatch (LMB/SHIFT+LMB/RMB) for AH integration parity
            -- with HDG_MaterialsList.
            bar.clickButton = CreateFrame("Button", nil, bar)
            bar.clickButton:SetAllPoints(bar)
            bar.clickButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
            bar.clickButton:SetScript("OnClick", function(self, button)
                local mat = bar._mat
                local matName = bar._matName
                if not mat or not matName then return end
                local AH = AuctionHouseFrame
                if button == "LeftButton" and not IsShiftKeyDown() and not IsControlKeyDown() then
                    if not AH or not AH:IsShown() then
                        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Open the Auction House first.")
                    elseif AH.SearchBar and AH.SearchBar.SearchBox then
                        AH.SearchBar.SearchBox:SetText(matName)
                        print(HDG.Theme.C_Success() .. "[Housing Decor Guide]|r AH search: " .. matName)
                    end
                elseif button == "LeftButton" and IsShiftKeyDown() then
                    if C_AddOns and C_AddOns.IsAddOnLoaded("Auctionator")
                       and Auctionator and Auctionator.API and Auctionator.API.v1
                       and Auctionator.API.v1.MultiSearch then
                        Auctionator.API.v1.MultiSearch("HousingDecorGuide", { matName })
                        print(HDG.Theme.C_Success() .. "[Housing Decor Guide]|r Auctionator search: " .. matName)
                    else
                        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Auctionator not loaded.")
                    end
                elseif button == "RightButton" then
                    if C_AddOns and C_AddOns.IsAddOnLoaded("Auctionator")
                       and Auctionator and Auctionator.API and Auctionator.API.v1
                       and Auctionator.API.v1.CreateShoppingList then
                        local listName = "Housing Decor (" .. ((UnitName and UnitName("player")) or "Char") .. ")"
                        Auctionator.API.v1.CreateShoppingList("HousingDecorGuide", listName, { matName })
                        print(HDG.Theme.C_Success() .. "[Housing Decor Guide]|r Added '" .. matName .. "' to " .. listName)
                    else
                        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r Auctionator not loaded.")
                    end
                end
            end)
            bar.clickButton:SetScript("OnEnter", function(self)
                local mat = bar._mat
                if not mat or not mat.id then return end
                GameTooltip:SetOwner(self, HDG_UIHelpers and HDG_UIHelpers.TOOLTIP_ANCHOR or "ANCHOR_RIGHT")
                GameTooltip:SetItemByID(mat.id)
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine(HDG.Theme.C_Dim() .. "Left-click: AH search|r", 0.7, 0.7, 0.7)
                GameTooltip:AddLine(HDG.Theme.C_Dim() .. "Shift-Left: Auctionator multi-search|r", 0.7, 0.7, 0.7)
                GameTooltip:AddLine(HDG.Theme.C_Dim() .. "Right-click: add to Auctionator list|r", 0.7, 0.7, 0.7)
                GameTooltip:Show()
            end)
            bar.clickButton:SetScript("OnLeave", function() GameTooltip:Hide() end)
        end

        local mat = elementData.mat
        bar._mat = mat
        local matName = (HDG_Data and HDG_Data.GetLocalizedName and HDG_Data.GetLocalizedName(mat.id))
                        or (HDG_PowerCrafting and HDG_PowerCrafting.GetItemName
                            and HDG_PowerCrafting.GetItemName(mat.id))
                        or ("Item " .. tostring(mat.id))
        bar._matName = matName

        -- Have/need progress: have comes from current bags + variants. mat.qty is
        -- the planned need for this mat across the entire plan (or the selected
        -- recipe's per-row need; sources is built upstream in RenderMatsPanel).
        local have = (HDG_Data and HDG_Data.GetItemCountWithVariants
                      and HDG_Data.GetItemCountWithVariants(mat.id)) or 0
        local need = mat.qty or 0
        bar:SetMinMaxValues(0, math.max(need, 1))
        bar:SetValue(math.min(have, need))

        local pct = need > 0 and (have / need * 100) or 100
        if HDG_UIHelpers and HDG_UIHelpers.GetProgressBarColor then
            local r, g, b = HDG_UIHelpers.GetProgressBarColor(pct)
            bar:SetStatusBarColor(r, g, b)
        end

        -- bg tint left transparent for now; mats don't carry expansion in our shape.
        bar.bg:SetColorTexture(0, 0, 0, 0.25)

        bar.nameText:SetText(matName)
        if have >= need then
            local s = GetScheme()
            if s then bar.nameText:SetTextColor(s.success.r, s.success.g, s.success.b) end
        else
            local s = GetScheme()
            if s then bar.nameText:SetTextColor(s.warning.r, s.warning.g, s.warning.b) end
        end

        local total = mat.totalCost or (need * (mat.unitPrice or 0))
        local countStr
        if have >= need then
            countStr = string.format("%s/%s   %s",
                BreakUpLargeNumbers(have), BreakUpLargeNumbers(need),
                total > 0 and FmtGold(total) or "")
        else
            countStr = string.format("%s/%s   %s",
                BreakUpLargeNumbers(have), BreakUpLargeNumbers(need),
                total > 0 and FmtGold(total) or "?")
        end
        bar.countText:SetText(countStr)
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)

    matsPanelFrame = {
        host             = matsHost,
        title            = title,
        sendAuctionatorBtn = sendAuctionatorBtn,
        scrollBox        = scrollBox,
        scrollBar        = scrollBar,
        scrollView       = view,
        currentSources   = {},
    }
end

function HDG_MogulTab.RenderMatsPanel(plan)
    if not matsPanelFrame then return end

    -- Per-recipe mats only when a planned (non-dimmed) row is selected; runners-up
    -- have crafts == 0, so we fall through to the plan-wide shopping list -- otherwise
    -- clicking a candidate would multiply by zero and show an empty mats panel.
    local sources
    local sel = HDG_MogulTab.selectedRow
    local perRecipeName = nil
    if sel and sel.score and sel.score.nonLumberMats and (sel.crafts or 0) > 0 then
        sources = {}
        for _, m in ipairs(sel.score.nonLumberMats) do
            local needed    = m.qty * sel.crafts
            local unitPrice = (HDG_PriceIntegration and HDG_PriceIntegration.GetItemPrice
                               and HDG_PriceIntegration.GetItemPrice(m.id)) or 0
            sources[#sources + 1] = {
                id        = m.id,
                qty       = needed,
                unitPrice = unitPrice,
                totalCost = needed * unitPrice,
            }
        end
        perRecipeName = sel.recipe and sel.recipe.name
    else
        sources = (plan and plan.shoppingList) or {}
    end

    matsPanelFrame.currentSources = sources

    -- Title reflects current scope: per-recipe when a plan row is selected,
    -- plan-wide otherwise. The hint text doubles as a click-to-deselect cue.
    if matsPanelFrame.title then
        if perRecipeName then
            matsPanelFrame.title:SetText(string.format(
                "%s %s  |cFF888888-- click row again to see all|r",
                L["MOGUL_HEADER_REAGENTS_FOR"] or "Reagents to buy for",
                perRecipeName))
        else
            matsPanelFrame.title:SetText(L["MOGUL_HEADER_MATS_TO_BUY"] or "Reagents to buy for complete plan")
        end
    end

    local provider = CreateDataProvider()
    for _, mat in ipairs(sources) do
        provider:Insert({ mat = mat })
    end
    matsPanelFrame.scrollBox:SetDataProvider(provider, ScrollBoxConstants.RetainScrollPosition)

    if matsPanelFrame.sendAuctionatorBtn then
        matsPanelFrame.sendAuctionatorBtn:SetShown(#sources > 0)
    end
end

function HDG_MogulTab.SendMatsToAuctionator()
    local mats = matsPanelFrame and matsPanelFrame.currentSources or {}
    if #mats == 0 then return end

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

    local callerID       = "HousingDecorGuide"
    -- Namespace per character so simultaneous use across alts doesn't clobber lists.
    local charName       = (UnitName and UnitName("player")) or "Char"
    local listName       = "Housing Decor (" .. charName .. ")"
    local hasQuantity    = Auctionator.API.v1.ConvertToSearchString ~= nil
    local searchStrings  = {}

    for _, mat in ipairs(mats) do
        local name = HDG_PowerCrafting and HDG_PowerCrafting.GetItemName
                     and HDG_PowerCrafting.GetItemName(mat.id)
        if name then
            local searchString
            if hasQuantity then
                searchString = Auctionator.API.v1.ConvertToSearchString(callerID, {
                    searchString = name,
                    isExact      = true,
                    quantity     = mat.qty,
                })
            else
                searchString = name
            end
            searchStrings[#searchStrings + 1] = searchString
        end
    end

    if #searchStrings == 0 then return end
    if Auctionator.API.v1.CreateShoppingList then
        Auctionator.API.v1.CreateShoppingList(callerID, listName, searchStrings)
        print(HDG.Theme.C_Success() .. "[Housing Decor Guide]|r Shopping list updated: " .. listName)
    end
end

function HDG_MogulTab.RenderPlanList(plan)
    if not planListFrame then return end

    local m    = GetMogulState()
    local mode = m.mode or "profit"

    local line1, line2 = "", ""

    -- Use plan.totals from the engine; it already aggregates lumber-only/lumber+mats
    -- profit, lumberUsed, crafts, and newAppearances. Don't re-aggregate here.
    local totals = (plan and plan.totals) or {}

    if not plan or not plan.rows or #plan.rows == 0 then
        if mode == "collection" then
            local hasRunners = plan and plan.candidates and #plan.candidates > 0
            if hasRunners then
                line1 = L["MOGUL_BANNER_EMPTY_COLLECTION_BUDGET"]
                    or "Uncollected decor available but doesn't fit your current lumber budget. See Runners-up below."
            else
                line1 = L["MOGUL_BANNER_EMPTY_COLLECTION_DONE"]
                    or "All decor you can craft is already collected."
            end
        else
            line1 = L["MOGUL_BANNER_EMPTY_PLAN"] or "No profitable crafts found with the current lumber budget."
        end
        line2 = ""
    elseif mode == "collection" then
        line1 = string.format("%s: %d     %s: %s",
            L["MOGUL_LABEL_NEW_APPEARANCES"] or "New appearances",
            totals.newAppearances or 0,
            L["MOGUL_LABEL_MAT_COST"] or "Mat cost",
            FmtGold(totals.matSpend or 0))
        -- Crafts + lumber moved to the Plan section header below.
        line2 = ""
    else
        -- profit mode: revenue + net profit pair up on line1.
        -- Crafts + lumber moved to the Plan section header below.
        line1 = string.format("%s: +%s     %s: +%s",
            L["MOGUL_LABEL_REVENUE"]    or "Revenue (mats free)",      FmtGold(totals.revenue or 0),
            L["MOGUL_LABEL_NET_PROFIT"] or "Net profit (mats bought)", FmtGold(totals.netProfit or 0))
        line2 = ""
    end

    planListFrame.summaryLine1:SetText(line1)
    planListFrame.summaryLine2:SetText(line2)
    if planListFrame.UpdateSupplyImpact then planListFrame.UpdateSupplyImpact() end

    local provider = CreateDataProvider()
    local hasPlanRows = plan and plan.rows and #plan.rows > 0

    -- Plan section: always inserted (so the Send-to-queue button has a home),
    -- even when the plan is empty. Button is disabled in the empty case.
    -- Section title carries the crafts + lumber-used stats inline (formerly on
    -- summary line2) so the summary block stays compact.
    local planSectionText
    if hasPlanRows then
        local lumberSeg
        if totals.capLimited then
            lumberSeg = string.format("%d / %d %s (%s)",
                totals.lumberUsed or 0,
                totals.lumberBudget or 0,
                L["MOGUL_LABEL_LUMBER_USED"] or "lumber used",
                L["MOGUL_CAP_LIMITED"] or "cap-limited")
        else
            lumberSeg = string.format("%d %s",
                totals.lumberUsed or 0,
                L["MOGUL_LABEL_LUMBER_USED"] or "lumber used")
        end
        planSectionText = string.format("%s -- %d %s, %s",
            L["MOGUL_SECTION_PLAN_LABEL"] or "Plan",
            totals.crafts or 0,
            L["MOGUL_LABEL_CRAFTS_LOWER"] or "crafts",
            lumberSeg)
    else
        planSectionText = L["MOGUL_SECTION_PLAN"] or "Plan -- craft these"
    end
    provider:Insert({
        section  = planSectionText,
        kind     = "plan",
        hasRows  = hasPlanRows,
    })
    if hasPlanRows then
        for _, row in ipairs(plan.rows) do
            provider:Insert({ row = row })
        end
    end

    -- Runners-up section: ALWAYS inserted when any runners exist, regardless
    -- of showCandidates. The toggle button lives on this header, so it must
    -- be reachable even when the rows are collapsed. Row insertion itself is
    -- gated on showCandidates.
    local picked = {}
    for _, r in ipairs((plan and plan.rows) or {}) do
        picked[r.recipe.itemID] = true
    end
    local runners = {}
    for _, score in ipairs((plan and plan.candidates) or {}) do
        local rid = score.recipe and score.recipe.itemID
        if rid and not picked[rid] then runners[#runners + 1] = score end
    end
    if #runners > 0 then
        provider:Insert({
            section = L["MOGUL_SECTION_CANDIDATES"] or "Runners-up -- did not fit",
            kind    = "candidates",
            count   = #runners,
        })
        if GetMogulState().showCandidates then
            for _, score in ipairs(runners) do
                provider:Insert({
                    row = { recipe = score.recipe, crafts = 0, score = score },
                    dimmed = true,
                })
            end
        end
    end

    planListFrame.scrollBox:SetDataProvider(provider, ScrollBoxConstants.RetainScrollPosition)
end

function HDG_MogulTab.OnRowSelected(row)
    -- Toggle: clicking the already-selected row clears the selection back to
    -- plan-wide mats (the mats panel returns to the full shopping list).
    if HDG_MogulTab.selectedRow == row then
        HDG_MogulTab.selectedRow = nil
    else
        HDG_MogulTab.selectedRow = row
    end
    if HDG_MogulTab.RenderMatsPanel then
        HDG_MogulTab.RenderMatsPanel(HDG_MogulTab.lastPlan)
    end
end

-- ============================================================================
-- ACTION ROW: Send entire plan + Show all candidates
-- ============================================================================

local function BuildActionRow(parent)
    if actionRowFrame then return end  -- idempotent

    -- All three former action-row buttons have moved:
    --   * Send entire plan to crafting queue -> Plan section header (right-anchored)
    --   * Show/Hide runners-up                -> Runners-up section header (right-anchored)
    --   * Send mats to Auctionator            -> MATS TO BUY title row (right-anchored)
    -- The container frame is retained as a thin spacer so the price-warning
    -- text and matsHost bottom anchor still have a stable reference point.
    local actionRow = CreateFrame("Frame", nil, parent)
    actionRow:SetPoint("BOTTOMLEFT",  parent, "BOTTOMLEFT",  8, 8)
    actionRow:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -8, 8)
    actionRow:SetHeight(4)

    actionRowFrame = {
        container = actionRow,
    }
end

function HDG_MogulTab.SendPlanToQueue()
    local plan = HDG_MogulTab.lastPlan
    if not plan or not plan.rows or #plan.rows == 0 then
        print(HDG.Theme.C_Warning() .. "[Housing Decor Guide]|r No plan to queue.")
        return
    end
    for _, row in ipairs(plan.rows) do
        local item = HDG_PowerCrafting.MakeQueueItem(row.recipe, row.crafts)
        if item then
            HDG.Store:Dispatch("ADD_TO_CRAFTING_QUEUE", { item = item })
        end
    end
    print(string.format("%s[Housing Decor Guide]|r Queued %d recipes (%d total crafts).",
        HDG.Theme.C_Success(),
        #plan.rows, plan.totals.crafts or 0))
    -- Trigger any cross-addon UI refresh (e.g. RecipesTab queue display).
    if HDG_UI and HDG_UI.RefreshUI then HDG_UI.RefreshUI() end
end

function HDG_MogulTab.RenderPriceWarning()
    if not mogulFrame then return end

    local hasPriceSrc = false
    if C_AddOns then
        hasPriceSrc = (C_AddOns.IsAddOnLoaded and C_AddOns.IsAddOnLoaded("Auctionator"))
                   or (C_AddOns.IsAddOnLoaded and C_AddOns.IsAddOnLoaded("TradeSkillMaster"))
                   or false
    end

    if not mogulFrame.priceWarning then
        local w = mogulFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        if actionRowFrame and actionRowFrame.container then
            w:SetPoint("BOTTOMLEFT",  actionRowFrame.container, "TOPLEFT",  0, 4)
            w:SetPoint("BOTTOMRIGHT", actionRowFrame.container, "TOPRIGHT", 0, 4)
        end
        w:SetJustifyH("LEFT")
        w:SetTextColor(1, 0.6, 0.2)
        local fs = GetScheme()
        if fs then HDG.Theme.ApplyFont(w, fs, "small", 11) end
        mogulFrame.priceWarning = w
    end

    mogulFrame.priceWarning:SetText(
        hasPriceSrc and ""
                     or (L["MOGUL_BANNER_NO_PRICE_ADDON"] or "Install Auctionator or TSM for accurate pricing.")
    )
end

local function CreatePanel()
    if mogulFrame then return end
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll    = false,
    })
    mogulFrame = layout.frame
    BuildHeader(mogulFrame)
    BuildLumberPanel(mogulFrame)
    BuildPlanList(mogulFrame)
    BuildMatsPanel(mogulFrame)
    BuildActionRow(mogulFrame)
    mogulFrame:Hide()
end

function HDG_MogulTab.Show()
    if not mainFrame then return end
    if not mogulFrame then CreatePanel() end
    mogulFrame:Show()
    HDG_MogulTab.Refresh()
end

function HDG_MogulTab.Hide()
    if mogulFrame then mogulFrame:Hide() end
end

-- ============================================================================
-- DATA COLLECTION HELPERS
-- ============================================================================

local function CollectLumberIDs()
    if HDG_LumberTracker and HDG_LumberTracker.GetLumberIDs then
        return HDG_LumberTracker.GetLumberIDs()
    end
    return {}
end

local function CollectOwnedMats(candidates, lumberIDs)
    local owned = {}
    for _, recipe in ipairs(candidates) do
        for _, reagent in ipairs(recipe.reagents or {}) do
            if not lumberIDs[reagent.id] and not owned[reagent.id] then
                if HDG_Data and HDG_Data.GetItemCountWithVariants then
                    owned[reagent.id] = HDG_Data.GetItemCountWithVariants(reagent.id) or 0
                else
                    owned[reagent.id] = 0
                end
            end
        end
    end
    return owned
end

-- Engine reads sale prices from recipe.salePrice (already populated by
-- CollectCandidates); matPrices only needs non-lumber reagent prices.
local function CollectMatPrices(candidates, lumberIDs)
    local prices = {}
    if not HDG_PriceIntegration or not HDG_PriceIntegration.GetItemPrice then return prices end
    for _, recipe in ipairs(candidates) do
        for _, reagent in ipairs(recipe.reagents or {}) do
            if not lumberIDs[reagent.id] and prices[reagent.id] == nil then
                prices[reagent.id] = HDG_PriceIntegration.GetItemPrice(reagent.id) or 0
            end
        end
    end
    return prices
end

local function CollectCandidates(viewMode)
    local out = {}
    if not HDG_PowerCrafting or not HDG_PowerCrafting.GetAllKnownDecorRecipes then return out end

    local allRecipes = HDG_PowerCrafting.GetAllKnownDecorRecipes(viewMode) or {}

    for _, recipe in ipairs(allRecipes) do
        local itemID = recipe.itemID
        -- Authoritative collected check via HDG_Data.IsItemCollected. Reads
        -- ownedDecorIDs / collectionCache populated by the central catalog
        -- searcher + SavedVars. The previous `entry.collected` read was
        -- against a field that nothing actually sets on catalog rows -- so
        -- collection-mode planning was effectively unfiltered.
        local isCollected = HDG_Data and HDG_Data.IsItemCollected
            and HDG_Data.IsItemCollected(itemID) or false
        local uncollected = isCollected and 0 or 1
        local salePrice = HDG_PriceIntegration
            and HDG_PriceIntegration.GetItemPrice
            and HDG_PriceIntegration.GetItemPrice(itemID) or 0

        out[#out + 1] = {
            itemID       = itemID,
            name         = recipe.name,
            spellID      = recipe.spellID,
            profession   = recipe.profession,
            expansion    = recipe.expansion,
            reagents     = recipe.reagents,
            salePrice    = salePrice,
            uncollected  = uncollected,
            knownByActive = recipe.knownByActive,
            bestAlt      = recipe.bestAlt,
        }
    end
    return out
end

-- ============================================================================
-- REFRESH: compute plan and cache results
--
-- Mogul is plan-on-demand by design. Refresh fires only on tab Show, header
-- filter dispatch, and the candidates toggle -- NOT on BAG_UPDATE, lumber
-- gain, price-scan completion, or catalog mutation. Live re-ranking would
-- jitter the plan while the player is reading it; the explicit refresh model
-- is intentional. Player switches filters or reopens the tab to re-plan.
-- ============================================================================

function HDG_MogulTab.Refresh()
    if not mogulFrame then return end

    local m = GetMogulState()
    local viewMode    = m.viewMode or "char"
    local mode        = m.mode or "profit"
    local optimizeBy  = m.optimizeBy or "lumberOnly"
    local algorithm   = m.algorithm or "greedy"
    local lumberBudgets = GetLumberBudgets()

    local lumberIDs  = CollectLumberIDs()
    local candidates = CollectCandidates(viewMode)

    -- Active-char view: only recipes the active char knows. Account view: all
    -- recipes any alt knows -- alt-only recipes appear in the plan tagged with
    -- `Best on: <altName>` so the player knows which character to log into.
    -- Owned mats + prices are still active-char-only (we can't see alts' bags).
    local planCandidates
    if viewMode == "account" then
        planCandidates = candidates
    else
        planCandidates = {}
        for _, c in ipairs(candidates) do
            if c.knownByActive then planCandidates[#planCandidates + 1] = c end
        end
    end

    local ownedMats = CollectOwnedMats(planCandidates, lumberIDs)
    local matPrices = CollectMatPrices(planCandidates, lumberIDs)

    local plan = HDG_Mogul.BuildPlan({
        algorithm     = algorithm,
        mode          = mode,
        optimizeBy    = optimizeBy,
        lumberBudgets = lumberBudgets,
        candidates    = planCandidates,
        ownedMats     = ownedMats,
        matPrices     = matPrices,
        lumberIDs     = lumberIDs,
        supplyImpact  = m.supplyImpact,
    })

    HDG_MogulTab.lastPlan       = plan
    HDG_MogulTab.lastViewMode   = viewMode
    HDG_MogulTab.allCandidates  = candidates
    -- Carry selection across refreshes when the same recipe is still in the plan.
    -- The score and crafts may differ -- repoint to the new row so per-recipe mats
    -- reflect the rebuilt plan, not stale cached numbers.
    local prevSelectedID = HDG_MogulTab.selectedRow and HDG_MogulTab.selectedRow.recipe
                          and HDG_MogulTab.selectedRow.recipe.itemID
    HDG_MogulTab.selectedRow = nil
    if prevSelectedID and plan and plan.rows then
        for _, row in ipairs(plan.rows) do
            if row.recipe and row.recipe.itemID == prevSelectedID then
                HDG_MogulTab.selectedRow = row
                break
            end
        end
    end

    if HDG_MogulTab.RenderHeader       then HDG_MogulTab.RenderHeader(lumberBudgets) end
    if HDG_MogulTab.RenderLumberPanel  then HDG_MogulTab.RenderLumberPanel(lumberBudgets, plan) end
    if HDG_MogulTab.RenderPlanList     then HDG_MogulTab.RenderPlanList(plan) end
    if HDG_MogulTab.RenderMatsPanel    then HDG_MogulTab.RenderMatsPanel(plan) end
    if HDG_MogulTab.RenderPriceWarning then HDG_MogulTab.RenderPriceWarning() end
end

privateDB.HDG_MogulTab = HDG_MogulTab
