local _, addonTable = ...
local WipedQoL = addonTable.WipedQoL
local TRANSLATE = addonTable.TRANSLATE or function(msg) return msg end

local PANEL_WIDTH = 390
local PANEL_HEIGHT = 500
local ROW_HEIGHT = 50
local ROW_SPACING = 4
local BUYBACK_SLOT_COUNT = 12
local SALE_DELAY = 0.12
local CONFIRM_DIALOG = "WIPEDQOL_EXPANSION_VENDOR_CONFIRM"

local function GetUI()
    return addonTable.UI
end

local function ApplyFont(fontString, size, flags)
    if addonTable.ApplyFont then
        addonTable.ApplyFont(fontString, size, flags)
    end
end

local function SkinFrame(frame, options)
    local helpers = GetUI()
    if helpers and helpers.SkinFrame then
        helpers.SkinFrame(frame, options)
    elseif addonTable.SkinFrame then
        addonTable.SkinFrame(frame, options)
    end
end

local function SkinButton(button)
    local helpers = GetUI()
    if helpers and helpers.SkinButton then
        helpers.SkinButton(button)
    elseif addonTable.SkinButton then
        addonTable.SkinButton(button)
    end
end

local function CreateLabel(parent, text, size)
    local helpers = GetUI()
    if helpers and helpers.CreateLabel then
        return helpers.CreateLabel(parent, text, size)
    end

    local label = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    label:SetText(text or "")
    label:SetJustifyH("LEFT")
    ApplyFont(label, size or 12, "")
    return label
end

local function GetAddonColor()
    local helpers = GetUI()
    if helpers and helpers.GetClassColor then
        return helpers.GetClassColor()
    end
    if addonTable.GetAddonColor then
        return addonTable.GetAddonColor()
    end
    return 1, 0.82, 0
end

local function FormatMoney(copper)
    copper = math.max(tonumber(copper) or 0, 0)
    if C_CurrencyInfo and C_CurrencyInfo.GetCoinTextureString then
        return C_CurrencyInfo.GetCoinTextureString(copper)
    end
    if GetMoneyString then
        return GetMoneyString(copper, true)
    end
    return tostring(copper)
end

local function RequestItemData(itemID)
    if C_Item and C_Item.RequestLoadItemDataByID then
        C_Item.RequestLoadItemDataByID(itemID)
    end
end

local function GetItemInfo(itemInfo)
    if C_Item and C_Item.GetItemInfo then
        return C_Item.GetItemInfo(itemInfo)
    end
    if _G.GetItemInfo then
        return _G.GetItemInfo(itemInfo)
    end
end

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

    if type(value) ~= "string" then
        return nil
    end

    local itemID = value:match("|Hitem:(%d+)")
        or value:match("item:(%d+)")
        or value:match("^%s*(%d+)%s*$")
    itemID = tonumber(itemID)
    if not itemID or itemID <= 0 then
        return nil
    end
    return math.floor(itemID)
end

local function GetCurrentExpansionID()
    local expansionID = tonumber(LE_EXPANSION_LEVEL_CURRENT)
    if expansionID then
        return expansionID
    end
    if GetExpansionLevel then
        return tonumber(GetExpansionLevel())
    end
    return nil
end

local function GetExpansionName(expansionID)
    local name = _G["EXPANSION_NAME" .. tostring(expansionID)]
    if type(name) == "string" and name ~= "" then
        return name
    end
    return string.format(TRANSLATE("Expansion %d"), expansionID)
end

local function SafeTrue(func, ...)
    if type(func) ~= "function" then
        return false
    end
    local ok, result = pcall(func, ...)
    return ok and result == true
end

local function SafeFirstResult(func, ...)
    if type(func) ~= "function" then
        return nil
    end
    local ok, result = pcall(func, ...)
    if ok then
        return result
    end
    return nil
end

local function IsExpansionVendorEncounterInProgress()
    if C_InstanceEncounter and C_InstanceEncounter.IsEncounterInProgress then
        return SafeTrue(C_InstanceEncounter.IsEncounterInProgress)
    end
    return SafeTrue(IsEncounterInProgress)
end

local function GetDisplayItemLevel(itemLink, itemEquipLoc, fallbackItemLevel)
    if type(itemEquipLoc) ~= "string" or itemEquipLoc == "" then
        return nil
    end

    local itemLevel
    if C_Item and C_Item.GetDetailedItemLevelInfo then
        itemLevel = tonumber(SafeFirstResult(
            C_Item.GetDetailedItemLevelInfo,
            itemLink
        ))
    end
    itemLevel = itemLevel or tonumber(fallbackItemLevel)
    if not itemLevel or itemLevel <= 0 then
        return nil
    end
    return math.floor(itemLevel + 0.5)
end

