local _, ns = ...
local L = ns.L or {}

local FEATURE_ID = "merchant_filters"
local CONFIG_VERSION = 7
local ROW_HEIGHT = 48
local ASSET_ROOT = "Interface\\AddOns\\Vaultloom\\Assets\\"
local BUTTON_NORMAL = ASSET_ROOT .. "button_reskin_normal.tga"
local BUTTON_HOVER = ASSET_ROOT .. "button_reskin_hover.tga"
local BUTTON_PRESSED = ASSET_ROOT .. "button_reskin_pressed.tga"
local MERCHANT_WINDOW_BG = ASSET_ROOT .. "merchant_window_bg.tga"
local MERCHANT_HEADER_BG = ASSET_ROOT .. "merchant_header_bg.tga"
local SCROLL_TRACK = ASSET_ROOT .. "scroll_track_reskin.tga"
local SCROLL_THUMB = ASSET_ROOT .. "scroll_thumb_reskin.tga"
local MERCHANT_WINDOW_SCALE_KEY = "merchant_window_scale"
local MERCHANT_WINDOW_SCALE_MIN = 0.75
local MERCHANT_WINDOW_SCALE_MAX = 1.35
local MERCHANT_WINDOW_SCALE_STEP = 0.05
local DISPLAY_MODE_REPLACE = "replace"
local DISPLAY_MODE_BUTTON = "button"
local DISPLAY_MODE_FILTER_ONLY = "filter_only"
local CLASS_FILTER_PLAYER = "player"
local CLASS_FILTER_ALL = "all"
local POOR_QUALITY = Enum and Enum.ItemQuality and Enum.ItemQuality.Poor or 0
local QUEST_ITEM_CLASS_ID = Enum and Enum.ItemClass and (Enum.ItemClass.Questitem or Enum.ItemClass.QuestItem) or nil
local SELL_STEP_DELAY_SECONDS = 0.08

local THEME = {
    bg = { 0.035, 0.032, 0.028 },
    panel = { 0.075, 0.065, 0.052 },
    row = { 0.105, 0.090, 0.072 },
    rowHover = { 0.155, 0.128, 0.090 },
    border = { 0.62, 0.46, 0.15 },
    borderBright = { 1.00, 0.82, 0.18 },
    text = { 0.92, 0.90, 0.84 },
    muted = { 0.66, 0.66, 0.62 },
    green = { 0.24, 0.92, 0.38 },
    red = { 0.95, 0.22, 0.18 },
}

local DEFAULT_FILTERS = {
    hideKnown = false,
    hideUnusableRecipes = false,
    hideUnaffordable = false,
    onlyRecipes = false,
    onlyKnownProfessions = false,
    hideDecor = false,
    hideMounts = false,
    hidePets = false,
    hideToys = false,
    hideCosmetics = false,
    hideGearAll = false,
    hideGearHead = false,
    hideGearNeck = false,
    hideGearShoulder = false,
    hideGearBack = false,
    hideGearChest = false,
    hideGearWrist = false,
    hideGearHands = false,
    hideGearWaist = false,
    hideGearLegs = false,
    hideGearFeet = false,
    hideGearFinger = false,
    hideGearTrinket = false,
    hideGearWeapon = false,
    hideGearOffHand = false,
}

local FILTER_ROWS = {
    { key = "hideKnown", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_KNOWN or "Bereits bekannte ausblenden" end },
    { key = "hideUnusableRecipes", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_UNUSABLE_RECIPES or "Rezepte anderer Berufe ausblenden" end },
    { key = "hideUnaffordable", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_UNAFFORDABLE or "Nicht kaufbare ausblenden" end },
    { key = "onlyRecipes", label = function() return L.FEATURE_MERCHANT_FILTERS_ONLY_RECIPES or "Nur Rezepte" end },
    { divider = true },
    { key = "hideDecor", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_DECOR or "Dekor ausblenden" end },
    { key = "hideMounts", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_MOUNTS or "Reittiere ausblenden" end },
    { key = "hidePets", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_PETS or "Haustiere ausblenden" end },
    { key = "hideToys", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_TOYS or "Spielzeuge ausblenden" end },
    { key = "hideCosmetics", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_COSMETICS or "Kosmetik ausblenden" end },
    { divider = true },
    { key = "hideGearAll", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_ALL or "Ausrüstung ausblenden" end },
    { key = "hideGearHead", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_HEAD or "Kopf / Helm ausblenden" end },
    { key = "hideGearNeck", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_NECK or "Hals ausblenden" end },
    { key = "hideGearShoulder", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_SHOULDER or "Schulter ausblenden" end },
    { key = "hideGearBack", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_BACK or "Rücken ausblenden" end },
    { key = "hideGearChest", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_CHEST or "Brust ausblenden" end },
    { key = "hideGearWrist", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_WRIST or "Handgelenke ausblenden" end },
    { key = "hideGearHands", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_HANDS or "Hände ausblenden" end },
    { key = "hideGearWaist", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_WAIST or "Taille ausblenden" end },
    { key = "hideGearLegs", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_LEGS or "Beine ausblenden" end },
    { key = "hideGearFeet", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_FEET or "Füße ausblenden" end },
    { key = "hideGearFinger", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_FINGER or "Ringe ausblenden" end },
    { key = "hideGearTrinket", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_TRINKET or "Schmuck ausblenden" end },
    { key = "hideGearWeapon", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_WEAPON or "Waffen ausblenden" end },
    { key = "hideGearOffHand", label = function() return L.FEATURE_MERCHANT_FILTERS_HIDE_GEAR_OFFHAND or "Nebenhand ausblenden" end },
}

local PROFESSION_SPELL_IDS = {
    2259, 2018, 7411, 4036, 2366, 45357, 25229, 2108,
    2575, 8613, 3908, 2550, 131474, 78670,
}

local PROFESSION_ALIAS_GROUPS = {
    { "alchemy", "alchemie" },
    { "blacksmithing", "schmiedekunst" },
    { "enchanting", "verzauberkunst" },
    { "engineering", "ingenieurskunst" },
    { "herbalism", "kraeuterkunde" },
    { "inscription", "inschriftenkunde" },
    { "jewelcrafting", "juwelierskunst" },
    { "leatherworking", "lederverarbeitung" },
    { "mining", "bergbau" },
    { "skinning", "kuerschnerei" },
    { "tailoring", "schneiderei", "schneider" },
    { "cooking", "kochkunst", "kochen" },
    { "fishing", "angeln" },
    { "archaeology", "archaeologie" },
}

local SAVE_MODES = { "off", "merchant", "global" }

local GEAR_FILTER_BY_EQUIP_LOC = {
    INVTYPE_HEAD = "hideGearHead",
    INVTYPE_NECK = "hideGearNeck",
    INVTYPE_SHOULDER = "hideGearShoulder",
    INVTYPE_CLOAK = "hideGearBack",
    INVTYPE_CHEST = "hideGearChest",
    INVTYPE_ROBE = "hideGearChest",
    INVTYPE_WRIST = "hideGearWrist",
    INVTYPE_HAND = "hideGearHands",
    INVTYPE_WAIST = "hideGearWaist",
    INVTYPE_LEGS = "hideGearLegs",
    INVTYPE_FEET = "hideGearFeet",
    INVTYPE_FINGER = "hideGearFinger",
    INVTYPE_TRINKET = "hideGearTrinket",
    INVTYPE_WEAPON = "hideGearWeapon",
    INVTYPE_2HWEAPON = "hideGearWeapon",
    INVTYPE_WEAPONMAINHAND = "hideGearWeapon",
    INVTYPE_RANGED = "hideGearWeapon",
    INVTYPE_RANGEDRIGHT = "hideGearWeapon",
    INVTYPE_THROWN = "hideGearWeapon",
    INVTYPE_SHIELD = "hideGearOffHand",
    INVTYPE_HOLDABLE = "hideGearOffHand",
    INVTYPE_WEAPONOFFHAND = "hideGearOffHand",
}

local Runtime = {
    enabled = false,
    hooked = false,
    refreshToken = 0,
    activeTab = "buy",
    items = {},
    filtered = {},
    buyback = {},
    merchantCurrencies = {},
    merchantCurrencyOrder = {},
    defaultMerchantAlpha = nil,
    defaultMerchantMouse = nil,
    defaultMerchantStrata = nil,
    defaultMerchantFrameLevel = nil,
    sellingJunk = false,
    junkSellToken = 0,
    originalFilterApplied = false,
    restoringOriginal = false,
}

local function wipeTable(tbl)
    if type(wipe) == "function" then
        wipe(tbl)
        return tbl
    end
    for key in pairs(tbl) do
        tbl[key] = nil
    end
    return tbl
end

local function copyValues(target, source)
    target = target or {}
    for key in pairs(DEFAULT_FILTERS) do
        target[key] = source and source[key] == true or false
    end
    return target
end

local function lowerText(text)
    local ok, value = pcall(function()
        if type(text) ~= "string" then
            return ""
        end
        return string.lower(text)
    end)
    if ok and type(value) == "string" then
        return value
    end
    return ""
end

local function stripTextureCodes(text)
    if type(text) ~= "string" then
        return ""
    end
    text = text:gsub("|T.-|t", "")
    text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
    text = text:gsub("|r", "")
    return text
end

local function setColorTexture(texture, color, alpha)
    if texture and texture.SetColorTexture then
        texture:SetColorTexture(color[1], color[2], color[3], alpha == nil and 1 or alpha)
    end
end

local function setBackdrop(frame, bg, border, alpha)
    if not frame or not frame.SetBackdrop then
        return
    end
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    frame:SetBackdropColor(bg[1], bg[2], bg[3], alpha or 0.96)
    frame:SetBackdropBorderColor(border[1], border[2], border[3], 0.78)
end

local function setWindowBackdrop(frame)
    if not frame or not frame.SetBackdrop then
        return
    end
    frame:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true,
        tileSize = 32,
        edgeSize = 16,
        insets = { left = 5, right = 5, top = 5, bottom = 5 },
    })
    frame:SetBackdropColor(0.045, 0.040, 0.034, 0)
    frame:SetBackdropBorderColor(0.76, 0.58, 0.24, 0.94)
end

local function createLabel(parent, font, justify)
    local label = parent:CreateFontString(nil, "OVERLAY", font or "GameFontHighlightSmall")
    label:SetJustifyH(justify or "LEFT")
    label:SetTextColor(THEME.text[1], THEME.text[2], THEME.text[3], 1)
    return label
end

local function styleButton(button, width)
    button:SetSize(width or 92, 24)
    setBackdrop(button, THEME.panel, THEME.border, 0.92)

    button.normalTexture = button.normalTexture or button:CreateTexture(nil, "BACKGROUND")
    button.normalTexture:SetAllPoints()
    button.normalTexture:SetTexture(BUTTON_NORMAL)

    button.hoverTexture = button.hoverTexture or button:CreateTexture(nil, "BORDER")
    button.hoverTexture:SetAllPoints()
    button.hoverTexture:SetTexture(BUTTON_HOVER)
    button.hoverTexture:Hide()

    button.pressedTexture = button.pressedTexture or button:CreateTexture(nil, "BORDER")
    button.pressedTexture:SetAllPoints()
    button.pressedTexture:SetTexture(BUTTON_PRESSED)
    button.pressedTexture:Hide()

    button.label = createLabel(button, "GameFontNormalSmall", "CENTER")
    button.label:SetPoint("CENTER", 0, 0)
    button.label:SetTextColor(1, 0.84, 0.18, 1)

    button:SetScript("OnEnter", function(self)
        if self:IsEnabled() then
            self.hoverTexture:Show()
        end
        if GameTooltip and (self.vlTooltipTitle or self.vlTooltipText) then
            GameTooltip:SetOwner(self, "ANCHOR_TOP")
            if self.vlTooltipTitle and self.vlTooltipTitle ~= "" then
                GameTooltip:AddLine(self.vlTooltipTitle, 1, 0.84, 0.18, true)
            end
            if self.vlTooltipText and self.vlTooltipText ~= "" then
                GameTooltip:AddLine(self.vlTooltipText, THEME.text[1], THEME.text[2], THEME.text[3], true)
            end
            GameTooltip:Show()
        end
    end)
    button:SetScript("OnLeave", function(self)
        self.hoverTexture:Hide()
        self.pressedTexture:Hide()
        if GameTooltip then
            GameTooltip:Hide()
        end
    end)
    button:SetScript("OnMouseDown", function(self)
        if self:IsEnabled() then
            self.pressedTexture:Show()
        end
    end)
    button:SetScript("OnMouseUp", function(self)
        self.pressedTexture:Hide()
    end)
    button.SetButtonText = function(self, text)
        self.label:SetText(text or "")
    end
    button.SetButtonEnabled = function(self, enabled)
        self:SetEnabled(enabled == true)
        self:SetAlpha(enabled == true and 1 or 0.45)
    end
end

local function createButton(parent, text, width)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
    styleButton(button, width)
    button:SetButtonText(text)
    return button
end

local function styleTab(button)
    setBackdrop(button, THEME.panel, THEME.border, 0.92)
    button.normalTexture = button.normalTexture or button:CreateTexture(nil, "BACKGROUND")
    button.normalTexture:SetAllPoints()
    button.normalTexture:SetTexture(BUTTON_NORMAL)
    button.hoverTexture = button.hoverTexture or button:CreateTexture(nil, "BORDER")
    button.hoverTexture:SetAllPoints()
    button.hoverTexture:SetTexture(BUTTON_HOVER)
    button.hoverTexture:SetAlpha(0.0)
    button.label = createLabel(button, "GameFontNormalSmall", "CENTER")
    button.label:SetPoint("CENTER", 0, 0)
    button.SetButtonText = function(self, text)
        self.label:SetText(text or "")
    end
    button.SetActive = function(self, active)
        if active then
            setBackdrop(self, { 0.13, 0.105, 0.070 }, THEME.borderBright, 1)
            self.hoverTexture:SetAlpha(1)
            self.label:SetTextColor(1, 0.86, 0.20, 1)
        else
            setBackdrop(self, THEME.panel, THEME.border, 0.92)
            self.hoverTexture:SetAlpha(0)
            self.label:SetTextColor(THEME.text[1], THEME.text[2], THEME.text[3], 0.86)
        end
    end
end

local function createCheckbox(parent, text)
    local row = CreateFrame("Button", nil, parent)
    row:SetSize(260, 22)
    row.checked = false

    row.box = CreateFrame("Frame", nil, row, "BackdropTemplate")
    row.box:SetSize(16, 16)
    row.box:SetPoint("LEFT", 0, 0)
    setBackdrop(row.box, { 0.018, 0.016, 0.014 }, THEME.border, 0.96)

    row.check = row.box:CreateTexture(nil, "ARTWORK")
    row.check:SetSize(18, 18)
    row.check:SetPoint("CENTER", row.box, "CENTER", 1, 0)
    row.check:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
    row.check:SetVertexColor(1, 0.86, 0.18, 1)
    row.check:Hide()

    row.label = createLabel(row, "GameFontHighlightSmall", "LEFT")
    row.label:SetPoint("LEFT", row.box, "RIGHT", 8, 0)
    row.label:SetPoint("RIGHT", row, "RIGHT", 0, 0)
    row.label:SetText(text or "")

    row.SetChecked = function(self, checked)
        self.checked = checked == true
        self.check:SetShown(self.checked)
        if self.checked then
            self.label:SetTextColor(1, 0.86, 0.20, 1)
            self.box:SetBackdropBorderColor(THEME.borderBright[1], THEME.borderBright[2], THEME.borderBright[3], 0.94)
        else
            self.label:SetTextColor(THEME.text[1], THEME.text[2], THEME.text[3], 0.92)
            self.box:SetBackdropBorderColor(THEME.border[1], THEME.border[2], THEME.border[3], 0.72)
        end
    end
    row.GetChecked = function(self)
        return self.checked == true
    end
    return row
end

local function styleVaultloomSlider(slider, thumbHeight)
    if not slider then
        return
    end
    setBackdrop(slider, { 0.018, 0.016, 0.014 }, THEME.border, 0.62)
    if not slider.trackTexture then
        slider.trackTexture = slider:CreateTexture(nil, "BACKGROUND")
        slider.trackTexture:SetAllPoints()
    end
    slider.trackTexture:SetTexture(SCROLL_TRACK)
    slider.trackTexture:SetVertexColor(1, 1, 1, 0.86)

    slider:SetThumbTexture(SCROLL_THUMB)
    if slider.GetThumbTexture then
        local thumb = slider:GetThumbTexture()
        if thumb then
            thumb:SetSize(12, thumbHeight or 34)
            thumb:SetVertexColor(1, 1, 1, 0.94)
        end
    end
end

local function getMerchantWindowScale()
    if ns.GetFeatureScale then
        return ns:GetFeatureScale(MERCHANT_WINDOW_SCALE_KEY, MERCHANT_WINDOW_SCALE_MIN, MERCHANT_WINDOW_SCALE_MAX, 1)
    end
    return 1
end

