local _, ns = ...
local L = ns.L or {}
local addonName = ns.ADDON_NAME or "Vaultloom"

local BACKDROP_TEMPLATE = BackdropTemplateMixin and "BackdropTemplate" or nil
local ASSET_ROOT = "Interface\\AddOns\\" .. tostring(addonName) .. "\\Assets\\"
local FEATURE_ID = "shopping_list"
local DB_VERSION = 1
local MINI_BUTTON_ON = "on"
local MINI_BUTTON_OFF = "off"
local RECIPE_BUTTON_ON = "on"
local RECIPE_BUTTON_OFF = "off"

local FRAME_NAME = "VaultloomShoppingListFrame"
local POSITION_KEY = "shopping_list_position"
local MINI_BUTTON_NAME = "VaultloomShoppingListButton"
local MINI_BUTTON_POSITION_KEY = "shopping_list_button_position"
local MINI_BUTTON_SCALE_KEY = "shopping_list_button_scale"
local MINI_BUTTON_SCALE_MIN = 0.65
local MINI_BUTTON_SCALE_MAX = 1.75
local MINI_BUTTON_SCALE_STEP = 0.05
local MINI_BUTTON_SIZE = 46
local QUESTION_MARK_ICON = 134400
local RING_TEXTURE = ASSET_ROOT .. "dock_ring_clean.tga"
local WINDOW_BG_TEXTURE = ASSET_ROOT .. "shopping_list_bg.tga"
local WINDOW_BG_TEX_RIGHT = 600 / 1024
local WINDOW_BG_TEX_BOTTOM = 630 / 1024

local COLORS = {
    bg = { 0.035, 0.034, 0.040, 0.96 },
    panel = { 0.055, 0.052, 0.060, 0.94 },
    panelSoft = { 0.075, 0.070, 0.080, 0.90 },
    border = { 0.36, 0.28, 0.14, 0.82 },
    borderSoft = { 0.25, 0.22, 0.18, 0.70 },
    gold = { 0.95, 0.76, 0.28, 1.00 },
    text = { 0.92, 0.90, 0.84, 1.00 },
    muted = { 0.70, 0.70, 0.74, 1.00 },
    good = { 0.34, 0.88, 0.46, 1.00 },
    warn = { 0.95, 0.70, 0.24, 1.00 },
    bad = { 0.95, 0.24, 0.24, 1.00 },
}

local Runtime = {
    enabled = false,
    frame = nil,
    eventFrame = nil,
    hooksReady = false,
    eventsReady = false,
    refreshQueued = false,
    rows = {},
    professionButton = nil,
    auctionButton = nil,
    miniButton = nil,
}

ns.ShoppingListRuntime = Runtime

local function applyBackdrop(frame, bgColor, borderColor)
    if not frame or not frame.SetBackdrop then
        return
    end

    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        tile = false,
        edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    bgColor = bgColor or COLORS.panel
    borderColor = borderColor or COLORS.border
    frame:SetBackdropColor(bgColor[1], bgColor[2], bgColor[3], bgColor[4] or 1)
    frame:SetBackdropBorderColor(borderColor[1], borderColor[2], borderColor[3], borderColor[4] or 1)
end

local function createLabel(parent, fontObject, justify)
    local text = parent:CreateFontString(nil, "OVERLAY", fontObject or "GameFontHighlightSmall")
    text:SetJustifyH(justify or "LEFT")
    text:SetJustifyV("MIDDLE")
    return text
end

local function createActionButton(parent, text, width, height)
    local button = CreateFrame("Button", nil, parent, BACKDROP_TEMPLATE)
    button:SetSize(width or 90, height or 24)
    applyBackdrop(button, COLORS.panelSoft, COLORS.border)

    button.label = createLabel(button, "GameFontHighlightSmall", "CENTER")
    button.label:SetPoint("LEFT", 8, 0)
    button.label:SetPoint("RIGHT", -8, 0)
    button.label:SetText(text or "")

    button:SetScript("OnEnter", function(selfButton)
        if selfButton.SetBackdropBorderColor then
            selfButton:SetBackdropBorderColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 1)
        end
        if selfButton.vlTooltipTitle or selfButton.vlTooltipText then
            GameTooltip:SetOwner(selfButton, "ANCHOR_CURSOR")
            if selfButton.vlTooltipTitle and selfButton.vlTooltipTitle ~= "" then
                GameTooltip:AddLine(selfButton.vlTooltipTitle, COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], true)
            end
            if selfButton.vlTooltipText and selfButton.vlTooltipText ~= "" then
                GameTooltip:AddLine(selfButton.vlTooltipText, COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
            end
            GameTooltip:Show()
        end
    end)
    button:SetScript("OnLeave", function(selfButton)
        if selfButton.SetBackdropBorderColor then
            selfButton:SetBackdropBorderColor(COLORS.border[1], COLORS.border[2], COLORS.border[3], COLORS.border[4])
        end
        GameTooltip:Hide()
    end)

    return button
end

local function setButtonText(button, text)
    if button and button.label then
        button.label:SetText(text or "")
    elseif button and button.SetText then
        button:SetText(text or "")
    end
end

local function setButtonTooltip(button, title, text)
    if not button then
        return
    end
    button.vlTooltipTitle = title
    button.vlTooltipText = text
end

local function colorForMissing(missing)
    missing = tonumber(missing) or 0
    if missing <= 0 then
        return COLORS.good
    end
    return COLORS.bad
end

local function isValidIcon(icon)
    if type(icon) == "number" then
        return icon > 0
    end
    if type(icon) ~= "string" or icon == "" then
        return false
    end
    if icon:match("^INVTYPE_") then
        return false
    end
    return icon:find("\\", 1, true) ~= nil or icon:find("/", 1, true) ~= nil
end

local function normalizeIcon(icon)
    return isValidIcon(icon) and icon or nil
end

local function parseItemID(value)
    if type(value) == "number" then
        return value > 0 and math.floor(value) or nil
    end
    if type(value) ~= "string" then
        return nil
    end

    local text = value:gsub("^%s+", ""):gsub("%s+$", "")
    local directID = tonumber(text)
    if directID and directID > 0 then
        return math.floor(directID)
    end

    local linkID = tonumber(text:match("item:(%d+)"))
    if linkID and linkID > 0 then
        return math.floor(linkID)
    end

    if C_Item and type(C_Item.GetItemInfoInstant) == "function" then
        local okInstant, resolvedID = pcall(C_Item.GetItemInfoInstant, text)
        resolvedID = okInstant and tonumber(resolvedID) or nil
        if resolvedID and resolvedID > 0 then
            return math.floor(resolvedID)
        end
    end

    if type(GetItemInfoInstant) == "function" then
        local okInstant, resolvedID = pcall(GetItemInfoInstant, text)
        resolvedID = okInstant and tonumber(resolvedID) or nil
        if resolvedID and resolvedID > 0 then
            return math.floor(resolvedID)
        end
    end

    return nil
end

local function getItemNameFromLink(itemLink)
    if type(itemLink) ~= "string" then
        return nil
    end
    return itemLink:match("%[(.-)%]")
end

local function getItemDetails(itemID, itemLink)
    itemID = parseItemID(itemID or itemLink)
    if not itemID then
        return nil
    end

    local name, link, quality, icon
    local query = itemLink or itemID
    if C_Item and type(C_Item.GetItemInfo) == "function" then
        local okInfo
        okInfo, name, link, quality, _, _, _, _, _, _, icon = pcall(C_Item.GetItemInfo, query)
        if not okInfo then
            name, link, quality, icon = nil, nil, nil, nil
        end
    elseif type(GetItemInfo) == "function" then
        local okInfo
        okInfo, name, link, quality, _, _, _, _, _, _, icon = pcall(GetItemInfo, query)
        if not okInfo then
            name, link, quality, icon = nil, nil, nil, nil
        end
    end

    icon = normalizeIcon(icon)

    if (not icon) and C_Item and type(C_Item.GetItemInfoInstant) == "function" then
        local okInstant, _, _, _, _, resolvedIcon = pcall(C_Item.GetItemInfoInstant, itemID)
        if okInstant then
            icon = normalizeIcon(resolvedIcon)
        end
    end

    if (not icon) and type(GetItemInfoInstant) == "function" then
        local okInstant, _, _, _, _, resolvedIcon = pcall(GetItemInfoInstant, itemID)
        if okInstant then
            icon = normalizeIcon(resolvedIcon)
        end
    end

    return {
        itemID = itemID,
        itemLink = link or itemLink,
        name = name or getItemNameFromLink(itemLink) or tostring(itemID),
        quality = tonumber(quality) or 1,
        icon = icon or QUESTION_MARK_ICON,
    }
end

local function getItemIcon(itemID, fallbackIcon)
    itemID = parseItemID(itemID)
    if not itemID then
        return normalizeIcon(fallbackIcon) or QUESTION_MARK_ICON
    end

    if C_Item and type(C_Item.GetItemIconByID) == "function" then
        local okIcon, icon = pcall(C_Item.GetItemIconByID, itemID)
        icon = okIcon and normalizeIcon(icon) or nil
        if icon then
            return icon
        end
    end

    if type(GetItemIcon) == "function" then
        local okIcon, icon = pcall(GetItemIcon, itemID)
        icon = okIcon and normalizeIcon(icon) or nil
        if icon then
            return icon
        end
    end

    if C_Item and type(C_Item.GetItemInfoInstant) == "function" then
        local okInstant, _, _, _, _, icon = pcall(C_Item.GetItemInfoInstant, itemID)
        icon = okInstant and normalizeIcon(icon) or nil
        if icon then
            return icon
        end
    end

    if type(GetItemInfoInstant) == "function" then
        local okInstant, _, _, _, _, icon = pcall(GetItemInfoInstant, itemID)
        icon = okInstant and normalizeIcon(icon) or nil
        if icon then
            return icon
        end
    end

    if C_Item and type(C_Item.GetItemInfo) == "function" then
        local okInfo, _, _, _, _, _, _, _, _, _, icon = pcall(C_Item.GetItemInfo, itemID)
        icon = okInfo and normalizeIcon(icon) or nil
        if icon then
            return icon
        end
    elseif type(GetItemInfo) == "function" then
        local okInfo, _, _, _, _, _, _, _, _, _, icon = pcall(GetItemInfo, itemID)
        icon = okInfo and normalizeIcon(icon) or nil
        if icon then
            return icon
        end
    end

    return normalizeIcon(fallbackIcon) or QUESTION_MARK_ICON