local function GetPlayerBagIDs()
    local bags = {}
    local seen = {}

    local function AddBag(bagID)
        bagID = tonumber(bagID)
        if bagID and not seen[bagID] then
            seen[bagID] = true
            bags[#bags + 1] = bagID
        end
    end

    AddBag(BACKPACK_CONTAINER or 0)
    for bagID = 1, tonumber(NUM_BAG_SLOTS) or 4 do
        AddBag(bagID)
    end
    if Enum and Enum.BagIndex then
        AddBag(Enum.BagIndex.ReagentBag)
    end

    table.sort(bags)
    return bags
end

local function GetItemLocation(bagID, slotID)
    if ItemLocation and ItemLocation.CreateFromBagAndSlot then
        return ItemLocation:CreateFromBagAndSlot(bagID, slotID)
    end
    return nil
end

local function GetItemGUID(bagID, slotID)
    local location = GetItemLocation(bagID, slotID)
    if not location or not C_Item or not C_Item.GetItemGUID then
        return nil
    end

    local ok, guid = pcall(C_Item.GetItemGUID, location)
    if ok then
        return guid
    end
    return nil
end

local function BuildEquipmentSetLookup()
    local lookup = {
        slots = {},
        itemIDs = {},
    }
    if not C_EquipmentSet or not C_EquipmentSet.GetEquipmentSetIDs then
        return lookup
    end

    local setIDs = SafeFirstResult(C_EquipmentSet.GetEquipmentSetIDs)
    if type(setIDs) ~= "table" then
        return lookup
    end

    for _, setID in ipairs(setIDs) do
        if C_EquipmentSet.GetItemLocations and EquipmentManager_GetLocationData then
            local locations = SafeFirstResult(C_EquipmentSet.GetItemLocations, setID)
            if type(locations) == "table" then
                for _, packedLocation in pairs(locations) do
                    if type(packedLocation) == "number"
                        and packedLocation ~= 0
                        and packedLocation ~= 1
                        and packedLocation ~= -1
                    then
                        local ok, locationData = pcall(
                            EquipmentManager_GetLocationData,
                            packedLocation
                        )
                        if ok and locationData and locationData.isBags then
                            local bagID = tonumber(locationData.bag)
                            local slotID = tonumber(locationData.slot)
                            if bagID and slotID then
                                lookup.slots[bagID .. ":" .. slotID] = true
                            end
                        end
                    end
                end
            end
        end

        if C_EquipmentSet.GetItemIDs then
            local itemIDs = SafeFirstResult(C_EquipmentSet.GetItemIDs, setID)
            if type(itemIDs) == "table" then
                for _, setItemID in pairs(itemIDs) do
                    setItemID = tonumber(setItemID)
                    if setItemID and setItemID > 0 then
                        lookup.itemIDs[setItemID] = true
                    end
                end
            end
        end
    end

    return lookup
end

local function IsEquipmentSetItem(lookup, bagID, slotID, itemID)
    if type(lookup) ~= "table" then
        return false
    end
    if lookup.slots[bagID .. ":" .. slotID] == true then
        return true
    end

    -- The container API identifies the concrete bag item. Falling back to the
    -- item ID would also protect different difficulty or Timewalking variants
    -- of the same item that are not assigned to an equipment set.
    if C_Container and C_Container.GetContainerItemEquipmentSetInfo then
        return false
    end
    return lookup.itemIDs[tonumber(itemID)] == true
end

local function HasMythicPlusCreationContext(itemLink, itemEquipLoc)
    if type(itemEquipLoc) ~= "string" or itemEquipLoc == "" then
        return false
    end

    if C_Item and C_Item.GetItemCreationContext then
        local ok, _, creationContext = pcall(C_Item.GetItemCreationContext, itemLink)
        if ok and type(creationContext) == "string" then
            creationContext = creationContext:lower()
            if creationContext:find("challenge", 1, true) then
                return true
            end
        end
    end

    return false
end

local function GetCurrentMythicPlusMinimumItemLevel()
    if not C_MythicPlus or not C_MythicPlus.GetRewardLevelForDifficultyLevel then
        return nil
    end

    local ok, _, endOfRunItemLevel = pcall(
        C_MythicPlus.GetRewardLevelForDifficultyLevel,
        2
    )
    endOfRunItemLevel = ok and tonumber(endOfRunItemLevel) or nil
    if endOfRunItemLevel and endOfRunItemLevel > 0 then
        return endOfRunItemLevel
    end
    return nil
end

local function GetItemUpgradeInfo(location)
    if not location
        or not C_ItemUpgrade
        or not C_ItemUpgrade.CanUpgradeItem
        or not C_ItemUpgrade.SetItemUpgradeFromLocation
        or not C_ItemUpgrade.GetItemUpgradeItemInfo
        or not C_ItemUpgrade.ClearItemUpgrade
        or not SafeTrue(C_ItemUpgrade.CanUpgradeItem, location)
    then
        return nil
    end

    pcall(C_ItemUpgrade.ClearItemUpgrade)
    local selected = pcall(C_ItemUpgrade.SetItemUpgradeFromLocation, location)
    local info = selected and SafeFirstResult(C_ItemUpgrade.GetItemUpgradeItemInfo) or nil
    pcall(C_ItemUpgrade.ClearItemUpgrade)
    return type(info) == "table" and info or nil
end

local function HasCurrentSeasonUpgradeTrack(location)
    local info = GetItemUpgradeInfo(location)
    if not info then
        return nil
    end

    local seasonalCosts = info.upgradeCostTypesForSeason
    if type(seasonalCosts) == "table" then
        return next(seasonalCosts) ~= nil
    end
    return nil
end

local function IsCurrentSeasonMythicPlusEquipment(itemLink, itemEquipLoc, location)
    if not HasMythicPlusCreationContext(itemLink, itemEquipLoc) then
        return false
    end

    local hasCurrentTrack = HasCurrentSeasonUpgradeTrack(location)
    if hasCurrentTrack ~= nil then
        return hasCurrentTrack
    end

    local minimumItemLevel = GetCurrentMythicPlusMinimumItemLevel()
    local actualItemLevel
    if C_Item and C_Item.GetDetailedItemLevelInfo then
        actualItemLevel = tonumber(SafeFirstResult(
            C_Item.GetDetailedItemLevelInfo,
            itemLink
        ))
    end
    if minimumItemLevel and actualItemLevel then
        return actualItemLevel >= minimumItemLevel
    end

    return true
end

local function IsProtectedItemClass(itemID, itemLink, classID)
    local itemClasses = Enum and Enum.ItemClass
    if itemClasses then
        if classID == itemClasses.Container
            or classID == itemClasses.Key
            or classID == itemClasses.Recipe
            or classID == itemClasses.Battlepet
        then
            return true
        end
    end

    if C_Item then
        if SafeTrue(C_Item.IsItemKeystoneByID, itemID)
            or SafeTrue(C_Item.IsCosmeticItem, itemLink)
            or SafeTrue(C_Item.IsDecorItem, itemLink)
        then
            return true
        end
    end

    if C_Heirloom and SafeTrue(C_Heirloom.IsItemHeirloom, itemID) then
        return true
    end
    if C_ToyBox and SafeFirstResult(C_ToyBox.GetToyInfo, itemID) then
        return true
    end
    if C_MountJournal and SafeFirstResult(C_MountJournal.GetMountFromItem, itemID) then
        return true
    end
    if C_PetJournal and SafeFirstResult(C_PetJournal.GetPetInfoByItemID, itemID) then
        return true
    end

    return false
end

local function IsUnboundBindOnEquipItem(bindType, location)
    local bindOnEquip = LE_ITEM_BIND_ON_EQUIP
        or (Enum and Enum.ItemBind and Enum.ItemBind.OnEquip)
        or 2
    if tonumber(bindType) ~= tonumber(bindOnEquip) then
        return false
    end

    if location and C_Item and C_Item.IsBound then
        return not SafeTrue(C_Item.IsBound, location)
    end
    return true
end

function WipedQoL:GetExpansionVendorSettings()
    local profile = self.db and self.db.profile
    if type(profile) ~= "table" then
        return {
            enabled = true,
            anchorSide = "LEFT",
        }
    end

    profile.modules = profile.modules or {}
    profile.modules.expansionVendor = profile.modules.expansionVendor or {
        enabled = true,
        anchorSide = "LEFT",
    }
    if profile.modules.expansionVendor.anchorSide ~= "RIGHT" then
        profile.modules.expansionVendor.anchorSide = "LEFT"
    end
    return profile.modules.expansionVendor
end

function WipedQoL:GetExpansionVendorBlacklist()
    local global = self.db and self.db.global
    if type(global) ~= "table" then
        return {}
    end

    global.expansionVendorBlacklist = global.expansionVendorBlacklist or {}
    return global.expansionVendorBlacklist
end

function WipedQoL:IsExpansionVendorBlacklisted(itemID)
    return self:GetExpansionVendorBlacklist()[tonumber(itemID)] == true
end

function WipedQoL:SetExpansionVendorEnabled(enabled)
    local settings = self:GetExpansionVendorSettings()
    settings.enabled = enabled == true

    if not settings.enabled then
        self:AbortExpansionVendorSale(nil, false)
        if self.expansionVendorFrame then
            self.expansionVendorFrame:Hide()
        end
        return
    end

    if MerchantFrame and MerchantFrame:IsShown() then
        self:HandleExpansionVendorMerchantShow()
    end
end

function WipedQoL:GetExpansionVendorAnchorSide()
    return self:GetExpansionVendorSettings().anchorSide
end

function WipedQoL:SetExpansionVendorAnchorSide(anchorSide)
    local settings = self:GetExpansionVendorSettings()
    settings.anchorSide = anchorSide == "RIGHT" and "RIGHT" or "LEFT"
    if self.expansionVendorFrame and self.expansionVendorFrame:IsShown() then
        self:PositionExpansionVendorFrame()
    end
end

function WipedQoL:SetExpansionVendorStatus(text, isError)
    local frame = self.expansionVendorFrame
    if not frame or not frame.status then
        return
    end

    frame.status:SetText(text or "")
    if isError then
        frame.status:SetTextColor(1, 0.3, 0.3, 1)
    else
        frame.status:SetTextColor(0.62, 0.62, 0.62, 1)
    end
end

function WipedQoL:AddExpansionVendorBlacklistItem(value)
    if self.expansionVendorSale then
        self:SetExpansionVendorStatus(TRANSLATE("Wait until selling is finished."), true)
        return false
    end

    local itemID = ParseItemID(value)
    if not itemID then
        self:SetExpansionVendorStatus(TRANSLATE("Enter a valid item ID."), true)
        return false
    end

    local blacklist = self:GetExpansionVendorBlacklist()
    if blacklist[itemID] then
        self:SetExpansionVendorStatus(TRANSLATE("This item is already protected."), true)
        return false
    end

    blacklist[itemID] = true
    RequestItemData(itemID)
    self:SetExpansionVendorStatus(
        string.format(TRANSLATE("Item %d added to the blacklist."), itemID),
        false
    )
    self:ScanExpansionVendorInventory()
    return true
end

function WipedQoL:RemoveExpansionVendorBlacklistItem(itemID)
    if self.expansionVendorSale then
        self:SetExpansionVendorStatus(TRANSLATE("Wait until selling is finished."), true)
        return
    end

    itemID = tonumber(itemID)
    if not itemID then
        return
    end

    self:GetExpansionVendorBlacklist()[itemID] = nil
    self:SetExpansionVendorStatus(
        string.format(TRANSLATE("Item %d removed from the blacklist."), itemID),
        false
    )
    self:ScanExpansionVendorInventory()
end

function WipedQoL:TryAddExpansionVendorCursorItem()
    if not GetCursorInfo then
        return false
    end

    local cursorType, itemID, itemLink = GetCursorInfo()
    if cursorType ~= "item" then
        self:SetExpansionVendorStatus(TRANSLATE("Drag an item here."), true)
        return false
    end

    local added = self:AddExpansionVendorBlacklistItem(itemID or itemLink)
    if ClearCursor then
        ClearCursor()
    end
    return added
end

function WipedQoL:EvaluateExpansionVendorSlot(bagID, slotID, equipmentSetLookup)
    if not C_Container or not C_Container.GetContainerItemInfo then
        return nil
    end

    local containerInfo = C_Container.GetContainerItemInfo(bagID, slotID)
    if not containerInfo
        or not containerInfo.itemID
        or containerInfo.isLocked
        or containerInfo.hasNoValue
        or containerInfo.hasLoot
    then
        return nil
    end

    local itemID = containerInfo.itemID
    if self:IsExpansionVendorBlacklisted(itemID) then
        return nil
    end

    local itemName, itemLink, quality, itemLevel, _, _, _, _, itemEquipLoc, itemTexture, sellPrice,
        classID, subclassID, bindType, expansionID = GetItemInfo(containerInfo.hyperlink or itemID)
    if not itemName then
        RequestItemData(itemID)
        return nil
    end

    local currentExpansionID = GetCurrentExpansionID()
    expansionID = tonumber(expansionID)
    sellPrice = tonumber(sellPrice) or 0
    if not currentExpansionID
        or not expansionID
        or expansionID >= currentExpansionID
        or sellPrice <= 0
    then
        return nil
    end

    local questInfo
    if C_Container.GetContainerItemQuestInfo then
        questInfo = C_Container.GetContainerItemQuestInfo(bagID, slotID)
    end
    if questInfo and (
        questInfo.isQuestItem
        or (tonumber(questInfo.questID) or 0) > 0
        or questInfo.isActive
    ) then
        return nil
    end

    equipmentSetLookup = equipmentSetLookup or BuildEquipmentSetLookup()
    if IsEquipmentSetItem(equipmentSetLookup, bagID, slotID, itemID) then
        return nil
    end

    if C_Container.GetContainerItemEquipmentSetInfo then
        local inSet = C_Container.GetContainerItemEquipmentSetInfo(bagID, slotID)
        if inSet then
            return nil
        end
    end

    local location = GetItemLocation(bagID, slotID)
    if IsCurrentSeasonMythicPlusEquipment(
        itemLink or containerInfo.hyperlink,
        itemEquipLoc,
        location
    ) then
        return nil
    end

    if location and C_Item then
        if SafeTrue(C_Item.CanBeRefunded, location)
            or SafeTrue(C_Item.IsBoundToAccountUntilEquip, location)
        then
            return nil
        end
    end

    if C_Item and SafeTrue(C_Item.IsItemBindToAccount, itemLink or itemID) then
        return nil
    end
    if IsUnboundBindOnEquipItem(bindType, location) then
        return nil
    end
    if IsProtectedItemClass(itemID, itemLink or containerInfo.hyperlink, classID) then
        return nil
    end

    local stackCount = tonumber(containerInfo.stackCount) or 1
    return {
        bagID = bagID,
        slotID = slotID,
        guid = GetItemGUID(bagID, slotID),
        itemID = itemID,
        name = itemName,
        link = itemLink or containerInfo.hyperlink,
        icon = itemTexture or containerInfo.iconFileID,
        quality = tonumber(quality) or tonumber(containerInfo.quality) or 0,
        itemLevel = GetDisplayItemLevel(
            itemLink or containerInfo.hyperlink,
            itemEquipLoc,
            itemLevel
        ),
        count = stackCount,
        sellPrice = sellPrice,
        value = sellPrice * stackCount,
        expansionID = expansionID,
        expansionName = GetExpansionName(expansionID),
        classID = classID,
        subclassID = subclassID,
        bindType = bindType,
    }
end

function WipedQoL:BuildExpansionVendorSnapshot()
    local candidates = {}
    local groupsByItemID = {}
    local totalItems = 0
    local totalValue = 0
    local highestQuality = 0

    if not C_Container or not C_Container.GetContainerNumSlots then
        return candidates, {}, 0, 0, 0
    end

    local equipmentSetLookup = BuildEquipmentSetLookup()
    for _, bagID in ipairs(GetPlayerBagIDs()) do
        local slotCount = C_Container.GetContainerNumSlots(bagID) or 0
        for slotID = 1, slotCount do
            local candidate = self:EvaluateExpansionVendorSlot(
                bagID,
                slotID,
                equipmentSetLookup
            )
            if candidate then
                candidates[#candidates + 1] = candidate
                totalItems = totalItems + candidate.count
                totalValue = totalValue + candidate.value
                highestQuality = math.max(highestQuality, candidate.quality)

                local group = groupsByItemID[candidate.itemID]
                if not group then
                    group = {
                        itemID = candidate.itemID,
                        name = candidate.name,
                        link = candidate.link,
                        icon = candidate.icon,
                        quality = candidate.quality,
                        itemLevel = candidate.itemLevel,
                        count = 0,
                        stacks = 0,
                        value = 0,
                        expansionID = candidate.expansionID,
                        expansionName = candidate.expansionName,
                    }
                    groupsByItemID[candidate.itemID] = group
                end

                group.count = group.count + candidate.count
                group.stacks = group.stacks + 1
                group.value = group.value + candidate.value
                group.quality = math.max(group.quality, candidate.quality)
                group.itemLevel = math.max(
                    group.itemLevel or 0,
                    candidate.itemLevel or 0
                )
            end
        end
    end

    local groups = {}
    for _, group in pairs(groupsByItemID) do
        groups[#groups + 1] = group
    end
    table.sort(groups, function(left, right)
        if left.expansionID ~= right.expansionID then
            return left.expansionID > right.expansionID
        end
        if left.quality ~= right.quality then
            return left.quality > right.quality
        end
        return tostring(left.name) < tostring(right.name)
    end)

    return candidates, groups, totalItems, totalValue, highestQuality
end

local function SetRowQualityColor(row, quality)
    local color = ITEM_QUALITY_COLORS and ITEM_QUALITY_COLORS[tonumber(quality) or 0]
    if color then
        row.name:SetTextColor(color.r or 1, color.g or 1, color.b or 1, 1)
    else
        row.name:SetTextColor(1, 1, 1, 0.95)
    end
end

function WipedQoL:CreateExpansionVendorRow(parent)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(ROW_HEIGHT)
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    SkinFrame(row, { light = true })

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(38, 38)
    row.icon:SetPoint("LEFT", row, "LEFT", 6, 0)
    row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    row.itemLevel = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.itemLevel:SetPoint("TOPLEFT", row.icon, "TOPLEFT", 2, -2)
    row.itemLevel:SetTextColor(1, 1, 1, 1)
    ApplyFont(row.itemLevel, 9, "OUTLINE")
    row.itemLevel:Hide()

    row.name = CreateLabel(row, "", 11)
    row.name:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 8, -2)
    row.name:SetPoint("RIGHT", row, "RIGHT", -88, 0)
    row.name:SetJustifyH("LEFT")
    row.name:SetWordWrap(false)

    row.details = CreateLabel(row, "", 10)
    row.details:SetPoint("BOTTOMLEFT", row.icon, "BOTTOMRIGHT", 8, 2)
    row.details:SetPoint("RIGHT", row, "RIGHT", -88, 0)
    row.details:SetTextColor(0.58, 0.66, 0.74, 1)
    row.details:SetJustifyH("LEFT")
    row.details:SetWordWrap(false)

    row.action = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.action:SetSize(72, 24)
    row.action:SetPoint("RIGHT", row, "RIGHT", -7, 0)
    SkinButton(row.action)

    row:SetScript("OnEnter", function(self)
        if not self.itemID then
            return
        end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        if self.link and GameTooltip.SetHyperlink then
            GameTooltip:SetHyperlink(self.link)
        elseif GameTooltip.SetItemByID then
            GameTooltip:SetItemByID(self.itemID)
        end
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
    row:SetScript("OnClick", function(self, button)
        if button ~= "RightButton" or not self.itemID then
            return
        end
        if self.mode == "preview" then
            WipedQoL:AddExpansionVendorBlacklistItem(self.itemID)
        else
            WipedQoL:RemoveExpansionVendorBlacklistItem(self.itemID)
        end
    end)

    return row
end

function WipedQoL:GetExpansionVendorBlacklistEntries()
    local entries = {}
    for itemID, protected in pairs(self:GetExpansionVendorBlacklist()) do
        itemID = tonumber(itemID)
        if protected == true and itemID then
            local name, link, quality, _, _, _, _, _, _, texture = GetItemInfo(itemID)
            if not name then
                RequestItemData(itemID)
            end
            entries[#entries + 1] = {
                itemID = itemID,
                name = name or string.format(TRANSLATE("Item %d"), itemID),
                link = link,
                icon = texture or 134400,
                quality = tonumber(quality) or 1,
            }
        end
    end

    table.sort(entries, function(left, right)
        return tostring(left.name) < tostring(right.name)
    end)
    return entries
end

function WipedQoL:RefreshExpansionVendorRows()
    local frame = self.expansionVendorFrame
    if not frame then
        return
    end

    local entries
    if frame.activeTab == "blacklist" then
        entries = self:GetExpansionVendorBlacklistEntries()
    else
        entries = self.expansionVendorGroups or {}
    end

    for index, entry in ipairs(entries) do
        local entryItemID = entry.itemID
        local row = frame.rows[index]
        if not row then
            row = self:CreateExpansionVendorRow(frame.scrollChild)
            frame.rows[index] = row
        end

        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", frame.scrollChild, "TOPLEFT", 0, -((index - 1) * (ROW_HEIGHT + ROW_SPACING)))
        row:SetPoint("RIGHT", frame.scrollChild, "RIGHT", 0, 0)
        row.itemID = entryItemID
        row.link = entry.link
        row.icon:SetTexture(entry.icon or 134400)
        row.name:SetText(entry.name or string.format(TRANSLATE("Item %d"), entryItemID))
        SetRowQualityColor(row, entry.quality)

        if frame.activeTab == "blacklist" then
            row.mode = "blacklist"
            row.itemLevel:Hide()
            row.details:SetText(string.format(TRANSLATE("Item ID: %d"), entryItemID))
            row.action:SetText(TRANSLATE("Remove"))
            row.action:SetScript("OnClick", function()
                WipedQoL:RemoveExpansionVendorBlacklistItem(entryItemID)
            end)
        else
            row.mode = "preview"
            if (tonumber(entry.itemLevel) or 0) > 0 then
                row.itemLevel:SetText(tostring(entry.itemLevel))
                row.itemLevel:Show()
            else
                row.itemLevel:Hide()
            end
            row.details:SetText(string.format(
                TRANSLATE("%s - %d items - %s"),
                entry.expansionName,
                entry.count,
                FormatMoney(entry.value)
            ))
            row.action:SetText(TRANSLATE("Protect"))
            row.action:SetScript("OnClick", function()
                WipedQoL:AddExpansionVendorBlacklistItem(entryItemID)
            end)
        end

        row.action:SetEnabled(self.expansionVendorSale == nil)
        row:Show()
    end

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

    local contentHeight = math.max(
        (#entries * (ROW_HEIGHT + ROW_SPACING)) - ROW_SPACING,
        frame.scroll:GetHeight()
    )
    frame.scrollChild:SetHeight(math.max(contentHeight, 1))
    frame.scroll:SetVerticalScroll(0)

    if frame.activeTab == "blacklist" then
        frame.empty:SetText(TRANSLATE("No protected items yet."))
    else
        frame.empty:SetText(TRANSLATE("No legacy items can be sold safely."))
    end
    frame.empty:SetShown(#entries == 0)
end

function WipedQoL:RefreshExpansionVendorControls()
    local frame = self.expansionVendorFrame
    if not frame then
        return
    end

    local selling = self.expansionVendorSale ~= nil
    local hasCandidates = #(self.expansionVendorCandidates or {}) > 0
    local saleBlocked = self:IsExpansionVendorSaleBlocked()
    frame.sellButton:SetEnabled(not selling and hasCandidates and not saleBlocked)
    frame.refreshButton:SetEnabled(not selling)
    frame.addButton:SetEnabled(not selling)
    frame.itemIDBox:SetEnabled(not selling)
    frame.dropZone:SetEnabled(not selling)

    local r, g, b = GetAddonColor()
    local previewActive = frame.activeTab ~= "blacklist"
    frame.previewTab:GetFontString():SetTextColor(
        previewActive and r or 0.88,
        previewActive and g or 0.88,
        previewActive and b or 0.88,
        1
    )
    frame.blacklistTab:GetFontString():SetTextColor(
        previewActive and 0.88 or r,
        previewActive and 0.88 or g,
        previewActive and 0.88 or b,
        1
    )
end

function WipedQoL:SelectExpansionVendorTab(tab)
    local frame = self.expansionVendorFrame
    if not frame then
        return
    end

    frame.activeTab = tab == "blacklist" and "blacklist" or "preview"
    self:RefreshExpansionVendorRows()
    self:RefreshExpansionVendorControls()
end

function WipedQoL:ScanExpansionVendorInventory()
    local frame = self.expansionVendorFrame
    if not frame or not frame:IsVisible() or self.expansionVendorSale then
        return
    end

    local candidates, groups, totalItems, totalValue, highestQuality =
        self:BuildExpansionVendorSnapshot()
    self.expansionVendorCandidates = candidates
    self.expansionVendorGroups = groups
    self.expansionVendorTotalItems = totalItems
    self.expansionVendorTotalValue = totalValue
    self.expansionVendorHighestQuality = highestQuality

    frame.summary:SetText(string.format(
        TRANSLATE("%d stacks - %d items - %s"),
        #candidates,
        totalItems,
        FormatMoney(totalValue)
    ))
    self:RefreshExpansionVendorRows()
    self:RefreshExpansionVendorControls()
end

function WipedQoL:ScheduleExpansionVendorScan()
    if self.expansionVendorSale then
        return
    end

    self.expansionVendorScanToken = (self.expansionVendorScanToken or 0) + 1
    local token = self.expansionVendorScanToken
    C_Timer.After(0.12, function()
        if WipedQoL.expansionVendorScanToken == token then
            WipedQoL:ScanExpansionVendorInventory()
        end
    end)
end

function WipedQoL:PositionExpansionVendorFrame()
    local frame = self.expansionVendorFrame
    if not frame or not MerchantFrame then
        return
    end

    frame:ClearAllPoints()
    local merchantLeft = MerchantFrame:GetLeft()
    local merchantRight = MerchantFrame:GetRight()
    local screenLeft = UIParent:GetLeft() or 0
    local screenRight = UIParent:GetRight() or UIParent:GetWidth()
    local leftFits = merchantLeft
        and merchantLeft - PANEL_WIDTH - 8 >= screenLeft
    local rightFits = merchantRight
        and screenRight
        and merchantRight + PANEL_WIDTH + 8 <= screenRight
    local preferLeft = self:GetExpansionVendorAnchorSide() == "LEFT"
    local useLeft
    if preferLeft then
        useLeft = leftFits or not rightFits
    else
        useLeft = not rightFits and leftFits
    end

    if useLeft then
        frame:SetPoint("TOPRIGHT", MerchantFrame, "TOPLEFT", -6, 0)
    else
        frame:SetPoint("TOPLEFT", MerchantFrame, "TOPRIGHT", 6, 0)
    end
end

local function CreateTabButton(parent, text)
    local button = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    button:SetSize(112, 24)
    button:SetText(text)
    SkinButton(button)
    return button
end

function WipedQoL:CreateExpansionVendorFrame()
    if self.expansionVendorFrame then
        return self.expansionVendorFrame
    end
    if not MerchantFrame then
        return nil
    end

    local frame = CreateFrame("Frame", "WipedQoLExpansionVendorFrame", MerchantFrame, "BackdropTemplate")
    frame:SetSize(PANEL_WIDTH, PANEL_HEIGHT)
    frame:SetFrameStrata("HIGH")
    frame:SetFrameLevel(MerchantFrame:GetFrameLevel() + 20)
    frame:SetClampedToScreen(true)
    frame:EnableMouse(true)
    frame.rows = {}
    frame.activeTab = "preview"
    SkinFrame(frame)

    if self.CreateTopAccentBar then
        self:CreateTopAccentBar(frame)
    end

    frame.title = CreateLabel(frame, TRANSLATE("Sell legacy items"), 14)
    frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -12)
    frame.title:SetTextColor(1, 1, 1, 0.96)

    frame.helpButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    frame.helpButton:SetSize(22, 22)
    frame.helpButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -10, -8)
    frame.helpButton:SetText("?")
    SkinButton(frame.helpButton)
    ApplyFont(frame.helpButton:GetFontString(), 13, "OUTLINE")
    frame.helpButton:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(TRANSLATE("Automatic selling rules"))
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(TRANSLATE("Items considered"), 0.35, 1, 0.45)
        GameTooltip:AddLine(
            "- " .. TRANSLATE("Items from previous expansions with a vendor sell price."),
            0.92,
            0.92,
            0.92,
            true
        )
        GameTooltip:AddLine(
            "- " .. TRANSLATE("Soulbound items and all quality levels can be selected. Epic or higher-quality items trigger an additional warning."),
            0.92,
            0.92,
            0.92,
            true
        )
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(TRANSLATE("Items excluded"), 1, 0.35, 0.35)
        GameTooltip:AddLine(
            "- " .. TRANSLATE("Current-expansion items, items without a vendor value, locked items, and loot containers."),
            0.78,
            0.78,
            0.78,
            true
        )
        GameTooltip:AddLine(
            "- " .. TRANSLATE("Quest items and items assigned to an equipment set."),
            0.78,
            0.78,
            0.78,
            true
        )
        GameTooltip:AddLine(
            "- " .. TRANSLATE("Current-season Mythic+ equipment, refundable items, and Warbound or account-bound items."),
            0.78,
            0.78,
            0.78,
            true
        )
        GameTooltip:AddLine(
            "- " .. TRANSLATE("Bags, keys, recipes, pet items, keystones, cosmetics, decor, heirlooms, toys, and mounts."),
            0.78,
            0.78,
            0.78,
            true
        )
        GameTooltip:AddLine(
            "- " .. TRANSLATE("Unbound bind-on-equip items and items protected through the blacklist."),
            0.78,
            0.78,
            0.78,
            true
        )
        GameTooltip:Show()
    end)
    frame.helpButton:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    frame.summary = CreateLabel(frame, "", 10)
    frame.summary:SetPoint("TOPLEFT", frame.title, "BOTTOMLEFT", 0, -5)
    frame.summary:SetPoint("RIGHT", frame, "RIGHT", -12, 0)
    frame.summary:SetTextColor(0.62, 0.62, 0.62, 1)

    frame.previewTab = CreateTabButton(frame, TRANSLATE("Preview"))
    frame.previewTab:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -58)
    frame.previewTab:SetScript("OnClick", function()
        WipedQoL:SelectExpansionVendorTab("preview")
    end)

    frame.blacklistTab = CreateTabButton(frame, TRANSLATE("Blacklist"))
    frame.blacklistTab:SetPoint("LEFT", frame.previewTab, "RIGHT", 6, 0)
    frame.blacklistTab:SetScript("OnClick", function()
        WipedQoL:SelectExpansionVendorTab("blacklist")
    end)

    frame.dropZone = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    frame.dropZone:SetSize(214, 54)
    frame.dropZone:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -90)
    frame.dropZone:SetText(TRANSLATE("Drag item here to protect it\nfrom being sold"))
    frame.dropZone:RegisterForDrag("LeftButton")
    SkinButton(frame.dropZone)
    frame.dropZone:SetScript("OnReceiveDrag", function()
        WipedQoL:TryAddExpansionVendorCursorItem()
    end)
    frame.dropZone:SetScript("OnMouseUp", function()
        local cursorType = GetCursorInfo and GetCursorInfo()
        if cursorType == "item" then
            WipedQoL:TryAddExpansionVendorCursorItem()
        end
    end)

    local itemIDLabel = CreateLabel(frame, TRANSLATE("Protect by Item ID"), 10)
    itemIDLabel:SetPoint("TOPLEFT", frame.dropZone, "TOPRIGHT", 10, -1)
    itemIDLabel:SetTextColor(0.7, 0.7, 0.7, 1)

    frame.itemIDBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
    frame.itemIDBox:SetSize(92, 26)
    frame.itemIDBox:SetPoint("TOPLEFT", itemIDLabel, "BOTTOMLEFT", 0, -5)
    frame.itemIDBox:SetAutoFocus(false)
    frame.itemIDBox:SetNumeric(true)
    frame.itemIDBox:SetMaxLetters(10)
    frame.itemIDBox:SetTextInsets(7, 7, 0, 0)
    ApplyFont(frame.itemIDBox, 11, "")
    if frame.itemIDBox.Left then frame.itemIDBox.Left:Hide() end
    if frame.itemIDBox.Middle then frame.itemIDBox.Middle:Hide() end
    if frame.itemIDBox.Right then frame.itemIDBox.Right:Hide() end
    SkinFrame(frame.itemIDBox, { light = true })

    frame.addButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    frame.addButton:SetSize(48, 26)
    frame.addButton:SetPoint("LEFT", frame.itemIDBox, "RIGHT", 6, 0)
    frame.addButton:SetText(TRANSLATE("Add"))
    SkinButton(frame.addButton)

    local function AddEnteredItemID()
        if WipedQoL:AddExpansionVendorBlacklistItem(frame.itemIDBox:GetText()) then
            frame.itemIDBox:SetText("")
        end
        frame.itemIDBox:ClearFocus()
    end
    frame.addButton:SetScript("OnClick", AddEnteredItemID)
    frame.itemIDBox:SetScript("OnEnterPressed", AddEnteredItemID)
    frame.itemIDBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)

    frame.scroll = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
    frame.scroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -154)
    frame.scroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30, 62)
    frame.scroll:EnableMouseWheel(true)

    frame.scrollChild = CreateFrame("Frame", nil, frame.scroll)
    frame.scrollChild:SetWidth(PANEL_WIDTH - 44)
    frame.scrollChild:SetHeight(1)
    frame.scroll:SetScrollChild(frame.scrollChild)
    frame.scroll:SetScript("OnMouseWheel", function(self, delta)
        local current = self:GetVerticalScroll()
        local maximum = math.max(frame.scrollChild:GetHeight() - self:GetHeight(), 0)
        self:SetVerticalScroll(math.max(0, math.min(current - (delta * 36), maximum)))
    end)
    local helpers = GetUI()
    if frame.scroll.ScrollBar and helpers and helpers.SkinScrollBar then
        helpers.SkinScrollBar(frame.scroll.ScrollBar)
    end

    frame.empty = CreateLabel(frame.scroll, "", 11)
    frame.empty:SetPoint("TOPLEFT", frame.scroll, "TOPLEFT", 8, -12)
    frame.empty:SetPoint("RIGHT", frame.scroll, "RIGHT", -8, 0)
    frame.empty:SetTextColor(0.55, 0.55, 0.55, 1)

    frame.status = CreateLabel(frame, "", 9)
    frame.status:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 12, 42)
    frame.status:SetPoint("RIGHT", frame, "RIGHT", -12, 0)
    frame.status:SetTextColor(0.62, 0.62, 0.62, 1)
    frame.status:SetJustifyH("LEFT")

    frame.refreshButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    frame.refreshButton:SetSize(104, 26)
    frame.refreshButton:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 12, 10)
    frame.refreshButton:SetText(TRANSLATE("Refresh"))
    frame.refreshButton:SetScript("OnClick", function()
        WipedQoL:SetExpansionVendorStatus("", false)
        WipedQoL:ScanExpansionVendorInventory()
    end)
    SkinButton(frame.refreshButton)

    frame.sellButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    frame.sellButton:SetSize(154, 26)
    frame.sellButton:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -12, 10)
    frame.sellButton:SetText(TRANSLATE("Sell everything shown"))
    frame.sellButton:SetScript("OnClick", function()
        WipedQoL:ConfirmExpansionVendorSale()
    end)
    SkinButton(frame.sellButton)

    frame:SetScript("OnShow", function()
        WipedQoL:PositionExpansionVendorFrame()
        WipedQoL:ScheduleExpansionVendorScan()
    end)

    self.expansionVendorFrame = frame
    self:PositionExpansionVendorFrame()
    self:RefreshExpansionVendorControls()
    return frame