local function storeMerchantWindowScale(scale)
    if ns.StoreFeatureScale then
        ns:StoreFeatureScale(MERCHANT_WINDOW_SCALE_KEY, scale, MERCHANT_WINDOW_SCALE_MIN, MERCHANT_WINDOW_SCALE_MAX)
    end
end

local function applyMerchantWindowScale(frame)
    if frame and frame.SetScale then
        frame:SetScale(getMerchantWindowScale())
    end
end

local function scaleMerchantWindowWithWheel(frame, delta)
    if not frame or tonumber(delta) == nil then
        return false
    end
    local ctrlDown = type(IsControlKeyDown) == "function" and IsControlKeyDown()
    local shiftDown = type(IsShiftKeyDown) == "function" and IsShiftKeyDown()
    if not ctrlDown and not shiftDown then
        return false
    end

    local nextScale = getMerchantWindowScale() + ((delta > 0 and 1 or -1) * MERCHANT_WINDOW_SCALE_STEP)
    if ns.ClampNumber then
        nextScale = ns:ClampNumber(nextScale, MERCHANT_WINDOW_SCALE_MIN, MERCHANT_WINDOW_SCALE_MAX)
    else
        nextScale = math.max(MERCHANT_WINDOW_SCALE_MIN, math.min(MERCHANT_WINDOW_SCALE_MAX, nextScale))
    end
    frame:SetScale(nextScale)
    storeMerchantWindowScale(nextScale)
    return true
end

local function getFeatureStore()
    if not ns.EnsureFeatureDB then
        return nil
    end
    local featureDB = ns:EnsureFeatureDB()
    if type(featureDB) ~= "table" then
        return nil
    end

    featureDB.merchantFilters = type(featureDB.merchantFilters) == "table" and featureDB.merchantFilters or {}
    local db = featureDB.merchantFilters
    if db.version ~= CONFIG_VERSION then
        db.filters = copyValues({}, DEFAULT_FILTERS)
        db.globalFilters = copyValues({}, DEFAULT_FILTERS)
        db.merchantFilters = {}
        db.saveMode = "off"
        db.classFilter = CLASS_FILTER_ALL
        db.version = CONFIG_VERSION
    end
    db.filters = copyValues(type(db.filters) == "table" and db.filters or {}, db.filters or DEFAULT_FILTERS)
    if db.filters.onlyKnownProfessions == true then
        db.filters.hideUnusableRecipes = true
        db.filters.onlyKnownProfessions = false
    end
    db.globalFilters = copyValues(type(db.globalFilters) == "table" and db.globalFilters or {}, db.globalFilters or DEFAULT_FILTERS)
    if db.globalFilters.onlyKnownProfessions == true then
        db.globalFilters.hideUnusableRecipes = true
        db.globalFilters.onlyKnownProfessions = false
    end
    db.merchantFilters = type(db.merchantFilters) == "table" and db.merchantFilters or {}
    for _, merchantFilters in pairs(db.merchantFilters) do
        if type(merchantFilters) == "table" and merchantFilters.onlyKnownProfessions == true then
            merchantFilters.hideUnusableRecipes = true
            merchantFilters.onlyKnownProfessions = false
        end
    end
    if db.saveMode ~= "merchant" and db.saveMode ~= "global" then
        db.saveMode = "off"
    end
    if db.classFilter ~= CLASS_FILTER_PLAYER then
        db.classFilter = CLASS_FILTER_ALL
    end
    return db
end

local function normalizeLoadedFilters(filters)
    if type(filters) ~= "table" then
        return DEFAULT_FILTERS
    end
    if filters.onlyKnownProfessions == true then
        filters.hideUnusableRecipes = true
        filters.onlyKnownProfessions = false
    end
    return filters
end

local function getFilters()
    local db = getFeatureStore()
    if not db then
        return DEFAULT_FILTERS
    end
    return normalizeLoadedFilters(db.filters)
end

local function getSaveMode()
    local db = getFeatureStore()
    return db and db.saveMode or "off"
end

local function setSaveMode(mode)
    local db = getFeatureStore()
    if not db then
        return
    end
    if mode == "merchant" or mode == "global" then
        db.saveMode = mode
    else
        db.saveMode = "off"
    end
end

local function getDisplayMode()
    local mode = ns.GetFeatureOptionValue and ns:GetFeatureOptionValue(FEATURE_ID, "display_mode") or DISPLAY_MODE_REPLACE
    if mode == DISPLAY_MODE_BUTTON or mode == DISPLAY_MODE_FILTER_ONLY then
        return mode
    end
    return DISPLAY_MODE_REPLACE
end

local function getClassFilterMode()
    local db = getFeatureStore()
    return db and db.classFilter or CLASS_FILTER_ALL
end

local function setClassFilterMode(mode)
    local db = getFeatureStore()
    if not db then
        return
    end
    db.classFilter = mode == CLASS_FILTER_ALL and CLASS_FILTER_ALL or CLASS_FILTER_PLAYER
end

local function getPlayerClassName()
    if type(UnitClass) == "function" then
        local className = UnitClass("player")
        if type(className) == "string" and className ~= "" then
            return className
        end
    end
    return CLASS or "Klasse"
end

local function getClassFilterLabel()
    if getClassFilterMode() == CLASS_FILTER_ALL then
        return L.FEATURE_MERCHANT_FILTERS_CLASS_ALL or "Klasse: Alle"
    end
    return string.format(L.FEATURE_MERCHANT_FILTERS_CLASS_PLAYER_FORMAT or "Klasse: %s", getPlayerClassName())
end

local function nextClassFilterMode()
    return getClassFilterMode() == CLASS_FILTER_ALL and CLASS_FILTER_PLAYER or CLASS_FILTER_ALL
end

local function normalizeMerchantTitle(text)
    if type(text) ~= "string" then
        return nil
    end
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    if text ~= "" then
        return text
    end
    return nil
end

local function safeGetFontStringText(fontString)
    if not fontString or type(fontString.GetText) ~= "function" then
        return ""
    end
    local ok, text = pcall(function()
        local value = fontString:GetText()
        if type(value) ~= "string" then
            return ""
        end
        value = value:gsub("\r", "")
        if value ~= "" then
            return value
        end
        return ""
    end)
    if ok and type(text) == "string" then
        return text
    end
    return ""
end

local function getUnitMerchantTitle(unit)
    if type(UnitExists) ~= "function" or type(UnitName) ~= "function" then
        return nil
    end
    local existsOk, exists = pcall(UnitExists, unit)
    if not existsOk or not exists then
        return nil
    end
    local nameOk, name = pcall(function()
        return normalizeMerchantTitle(UnitName(unit))
    end)
    if nameOk then
        return name
    end
    return nil
end

local function getMerchantTitle()
    return getUnitMerchantTitle("npc")
        or getUnitMerchantTitle("target")
        or getUnitMerchantTitle("mouseover")
        or (MERCHANT or "Händler")
end

local function refreshMerchantPortrait(frame)
    if not frame or not frame.portrait then
        return
    end

    local texture = nil
    local portraitSet = false
    if type(SetPortraitTexture) == "function" and type(UnitExists) == "function" and UnitExists("target") then
        portraitSet = pcall(SetPortraitTexture, frame.portrait, "target") == true
    end

    for _, globalName in ipairs({
        "MerchantFramePortrait",
        "MerchantFramePortraitFramePortrait",
        "MerchantFramePortraitTexture",
    }) do
        local portrait = _G[globalName]
        if portrait and portrait.GetTexture then
            local candidate = portrait:GetTexture()
            if candidate and not tostring(candidate):find("TempPortraitAlphaMask", 1, true) then
                texture = candidate
                break
            end
        end
    end

    if portraitSet then
        frame.portrait:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    elseif texture then
        frame.portrait:SetTexture(texture)
        frame.portrait:SetTexCoord(0, 1, 0, 1)
    else
        frame.portrait:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
        frame.portrait:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    end
    frame.portrait:SetAlpha(1)
    frame.portrait:Show()

    if frame.portraitRing then
        frame.portraitRing:SetVertexColor(THEME.borderBright[1], THEME.borderBright[2], THEME.borderBright[3], 1)
    end
end

local function getNPCIDFromGUID(guid)
    local ok, npcID = pcall(function()
        if type(guid) ~= "string" or guid == "" then
            return nil
        end
        if type(strsplit) == "function" then
            local unitType, _, _, _, _, parsedNPCID = strsplit("-", guid)
            if (unitType == "Creature" or unitType == "Vehicle") and tonumber(parsedNPCID) then
                return tostring(parsedNPCID)
            end
        end
        return guid:match("^[^-]+%-[^-]+%-[^-]+%-[^-]+%-[^-]+%-(%d+)%-")
    end)
    if ok then
        return npcID
    end
    return nil
end

local function getMerchantKey()
    local title = getMerchantTitle()
    local normalizedTitle = lowerText(title):gsub("%s+", "_")

    if type(UnitGUID) == "function" then
        for _, unit in ipairs({ "npc", "target", "mouseover" }) do
            local okGUID, guid = pcall(UnitGUID, unit)
            if okGUID then
                local unitName = nil
                if type(UnitName) == "function" then
                    local okName, name = pcall(UnitName, unit)
                    unitName = okName and name or nil
                end
                if unit == "npc" or lowerText(unitName) == lowerText(title) then
                    local npcID = getNPCIDFromGUID(guid)
                    if npcID then
                        return "npc:" .. npcID
                    end
                end
            end
        end
    end

    return "name:" .. normalizedTitle
end

local function persistFilters()
    local db = getFeatureStore()
    if not db then
        return
    end

    if db.saveMode == "global" then
        copyValues(db.globalFilters, db.filters)
    elseif db.saveMode == "merchant" then
        db.merchantFilters[getMerchantKey()] = copyValues(db.merchantFilters[getMerchantKey()] or {}, db.filters)
    end
end

local function applySavedFiltersForCurrentMerchant()
    local db = getFeatureStore()
    if not db then
        return
    end

    if db.saveMode == "global" then
        copyValues(db.filters, db.globalFilters)
    elseif db.saveMode == "merchant" then
        copyValues(db.filters, db.merchantFilters[getMerchantKey()] or DEFAULT_FILTERS)
    else
        copyValues(db.filters, DEFAULT_FILTERS)
    end
end

local function setFilterValue(key, value)
    local filters = getFilters()
    if filters[key] ~= nil then
        filters[key] = value == true
        persistFilters()
    end
end

local function clearFilters()
    copyValues(getFilters(), DEFAULT_FILTERS)
    persistFilters()
end

local function hasActiveFilters(filters)
    for key in pairs(DEFAULT_FILTERS) do
        if filters[key] == true then
            return true
        end
    end
    return false
end