end

local function refreshEntryDetails(entry)
    if type(entry) ~= "table" then
        return entry
    end

    local details = getItemDetails(entry.itemID, entry.itemLink)
    if type(details) == "table" then
        entry.itemID = details.itemID or entry.itemID
        entry.itemLink = details.itemLink or entry.itemLink
        entry.name = details.name or entry.name
        entry.quality = details.quality or entry.quality
        entry.icon = getItemIcon(details.itemID or entry.itemID, details.icon or entry.icon)
    else
        entry.icon = getItemIcon(entry.itemID, entry.icon)
    end

    return entry
end

local function getQualityColor(quality)
    quality = tonumber(quality) or 1
    if C_Item and type(C_Item.GetItemQualityColor) == "function" then
        local okColor, r, g, b = pcall(C_Item.GetItemQualityColor, quality)
        if okColor and r and g and b then
            return r, g, b
        end
    elseif type(GetItemQualityColor) == "function" then
        local okColor, r, g, b = pcall(GetItemQualityColor, quality)
        if okColor and r and g and b then
            return r, g, b
        end
    end
    return 1, 1, 1
end

local function ensureShoppingDB()
    if not ns.db and ns.EnsureDB then
        ns:EnsureDB()
    end

    local db = ns.db
    if type(db) ~= "table" then
        return nil
    end

    db.shoppingList = type(db.shoppingList) == "table" and db.shoppingList or {}
    db.shoppingList.version = DB_VERSION
    db.shoppingList.entries = type(db.shoppingList.entries) == "table" and db.shoppingList.entries or {}
    db.shoppingList.order = type(db.shoppingList.order) == "table" and db.shoppingList.order or {}
    db.shoppingList.nextID = tonumber(db.shoppingList.nextID) or 1
    return db.shoppingList
end

local function makeEntryID(db)
    db.nextID = tonumber(db.nextID) or 1
    local entryID = "shopping_" .. tostring(db.nextID)
    db.nextID = db.nextID + 1
    return entryID
end

local function getNow()
    return type(time) == "function" and time() or 0
end

local function clampQuantity(quantity)
    quantity = math.floor((tonumber(quantity) or 1) + 0.5)
    if quantity < 1 then
        quantity = 1
    elseif quantity > 9999 then
        quantity = 9999
    end
    return quantity
end