end

function WipedQoL:ConfirmExpansionVendorSale()
    if self.expansionVendorSale then
        return
    end
    if self:IsExpansionVendorSaleBlocked() then
        self:SetExpansionVendorStatus(
            TRANSLATE("Selling is unavailable during combat or a boss encounter."),
            true
        )
        self:RefreshExpansionVendorControls()
        return
    end

    self:ScanExpansionVendorInventory()
    local candidates = self.expansionVendorCandidates or {}
    if #candidates == 0 then
        return
    end

    local warnings = {}
    if #candidates > BUYBACK_SLOT_COUNT then
        warnings[#warnings + 1] = TRANSLATE("More than 12 stacks will be sold. Not everything can be bought back.")
    end
    if (self.expansionVendorHighestQuality or 0) >= 4 then
        warnings[#warnings + 1] = TRANSLATE("The list contains epic or higher-quality items.")
    end

    local text = string.format(
        TRANSLATE("Sell %d stacks with %d items for approximately %s?"),
        #candidates,
        self.expansionVendorTotalItems or 0,
        FormatMoney(self.expansionVendorTotalValue or 0)
    )
    if #warnings > 0 then
        text = text .. "\n\n|cffff5555" .. table.concat(warnings, "\n") .. "|r"
    end

    StaticPopup_Show(CONFIRM_DIALOG, text)
end

function WipedQoL:BuildExpansionVendorSaleQueue()
    local queue = {}
    for _, candidate in ipairs(self.expansionVendorCandidates or {}) do
        queue[#queue + 1] = {
            guid = candidate.guid,
            bagID = candidate.bagID,
            slotID = candidate.slotID,
            itemID = candidate.itemID,
            count = candidate.count,
        }
    end
    return queue
end

function WipedQoL:FindExpansionVendorQueueItem(entry)
    if entry.guid then
        for _, bagID in ipairs(GetPlayerBagIDs()) do
            local slotCount = C_Container.GetContainerNumSlots(bagID) or 0
            for slotID = 1, slotCount do
                if GetItemGUID(bagID, slotID) == entry.guid then
                    return bagID, slotID
                end
            end
        end
        return nil
    end

    local info = C_Container.GetContainerItemInfo(entry.bagID, entry.slotID)
    if info and info.itemID == entry.itemID then
        return entry.bagID, entry.slotID
    end
    return nil
end

function WipedQoL:StartExpansionVendorSale()
    if self.expansionVendorSale or not MerchantFrame or not MerchantFrame:IsShown() then
        return
    end
    if self:IsExpansionVendorSaleBlocked() then
        self:SetExpansionVendorStatus(
            TRANSLATE("Selling is unavailable during combat or a boss encounter."),
            true
        )
        self:RefreshExpansionVendorControls()
        return
    end

    self:ScanExpansionVendorInventory()
    local queue = self:BuildExpansionVendorSaleQueue()
    if #queue == 0 then
        return
    end

    self.expansionVendorSale = {
        queue = queue,
        index = 1,
        soldStacks = 0,
        soldItems = 0,
        soldValue = 0,
        skipped = 0,
        waiting = false,
    }
    self:SetExpansionVendorStatus(
        string.format(TRANSLATE("Selling: 0/%d stacks"), #queue),
        false
    )
    self:RefreshExpansionVendorControls()
    self:RefreshExpansionVendorRows()
    self:ProcessNextExpansionVendorSale()
end

function WipedQoL:ProcessNextExpansionVendorSale()
    local sale = self.expansionVendorSale
    if not sale or sale.waiting then
        return
    end
    if self:IsExpansionVendorSaleBlocked() then
        self:AbortExpansionVendorSale(
            TRANSLATE("Selling stopped because combat or a boss encounter started."),
            true
        )
        return
    end
    if not MerchantFrame or not MerchantFrame:IsShown() then
        self:AbortExpansionVendorSale(TRANSLATE("Selling stopped because the merchant was closed."), true)
        return
    end

    while sale.index <= #sale.queue do
        local entry = sale.queue[sale.index]
        sale.index = sale.index + 1

        local bagID, slotID = self:FindExpansionVendorQueueItem(entry)
        local candidate = bagID and self:EvaluateExpansionVendorSlot(bagID, slotID) or nil
        if candidate and candidate.itemID == entry.itemID then
            sale.waiting = true
            sale.pendingToken = (sale.pendingToken or 0) + 1
            sale.pending = {
                token = sale.pendingToken,
                entry = entry,
                guid = candidate.guid,
                itemID = candidate.itemID,
                count = candidate.count,
                value = candidate.value,
                bagID = bagID,
                slotID = slotID,
                started = GetTime and GetTime() or 0,
            }

            local pendingToken = sale.pendingToken
            C_Container.UseContainerItem(bagID, slotID)
            C_Timer.After(0.55, function()
                if WipedQoL.expansionVendorSale == sale
                    and sale.waiting
                    and sale.pending
                    and sale.pending.token == pendingToken
                then
                    WipedQoL:FinalizeExpansionVendorPendingSale()
                end
            end)
            return
        end

        sale.skipped = sale.skipped + 1
    end

    self:FinishExpansionVendorSale()
end

function WipedQoL:FinalizeExpansionVendorPendingSale()
    local sale = self.expansionVendorSale
    if not sale or not sale.waiting or not sale.pending then
        return
    end

    local pending = sale.pending
    sale.waiting = false
    sale.pending = nil

    local bagID, slotID
    if pending.guid then
        bagID, slotID = self:FindExpansionVendorQueueItem({
            guid = pending.guid,
            itemID = pending.itemID,
        })
    else
        local info = C_Container.GetContainerItemInfo(pending.bagID, pending.slotID)
        if info and info.itemID == pending.itemID then
            bagID, slotID = pending.bagID, pending.slotID
        end
    end

    local remaining = 0
    if bagID and slotID then
        local info = C_Container.GetContainerItemInfo(bagID, slotID)
        if info and info.itemID == pending.itemID then
            remaining = tonumber(info.stackCount) or pending.count
        end
    end

    local soldCount = math.max((pending.count or 0) - remaining, 0)
    if soldCount > 0 or (pending.guid and not bagID) then
        soldCount = soldCount > 0 and soldCount or pending.count
        sale.soldStacks = sale.soldStacks + 1
        sale.soldItems = sale.soldItems + soldCount
        sale.soldValue = sale.soldValue + math.floor((pending.value or 0) * soldCount / math.max(pending.count or 1, 1))
    else
        sale.skipped = sale.skipped + 1
    end

    self:SetExpansionVendorStatus(string.format(
        TRANSLATE("Selling: %d/%d stacks"),
        sale.soldStacks + sale.skipped,
        #sale.queue
    ), false)

    C_Timer.After(SALE_DELAY, function()
        if WipedQoL.expansionVendorSale == sale then
            WipedQoL:ProcessNextExpansionVendorSale()
        end
    end)
end

function WipedQoL:FinishExpansionVendorSale()
    local sale = self.expansionVendorSale
    if not sale then
        return
    end

    self.expansionVendorSale = nil
    self:ScanExpansionVendorInventory()
    self:SetExpansionVendorStatus(string.format(
        TRANSLATE("Sold %d stacks (%d items) for approximately %s. %d skipped."),
        sale.soldStacks,
        sale.soldItems,
        FormatMoney(sale.soldValue),
        sale.skipped
    ), false)
    self:RefreshExpansionVendorControls()
end

function WipedQoL:AbortExpansionVendorSale(reason, refresh)
    if not self.expansionVendorSale then
        return
    end

    self.expansionVendorSale = nil
    if reason then
        self:SetExpansionVendorStatus(reason, true)
    end
    self:RefreshExpansionVendorControls()
    self:RefreshExpansionVendorRows()
    if refresh then
        self:ScheduleExpansionVendorScan()
    end
end

function WipedQoL:HandleExpansionVendorMerchantShow()
    if self:GetExpansionVendorSettings().enabled ~= true then
        return
    end

    C_Timer.After(0, function()
        if not MerchantFrame or not MerchantFrame:IsShown() then
            return
        end
        local frame = WipedQoL:CreateExpansionVendorFrame()
        if frame then
            frame:Show()
            WipedQoL:PositionExpansionVendorFrame()
            if WipedQoL:IsExpansionVendorSaleBlocked() then
                WipedQoL:SetExpansionVendorStatus(
                    TRANSLATE("Selling is unavailable during combat or a boss encounter."),
                    true
                )
            else
                WipedQoL:SetExpansionVendorStatus("", false)
            end
            WipedQoL:ScanExpansionVendorInventory()
        end
    end)
end

function WipedQoL:HandleExpansionVendorMerchantClosed()
    self:AbortExpansionVendorSale(nil, false)
    if self.expansionVendorFrame then
        self.expansionVendorFrame:Hide()
    end
end

function WipedQoL:HandleExpansionVendorBagUpdate()
    local sale = self.expansionVendorSale
    if sale and sale.waiting then
        local pendingToken = sale.pending and sale.pending.token
        local elapsed = (GetTime and GetTime() or 0) - (sale.pending and sale.pending.started or 0)
        C_Timer.After(math.max(0.05 - elapsed, 0), function()
            if WipedQoL.expansionVendorSale == sale
                and sale.waiting
                and sale.pending
                and sale.pending.token == pendingToken
            then
                WipedQoL:FinalizeExpansionVendorPendingSale()
            end
        end)
        return
    end

    self:ScheduleExpansionVendorScan()
end

function WipedQoL:HandleExpansionVendorItemInfoReceived()
    local frame = self.expansionVendorFrame
    if frame and frame:IsVisible() and not self.expansionVendorSale then
        self:ScheduleExpansionVendorScan()
    end
end

function WipedQoL:HandleExpansionVendorEquipmentSetsChanged()
    local frame = self.expansionVendorFrame
    if frame and frame:IsVisible() and not self.expansionVendorSale then
        self:ScheduleExpansionVendorScan()
    end
end

function WipedQoL:HandleExpansionVendorTradeTimerConfirmation()
    self:AbortExpansionVendorSale(
        TRANSLATE("Selling stopped because an item requires confirmation."),
        true
    )
end

function WipedQoL:IsExpansionVendorSaleBlocked()
    return SafeTrue(InCombatLockdown)
        or SafeTrue(UnitAffectingCombat, "player")
        or self.expansionVendorEncounterActive == true
        or IsExpansionVendorEncounterInProgress()
end

function WipedQoL:HandleExpansionVendorSaleGuardEvent(event)
    if event == "ENCOUNTER_START" then
        self.expansionVendorEncounterActive = true
    elseif event == "ENCOUNTER_END" then
        self.expansionVendorEncounterActive = nil
    elseif event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
        self.expansionVendorEncounterActive =
            IsExpansionVendorEncounterInProgress() and true or nil
    end

    if event == "ENCOUNTER_END" or event == "PLAYER_REGEN_ENABLED" then
        C_Timer.After(0.1, function()
            WipedQoL:HandleExpansionVendorSaleGuardEvent("REFRESH")
        end)
    end

    local blocked = self:IsExpansionVendorSaleBlocked()
    local lockedMessage =
        TRANSLATE("Selling is unavailable during combat or a boss encounter.")

    if blocked then
        if StaticPopup_Hide then
            StaticPopup_Hide(CONFIRM_DIALOG)
        end
        if self.expansionVendorSale then
            self:AbortExpansionVendorSale(
                TRANSLATE("Selling stopped because combat or a boss encounter started."),
                true
            )
        else
            self:SetExpansionVendorStatus(lockedMessage, true)
            self:RefreshExpansionVendorControls()
        end
        return
    end

    local frame = self.expansionVendorFrame
    if frame and frame.status and frame.status:GetText() == lockedMessage then
        self:SetExpansionVendorStatus("", false)
    end
    self:RefreshExpansionVendorControls()
end

function WipedQoL:SetupExpansionVendor()
    if self.expansionVendorSetupDone then
        return
    end
    self.expansionVendorSetupDone = true

    if C_MythicPlus and C_MythicPlus.RequestRewards then
        C_MythicPlus.RequestRewards()
    end

    local saleGuardFrame = CreateFrame("Frame")
    saleGuardFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
    saleGuardFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
    saleGuardFrame:RegisterEvent("ENCOUNTER_START")
    saleGuardFrame:RegisterEvent("ENCOUNTER_END")
    saleGuardFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    saleGuardFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    saleGuardFrame:SetScript("OnEvent", function(_, event)
        WipedQoL:HandleExpansionVendorSaleGuardEvent(event)
    end)
    self.expansionVendorSaleGuardFrame = saleGuardFrame

    self:RegisterEvent("MERCHANT_SHOW", "HandleExpansionVendorMerchantShow")
    self:RegisterEvent("MERCHANT_CLOSED", "HandleExpansionVendorMerchantClosed")
    self:RegisterEvent("BAG_UPDATE_DELAYED", "HandleExpansionVendorBagUpdate")
    self:RegisterEvent("GET_ITEM_INFO_RECEIVED", "HandleExpansionVendorItemInfoReceived")
    self:RegisterEvent(
        "EQUIPMENT_SETS_CHANGED",
        "HandleExpansionVendorEquipmentSetsChanged"
    )
    self:RegisterEvent(
        "MERCHANT_CONFIRM_TRADE_TIMER_REMOVAL",
        "HandleExpansionVendorTradeTimerConfirmation"
    )
end

StaticPopupDialogs[CONFIRM_DIALOG] = {
    text = "%s",
    button1 = ACCEPT,
    button2 = CANCEL,
    OnAccept = function()
        WipedQoL:StartExpansionVendorSale()
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}