local function nextSaveMode()
    local current = getSaveMode()
    for index, mode in ipairs(SAVE_MODES) do
        if mode == current then
            return SAVE_MODES[(index % #SAVE_MODES) + 1]
        end
    end
    return "off"
end

local function getSaveModeLabel()
    local mode = getSaveMode()
    if mode == "merchant" then
        return L.FEATURE_MERCHANT_FILTERS_SAVE_MERCHANT or "Merken: Händler"
    elseif mode == "global" then
        return L.FEATURE_MERCHANT_FILTERS_SAVE_GLOBAL or "Merken: Global"
    end
    return L.FEATURE_MERCHANT_FILTERS_SAVE_OFF or "Merken: Aus"
end

local function formatMoney(copper)
    copper = math.max(0, math.floor(tonumber(copper) or 0))
    if C_CurrencyInfo and type(C_CurrencyInfo.GetCoinTextureString) == "function" then
        local ok, text = pcall(C_CurrencyInfo.GetCoinTextureString, copper, 12)
        if ok and type(text) == "string" and text ~= "" then
            return text
        end
    end
    if type(GetMoneyString) == "function" then
        local ok, text = pcall(GetMoneyString, copper)
        if ok and type(text) == "string" and text ~= "" then
            return text
        end
    end
    return tostring(copper) .. "c"
end

local function getMoneyText()
    if type(GetMoney) == "function" then
        local ok, money = pcall(GetMoney)
        if ok then
            return formatMoney(money)
        end
    end
    return ""
end

local function getMerchantItemCount()
    if C_MerchantFrame and type(C_MerchantFrame.GetNumItems) == "function" then
        local ok, count = pcall(C_MerchantFrame.GetNumItems)
        if ok and tonumber(count) then
            return tonumber(count)
        end
    end
    if type(GetMerchantNumItems) == "function" then
        local ok, count = pcall(GetMerchantNumItems)
        if ok and tonumber(count) then
            return tonumber(count)
        end
    end
    return 0
end

local function getMerchantItemLink(index)
    if type(GetMerchantItemLink) == "function" then
        local ok, link = pcall(GetMerchantItemLink, index)
        if ok and type(link) == "string" and link ~= "" then
            return link
        end
    end
    return nil
end

local function isMerchantModifiedItemClick()
    if type(IsModifiedClick) == "function" then
        if IsModifiedClick("CHATLINK") or IsModifiedClick("DRESSUP") or IsModifiedClick("COMPAREITEMS") then
            return true
        end
    end
    return false
end

local function handleMerchantModifiedItemClick(link)
    if type(link) ~= "string" or link == "" or not isMerchantModifiedItemClick() then
        return false
    end

    if type(HandleModifiedItemClick) == "function" then
        local ok, handled = pcall(HandleModifiedItemClick, link)
        if ok and handled then
            return true
        end
    end

    if type(IsModifiedClick) == "function" and IsModifiedClick("DRESSUP") then
        if type(DressUpItemLink) == "function" then
            local ok = pcall(DressUpItemLink, link)
            if ok then
                return true
            end
        end
        return true
    end

    if type(IsModifiedClick) == "function" and IsModifiedClick("CHATLINK") then
        if type(ChatEdit_InsertLink) == "function" then
            local ok, inserted = pcall(ChatEdit_InsertLink, link)
            if ok and inserted then
                return true
            end
        end
        return true
    end

    return true
end

local function getMerchantItemID(index, link)
    if type(GetMerchantItemID) == "function" then
        local ok, itemID = pcall(GetMerchantItemID, index)
        if ok and tonumber(itemID) then
            return tonumber(itemID)
        end
    end
    return type(link) == "string" and tonumber(link:match("item:(%d+)")) or nil
end

local function getMerchantInfo(index)
    if C_MerchantFrame and type(C_MerchantFrame.GetItemInfo) == "function" then
        local ok, info = pcall(C_MerchantFrame.GetItemInfo, index)
        if ok and type(info) == "table" and info.name then
            return {
                name = info.name,
                texture = info.texture,
                price = tonumber(info.price) or 0,
                quantity = tonumber(info.stackCount or info.quantity) or 1,
                numAvailable = tonumber(info.numAvailable),
                isPurchasable = info.isPurchasable ~= false,
                isUsable = info.isUsable ~= false,
            }
        end
    end
    if type(GetMerchantItemInfo) == "function" then
        local ok, name, texture, price, quantity, numAvailable, isPurchasable, isUsable = pcall(GetMerchantItemInfo, index)
        if ok and name then
            return {
                name = name,
                texture = texture,
                price = tonumber(price) or 0,
                quantity = tonumber(quantity) or 1,
                numAvailable = tonumber(numAvailable),
                isPurchasable = isPurchasable ~= false,
                isUsable = isUsable ~= false,
            }
        end
    end
    return nil
end

local function merchantItemIsSoldOut(info)
    return tonumber(info and info.numAvailable) == 0
end

local function getItemStaticInfo(link)
    if not link then
        return {}
    end
    local instantEquipLoc, instantTexture, instantClassID, instantSubclassID
    local instantAPI = C_Item and C_Item.GetItemInfoInstant or GetItemInfoInstant
    if type(instantAPI) == "function" then
        local okInstant, _, _, _, equipLoc, texture, classID, subclassID = pcall(instantAPI, link)
        if okInstant then
            instantEquipLoc = equipLoc
            instantTexture = texture
            instantClassID = classID
            instantSubclassID = subclassID
        end
    end
    local api = C_Item and C_Item.GetItemInfo or GetItemInfo
    if type(api) == "function" then
        local ok, name, itemLink, quality, itemLevel, minLevel, itemType, itemSubType, stackCount, equipLoc, texture, sellPrice, classID, subclassID = pcall(api, link)
        if ok then
            return {
                name = name,
                link = itemLink,
                quality = quality,
                itemType = itemType,
                itemSubType = itemSubType,
                equipLoc = equipLoc or instantEquipLoc,
                texture = texture or instantTexture,
                sellPrice = sellPrice,
                classID = classID or instantClassID,
                subclassID = subclassID or instantSubclassID,
            }
        end
    end
    return {
        equipLoc = instantEquipLoc,
        texture = instantTexture,
        classID = instantClassID,
        subclassID = instantSubclassID,
    }
end

local function getGearFilterKey(equipLoc)
    if type(equipLoc) ~= "string" or equipLoc == "" then
        return nil
    end
    return GEAR_FILTER_BY_EQUIP_LOC[equipLoc]
end

local function merchantFrameOpen()
    if not MerchantFrame or type(MerchantFrame.IsShown) ~= "function" then
        return false
    end
    if type(MerchantFrame.IsForbidden) == "function" and MerchantFrame:IsForbidden() then
        return false
    end
    return MerchantFrame:IsShown() == true
end

local function merchantCanSellItems()
    if not merchantFrameOpen() then
        return false
    end
    if type(CanMerchantRepair) == "function" then
        local ok, canRepair = pcall(CanMerchantRepair)
        if ok and canRepair == true then
            return true
        end
    end
    return getMerchantItemCount() > 0
end

local function getContainerItemInfo(bag, slot)
    if C_Container and type(C_Container.GetContainerItemInfo) == "function" then
        local ok, info = pcall(C_Container.GetContainerItemInfo, bag, slot)
        if ok and type(info) == "table" then
            return info
        end
    end
    return nil
end

local function getContainerItemID(bag, slot, info)
    if type(info) == "table" and tonumber(info.itemID) then
        return tonumber(info.itemID)
    end
    if C_Container and type(C_Container.GetContainerItemID) == "function" then
        local ok, itemID = pcall(C_Container.GetContainerItemID, bag, slot)
        if ok and tonumber(itemID) then
            return tonumber(itemID)
        end
    end
    return nil
end

local function isSellableJunkItem(bag, slot)
    local info = getContainerItemInfo(bag, slot)
    if not info or info.isLocked == true or info.hasNoValue == true then
        return nil
    end

    local itemID = getContainerItemID(bag, slot, info)
    local itemRef = info.hyperlink or itemID
    if itemRef == nil then
        return nil
    end

    local staticInfo = getItemStaticInfo(itemRef)
    local quality = tonumber(info.quality)
    if quality == nil and staticInfo then
        quality = tonumber(staticInfo.quality)
    end
    if quality ~= POOR_QUALITY then
        return nil
    end

    if staticInfo and QUEST_ITEM_CLASS_ID and tonumber(staticInfo.classID) == QUEST_ITEM_CLASS_ID then
        return nil
    end

    local sellPrice = staticInfo and tonumber(staticInfo.sellPrice) or 0
    if sellPrice <= 0 then
        return nil
    end

    local stackCount = math.max(1, tonumber(info.stackCount) or 1)
    return {
        bag = bag,
        slot = slot,
        stackCount = stackCount,
        value = sellPrice * stackCount,
    }
end

local function buildJunkSellQueue()
    local queue = {}
    if not C_Container or type(C_Container.GetContainerNumSlots) ~= "function" then
        return queue
    end

    local lastBag = tonumber(NUM_BAG_SLOTS) or 4
    for bag = 0, lastBag do
        local okSlots, slots = pcall(C_Container.GetContainerNumSlots, bag)
        slots = okSlots and tonumber(slots) or 0
        for slot = 1, slots do
            local entry = isSellableJunkItem(bag, slot)
            if entry then
                queue[#queue + 1] = entry
            end
        end
    end
    return queue
end

local function countSellableJunk()
    local queue = buildJunkSellQueue()
    local count = 0
    local value = 0
    for _, entry in ipairs(queue) do
        count = count + (tonumber(entry.stackCount) or 1)
        value = value + (tonumber(entry.value) or 0)
    end
    return count, value
end

local function getTooltipLines(index, link)
    local lines = {}
    if C_TooltipInfo and type(C_TooltipInfo.GetMerchantItem) == "function" then
        local ok, data = pcall(C_TooltipInfo.GetMerchantItem, index)
        if ok and type(data) == "table" and type(data.lines) == "table" then
            for _, line in ipairs(data.lines) do
                if type(line) == "table" then
                    local left = type(line.leftText) == "string" and line.leftText or ""
                    local right = type(line.rightText) == "string" and line.rightText or ""
                    if left ~= "" and right ~= "" then
                        lines[#lines + 1] = left .. " " .. right
                    elseif left ~= "" then
                        lines[#lines + 1] = left
                    elseif right ~= "" then
                        lines[#lines + 1] = right
                    end
                end
            end
            if #lines > 0 then
                return lines
            end
        end
    end

    if type(CreateFrame) == "function" then
        Runtime.scanner = Runtime.scanner or CreateFrame("GameTooltip", "VaultloomMerchantWindowScanner", nil, "GameTooltipTemplate")
        local scanner = Runtime.scanner
        scanner:SetOwner(WorldFrame or UIParent, "ANCHOR_NONE")
        scanner:ClearLines()
        local set = false
        if type(scanner.SetMerchantItem) == "function" then
            set = pcall(scanner.SetMerchantItem, scanner, index) == true
        end
        if not set and link and type(scanner.SetHyperlink) == "function" then
            pcall(scanner.SetHyperlink, scanner, link)
        end
        for lineIndex = 1, scanner:NumLines() do
            local textLine = _G["VaultloomMerchantWindowScannerTextLeft" .. lineIndex]
            local rightLine = _G["VaultloomMerchantWindowScannerTextRight" .. lineIndex]
            local leftText = safeGetFontStringText(textLine)
            local rightText = safeGetFontStringText(rightLine)
            if leftText ~= "" and rightText ~= "" then
                lines[#lines + 1] = leftText .. " " .. rightText
            elseif leftText ~= "" then
                lines[#lines + 1] = leftText
            elseif rightText ~= "" then
                lines[#lines + 1] = rightText
            end
        end
    end
    return lines
end

local function getSpellName(spellID)
    if C_Spell and type(C_Spell.GetSpellName) == "function" then
        local ok, name = pcall(C_Spell.GetSpellName, spellID)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end
    if type(GetSpellInfo) == "function" then
        local ok, name = pcall(GetSpellInfo, spellID)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end
    return nil
end

local function getProfessionLabels()
    local labels = {}
    local function addLabel(key, label)
        if type(key) == "string" and key ~= "" and type(label) == "string" and label ~= "" then
            labels[lowerText(key)] = label
        end
    end
    for _, spellID in ipairs(PROFESSION_SPELL_IDS) do
        local name = getSpellName(spellID)
        if name then
            addLabel(name, name)
        end
    end
    if type(GetProfessions) == "function" and type(GetProfessionInfo) == "function" then
        local slots = { GetProfessions() }
        for _, slotIndex in ipairs(slots) do
            if slotIndex then
                local ok, name = pcall(GetProfessionInfo, slotIndex)
                if ok and type(name) == "string" and name ~= "" then
                    addLabel(name, name)
                end
            end
        end
    end
    addLabel("alchemy", "Alchemy")
    addLabel("alchemie", "Alchemie")
    addLabel("blacksmithing", "Blacksmithing")
    addLabel("schmiedekunst", "Schmiedekunst")
    addLabel("enchanting", "Enchanting")
    addLabel("verzauberkunst", "Verzauberkunst")
    addLabel("engineering", "Engineering")
    addLabel("ingenieurskunst", "Ingenieurskunst")
    addLabel("herbalism", "Herbalism")
    addLabel("kräuterkunde", "Kräuterkunde")
    addLabel("kraeuterkunde", "Kräuterkunde")
    addLabel("inscription", "Inscription")
    addLabel("inschriftenkunde", "Inschriftenkunde")
    addLabel("jewelcrafting", "Jewelcrafting")
    addLabel("juwelierskunst", "Juwelierskunst")
    addLabel("leatherworking", "Leatherworking")
    addLabel("lederverarbeitung", "Lederverarbeitung")
    addLabel("mining", "Mining")
    addLabel("bergbau", "Bergbau")
    addLabel("skinning", "Skinning")
    addLabel("kürschnerei", "Kürschnerei")
    addLabel("kuerschnerei", "Kürschnerei")
    addLabel("tailoring", "Tailoring")
    addLabel("schneiderei", "Schneiderei")
    addLabel("schneider", "Schneiderei")
    addLabel("cooking", "Cooking")
    addLabel("kochkunst", "Kochkunst")
    addLabel("kochen", "Kochkunst")
    addLabel("fishing", "Fishing")
    addLabel("angeln", "Angeln")
    addLabel("archaeology", "Archaeology")
    addLabel("archäologie", "Archäologie")
    addLabel("archaeologie", "Archäologie")
    return labels
end

local function getPlayerProfessionMap()
    local result = {}
    local function addAliasGroup(name)
        local loweredName = lowerText(name)
        for _, group in ipairs(PROFESSION_ALIAS_GROUPS) do
            local groupMatches = false
            for _, alias in ipairs(group) do
                local loweredAlias = lowerText(alias)
                if loweredAlias ~= "" and (loweredName == loweredAlias or loweredName:find(loweredAlias, 1, true)) then
                    groupMatches = true
                    break
                end
            end
            if groupMatches then
                for _, alias in ipairs(group) do
                    result[lowerText(alias)] = true
                end
            end
        end
    end
    local function addProfessionName(name)
        if type(name) ~= "string" or name == "" then
            return
        end
        local lowered = lowerText(name)
        result[lowered] = true
        addAliasGroup(name)
        if lowered == lowerText("Kürschnerei") then result["kuerschnerei"] = true end
        if lowered == lowerText("Kräuterkunde") then result["kraeuterkunde"] = true end
        if lowered == lowerText("Schneiderei") then result["schneider"] = true end
        if lowered == lowerText("Kochkunst") then result["kochen"] = true end
        if lowered == lowerText("Archäologie") then result["archaeologie"] = true end
    end
    if type(GetProfessions) == "function" and type(GetProfessionInfo) == "function" then
        local slots = { GetProfessions() }
        for _, slotIndex in ipairs(slots) do
            if slotIndex then
                local ok, name = pcall(GetProfessionInfo, slotIndex)
                if ok and type(name) == "string" and name ~= "" then
                    addProfessionName(name)
                end
            end
        end
    end
    return result
end

local function professionMentionMatchesPlayer(text, professionLabels, playerProfessions)
    local lowered = lowerText(stripTextureCodes(text))
    local mentioned = false
    for key in pairs(professionLabels or {}) do
        if key ~= "" and lowered:find(key, 1, true) then
            mentioned = true
            if playerProfessions and playerProfessions[key] then
                return true, true
            end
        end
    end
    return false, mentioned
end

local function findProfessionLabelInText(text, professionLabels)
    local lowered = lowerText(stripTextureCodes(text))
    for key, label in pairs(professionLabels or {}) do
        if key ~= "" and lowered:find(key, 1, true) then
            return label
        end
    end
    return nil
end

local function lineLooksLikeRecipeProfession(lowered)
    return lowered:find("recipe", 1, true) or lowered:find("rezept", 1, true)
        or lowered:find("pattern", 1, true) or lowered:find("muster", 1, true)
        or lowered:find("schematic", 1, true) or lowered:find("bauplan", 1, true)
        or lowered:find("formula", 1, true) or lowered:find("formel", 1, true)
        or lowered:find("design", 1, true) or lowered:find("technik:", 1, true)
        or lowered:find("plans", 1, true)
end

local function lineLooksLikeRequirement(lowered)
    return lowered:find("requires", 1, true) or lowered:find("requirement", 1, true)
        or lowered:find("erfordert", 1, true) or lowered:find("ben", 1, true)
end

local function playerHasProfession(profession, playerProfessions)
    if type(profession) ~= "string" or profession == "" then
        return false
    end
    local key = lowerText(profession)
    if playerProfessions and playerProfessions[key] == true then
        return true
    end
    local matchesPlayer = professionMentionMatchesPlayer(profession, getProfessionLabels(), playerProfessions)
    return matchesPlayer == true
end

local function staticInfoLooksLikeRecipe(staticInfo)
    local recipeClass = Enum and Enum.ItemClass and Enum.ItemClass.Recipe or 9
    local itemType = lowerText(staticInfo and staticInfo.itemType)
    return tonumber(staticInfo and staticInfo.classID) == recipeClass
        or itemType == "recipe"
        or itemType == "rezept"
end

local function findRequiredProfession(lines, staticInfo)
    local labels = getProfessionLabels()
    local recipeClass = Enum and Enum.ItemClass and Enum.ItemClass.Recipe or 9
    local isRecipeStatic = tonumber(staticInfo and staticInfo.classID) == recipeClass

    for lineIndex = #(lines or {}), 1, -1 do
        local text = lines[lineIndex]
        local lowered = lowerText(stripTextureCodes(text))
        if lowered:find("requires", 1, true) or lowered:find("requirement", 1, true)
            or lowered:find("erfordert", 1, true) or lowered:find("benötigt", 1, true)
            or lowered:find("benötigt", 1, true) or lowered:find("benoetigt", 1, true)
            or lowered:find("benotigt", 1, true) then
            for key, label in pairs(labels) do
                if key ~= "" and lowered:find(key, 1, true) then
                    return label
                end
            end
        end
    end

    local subType = lowerText(staticInfo and staticInfo.itemSubType)
    if labels[subType] then
        return labels[subType]
    end

    if isRecipeStatic then
        return nil
    end

    for _, text in ipairs(lines or {}) do
        local lowered = lowerText(stripTextureCodes(text))
        for key, label in pairs(labels) do
            if key ~= "" and string.find(lowered, key, 1, true) then
                return label
            end
        end
    end
    return nil
end

findRequiredProfession = function(lines, staticInfo)
    local labels = getProfessionLabels()
    local isRecipeStatic = staticInfoLooksLikeRecipe(staticInfo)

    for lineIndex = #(lines or {}), 1, -1 do
        local text = lines[lineIndex]
        local lowered = lowerText(stripTextureCodes(text))
        if lineLooksLikeRecipeProfession(lowered) then
            local profession = findProfessionLabelInText(text, labels)
            if profession then
                return profession
            end
        end
    end

    for lineIndex = #(lines or {}), 1, -1 do
        local text = lines[lineIndex]
        local lowered = lowerText(stripTextureCodes(text))
        if lineLooksLikeRequirement(lowered) then
            local profession = findProfessionLabelInText(text, labels)
            if profession then
                return profession
            end
        end
    end

    if isRecipeStatic then
        return nil
    end

    local subType = lowerText(staticInfo and staticInfo.itemSubType)
    if subType ~= "" and labels[subType] then
        return labels[subType]
    end

    for _, text in ipairs(lines or {}) do
        local lowered = lowerText(stripTextureCodes(text))
        if lineLooksLikeRecipeProfession(lowered) or lineLooksLikeRequirement(lowered) then
            local profession = findProfessionLabelInText(text, labels)
            if profession then
                return profession
            end
        end
    end
    return nil
end

local function isRecipeItem(staticInfo, lines)
    if staticInfoLooksLikeRecipe(staticInfo) then
        return true
    end
    for _, text in ipairs(lines or {}) do
        local lowered = lowerText(text)
        if lowered:find("recipe", 1, true) or lowered:find("pattern", 1, true)
            or lowered:find("design", 1, true) or lowered:find("formula", 1, true)
            or lowered:find("schematic", 1, true) or lowered:find("rezept", 1, true)
            or lowered:find("muster", 1, true) or lowered:find("bauplan", 1, true)
            or lowered:find("technik:", 1, true) or lowered:find("plans", 1, true)
            or lowered:find("plan:", 1, true) then
            return true
        end
    end
    return false
end

local function isDecorItem(link, staticInfo)
    if link and C_Item and type(C_Item.IsDecorItem) == "function" then
        local ok, isDecor = pcall(C_Item.IsDecorItem, link)
        if ok and isDecor == true then
            return true
        end
    end
    local itemType = lowerText(staticInfo and staticInfo.itemType)
    local subType = lowerText(staticInfo and staticInfo.itemSubType)
    return itemType:find("housing", 1, true) or itemType:find("dekoration", 1, true)
        or subType:find("decor", 1, true) or subType:find("dekor", 1, true)
end

local function isToyItem(itemID)
    if itemID and C_ToyBox and type(C_ToyBox.GetToyInfo) == "function" then
        local ok, toyName = pcall(C_ToyBox.GetToyInfo, itemID)
        return ok and toyName ~= nil
    end
    return false
end

local function isPetItem(itemID)
    if itemID and C_PetJournal and type(C_PetJournal.GetPetInfoByItemID) == "function" then
        local ok, speciesID = pcall(C_PetJournal.GetPetInfoByItemID, itemID)
        return ok and speciesID ~= nil
    end
    return false
end

local function isMountItem(itemID, staticInfo)
    if itemID and C_MountJournal and type(C_MountJournal.GetMountFromItem) == "function" then
        local ok, mountID = pcall(C_MountJournal.GetMountFromItem, itemID)
        if ok and mountID then
            return true
        end
    end
    local subType = lowerText(staticInfo and staticInfo.itemSubType)
    return subType == "mount" or subType == "reittier"
end

local function isCosmeticItem(link, staticInfo, lines)
    if link and C_Item and type(C_Item.IsCosmeticItem) == "function" then
        local ok, result = pcall(C_Item.IsCosmeticItem, link)
        if ok and result == true then
            return true
        end
    end
    if staticInfo and staticInfo.equipLoc == "INVTYPE_COSMETIC" then
        return true
    end
    for _, text in ipairs(lines or {}) do
        local lowered = lowerText(text)
        if lowered:find("cosmetic", 1, true) or lowered:find("ensemble", 1, true)
            or lowered:find("appearance", 1, true) or lowered:find("kosmetisch", 1, true) then
            return true
        end
    end
    return false
end

local function tooltipSaysKnown(lines)
    for _, text in ipairs(lines or {}) do
        local lowered = lowerText(text)
        if lowered:find("already known", 1, true) or lowered:find("already collected", 1, true)
            or lowered:find("appearance known", 1, true) or lowered:find("bereits bekannt", 1, true)
            or lowered:find("bereits gesammelt", 1, true) or lowered:find("vorlage bekannt", 1, true) then
            return true
        end
        if type(ITEM_SPELL_KNOWN) == "string" and text == ITEM_SPELL_KNOWN then
            return true
        end
    end
    return false
end

local function canAffordMerchantItem(index, info)
    local apiAffordable = nil
    if type(CanAffordMerchantItem) == "function" then
        local ok, canAfford = pcall(CanAffordMerchantItem, index)
        if ok then
            apiAffordable = canAfford == true
        end
    end

    local price = tonumber(info and info.price) or 0
    local moneyAffordable = true
    if price > 0 and type(GetMoney) == "function" then
        local ok, money = pcall(GetMoney)
        if ok and tonumber(money) and tonumber(money) < price then
            moneyAffordable = false
        end
    end

    if moneyAffordable == false then
        return false
    end

    local costCount = 0
    if type(GetMerchantItemCostInfo) == "function" then
        local okCount, count = pcall(GetMerchantItemCostInfo, index)
        costCount = okCount and tonumber(count) or 0
    end

    if price > 0 and costCount == 0 then
        return true
    end

    if apiAffordable ~= nil then
        return apiAffordable
    end

    return true
end

local function formatCost(index, info)
    local parts = {}
    if info and tonumber(info.price) and tonumber(info.price) > 0 then
        parts[#parts + 1] = formatMoney(info.price)
    end
    if type(GetMerchantItemCostInfo) == "function" and type(GetMerchantItemCostItem) == "function" then
        local okCount, count = pcall(GetMerchantItemCostInfo, index)
        count = okCount and tonumber(count) or 0
        for costIndex = 1, count do
            local okCost, texture, amount, link, currencyName = pcall(GetMerchantItemCostItem, index, costIndex)
            if okCost and amount and texture then
                parts[#parts + 1] = string.format("|T%s:13:13:0:0|t %s", tostring(texture), tostring(amount))
            elseif okCost and amount and currencyName then
                parts[#parts + 1] = tostring(amount) .. " " .. tostring(currencyName)
            end
        end
    end
    return #parts > 0 and table.concat(parts, "  ") or (L.FEATURE_MERCHANT_FILTERS_FREE or "Gratis")
end

local function getCurrencyIDFromLink(link)
    if type(link) ~= "string" then
        return nil
    end
    return tonumber(link:match("currency:(%d+)"))
end

local function getItemIDFromLink(link)
    if type(link) ~= "string" then
        return nil
    end
    return tonumber(link:match("item:(%d+)"))
end

local function getCurrencyInfoByID(currencyID)
    currencyID = tonumber(currencyID)
    if not currencyID or currencyID <= 0 or not C_CurrencyInfo or type(C_CurrencyInfo.GetCurrencyInfo) ~= "function" then
        return nil
    end
    local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfo, currencyID)
    return ok and type(info) == "table" and info or nil
end

local function getCurrencyNameFromLink(link)
    if type(link) ~= "string" then
        return nil
    end
    if C_CurrencyInfo and type(C_CurrencyInfo.GetCurrencyInfoFromLink) == "function" then
        local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfoFromLink, link)
        if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then
            return info.name
        end
    end
    if type(GetCurrencyInfoFromLink) == "function" then
        local ok, name = pcall(GetCurrencyInfoFromLink, link)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end
    return nil
end

local function getOwnedCurrencyFromLink(link)
    if type(link) ~= "string" then
        return nil
    end
    local bestQuantity = nil
    if C_CurrencyInfo and type(C_CurrencyInfo.GetCurrencyInfoFromLink) == "function" then
        local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfoFromLink, link)
        if ok and type(info) == "table" and tonumber(info.quantity) then
            bestQuantity = math.max(tonumber(info.quantity) or 0, bestQuantity or 0)
        end
    end
    if type(GetCurrencyInfoFromLink) == "function" then
        local ok, _, amount = pcall(GetCurrencyInfoFromLink, link)
        if ok and tonumber(amount) then
            bestQuantity = math.max(tonumber(amount) or 0, bestQuantity or 0)
        end
    end
    local currencyID = getCurrencyIDFromLink(link)
    if currencyID and C_CurrencyInfo and type(C_CurrencyInfo.GetCurrencyInfo) == "function" then
        local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfo, currencyID)
        if ok and type(info) == "table" and tonumber(info.quantity) then
            bestQuantity = math.max(tonumber(info.quantity) or 0, bestQuantity or 0)
        end
    end
    return bestQuantity
end

local function getCurrencyListEntry(index)
    if not C_CurrencyInfo or type(C_CurrencyInfo.GetCurrencyListInfo) ~= "function" then
        return nil
    end
    local ok, infoOrName, isHeader, _, _, _, quantity, iconFileID, _, _, _, _, _, currencyID = pcall(C_CurrencyInfo.GetCurrencyListInfo, index)
    if not ok then
        return nil
    end
    if type(infoOrName) == "table" then
        return {
            name = infoOrName.name,
            quantity = tonumber(infoOrName.quantity),
            iconFileID = infoOrName.iconFileID,
            currencyID = infoOrName.currencyTypesID or infoOrName.currencyID,
            isHeader = infoOrName.isHeader == true,
        }
    end
    return {
        name = infoOrName,
        quantity = tonumber(quantity),
        iconFileID = iconFileID,
        currencyID = currencyID,
        isHeader = isHeader == true,
    }
end

local function findOwnedCurrencyByNameOrTexture(name, texture)
    if not C_CurrencyInfo or type(C_CurrencyInfo.GetCurrencyListSize) ~= "function" then
        return nil
    end
    local wantedName = lowerText(stripTextureCodes(name))
    local wantedTexture = tostring(texture or "")
    local okSize, size = pcall(C_CurrencyInfo.GetCurrencyListSize)
    size = okSize and tonumber(size) or 0
    for index = 1, size do
        local info = getCurrencyListEntry(index)
        if info and not info.isHeader then
            local nameMatches = wantedName ~= "" and lowerText(info.name) == wantedName
            local textureMatches = wantedTexture ~= "" and tostring(info.iconFileID or "") == wantedTexture
            if nameMatches or textureMatches then
                return tonumber(info.quantity) or 0
            end
        end
    end
    return nil
end

local function getOwnedCostAmount(link, name, texture)
    if type(link) ~= "string" then
        return findOwnedCurrencyByNameOrTexture(name, texture)
    end
    local currencyOwned = getOwnedCurrencyFromLink(link)
    if link:find("item:", 1, true) and type(GetItemCount) == "function" then
        local okOwned, countOwned = pcall(GetItemCount, link, true)
        if okOwned and tonumber(countOwned) then
            return tonumber(countOwned)
        end
    end
    local listOwned = findOwnedCurrencyByNameOrTexture(name, texture)
    if currencyOwned ~= nil or listOwned ~= nil then
        return math.max(tonumber(currencyOwned) or 0, tonumber(listOwned) or 0)
    end
    return nil
end

local function addMerchantCurrencies(index)
    if type(GetMerchantItemCostInfo) ~= "function" or type(GetMerchantItemCostItem) ~= "function" then
        return
    end
    local okCount, count = pcall(GetMerchantItemCostInfo, index)
    count = okCount and tonumber(count) or 0
    for costIndex = 1, count do
        local okCost, texture, amount, link, name = pcall(GetMerchantItemCostItem, index, costIndex)
        local displayName = name or getCurrencyNameFromLink(link)
        if okCost and texture then
            local currencyID = getCurrencyIDFromLink(link)
            local itemID = getItemIDFromLink(link)
            local key, owned, finalTexture, finalName

            if currencyID then
                local currencyInfo = getCurrencyInfoByID(currencyID)
                key = "currency:" .. tostring(currencyID)
                finalTexture = currencyInfo and (currencyInfo.iconFileID or currencyInfo.icon) or texture
                finalName = currencyInfo and currencyInfo.name or displayName
                local apiOwned = currencyInfo and tonumber(currencyInfo.quantity) or nil
                local fallbackOwned = getOwnedCostAmount(link, finalName, finalTexture)
                if apiOwned ~= nil or fallbackOwned ~= nil then
                    owned = math.max(tonumber(apiOwned) or 0, tonumber(fallbackOwned) or 0)
                end
            elseif itemID then
                key = "item:" .. tostring(itemID)
                finalTexture = texture
                finalName = displayName
                if type(GetItemCount) == "function" then
                    local okOwned, countOwned = pcall(GetItemCount, itemID, true)
                    owned = okOwned and tonumber(countOwned) or nil
                end
            elseif displayName or texture then
                key = "cost:" .. lowerText(displayName or "") .. ":" .. tostring(texture or "")
                finalTexture = texture
                finalName = displayName
                owned = getOwnedCostAmount(link, finalName, texture)
            end

            if key and finalTexture then
                if not Runtime.merchantCurrencies[key] then
                    Runtime.merchantCurrencyOrder[#Runtime.merchantCurrencyOrder + 1] = key
                    Runtime.merchantCurrencies[key] = {
                        texture = finalTexture,
                        owned = owned,
                        name = finalName,
                        currencyID = currencyID,
                        itemID = itemID,
                        link = link,
                    }
                else
                    Runtime.merchantCurrencies[key].texture = Runtime.merchantCurrencies[key].texture or finalTexture
                    Runtime.merchantCurrencies[key].name = Runtime.merchantCurrencies[key].name or finalName
                    Runtime.merchantCurrencies[key].currencyID = Runtime.merchantCurrencies[key].currencyID or currencyID
                    Runtime.merchantCurrencies[key].itemID = Runtime.merchantCurrencies[key].itemID or itemID
                    Runtime.merchantCurrencies[key].link = Runtime.merchantCurrencies[key].link or link
                end
                if owned ~= nil then
                    Runtime.merchantCurrencies[key].owned = math.max(tonumber(Runtime.merchantCurrencies[key].owned) or 0, tonumber(owned) or 0)
                end
            end
        end
    end
end

local function buildMerchantCurrencyText()
    local parts = {}
    for orderIndex, key in ipairs(Runtime.merchantCurrencyOrder or {}) do
        local entry = Runtime.merchantCurrencies[key]
        if orderIndex > 6 then
            break
        end
        if entry and entry.owned ~= nil then
            parts[#parts + 1] = string.format("|T%s:14:14:0:0|t %s", tostring(entry.texture), tostring(entry.owned))
        elseif entry then
            parts[#parts + 1] = string.format("|T%s:14:14:0:0|t", tostring(entry.texture))
        end
    end
    return table.concat(parts, "   ")
end

local function collectCostDetails(index)
    local details = {}
    if type(GetMerchantItemCostInfo) ~= "function" or type(GetMerchantItemCostItem) ~= "function" then
        return details
    end

    local okCount, count = pcall(GetMerchantItemCostInfo, index)
    count = okCount and tonumber(count) or 0
    for costIndex = 1, count do
        local okCost, texture, amount, link, name = pcall(GetMerchantItemCostItem, index, costIndex)
        if okCost and texture then
            local currencyID = getCurrencyIDFromLink(link)
            local itemID = getItemIDFromLink(link)
            local displayName = name or getCurrencyNameFromLink(link)
            local displayTexture = texture
            if currencyID then
                local currencyInfo = getCurrencyInfoByID(currencyID)
                if currencyInfo then
                    displayName = currencyInfo.name or displayName
                    displayTexture = currencyInfo.iconFileID or currencyInfo.icon or displayTexture
                end
            elseif itemID and C_Item then
                if type(C_Item.GetItemNameByID) == "function" then
                    local okName, itemName = pcall(C_Item.GetItemNameByID, itemID)
                    if okName and type(itemName) == "string" and itemName ~= "" then
                        displayName = itemName
                    end
                end
                if type(C_Item.GetItemIconByID) == "function" then
                    local okIcon, itemIcon = pcall(C_Item.GetItemIconByID, itemID)
                    if okIcon and itemIcon then
                        displayTexture = itemIcon
                    end
                end
            end

            details[#details + 1] = {
                texture = displayTexture,
                amount = tonumber(amount) or 0,
                link = link,
                name = displayName,
                currencyID = currencyID,
                itemID = itemID,
                owned = getOwnedCostAmount(link, displayName, displayTexture),
            }
        end
    end
    return details
end

local function classifyMerchantEntry(index, info, link)
    local staticInfo = getItemStaticInfo(link)
    local itemID = getMerchantItemID(index, link)
    local tooltipLines = getTooltipLines(index, link)
    local profession = findRequiredProfession(tooltipLines, staticInfo)
    local playerProfessions = getPlayerProfessionMap()
    local isSoldOut = merchantItemIsSoldOut(info)
    local isAffordable = canAffordMerchantItem(index, info)

    local entry = {
        index = index,
        name = info.name or staticInfo.name or UNKNOWN or "Unknown",
        icon = info.texture or staticInfo.texture,
        link = link,
        itemID = itemID,
        info = info,
        staticInfo = staticInfo,
        requiredProfession = profession,
        hasMatchingProfession = playerHasProfession(profession, playerProfessions),
        isAffordable = isAffordable,
        isPurchasable = info.isPurchasable ~= false,
        isUsable = info.isUsable ~= false,
        isSoldOut = isSoldOut,
        canBuy = isAffordable and not isSoldOut,
        costText = formatCost(index, info),
        costDetails = collectCostDetails(index),
        tooltipLines = tooltipLines,
    }
    entry.isRecipe = isRecipeItem(staticInfo, tooltipLines) or entry.requiredProfession ~= nil
    entry.isDecor = isDecorItem(link, staticInfo)
    entry.isToy = isToyItem(itemID)
    entry.isPet = isPetItem(itemID)
    entry.isMount = isMountItem(itemID, staticInfo)
    entry.isCosmetic = isCosmeticItem(link, staticInfo, tooltipLines)
    entry.isKnown = tooltipSaysKnown(tooltipLines)
    entry.isOtherProfessionRecipe = entry.isRecipe and entry.requiredProfession ~= nil and not entry.hasMatchingProfession
    entry.gearFilterKey = getGearFilterKey(staticInfo.equipLoc)
    entry.isGear = entry.gearFilterKey ~= nil
    entry.isClassFilteredOut = (entry.isGear or entry.isCosmetic) and entry.isUsable == false and not entry.isRecipe
    return entry
end

local function entryPassesFilters(entry, filters)
    if getClassFilterMode() == CLASS_FILTER_PLAYER and entry.isClassFilteredOut then return false end
    if filters.onlyRecipes and not entry.isRecipe then return false end
    if filters.hideKnown and entry.isKnown then return false end
    if (filters.hideUnusableRecipes or filters.onlyKnownProfessions) and entry.isOtherProfessionRecipe then return false end
    if filters.hideUnaffordable and not entry.isAffordable then return false end
    if filters.hideDecor and entry.isDecor then return false end
    if filters.hideMounts and entry.isMount then return false end
    if filters.hidePets and entry.isPet then return false end
    if filters.hideToys and entry.isToy then return false end
    if filters.hideCosmetics and entry.isCosmetic then return false end
    if filters.hideGearAll and entry.isGear then return false end
    if entry.gearFilterKey and filters[entry.gearFilterKey] then return false end
    return true
end

local function getEntryTags(entry)
    local tags = {}
    if entry.isRecipe then tags[#tags + 1] = L.FEATURE_MERCHANT_FILTERS_TAG_RECIPE or "Rezept" end
    if entry.requiredProfession then tags[#tags + 1] = entry.requiredProfession end
    if entry.isDecor then tags[#tags + 1] = L.FEATURE_MERCHANT_FILTERS_TAG_DECOR or "Dekor" end
    if entry.isMount then tags[#tags + 1] = L.FEATURE_MERCHANT_FILTERS_TAG_MOUNT or "Reittier" end
    if entry.isPet then tags[#tags + 1] = L.FEATURE_MERCHANT_FILTERS_TAG_PET or "Haustier" end
    if entry.isToy then tags[#tags + 1] = L.FEATURE_MERCHANT_FILTERS_TAG_TOY or "Spielzeug" end
    if entry.isCosmetic then tags[#tags + 1] = L.FEATURE_MERCHANT_FILTERS_TAG_COSMETIC or "Kosmetik" end
    if entry.isKnown then tags[#tags + 1] = L.FEATURE_MERCHANT_FILTERS_TAG_KNOWN or "Bekannt" end
    return table.concat(tags, "  |  ")
end

local function setItemNameColor(label, entry)
    local quality = tonumber(entry and entry.staticInfo and entry.staticInfo.quality)
    local color = quality and ITEM_QUALITY_COLORS and ITEM_QUALITY_COLORS[quality] or nil
    if color then
        label:SetTextColor(color.r or 1, color.g or 1, color.b or 1, 1)
    else
        label:SetTextColor(1, 0.84, 0.18, 1)
    end
end

local function showCostDetailTooltip(owner, detail)
    if not GameTooltip or type(detail) ~= "table" then
        return
    end

    GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
    local usedNativeTooltip = false
    if detail.currencyID and type(GameTooltip.SetCurrencyByID) == "function" then
        usedNativeTooltip = pcall(GameTooltip.SetCurrencyByID, GameTooltip, detail.currencyID) == true
    elseif detail.link and type(GameTooltip.SetHyperlink) == "function" then
        usedNativeTooltip = pcall(GameTooltip.SetHyperlink, GameTooltip, detail.link) == true
    end

    if not usedNativeTooltip then
        GameTooltip:AddLine(detail.name or (L.FEATURE_MERCHANT_FILTERS_COST or "Kosten"), 1, 0.84, 0.18, true)
    end
    if tonumber(detail.amount) and tonumber(detail.amount) > 0 then
        GameTooltip:AddLine(string.format("%s: %s", L.FEATURE_MERCHANT_FILTERS_COST or "Kosten", tostring(detail.amount)), THEME.text[1], THEME.text[2], THEME.text[3], true)
    end
    if detail.owned ~= nil then
        GameTooltip:AddLine(string.format("%s: %s", L.FEATURE_MERCHANT_FILTERS_OWNED or "Besitz", tostring(detail.owned)), 0.74, 0.80, 0.90, true)
    elseif owner and owner.useOwned then
        GameTooltip:AddLine(string.format("%s: ?", L.FEATURE_MERCHANT_FILTERS_OWNED or "Besitz"), 0.74, 0.80, 0.90, true)
    end
    GameTooltip:Show()
end

local function createCostIconButton(parent)
    local button = CreateFrame("Button", nil, parent)
    button:SetSize(58, 20)

    button.icon = button:CreateTexture(nil, "ARTWORK")
    button.icon:SetSize(14, 14)
    button.icon:SetPoint("LEFT", 0, 0)
    button.icon:SetAlpha(0.92)

    button.count = createLabel(button, "GameFontHighlightSmall", "LEFT")
    button.count:SetPoint("LEFT", button.icon, "RIGHT", 4, 0)
    button.count:SetPoint("RIGHT", 0, 0)
    button.count:SetWordWrap(false)

    button:SetScript("OnEnter", function(selfButton)
        selfButton.icon:SetAlpha(1)
        selfButton.count:SetTextColor(1, 0.86, 0.20, 1)
        showCostDetailTooltip(selfButton, selfButton.detail)
    end)
    button:SetScript("OnLeave", function(selfButton)
        selfButton.icon:SetAlpha(0.92)
        selfButton.count:SetTextColor(THEME.text[1], THEME.text[2], THEME.text[3], 1)
        if GameTooltip then
            GameTooltip:Hide()
        end
    end)
    return button
end

local function updateCostButtonWidth(button)
    if not button then
        return 58
    end
    local textWidth = 28
    if button.count and button.count.GetStringWidth then
        textWidth = math.ceil(tonumber(button.count:GetStringWidth()) or textWidth)
    end
    local width = math.max(50, math.min(92, 14 + 4 + textWidth + 4))
    button:SetWidth(width)
    return width
end

local function applyCostButton(button, detail, useOwned)
    if not button or type(detail) ~= "table" then
        if button then
            button.detail = nil
            button.useOwned = false
            button:Hide()
        end
        return
    end
    button.detail = detail
    button.useOwned = useOwned == true
    button.icon:SetTexture(detail.texture or "Interface\\Icons\\INV_Misc_QuestionMark")
    button.icon:SetAlpha(0.92)
    local amount = useOwned and detail.owned or detail.amount
    button.count:SetText(amount ~= nil and tostring(tonumber(amount) or amount) or "?")
    button.count:SetTextColor(THEME.text[1], THEME.text[2], THEME.text[3], 1)
    updateCostButtonWidth(button)
    button:Show()
end

local function positionCostButtonsRight(buttons, anchorFrame, rightOffset, yOffset, gap)
    local offset = tonumber(rightOffset) or 0
    for _, button in ipairs(buttons or {}) do
        if button and button:IsShown() then
            button:ClearAllPoints()
            button:SetPoint("RIGHT", anchorFrame, "RIGHT", -offset, yOffset or 0)
            local width = button:GetWidth()
            if not width or width <= 0 then
                width = updateCostButtonWidth(button)
            end
            offset = offset + width + (gap or 8)
        end
    end
end

local function updateRowCostButtons(row, entry)
    if not row or not row.costButtons then
        return
    end
    local details = entry and entry.costDetails or {}
    if #details == 0 then
        for _, button in ipairs(row.costButtons) do
            button.detail = nil
            button:Hide()
        end
        if row.price then
            row.price:SetText(entry and entry.costText or "")
            row.price:Show()
        end
        return
    end

    if row.price then
        row.price:SetText("")
        row.price:Hide()
    end
    for index, button in ipairs(row.costButtons) do
        applyCostButton(button, details[index], false)
    end
    positionCostButtonsRight(row.costButtons, row, 90, 0, 8)
end

local function updateFooterCurrencyButtons(frame)
    if not frame or not frame.currencyButtons then
        return
    end
    local entries = {}
    local seen = {}
    for _, key in ipairs(Runtime.merchantCurrencyOrder or {}) do
        local entry = Runtime.merchantCurrencies[key]
        local displayKey = nil
        if entry and entry.texture then
            displayKey = tostring(entry.texture) .. ":" .. tostring(entry.owned or "?")
        end
        if entry and entry.texture and not seen[displayKey] and #entries < #frame.currencyButtons then
            seen[displayKey] = true
            entries[#entries + 1] = entry
        end
    end
    for index, button in ipairs(frame.currencyButtons) do
        button:ClearAllPoints()
        if entries[index] then
            applyCostButton(button, entries[index], true)
        else
            applyCostButton(button, nil, true)
        end
    end
    if #entries <= 3 then
        positionCostButtonsRight(frame.currencyButtons, frame.footer, 14, 0, 10)
    else
        local firstRow = {}
        local secondRow = {}
        for index, button in ipairs(frame.currencyButtons) do
            if button:IsShown() then
                if index <= 3 then
                    firstRow[#firstRow + 1] = button
                else
                    secondRow[#secondRow + 1] = button
                end
            end
        end
        positionCostButtonsRight(firstRow, frame.footer, 14, 10, 10)
        positionCostButtonsRight(secondRow, frame.footer, 14, -10, 10)
    end
end

function Runtime:ScanMerchant()
    wipeTable(self.items)
    wipeTable(self.filtered)
    wipeTable(self.merchantCurrencies)
    wipeTable(self.merchantCurrencyOrder)

    local filters = getFilters()
    for index = 1, getMerchantItemCount() do
        local info = getMerchantInfo(index)
        local link = getMerchantItemLink(index)
        if info and info.name then
            addMerchantCurrencies(index)
            local entry = classifyMerchantEntry(index, info, link)
            self.items[#self.items + 1] = entry
            if entryPassesFilters(entry, filters) then
                self.filtered[#self.filtered + 1] = entry
            end
        end
    end
end

local function getBuybackCount()
    if type(GetNumBuybackItems) == "function" then
        local ok, count = pcall(GetNumBuybackItems)
        if ok and tonumber(count) then
            return tonumber(count)
        end
    end
    return 0
end

function Runtime:ScanBuyback()
    wipeTable(self.buyback)
    if type(GetBuybackItemInfo) ~= "function" then
        return
    end
    for index = 1, getBuybackCount() do
        local ok, name, texture, price, quantity = pcall(GetBuybackItemInfo, index)
        if ok and name then
            local link = type(GetBuybackItemLink) == "function" and select(2, pcall(GetBuybackItemLink, index)) or nil
            self.buyback[#self.buyback + 1] = {
                index = index,
                name = name,
                icon = texture,
                price = tonumber(price) or 0,
                quantity = tonumber(quantity) or 1,
                link = link,
                costText = formatMoney(tonumber(price) or 0),
                isAffordable = type(GetMoney) ~= "function" or (GetMoney() >= (tonumber(price) or 0)),
            }
        end
    end
end

local function concealMerchantFrame()
    if not MerchantFrame then
        return
    end
    if Runtime.defaultMerchantAlpha == nil and MerchantFrame.GetAlpha then
        Runtime.defaultMerchantAlpha = MerchantFrame:GetAlpha()
    end
    if Runtime.defaultMerchantMouse == nil and MerchantFrame.IsMouseEnabled then
        Runtime.defaultMerchantMouse = MerchantFrame:IsMouseEnabled()
    end
    if Runtime.defaultMerchantStrata == nil and MerchantFrame.GetFrameStrata then
        Runtime.defaultMerchantStrata = MerchantFrame:GetFrameStrata()
    end
    if Runtime.defaultMerchantFrameLevel == nil and MerchantFrame.GetFrameLevel then
        Runtime.defaultMerchantFrameLevel = MerchantFrame:GetFrameLevel()
    end
    if MerchantFrame.SetAlpha then MerchantFrame:SetAlpha(0) end
    if MerchantFrame.EnableMouse then MerchantFrame:EnableMouse(false) end
    if MerchantFrame.SetFrameStrata then MerchantFrame:SetFrameStrata("BACKGROUND") end
end

local function restoreMerchantFrame()
    if not MerchantFrame then
        return
    end
    if MerchantFrame.SetAlpha then MerchantFrame:SetAlpha(Runtime.defaultMerchantAlpha or 1) end
    if MerchantFrame.EnableMouse then MerchantFrame:EnableMouse(Runtime.defaultMerchantMouse ~= false) end
    if Runtime.defaultMerchantStrata and MerchantFrame.SetFrameStrata then MerchantFrame:SetFrameStrata(Runtime.defaultMerchantStrata) end
    if Runtime.defaultMerchantFrameLevel and MerchantFrame.SetFrameLevel then MerchantFrame:SetFrameLevel(Runtime.defaultMerchantFrameLevel) end
end

local function makeBackdropFillTransparent(frame, alpha)
    if not frame or not frame.SetBackdropColor then
        return
    end
    frame:SetBackdropColor(0.02, 0.018, 0.015, alpha or 0.14)
end

function Runtime:EnsureFrame()
    if self.frame or type(CreateFrame) ~= "function" then
        return
    end

    local frame = CreateFrame("Frame", "VaultloomMerchantWindow", UIParent, "BackdropTemplate")
    frame:SetSize(666, 622)
    frame:SetFrameStrata("DIALOG")
    frame:SetClampedToScreen(true)
    frame:EnableMouse(true)
    frame:SetMovable(true)
    frame:EnableMouseWheel(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", function(selfFrame)
        if IsShiftKeyDown() then selfFrame:StartMoving() end
    end)
    frame:SetScript("OnDragStop", function(selfFrame) selfFrame:StopMovingOrSizing() end)
    frame:SetScript("OnMouseWheel", function(selfFrame, delta)
        scaleMerchantWindowWithWheel(selfFrame, delta)
    end)
    setWindowBackdrop(frame)
    makeBackdropFillTransparent(frame, 0)
    applyMerchantWindowScale(frame)

    frame.backgroundTexture = frame:CreateTexture(nil, "BORDER", nil, -8)
    frame.backgroundTexture:SetPoint("TOPLEFT", frame, "TOPLEFT", 4, -4)
    frame.backgroundTexture:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -4, 4)
    frame.backgroundTexture:SetTexture(MERCHANT_WINDOW_BG)
    frame.backgroundTexture:SetTexCoord(0, 1, 0, 1)
    frame.backgroundTexture:SetAlpha(1)

    frame.backgroundShade = frame:CreateTexture(nil, "BORDER", nil, -7)
    frame.backgroundShade:SetPoint("TOPLEFT", frame.backgroundTexture, "TOPLEFT", 0, 0)
    frame.backgroundShade:SetPoint("BOTTOMRIGHT", frame.backgroundTexture, "BOTTOMRIGHT", 0, 0)
    frame.backgroundShade:SetColorTexture(0, 0, 0, 0.02)

    frame:Hide()

    frame.header = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    frame.header:SetPoint("TOPLEFT", 18, -18)
    frame.header:SetPoint("TOPRIGHT", -18, -18)
    frame.header:SetHeight(70)
    setBackdrop(frame.header, { 0.06, 0.052, 0.044 }, THEME.border, 0.96)
    makeBackdropFillTransparent(frame.header, 0)

    frame.headerBg = frame.header:CreateTexture(nil, "BORDER", nil, -8)
    frame.headerBg:SetAllPoints()
    frame.headerBg:SetTexture(MERCHANT_HEADER_BG)
    frame.headerBg:SetTexCoord(0, 1, 0, 1)
    frame.headerBg:SetAlpha(1)

    frame.headerShade = frame.header:CreateTexture(nil, "BORDER", nil, -7)
    frame.headerShade:SetAllPoints()
    frame.headerShade:SetColorTexture(0, 0, 0, 0.035)

    frame.portraitBackplate = frame.header:CreateTexture(nil, "ARTWORK")
    frame.portraitBackplate:SetSize(62, 62)
    frame.portraitBackplate:SetPoint("LEFT", 12, 0)
    frame.portraitBackplate:SetTexture("Interface\\CHARACTERFRAME\\TempPortraitAlphaMask")
    frame.portraitBackplate:SetVertexColor(0.02, 0.018, 0.015, 1)

    frame.portraitRing = frame.header:CreateTexture(nil, "ARTWORK")
    frame.portraitRing:SetSize(58, 58)
    frame.portraitRing:SetPoint("CENTER", frame.portraitBackplate, "CENTER", 0, 0)
    frame.portraitRing:SetTexture("Interface\\CHARACTERFRAME\\TempPortraitAlphaMask")
    frame.portraitRing:SetVertexColor(THEME.borderBright[1], THEME.borderBright[2], THEME.borderBright[3], 1)

    frame.portraitInset = frame.header:CreateTexture(nil, "ARTWORK")
    frame.portraitInset:SetSize(52, 52)
    frame.portraitInset:SetPoint("CENTER", frame.portraitBackplate, "CENTER", 0, 0)
    frame.portraitInset:SetTexture("Interface\\CHARACTERFRAME\\TempPortraitAlphaMask")
    frame.portraitInset:SetVertexColor(0.035, 0.031, 0.026, 1)

    frame.portrait = frame.header:CreateTexture(nil, "OVERLAY")
    frame.portrait:SetSize(48, 48)
    frame.portrait:SetPoint("CENTER", frame.portraitBackplate, "CENTER", 0, 0)
    if frame.portrait.AddMaskTexture then
        frame.portraitMask = frame.header:CreateMaskTexture()
        frame.portraitMask:SetTexture("Interface\\CHARACTERFRAME\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        frame.portraitMask:SetAllPoints(frame.portrait)
        frame.portrait:AddMaskTexture(frame.portraitMask)
    end

    frame.title = createLabel(frame.header, "GameFontNormalLarge", "LEFT")
    frame.title:SetPoint("LEFT", frame.portraitBackplate, "RIGHT", 12, 9)
    frame.title:SetPoint("RIGHT", -130, 9)
    frame.title:SetTextColor(1, 0.86, 0.18, 1)

    frame.subtitle = createLabel(frame.header, "GameFontDisableSmall", "LEFT")
    frame.subtitle:SetPoint("TOPLEFT", frame.title, "BOTTOMLEFT", 0, -4)
    frame.subtitle:SetPoint("RIGHT", -130, 0)

    frame.closeButton = createButton(frame.header, "X", 34)
    frame.closeButton:SetPoint("RIGHT", -12, 0)
    frame.closeButton:SetScript("OnClick", function()
        if type(CloseMerchant) == "function" then
            CloseMerchant()
        else
            Runtime:Hide()
        end
    end)

    frame.buyTab = CreateFrame("Button", nil, frame, "BackdropTemplate")
    frame.buyTab:SetSize(104, 28)
    frame.buyTab:SetPoint("TOPLEFT", frame.header, "BOTTOMLEFT", 0, -10)
    styleTab(frame.buyTab)
    frame.buyTab:SetButtonText(MERCHANT or "Kaufen")
    frame.buyTab:SetScript("OnClick", function() Runtime:SetTab("buy") end)

    frame.buybackTab = CreateFrame("Button", nil, frame, "BackdropTemplate")
    frame.buybackTab:SetSize(104, 28)
    frame.buybackTab:SetPoint("LEFT", frame.buyTab, "RIGHT", 8, 0)
    styleTab(frame.buybackTab)
    frame.buybackTab:SetButtonText(BUYBACK or "Rückkauf")
    frame.buybackTab:SetScript("OnClick", function() Runtime:SetTab("buyback") end)

    frame.classButton = createButton(frame, getClassFilterLabel(), 146)
    frame.classButton:SetPoint("LEFT", frame.buybackTab, "RIGHT", 8, 0)
    frame.classButton:SetScript("OnClick", function()
        setClassFilterMode(nextClassFilterMode())
        Runtime:Refresh()
    end)

    frame.filterButton = createButton(frame, L.FEATURE_MERCHANT_FILTERS_BUTTON or "Filter", 98)
    frame.filterButton:SetPoint("TOPRIGHT", frame.header, "BOTTOMRIGHT", -116, -10)
    frame.filterButton:SetScript("OnClick", function() Runtime:ToggleFilters() end)

    frame.saveButton = createButton(frame, getSaveModeLabel(), 108)
    frame.saveButton:SetPoint("LEFT", frame.filterButton, "RIGHT", 8, 0)
    frame.saveButton:SetScript("OnClick", function()
        setSaveMode(nextSaveMode())
        persistFilters()
        Runtime:Refresh()
    end)

    frame.body = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    frame.body:SetPoint("TOPLEFT", frame.buyTab, "BOTTOMLEFT", 0, -10)
    frame.body:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -10, 66)
    setBackdrop(frame.body, { 0.025, 0.023, 0.020 }, THEME.border, 0.94)
    makeBackdropFillTransparent(frame.body, 0)

    frame.bodyBg = frame.body:CreateTexture(nil, "BORDER", nil, -8)
    frame.bodyBg:SetAllPoints()
    frame.bodyBg:SetTexture(MERCHANT_WINDOW_BG)
    frame.bodyBg:SetTexCoord(0.08, 0.92, 0.10, 0.76)
    frame.bodyBg:SetAlpha(1)

    frame.bodyShade = frame.body:CreateTexture(nil, "BORDER", nil, -7)
    frame.bodyShade:SetAllPoints()
    frame.bodyShade:SetColorTexture(0, 0, 0, 0.06)

    frame.filterPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    frame.filterPanel:SetPoint("TOPLEFT", frame, "TOPRIGHT", 8, -18)
    frame.filterPanel:SetSize(430, 430)
    frame.filterPanel:SetFrameLevel(frame:GetFrameLevel() + 20)
    frame.filterPanel:SetClampedToScreen(true)
    setBackdrop(frame.filterPanel, { 0.035, 0.031, 0.026 }, THEME.borderBright, 0.98)
    makeBackdropFillTransparent(frame.filterPanel, 0)
    frame.filterPanel.bg = frame.filterPanel:CreateTexture(nil, "BORDER", nil, -8)
    frame.filterPanel.bg:SetAllPoints()
    frame.filterPanel.bg:SetTexture(MERCHANT_WINDOW_BG)
    frame.filterPanel.bg:SetTexCoord(0.16, 0.84, 0.18, 0.78)
    frame.filterPanel.bg:SetAlpha(1)
    frame.filterPanel.shade = frame.filterPanel:CreateTexture(nil, "BORDER", nil, -7)
    frame.filterPanel.shade:SetAllPoints()
    frame.filterPanel.shade:SetColorTexture(0, 0, 0, 0.08)
    frame.filterPanel:Hide()

    frame.filterTitle = createLabel(frame.filterPanel, "GameFontNormal", "LEFT")
    frame.filterTitle:SetPoint("TOPLEFT", 14, -12)
    frame.filterTitle:SetText(L.FEATURE_MERCHANT_FILTERS_TITLE or "Händlerfilter")
    frame.filterTitle:SetTextColor(1, 0.84, 0.18, 1)

    frame.filterSummary = createLabel(frame.filterPanel, "GameFontDisableSmall", "LEFT")
    frame.filterSummary:SetPoint("TOPLEFT", frame.filterTitle, "BOTTOMLEFT", 0, -4)
    frame.filterSummary:SetPoint("RIGHT", -14, 0)

    frame.filterScroll = CreateFrame("ScrollFrame", nil, frame.filterPanel)
    frame.filterScroll:SetPoint("TOPLEFT", frame.filterSummary, "BOTTOMLEFT", 0, -12)
    frame.filterScroll:SetPoint("BOTTOMRIGHT", frame.filterPanel, "BOTTOMRIGHT", -32, 52)

    frame.filterScrollChild = CreateFrame("Frame", nil, frame.filterScroll)
    frame.filterScrollChild:SetSize(360, 10)
    frame.filterScroll:SetScrollChild(frame.filterScrollChild)

    frame.filterSlider = CreateFrame("Slider", nil, frame.filterPanel, "BackdropTemplate")
    frame.filterSlider:SetOrientation("VERTICAL")
    frame.filterSlider:SetPoint("TOPRIGHT", -14, -62)
    frame.filterSlider:SetPoint("BOTTOMRIGHT", -14, 54)
    frame.filterSlider:SetWidth(8)
    styleVaultloomSlider(frame.filterSlider, 32)
    frame.filterSlider:SetScript("OnValueChanged", function(slider, value)
        if frame.filterScroll then
            frame.filterScroll:SetVerticalScroll(value or 0)
        end
    end)

    frame.filterScroll:SetScript("OnMouseWheel", function(scroll, delta)
        if scaleMerchantWindowWithWheel(frame, delta) then
            return
        end
        local current = scroll:GetVerticalScroll() or 0
        local maxScroll = scroll:GetVerticalScrollRange() or 0
        local nextValue = current - (delta * 30)
        if nextValue < 0 then nextValue = 0 end
        if nextValue > maxScroll then nextValue = maxScroll end
        scroll:SetVerticalScroll(nextValue)
        if frame.filterSlider then frame.filterSlider:SetValue(nextValue) end
    end)

    frame.filterRows = {}
    local filterOffset = 0
    for _, rowInfo in ipairs(FILTER_ROWS) do
        if rowInfo.divider then
            local divider = frame.filterScrollChild:CreateTexture(nil, "ARTWORK")
            divider:SetPoint("TOPLEFT", frame.filterScrollChild, "TOPLEFT", 0, -filterOffset - 7)
            divider:SetPoint("RIGHT", frame.filterScrollChild, "RIGHT", -4, 0)
            divider:SetHeight(1)
            setColorTexture(divider, THEME.border, 0.55)
            filterOffset = filterOffset + 18
        else
            local row = createCheckbox(frame.filterScrollChild, rowInfo.label())
            row:SetSize(360, 24)
            row:SetPoint("TOPLEFT", frame.filterScrollChild, "TOPLEFT", 0, -filterOffset)
            row.key = rowInfo.key
            row:SetScript("OnClick", function(selfRow)
                setFilterValue(selfRow.key, not selfRow:GetChecked())
                Runtime:Refresh()
            end)
            frame.filterRows[#frame.filterRows + 1] = row
            filterOffset = filterOffset + 30
        end
    end
    frame.filterScrollChild:SetHeight(math.max(10, filterOffset))

    frame.clearButton = createButton(frame.filterPanel, L.FEATURE_MERCHANT_FILTERS_CLEAR or "Filter aus", 112)
    frame.clearButton:SetPoint("BOTTOMLEFT", 14, 12)
    frame.clearButton:SetScript("OnClick", function()
        clearFilters()
        Runtime:Refresh()
    end)

    frame.closeFilterButton = createButton(frame.filterPanel, L.FEATURE_MERCHANT_FILTERS_BACK or "Zurück", 104)
    frame.closeFilterButton:SetPoint("BOTTOMRIGHT", -14, 12)
    frame.closeFilterButton:SetScript("OnClick", function()
        frame.filterPanel:Hide()
    end)

    frame.scroll = CreateFrame("ScrollFrame", nil, frame.body)
    frame.scroll:SetPoint("TOPLEFT", 10, -10)
    frame.scroll:SetPoint("BOTTOMRIGHT", -24, 10)
    frame.scrollChild = CreateFrame("Frame", nil, frame.scroll)
    frame.scrollChild:SetSize(600, 10)
    frame.scroll:SetScrollChild(frame.scrollChild)
    frame.scroll:SetScript("OnMouseWheel", function(scroll, delta)
        if scaleMerchantWindowWithWheel(frame, delta) then
            return
        end
        local current = scroll:GetVerticalScroll() or 0
        local maxScroll = scroll:GetVerticalScrollRange() or 0
        local nextValue = current - (delta * ROW_HEIGHT)
        if nextValue < 0 then nextValue = 0 end
        if nextValue > maxScroll then nextValue = maxScroll end
        scroll:SetVerticalScroll(nextValue)
        if frame.slider then frame.slider:SetValue(nextValue) end
    end)

    frame.slider = CreateFrame("Slider", nil, frame.body, "BackdropTemplate")
    frame.slider:SetOrientation("VERTICAL")
    frame.slider:SetPoint("TOPRIGHT", -8, -12)
    frame.slider:SetPoint("BOTTOMRIGHT", -8, 12)
    frame.slider:SetWidth(8)
    styleVaultloomSlider(frame.slider, 34)
    frame.slider:SetScript("OnValueChanged", function(slider, value)
        if frame.scroll then
            frame.scroll:SetVerticalScroll(value or 0)
        end
    end)

    frame.empty = createLabel(frame.body, "GameFontDisable", "CENTER")
    frame.empty:SetPoint("CENTER", frame.body, "CENTER", 0, 0)

    frame.footer = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    frame.footer:SetPoint("LEFT", 10, 0)
    frame.footer:SetPoint("RIGHT", -10, 0)
    frame.footer:SetPoint("BOTTOM", 0, 10)
    frame.footer:SetHeight(46)
    setBackdrop(frame.footer, { 0.045, 0.040, 0.034 }, THEME.border, 0.96)
    makeBackdropFillTransparent(frame.footer, 0)

    frame.footerBg = frame.footer:CreateTexture(nil, "BORDER", nil, -8)
    frame.footerBg:SetAllPoints()
    frame.footerBg:SetTexture(MERCHANT_WINDOW_BG)
    frame.footerBg:SetTexCoord(0.05, 0.95, 0.78, 0.97)
    frame.footerBg:SetAlpha(1)

    frame.footerShade = frame.footer:CreateTexture(nil, "BORDER", nil, -7)
    frame.footerShade:SetAllPoints()
    frame.footerShade:SetColorTexture(0, 0, 0, 0.07)

    frame.money = createLabel(frame.footer, "GameFontHighlightSmall", "LEFT")
    frame.money:SetPoint("LEFT", 14, 0)
    frame.money:SetWidth(185)

    frame.currency = createLabel(frame.footer, "GameFontHighlightSmall", "RIGHT")
    frame.currency:SetPoint("RIGHT", -14, 0)
    frame.currency:SetSize(160, 20)
    frame.currency:SetJustifyV("MIDDLE")
    frame.currency:Hide()

    frame.currencyButtons = {}
    for index = 1, 6 do
        local costButton = createCostIconButton(frame.footer)
        local column = (index - 1) % 3
        local row = math.floor((index - 1) / 3)
        costButton:SetPoint("RIGHT", frame.footer, "RIGHT", -14 - (column * 56), row == 0 and 10 or -10)
        costButton:Hide()
        frame.currencyButtons[index] = costButton
    end

    frame.junkButton = createButton(frame.footer, "Plunder", 112)
    frame.junkButton:SetPoint("CENTER", frame.footer, "CENTER", -64, 0)
    frame.junkButton:SetScript("OnClick", function()
        Runtime:SellJunk()
    end)

    frame.repairButton = createButton(frame.footer, "Reparieren", 118)
    frame.repairButton:SetPoint("LEFT", frame.junkButton, "RIGHT", 8, 0)
    frame.repairButton:SetScript("OnClick", function()
        if type(RepairAllItems) == "function" then
            RepairAllItems(false)
            Runtime:ScheduleRefresh(0.20)
        end
    end)

    frame.rows = {}
    self.frame = frame
end

local function getVisibleEntries()
    if Runtime.activeTab == "buyback" then
        return Runtime.buyback
    end
    return Runtime.filtered
end

function Runtime:CreateRow(parent)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(ROW_HEIGHT - 4)
    row:EnableMouse(true)
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    setBackdrop(row, THEME.row, THEME.border, 0.94)

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

    row.name = createLabel(row, "GameFontNormalSmall", "LEFT")
    row.name:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 10, -6)
    row.name:SetPoint("RIGHT", -188, 0)
    row.name:SetWordWrap(false)

    row.meta = createLabel(row, "GameFontDisableSmall", "LEFT")
    row.meta:SetPoint("TOPLEFT", row.name, "BOTTOMLEFT", 0, -3)
    row.meta:SetPoint("RIGHT", row.name, "RIGHT", 0, 0)
    row.meta:SetWordWrap(false)

    row.price = createLabel(row, "GameFontHighlightSmall", "RIGHT")
    row.price:SetPoint("RIGHT", -90, 0)
    row.price:SetWidth(190)

    row.costButtons = {}
    for index = 1, 3 do
        local costButton = createCostIconButton(row)
        costButton:SetPoint("RIGHT", row, "RIGHT", -86 - ((index - 1) * 54), 0)
        costButton:Hide()
        row.costButtons[index] = costButton
    end

    row.action = createButton(row, BUY or "Kaufen", 74)
    row.action:SetPoint("RIGHT", -8, 0)
    row.action:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    row:SetScript("OnEnter", function(selfRow)
        setBackdrop(selfRow, THEME.rowHover, THEME.borderBright, 0.98)
        local entry = selfRow.entry
        if not entry then return end
        GameTooltip:SetOwner(selfRow, "ANCHOR_RIGHT")
        if Runtime.activeTab == "buyback" then
            if type(GameTooltip.SetBuybackItem) == "function" then
                pcall(GameTooltip.SetBuybackItem, GameTooltip, entry.index)
            elseif entry.link then
                GameTooltip:SetHyperlink(entry.link)
            end
        elseif type(GameTooltip.SetMerchantItem) == "function" then
            local ok = pcall(GameTooltip.SetMerchantItem, GameTooltip, entry.index)
            if not ok and entry.link then GameTooltip:SetHyperlink(entry.link) end
        elseif entry.link then
            GameTooltip:SetHyperlink(entry.link)
        end
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function(selfRow)
        setBackdrop(selfRow, THEME.row, THEME.border, 0.94)
        GameTooltip:Hide()
    end)
    row:SetScript("OnClick", function(selfRow)
        local entry = selfRow.entry
        if entry then
            handleMerchantModifiedItemClick(entry.link)
        end
    end)
    row.action:SetScript("OnClick", function(selfButton)
        local entry = selfButton:GetParent().entry
        if not entry then return end
        if handleMerchantModifiedItemClick(entry.link) then
            return
        end
        if Runtime.activeTab == "buyback" then
            if type(BuybackItem) == "function" then pcall(BuybackItem, entry.index) end
        elseif type(BuyMerchantItem) == "function" then
            pcall(BuyMerchantItem, entry.index)
        end
        Runtime:ScheduleRefresh(0.18)
    end)

    return row
end

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

    local entries = getVisibleEntries()
    local child = frame.scrollChild
    child:SetWidth(math.max(300, (frame.scroll:GetWidth() or 610) - 2))
    child:SetHeight(math.max(10, #entries * ROW_HEIGHT))

    for index, entry in ipairs(entries) do
        local row = frame.rows[index]
        if not row then
            row = self:CreateRow(child)
            frame.rows[index] = row
        end
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", child, "TOPLEFT", 0, -((index - 1) * ROW_HEIGHT))
        row:SetPoint("RIGHT", child, "RIGHT", -2, 0)
        row.entry = entry
        row.icon:SetTexture(entry.icon or "Interface\\Icons\\INV_Misc_QuestionMark")
        row.name:SetText(entry.name or UNKNOWN or "Unknown")
        setItemNameColor(row.name, entry)
        if self.activeTab == "buyback" then
            row.meta:SetText(BUYBACK or "Rückkauf")
            updateRowCostButtons(row, nil)
            row.price:SetText(entry.costText or "")
            row.price:Show()
            row.action:SetButtonText(BUYBACK or "Rückkauf")
            row.action:SetButtonEnabled(entry.isAffordable ~= false)
        else
            row.meta:SetText(getEntryTags(entry))
            updateRowCostButtons(row, entry)
            row.action:SetButtonText(BUY or "Kaufen")
            row.action:SetButtonEnabled(entry.canBuy == true)
        end
        row:SetAlpha(entry.isKnown and 0.55 or 1)
        row:Show()
    end

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

    frame.empty:SetShown(#entries == 0)
    if self.activeTab == "buyback" then
        frame.empty:SetText(L.FEATURE_MERCHANT_FILTERS_EMPTY_BUYBACK or "Keine Rückkauf-Items.")
    else
        frame.empty:SetText(L.FEATURE_MERCHANT_FILTERS_EMPTY or "Keine Händleritems passen zu diesen Filtern.")
    end

    local maxScroll = math.max(0, (#entries * ROW_HEIGHT) - (frame.scroll:GetHeight() or 1))
    frame.slider:SetMinMaxValues(0, maxScroll)
    frame.slider:SetValueStep(ROW_HEIGHT)
    frame.slider:SetShown(maxScroll > 0)
    if (frame.scroll:GetVerticalScroll() or 0) > maxScroll then
        frame.scroll:SetVerticalScroll(maxScroll)
        frame.slider:SetValue(maxScroll)
    end
end

local function refreshRepair(frame)
    if not frame or not frame.repairButton then return end
    local canRepair = type(CanMerchantRepair) == "function" and CanMerchantRepair()
    frame.repairButton:SetShown(canRepair == true)
    if frame.junkButton then
        frame.junkButton:ClearAllPoints()
        frame.junkButton:SetPoint("CENTER", frame.footer, "CENTER", canRepair == true and -64 or 0, 0)
    end
    if frame.repairButton then
        frame.repairButton:ClearAllPoints()
        frame.repairButton:SetPoint("LEFT", frame.junkButton, "RIGHT", 8, 0)
    end
    if not canRepair then return end
    local cost = 0
    if type(GetRepairAllCost) == "function" then
        local ok, repairCost = pcall(GetRepairAllCost)
        if ok then cost = tonumber(repairCost) or 0 end
    end
    frame.repairButton:SetButtonText("Reparieren")
    frame.repairButton:SetButtonEnabled(cost > 0)
    frame.repairButton.vlTooltipTitle = REPAIR_ALL_ITEMS or "Alle Gegenstaende reparieren"
    frame.repairButton.vlTooltipText = cost > 0 and formatMoney(cost) or (L.FEATURE_MERCHANT_FILTERS_NO_REPAIR_COST or "Keine Reparaturkosten.")
end

local function refreshJunkButton(frame)
    if not frame or not frame.junkButton then return end
    local count, value = countSellableJunk()
    local hasJunk = count > 0 and merchantCanSellItems()
    frame.junkButton:SetShown(merchantCanSellItems())
    if Runtime.sellingJunk then
        frame.junkButton:SetButtonText(L.FEATURE_MERCHANT_FILTERS_SELLING_JUNK or "Verkaufe...")
    elseif hasJunk then
        frame.junkButton:SetButtonText("Plunder")
    else
        frame.junkButton:SetButtonText("Plunder")
    end
    frame.junkButton:SetButtonEnabled(hasJunk and not Runtime.sellingJunk)
    frame.junkButton.vlTooltipTitle = L.FEATURE_MERCHANT_FILTERS_SELL_JUNK or "Plunder verkaufen"
    frame.junkButton.vlTooltipText = hasJunk and string.format("%d | %s", count, formatMoney(value)) or nil
end

function Runtime:FinishSellingJunk(count, value)
    self.sellingJunk = false
    if ns.Print and tonumber(count) and count > 0 then
        ns:Print(string.format(
            L.FEATURE_MERCHANT_FILTERS_SOLD_JUNK_FORMAT or "Plunder verkauft: %d für %s.",
            count,
            formatMoney(value or 0)
        ))
    end
    self:ScheduleRefresh(0.12)
end

function Runtime:SellJunk()
    if self.sellingJunk or not merchantCanSellItems() then
        return
    end
    if type(InCombatLockdown) == "function" and InCombatLockdown() then
        return
    end
    if not C_Container or type(C_Container.UseContainerItem) ~= "function" then
        return
    end

    local queue = buildJunkSellQueue()
    if #queue == 0 then
        self:ScheduleRefresh(0.05)
        return
    end

    self.sellingJunk = true
    self.junkSellToken = self.junkSellToken + 1
    local token = self.junkSellToken
    local soldCount = 0
    local soldValue = 0

    local function sellNext()
        if token ~= Runtime.junkSellToken or not Runtime.enabled or not Runtime.sellingJunk or not merchantCanSellItems() then
            Runtime:FinishSellingJunk(soldCount, soldValue)
            return
        end

        local entry = table.remove(queue, 1)
        if not entry then
            Runtime:FinishSellingJunk(soldCount, soldValue)
            return
        end

        local current = isSellableJunkItem(entry.bag, entry.slot)
        if current then
            local ok = pcall(C_Container.UseContainerItem, entry.bag, entry.slot)
            if ok then
                soldCount = soldCount + (tonumber(current.stackCount) or 1)
                soldValue = soldValue + (tonumber(current.value) or 0)
            end
        end

        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(SELL_STEP_DELAY_SECONDS, sellNext)
        else
            sellNext()
        end
    end

    self:Refresh()
    sellNext()
end

function Runtime:RefreshFilterPanel()
    local frame = self.frame
    if not frame then return end
    local filters = getFilters()
    for _, row in ipairs(frame.filterRows or {}) do
        row:SetChecked(filters[row.key] == true)
    end
    if frame.filterScrollChild and frame.filterScroll and frame.filterSlider then
        frame.filterScrollChild:SetWidth(math.max(260, (frame.filterScroll:GetWidth() or 360) - 2))
        local maxScroll = math.max(0, (frame.filterScrollChild:GetHeight() or 0) - (frame.filterScroll:GetHeight() or 1))
        frame.filterSlider:SetMinMaxValues(0, maxScroll)
        frame.filterSlider:SetValueStep(30)
        frame.filterSlider:SetShown(maxScroll > 0)
        if (frame.filterScroll:GetVerticalScroll() or 0) > maxScroll then
            frame.filterScroll:SetVerticalScroll(maxScroll)
            frame.filterSlider:SetValue(maxScroll)
        end
    end
    frame.filterSummary:SetText(string.format(
        L.FEATURE_MERCHANT_FILTERS_SUMMARY_FORMAT or "%d sichtbar / %d gescannt",
        #self.filtered,
        #self.items
    ))
    frame.filterButton:SetButtonText(hasActiveFilters(filters) and (L.FEATURE_MERCHANT_FILTERS_BUTTON_ACTIVE or "Filter *") or (L.FEATURE_MERCHANT_FILTERS_BUTTON or "Filter"))
    frame.saveButton:SetButtonText(getSaveModeLabel())
end

function Runtime:Refresh()
    if not self.frame or not self.frame:IsShown() then
        return
    end
    self:ScanMerchant()
    self:ScanBuyback()

    local frame = self.frame
    frame.title:SetText(getMerchantTitle())
    refreshMerchantPortrait(frame)
    frame.subtitle:SetText(string.format(
        L.FEATURE_MERCHANT_FILTERS_SUMMARY_FORMAT or "%d sichtbar / %d gescannt",
        #self.filtered,
        #self.items
    ))
    frame.buyTab:SetActive(self.activeTab == "buy")
    frame.buybackTab:SetActive(self.activeTab == "buyback")
    frame.classButton:SetShown(self.activeTab == "buy")
    frame.classButton:SetButtonText(getClassFilterLabel())
    frame.filterButton:SetShown(self.activeTab == "buy")
    frame.saveButton:SetShown(self.activeTab == "buy")
    frame.filterPanel:SetShown(frame.filterPanel:IsShown() and self.activeTab == "buy")
    frame.money:SetText(getMoneyText())
    frame.currency:SetText("")
    frame.currency:Hide()
    updateFooterCurrencyButtons(frame)
    refreshRepair(frame)
    refreshJunkButton(frame)
    self:RefreshFilterPanel()
    self:RefreshRows()
end

function Runtime:ToggleFilters()
    if not self.frame then return end
    self.frame.filterPanel:SetShown(not self.frame.filterPanel:IsShown())
    self:RefreshFilterPanel()
end

function Runtime:SetTab(tab)
    self.activeTab = tab == "buyback" and "buyback" or "buy"
    if self.frame and self.activeTab ~= "buy" then
        self.frame.filterPanel:Hide()
    end
    self:Refresh()
end

function Runtime:RestoreOriginalMerchantItems()
    local perPage = tonumber(MERCHANT_ITEMS_PER_PAGE) or 12
    for buttonIndex = 1, perPage do
        local button = _G["MerchantItem" .. buttonIndex] or _G["MerchantFrameItem" .. buttonIndex]
        if button and button.SetShown then
            button:SetShown(true)
            if button.SetAlpha then button:SetAlpha(1) end
            if button.EnableMouse then button:EnableMouse(true) end
        end
    end
    if self.originalFilterApplied and not self.restoringOriginal and merchantFrameOpen() and type(MerchantFrame_Update) == "function" then
        self.restoringOriginal = true
        pcall(MerchantFrame_Update)
        self.restoringOriginal = false
    end
    self.originalFilterApplied = false
end

local function hideOriginalSlotCostFrames(buttonIndex)
    local moneyFrame = _G["MerchantItem" .. buttonIndex .. "MoneyFrame"]
    local altCurrency = _G["MerchantItem" .. buttonIndex .. "AltCurrencyFrame"]
    if moneyFrame then moneyFrame:Hide() end
    if altCurrency then altCurrency:Hide() end
    local maxCost = tonumber(MAX_ITEM_COST) or 3
    for costIndex = 1, maxCost do
        local currencyButtonName = "MerchantItem" .. buttonIndex .. "AltCurrencyFrameItem" .. costIndex
        local currencyButton = _G[currencyButtonName]
        if currencyButton then currencyButton:Hide() end
        local currencyCount = _G[currencyButtonName .. "Count"] or (currencyButton and currencyButton.Count)
        local currencyIcon = _G[currencyButtonName .. "IconTexture"] or (currencyButton and (currencyButton.Icon or currencyButton.icon))
        if currencyCount then
            currencyCount:SetText("")
            currencyCount:Hide()
        end
        if currencyIcon then
            currencyIcon:SetTexture(nil)
            currencyIcon:Hide()
        end
    end
end

local function refreshOriginalSlotCostFrameChildren(buttonIndex, merchantIndex)
    local maxCost = tonumber(MAX_ITEM_COST) or 3
    for costIndex = 1, maxCost do
        local currencyButtonName = "MerchantItem" .. buttonIndex .. "AltCurrencyFrameItem" .. costIndex
        local currencyButton = _G[currencyButtonName]
        if currencyButton then
            currencyButton.index = merchantIndex
            if currencyButton.SetID then currencyButton:SetID(merchantIndex) end
            if currencyButton:IsShown() then
                local currencyCount = _G[currencyButtonName .. "Count"] or currencyButton.Count
                local currencyIcon = _G[currencyButtonName .. "IconTexture"] or currencyButton.Icon or currencyButton.icon
                if safeGetFontStringText(currencyCount) ~= "" then currencyCount:Show() end
                if currencyIcon and currencyIcon:GetTexture() then currencyIcon:Show() end
            end
        end
    end
end

local function setOriginalSlotVertexColor(slot, itemButton, red, green, blue)
    if type(SetItemButtonSlotVertexColor) == "function" then SetItemButtonSlotVertexColor(slot, red, green, blue) end
    if itemButton and type(SetItemButtonTextureVertexColor) == "function" then SetItemButtonTextureVertexColor(itemButton, red, green, blue) end
    if itemButton and type(SetItemButtonNormalTextureVertexColor) == "function" then SetItemButtonNormalTextureVertexColor(itemButton, red, green, blue) end
    if type(SetItemButtonNameFrameVertexColor) == "function" then SetItemButtonNameFrameVertexColor(slot, red, green, blue) end
end

local function clearOriginalMerchantSlot(buttonIndex)
    local slot = _G["MerchantItem" .. buttonIndex] or _G["MerchantFrameItem" .. buttonIndex]
    if not slot then
        return
    end
    local itemName = _G["MerchantItem" .. buttonIndex .. "Name"] or slot.Name
    local itemButton = _G["MerchantItem" .. buttonIndex .. "ItemButton"] or slot.ItemButton

    slot:Hide()
    slot:SetAlpha(1)
    if slot.EnableMouse then slot:EnableMouse(false) end
    if slot.SetID then slot:SetID(buttonIndex) end
    slot.itemIndex = nil
    slot.index = nil

    if itemName then itemName:SetText("") end
    if itemButton then
        if itemButton.SetID then itemButton:SetID(buttonIndex) end
        itemButton.link = nil
        itemButton.texture = nil
        itemButton:Hide()
        if itemButton.IconQuestTexture then itemButton.IconQuestTexture:Hide() end
        if type(SetItemButtonDesaturated) == "function" then
            SetItemButtonDesaturated(itemButton, false)
        end
    end
    setOriginalSlotVertexColor(slot, itemButton, 1.0, 1.0, 1.0)
    hideOriginalSlotCostFrames(buttonIndex)
end

local function updateOriginalMerchantSlot(buttonIndex, entry)
    local merchantIndex = entry and entry.index
    if not merchantIndex then
        clearOriginalMerchantSlot(buttonIndex)
        return
    end

    local slot = _G["MerchantItem" .. buttonIndex] or _G["MerchantFrameItem" .. buttonIndex]
    if not slot then
        return
    end

    local info = getMerchantInfo(merchantIndex)
    if not info or not info.name then
        clearOriginalMerchantSlot(buttonIndex)
        return
    end

    local link = entry.link or getMerchantItemLink(merchantIndex)
    local itemName = _G["MerchantItem" .. buttonIndex .. "Name"] or slot.Name
    local itemButton = _G["MerchantItem" .. buttonIndex .. "ItemButton"] or slot.ItemButton
    local moneyFrame = _G["MerchantItem" .. buttonIndex .. "MoneyFrame"]
    local altCurrency = _G["MerchantItem" .. buttonIndex .. "AltCurrencyFrame"]

    slot:Show()
    slot:SetAlpha(1)
    if slot.EnableMouse then slot:EnableMouse(true) end
    if slot.SetID then slot:SetID(merchantIndex) end
    slot.itemIndex = merchantIndex
    slot.index = merchantIndex

    if itemName then itemName:SetText(info.name or "") end
    if itemButton then
        if itemButton.SetID then itemButton:SetID(merchantIndex) end
        itemButton.link = link
        itemButton.texture = info.texture
        if type(SetItemButtonTexture) == "function" then
            SetItemButtonTexture(itemButton, info.texture)
        elseif itemButton.Icon then
            itemButton.Icon:SetTexture(info.texture)
        end
        if itemButton.IconQuestTexture then itemButton.IconQuestTexture:Hide() end
        if type(SetItemButtonDesaturated) == "function" then
            SetItemButtonDesaturated(itemButton, false)
        end
        itemButton:Show()
    end

    if type(MerchantFrameItem_UpdateQuality) == "function" then
        pcall(MerchantFrameItem_UpdateQuality, slot, link)
    end

    hideOriginalSlotCostFrames(buttonIndex)
    local costCount = 0
    if type(GetMerchantItemCostInfo) == "function" then
        local okCost, merchantCostCount = pcall(GetMerchantItemCostInfo, merchantIndex)
        costCount = okCost and tonumber(merchantCostCount) or 0
    end
    if costCount > 0 then
        if moneyFrame then moneyFrame:Hide() end
        if type(MerchantFrame_UpdateAltCurrency) == "function" then
            pcall(MerchantFrame_UpdateAltCurrency, merchantIndex, buttonIndex, canAffordMerchantItem(merchantIndex, info))
        end
        if altCurrency then altCurrency:Show() end
        refreshOriginalSlotCostFrameChildren(buttonIndex, merchantIndex)
    elseif tonumber(info.price) and info.price > 0 and moneyFrame and type(MoneyFrame_Update) == "function" then
        MoneyFrame_Update("MerchantItem" .. buttonIndex .. "MoneyFrame", info.price)
        moneyFrame:Show()
    end

    if not info.isPurchasable or not info.isUsable then
        setOriginalSlotVertexColor(slot, itemButton, 0.9, 0, 0)
    else
        setOriginalSlotVertexColor(slot, itemButton, 1.0, 1.0, 1.0)
    end
end

local function updateOriginalMerchantPaging(totalFiltered, perPage)
    if not MerchantFrame then
        return 1
    end
    local totalPages = math.max(1, math.ceil((tonumber(totalFiltered) or 0) / math.max(1, perPage)))
    local page = math.max(1, tonumber(MerchantFrame.page) or 1)
    if page > totalPages then
        page = totalPages
        MerchantFrame.page = page
    end
    if totalFiltered <= perPage then
        if MerchantPageText then MerchantPageText:Hide() end
        if MerchantPrevPageButton then MerchantPrevPageButton:Hide() end
        if MerchantNextPageButton then MerchantNextPageButton:Hide() end
    else
        if MerchantPageText then
            MerchantPageText:SetFormattedText(MERCHANT_PAGE_NUMBER or "%d / %d", page, totalPages)
            MerchantPageText:Show()
        end
        if MerchantPrevPageButton then
            MerchantPrevPageButton:Show()
            if page <= 1 then MerchantPrevPageButton:Disable() else MerchantPrevPageButton:Enable() end
        end
        if MerchantNextPageButton then
            MerchantNextPageButton:Show()
            if page >= totalPages then MerchantNextPageButton:Disable() else MerchantNextPageButton:Enable() end
        end
    end
    return page
end

function Runtime:ApplyOriginalFrameFilters()
    if getDisplayMode() ~= DISPLAY_MODE_FILTER_ONLY or not merchantFrameOpen() then
        self:RestoreOriginalMerchantItems()
        return
    end
    if MerchantFrame and MerchantFrame.selectedTab and MerchantFrame.selectedTab ~= 1 then
        self:RestoreOriginalMerchantItems()
        return
    end

    local filters = getFilters()
    if getClassFilterMode() == CLASS_FILTER_ALL and not hasActiveFilters(filters) then
        self:RestoreOriginalMerchantItems()
        return
    end

    local perPage = tonumber(MERCHANT_ITEMS_PER_PAGE) or 12
    local page = updateOriginalMerchantPaging(#(self.filtered or {}), perPage)
    local offset = (page - 1) * perPage
    for buttonIndex = 1, perPage do
        updateOriginalMerchantSlot(buttonIndex, self.filtered[offset + buttonIndex])
    end
    self.originalFilterApplied = true
end

function Runtime:PositionFloatingButton()
    local button = self.floatingButton
    if not button then return end
    button:ClearAllPoints()
    if MerchantFrame and MerchantFrame.GetRight and MerchantFrame:GetRight() then
        button:SetPoint("TOPLEFT", MerchantFrame, "TOPRIGHT", 8, -42)
        if MerchantFrame.GetFrameLevel and button.SetFrameLevel then
            button:SetFrameLevel((MerchantFrame:GetFrameLevel() or 1) + 20)
        end
    else
        button:SetPoint("CENTER", UIParent, "CENTER", 340, 120)
    end
end

function Runtime:EnsureFloatingButton()
    if self.floatingButton or type(CreateFrame) ~= "function" then
        return
    end

    local button = createButton(UIParent, "Vaultloom", 104)
    button:SetFrameStrata("DIALOG")
    button:SetClampedToScreen(true)
    button:SetScript("OnClick", function()
        if getDisplayMode() == DISPLAY_MODE_FILTER_ONLY then
            Runtime:ToggleFloatingFilterPanel()
        else
            Runtime:Show()
        end
    end)
    button:Hide()
    self.floatingButton = button
end

function Runtime:EnsureFloatingFilterPanel()
    if self.floatingFilterPanel or type(CreateFrame) ~= "function" then
        return
    end

    local panel = CreateFrame("Frame", "VaultloomMerchantFilterOnlyPanel", UIParent, "BackdropTemplate")
    panel:SetSize(430, 430)
    panel:SetFrameStrata("DIALOG")
    panel:SetFrameLevel(80)
    panel:SetClampedToScreen(true)
    setBackdrop(panel, { 0.035, 0.031, 0.026 }, THEME.borderBright, 0.98)
    makeBackdropFillTransparent(panel, 0)
    panel.bg = panel:CreateTexture(nil, "BORDER", nil, -8)
    panel.bg:SetAllPoints()
    panel.bg:SetTexture(MERCHANT_WINDOW_BG)
    panel.bg:SetTexCoord(0.16, 0.84, 0.18, 0.78)
    panel.bg:SetAlpha(1)
    panel.shade = panel:CreateTexture(nil, "BORDER", nil, -7)
    panel.shade:SetAllPoints()
    panel.shade:SetColorTexture(0, 0, 0, 0.08)
    panel:Hide()

    panel.title = createLabel(panel, "GameFontNormal", "LEFT")
    panel.title:SetPoint("TOPLEFT", 14, -12)
    panel.title:SetText(L.FEATURE_MERCHANT_FILTERS_TITLE or "Händlerfilter")
    panel.title:SetTextColor(1, 0.84, 0.18, 1)

    panel.summary = createLabel(panel, "GameFontDisableSmall", "LEFT")
    panel.summary:SetPoint("TOPLEFT", panel.title, "BOTTOMLEFT", 0, -4)
    panel.summary:SetPoint("RIGHT", -14, 0)

    panel.scroll = CreateFrame("ScrollFrame", nil, panel)
    panel.scroll:SetPoint("TOPLEFT", panel.summary, "BOTTOMLEFT", 0, -12)
    panel.scroll:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -32, 52)

    panel.scrollChild = CreateFrame("Frame", nil, panel.scroll)
    panel.scrollChild:SetSize(360, 10)
    panel.scroll:SetScrollChild(panel.scrollChild)

    panel.slider = CreateFrame("Slider", nil, panel, "BackdropTemplate")
    panel.slider:SetOrientation("VERTICAL")
    panel.slider:SetPoint("TOPRIGHT", -14, -62)
    panel.slider:SetPoint("BOTTOMRIGHT", -14, 54)
    panel.slider:SetWidth(8)
    styleVaultloomSlider(panel.slider, 32)
    panel.slider:SetScript("OnValueChanged", function(slider, value)
        if panel.scroll then
            panel.scroll:SetVerticalScroll(value or 0)
        end
    end)

    panel.scroll:SetScript("OnMouseWheel", function(scroll, delta)
        local current = scroll:GetVerticalScroll() or 0
        local maxScroll = scroll:GetVerticalScrollRange() or 0
        local nextValue = current - (delta * 30)
        if nextValue < 0 then nextValue = 0 end
        if nextValue > maxScroll then nextValue = maxScroll end
        scroll:SetVerticalScroll(nextValue)
        if panel.slider then panel.slider:SetValue(nextValue) end
    end)

    panel.rows = {}
    local filterOffset = 0
    for _, rowInfo in ipairs(FILTER_ROWS) do
        if rowInfo.divider then
            local divider = panel.scrollChild:CreateTexture(nil, "ARTWORK")
            divider:SetPoint("TOPLEFT", panel.scrollChild, "TOPLEFT", 0, -filterOffset - 7)
            divider:SetPoint("RIGHT", panel.scrollChild, "RIGHT", -4, 0)
            divider:SetHeight(1)
            setColorTexture(divider, THEME.border, 0.55)
            filterOffset = filterOffset + 18
        else
            local row = createCheckbox(panel.scrollChild, rowInfo.label())
            row:SetSize(360, 24)
            row:SetPoint("TOPLEFT", panel.scrollChild, "TOPLEFT", 0, -filterOffset)
            row.key = rowInfo.key
            row:SetScript("OnClick", function(selfRow)
                setFilterValue(selfRow.key, not selfRow:GetChecked())
                Runtime:RefreshPassive()
            end)
            panel.rows[#panel.rows + 1] = row
            filterOffset = filterOffset + 30
        end
    end
    panel.scrollChild:SetHeight(math.max(10, filterOffset))

    panel.clearButton = createButton(panel, L.FEATURE_MERCHANT_FILTERS_CLEAR or "Filter aus", 112)
    panel.clearButton:SetPoint("BOTTOMLEFT", 14, 12)
    panel.clearButton:SetScript("OnClick", function()
        clearFilters()
        Runtime:RefreshPassive()
    end)

    panel.classButton = createButton(panel, getClassFilterLabel(), 142)
    panel.classButton:SetPoint("LEFT", panel.clearButton, "RIGHT", 8, 0)
    panel.classButton:SetScript("OnClick", function()
        setClassFilterMode(nextClassFilterMode())
        Runtime:RefreshPassive()
    end)

    panel.closeButton = createButton(panel, L.FEATURE_MERCHANT_FILTERS_BACK or "Zurück", 104)
    panel.closeButton:SetPoint("BOTTOMRIGHT", -14, 12)
    panel.closeButton:SetScript("OnClick", function()
        panel:Hide()
    end)

    self.floatingFilterPanel = panel
end

function Runtime:PositionFloatingFilterPanel()
    local panel = self.floatingFilterPanel
    if not panel then return end
    panel:ClearAllPoints()
    if self.floatingButton and self.floatingButton:IsShown() then
        panel:SetPoint("TOPLEFT", self.floatingButton, "TOPRIGHT", 8, 0)
    elseif MerchantFrame and MerchantFrame.GetRight and MerchantFrame:GetRight() then
        panel:SetPoint("TOPLEFT", MerchantFrame, "TOPRIGHT", 8, -80)
    else
        panel:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end
end

function Runtime:RefreshFloatingFilterPanel()
    local panel = self.floatingFilterPanel
    if not panel then return end
    local filters = getFilters()
    for _, row in ipairs(panel.rows or {}) do
        row:SetChecked(filters[row.key] == true)
    end
    if panel.summary then
        panel.summary:SetText(string.format(
            L.FEATURE_MERCHANT_FILTERS_SUMMARY_FORMAT or "%d sichtbar / %d gescannt",
            #self.filtered,
            #self.items
        ))
    end
    if panel.classButton then
        panel.classButton:SetButtonText(getClassFilterLabel())
    end
    if panel.scrollChild and panel.scroll and panel.slider then
        panel.scrollChild:SetWidth(math.max(260, (panel.scroll:GetWidth() or 360) - 2))
        local maxScroll = math.max(0, (panel.scrollChild:GetHeight() or 0) - (panel.scroll:GetHeight() or 1))
        panel.slider:SetMinMaxValues(0, maxScroll)
        panel.slider:SetValueStep(30)
        panel.slider:SetShown(maxScroll > 0)
        if (panel.scroll:GetVerticalScroll() or 0) > maxScroll then
            panel.scroll:SetVerticalScroll(maxScroll)
            panel.slider:SetValue(maxScroll)
        end
    end
end

function Runtime:ToggleFloatingFilterPanel()
    self:EnsureFloatingButton()
    self:EnsureFloatingFilterPanel()
    if not self.floatingFilterPanel then return end
    if self.floatingFilterPanel:IsShown() then
        self.floatingFilterPanel:Hide()
        return
    end
    self:PositionFloatingFilterPanel()
    self.floatingFilterPanel:Show()
    self:RefreshPassive()
end

function Runtime:ShowFloatingButton(mode)
    self:EnsureFloatingButton()
    if not self.floatingButton then return end
    self.floatingButton:SetButtonText(mode == DISPLAY_MODE_FILTER_ONLY and (L.FEATURE_MERCHANT_FILTERS_BUTTON or "Filter") or "Vaultloom")
    self:PositionFloatingButton()
    self.floatingButton:Show()
end

function Runtime:HideFloatingButton()
    if self.floatingButton then
        self.floatingButton:Hide()
    end
    if self.floatingFilterPanel then
        self.floatingFilterPanel:Hide()
    end
end

function Runtime:RefreshPassive()
    if not self.enabled or not merchantFrameOpen() then
        return
    end
    self:ScanMerchant()
    self:ApplyOriginalFrameFilters()
    self:RefreshFloatingFilterPanel()
    if self.floatingButton and self.floatingButton:IsShown() and getDisplayMode() == DISPLAY_MODE_FILTER_ONLY then
        self.floatingButton:SetButtonText(hasActiveFilters(getFilters()) and (L.FEATURE_MERCHANT_FILTERS_BUTTON_ACTIVE or "Filter *") or (L.FEATURE_MERCHANT_FILTERS_BUTTON or "Filter"))
    end
end

function Runtime:HandleMerchantShow()
    applySavedFiltersForCurrentMerchant()
    self.activeTab = "buy"

    local mode = getDisplayMode()
    if mode == DISPLAY_MODE_REPLACE then
        self:HideFloatingButton()
        self:Show()
        return
    end

    if self.frame then
        self.frame:Hide()
        if self.frame.filterPanel then self.frame.filterPanel:Hide() end
    end
    restoreMerchantFrame()
    self:ShowFloatingButton(mode)
    self:RefreshPassive()
end

function Runtime:ApplyDisplayMode()
    self:Hide()
    self:RestoreOriginalMerchantItems()
    if self.enabled and merchantFrameOpen() then
        self:HandleMerchantShow()
    end
end

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

    applySavedFiltersForCurrentMerchant()
    self:HideFloatingButton()
    concealMerchantFrame()
    applyMerchantWindowScale(self.frame)

    self.frame:ClearAllPoints()
    if MerchantFrame and MerchantFrame.GetLeft and MerchantFrame:GetLeft() then
        self.frame:SetPoint("TOPLEFT", MerchantFrame, "TOPLEFT", -16, 18)
    else
        self.frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end
    self.frame:Show()
    self.activeTab = "buy"
    self:Refresh()
end

function Runtime:Hide()
    if self.frame then
        self.frame:Hide()
        if self.frame.filterPanel then self.frame.filterPanel:Hide() end
    end
    self:HideFloatingButton()
    self:RestoreOriginalMerchantItems()
    self.sellingJunk = false
    self.junkSellToken = self.junkSellToken + 1
    restoreMerchantFrame()
end

function Runtime:ScheduleRefresh(delay)
    if not self.enabled then return end
    self.refreshToken = self.refreshToken + 1
    local token = self.refreshToken
    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(delay or 0.08, function()
            if Runtime.enabled and Runtime.refreshToken == token then
                if Runtime.frame and Runtime.frame:IsShown() then
                    Runtime:Refresh()
                else
                    Runtime:RefreshPassive()
                end
            end
        end)
    else
        if self.frame and self.frame:IsShown() then
            self:Refresh()
        else
            self:RefreshPassive()
        end
    end
end

function Runtime:Hook()
    if self.hooked then return end
    self.hooked = true
    if type(hooksecurefunc) == "function" and type(MerchantFrame_Update) == "function" then
        hooksecurefunc("MerchantFrame_Update", function()
            if Runtime.restoringOriginal then
                return
            end
            if Runtime.enabled and Runtime.frame and Runtime.frame:IsShown() then
                Runtime:ScheduleRefresh(0.04)
            elseif Runtime.enabled and getDisplayMode() == DISPLAY_MODE_FILTER_ONLY then
                Runtime:ScheduleRefresh(0.04)
            end
        end)
    end
end

function Runtime:Enable()
    self.enabled = true
    self:Hook()
    if self.eventFrame == nil and type(CreateFrame) == "function" then
        self.eventFrame = CreateFrame("Frame")
        self.eventFrame:SetScript("OnEvent", function(_, event)
            if event == "MERCHANT_SHOW" then
                Runtime:HandleMerchantShow()
            elseif event == "MERCHANT_CLOSED" then
                Runtime:Hide()
            else
                Runtime:ScheduleRefresh(event == "MERCHANT_UPDATE" and 0.06 or 0.12)
            end
        end)
    end
    if self.eventFrame then
        self.eventFrame:RegisterEvent("MERCHANT_SHOW")
        self.eventFrame:RegisterEvent("MERCHANT_UPDATE")
        self.eventFrame:RegisterEvent("MERCHANT_CLOSED")
        self.eventFrame:RegisterEvent("PLAYER_MONEY")
        self.eventFrame:RegisterEvent("BAG_UPDATE_DELAYED")
    end
    if MerchantFrame and MerchantFrame.IsShown and MerchantFrame:IsShown() then
        self:HandleMerchantShow()
    end
end

function Runtime:Disable()
    self.enabled = false
    if self.eventFrame then
        self.eventFrame:UnregisterEvent("MERCHANT_SHOW")
        self.eventFrame:UnregisterEvent("MERCHANT_UPDATE")
        self.eventFrame:UnregisterEvent("MERCHANT_CLOSED")
        self.eventFrame:UnregisterEvent("PLAYER_MONEY")
        self.eventFrame:UnregisterEvent("BAG_UPDATE_DELAYED")
    end
    self:Hide()
end

if ns.RegisterFeatureDefinition then
    ns:RegisterFeatureDefinition(FEATURE_ID, {
        id = FEATURE_ID,
        category = "blizzard",
        order = 75,
        title = function() return L.FEATURE_MERCHANT_FILTERS_TITLE or "Händlerfilter" end,
        description = function() return L.FEATURE_MERCHANT_FILTERS_DESCRIPTION or "Ersetzt das Händlerfenster durch ein sauberes Vaultloom-Fenster mit Filtern." end,
        note = function() return L.FEATURE_MERCHANT_FILTERS_NOTE or "Startet ungefiltert. Filter können pro Händler oder global gespeichert werden." end,
        previewLabel = function() return L.FEATURE_MERCHANT_FILTERS_PREVIEW_LABEL or "Händlerfenster" end,
        previewNote = function() return L.FEATURE_MERCHANT_FILTERS_PREVIEW_NOTE or "Kaufen, Rückkauf, Repair, Gold, Währungen und Filter in Vaultloom-Optik." end,
        optionToggle = {
            key = "display_mode",
            label = function() return L.FEATURE_MERCHANT_FILTERS_MODE_LABEL or "Modus" end,
            values = { DISPLAY_MODE_REPLACE, DISPLAY_MODE_BUTTON, DISPLAY_MODE_FILTER_ONLY },
            defaultValue = DISPLAY_MODE_REPLACE,
            valueLabels = {
                replace = function() return L.FEATURE_MERCHANT_FILTERS_MODE_REPLACE or "Ersetzen" end,
                button = function() return L.FEATURE_MERCHANT_FILTERS_MODE_BUTTON or "Button" end,
                filter_only = function() return L.FEATURE_MERCHANT_FILTERS_MODE_FILTER_ONLY or "Nur Filter" end,
            },
        },
        apply = function(enabled)
            if enabled then Runtime:Enable() else Runtime:Disable() end
        end,
        onOptionChanged = function()
            Runtime:ApplyDisplayMode()
        end,
    })
end