local function getOrderedEntries()
    local db = ensureShoppingDB()
    if type(db) ~= "table" then
        return {}
    end

    local entries = db.entries
    local ordered = {}
    local seen = {}
    local cleanOrder = {}

    for _, entryID in ipairs(db.order or {}) do
        local entry = entries[entryID]
        if type(entry) == "table" and not seen[entryID] then
            ordered[#ordered + 1] = entry
            cleanOrder[#cleanOrder + 1] = entryID
            seen[entryID] = true
        end
    end

    for entryID, entry in pairs(entries) do
        if type(entry) == "table" and not seen[entryID] then
            ordered[#ordered + 1] = entry
            cleanOrder[#cleanOrder + 1] = entryID
            seen[entryID] = true
        end
    end

    db.order = cleanOrder
    return ordered
end

local function findExistingEntry(db, kind, itemID, recipeID)
    if type(db) ~= "table" then
        return nil
    end

    itemID = tonumber(itemID)
    recipeID = tonumber(recipeID)
    for _, entryID in ipairs(db.order or {}) do
        local entry = db.entries and db.entries[entryID] or nil
        if type(entry) == "table" and entry.kind == kind then
            if kind == "recipe" and recipeID and tonumber(entry.recipeID) == recipeID then
                return entry
            elseif kind ~= "recipe" and itemID and tonumber(entry.itemID) == itemID then
                return entry
            end
        end
    end
    return nil
end

local function getStoredCount(itemID)
    itemID = parseItemID(itemID)
    if not itemID then
        return 0
    end

    if ns.GetInventoryTrackerItemSummary then
        local okSummary, summary = pcall(ns.GetInventoryTrackerItemSummary, ns, itemID)
        if okSummary and type(summary) == "table" and tonumber(summary.total) then
            return math.max(0, math.floor(tonumber(summary.total) + 0.5))
        end
    end

    if C_Item and type(C_Item.GetItemCount) == "function" then
        local okCount, count = pcall(C_Item.GetItemCount, itemID, true, false, true, true)
        if not okCount then
            okCount, count = pcall(C_Item.GetItemCount, itemID, true, false, true)
        end
        if okCount and tonumber(count) then
            return math.max(0, math.floor(tonumber(count) + 0.5))
        end
    end

    if type(GetItemCount) == "function" then
        local okCount, count = pcall(GetItemCount, itemID, true, true)
        if okCount and tonumber(count) then
            return math.max(0, math.floor(tonumber(count) + 0.5))
        end
    end

    return 0
end

local function bindItemTooltip(frame, itemLink, itemID)
    if not frame then
        return
    end

    frame.vlShoppingItemLink = itemLink
    frame.vlShoppingItemID = itemID
    frame:EnableMouse(true)
    frame:SetScript("OnEnter", function(selfFrame)
        local link = selfFrame.vlShoppingItemLink
        local id = selfFrame.vlShoppingItemID
        if not GameTooltip or (not link and not id) then
            return
        end
        GameTooltip:SetOwner(selfFrame, "ANCHOR_CURSOR")
        if link then
            GameTooltip:SetHyperlink(link)
        elseif id then
            GameTooltip:SetHyperlink("item:" .. tostring(id))
        end
        GameTooltip:Show()
    end)
    frame:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
end

local function createItemButton(parent, size)
    local button = CreateFrame("Button", nil, parent, BACKDROP_TEMPLATE)
    button:SetSize(size or 34, size or 34)
    applyBackdrop(button, COLORS.panelSoft, COLORS.borderSoft)
    button.icon = button:CreateTexture(nil, "ARTWORK")
    button.icon:SetPoint("TOPLEFT", 2, -2)
    button.icon:SetPoint("BOTTOMRIGHT", -2, 2)
    button.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    return button
end

local function setItemButtonTexture(button, itemID, icon)
    if not button or not button.icon then
        return
    end
    button.icon:SetTexture(getItemIcon(itemID, icon))
end

local function selectedRecipeFromFrameFields()
    local candidates = {}

    local function addCandidate(value)
        value = tonumber(value)
        if value and value > 0 then
            candidates[#candidates + 1] = math.floor(value)
        end
    end

    local function callMethod(object, methodName)
        if type(object) == "table" and type(object[methodName]) == "function" then
            local ok, value = pcall(object[methodName], object)
            if ok then
                return value
            end
        end
        return nil
    end

    local tradeSkillFrame = type(TradeSkillFrame) == "table" and TradeSkillFrame or nil
    if tradeSkillFrame then
        addCandidate(tradeSkillFrame.selectedSkill)
        addCandidate(tradeSkillFrame.selectedRecipeID)
    end

    local professionsFrame = type(ProfessionsFrame) == "table" and ProfessionsFrame or nil
    if professionsFrame then
        addCandidate(professionsFrame.selectedRecipeID)
        addCandidate(callMethod(professionsFrame, "GetSelectedRecipeID"))

        local craftingPage = professionsFrame.CraftingPage
        if type(craftingPage) == "table" then
            addCandidate(craftingPage.selectedRecipeID)
            addCandidate(craftingPage.recipeID)
            addCandidate(callMethod(craftingPage, "GetSelectedRecipeID"))

            local recipeInfo = callMethod(craftingPage, "GetSelectedRecipeInfo")
            if type(recipeInfo) == "table" then
                addCandidate(recipeInfo.recipeID)
            end

            local schematicForm = craftingPage.SchematicForm
            if type(schematicForm) == "table" then
                addCandidate(schematicForm.recipeID)
                addCandidate(schematicForm.selectedRecipeID)

                local schematic = callMethod(schematicForm, "GetRecipeSchematic")
                if type(schematic) == "table" then
                    addCandidate(schematic.recipeID)
                end

                local transaction = callMethod(schematicForm, "GetTransaction")
                if type(transaction) == "table" then
                    addCandidate(callMethod(transaction, "GetRecipeID"))
                    addCandidate(transaction.recipeID)
                end

                if type(schematicForm.recipeSchematic) == "table" then
                    addCandidate(schematicForm.recipeSchematic.recipeID)
                end
            end
        end
    end

    for _, recipeID in ipairs(candidates) do
        if C_TradeSkillUI and type(C_TradeSkillUI.GetRecipeInfo) == "function" then
            local okInfo, info = pcall(C_TradeSkillUI.GetRecipeInfo, recipeID)
            if okInfo and type(info) == "table" then
                return recipeID
            end
        else
            return recipeID
        end
    end

    return nil
end

local function getRecipeSchematic(recipeID)
    if not (C_TradeSkillUI and type(C_TradeSkillUI.GetRecipeSchematic) == "function") then
        return nil
    end

    local okSchematic, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
    if okSchematic and type(schematic) == "table" then
        return schematic
    end

    okSchematic, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID)
    if okSchematic and type(schematic) == "table" then
        return schematic
    end

    return nil
end

local function getRecipeInfo(recipeID)
    if not (C_TradeSkillUI and type(C_TradeSkillUI.GetRecipeInfo) == "function") then
        return nil
    end
    local okInfo, info = pcall(C_TradeSkillUI.GetRecipeInfo, recipeID)
    if okInfo and type(info) == "table" then
        return info
    end
    return nil
end

local function getRecipeItemLink(recipeID)
    if C_TradeSkillUI and type(C_TradeSkillUI.GetRecipeItemLink) == "function" then
        local okLink, itemLink = pcall(C_TradeSkillUI.GetRecipeItemLink, recipeID)
        if okLink and type(itemLink) == "string" and itemLink ~= "" then
            return itemLink
        end
    end
    return nil
end

local function addReagent(reagentsByID, itemID, quantity, itemLink, name, icon)
    itemID = parseItemID(itemID or itemLink)
    quantity = tonumber(quantity) or 0
    if not itemID or quantity <= 0 then
        return
    end

    local details = getItemDetails(itemID, itemLink) or {}
    local reagent = reagentsByID[itemID]
    if not reagent then
        reagent = {
            itemID = itemID,
            itemLink = itemLink or details.itemLink,
            name = name or details.name or tostring(itemID),
            icon = icon or details.icon or "Interface\\Icons\\INV_Misc_QuestionMark",
            quantity = 0,
        }
        reagentsByID[itemID] = reagent
    end
    reagent.quantity = (tonumber(reagent.quantity) or 0) + quantity
end

local function collectSchematicReagents(recipeID, schematic)
    local reagentsByID = {}
    for _, slot in ipairs((schematic and schematic.reagentSlotSchematics) or {}) do
        local required = tonumber(slot.quantityRequired or slot.requiredQuantity or slot.quantity or 0) or 0
        if required > 0 then
            local selectedReagent = nil
            for _, reagentInfo in ipairs(slot.reagents or {}) do
                local itemID = tonumber(reagentInfo.itemID or (reagentInfo.itemInfo and reagentInfo.itemInfo.itemID))
                if itemID then
                    selectedReagent = reagentInfo
                    break
                end
            end
            if selectedReagent then
                local itemID = tonumber(selectedReagent.itemID or (selectedReagent.itemInfo and selectedReagent.itemInfo.itemID))
                local itemLink = selectedReagent.itemLink or (selectedReagent.itemInfo and selectedReagent.itemInfo.hyperlink)
                local name = selectedReagent.name or (selectedReagent.itemInfo and selectedReagent.itemInfo.itemName)
                local icon = selectedReagent.icon or (selectedReagent.itemInfo and selectedReagent.itemInfo.iconFileID)
                addReagent(reagentsByID, itemID, required, itemLink, name, icon)
            end
        end
    end

    if next(reagentsByID) == nil and C_TradeSkillUI and type(C_TradeSkillUI.GetRecipeNumReagents) == "function" then
        local okNum, numReagents = pcall(C_TradeSkillUI.GetRecipeNumReagents, recipeID)
        numReagents = okNum and tonumber(numReagents) or 0
        for index = 1, numReagents do
            local name, icon, quantity, itemID
            if type(C_TradeSkillUI.GetRecipeReagentInfo) == "function" then
                local okReagent
                okReagent, name, icon, quantity, _, _, itemID = pcall(C_TradeSkillUI.GetRecipeReagentInfo, recipeID, index)
                if not okReagent then
                    name, icon, quantity, itemID = nil, nil, nil, nil
                end
            end

            local itemLink = nil
            if type(C_TradeSkillUI.GetRecipeReagentItemLink) == "function" then
                local okLink
                okLink, itemLink = pcall(C_TradeSkillUI.GetRecipeReagentItemLink, recipeID, index)
                if not okLink then
                    itemLink = nil
                end
            end
            addReagent(reagentsByID, itemID or parseItemID(itemLink), quantity, itemLink, name, icon)
        end
    end

    local reagents = {}
    for _, reagent in pairs(reagentsByID) do
        reagents[#reagents + 1] = reagent
    end
    table.sort(reagents, function(a, b)
        return tostring(a.name or "") < tostring(b.name or "")
    end)

    return reagents
end

local function collectRecipeEntry(recipeID)
    recipeID = tonumber(recipeID)
    if not recipeID or recipeID <= 0 then
        return nil
    end

    local recipeInfo = getRecipeInfo(recipeID)
    local schematic = getRecipeSchematic(recipeID)
    local itemLink = getRecipeItemLink(recipeID)
    local itemID = parseItemID(itemLink) or tonumber(schematic and schematic.outputItemID)
    local details = itemID and getItemDetails(itemID, itemLink) or nil
    local reagents = collectSchematicReagents(recipeID, schematic)

    return {
        kind = "recipe",
        recipeID = recipeID,
        itemID = itemID,
        itemLink = itemLink or (details and details.itemLink) or nil,
        name = (details and details.name) or (recipeInfo and recipeInfo.name) or tostring(recipeID),
        icon = (details and details.icon) or (recipeInfo and recipeInfo.icon) or "Interface\\Icons\\INV_Misc_QuestionMark",
        quality = details and details.quality or 1,
        reagents = reagents,
    }
end

function Runtime:QueueRefresh(delay)
    if self.refreshQueued then
        return
    end
    self.refreshQueued = true
    C_Timer.After(delay or 0.05, function()
        Runtime.refreshQueued = false
        Runtime:Refresh()
        Runtime:RefreshProfessionButton()
        if Runtime.RefreshAuctionButton then
            Runtime:RefreshAuctionButton()
        end
    end)
end

function Runtime:ShowMessage(message)
    if ns.ShowFeatureError and type(message) == "string" and message ~= "" then
        ns:ShowFeatureError(message)
    elseif ns.Print then
        ns:Print(message)
    end
end

function Runtime:AddItem(value, quantity, silent)
    local itemID = parseItemID(value)
    if not itemID then
        if not silent then
            self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_NO_ITEM or "Vaultloom: no item found.")
        end
        return false
    end

    quantity = clampQuantity(quantity)
    local details = getItemDetails(itemID, type(value) == "string" and value or nil)
    if not details then
        if not silent then
            self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_NO_ITEM or "Vaultloom: no item found.")
        end
        return false
    end

    local db = ensureShoppingDB()
    if type(db) ~= "table" then
        return false
    end

    local entry = findExistingEntry(db, "item", details.itemID, nil)
    if entry then
        entry.quantity = clampQuantity((tonumber(entry.quantity) or 1) + quantity)
        entry.itemLink = details.itemLink or entry.itemLink
        entry.name = details.name or entry.name
        entry.icon = details.icon or entry.icon
        entry.quality = details.quality or entry.quality
        entry.updatedAt = getNow()
    else
        local entryID = makeEntryID(db)
        entry = {
            id = entryID,
            kind = "item",
            itemID = details.itemID,
            itemLink = details.itemLink,
            name = details.name,
            icon = details.icon,
            quality = details.quality,
            quantity = quantity,
            createdAt = getNow(),
            updatedAt = getNow(),
        }
        db.entries[entryID] = entry
        db.order[#db.order + 1] = entryID
    end

    self:QueueRefresh()
    if not silent then
        self:ShowMessage(string.format(L.FEATURE_SHOPPING_LIST_ADDED_FORMAT or "Vaultloom: added %s.", details.name or tostring(details.itemID)))
    end
    return true
end

function Runtime:AddRecipe(recipeID, quantity, silent)
    if not self.enabled then
        if not silent then
            self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_FEATURE_DISABLED or "Vaultloom: enable Shopping List first.")
        end
        return false
    end

    local recipe = collectRecipeEntry(recipeID)
    if not recipe or not recipe.recipeID then
        if not silent then
            self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_NO_RECIPE or "Vaultloom: no selected recipe found.")
        end
        return false
    end
    if #(recipe.reagents or {}) == 0 then
        if not silent then
            self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_RECIPE_REAGENTS or "Vaultloom: no reagents found for this recipe.")
        end
        return false
    end

    quantity = clampQuantity(quantity)
    local db = ensureShoppingDB()
    if type(db) ~= "table" then
        return false
    end

    local entry = findExistingEntry(db, "recipe", recipe.itemID, recipe.recipeID)
    if entry then
        entry.quantity = clampQuantity((tonumber(entry.quantity) or 1) + quantity)
        entry.itemID = recipe.itemID or entry.itemID
        entry.itemLink = recipe.itemLink or entry.itemLink
        entry.name = recipe.name or entry.name
        entry.icon = recipe.icon or entry.icon
        entry.quality = recipe.quality or entry.quality
        entry.reagents = recipe.reagents
        entry.updatedAt = getNow()
    else
        local entryID = makeEntryID(db)
        entry = recipe
        entry.id = entryID
        entry.quantity = quantity
        entry.createdAt = getNow()
        entry.updatedAt = getNow()
        db.entries[entryID] = entry
        db.order[#db.order + 1] = entryID
    end

    self:Open()
    self:QueueRefresh()
    if not silent then
        self:ShowMessage(string.format(L.FEATURE_SHOPPING_LIST_RECIPE_ADDED_FORMAT or "Vaultloom: added recipe %s.", entry.name or tostring(entry.recipeID)))
    end
    return true
end

function Runtime:AddSelectedRecipe()
    local recipeID = selectedRecipeFromFrameFields()
    if not recipeID then
        self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_NO_RECIPE or "Vaultloom: no selected recipe found.")
        return false
    end
    return self:AddRecipe(recipeID, 1, false)
end

function Runtime:RemoveEntry(entryID)
    local db = ensureShoppingDB()
    if type(db) ~= "table" or type(entryID) ~= "string" then
        return
    end

    db.entries[entryID] = nil
    local clean = {}
    for _, id in ipairs(db.order or {}) do
        if id ~= entryID and db.entries[id] then
            clean[#clean + 1] = id
        end
    end
    db.order = clean
    self:QueueRefresh()
end

function Runtime:ChangeQuantity(entryID, delta)
    local db = ensureShoppingDB()
    local entry = type(db) == "table" and db.entries and db.entries[entryID] or nil
    if type(entry) ~= "table" then
        return
    end

    entry.quantity = clampQuantity((tonumber(entry.quantity) or 1) + (tonumber(delta) or 0))
    entry.updatedAt = getNow()
    self:QueueRefresh()
end

function Runtime:ClearList()
    local db = ensureShoppingDB()
    if type(db) ~= "table" then
        return
    end
    db.entries = {}
    db.order = {}
    self:QueueRefresh()
end

function Runtime:AddCursorItem(quantity)
    if type(GetCursorInfo) ~= "function" then
        self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_NO_ITEM or "Vaultloom: no item found.")
        return false
    end

    local cursorType, itemID, itemLink = GetCursorInfo()
    if cursorType ~= "item" then
        self:ShowMessage(L.FEATURE_SHOPPING_LIST_DROP_EMPTY or "Vaultloom: drag an item onto the square.")
        return false
    end

    local added = self:AddItem(itemLink or itemID, quantity or (self.frame and self.frame.qtyInput and self.frame.qtyInput:GetNumber()) or 1, false)
    if added and type(ClearCursor) == "function" then
        ClearCursor()
    end
    return added
end

local function createReagentRow(parent)
    local row = CreateFrame("Button", nil, parent, BACKDROP_TEMPLATE)
    row:SetHeight(38)
    applyBackdrop(row, COLORS.panel, COLORS.borderSoft)

    row.icon = createItemButton(row, 26)
    row.icon:SetPoint("LEFT", 10, 0)

    row.name = createLabel(row, "GameFontHighlightSmall", "LEFT")
    row.name:SetPoint("LEFT", row.icon, "RIGHT", 10, 0)
    row.name:SetPoint("RIGHT", row, "CENTER", 76, 0)

    row.count = createLabel(row, "GameFontHighlightSmall", "RIGHT")
    row.count:SetPoint("RIGHT", -14, 0)
    row.count:SetWidth(230)

    return row
end

local function createEntryRow(parent)
    local row = CreateFrame("Frame", nil, parent, BACKDROP_TEMPLATE)
    applyBackdrop(row, COLORS.panelSoft, COLORS.borderSoft)
    row.reagentRows = {}

    row.icon = createItemButton(row, 46)
    row.icon:SetPoint("TOPLEFT", 14, -14)

    row.title = createLabel(row, "GameFontHighlight", "LEFT")
    row.title:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 12, -1)
    row.title:SetPoint("RIGHT", -164, 0)

    row.kind = createLabel(row, "GameFontHighlightSmall", "LEFT")
    row.kind:SetPoint("TOPLEFT", row.title, "BOTTOMLEFT", 0, -4)
    row.kind:SetPoint("RIGHT", row.title, "RIGHT", 0, 0)
    row.kind:SetTextColor(COLORS.muted[1], COLORS.muted[2], COLORS.muted[3], 1)

    row.summary = createLabel(row, "GameFontHighlightSmall", "LEFT")
    row.summary:SetPoint("TOPLEFT", row.kind, "BOTTOMLEFT", 0, -6)
    row.summary:SetPoint("RIGHT", row.title, "RIGHT", 0, 0)

    row.quantity = createLabel(row, "GameFontNormalLarge", "RIGHT")
    row.quantity:SetPoint("TOPRIGHT", -18, -18)
    row.quantity:SetWidth(70)

    row.minus = createActionButton(row, "-", 24, 22)
    row.minus:SetPoint("TOPRIGHT", row.quantity, "BOTTOMRIGHT", -52, -11)
    row.plus = createActionButton(row, "+", 24, 22)
    row.plus:SetPoint("LEFT", row.minus, "RIGHT", 6, 0)
    row.remove = createActionButton(row, "x", 24, 22)
    row.remove:SetPoint("LEFT", row.plus, "RIGHT", 6, 0)

    return row
