-- UI/ContextDrawer.lua
-- Full-width bottom context-sensitive drawer for the mini view.
-- Replaces ActionDrawer.lua — bank actions are ported here; AH actions
-- delegate to ns.AuctionPost (loaded separately).
local addonName, ns = ...

local UI = ns.UI
local Tracker  -- resolved lazily

--------------------------
-- Session state
--------------------------

ns._automationPaused = false

--------------------------
-- Constants
--------------------------

local THUMB_HEIGHT   = 14   -- matches tool drawer's THUMB_WIDTH for visual symmetry
local BTN_HEIGHT     = 18
local BTN_SPACING    = 2
local PAD            = 4
local HEADER_HEIGHT  = 16
local ANIM_DURATION  = 0.15

local DEFAULT_CONTENT_H = HEADER_HEIGHT + PAD + BTN_HEIGHT + PAD  -- 42
local DEFAULT_FULL_H    = DEFAULT_CONTENT_H + THUMB_HEIGHT  -- 56

local BANK_CONTENT_H = HEADER_HEIGHT + PAD + 3 * BTN_HEIGHT + 2 * BTN_SPACING + PAD  -- 82
local BANK_FULL_H    = BANK_CONTENT_H + THUMB_HEIGHT  -- 96

local DRAWER_BACKDROP = {
    bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 14,
    insets   = { left = 3, right = 3, top = 3, bottom = 3 },
}

local BTN_BACKDROP = {
    bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 6,
    insets   = { left = 1, right = 1, top = 1, bottom = 1 },
}

--------------------------
-- Context detection
--------------------------

-- ns._serviceState is exported by ToolDrawer.lua (loaded before us).
-- We register for the same events purely to trigger a refresh; the
-- authoritative state lives in the shared table.
local ctxEvt = CreateFrame("Frame")
ctxEvt:RegisterEvent("AUCTION_HOUSE_SHOW")
ctxEvt:RegisterEvent("AUCTION_HOUSE_CLOSED")
ctxEvt:RegisterEvent("BANKFRAME_OPENED")
ctxEvt:RegisterEvent("BANKFRAME_CLOSED")
ctxEvt:RegisterEvent("PLAYER_ENTERING_WORLD")
-- PLAYER_MONEY drives RefreshBankLabels so the manual "Withdraw Gold: Xg" /
-- "Deposit Earnings: Xg" button labels reflect the new balance after a
-- C_Bank.WithdrawMoney / DepositMoney completes (the balance update arrives
-- a frame or two after the API call returns).
ctxEvt:RegisterEvent("PLAYER_MONEY")
ctxEvt:SetScript("OnEvent", function()
    -- ToolDrawer updates ns._serviceState before we fire (same event,
    -- but registered first). Just kick our refresh.
    if UI.RefreshContextDrawer then UI:RefreshContextDrawer() end
end)

local function GetContext()
    local ss = ns._serviceState
    if ss and ss.auctionOpen then return "auction" end
    if ss and ss.bankOpen    then return "bank" end
    return "default"
end

--------------------------
-- Drawer frames
--------------------------

local contextClip    = nil   -- clip frame
local contextContent = nil   -- inner backdrop
local contextThumb   = nil   -- bottom grip tab

local currentContext  = nil   -- "bank" | "auction" | "default"
local drawerOpen      = false
local animating       = false
local animTarget      = THUMB_HEIGHT
local currentFullH    = THUMB_HEIGHT  -- recalculated per context

-- Per-context content containers (lazily created)
local bankFrame       = nil
local ahFrame         = nil

-- Button pools
local bankButtons     = {}
local scanRows        = {}
local ownedRows       = {}

-- Scan result storage
local currentScanResults    = {}
local currentOwnedAuctions  = {}

--------------------------
-- Button factory
--------------------------

local function CreateActionButton(parent, label, tooltip, onClick)
    local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
    btn:SetHeight(BTN_HEIGHT)
    btn:SetBackdrop(BTN_BACKDROP)
    btn:SetBackdropColor(0.12, 0.14, 0.20, 0.9)
    btn:SetBackdropBorderColor(0.3, 0.35, 0.5, 0.8)

    btn.label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    btn.label:SetPoint("CENTER")
    btn.label:SetText(label)
    btn.label:SetTextColor(0.85, 0.85, 0.9)

    btn.highlight = btn:CreateTexture(nil, "HIGHLIGHT")
    btn.highlight:SetAllPoints()
    btn.highlight:SetColorTexture(1, 1, 1, 0.08)

    btn._tooltip = tooltip
    btn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(0.18, 0.22, 0.32, 1)
        if self._tooltip then
            GameTooltip:SetOwner(self, "ANCHOR_TOPRIGHT")
            GameTooltip:SetText(self._tooltip, 1, 1, 1, 1, true)
            GameTooltip:Show()
        end
    end)
    btn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(0.12, 0.14, 0.20, 0.9)
        GameTooltip:Hide()
    end)
    btn:SetScript("OnClick", onClick)

    return btn
end

--------------------------
-- Bank button actions
--------------------------

local function IsBankOpen()
    return (C_Bank and C_Bank.IsItemCountAvailable) and true or
           (BankFrame and BankFrame:IsShown()) or false
end

-- Forward-declared; set by BuildDefaultContent later in the file.
local _defaultPauseBtn = nil

local function RefreshPauseButton(specificBtn)
    local buttons = {}
    if bankButtons.pause then table.insert(buttons, bankButtons.pause) end
    if specificBtn then table.insert(buttons, specificBtn) end
    if _defaultPauseBtn then table.insert(buttons, _defaultPauseBtn) end
    for _, btn in ipairs(buttons) do
        if ns._automationPaused then
            btn.label:SetText("|cffff8800Resume Automation|r")
            btn._tooltip = "Resume automation (currently paused)"
            btn:SetBackdropBorderColor(0.7, 0.5, 0.2, 0.9)
        else
            btn.label:SetText("Pause Automation")
            btn._tooltip = "Temporarily pause all bank automation"
            btn:SetBackdropBorderColor(0.3, 0.35, 0.5, 0.8)
        end
    end
end

local function DoPause()
    ns._automationPaused = not ns._automationPaused
    RefreshPauseButton()
    ns:Print(ns._automationPaused
        and (ns.COLORS.ORANGE .. "Automation paused.|r")
        or  (ns.COLORS.GREEN  .. "Automation resumed.|r"))
end

local function PostOp()
    if ns.Scanner then ns.Scanner:ScanCurrentCharacter() end
    if ns.TodoList then
        if ns.TodoList.RefreshLocations then ns.TodoList:RefreshLocations() end
        if ns.TodoList.RefreshTaskSteps then ns.TodoList:RefreshTaskSteps() end
    end
    if ns.UI then
        if ns.UI.mainFrame and ns.UI.mainFrame:IsShown() then ns.UI:Refresh() end
        if ns.UI.RefreshMini then ns.UI:RefreshMini() end
    end
end