end

function Runtime:ApplyEntryRow(row, entry)
    if not row or type(entry) ~= "table" then
        return 0
    end

    entry = refreshEntryDetails(entry)
    local quantity = clampQuantity(entry.quantity)
    local isRecipe = entry.kind == "recipe"
    local reagents = isRecipe and type(entry.reagents) == "table" and entry.reagents or nil
    local reagentCount = reagents and #reagents or 0
    local reagentRowHeight = 38
    local reagentGap = 7
    local baseHeight = isRecipe and 96 or 84
    local rowHeight = baseHeight + (reagentCount * (reagentRowHeight + reagentGap)) + (reagentCount > 0 and 4 or 0)
    row:SetHeight(rowHeight)

    row.entryID = entry.id
    setItemButtonTexture(row.icon, entry.itemID, entry.icon)
    bindItemTooltip(row.icon, entry.itemLink, entry.itemID)
    bindItemTooltip(row, entry.itemLink, entry.itemID)

    local r, g, b = getQualityColor(entry.quality)
    row.title:SetText(entry.name or tostring(entry.itemID or entry.recipeID or "?"))
    row.title:SetTextColor(r, g, b, 1)
    row.quantity:SetText("x" .. tostring(quantity))
    row.kind:SetText(isRecipe and (L.FEATURE_SHOPPING_LIST_KIND_RECIPE or "Recipe") or (L.FEATURE_SHOPPING_LIST_KIND_ITEM or "Item"))

    local missingTotal = 0
    if isRecipe then
        for _, reagent in ipairs(reagents or {}) do
            local need = clampQuantity(reagent.quantity) * quantity
            local have = getStoredCount(reagent.itemID)
            missingTotal = missingTotal + math.max(0, need - have)
        end
        row.summary:SetText(string.format(L.FEATURE_SHOPPING_LIST_RECIPE_SUMMARY_FORMAT or "%d reagents missing", missingTotal))
    else
        local have = getStoredCount(entry.itemID)
        local missing = math.max(0, quantity - have)
        missingTotal = missing
        row.summary:SetText(string.format(
            L.FEATURE_SHOPPING_LIST_ITEM_SUMMARY_FORMAT or "Need %d · Have %d · Missing %d",
            quantity,
            have,
            missing
        ))
    end

    local border = colorForMissing(missingTotal)
    if row.SetBackdropBorderColor then
        row:SetBackdropBorderColor(border[1], border[2], border[3], missingTotal > 0 and 0.90 or 0.72)
    end
    row.summary:SetTextColor(border[1], border[2], border[3], 1)

    setButtonTooltip(row.minus, L.FEATURE_SHOPPING_LIST_DECREASE or "Less", L.FEATURE_SHOPPING_LIST_DECREASE_TOOLTIP or "Reduce target quantity.")
    setButtonTooltip(row.plus, L.FEATURE_SHOPPING_LIST_INCREASE or "More", L.FEATURE_SHOPPING_LIST_INCREASE_TOOLTIP or "Increase target quantity.")
    setButtonTooltip(row.remove, L.FEATURE_SHOPPING_LIST_REMOVE or "Remove", L.FEATURE_SHOPPING_LIST_REMOVE_TOOLTIP or "Remove this entry.")
    row.minus:SetScript("OnClick", function()
        Runtime:ChangeQuantity(entry.id, -1)
    end)
    row.plus:SetScript("OnClick", function()
        Runtime:ChangeQuantity(entry.id, 1)
    end)
    row.remove:SetScript("OnClick", function()
        Runtime:RemoveEntry(entry.id)
    end)

    for index = 1, reagentCount do
        local reagent = reagents[index]
        local reagentRow = row.reagentRows[index]
        if not reagentRow then
            reagentRow = createReagentRow(row)
            row.reagentRows[index] = reagentRow
        end

        reagentRow:ClearAllPoints()
        if index == 1 then
            reagentRow:SetPoint("TOPLEFT", row.icon, "BOTTOMLEFT", 0, -20)
        else
            reagentRow:SetPoint("TOPLEFT", row.reagentRows[index - 1], "BOTTOMLEFT", 0, -reagentGap)
        end
        reagentRow:SetPoint("RIGHT", -14, 0)
        reagentRow:SetHeight(reagentRowHeight)
        reagentRow:Show()

        local details = getItemDetails(reagent.itemID, reagent.itemLink) or reagent
        reagent.icon = getItemIcon(reagent.itemID, details.icon or reagent.icon)
        reagent.itemLink = details.itemLink or reagent.itemLink
        reagent.name = details.name or reagent.name
        local need = clampQuantity(reagent.quantity) * quantity
        local have = getStoredCount(reagent.itemID)
        local missing = math.max(0, need - have)
        local reagentColor = colorForMissing(missing)

        setItemButtonTexture(reagentRow.icon, reagent.itemID, reagent.icon)
        reagentRow.name:SetText(details.name or reagent.name or tostring(reagent.itemID))
        reagentRow.count:SetText(string.format(
            L.FEATURE_SHOPPING_LIST_REAGENT_FORMAT or "Need %d · Have %d · Missing %d",
            need,
            have,
            missing
        ))
        reagentRow.count:SetTextColor(reagentColor[1], reagentColor[2], reagentColor[3], 1)
        if reagentRow.SetBackdropBorderColor then
            reagentRow:SetBackdropBorderColor(reagentColor[1], reagentColor[2], reagentColor[3], missing > 0 and 0.72 or 0.45)
        end
        bindItemTooltip(reagentRow, details.itemLink or reagent.itemLink, reagent.itemID)
        bindItemTooltip(reagentRow.icon, details.itemLink or reagent.itemLink, reagent.itemID)
    end

    for index = reagentCount + 1, #row.reagentRows do
        row.reagentRows[index]:Hide()
    end

    return rowHeight
end

function Runtime:Refresh()
    local frame = self.frame
    if not frame then
        return
    end

    local entries = getOrderedEntries()
    frame.emptyText:SetShown(#entries == 0)
    frame.emptyText:SetText(L.FEATURE_SHOPPING_LIST_EMPTY or "Your shopping list is empty.")

    local previous = nil
    local contentHeight = 0
    for index, entry in ipairs(entries) do
        local row = self.rows[index]
        if not row then
            row = createEntryRow(frame.scrollChild)
            self.rows[index] = row
        end

        row:ClearAllPoints()
        if previous then
            row:SetPoint("TOPLEFT", previous, "BOTTOMLEFT", 0, -10)
        else
            row:SetPoint("TOPLEFT", frame.scrollChild, "TOPLEFT", 0, 0)
        end
        row:SetPoint("RIGHT", frame.scrollChild, "RIGHT", -6, 0)
        row:Show()
        local height = self:ApplyEntryRow(row, entry)
        contentHeight = contentHeight + height + (index > 1 and 10 or 0)
        previous = row
    end

    for index = #entries + 1, #self.rows do
        self.rows[index]:Hide()
    end

    frame.scrollChild:SetHeight(math.max(20, contentHeight))
end

function Runtime:BuildFrame()
    local frame = CreateFrame("Frame", FRAME_NAME, UIParent, BACKDROP_TEMPLATE)
    frame:SetSize(600, 630)
    frame:SetFrameStrata("DIALOG")
    frame:EnableMouse(true)
    frame:SetMovable(true)
    frame:RegisterForDrag("LeftButton")
    applyBackdrop(frame, COLORS.bg, COLORS.border)
    if frame.SetBackdropColor then
        frame:SetBackdropColor(0, 0, 0, 0)
    end

    frame.bgTexture = frame:CreateTexture(nil, "BACKGROUND", nil, 1)
    frame.bgTexture:SetAllPoints(frame)
    frame.bgTexture:SetTexture(WINDOW_BG_TEXTURE)
    frame.bgTexture:SetTexCoord(0, WINDOW_BG_TEX_RIGHT, 0, WINDOW_BG_TEX_BOTTOM)
    frame.bgTexture:SetVertexColor(1, 1, 1, 1)

    frame.bgTint = frame:CreateTexture(nil, "BACKGROUND", nil, 2)
    frame.bgTint:SetAllPoints(frame)
    frame.bgTint:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.bgTint:SetColorTexture(COLORS.bg[1], COLORS.bg[2], COLORS.bg[3], 0.30)

    if ns.ApplyFeaturePosition then
        ns:ApplyFeaturePosition(frame, POSITION_KEY, { point = "CENTER", relativePoint = "CENTER", x = 0, y = 0 })
    else
        frame:SetPoint("CENTER")
    end

    frame:SetScript("OnDragStart", function(selfFrame)
        if InCombatLockdown and InCombatLockdown() then
            return
        end
        selfFrame:StartMoving()
    end)
    frame:SetScript("OnDragStop", function(selfFrame)
        selfFrame:StopMovingOrSizing()
        if ns.StoreFeaturePosition then
            ns:StoreFeaturePosition(selfFrame, POSITION_KEY)
        end
    end)
    frame:SetScript("OnShow", function()
        Runtime:QueueRefresh(0)
    end)

    frame.title = createLabel(frame, "GameFontNormalLarge", "LEFT")
    frame.title:SetPoint("TOPLEFT", 18, -16)
    frame.title:SetText(L.FEATURE_SHOPPING_LIST_WINDOW_TITLE or "Vaultloom Shopping List")
    frame.title:SetTextColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 1)

    frame.subtitle = createLabel(frame, "GameFontHighlightSmall", "LEFT")
    frame.subtitle:SetPoint("TOPLEFT", frame.title, "BOTTOMLEFT", 0, -4)
    frame.subtitle:SetPoint("RIGHT", -32, 0)
    frame.subtitle:SetText(L.FEATURE_SHOPPING_LIST_WINDOW_SUBTITLE or "Add items and recipes to your shopping list.")
    frame.subtitle:SetTextColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], 1)

    frame.closeButton = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    frame.closeButton:SetPoint("TOPRIGHT", -6, -6)
    frame.closeButton:SetScript("OnClick", function()
        frame:Hide()
    end)

    frame.refreshButton = createActionButton(frame, L.FEATURE_SHOPPING_LIST_REFRESH or "Refresh", 96, 26)
    frame.refreshButton:SetPoint("TOPLEFT", frame.subtitle, "BOTTOMLEFT", 0, -12)
    frame.refreshButton:SetScript("OnClick", function()
        Runtime:Refresh()
    end)

    frame.clearButton = createActionButton(frame, L.FEATURE_SHOPPING_LIST_CLEAR or "Clear", 72, 26)
    frame.clearButton:SetPoint("LEFT", frame.refreshButton, "RIGHT", 10, 0)
    setButtonTooltip(frame.clearButton, L.FEATURE_SHOPPING_LIST_CLEAR or "Clear", L.FEATURE_SHOPPING_LIST_CLEAR_TOOLTIP or "Shift-click to clear the list.")
    frame.clearButton:SetScript("OnClick", function()
        if IsShiftKeyDown and IsShiftKeyDown() then
            Runtime:ClearList()
        else
            Runtime:ShowMessage(L.FEATURE_SHOPPING_LIST_CLEAR_HINT or "Vaultloom: Shift-click to clear the shopping list.")
        end
    end)

    frame.inputPanel = CreateFrame("Frame", nil, frame, BACKDROP_TEMPLATE)
    frame.inputPanel:SetPoint("TOPLEFT", 18, -108)
    frame.inputPanel:SetPoint("TOPRIGHT", -18, -108)
    frame.inputPanel:SetHeight(62)
    applyBackdrop(frame.inputPanel, COLORS.panel, COLORS.borderSoft)

    frame.dropTarget = createItemButton(frame.inputPanel, 38)
    frame.dropTarget:SetPoint("LEFT", 12, 0)
    frame.dropTarget.icon:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.dropTarget.icon:SetColorTexture(0.08, 0.07, 0.07, 0.94)
    frame.dropTarget.plusH = frame.dropTarget:CreateTexture(nil, "OVERLAY")
    frame.dropTarget.plusH:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.dropTarget.plusH:SetColorTexture(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 1)
    frame.dropTarget.plusH:SetSize(20, 4)
    frame.dropTarget.plusH:SetPoint("CENTER")
    frame.dropTarget.plusV = frame.dropTarget:CreateTexture(nil, "OVERLAY")
    frame.dropTarget.plusV:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.dropTarget.plusV:SetColorTexture(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 1)
    frame.dropTarget.plusV:SetSize(4, 20)
    frame.dropTarget.plusV:SetPoint("CENTER")
    frame.dropTarget:RegisterForClicks("AnyUp")
    frame.dropTarget:SetScript("OnReceiveDrag", function()
        Runtime:AddCursorItem(frame.qtyInput and frame.qtyInput:GetNumber() or 1)
    end)
    frame.dropTarget:SetScript("OnMouseUp", function()
        local cursorType = type(GetCursorInfo) == "function" and GetCursorInfo() or nil
        if cursorType == "item" or (CursorHasItem and CursorHasItem()) then
            Runtime:AddCursorItem(frame.qtyInput and frame.qtyInput:GetNumber() or 1)
        end
    end)
    setButtonTooltip(frame.dropTarget, L.FEATURE_SHOPPING_LIST_DROP_TARGET or "Drop Item", L.FEATURE_SHOPPING_LIST_DROP_TOOLTIP or "Drag an item here to add it to the shopping list.")
    frame.dropTarget:SetScript("OnEnter", function(selfButton)
        if selfButton.SetBackdropBorderColor then
            selfButton:SetBackdropBorderColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 1)
        end
        GameTooltip:SetOwner(selfButton, "ANCHOR_CURSOR")
        GameTooltip:AddLine(selfButton.vlTooltipTitle or "", COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], true)
        GameTooltip:AddLine(selfButton.vlTooltipText or "", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:Show()
    end)
    frame.dropTarget:SetScript("OnLeave", function(selfButton)
        if selfButton.SetBackdropBorderColor then
            selfButton:SetBackdropBorderColor(COLORS.borderSoft[1], COLORS.borderSoft[2], COLORS.borderSoft[3], COLORS.borderSoft[4])
        end
        GameTooltip:Hide()
    end)

    frame.itemInput = CreateFrame("EditBox", nil, frame.inputPanel, BACKDROP_TEMPLATE)
    frame.itemInput:SetPoint("LEFT", frame.dropTarget, "RIGHT", 10, 0)
    frame.itemInput:SetSize(238, 28)
    frame.itemInput:SetAutoFocus(false)
    frame.itemInput:SetFontObject("GameFontHighlightSmall")
    frame.itemInput:SetTextInsets(8, 8, 0, 0)
    applyBackdrop(frame.itemInput, COLORS.panelSoft, COLORS.borderSoft)

    frame.inputPlaceholder = createLabel(frame.itemInput, "GameFontDisableSmall", "LEFT")
    frame.inputPlaceholder:SetPoint("LEFT", 8, 0)
    frame.inputPlaceholder:SetPoint("RIGHT", -8, 0)
    frame.inputPlaceholder:SetText(L.FEATURE_SHOPPING_LIST_INPUT_PLACEHOLDER or "Paste item link or item ID")
    frame.itemInput:SetScript("OnTextChanged", function(selfInput)
        frame.inputPlaceholder:SetShown((selfInput:GetText() or "") == "")
    end)
    frame.itemInput:SetScript("OnEnterPressed", function()
        Runtime:AddItem(frame.itemInput:GetText(), frame.qtyInput:GetNumber())
        frame.itemInput:SetText("")
    end)

    frame.qtyInput = CreateFrame("EditBox", nil, frame.inputPanel, BACKDROP_TEMPLATE)
    frame.qtyInput:SetPoint("LEFT", frame.itemInput, "RIGHT", 8, 0)
    frame.qtyInput:SetSize(48, 28)
    frame.qtyInput:SetAutoFocus(false)
    frame.qtyInput:SetNumeric(true)
    frame.qtyInput:SetNumber(1)
    frame.qtyInput:SetFontObject("GameFontHighlightSmall")
    frame.qtyInput:SetTextInsets(8, 8, 0, 0)
    applyBackdrop(frame.qtyInput, COLORS.panelSoft, COLORS.borderSoft)

    frame.addButton = createActionButton(frame.inputPanel, L.FEATURE_SHOPPING_LIST_ADD or "Add", 84, 28)
    frame.addButton:SetPoint("LEFT", frame.qtyInput, "RIGHT", 8, 0)
    frame.addButton:SetScript("OnClick", function()
        if Runtime:AddItem(frame.itemInput:GetText(), frame.qtyInput:GetNumber()) then
            frame.itemInput:SetText("")
        end
    end)

    frame.recipeButton = createActionButton(frame.inputPanel, L.FEATURE_SHOPPING_LIST_ADD_RECIPE or "Recipe", 82, 28)
    frame.recipeButton:SetPoint("LEFT", frame.addButton, "RIGHT", 8, 0)
    setButtonTooltip(frame.recipeButton, L.FEATURE_SHOPPING_LIST_ADD_RECIPE or "Recipe", L.FEATURE_SHOPPING_LIST_RECIPE_BUTTON_TOOLTIP or "Adds the currently selected profession recipe.")
    frame.recipeButton:SetScript("OnClick", function()
        Runtime:AddSelectedRecipe()
    end)

    frame.scroll = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
    frame.scroll:SetPoint("TOPLEFT", frame.inputPanel, "BOTTOMLEFT", 0, -18)
    frame.scroll:SetPoint("BOTTOMRIGHT", -34, 18)

    frame.scrollChild = CreateFrame("Frame", nil, frame.scroll)
    frame.scrollChild:SetSize(540, 20)
    frame.scroll:SetScrollChild(frame.scrollChild)

    frame.emptyText = createLabel(frame.scrollChild, "GameFontDisable", "CENTER")
    frame.emptyText:SetPoint("TOP", frame.scrollChild, "TOP", 0, -52)
    frame.emptyText:SetWidth(440)

    frame:Hide()
    self.frame = frame
    return frame