-- Wraps a manual bank operation in the unified bank popup so the Execute
-- click runs in hardware-event context, matching the auto-flow UX. Without
-- this wrapper the drawer buttons skipped the popup and went straight to
-- ProcessSync, which felt inconsistent once the popup became canonical for
-- the auto chain.
--
-- spec = {
--   ops          -- operation array from Tracker:Build*Ops
--   payloadKey   -- "pulls" | "deposits" | "extras"
--   processLabel -- progress label passed to BankQueue:ProcessSync
--   phase        -- bucket key for BankOpProgress ("Pulling" | "Depositing")
--   successWord  -- chat prefix on success ("Pulled" | "Deposited" | "Extras")
--   errorNoun    -- chat noun on failure ("pull" | "deposit" | "extra")
-- }
local function RunManualBankOp(spec)
    if not (ns.BankQueue and ns.BankQueue.ProcessSync) then return end

    local ops = spec.ops
    local function RunOps()
        -- Per-IssueOne optimistic ticks: each successful issuance inside
        -- ProcessSync fires onProgress(1, total, {opName}) so the popup bar
        -- advances move-by-move during long executions instead of jumping
        -- only at retry boundaries. ProcessSync emits a negative
        -- compensation tick at the end for any ops that issued but failed
        -- verification (#127). onProgress is cleared by BankPopupComplete /
        -- HideBankPopup.
        --
        -- onWait drives the heartbeat countdown: ProcessSync fires it
        -- before each timed wait (inter-move, verify, retry, panel-settle)
        -- with the duration and reason, so the popup can render a definite
        -- timer labelled with what the addon is waiting for.
        if ns.BankQueue then
            ns.BankQueue.onProgress = function(deltaSuccess, _total, deltaNames)
                if ns.UI and ns.UI.BankOpProgress then
                    ns.UI:BankOpProgress(deltaSuccess or 0, 0, spec.phase, deltaNames)
                end
            end
            ns.BankQueue.onWait = function(seconds, reason, kind)
                if ns.UI and ns.UI.BeginHeartbeat then
                    ns.UI:BeginHeartbeat(seconds, reason, kind)
                end
            end
            ns.BankQueue.onWaitEnd = function()
                if ns.UI and ns.UI.EndHeartbeat then
                    ns.UI:EndHeartbeat()
                end
            end
        end
        ns.BankQueue:ProcessSync(ops, spec.processLabel, function(success, errors, failed)
            if #success > 0 then ns:Print(spec.successWord .. ": " .. table.concat(success, ", ")) end
            if errors > 0 then
                local detail = (failed and #failed > 0)
                    and (": " .. table.concat(failed, ", ")) or ""
                ns:Print(ns.COLORS.YELLOW .. errors .. " " .. spec.errorNoun .. "(s) failed|r" .. detail)
            end
            -- Successes were already ticked in via onProgress above; the
            -- final tick contributes the failure count (and the failed
            -- names list, which the popup renders as red rows in the
            -- completion summary).
            if ns.UI and ns.UI.BankOpProgress then
                ns.UI:BankOpProgress(0, errors, spec.phase, nil, failed)
            end
            PostOp()
            if ns.UI and ns.UI.BankPopupComplete then ns.UI:BankPopupComplete() end
        end)
    end

    if not (ns.UI and ns.UI.ShowBankPopup) then RunOps() return end

    if ns.UI.IsBankExecuting and not ns.UI:IsBankExecuting() and ns.UI.BeginBankExecution then
        ns.UI:BeginBankExecution(#ops)
    end

    ns.UI:ShowBankPopup({ [spec.payloadKey] = ops }, function()
        if ns.UI.BankOpProgress then ns.UI:BankOpProgress(0, 0, spec.phase) end
        RunOps()
    end)
end

local function DoPull()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker then return end
    local ops = Tracker:BuildPullOps()
    if #ops == 0 then ns:Print("Nothing to pull.") return end
    RunManualBankOp({
        ops          = ops,
        payloadKey   = "pulls",
        processLabel = "Pulling...",
        phase        = "Pulling",
        successWord  = "Pulled",
        errorNoun    = "pull",
    })
end

local function DoDeposit()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker then return end
    local ops = Tracker:BuildDepositOps()
    if #ops == 0 then ns:Print("Nothing to deposit.") return end
    RunManualBankOp({
        ops          = ops,
        payloadKey   = "deposits",
        processLabel = "Depositing...",
        phase        = "Depositing",
        successWord  = "Deposited",
        errorNoun    = "deposit",
    })
end

local function DoExtras()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker then return end
    local depositSlots = {}
    local depositOps = Tracker:BuildDepositOps()
    for _, op in ipairs(depositOps) do
        depositSlots[op.srcBag .. ":" .. op.srcSlot] = true
    end
    local ops = Tracker:BuildExtraDepositOps(depositSlots)
    if #ops == 0 then ns:Print("No extras to deposit.") return end
    RunManualBankOp({
        ops          = ops,
        payloadKey   = "extras",
        processLabel = "Extras...",
        phase        = "Depositing",
        successWord  = "Extras",
        errorNoun    = "extra",
    })
end

-- Pull Saleable: walks the warbank, finds every unique TSM-Auctioning-grouped
-- item, and pulls up to `op.postCap` (or defaultSellQty) of each, capped by
-- what's still needed after subtracting bags-already-have. Independent of
-- the to-do list — this is the "pull one of everything I can sell" button,
-- distinct from "Pull Items" (DoPull) which walks only the current
-- character's to-do tasks via Tracker:BuildPullOps.
--
-- Reagent handling honours the trackReagents master setting and its
-- pullSaleableIncludeReagents sub-toggle. With master off, reagents are
-- skipped entirely. With master on, the sub-toggle decides per-flow.
local function DoPullSaleable()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker then return end

    local warbankTabs = ns:GetEnabledWarbankTabs() or {}
    if #warbankTabs == 0 then ns:Print("No warbank tabs configured.") return end

    local defaultQty = ns.db and ns.db.settings.defaultSellQty or 1
    local tsmEnabled = ns.TSM and ns.TSM.IsEnabled and ns.TSM:IsEnabled()
    if not tsmEnabled then
        ns:Print("Pull Saleable requires TSM (uses Auctioning ops to identify saleable items).")
        return
    end

    -- Reagent gating: classID == 7 is Item Class Tradegoods (herbs, ore,
    -- leather, cloth, gems, elemental). C_Item.GetItemInfoInstant is
    -- synchronous so we can call it inline without the async-load dance.
    local trackReagents   = ns.db and ns.db.settings.trackReagents
    local pullReagents    = ns.db and ns.db.settings.pullSaleableIncludeReagents
    local reagentsAllowed = trackReagents and pullReagents
    local function IsReagent(itemID)
        if not C_Item or not C_Item.GetItemInfoInstant then return false end
        local _, _, _, _, _, classID = C_Item.GetItemInfoInstant(itemID)
        return classID == 7
    end

    -- Pass 1: walk warbank slots, dedupe by itemKey, capture per-key
    -- target qty (postCap or default) and the list of slots we can
    -- pull from for that key.
    local targetQty = {}
    local itemNames = {}
    local itemSlots = {}

    for _, bagIndex in ipairs(warbankTabs) do
        local ok, numSlots = pcall(C_Container.GetContainerNumSlots, bagIndex)
        if ok and numSlots then
            for slot = 1, numSlots do
                local ok2, info = pcall(C_Container.GetContainerItemInfo, bagIndex, slot)
                -- Skip soulbound — they can't be auctioned anyway.
                if ok2 and info and info.hyperlink and not info.isBound then
                    local itemID, bonusIDs, modifiers = ns:ParseItemLink(info.hyperlink)
                    if itemID and not ns:IsDoNotTrack(itemID) then
                        if not (IsReagent(itemID) and not reagentsAllowed) then
                            local key = ns:MakeItemKey(itemID, bonusIDs, modifiers)
                            local opOk, op = pcall(ns.TSM.GetItemAuctioningOp, ns.TSM, key)
                            if opOk and op then
                                if not targetQty[key] then
                                    local qty = defaultQty
                                    if op.postCap then
                                        local cap = tonumber(op.postCap)
                                        if cap and cap > 0 then qty = cap end
                                    end
                                    targetQty[key] = qty
                                    itemNames[key] = info.hyperlink:match("|h%[(.-)%]|h") or ("Item " .. itemID)
                                    itemSlots[key] = {}
                                end
                                table.insert(itemSlots[key], {
                                    bag   = bagIndex,
                                    slot  = slot,
                                    stack = info.stackCount or 1,
                                    icon  = info.iconFileID,
                                })
                            end
                        end
                    end
                end
            end
        end
    end

    if not next(targetQty) then
        ns:Print("No saleable items in warbank.")
        return
    end

    -- Pass 2: subtract bags-already-have, build pull ops per key, capping
    -- the per-slot pull at the still-needed qty so we don't pull a 20-stack
    -- when postCap is 5.
    local ops = {}
    for key, target in pairs(targetQty) do
        local synthItem = { itemKey = key, name = itemNames[key] }
        local inBags = Tracker._CountInBags and Tracker._CountInBags(synthItem) or 0
        local needed = target - inBags
        if needed > 0 then
            for _, s in ipairs(itemSlots[key]) do
                if needed <= 0 then break end
                local pullQty = math.min(s.stack, needed)
                table.insert(ops, {
                    op       = "pull",
                    srcBag   = s.bag,
                    srcSlot  = s.slot,
                    name     = itemNames[key],
                    icon     = s.icon,
                    quantity = pullQty,
                })
                needed = needed - pullQty
            end
        end
    end

    if #ops == 0 then
        ns:Print("Nothing to pull (already have target qty in bags).")
        return
    end
    ns:Print("Pulling " .. #ops .. " saleable item(s) from warbank...")
    RunManualBankOp({
        ops          = ops,
        payloadKey   = "pulls",
        processLabel = "Pull Saleable...",
        phase        = "Pulling",
        successWord  = "Pulled",
        errorNoun    = "pull",
    })
end

local function DoPullGold()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker or not Tracker.AutoWithdrawGold then return end
    -- (#155) AutoWithdrawGold gates on goldWithdrawMode != "disabled",
    -- so a manual button click runs whenever the action is enabled.
    -- The previous save-restore-toggle hack to bypass the autoWithdrawGold
    -- bool gate isn't needed anymore.
    local withdrawn = Tracker:AutoWithdrawGold()
    if not withdrawn or withdrawn == 0 then
        ns:Print("Already at or above target balance — nothing to withdraw.")
    end
    PostOp()
end

local function DoDepositGold()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker or not Tracker.AutoDepositGold then return end
    local deposited = Tracker:AutoDepositGold()
    if not deposited or deposited == 0 then
        ns:Print("At or below target balance — nothing to deposit.")
    end
    PostOp()
end

-- (#155) Drawer button: deposit reagent-class items not needed by current
-- to-do tasks. Mirrors DoExtras (DoExtras for non-reagent items).
local function DoReagents()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker then return end
    local depositSlots = {}
    local depositOps = Tracker:BuildDepositOps()
    for _, op in ipairs(depositOps) do
        depositSlots[op.srcBag .. ":" .. op.srcSlot] = true
    end
    local extraOps = Tracker:BuildExtraDepositOps(depositSlots)
    for _, op in ipairs(extraOps) do
        depositSlots[op.srcBag .. ":" .. op.srcSlot] = true
    end
    local ops = Tracker:BuildReagentDepositOps(depositSlots)
    if #ops == 0 then ns:Print("No reagents to deposit.") return end
    RunManualBankOp({
        ops          = ops,
        payloadKey   = "reagents",
        processLabel = "Reagents...",
        phase        = "Depositing",
        successWord  = "Reagents",
        errorNoun    = "reagent",
    })
end

--------------------------
-- Gold amount helpers
--------------------------

local function GetWithdrawLabel()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker or not Tracker.CalculateRequiredGold then return "Withdraw Gold" end
    local charKey = ns:GetCharKey()
    local currentRealm = charKey:match("%-(.+)$") or GetRealmName()
    local ok, totalCopper = pcall(Tracker.CalculateRequiredGold, Tracker, charKey, currentRealm)
    if not ok then totalCopper = 0 end
    local bufferCopper = (ns.db and ns.db.settings.goldBuffer or 0) * 10000
    local estimatedCopper = math.max(bufferCopper, math.ceil((totalCopper or 0) * 1.1))
    estimatedCopper = math.ceil(estimatedCopper / 10000) * 10000
    local playerCopper = GetMoney and GetMoney() or 0
    if playerCopper >= estimatedCopper then return "Withdraw Gold: 0g" end
    local shortfall = estimatedCopper - playerCopper
    return "Withdraw Gold: " .. ns:FormatGold(shortfall)
end

local function GetDepositLabel()
    if not Tracker then Tracker = ns.Tracker end
    if not Tracker or not Tracker.CalculateRequiredGold then return "Deposit Earnings" end
    local charKey = ns:GetCharKey()
    local currentRealm = charKey:match("%-(.+)$") or GetRealmName()
    local ok, feesCopper = pcall(Tracker.CalculateRequiredGold, Tracker, charKey, currentRealm)
    if not ok then feesCopper = 0 end
    local bufferCopper = (ns.db and ns.db.settings.goldBuffer or 0) * 10000
    local keepCopper = math.max(bufferCopper, math.ceil((feesCopper or 0) * 1.1))
    keepCopper = math.ceil(keepCopper / 10000) * 10000
    local playerCopper = GetMoney and GetMoney() or 0
    if playerCopper <= keepCopper then return "Deposit Earnings: 0g" end
    local excess = playerCopper - keepCopper
    return "Deposit Earnings: " .. ns:FormatGold(excess)
end

--------------------------
-- Scroll row factory
--------------------------

local ROW_HEIGHT = 34

local function GetOrCreateScanRow(parent, index)
    if scanRows[index] then return scanRows[index] end

    local row = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    row:SetHeight(ROW_HEIGHT)
    row:EnableMouse(true)
    row:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 6,
        insets = {left = 1, right = 1, top = 1, bottom = 1},
    })
    row:SetBackdropColor(0.08, 0.08, 0.12, 0.6)
    row:SetBackdropBorderColor(0.2, 0.2, 0.3, 0.5)

    -- Line 1: [icon] Name                          x5
    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(16, 16)
    row.icon:SetPoint("TOPLEFT", row, "TOPLEFT", 4, -2)

    row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.name:SetPoint("LEFT", row.icon, "RIGHT", 4, 0)
    row.name:SetJustifyH("LEFT")
    row.name:SetTextColor(0.85, 0.85, 0.9)
    row.name:SetWordWrap(false)

    row.qty = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.qty:SetPoint("TOPRIGHT", row, "TOPRIGHT", -78, -4)
    row.qty:SetJustifyH("RIGHT")
    row.qty:SetTextColor(0.7, 0.7, 0.7)

    row.name:SetPoint("RIGHT", row.qty, "LEFT", -4, 0)

    -- Line 2: Deal: 500g  Post: 450g  [status]    [Post][Skip]
    row.dealPrice = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.dealPrice:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 4, 3)
    row.dealPrice:SetJustifyH("LEFT")
    row.dealPrice:SetTextColor(0.5, 0.8, 0.5)

    row.postPrice = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.postPrice:SetPoint("LEFT", row.dealPrice, "RIGHT", 6, 0)
    row.postPrice:SetJustifyH("LEFT")
    row.postPrice:SetTextColor(0.9, 0.85, 0.5)

    row.info = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.info:SetPoint("LEFT", row.postPrice, "RIGHT", 6, 0)
    row.info:SetJustifyH("LEFT")

    row.postBtn = CreateActionButton(row, "Post", "Post this item", nil)
    row.postBtn:SetSize(34, 14)
    row.postBtn:SetPoint("TOPRIGHT", row, "TOPRIGHT", -2, -2)

    row.skipBtn = CreateActionButton(row, "Skip", "Skip this item", nil)
    row.skipBtn:SetSize(34, 14)
    row.skipBtn:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -2, 2)

    row._result = nil
    row:SetScript("OnEnter", function(self)
        local r = self._result
        if not r then return end
        GameTooltip:SetOwner(self, "ANCHOR_TOPRIGHT")
        -- Prefer the actual item link from the bag — it carries bonus IDs
        -- and modifiers so the tooltip shows the correct ilvl / sockets /
        -- variant. SetItemByID falls back to the base item with no
        -- bonuses, which misrepresents upgraded gear and recipe ranks.
        if r.itemLink then
            GameTooltip:SetHyperlink(r.itemLink)
        elseif not ns:SetTooltipItem(GameTooltip, r.itemKey, r.itemID) then
            GameTooltip:SetText(r.name or "?", 1, 1, 1)
        end
        if r.pricing then
            GameTooltip:AddLine(" ")
            -- Only the post price (what TSM's decision tree actually chose)
            -- goes in the headline row. When the decision was "skip" (below
            -- min with no reset / above max with no fallback / whitelisted
            -- competitor / invalid seller) we show the reason explicitly —
            -- falling back to normalCopper here would lie about what
            -- FlipQueue is going to do.
            if r.pricing.buyoutCopper then
                GameTooltip:AddDoubleLine("Post price:", ns:FormatGoldPrecise(r.pricing.buyoutCopper), 0.7, 0.7, 0.7, 1, 1, 1)
            elseif r.pricing.belowThreshold then
                GameTooltip:AddDoubleLine("Post price:", "skip (below min)", 0.7, 0.7, 0.7, 1, 0.6, 0.3)
            elseif r.pricing.aboveMaxSkip then
                GameTooltip:AddDoubleLine("Post price:", "skip (above max)", 0.7, 0.7, 0.7, 1, 0.6, 0.3)
            elseif r.pricing.skipWhitelist then
                GameTooltip:AddDoubleLine("Post price:", "skip (whitelist)", 0.7, 0.7, 0.7, 1, 0.6, 0.3)
            elseif r.pricing.invalidReason then
                GameTooltip:AddDoubleLine("Post price:", "skip (" .. r.pricing.invalidReason .. ")", 0.7, 0.7, 0.7, 1, 0.4, 0.3)
            elseif r.pricing.lowestSourceKey == "none" or r.pricing.lowestSourceKey == "stale" then
                GameTooltip:AddDoubleLine("Post price:", "scan pending…", 0.7, 0.7, 0.7, 0.8, 0.8, 0.6)
            end
            -- Market reference: what we undercut (from live scan or TSM DBMinBuyout).
            if r.pricing.lowestCopper then
                GameTooltip:AddDoubleLine("AH lowest:", ns:FormatGoldPrecise(r.pricing.lowestCopper), 0.7, 0.7, 0.7, 0.85, 0.85, 1)
            end
            -- Our own existing lowest auction for this item, if any. Shown so
            -- players can see when FlipQueue is matching their own price
            -- (the "we're lowest" branch) instead of undercutting.
            if r.pricing.ownLowestCopper then
                GameTooltip:AddDoubleLine("Your lowest:", ns:FormatGoldPrecise(r.pricing.ownLowestCopper), 0.7, 0.7, 0.7, 0.7, 0.9, 0.7)
            end
            -- Why we chose that price (undercut / reset / aboveMax / match own / no competition).
            if r.pricing.reason then
                GameTooltip:AddDoubleLine("Reason:", r.pricing.reason, 0.7, 0.7, 0.7, 0.6, 0.8, 0.6)
            end
            -- Freshness of the `AH lowest` number. The decision tree picks
            -- whichever source is freshest (live cache when recent enough vs
            -- TSM's hourly DBMinBuyout snapshot); we just surface what was
            -- chosen so the player can judge whether to run a fresh scan.
            if r.pricing.lowestSourceLabel then
                local label = r.pricing.lowestSourceLabel
                if r.pricing.lowestAgeSec then
                    local age = r.pricing.lowestAgeSec
                    local ageText
                    if age < 60 then
                        ageText = age .. "s ago"
                    elseif age < 3600 then
                        ageText = math.floor(age / 60) .. "m ago"
                    elseif age < 86400 then
                        ageText = math.floor(age / 3600) .. "h ago"
                    else
                        ageText = math.floor(age / 86400) .. "d ago"
                    end
                    if r.pricing.lowestSourceKey == "live" then
                        label = "live " .. ageText
                    elseif r.pricing.lowestSourceKey == "stale" then
                        label = "stale (" .. ageText .. ") — auto-scan running"
                    end
                end
                local r2, g2, b2 = 0.6, 0.9, 0.6  -- live = green
                if r.pricing.lowestSourceKey == "stale" then
                    r2, g2, b2 = 0.9, 0.7, 0.4    -- stale = amber
                elseif r.pricing.lowestSourceKey == "none" then
                    r2, g2, b2 = 0.7, 0.7, 0.4
                end
                GameTooltip:AddDoubleLine("Scan:", label, 0.7, 0.7, 0.7, r2, g2, b2)
            end
            -- Baseline (normalPrice) is still interesting context but not the
            -- post price itself.
            if r.pricing.normalCopper and r.pricing.normalCopper ~= r.pricing.buyoutCopper then
                GameTooltip:AddDoubleLine("Normal:", ns:FormatGold(r.pricing.normalCopper), 0.7, 0.7, 0.7, 0.7, 0.7, 0.7)
            end
            if r.pricing.minCopper then
                GameTooltip:AddDoubleLine("Min price:", ns:FormatGold(r.pricing.minCopper), 0.7, 0.7, 0.7, 0.9, 0.7, 0.3)
            end
            if r.pricing.maxCopper then
                GameTooltip:AddDoubleLine("Max price:", ns:FormatGold(r.pricing.maxCopper), 0.7, 0.7, 0.7, 0.9, 0.7, 0.3)
            end
            if r.pricing.opName then
                if r.pricing.opName == "#Default" then
                    -- The auto-fallback op fires when an item isn't in any
                    -- TSM Auctioning group. TSM's posting view doesn't
                    -- always scan competitors for fallback-op items, so
                    -- TSM may report "no competitors" even when listings
                    -- exist. FlipQueue's passive scan listener picks them
                    -- up regardless, so the post price can diverge.
                    GameTooltip:AddDoubleLine("TSM operation:", "#Default (fallback)",
                        0.7, 0.7, 0.7, 0.7, 0.7, 0.5)
                    GameTooltip:AddLine(
                        "This item isn't in a TSM Auctioning group. TSM may not show competitors for fallback-op items, but FlipQueue's scan listener still picks them up. Add to a TSM group for parity.",
                        0.55, 0.55, 0.55, true)
                else
                    GameTooltip:AddDoubleLine("TSM operation:", r.pricing.opName, 0.7, 0.7, 0.7, 0.5, 0.7, 1)
                end
            end
            if r.pricing.postCap then
                GameTooltip:AddDoubleLine("Post cap:", tostring(r.pricing.postCap), 0.7, 0.7, 0.7, 0.7, 0.7, 0.7)
            end
        end
        if r.dealPrice then
            GameTooltip:AddDoubleLine("Deal price:", r.dealPrice, 0.7, 0.7, 0.7, 0.4, 0.9, 0.4)
        end
        if r.status == "below_threshold" then
            GameTooltip:AddLine("Cheapest auction below min price", 1, 0.4, 0.3)
        elseif r.status == "above_max" then
            GameTooltip:AddLine("Above operation's max price (aboveMax=none)", 1, 0.6, 0.3)
        elseif r.status == "no_price" then
            GameTooltip:AddLine("No TSM pricing data", 0.7, 0.7, 0.7)
        end
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)

    scanRows[index] = row
    return row
end

local function GetOrCreateOwnedRow(parent, index)
    if ownedRows[index] then return ownedRows[index] end

    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(ROW_HEIGHT)
    row:EnableMouse(true)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(16, 16)
    row.icon:SetPoint("LEFT", row, "LEFT", 0, 0)

    row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.name:SetPoint("LEFT", row.icon, "RIGHT", 4, 0)
    row.name:SetJustifyH("LEFT")
    row.name:SetTextColor(0.85, 0.85, 0.9)

    row.qty = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.qty:SetWidth(30)
    row.qty:SetJustifyH("RIGHT")
    row.qty:SetTextColor(0.7, 0.7, 0.7)

    row.price = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.price:SetWidth(60)
    row.price:SetJustifyH("RIGHT")
    row.price:SetTextColor(0.9, 0.85, 0.6)

    row.undercut = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.undercut:SetJustifyH("LEFT")
    row.undercut:SetTextColor(1, 0.4, 0.3)

    row.cancelBtn = CreateActionButton(row, "Cancel", "Cancel this auction", nil)
    row.cancelBtn:SetSize(46, ROW_HEIGHT - 2)

    row._auction = nil
    row:SetScript("OnEnter", function(self)
        local a = self._auction
        if not a then return end
        GameTooltip:SetOwner(self, "ANCHOR_TOPRIGHT")
        if not ns:SetTooltipItem(GameTooltip, a.itemKey, a.itemID) then
            GameTooltip:SetText(a.name or "?", 1, 1, 1)
        end
        GameTooltip:AddLine(" ")
        if a.totalBuyout then
            GameTooltip:AddDoubleLine("Your price:", ns:FormatGold(a.totalBuyout), 0.7, 0.7, 0.7, 1, 1, 1)
        end
        if a.buyoutPerUnit and a.quantity and a.quantity > 1 then
            GameTooltip:AddDoubleLine("Per unit:", ns:FormatGold(a.buyoutPerUnit), 0.7, 0.7, 0.7, 0.7, 0.7, 0.7)
        end
        if a.marketPrice then
            GameTooltip:AddDoubleLine("Market (DBMinBuyout):", ns:FormatGold(a.marketPrice), 0.7, 0.7, 0.7, 0.5, 0.8, 1)
        end
        if a.isUndercut then
            local diff = (a.undercutBy and a.undercutBy > 0) and ns:FormatGold(a.undercutBy) or "?"
            GameTooltip:AddLine("Undercut by " .. diff, 1, 0.3, 0.3)
        end
        if a.timeLeft then
            local hrs = math.floor(a.timeLeft / 3600)
            local mins = math.floor((a.timeLeft % 3600) / 60)
            GameTooltip:AddDoubleLine("Time left:", hrs .. "h " .. mins .. "m", 0.7, 0.7, 0.7, 0.7, 0.7, 0.7)
        end
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Anchors: [icon][name...][qty][price][undercut][Cancel]
    row.cancelBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0)
    row.undercut:SetPoint("RIGHT", row.cancelBtn, "LEFT", -4, 0)
    row.price:SetPoint("RIGHT", row.undercut, "LEFT", -4, 0)
    row.qty:SetPoint("RIGHT", row.price, "LEFT", -4, 0)
    row.name:SetPoint("RIGHT", row.qty, "LEFT", -4, 0)

    ownedRows[index] = row
    return row
end

--------------------------
-- Bank context builder
--------------------------

local bankHeader = nil

local function BuildBankContent(parent)
    if bankFrame then return bankFrame end

    bankFrame = CreateFrame("Frame", nil, parent)
    bankFrame:SetPoint("TOPLEFT", parent, "TOPLEFT", PAD, -PAD)
    bankFrame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -PAD, -PAD)
    bankFrame:SetHeight(BANK_CONTENT_H - PAD)  -- content below top pad

    -- Header
    bankHeader = bankFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bankHeader:SetPoint("TOPLEFT", bankFrame, "TOPLEFT", 0, 0)
    bankHeader:SetText("Bank Operations")
    bankHeader:SetTextColor(1, 1, 1)

    local function RowY(row)
        return -(HEADER_HEIGHT + (row - 1) * (BTN_HEIGHT + BTN_SPACING))
    end

    -- We need to defer width calculation — use OnShow to resize.
    -- (#155) Row 1 grew from 4 buttons to 5 (added Deposit Reagents).
    local function LayoutButtons()
        local contentW = bankFrame:GetWidth()
        if contentW <= 0 then contentW = 300 end
        local col5W = math.floor((contentW - BTN_SPACING * 4) / 5)
        local col2W = math.floor((contentW - BTN_SPACING) / 2)

        -- Row 1: 5 equal buttons (Pause + 4 action buttons)
        if bankButtons.pause then
            bankButtons.pause:SetWidth(col5W)
        end
        if bankButtons.pull then
            bankButtons.pull:SetWidth(col5W)
        end
        if bankButtons.deposit then
            bankButtons.deposit:SetWidth(col5W)
        end
        if bankButtons.extras then
            bankButtons.extras:SetWidth(col5W)
        end
        if bankButtons.reagents then
            bankButtons.reagents:SetWidth(col5W)
        end

        -- Row 2: 2 half buttons
        if bankButtons.pullGold then
            bankButtons.pullGold:SetWidth(col2W)
        end
        if bankButtons.depGold then
            bankButtons.depGold:SetWidth(col2W)
        end

        -- Row 3: full width
        -- pullAll is anchored left+right so no explicit width needed
    end

    -- Row 1: [Pause] [Pull Tasks] [Deposit Tasks] [Deposit Extras] [Deposit Reagents]
    bankButtons.pause = CreateActionButton(bankFrame, "Pause Automation",
        "Pause auto-fire on bank open. Manual buttons stay available — pause only blocks auto-fire, not manual access.", DoPause)
    bankButtons.pause:SetPoint("TOPLEFT", bankFrame, "TOPLEFT", 0, RowY(1))

    bankButtons.pull = CreateActionButton(bankFrame, "Pull Tasks",
        "Pull items from bank for current character's tasks", DoPull)
    bankButtons.pull:SetPoint("LEFT", bankButtons.pause, "RIGHT", BTN_SPACING, 0)

    -- (#155) Renamed from "Deposit Items" — these are deposits driven by
    -- to-do tasks (cross-character routing), not "all items in bag."
    bankButtons.deposit = CreateActionButton(bankFrame, "Deposit Tasks",
        "Deposit task items to warbank for other characters", DoDeposit)
    bankButtons.deposit:SetPoint("LEFT", bankButtons.pull, "RIGHT", BTN_SPACING, 0)

    bankButtons.extras = CreateActionButton(bankFrame, "Deposit Extras",
        "Deposit non-task, non-reagent items not needed by current character", DoExtras)
    bankButtons.extras:SetPoint("LEFT", bankButtons.deposit, "RIGHT", BTN_SPACING, 0)

    -- (#155) New action class: reagents have their own button + tri-state.
    bankButtons.reagents = CreateActionButton(bankFrame, "Deposit Reagents",
        "Deposit Tradegoods (reagents) not needed by current character", DoReagents)
    bankButtons.reagents:SetPoint("LEFT", bankButtons.extras, "RIGHT", BTN_SPACING, 0)

    -- Row 2: [Withdraw Gold: Xg] [Deposit Earnings: Xg]
    bankButtons.pullGold = CreateActionButton(bankFrame, GetWithdrawLabel(),
        "Withdraw gold from warbank for fees + purchases", DoPullGold)
    bankButtons.pullGold:SetPoint("TOPLEFT", bankFrame, "TOPLEFT", 0, RowY(2))

    bankButtons.depGold = CreateActionButton(bankFrame, GetDepositLabel(),
        "Deposit excess gold to warbank", DoDepositGold)
    bankButtons.depGold:SetPoint("LEFT", bankButtons.pullGold, "RIGHT", BTN_SPACING, 0)

    -- Row 3: [Pull Saleable] — full width
    bankButtons.pullAll = CreateActionButton(bankFrame, "Pull Saleable",
        "Pull every saleable item from warbank (TSM postCap or default qty per item)", DoPullSaleable)
    bankButtons.pullAll:SetPoint("TOPLEFT", bankFrame, "TOPLEFT", 0, RowY(3))
    bankButtons.pullAll:SetPoint("RIGHT", bankFrame, "RIGHT", 0, 0)

    bankFrame:SetScript("OnShow", LayoutButtons)
    -- Also lay out immediately if we have width
    C_Timer.After(0, LayoutButtons)

    RefreshPauseButton()
    return bankFrame
end

-- (#155) Drawer button visibility: each button is hidden only when its
-- action class is in "disabled" mode. "auto" and "manual" both keep the
-- button visible (manual access stays available; pause doesn't hide
-- buttons either, it just blocks auto-fire). The Pause button itself
-- stays visible regardless — pausing is a meta-control.
local function ApplyMasterVisibility()
    local Tracker = ns.Tracker
    if not Tracker or not Tracker.GetActionMode then return end
    local charKey = ns.GetCharKey and ns:GetCharKey()
    if not charKey then return end

    local function visible(class)
        return Tracker:GetActionMode(charKey, class) ~= "disabled"
    end

    local todoVis     = visible("todo")
    local extrasVis   = visible("extras")
    local reagentsVis = visible("reagents")
    local goldWVis    = visible("goldWithdraw")
    local goldDVis    = visible("goldDeposit")
    -- Pull Saleable lives outside the tri-state model (always-manual,
    -- per architecture). Hide only when manageItems master is off.
    local itemsMaster = Tracker:InScope(charKey, "items")

    if bankButtons.pull     then if todoVis     then bankButtons.pull:Show()     else bankButtons.pull:Hide()     end end
    if bankButtons.deposit  then if todoVis     then bankButtons.deposit:Show()  else bankButtons.deposit:Hide()  end end
    if bankButtons.extras   then if extrasVis   then bankButtons.extras:Show()   else bankButtons.extras:Hide()   end end
    if bankButtons.reagents then if reagentsVis then bankButtons.reagents:Show() else bankButtons.reagents:Hide() end end
    if bankButtons.pullAll  then if itemsMaster then bankButtons.pullAll:Show()  else bankButtons.pullAll:Hide()  end end

    if bankButtons.pullGold then if goldWVis then bankButtons.pullGold:Show() else bankButtons.pullGold:Hide() end end
    if bankButtons.depGold  then if goldDVis then bankButtons.depGold:Show()  else bankButtons.depGold:Hide()  end end
end

local function RefreshBankLabels()
    if bankButtons.pullGold then
        bankButtons.pullGold.label:SetText(GetWithdrawLabel())
    end
    if bankButtons.depGold then
        bankButtons.depGold.label:SetText(GetDepositLabel())
    end
    ApplyMasterVisibility()
    RefreshPauseButton()
end

--------------------------
-- AH context builder
--------------------------

local ahContentFrame = nil
local ahHeader       = nil
local ahScanTodo     = nil
local ahScanAll      = nil
local ahPostAll      = nil
local ahSeparator    = nil
local ahOwnedHeader  = nil
local ahCancelUnder  = nil
local ahNoModule     = nil

local MAX_SCAN_ROWS  = 6
local MAX_OWNED_ROWS = 4

local function CalculateAHHeight()
    -- Header + top row
    local h = HEADER_HEIGHT + BTN_HEIGHT + BTN_SPACING

    -- Scan results
    local scanCount = math.min(#currentScanResults, MAX_SCAN_ROWS)
    if scanCount > 0 then
        h = h + scanCount * ROW_HEIGHT + BTN_SPACING
    end

    -- Post All button (only if scan results exist)
    if #currentScanResults > 0 then
        h = h + BTN_HEIGHT + BTN_SPACING
    end

    -- Separator + owned header
    h = h + 2 + BTN_SPACING + HEADER_HEIGHT + BTN_SPACING

    -- Owned auction rows
    local ownedCount = math.min(#currentOwnedAuctions, MAX_OWNED_ROWS)
    if ownedCount > 0 then
        h = h + ownedCount * ROW_HEIGHT + BTN_SPACING
    end

    -- Cancel Undercuts button
    local hasUndercuts = false
    for _, a in ipairs(currentOwnedAuctions) do
        if a.isUndercut then hasUndercuts = true; break end
    end
    if hasUndercuts then
        h = h + BTN_HEIGHT + BTN_SPACING
    end

    h = h + PAD * 2
    return math.min(h, 400)
end

local function FindDealPrice(itemKey, itemName)
    if not ns.TodoList then return nil end
    local list = ns.TodoList:GetCurrentList()
    if not list or not list.tasks then return nil end
    local lname = itemName and itemName:lower() or nil
    for _, task in ipairs(list.tasks) do
        if task.status == "pending" and task.expectedPrice and task.expectedPrice ~= "" then
            if task.itemKey == itemKey
                or (lname and task.name and task.name:lower() == lname) then
                return task.expectedPrice
            end
        end
    end
    return nil
end

local function RefreshAHScanRows()
    if not ahContentFrame then
        ns:PrintDebug("[ContextDrawer] RefreshAHScanRows: ahContentFrame nil")
        return
    end
    local scanCount = math.min(#currentScanResults, MAX_SCAN_ROWS)
    ns:PrintDebug("[ContextDrawer] RefreshAHScanRows: " .. #currentScanResults ..
        " results, showing " .. scanCount .. " rows, frame=" ..
        tostring(ahContentFrame:IsShown()) ..
        " parent=" .. tostring(ahContentFrame:GetParent() and ahContentFrame:GetParent():IsShown()) ..
        " clipH=" .. tostring(contextClip and contextClip:GetHeight() or "nil") ..
        " contentH=" .. tostring(contextContent and contextContent:GetHeight() or "nil"))
    local AP = ns.AuctionPost

    for i = 1, MAX_SCAN_ROWS do
        local row = scanRows[i]
        if row then row:Hide() end
    end

    -- Rows anchor below the Scan buttons + the Post Next button (the latter
    -- sits in a fixed slot above the queue so it doesn't drift as items get
    -- posted off the top).
    local yOff = -(HEADER_HEIGHT + BTN_HEIGHT + BTN_SPACING)
    if #currentScanResults > 0 then
        yOff = yOff - BTN_HEIGHT - BTN_SPACING
    end

    -- Identify the next-to-post item so we can highlight that row. PostNext
    -- always picks the first ready item in the list.
    local nextReadyIdx
    for i, result in ipairs(currentScanResults) do
        if result.status == "ready" then
            nextReadyIdx = i
            break
        end
    end

    for i = 1, scanCount do
        local row = GetOrCreateScanRow(ahContentFrame, i)
        local result = currentScanResults[i]
        row:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, yOff - (i - 1) * ROW_HEIGHT)
        row:SetPoint("RIGHT", ahContentFrame, "RIGHT", 0, 0)

        -- Visual indicator for the next-to-post item.
        if i == nextReadyIdx then
            row:SetBackdropColor(0.15, 0.25, 0.45, 0.85)
            row:SetBackdropBorderColor(0.45, 0.65, 0.95, 0.95)
        else
            row:SetBackdropColor(0.08, 0.08, 0.12, 0.6)
            row:SetBackdropBorderColor(0.2, 0.2, 0.3, 0.5)
        end

        if result.icon then row.icon:SetTexture(result.icon) end
        row.name:SetText(result.name or "?")
        row.qty:SetText("x" .. (result.postQty or result.totalCount or 1))

        -- Deal price from todo list
        local dealStr = FindDealPrice(result.itemKey, result.name)
        result.dealPrice = dealStr
        row._result = result

        if dealStr then
            row.dealPrice:SetText("|cff88bb88Deal:|r " .. dealStr)
        else
            row.dealPrice:SetText("")
        end

        -- Post price = what TSM's decision tree chose. When the decision was
        -- a skip, say so — the old "fall back to normalCopper" showed a
        -- number that looked like the post price but wasn't.
        local pricing = result.pricing
        if pricing and pricing.buyoutCopper then
            row.postPrice:SetText("|cffddcc66Post:|r " .. ns:FormatGold(pricing.buyoutCopper))
        elseif pricing and (pricing.belowThreshold or pricing.aboveMaxSkip
            or pricing.skipWhitelist or pricing.invalidReason) then
            row.postPrice:SetText("|cff888888Post:|r skip")
        elseif pricing and (pricing.lowestSourceKey == "none"
            or pricing.lowestSourceKey == "stale") then
            row.postPrice:SetText("|cffaaaa55Post:|r scanning…")
        else
            row.postPrice:SetText("")
        end

        -- Compact form of the TSM decision reason for inline display. The
        -- full string lives in pricing.reason; we trim it for the row so
        -- the player can see at a glance which branch fired ("undercut",
        -- "match own", "reset → min", etc.) without opening the tooltip.
        local function CompactReason(reason)
            if not reason or reason == "" then return nil end
            if reason == "undercut"                       then return "undercut"        end
            if reason == "undercut blacklist"             then return "undercut (bl)"   end
            if reason == "match whitelist"                then return "match wl"        end
            if reason:find("normal %(no competition%)")   then return "normal · no comp." end
            if reason:find("match own")                   then return "match own"       end
            if reason:find("^reset_minPrice")             then return "reset → min"    end
            if reason:find("^reset_maxPrice")             then return "reset → max"    end
            if reason:find("^reset_normalPrice")          then return "reset → normal" end
            if reason:find("^aboveMax_minPrice")          then return "max → min"      end
            if reason:find("^aboveMax_maxPrice")          then return "max → max"      end
            if reason:find("^aboveMax_normalPrice")       then return "max → normal"   end
            return reason
        end

        -- Inline op-name tag rendered after the status text. #Default is
        -- TSM's auto-fallback op for ungrouped items; styled muted gray
        -- with a (fallback) suffix so the player can spot at a glance
        -- when our decision came from the lazier code path. Custom ops
        -- get a soft blue tag (clearly distinguishable from the gray
        -- fallback) and are truncated past 14 chars so long op names
        -- don't crowd the row.
        local function OpTag(p)
            if not (p and p.opName) then return "" end
            if p.opName == "#Default" then
                return "  |cff888888[#Default (fallback)]|r"
            end
            local name = p.opName
            if #name > 14 then name = name:sub(1, 13) .. "…" end
            return "  |cff7eb0d8[" .. name .. "]|r"
        end
        local opTag = OpTag(pricing)

        -- Status info and row coloring
        if result.status == "below_threshold" then
            row.info:SetText("|cffff6644Below min price|r" .. opTag)
            row.name:SetTextColor(0.7, 0.5, 0.5)
            row:SetBackdropBorderColor(0.5, 0.2, 0.2, 0.7)
        elseif result.status == "above_max" then
            row.info:SetText("|cffff9944Above max price|r" .. opTag)
            row.name:SetTextColor(0.7, 0.6, 0.5)
            row:SetBackdropBorderColor(0.5, 0.3, 0.15, 0.7)
        elseif result.status == "no_price" then
            row.info:SetText("|cff888888No TSM data|r" .. opTag)
            row.name:SetTextColor(0.6, 0.6, 0.6)
            row:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5)
        elseif result.status == "scan_pending" then
            row.info:SetText("|cffaaaa55Scan pending|r" .. opTag)
            row.name:SetTextColor(0.7, 0.7, 0.6)
            row:SetBackdropBorderColor(0.4, 0.4, 0.2, 0.6)
        elseif result.status == "skip_whitelist" then
            row.info:SetText("|cffffaa44Whitelisted competitor|r" .. opTag)
            row.name:SetTextColor(0.7, 0.6, 0.5)
            row:SetBackdropBorderColor(0.5, 0.4, 0.2, 0.7)
        elseif result.status == "invalid" then
            row.info:SetText("|cffff4444" .. (pricing and pricing.invalidReason or "invalid") .. "|r" .. opTag)
            row.name:SetTextColor(0.7, 0.4, 0.4)
            row:SetBackdropBorderColor(0.6, 0.2, 0.2, 0.7)
        elseif result.status == "dnt" then
            row.info:SetText("|cffff8800Do Not Track|r" .. opTag)
            row.name:SetTextColor(0.6, 0.5, 0.4)
            row:SetBackdropBorderColor(0.5, 0.35, 0.1, 0.7)
        else
            -- Ready row — surface the TSM decision rule (which branch of
            -- MakePostDecision fired) so the player can see it without
            -- hovering. Tooltip still has the full reason string.
            local rule = pricing and CompactReason(pricing.reason)
            if rule then
                row.info:SetText("|cff8aa8d8" .. rule .. "|r" .. opTag)
            elseif opTag ~= "" then
                row.info:SetText(opTag:gsub("^  ", ""))  -- no rule, opTag without leading spacer
            else
                row.info:SetText("")
            end
            row.name:SetTextColor(0.85, 0.85, 0.9)
            row:SetBackdropBorderColor(0.2, 0.2, 0.3, 0.5)
        end

        -- Disable Post for non-ready items
        if result.status ~= "ready" then
            row.postBtn:Disable()
            row.postBtn.label:SetTextColor(0.4, 0.4, 0.4)
        else
            row.postBtn:Enable()
            row.postBtn.label:SetTextColor(0.85, 0.85, 0.9)
        end

        local capturedI = i
        row.postBtn:SetScript("OnClick", function()
            local ap = ns.AuctionPost
            if ap and ap.PostItem then
                ap:PostItem(result, function(ok)
                    if ok then
                        table.remove(currentScanResults, capturedI)
                        RefreshAHScanRows()
                    end
                end)
            end
        end)
        row.skipBtn:SetScript("OnClick", function()
            -- Mark linked to-do task as skipped
            if ns.TodoList then
                local currentList = ns.TodoList:GetCurrentList()
                if currentList and currentList.tasks then
                    for ti, task in ipairs(currentList.tasks) do
                        if task.status == "pending" then
                            local matched = ns:ItemsMatch(result.itemKey, result.name, task, nil)
                            if matched then
                                task.status = "skipped"
                                task.failReason = "Skipped from AH drawer"
                                break
                            end
                        end
                    end
                end
            end
            table.remove(currentScanResults, capturedI)
            RefreshAHScanRows()
        end)
        row:Show()
    end
end

local function RefreshAHOwnedRows()
    if not ahContentFrame then return end
    local ownedCount = math.min(#currentOwnedAuctions, MAX_OWNED_ROWS)
    local AP = ns.AuctionPost

    for i = 1, MAX_OWNED_ROWS do
        local row = ownedRows[i]
        if row then row:Hide() end
    end

    if not ahOwnedHeader then return end

    ahOwnedHeader:SetText("Your Auctions (" .. #currentOwnedAuctions .. ")")

    -- Position owned rows below the separator
    for i = 1, ownedCount do
        local row = GetOrCreateOwnedRow(ahContentFrame, i)
        local auction = currentOwnedAuctions[i]

        if auction.icon then row.icon:SetTexture(auction.icon) end
        row.name:SetText(auction.name or "?")
        row.qty:SetText(auction.quantity and ("x" .. auction.quantity) or "")
        row.price:SetText(auction.buyoutPerUnit and ns:FormatGold(auction.buyoutPerUnit) or "")

        if auction.isUndercut then
            row.undercut:SetText("|cffff4433undercut|r")
            row.name:SetTextColor(1, 0.5, 0.4)
        else
            row.undercut:SetText("")
            row.name:SetTextColor(0.85, 0.85, 0.9)
        end

        row._auction = auction

        local capturedI = i
        row.cancelBtn:SetScript("OnClick", function()
            local ap = ns.AuctionPost
            if ap and ap.CancelAuction and auction.auctionID then
                ap:CancelAuction(auction.auctionID, function(ok)
                    if ok then
                        table.remove(currentOwnedAuctions, capturedI)
                        RefreshAHOwnedRows()
                    end
                end)
            end
        end)
        row:Show()
    end

    -- Cancel Undercuts button
    local hasUndercuts = false
    for _, a in ipairs(currentOwnedAuctions) do
        if a.isUndercut then hasUndercuts = true; break end
    end
    if ahCancelUnder then
        if hasUndercuts then ahCancelUnder:Show() else ahCancelUnder:Hide() end
    end
end

local function BuildAHContent(parent)
    if ahContentFrame then return ahContentFrame end

    ahContentFrame = CreateFrame("Frame", nil, parent)
    ahContentFrame:SetPoint("TOPLEFT", parent, "TOPLEFT", PAD, -PAD)
    ahContentFrame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -PAD, -PAD)
    ahContentFrame:SetHeight(200)

    local AP = ns.AuctionPost

    -- Header
    ahHeader = ahContentFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    ahHeader:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, 0)
    ahHeader:SetText("Auction House")
    ahHeader:SetTextColor(1, 1, 1)

    -- Graceful degradation if AuctionPost not loaded
    if not AP then
        ahNoModule = ahContentFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        ahNoModule:SetPoint("TOPLEFT", ahHeader, "BOTTOMLEFT", 0, -4)
        ahNoModule:SetText("|cff888888AuctionPost not loaded|r")
        return ahContentFrame
    end

    -- Top row: [Scan To-Do] [Scan All]
    ahScanTodo = CreateActionButton(ahContentFrame, "Scan To-Do",
        "Scan bags for items on your to-do list and refresh live AH prices for them. Use this when auto-scan-on-open is off and you want fresh data now.", function()
            local ap = ns.AuctionPost
            if ap and ap.ScanBags then
                currentScanResults = ap:ScanBags(true) or {}
                ns._currentAHScanResults = currentScanResults
                ns:PrintDebug("[ContextDrawer] Scan To-Do: " .. #currentScanResults .. " results")
                if ns.AuctionAutoScan and ns.AuctionAutoScan.ScanQueue then
                    ns.AuctionAutoScan:ScanQueue(currentScanResults)
                end
                RefreshAHScanRows()
                if ahContentFrame._layoutAH then ahContentFrame._layoutAH() end
                local ahH = CalculateAHHeight() + THUMB_HEIGHT
                currentFullH = ahH
                contextContent:SetHeight(ahH)
                if contextClip and drawerOpen then
                    contextClip:SetHeight(ahH)
                end
            else
                ns:Print(ns.COLORS.RED .. "AuctionPost module not loaded.|r")
            end
        end)
    ahScanTodo:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, -HEADER_HEIGHT)

    ahScanAll = CreateActionButton(ahContentFrame, "Scan All",
        "Scan all postable bag items and refresh live AH prices for them. Heavier than Scan To-Do — only use when you want a full sweep.", function()
            local ap = ns.AuctionPost
            if ap and ap.ScanBags then
                currentScanResults = ap:ScanBags(false) or {}
                ns._currentAHScanResults = currentScanResults
                ns:PrintDebug("[ContextDrawer] Scan All: " .. #currentScanResults .. " results")
                if ns.AuctionAutoScan and ns.AuctionAutoScan.ScanQueue then
                    ns.AuctionAutoScan:ScanQueue(currentScanResults)
                end
                RefreshAHScanRows()
                if ahContentFrame._layoutAH then ahContentFrame._layoutAH() end
                local ahH = CalculateAHHeight() + THUMB_HEIGHT
                currentFullH = ahH
                contextContent:SetHeight(ahH)
                if contextClip and drawerOpen then
                    contextClip:SetHeight(ahH)
                end
            else
                ns:Print(ns.COLORS.RED .. "AuctionPost module not loaded.|r")
            end
        end)
    ahScanAll:SetPoint("LEFT", ahScanTodo, "RIGHT", BTN_SPACING, 0)

    -- Post All button (positioned dynamically)
    ahPostAll = CreateActionButton(ahContentFrame, "Post Next",
        "Post the next ready item (one per click — AH requires hardware event)", function()
            local ap = ns.AuctionPost
            if not ap or not ap.PostNext then return end
            local didPost = ap:PostNext(currentScanResults, function(success, item)
                if success then
                    -- Remove posted item from results
                    for ri = #currentScanResults, 1, -1 do
                        if currentScanResults[ri] == item then
                            table.remove(currentScanResults, ri)
                            break
                        end
                    end
                    RefreshAHScanRows()
                    local remaining = ap:CountReady(currentScanResults)
                    if remaining > 0 then
                        ahPostAll.label:SetText("Post Next (" .. remaining .. ")")
                    else
                        ahPostAll.label:SetText("Post Next")
                        ahPostAll:Hide()
                    end
                    if ahContentFrame._layoutAH then ahContentFrame._layoutAH() end
                    local ahH = CalculateAHHeight() + THUMB_HEIGHT
                    currentFullH = ahH
                    contextContent:SetHeight(ahH)
                    if contextClip and drawerOpen then
                        contextClip:SetHeight(ahH)
                    end
                end
            end)
            if not didPost then
                ns:Print("Nothing left to post.")
            end
        end)
    ahPostAll:Hide()

    -- Separator
    ahSeparator = ahContentFrame:CreateTexture(nil, "ARTWORK")
    ahSeparator:SetHeight(1)
    ahSeparator:SetColorTexture(0.3, 0.3, 0.4, 0.6)

    -- Owned header
    ahOwnedHeader = ahContentFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    ahOwnedHeader:SetText("Your Auctions (0)")
    ahOwnedHeader:SetTextColor(1, 1, 1)

    -- Cancel Undercuts
    ahCancelUnder = CreateActionButton(ahContentFrame, "Cancel Undercuts",
        "Cancel all undercut auctions", function()
            local AP = ns.AuctionPost
            if AP and AP.CancelAuction then
                local toCancel = {}
                for _, a in ipairs(currentOwnedAuctions) do
                    if a.isUndercut and a.auctionID then
                        table.insert(toCancel, a)
                    end
                end
                for _, a in ipairs(toCancel) do
                    AP:CancelAuction(a.auctionID, function() end)
                end
                -- Re-fetch after a short delay
                C_Timer.After(0.5, function()
                    if AP and AP.GetOwnedAuctions then
                        currentOwnedAuctions = AP:GetOwnedAuctions() or {}
                    end
                    RefreshAHOwnedRows()
                    UI:RefreshContextDrawer()
                end)
            end
        end)
    ahCancelUnder:Hide()

    -- Width layout
    local function LayoutAH()
        local contentW = ahContentFrame:GetWidth()
        if contentW <= 0 then contentW = 300 end
        local col2W = math.floor((contentW - BTN_SPACING) / 2)

        ahScanTodo:SetWidth(col2W)
        ahScanAll:SetWidth(col2W)

        -- Dynamic vertical layout
        local yOff = -(HEADER_HEIGHT + BTN_HEIGHT + BTN_SPACING)

        -- Post Next sits directly below the scan buttons in a fixed slot,
        -- ABOVE the queue. Posting an item shrinks the queue downward without
        -- moving Post Next, so the player's mouse stays on the button for
        -- repeated clicks.
        if #currentScanResults > 0 then
            ahPostAll:ClearAllPoints()
            ahPostAll:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, yOff)
            ahPostAll:SetPoint("RIGHT", ahContentFrame, "RIGHT", 0, 0)
            ahPostAll:Show()
            yOff = yOff - BTN_HEIGHT - BTN_SPACING
        else
            ahPostAll:Hide()
        end

        local scanCount = math.min(#currentScanResults, MAX_SCAN_ROWS)
        yOff = yOff - scanCount * ROW_HEIGHT
        if scanCount > 0 then yOff = yOff - BTN_SPACING end

        -- Separator
        ahSeparator:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, yOff)
        ahSeparator:SetPoint("RIGHT", ahContentFrame, "RIGHT", 0, 0)
        yOff = yOff - 2 - BTN_SPACING

        -- Owned header
        ahOwnedHeader:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, yOff)
        yOff = yOff - HEADER_HEIGHT - BTN_SPACING

        -- Owned rows
        local ownedCount = math.min(#currentOwnedAuctions, MAX_OWNED_ROWS)
        for i = 1, ownedCount do
            local row = GetOrCreateOwnedRow(ahContentFrame, i)
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, yOff - (i - 1) * ROW_HEIGHT)
            row:SetPoint("RIGHT", ahContentFrame, "RIGHT", 0, 0)
        end
        yOff = yOff - ownedCount * ROW_HEIGHT
        if ownedCount > 0 then yOff = yOff - BTN_SPACING end

        -- Cancel Undercuts
        local hasUndercuts = false
        for _, a in ipairs(currentOwnedAuctions) do
            if a.isUndercut then hasUndercuts = true; break end
        end
        if hasUndercuts then
            ahCancelUnder:ClearAllPoints()
            ahCancelUnder:SetPoint("TOPLEFT", ahContentFrame, "TOPLEFT", 0, yOff)
            ahCancelUnder:SetPoint("RIGHT", ahContentFrame, "RIGHT", 0, 0)
            ahCancelUnder:Show()
        else
            ahCancelUnder:Hide()
        end
    end

    ahContentFrame._layoutAH = LayoutAH
    ahContentFrame:SetScript("OnShow", LayoutAH)
    C_Timer.After(0, LayoutAH)

    -- Fetch owned auctions on first build
    local ap2 = ns.AuctionPost
    if ap2 and ap2.GetOwnedAuctions then
        currentOwnedAuctions = ap2:GetOwnedAuctions() or {}
        RefreshAHOwnedRows()
    end

    return ahContentFrame
end

-- Forward declaration: defined after BuildDefaultContent, called from the
-- animation OnUpdate closure inside EnsureContextDrawer.
local HideAllContextContent

--------------------------
-- Drawer construction
--------------------------

local function EnsureDrawer()
    if contextClip then return true end

    local mini = _G["FlipQueueMiniFrame"]
    if not mini then return false end

    -- Resolve Tracker now that Core has loaded
    Tracker = ns.Tracker

    -- Clip frame: full width, overlapping mini border by 3px for seamless look
    -- Backdrop lives on the clip frame itself (not the content) so the visible
    -- drawer is always a complete rounded rectangle — both top and bottom
    -- borders track the clip's animated height, so the collapsed thumb doesn't
    -- look like it's cut off on one side.
    contextClip = CreateFrame("Frame", "FlipQueueContextClip", mini, "BackdropTemplate")
    contextClip:SetClipsChildren(true)
    contextClip:SetHeight(THUMB_HEIGHT)
    contextClip:SetPoint("TOPLEFT",  mini, "BOTTOMLEFT",  0, 3)
    contextClip:SetPoint("TOPRIGHT", mini, "BOTTOMRIGHT", 0, 3)
    contextClip:SetFrameStrata("MEDIUM")
    contextClip:SetBackdrop(DRAWER_BACKDROP)
    contextClip:SetBackdropColor(0.05, 0.05, 0.1, 0.9)
    contextClip:SetBackdropBorderColor(0.3, 0.3, 0.4, 0.8)

    -- Content frame: anchored to TOP of clip (grows downward with clip).
    -- Using TOP anchor instead of BOTTOM avoids the backdrop extending
    -- above the clip rect (SetClipsChildren doesn't clip a frame's own
    -- backdrop, only its children).
    -- Content frame is just a container for buttons now; the backdrop lives
    -- on the clip frame. Kept as a full-height shell so button positions
    -- (which reference its TOPLEFT) remain stable during animation.
    contextContent = CreateFrame("Frame", "FlipQueueContextContent", contextClip)
    contextContent:SetHeight(THUMB_HEIGHT)
    contextContent:SetPoint("TOPLEFT",  contextClip, "TOPLEFT",  0, 0)
    contextContent:SetPoint("TOPRIGHT", contextClip, "TOPRIGHT", 0, 0)

    -- Thumb (grip tab at bottom). Parented to contextClip so it always sits
    -- at the visible bottom edge regardless of contextContent's height.
    contextThumb = CreateFrame("Button", "FlipQueueContextTab", contextClip)
    contextThumb:SetHeight(THUMB_HEIGHT)
    contextThumb:SetPoint("BOTTOMLEFT",  contextClip, "BOTTOMLEFT",  0, 0)
    contextThumb:SetPoint("BOTTOMRIGHT", contextClip, "BOTTOMRIGHT", 0, 0)
    contextThumb:SetFrameLevel(contextContent:GetFrameLevel() + 5)

    for j = 1, 3 do
        local grip = contextThumb:CreateTexture(nil, "ARTWORK")
        grip:SetHeight(1)
        grip:SetWidth(16)
        grip:SetPoint("CENTER", contextThumb, "CENTER", 0, (j - 2) * 3)
        grip:SetColorTexture(0.4, 0.4, 0.5, 0.6)
    end

    contextThumb.highlight = contextThumb:CreateTexture(nil, "HIGHLIGHT")
    contextThumb.highlight:SetAllPoints()
    contextThumb.highlight:SetColorTexture(1, 1, 1, 0.06)

    contextThumb:SetScript("OnClick", function()
        UI:ToggleContextDrawer()
    end)

    -- Animation: only the clip height changes. The content stays at full
    -- size (anchored to clip TOP) and is revealed/hidden by the clip.
    -- The backdrop issue (extending above clip) is handled by hiding
    -- content frames when collapsed.
    local animSpeed = 300 / ANIM_DURATION
    contextClip:SetScript("OnUpdate", function(self, elapsed)
        if not animating then return end
        local cur = self:GetHeight()
        local diff = animTarget - cur
        local step = animSpeed * elapsed
        if math.abs(diff) <= step then
            self:SetHeight(animTarget)
            animating = false
            if animTarget <= THUMB_HEIGHT then
                HideAllContextContent()
            end
        else
            self:SetHeight(cur + (diff > 0 and step or -step))
        end
    end)

    return true
end

--------------------------
-- Context switching
--------------------------

local defaultFrame = nil

local function BuildDefaultContent(parent)
    if defaultFrame then return defaultFrame end

    defaultFrame = CreateFrame("Frame", nil, parent)
    defaultFrame:SetPoint("TOPLEFT", parent, "TOPLEFT", PAD, -PAD)
    defaultFrame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -PAD, -PAD)
    defaultFrame:SetHeight(DEFAULT_CONTENT_H - PAD)

    local hdr = defaultFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hdr:SetPoint("TOPLEFT", defaultFrame, "TOPLEFT", 0, 0)
    hdr:SetText("|cffffffffFlipQueue|r")

    local pauseBtn = CreateActionButton(defaultFrame, "Pause Automation",
        "Temporarily pause all bank automation", DoPause)
    pauseBtn:SetPoint("TOPLEFT", defaultFrame, "TOPLEFT", 0,
        -(HEADER_HEIGHT + PAD))
    pauseBtn:SetPoint("RIGHT", defaultFrame, "RIGHT", 0, 0)

    defaultFrame._pauseBtn = pauseBtn
    _defaultPauseBtn = pauseBtn
    defaultFrame:SetScript("OnShow", function()
        RefreshPauseButton(pauseBtn)
    end)

    return defaultFrame
end

HideAllContextContent = function()
    if bankFrame then bankFrame:Hide() end
    if ahContentFrame then ahContentFrame:Hide() end
    if defaultFrame then defaultFrame:Hide() end
end

local function ShowContext(ctx)
    HideAllContextContent()

    if ctx == "bank" then
        local bf = BuildBankContent(contextContent)
        bf:Show()
        RefreshBankLabels()
        currentFullH = BANK_FULL_H
        contextContent:SetHeight(BANK_FULL_H)
    elseif ctx == "auction" then
        local af = BuildAHContent(contextContent)
        af:Show()
        -- Auto-scan-on-AH-open is handled by AuctionAutoScan's AUCTION_HOUSE_SHOW
        -- handler (gated by ns.db.settings.ahAutoScanOnOpen). When the player
        -- has it off — the default — the Scan To-Do / Scan All buttons below
        -- are the manual triggers. Don't duplicate the scan here.
        -- Fetch owned auctions
        local ap2 = ns.AuctionPost
        if ap2 and ap2.GetOwnedAuctions then
            currentOwnedAuctions = ap2:GetOwnedAuctions() or {}
        end
        -- Calculate and set height AFTER scan results are populated
        local ahH = CalculateAHHeight() + THUMB_HEIGHT
        currentFullH = ahH
        -- Set content to full height immediately — the animation will
        -- handle the clip reveal, but the content must be full-sized
        -- so rows have room to render.
        contextContent:SetHeight(ahH)
        RefreshAHScanRows()
        RefreshAHOwnedRows()
    else
        -- default: pause button only
        local df = BuildDefaultContent(contextContent)
        df:Show()
        currentFullH = DEFAULT_FULL_H
        contextContent:SetHeight(DEFAULT_FULL_H)
    end
end

--------------------------
-- Drawer persistence
--------------------------

local function SaveDrawerShown(shown)
    if ns.db and ns.db.settings then
        ns.db.settings.contextDrawerShown = shown and true or false
    end
end

--------------------------
-- Public API (on ns.UI)
--------------------------

function UI:ShowContextDrawer()
    if not EnsureDrawer() then return end
    local mini = _G["FlipQueueMiniFrame"]
    if not mini or not mini:IsShown() then return end

    -- Always rebuild content when opening (it may have been hidden on close)
    local ctx = GetContext()
    currentContext = nil  -- force ShowContext to rebuild
    ShowContext(ctx)
    currentContext = ctx
    drawerOpen = true
    animTarget = currentFullH
    animating  = true
    SaveDrawerShown(true)
end

function UI:HideContextDrawer()
    if not contextClip then return end
    drawerOpen = false
    animTarget = THUMB_HEIGHT
    animating  = true
    HideAllContextContent()
    SaveDrawerShown(false)
end

function UI:ToggleContextDrawer()
    if drawerOpen then
        UI:HideContextDrawer()
    else
        UI:ShowContextDrawer()
    end
end

function UI:IsContextDrawerShown()
    return drawerOpen
end

function UI:UpdateContextDrawerWidth()
    -- The clip frame is anchored TOPLEFT/TOPRIGHT to the mini so width
    -- follows automatically. Content is anchored BOTTOMLEFT/BOTTOMRIGHT
    -- to the clip so it also auto-sizes. We just need to trigger a re-layout
    -- of the current context's buttons.
    if bankFrame and bankFrame:IsShown() then
        local handler = bankFrame:GetScript("OnShow")
        if handler then handler(bankFrame) end
    end
    if ahContentFrame and ahContentFrame:IsShown() then
        local handler = ahContentFrame:GetScript("OnShow")
        if handler then handler(ahContentFrame) end
    end
end

-- Queued refresh for when combat ends. Show/Hide/SetSize/SetPoint on
-- contextClip are protected during combat (FlipQueueContextClip inherits
-- protection from the mini-view parent), so a refresh triggered by an event
-- that fires mid-combat would otherwise hit ADDON_ACTION_BLOCKED.
local _ctxPendingRefreshAfterCombat = false

function UI:RefreshContextDrawer()
    if not EnsureDrawer() then return end

    -- Lazy-resolve Tracker each refresh in case it loaded after us
    if not Tracker then Tracker = ns.Tracker end

    -- Defer the whole refresh while combat is active — any Show/Hide/resize
    -- on contextClip would be blocked by the protection chain.
    if InCombatLockdown and InCombatLockdown() then
        _ctxPendingRefreshAfterCombat = true
        return
    end

    local mini = _G["FlipQueueMiniFrame"]
    if not mini or not mini:IsShown() then
        if contextClip then contextClip:Hide() end
        return
    end

    contextClip:Show()

    local ctx = GetContext()

    -- Rebuild content only on context change
    if ctx ~= currentContext then
        currentContext = ctx
        ShowContext(ctx)

        -- Restore saved open/closed state or auto-open for non-default
        if ns.db and ns.db.settings then
            local saved = ns.db.settings.contextDrawerShown
            if saved == true then
                drawerOpen = true
                contextClip:SetHeight(currentFullH)
                animTarget = currentFullH
                animating = false
            elseif saved == false then
                drawerOpen = false
                contextClip:SetHeight(THUMB_HEIGHT)
                animTarget = THUMB_HEIGHT
                animating = false
                HideAllContextContent()
            else
                -- First time: auto-open when a service is detected
                drawerOpen = true
                animTarget = currentFullH
                animating = true
                SaveDrawerShown(true)
            end
        end
    else
        -- Same context, just refresh labels / data
        if ctx == "bank" then
            RefreshBankLabels()
        elseif ctx == "auction" then
            if ahContentFrame then
                ahContentFrame:Show()
                -- Only recalculate height, don't re-render rows
                -- (scan buttons handle row rendering + height themselves)
                local ahH = CalculateAHHeight() + THUMB_HEIGHT
                if ahH > currentFullH and drawerOpen then
                    currentFullH = ahH
                    contextContent:SetHeight(ahH)
                    contextClip:SetHeight(ahH)
                end
            end
        end
    end
end

-- Drain the queued refresh when combat ends.
local _ctxCombatEvt = CreateFrame("Frame")
_ctxCombatEvt:RegisterEvent("PLAYER_REGEN_ENABLED")
_ctxCombatEvt:SetScript("OnEvent", function()
    if _ctxPendingRefreshAfterCombat and UI.RefreshContextDrawer then
        _ctxPendingRefreshAfterCombat = false
        UI:RefreshContextDrawer()
    end
end)

-- After a TSM (or any addon's) AH scan settles, re-resolve pricing for the
-- currently-displayed AH scan rows so the player sees fresh per-variant
-- prices without having to click "Scan To-Do" again. The cache module fires
-- this listener once per scan burst (debounced ~2s after the last event).
if ns.AuctionScanCache and ns.AuctionScanCache.RegisterUpdated then
    ns.AuctionScanCache:RegisterUpdated(function()
        if not currentScanResults or #currentScanResults == 0 then return end
        local ap = ns.AuctionPost
        if not ap or not ap.RerunPricing then return end
        if InCombatLockdown and InCombatLockdown() then
            -- Defer through the existing combat-end path.
            _ctxPendingRefreshAfterCombat = true
            return
        end
        ap:RerunPricing(currentScanResults)
        if ahContentFrame then
            -- RefreshAHScanRows is a file-local; only call it if the AH UI
            -- is built. Layout / height recompute happens inside the refresh.
            if RefreshAHScanRows then RefreshAHScanRows() end
        end
    end)
end