end

function Runtime:EnsureFrame()
    if self.frame then
        return self.frame
    end
    return self:BuildFrame()
end

function Runtime:Open()
    if not self.enabled then
        self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_FEATURE_DISABLED or "Vaultloom: enable Shopping List first.")
        return
    end
    local frame = self:EnsureFrame()
    frame:Show()
    self:Refresh()
end

function Runtime:Toggle()
    if self.frame and self.frame:IsShown() then
        self.frame:Hide()
        return
    end
    self:Open()
end

function Runtime:GetRecipeButtonEnabled()
    if ns.GetFeatureOptionValue then
        return ns:GetFeatureOptionValue(FEATURE_ID, "recipe_button") ~= RECIPE_BUTTON_OFF
    end
    return true
end

function Runtime:GetMiniButtonEnabled()
    if ns.GetFeatureOptionValue then
        return ns:GetFeatureOptionValue(FEATURE_ID, "mini_button") ~= MINI_BUTTON_OFF
    end
    return true
end

function Runtime:EnsureMiniButton()
    if self.miniButton then
        return self.miniButton
    end

    local button = CreateFrame("Button", MINI_BUTTON_NAME, UIParent, BACKDROP_TEMPLATE)
    button:SetSize(MINI_BUTTON_SIZE, MINI_BUTTON_SIZE)
    button:SetFrameStrata("MEDIUM")
    button:SetFrameLevel(24)
    button:SetClampedToScreen(true)
    button:SetMovable(true)
    button:EnableMouse(true)
    button:EnableMouseWheel(true)
    button:RegisterForClicks("LeftButtonUp")
    button:RegisterForDrag("LeftButton")

    if ns.ApplyFeaturePosition then
        ns:ApplyFeaturePosition(button, MINI_BUTTON_POSITION_KEY, { point = "CENTER", relativePoint = "CENTER", x = 330, y = 0 })
    else
        button:SetPoint("CENTER", UIParent, "CENTER", 330, 0)
    end
    if ns.ApplyFeatureScale then
        ns:ApplyFeatureScale(button, MINI_BUTTON_SCALE_KEY, MINI_BUTTON_SCALE_MIN, MINI_BUTTON_SCALE_MAX, 1)
    end

    button.iconShadow = button:CreateTexture(nil, "BACKGROUND", nil, 1)
    button.iconShadow:SetPoint("TOPLEFT", 5, -5)
    button.iconShadow:SetPoint("BOTTOMRIGHT", -5, 5)
    button.iconShadow:SetTexture("Interface\\Buttons\\WHITE8X8")
    button.iconShadow:SetColorTexture(0, 0, 0, 0.58)

    button.icon = button:CreateTexture(nil, "ARTWORK")
    button.icon:SetPoint("TOPLEFT", 5, -5)
    button.icon:SetPoint("BOTTOMRIGHT", -5, 5)
    button.icon:SetTexture("Interface\\Icons\\INV_Misc_Bag_10")
    button.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    button.iconMask = button:CreateMaskTexture(nil, "ARTWORK")
    button.iconMask:SetTexture("Interface\\CHARACTERFRAME\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    button.iconMask:SetPoint("TOPLEFT", button.icon, "TOPLEFT", 0, 0)
    button.iconMask:SetPoint("BOTTOMRIGHT", button.icon, "BOTTOMRIGHT", 0, 0)
    button.icon:AddMaskTexture(button.iconMask)

    button.iconShadowMask = button:CreateMaskTexture(nil, "ARTWORK")
    button.iconShadowMask:SetTexture("Interface\\CHARACTERFRAME\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    button.iconShadowMask:SetPoint("TOPLEFT", button.iconShadow, "TOPLEFT", 0, 0)
    button.iconShadowMask:SetPoint("BOTTOMRIGHT", button.iconShadow, "BOTTOMRIGHT", 0, 0)
    button.iconShadow:AddMaskTexture(button.iconShadowMask)

    button.activeGlow = button:CreateTexture(nil, "BACKGROUND", nil, 2)
    button.activeGlow:SetPoint("TOPLEFT", -3, 3)
    button.activeGlow:SetPoint("BOTTOMRIGHT", 3, -3)
    button.activeGlow:SetTexture(RING_TEXTURE)
    button.activeGlow:SetBlendMode("ADD")
    button.activeGlow:SetVertexColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 0.46)

    button.highlight = button:CreateTexture(nil, "HIGHLIGHT")
    button.highlight:SetPoint("TOPLEFT", -1, 1)
    button.highlight:SetPoint("BOTTOMRIGHT", 1, -1)
    button.highlight:SetTexture(RING_TEXTURE)
    button.highlight:SetVertexColor(1.00, 0.92, 0.70, 0.70)
    button.highlight:SetAlpha(0)

    button.ring = button:CreateTexture(nil, "OVERLAY")
    button.ring:SetPoint("TOPLEFT", -1, 1)
    button.ring:SetPoint("BOTTOMRIGHT", 1, -1)
    button.ring:SetTexture(RING_TEXTURE)
    button.ring:SetVertexColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 0.96)
    button.ring:SetAlpha(0.96)

    button:SetScript("OnClick", function()
        Runtime:Toggle()
    end)
    button:SetScript("OnDragStart", function(selfButton)
        if InCombatLockdown and InCombatLockdown() then
            return
        end
        selfButton:StartMoving()
    end)
    button:SetScript("OnDragStop", function(selfButton)
        selfButton:StopMovingOrSizing()
        if ns.StoreFeaturePosition then
            ns:StoreFeaturePosition(selfButton, MINI_BUTTON_POSITION_KEY)
        end
    end)
    button:SetScript("OnEnter", function(selfButton)
        if selfButton.highlight then
            selfButton.highlight:SetAlpha(0.62)
        end
        GameTooltip:SetOwner(selfButton, "ANCHOR_RIGHT")
        GameTooltip:AddLine(L.FEATURE_SHOPPING_LIST_MINI_BUTTON_TITLE or "Shopping List", COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], true)
        GameTooltip:AddLine(L.FEATURE_SHOPPING_LIST_MINI_BUTTON_CLICK or "Left click: open or close", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:AddLine(L.FEATURE_SHOPPING_LIST_MINI_BUTTON_DRAG or "Left drag: move", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:AddLine(L.FEATURE_SHOPPING_LIST_MINI_BUTTON_WHEEL or "Shift + mouse wheel: size", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:Show()
    end)
    button:SetScript("OnLeave", function(selfButton)
        if selfButton.highlight then
            selfButton.highlight:SetAlpha(0)
        end
        GameTooltip:Hide()
    end)
    button:SetScript("OnMouseWheel", function(selfButton, delta)
        if type(IsShiftKeyDown) == "function" and not IsShiftKeyDown() then
            return
        end
        if not (ns.GetFeatureScale and ns.ClampNumber and ns.StoreFeatureScale) then
            return
        end

        local nextScale = ns:GetFeatureScale(MINI_BUTTON_SCALE_KEY, MINI_BUTTON_SCALE_MIN, MINI_BUTTON_SCALE_MAX, 1)
        if delta > 0 then
            nextScale = nextScale + MINI_BUTTON_SCALE_STEP
        elseif delta < 0 then
            nextScale = nextScale - MINI_BUTTON_SCALE_STEP
        end
        nextScale = ns:ClampNumber(nextScale, MINI_BUTTON_SCALE_MIN, MINI_BUTTON_SCALE_MAX)
        selfButton:SetScale(nextScale)
        ns:StoreFeatureScale(MINI_BUTTON_SCALE_KEY, nextScale, MINI_BUTTON_SCALE_MIN, MINI_BUTTON_SCALE_MAX)
    end)

    button:Hide()
    self.miniButton = button
    return button
end

function Runtime:RefreshMiniButton()
    if self.enabled ~= true or not self:GetMiniButtonEnabled() then
        if self.miniButton then
            self.miniButton:Hide()
        end
        return
    end

    local button = self:EnsureMiniButton()
    if ns.ApplyFeatureScale then
        ns:ApplyFeatureScale(button, MINI_BUTTON_SCALE_KEY, MINI_BUTTON_SCALE_MIN, MINI_BUTTON_SCALE_MAX, 1)
    end
    button:Show()
end

function Runtime:EnsureHooks()
    if self.hooksReady then
        return
    end
    self.hooksReady = true
end

function Runtime:EnsureProfessionButton()
    local parent = nil
    if type(ProfessionsFrame) == "table" and ProfessionsFrame.IsShown and ProfessionsFrame:IsShown() then
        parent = ProfessionsFrame
    elseif type(TradeSkillFrame) == "table" and TradeSkillFrame.IsShown and TradeSkillFrame:IsShown() then
        parent = TradeSkillFrame
    end

    if not parent then
        if self.professionButton then
            self.professionButton:Hide()
        end
        return nil
    end

    local button = self.professionButton
    if not button then
        button = createActionButton(parent, L.FEATURE_SHOPPING_LIST_RECIPE_BUTTON or "Shopping List", 126, 28)
        button:SetFrameStrata("HIGH")
        button:SetScript("OnClick", function()
            Runtime:AddSelectedRecipe()
        end)
        self.professionButton = button
    end

    if button:GetParent() ~= parent then
        button:SetParent(parent)
    end

    button:ClearAllPoints()
    button:SetPoint("TOPLEFT", parent, "TOPRIGHT", 8, -62)
    setButtonText(button, L.FEATURE_SHOPPING_LIST_RECIPE_BUTTON or "Shopping List")
    setButtonTooltip(button, L.FEATURE_SHOPPING_LIST_RECIPE_BUTTON or "Shopping List", L.FEATURE_SHOPPING_LIST_RECIPE_BUTTON_TOOLTIP or "Adds the currently selected profession recipe.")
    return button
end

function Runtime:RefreshProfessionButton()
    if self.enabled ~= true or not self:GetRecipeButtonEnabled() then
        if self.professionButton then
            self.professionButton:Hide()
        end
        return
    end

    local button = self:EnsureProfessionButton()
    if button then
        button:SetShown(selectedRecipeFromFrameFields() ~= nil)
    end
end

local function callObjectMethod(object, methodName)
    if type(object) == "table" and type(object[methodName]) == "function" then
        local ok, value = pcall(object[methodName], object)
        if ok then
            return value
        end
    end
    return nil
end

local function getItemLinkFromLocation(location)
    if not location then
        return nil
    end
    if C_Item and type(C_Item.GetItemLink) == "function" then
        local okLink, itemLink = pcall(C_Item.GetItemLink, location)
        if okLink and type(itemLink) == "string" and itemLink ~= "" then
            return itemLink
        end
    end
    return nil
end

local function getItemIDFromLocation(location)
    if not location then
        return nil
    end
    if C_AuctionHouse and type(C_AuctionHouse.GetItemKeyFromItem) == "function" then
        local okKey, itemKey = pcall(C_AuctionHouse.GetItemKeyFromItem, location)
        if okKey and type(itemKey) == "table" then
            return parseItemID(itemKey.itemID or itemKey.itemId)
        end
    end
    return nil
end

local function extractAuctionItem(value, depth)
    depth = (tonumber(depth) or 0) + 1
    if depth > 4 then
        return nil, nil
    end

    if type(value) == "number" then
        return parseItemID(value), nil
    end
    if type(value) == "string" then
        return parseItemID(value), value
    end
    if type(value) ~= "table" then
        return nil, nil
    end

    local itemLink = value.itemLink or value.link or value.hyperlink or callObjectMethod(value, "GetItemLink") or callObjectMethod(value, "GetItemHyperlink")
    local itemID = parseItemID(itemLink)
        or parseItemID(value.itemID)
        or parseItemID(value.itemId)
        or parseItemID(value.id)
        or parseItemID(callObjectMethod(value, "GetItemID"))
        or parseItemID(callObjectMethod(value, "GetID"))

    if (not itemID) and type(value.itemKey) == "table" then
        itemID = parseItemID(value.itemKey.itemID or value.itemKey.itemId)
    end
    if (not itemID) and type(value.item) == "table" then
        itemID, itemLink = extractAuctionItem(value.item, depth)
    end
    if (not itemID) and type(value.itemInfo) == "table" then
        itemID, itemLink = extractAuctionItem(value.itemInfo, depth)
    end
    if (not itemID) and type(value.selectedEntry) == "table" then
        itemID, itemLink = extractAuctionItem(value.selectedEntry, depth)
    end
    if (not itemID) and type(value.selectedResult) == "table" then
        itemID, itemLink = extractAuctionItem(value.selectedResult, depth)
    end
    if (not itemID) and type(value.selectedItem) == "table" then
        itemID, itemLink = extractAuctionItem(value.selectedItem, depth)
    end
    if (not itemID) and type(value.selectedItemKey) == "table" then
        itemID, itemLink = extractAuctionItem(value.selectedItemKey, depth)
    end
    if not itemID then
        itemID, itemLink = extractAuctionItem(callObjectMethod(value, "GetSelectedEntry"), depth)
    end
    if not itemID then
        itemID, itemLink = extractAuctionItem(callObjectMethod(value, "GetSelectedItem"), depth)
    end
    if not itemID then
        itemID, itemLink = extractAuctionItem(callObjectMethod(value, "GetSelectedItemKey"), depth)
    end

    return itemID, itemLink
end

local function addAuctionCandidate(candidates, itemID, itemLink)
    itemID = parseItemID(itemID or itemLink)
    if itemID then
        candidates[#candidates + 1] = {
            itemID = itemID,
            itemLink = itemLink,
        }
    end
end

local function addAuctionDisplayCandidate(candidates, display)
    if type(display) ~= "table" then
        return
    end

    local location = callObjectMethod(display, "GetItemLocation")
    if location then
        local itemLink = getItemLinkFromLocation(location)
        addAuctionCandidate(candidates, parseItemID(itemLink) or getItemIDFromLocation(location), itemLink)
    end

    local itemID, itemLink = extractAuctionItem(callObjectMethod(display, "GetItem"))
    addAuctionCandidate(candidates, itemID, itemLink)

    itemID, itemLink = extractAuctionItem(callObjectMethod(display, "GetItemKey"))
    addAuctionCandidate(candidates, itemID, itemLink)

    itemID, itemLink = extractAuctionItem(display)
    addAuctionCandidate(candidates, itemID, itemLink)
end

local function getNestedField(root, ...)
    local current = root
    for index = 1, select("#", ...) do
        if type(current) ~= "table" then
            return nil
        end
        current = current[select(index, ...)]
        if not current then
            return nil
        end
    end
    return current
end

function Runtime:GetAuctionItem()
    local auctionFrame = type(AuctionHouseFrame) == "table" and AuctionHouseFrame or nil
    if not auctionFrame or (auctionFrame.IsShown and not auctionFrame:IsShown()) then
        return nil
    end

    local candidates = {}
    local displayPaths = {
        { "CommoditiesSellFrame", "ItemDisplay" },
        { "ItemSellFrame", "ItemDisplay" },
        { "CommoditiesBuyFrame", "ItemDisplay" },
        { "ItemBuyFrame", "ItemDisplay" },
        { "BuyDialog", "ItemDisplay" },
    }

    for _, path in ipairs(displayPaths) do
        addAuctionDisplayCandidate(candidates, getNestedField(auctionFrame, path[1], path[2]))
    end

    addAuctionDisplayCandidate(candidates, auctionFrame.CommoditiesSellFrame)
    addAuctionDisplayCandidate(candidates, auctionFrame.ItemSellFrame)
    addAuctionDisplayCandidate(candidates, auctionFrame.CommoditiesBuyFrame)
    addAuctionDisplayCandidate(candidates, auctionFrame.ItemBuyFrame)
    addAuctionDisplayCandidate(candidates, auctionFrame.BrowseResultsFrame)

    for _, candidate in ipairs(candidates) do
        local details = getItemDetails(candidate.itemID, candidate.itemLink)
        if details and details.itemID then
            return details
        end
    end

    return nil
end

function Runtime:AddAuctionItem()
    if self.enabled ~= true then
        self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_FEATURE_DISABLED or "Vaultloom: enable Shopping List first.")
        return false
    end

    local details = self:GetAuctionItem()
    if not details then
        self:ShowMessage(L.FEATURE_SHOPPING_LIST_ERROR_NO_AUCTION_ITEM or L.FEATURE_SHOPPING_LIST_ERROR_NO_ITEM or "Vaultloom: no auction item found.")
        return false
    end

    self:Open()
    return self:AddItem(details.itemLink or details.itemID, 1, false)
end

function Runtime:EnsureAuctionButton()
    local parent = type(AuctionHouseFrame) == "table" and AuctionHouseFrame or nil
    if not parent or (parent.IsShown and not parent:IsShown()) then
        if self.auctionButton then
            self.auctionButton:Hide()
        end
        return nil
    end

    local button = self.auctionButton
    if not button then
        button = createActionButton(parent, L.FEATURE_SHOPPING_LIST_AUCTION_BUTTON or "Add to List", 126, 28)
        button:SetFrameStrata("HIGH")
        button:SetScript("OnClick", function()
            Runtime:AddAuctionItem()
        end)
        self.auctionButton = button
    end

    if button:GetParent() ~= parent then
        button:SetParent(parent)
    end

    button:ClearAllPoints()
    button:SetPoint("TOPLEFT", parent, "TOPRIGHT", 8, -62)
    if parent.GetFrameLevel and button.SetFrameLevel then
        local okLevel, level = pcall(parent.GetFrameLevel, parent)
        if okLevel and tonumber(level) then
            button:SetFrameLevel(level + 30)
        end
    end
    setButtonText(button, L.FEATURE_SHOPPING_LIST_AUCTION_BUTTON or "Add to List")
    setButtonTooltip(button, L.FEATURE_SHOPPING_LIST_AUCTION_BUTTON or "Add to List", L.FEATURE_SHOPPING_LIST_AUCTION_BUTTON_TOOLTIP or "Adds the item currently shown in the auction house.")
    return button
end

function Runtime:RefreshAuctionButton()
    if self.enabled ~= true then
        if self.auctionButton then
            self.auctionButton:Hide()
        end
        return
    end

    local button = self:EnsureAuctionButton()
    if button then
        button:Show()
    end
end

function Runtime:EnsureEventFrame()
    if self.eventFrame then
        return self.eventFrame
    end

    local frame = CreateFrame("Frame")
    frame:SetScript("OnEvent", function(_, event)
        if Runtime.enabled ~= true then
            return
        end

        if event == "BAG_UPDATE_DELAYED"
            or event == "PLAYERBANKSLOTS_CHANGED"
            or event == "PLAYERREAGENTBANKSLOTS_CHANGED"
            or event == "PLAYER_ACCOUNT_BANK_TAB_SLOTS_CHANGED"
        then
            if Runtime.frame and Runtime.frame:IsShown() then
                Runtime:QueueRefresh(0.10)
            end
        elseif event == "TRADE_SKILL_SHOW"
            or event == "TRADE_SKILL_LIST_UPDATE"
            or event == "PROFESSIONS_CRAFTING_FORM_REFRESH"
            or event == "PROFESSIONS_RECIPE_LIST_SELECTION_CHANGED"
        then
            Runtime:QueueRefresh(0.05)
        elseif event == "TRADE_SKILL_CLOSE" then
            if Runtime.professionButton then
                Runtime.professionButton:Hide()
            end
        elseif event == "AUCTION_HOUSE_CLOSED" then
            if Runtime.auctionButton then
                Runtime.auctionButton:Hide()
            end
        elseif event == "AUCTION_HOUSE_SHOW"
            or event == "AUCTION_HOUSE_BROWSE_RESULTS_UPDATED"
            or event == "AUCTION_HOUSE_NEW_RESULTS_RECEIVED"
            or event == "ITEM_SEARCH_RESULTS_UPDATED"
            or event == "COMMODITY_SEARCH_RESULTS_UPDATED"
        then
            C_Timer.After(0.05, function()
                Runtime:RefreshAuctionButton()
            end)
        end
    end)

    self.eventFrame = frame
    return frame
end

local function registerEvent(frame, eventName)
    if frame and type(eventName) == "string" and eventName ~= "" then
        pcall(frame.RegisterEvent, frame, eventName)
    end
end

function Runtime:RegisterEvents()
    if self.eventsReady then
        return
    end

    local frame = self:EnsureEventFrame()
    self.eventsReady = true
    registerEvent(frame, "BAG_UPDATE_DELAYED")
    registerEvent(frame, "PLAYERBANKSLOTS_CHANGED")
    registerEvent(frame, "PLAYERREAGENTBANKSLOTS_CHANGED")
    registerEvent(frame, "PLAYER_ACCOUNT_BANK_TAB_SLOTS_CHANGED")
    registerEvent(frame, "TRADE_SKILL_SHOW")
    registerEvent(frame, "TRADE_SKILL_CLOSE")
    registerEvent(frame, "TRADE_SKILL_LIST_UPDATE")
    registerEvent(frame, "PROFESSIONS_CRAFTING_FORM_REFRESH")
    registerEvent(frame, "PROFESSIONS_RECIPE_LIST_SELECTION_CHANGED")
    registerEvent(frame, "AUCTION_HOUSE_SHOW")
    registerEvent(frame, "AUCTION_HOUSE_CLOSED")
    registerEvent(frame, "AUCTION_HOUSE_BROWSE_RESULTS_UPDATED")
    registerEvent(frame, "AUCTION_HOUSE_NEW_RESULTS_RECEIVED")
    registerEvent(frame, "ITEM_SEARCH_RESULTS_UPDATED")
    registerEvent(frame, "COMMODITY_SEARCH_RESULTS_UPDATED")
end

function Runtime:UnregisterEvents()
    if self.eventFrame and self.eventsReady then
        pcall(self.eventFrame.UnregisterAllEvents, self.eventFrame)
    end
    self.eventsReady = false
    self.refreshQueued = false
end

function Runtime:SetEnabled(enabled, reason)
    self.enabled = enabled == true
    self:EnsureHooks()

    if self.enabled then
        self:RegisterEvents()
        self:RefreshMiniButton()
        self:RefreshAuctionButton()
        self:QueueRefresh(0.10)
        if reason == "toggle" then
            self:Open()
        end
    else
        self:UnregisterEvents()
        if self.frame then
            self.frame:Hide()
        end
        if self.professionButton then
            self.professionButton:Hide()
        end
        if self.auctionButton then
            self.auctionButton:Hide()
        end
        if self.miniButton then
            self.miniButton:Hide()
        end
    end
end

function ns:OpenShoppingList()
    Runtime:Open()
end

function ns:ToggleShoppingList()
    Runtime:Toggle()
end

function ns:AddItemToShoppingList(value, quantity)
    return Runtime:AddItem(value, quantity, false)
end

function ns:AddSelectedRecipeToShoppingList()
    return Runtime:AddSelectedRecipe()
end

ns:RegisterFeatureDefinition(FEATURE_ID, {
    id = FEATURE_ID,
    order = 31,
    category = "crafting",
    title = function() return L.FEATURE_SHOPPING_LIST_TITLE or "Shopping List" end,
    description = function() return L.FEATURE_SHOPPING_LIST_DESCRIPTION or "Plans item and recipe material needs with saved inventory totals." end,
    note = function() return L.FEATURE_SHOPPING_LIST_NOTE or "Open with /vl shop, paste item links manually, or use the profession recipe button. Refreshes only on events and button clicks." end,
    previewLabel = function() return L.FEATURE_SHOPPING_LIST_PREVIEW_LABEL or "Material Plan" end,
    previewNote = function() return L.FEATURE_SHOPPING_LIST_PREVIEW_NOTE or "Target items on top, reagents below with needed, owned, and missing counts." end,
    optionToggle = {
        key = "mini_button",
        label = function() return L.FEATURE_SHOPPING_LIST_MINI_TOGGLE_LABEL or "Mini Button" end,
        values = { MINI_BUTTON_ON, MINI_BUTTON_OFF },
        defaultValue = MINI_BUTTON_ON,
        valueLabels = {
            on = function() return L.FEATURE_SHOPPING_LIST_MINI_TOGGLE_ON or "Button On" end,
            off = function() return L.FEATURE_SHOPPING_LIST_MINI_TOGGLE_OFF or "Button Off" end,
        },
    },
    secondaryOptionToggle = {
        key = "recipe_button",
        label = function() return L.FEATURE_SHOPPING_LIST_RECIPE_TOGGLE_LABEL or "Recipe Button" end,
        values = { RECIPE_BUTTON_ON, RECIPE_BUTTON_OFF },
        defaultValue = RECIPE_BUTTON_ON,
        valueLabels = {
            on = function() return L.FEATURE_SHOPPING_LIST_RECIPE_TOGGLE_ON or "Recipe On" end,
            off = function() return L.FEATURE_SHOPPING_LIST_RECIPE_TOGGLE_OFF or "Recipe Off" end,
        },
    },
    requiresReload = false,
    apply = function(isEnabled, reason)
        Runtime:SetEnabled(isEnabled == true, reason)
    end,
    onOptionChanged = function()
        Runtime:RefreshProfessionButton()
        Runtime:RefreshAuctionButton()
        Runtime:RefreshMiniButton()
    end,
})
