-- WhatFarm/Util/Bagworth.lua
-- Live Bag Worth (scan-local caches only; no persisted pricing cache)
-- Uses WhatFarm provider strings:
--   "vendor"
--   "auctionator"
--   "tsm:dbregionmarketavg" (or any TSM custom price string)
--
-- Public API:
--   WF.BagWorth:Init()
--   WF.BagWorth:Scan(scope, providerStr, cb, opt)
--
-- Scopes:
--   "bags" | "bank" | "bagsbank" | "gb" | "all"
--
-- Events:
--   "progress" payload={ scope, source, tab, tabs, phase }
--   "done"     payload={ scope, source, total, bagsTotal, bankTotal, gbTotal, top, skippedItems, pricedStacks, pricedQty, skippedStacks, skippedQty, bankScanned=false, cached=false, note }
--   "error"    payload={ scope, source, message }

local ADDON_NAME, ns = ...
local WF = ns.WF

WF.BagWorth = WF.BagWorth or {}
local BW = WF.BagWorth

local DEFAULT_PROVIDER   = "vendor"
local DEFAULT_TSM_PRICE  = "dbregionmarketavg"

local ParseProvider

-- -------------------------------------------------------
-- Utils
-- -------------------------------------------------------
local function Trim(x)
  x = tostring(x or "")
  x = x:gsub("^%s+", "")
  x = x:gsub("%s+$", "")
  return x
end

local function SafeEmit(cb, event, payload)
  if type(cb) ~= "function" then
    return true
  end

  local ok, err = xpcall(function()
    cb(event, payload)
  end, geterrorhandler())
  if ok then
    return true
  end

  return false, err
end

-- -------------------------------------------------------
-- Filters (Bag Worth Options)
-- -------------------------------------------------------
-- opt can pass either:
--   opt.filters = { greys=true, whites=true, greens=true, blues=true, epics=true, mats=true, gear=true, patterns=true, pets=true, consumables=true, gems=true }
-- or direct include flags:
--   opt.includeGreys/includeWhites/includeGreens/includeBlues/includeEpics/includeMats/includeGear/includePatterns/includePets/includeConsumables/includeGems
--
-- Behavior:
-- - If at least one category is explicitly enabled, category filters become an inclusion set.
--   Example: { mats=true } = mats only.
-- - If categories are only explicitly disabled, they behave as exclusions.
--   Example: { mats=false } = everything except mats.
-- - If at least one gear quality is explicitly enabled, quality filters become an inclusion set for gear.
--   Example: { gear=true, greens=true } = only green gear.
-- - If qualities are only explicitly disabled, they behave as exclusions.
--   Example: { gear=true, greens=false } = all non-trash gear except greens.
-- - Poor/common items are controlled by the Greys/Whites toggles.
-- - Unknown categories are excluded only while in inclusion-set mode.

local function NormalizeFilters(opt)
  if type(opt) ~= "table" then
    return nil
  end

  local src = (type(opt.filters) == "table") and opt.filters or opt

  local qualityDefs = {
    { name = "greys",  keys = { "greys", "includeGreys", "grey", "gray", "includeGrey", "includeGray", "poor", "includePoor" } },
    { name = "whites", keys = { "whites", "includeWhites", "white", "includeWhite", "common", "includeCommon" } },
    { name = "greens", keys = { "greens", "includeGreens", "green", "includeGreen", "uncommon", "includeUncommon" } },
    { name = "blues",  keys = { "blues", "includeBlues", "blue", "includeBlue", "rare", "rares", "includeRare", "includeRares" } },
    { name = "epics",  keys = { "epics", "includeEpics", "epic", "includeEpic", "purple", "includePurple" } },
  }

  local categoryDefs = {
    { name = "mats",        keys = { "mats", "includeMats" } },
    { name = "gear",        keys = { "gear", "includeGear" } },
    { name = "patterns",    keys = { "patterns", "includePatterns" } },
    { name = "pets",        keys = { "pets", "includePets" } },
    { name = "toys",        keys = { "toys", "includeToys" } },
    { name = "consumables", keys = { "consumables", "includeConsumables" } },
    { name = "gems",        keys = { "gems", "includeGems" } },
  }

  local hasAny = false

  local categoryExplicitCount = 0
  local categoryHasTrue = false

  local qualityExplicitCount = 0
  local qualityHasTrue = false

  local function GetExplicitValue(keys)
    for i = 1, #keys do
      local key = keys[i]
      if src[key] ~= nil then
        return (src[key] and true or false), true
      end
    end
    return nil, false
  end

  for i = 1, #qualityDefs do
    local v, found = GetExplicitValue(qualityDefs[i].keys)
    if found then
      hasAny = true
      qualityExplicitCount = qualityExplicitCount + 1
      if v == true then
        qualityHasTrue = true
      end
    end
  end

  for i = 1, #categoryDefs do
    local v, found = GetExplicitValue(categoryDefs[i].keys)
    if found then
      hasAny = true
      categoryExplicitCount = categoryExplicitCount + 1
      if v == true then
        categoryHasTrue = true
      end
    end
  end

  if not hasAny then
    return nil
  end

  local categoryMode = false
  if categoryHasTrue then
    categoryMode = true
  elseif categoryExplicitCount == #categoryDefs then
    categoryMode = true
  end

  local qualityMode = false
  if qualityHasTrue then
    qualityMode = true
  elseif qualityExplicitCount == #qualityDefs then
    qualityMode = true
  end

  local function QualityFlag(def)
    local v, found = GetExplicitValue(def.keys)
    if found then
      return v
    end

    if qualityMode then
      return false
    end

    return true
  end

  local function CategoryFlag(def)
    local v, found = GetExplicitValue(def.keys)
    if found then
      return v
    end

    if categoryMode then
      return false
    end

    return true
  end

  local out = {
    _categoryMode = categoryMode,
    _qualityMode = qualityMode,
  }

  for i = 1, #qualityDefs do
    local def = qualityDefs[i]
    out[def.name] = QualityFlag(def)
  end

  for i = 1, #categoryDefs do
    local def = categoryDefs[i]
    out[def.name] = CategoryFlag(def)
  end

  return out
end

local function GetItemQuality(itemID, itemLink, fallbackQuality)
  local q = tonumber(fallbackQuality)
  if q ~= nil then
    return q
  end

  itemID = tonumber(itemID)
  if C_Item and C_Item.GetItemQualityByID and itemID then
    local ok, v = pcall(C_Item.GetItemQualityByID, itemID)
    v = tonumber(v)
    if ok and v ~= nil then
      return v
    end
  end

  if itemLink then
    local v = select(3, GetItemInfo(itemLink))
    v = tonumber(v)
    if v ~= nil then
      return v
    end
  end

  if itemID then
    local v = select(3, GetItemInfo(itemID))
    v = tonumber(v)
    if v ~= nil then
      return v
    end
  end

  return nil
end

local _metaCache = {}
local _metaCacheOrder = {}
local _metaCacheSeen = {}
local META_CACHE_LIMIT = 500

local function SetMetaCache(itemID, rec)
  itemID = tonumber(itemID)
  if not itemID or type(rec) ~= "table" then return end
  if not _metaCacheSeen[itemID] then
    _metaCacheOrder[#_metaCacheOrder + 1] = itemID
    _metaCacheSeen[itemID] = true
  end
  _metaCache[itemID] = rec
  while #_metaCacheOrder > META_CACHE_LIMIT do
    local old = table.remove(_metaCacheOrder, 1)
    if old then
      _metaCacheSeen[old] = nil
      _metaCache[old] = nil
    end
  end
end

local function GetItemMeta(itemID, itemLink)
  itemID = tonumber(itemID)
  if not itemID and itemLink then
    itemID = select(1, GetItemInfoInstant(itemLink))
    itemID = tonumber(itemID)
  end

  local cached = itemID and _metaCache[itemID]
  if cached then
    return cached.classID, cached.subClassID, cached.isCraftingReagent, cached.equipLoc
  end

  local classID
  local subClassID
  local isCraftingReagent
  local equipLoc

  local itemInfo = itemLink or itemID
  if itemInfo then
    local _, _, _, instantEquipLoc, _, instantClassID, instantSubClassID = GetItemInfoInstant(itemInfo)

    if instantEquipLoc and instantEquipLoc ~= "" then
      equipLoc = instantEquipLoc
    end

    if instantClassID ~= nil then
      classID = tonumber(instantClassID) or classID
    end

    if instantSubClassID ~= nil then
      subClassID = tonumber(instantSubClassID) or subClassID
    end
  end

  local itemName, _, _, _, _, _, _, _, fullEquipLoc, _, _, fullClassID, fullSubClassID, _, _, _, craftingReagent = GetItemInfo(itemInfo)
  if itemName then
    if fullEquipLoc and fullEquipLoc ~= "" then
      equipLoc = fullEquipLoc
    end

    if fullClassID ~= nil then
      classID = tonumber(fullClassID) or classID
    end

    if fullSubClassID ~= nil then
      subClassID = tonumber(fullSubClassID) or subClassID
    end

    if craftingReagent ~= nil then
      isCraftingReagent = craftingReagent and true or false
    end
  elseif itemID and C_Item and C_Item.RequestLoadItemDataByID then
    pcall(C_Item.RequestLoadItemDataByID, itemID)
  end

  if itemID and (
    classID ~= nil or
    subClassID ~= nil or
    isCraftingReagent ~= nil or
    (equipLoc and equipLoc ~= "")
  ) then
    SetMetaCache(itemID, {
      classID = classID,
      subClassID = subClassID,
      isCraftingReagent = isCraftingReagent,
      equipLoc = equipLoc,
    })
  end

  return classID, subClassID, isCraftingReagent, equipLoc
end

local function IsGear(classID, equipLoc)
  if classID == 2 or classID == 4 then
    return true
  end

  if equipLoc and equipLoc ~= "" then
    if equipLoc ~= "INVTYPE_BAG" and equipLoc ~= "INVTYPE_QUIVER" then
      if equipLoc:find("PROFESSION") then
        return true
      end

      if classID == nil then
        return true
      end
    end
  end

  return false
end

local function IsMats(classID, subClassID, isCraftingReagent)
  if isCraftingReagent == true then
    return true
  end

  if classID == 7 then
    return true
  end

  return false
end

local function IsPattern(classID)
  return classID == 9
end

local function IsGem(classID)
  return classID == 3
end

local function IsConsumable(classID)
  return classID == 0
end

local function IsPet(classID, subClassID, itemLink)
  if classID == 17 then
    return true
  end

  if classID == 15 and subClassID == 2 then
    return true
  end

  if itemLink and type(itemLink) == "string" and itemLink:find("battlepet:") then
    return true
  end

  return false
end

local function IsToy(classID, subClassID)
  -- WoW item class 15, subclass 3 = Toys
  return classID == 15 and subClassID == 3
end

local function PassQuality(quality, filters)
  -- Applies green/blue/epic filter to any item regardless of category.
  if not filters then return true, nil end
  quality = tonumber(quality)
  if quality == nil then return true, nil end
  if quality == 2 and filters.greens == false then
    return false, "Green items filtered"
  end
  if quality == 3 and filters.blues == false then
    return false, "Blue items filtered"
  end
  if quality >= 4 and filters.epics == false then
    return false, "Epic items filtered"
  end
  return true, nil
end

local function PassLowQuality(quality, filters)
  if not filters then
    return true, nil
  end

  quality = tonumber(quality)
  if quality == nil then
    return true, nil
  end

  if quality == 0 and filters.greys == false then
    return false, "Grey items filtered"
  end

  if quality == 1 and filters.whites == false then
    return false, "White items filtered"
  end

  return true, nil
end

local function GetItemCategory(itemID, itemLink, quality)
  local classID, subClassID, isCraftingReagent, equipLoc = GetItemMeta(itemID, itemLink)

  if IsGear(classID, equipLoc) then
    quality = GetItemQuality(itemID, itemLink, quality)
    return "gear", quality
  end

  if IsMats(classID, subClassID, isCraftingReagent) then
    return "mats", quality
  end

  if IsPattern(classID) then
    return "patterns", quality
  end

  if IsToy(classID, subClassID) then
    return "toys", quality
  end

  if IsPet(classID, subClassID, itemLink) then
    return "pets", quality
  end

  if IsConsumable(classID) then
    return "consumables", quality
  end

  if IsGem(classID) then
    return "gems", quality
  end

  return nil, quality
end

local function NormalizeCategory(category)
  category = tostring(category or ""):lower()
  if category == "gear" or category == "mats" or category == "patterns" or category == "pets" or category == "toys" or category == "consumables" or category == "gems" then
    return category
  end
  return "other"
end

local function ResolveEntryCategory(itemID, itemLink, category, quality)
  local normalized = NormalizeCategory(category)
  if category ~= nil and normalized ~= "other" then
    return normalized
  end

  local derivedCategory = select(1, GetItemCategory(itemID, itemLink, quality))
  return NormalizeCategory(derivedCategory)
end

local function ShouldIncludeItem(itemID, itemLink, quality, filters)
  if not filters then
    return true, nil, NormalizeCategory(select(1, GetItemCategory(itemID, itemLink, quality)))
  end

  local category, resolvedQuality = GetItemCategory(itemID, itemLink, quality)
  local normalizedCategory = NormalizeCategory(category)

  -- Category exclusions must win before quality exclusions.
  -- Example: white crafting reagents should be reported as materials when
  -- materials are excluded, not as generic white items just because their
  -- tooltip quality is common.
  if category == "gear" then
    if filters.gear == false then
      return false, "Gear filtered", normalizedCategory
    end

    local okLow, lowReason = PassLowQuality(resolvedQuality, filters)
    if not okLow then
      return false, lowReason, normalizedCategory
    end

    local okQ, qReason = PassQuality(resolvedQuality, filters)
    if not okQ then
      return false, qReason, normalizedCategory
    end

    return true, nil, normalizedCategory
  end

  if category == "mats" then
    if filters.mats == false then
      return false, "Materials filtered", normalizedCategory
    end
    return true, nil, normalizedCategory
  end

  if category == "patterns" then
    if filters.patterns == false then
      return false, "Patterns filtered", normalizedCategory
    end
    return true, nil, normalizedCategory
  end

  if category == "toys" then
    if filters.toys == false then
      return false, "Toys filtered", normalizedCategory
    end
    return true, nil, normalizedCategory
  end

  if category == "pets" then
    if filters.pets == false then
      return false, "Pets filtered", normalizedCategory
    end
    return true, nil, normalizedCategory
  end

  if category == "consumables" then
    if filters.consumables == false then
      return false, "Consumables filtered", normalizedCategory
    end
    return true, nil, normalizedCategory
  end

  if category == "gems" then
    if filters.gems == false then
      return false, "Gems filtered", normalizedCategory
    end
    return true, nil, normalizedCategory
  end

  if filters._categoryMode then
    return false, "Unknown item type filtered", normalizedCategory
  end

  -- Unknown/other items still respect the low-quality toggles.
  local okLow, lowReason = PassLowQuality(resolvedQuality, filters)
  if not okLow then
    return false, lowReason, normalizedCategory
  end

  local okQ, qReason = PassQuality(resolvedQuality, filters)
  if not okQ then
    return false, qReason, normalizedCategory
  end

  return true, nil, normalizedCategory
end

local function GetSkipDisplayName(itemID, itemLink)
  if type(itemLink) == "string" and itemLink ~= "" then
    local n = itemLink:match("%[(.-)%]")
    if n and n ~= "" then
      return n
    end

    local itemName = GetItemInfo(itemLink)
    if itemName and itemName ~= "" then
      return itemName
    end
  end

  itemID = tonumber(itemID)
  if itemID then
    local itemName = GetItemInfo(itemID)
    if itemName and itemName ~= "" then
      return itemName
    end
    return "item:" .. tostring(itemID)
  end

  return "Unknown Item"
end

local function MakeByItemKey(itemID, itemLink)
  if type(itemLink) == "string" and itemLink ~= "" then
    return itemLink
  end

  itemID = tonumber(itemID)
  if itemID then
    return "item:" .. tostring(itemID)
  end

  return nil
end

local function GetValuedDisplayName(itemID, itemLink)
  if type(itemLink) == "string" and itemLink ~= "" then
    local n = itemLink:match("%[(.-)%]")
    if n and n ~= "" then
      return n
    end

    local itemName = GetItemInfo(itemLink)
    if itemName and itemName ~= "" then
      return itemName
    end
  end

  itemID = tonumber(itemID)
  if itemID then
    local itemName = GetItemInfo(itemID)
    if itemName and itemName ~= "" then
      return itemName
    end
    return "item:" .. tostring(itemID)
  end

  return "Unknown Item"
end

local function AddValuedEntry(dst, itemID, itemLink, count, stackValue, category)
  if type(dst) ~= "table" then
    return
  end

  count = tonumber(count) or 0
  stackValue = tonumber(stackValue) or 0
  if count <= 0 or stackValue <= 0 then
    return
  end

  local key = MakeByItemKey(itemID, itemLink)
  if not key then
    return
  end

  local e = dst[key]
  if not e then
    e = {
      key = key,
      itemID = tonumber(itemID),
      link = itemLink,
      name = GetValuedDisplayName(itemID, itemLink),
      count = 0,
      value = 0,
      category = ResolveEntryCategory(itemID, itemLink, category),
    }
    dst[key] = e
  end

  if not e.itemID and itemID then
    e.itemID = tonumber(itemID)
  end
  if (not e.link or e.link == "") and itemLink then
    e.link = itemLink
  end
  if (not e.name or e.name == "Unknown Item") then
    e.name = GetValuedDisplayName(itemID or e.itemID, itemLink or e.link)
  end

  e.count = (tonumber(e.count) or 0) + count
  e.value = (tonumber(e.value) or 0) + stackValue

  local resolvedCategory = ResolveEntryCategory(itemID or e.itemID, itemLink or e.link, category or e.category)
  if (not e.category) or e.category == "other" or (resolvedCategory and resolvedCategory ~= "other") then
    e.category = resolvedCategory
  end
end

local function AddSkippedEntry(dst, itemID, itemLink, count, reason)
  if type(dst) ~= "table" then
    return
  end

  count = tonumber(count) or 0
  if count <= 0 then
    return
  end

  reason = tostring(reason or "Skipped")
  local linkKey = tostring(itemLink or "")
  local key = reason .. "" .. linkKey .. "" .. tostring(itemID or "")
  local e = dst[key]
  if not e then
    e = {
      itemID = tonumber(itemID),
      link = itemLink,
      name = GetSkipDisplayName(itemID, itemLink),
      count = 0,
      reason = reason,
    }
    dst[key] = e
  end

  if not e.itemID and itemID then
    e.itemID = tonumber(itemID)
  end
  if (not e.link or e.link == "") and itemLink then
    e.link = itemLink
  end
  if (not e.name or e.name == "Unknown Item") then
    e.name = GetSkipDisplayName(itemID or e.itemID, itemLink or e.link)
  end

  e.count = (e.count or 0) + count
end

local function BuildTopList(src, limit)
  local out = {}
  if type(src) ~= "table" then
    return out
  end

  for _, data in pairs(src) do
    if type(data) == "table" then
      local count = tonumber(data.count) or 0
      local value = tonumber(data.value) or 0
      local link = data.link
      if count > 0 and value > 0 then
        out[#out + 1] = {
          link = link,
          name = data.name,
          itemID = tonumber(data.itemID),
          count = count,
          value = value,
          category = ResolveEntryCategory(data.itemID, link, data.category),
        }
      end
    end
  end

  table.sort(out, function(a, b)
    local av = tonumber(a.value) or 0
    local bv = tonumber(b.value) or 0
    if av == bv then
      local an = tostring(a.name or a.link or a.itemID or "")
      local bn = tostring(b.name or b.link or b.itemID or "")
      return an < bn
    end
    return av > bv
  end)

  if limit and #out > limit then
    for i = #out, limit + 1, -1 do
      out[i] = nil
    end
  end

  return out
end

local function BuildBreakdownList(src, limit)
  local out = {}
  if type(src) ~= "table" then
    return out
  end

  for _, data in pairs(src) do
    if type(data) == "table" then
      local count = tonumber(data.count) or 0
      local value = tonumber(data.value) or 0
      local link = data.link
      if count > 0 and value > 0 then
        out[#out + 1] = {
          link = link,
          name = data.name,
          itemID = tonumber(data.itemID),
          count = count,
          each = math.floor((value / count) + 0.5),
          value = value,
          category = ResolveEntryCategory(data.itemID, link, data.category),
        }
      end
    end
  end

  table.sort(out, function(a, b)
    local av = tonumber(a.value) or 0
    local bv = tonumber(b.value) or 0
    if av == bv then
      local an = tostring(a.name or a.link or a.itemID or "")
      local bn = tostring(b.name or b.link or b.itemID or "")
      return an < bn
    end
    return av > bv
  end)

  if limit and #out > limit then
    for i = #out, limit + 1, -1 do
      out[i] = nil
    end
  end

  return out
end

local function BuildSkippedList(src, limit)
  local out = {}
  if type(src) ~= "table" then
    return out
  end

  for _, data in pairs(src) do
    if type(data) == "table" then
      out[#out + 1] = {
        itemID = tonumber(data.itemID),
        link = data.link,
        name = data.name,
        count = tonumber(data.count) or 0,
        reason = tostring(data.reason or "Skipped"),
      }
    end
  end

  table.sort(out, function(a, b)
    local ar = tostring(a.reason or "")
    local br = tostring(b.reason or "")
    if ar == br then
      local ac = tonumber(a.count) or 0
      local bc = tonumber(b.count) or 0
      if ac == bc then
        local an = tostring(a.name or a.link or "")
        local bn = tostring(b.name or b.link or "")
        return an < bn
      end
      return ac > bc
    end
    return ar < br
  end)

  if limit and #out > limit then
    for i = #out, limit + 1, -1 do
      out[i] = nil
    end
  end

  return out
end

local function MergeSkippedEntries(dst, src)
  if type(dst) ~= "table" or type(src) ~= "table" then
    return
  end

  for key, data in pairs(src) do
    if type(data) == "table" then
      local e = dst[key]
      if not e then
        dst[key] = {
          link = data.link,
          name = data.name,
          count = tonumber(data.count) or 0,
          reason = data.reason,
        }
      else
        e.count = (tonumber(e.count) or 0) + (tonumber(data.count) or 0)
      end
    end
  end
end

local function GetNoValueReason(providerStr)
  local provider = select(1, ParseProvider(providerStr))
  if provider == "tsm" then
    return "No TSM value"
  end
  if provider == "auctionator" then
    return "No Auctionator value"
  end
  return "No vendor value"
end

local function IsItemBoundAtContainerSlot(bagID, slot)
  if not (C_Item and C_Item.IsBound and ItemLocation and ItemLocation.CreateFromBagAndSlot) then
    return false
  end

  local okCreate, itemLoc = pcall(ItemLocation.CreateFromBagAndSlot, ItemLocation, bagID, slot)
  if not okCreate or not itemLoc then
    return false
  end

  local okBound, isBound = pcall(C_Item.IsBound, itemLoc)
  if okBound and isBound then
    return true
  end

  return false
end

local function MergeByItem(dst, src)
  if type(dst) ~= "table" or type(src) ~= "table" then return end
  for key, data in pairs(src) do
    if type(data) == "table" then
      local resolvedKey = data.key or MakeByItemKey(data.itemID, data.link) or key
      local d = dst[resolvedKey]
      if not d then
        dst[resolvedKey] = {
          key = resolvedKey,
          itemID = tonumber(data.itemID),
          link = data.link,
          name = data.name,
          count = tonumber(data.count) or 0,
          value = tonumber(data.value) or 0,
          category = ResolveEntryCategory(data.itemID, data.link, data.category),
        }
      else
        d.count = (tonumber(d.count) or 0) + (tonumber(data.count) or 0)
        d.value = (tonumber(d.value) or 0) + (tonumber(data.value) or 0)
        if not d.itemID and data.itemID then
          d.itemID = tonumber(data.itemID)
        end
        if (not d.link or d.link == "") and data.link then
          d.link = data.link
        end
        if (not d.name or d.name == "") and data.name then
          d.name = data.name
        end
        local resolvedCategory = ResolveEntryCategory(data.itemID or d.itemID, data.link or d.link, data.category or d.category)
        if not d.category or d.category == "other" or (resolvedCategory and resolvedCategory ~= "other") then
          d.category = resolvedCategory
        end
      end
    end
  end
end

ParseProvider = function(p)
  if p == nil then
    return "vendor", nil
  end
  if type(p) ~= "string" then
    p = tostring(p)
  end
  p = Trim(p)
  if p == "" then
    return "vendor", nil
  end

  local a, b = p:match("^([%a_]+)%:(.+)$")
  if a and b then
    a = a:lower()
    b = Trim(b)
    if b == "" then b = nil end
    return a, b
  end

  return p:lower(), nil
end

local function GetVendorSell(itemID)
  itemID = tonumber(itemID)
  if not itemID then return 0 end
  local sellPrice = select(11, GetItemInfo(itemID))
  if sellPrice then
    return tonumber(sellPrice) or 0
  end
  return 0
end

-- -------------------------------------------------------
-- Auctionator
-- -------------------------------------------------------
local function GetAuctionatorValue(itemID)
  itemID = tonumber(itemID)
  if not itemID then return nil end

  if Auctionator and Auctionator.API and Auctionator.API.v1 and Auctionator.API.v1.GetAuctionPriceByItemID then
    local ok, price = pcall(Auctionator.API.v1.GetAuctionPriceByItemID, ADDON_NAME, itemID)
    if ok and price then
      price = tonumber(price) or 0
      if price > 0 then
        return price
      end
      return 0
    end
  end

  return nil
end

-- -------------------------------------------------------
-- TSM (supports TSM_API and TSMAPI_FOUR)
-- -------------------------------------------------------
local function HasTSM()
  if TSM_API and TSM_API.GetCustomPriceValue then return true end
  if TSMAPI_FOUR and TSMAPI_FOUR.CustomPrice and TSMAPI_FOUR.CustomPrice.GetValue then return true end
  return false
end

local function ToTSMItemString(itemID, itemLink)
  if itemLink then
    if TSM_API and TSM_API.ToItemString then
      local s = TSM_API.ToItemString(itemLink)
      if s then return s end
    end
    if TSMAPI_FOUR and TSMAPI_FOUR.Item and TSMAPI_FOUR.Item.ToItemString then
      local s = TSMAPI_FOUR.Item.ToItemString(itemLink)
      if s then return s end
    end
  end

  itemID = tonumber(itemID)
  if itemID then
    return "i:" .. tostring(itemID)
  end
  return nil
end

local function GetTSMValue(itemID, itemLink, priceString)
  if not HasTSM() then return nil end

  priceString = Trim(priceString)
  if priceString == "" then
    priceString = DEFAULT_TSM_PRICE
  end

  local itemString = ToTSMItemString(itemID, itemLink)
  if not itemString then return nil end

  local v
  if TSM_API and TSM_API.GetCustomPriceValue then
    v = TSM_API.GetCustomPriceValue(priceString, itemString)
  elseif TSMAPI_FOUR and TSMAPI_FOUR.CustomPrice and TSMAPI_FOUR.CustomPrice.GetValue then
    v = TSMAPI_FOUR.CustomPrice.GetValue(priceString, itemString)
  end

  v = tonumber(v)
  if not v or v <= 0 then
    return nil
  end
  return v
end

-- -------------------------------------------------------
-- Unified pricing
-- -------------------------------------------------------
local function GetItemUnitValue(itemID, itemLink, providerStr, valueCache)
  if not itemID then
    if itemLink then
      itemID = select(1, GetItemInfoInstant(itemLink))
    end
  end
  itemID = tonumber(itemID)
  if not itemID then return 0 end

  local provider, extra = ParseProvider(providerStr)
  local cacheKey = nil
  if type(valueCache) == "table" then
    cacheKey = tostring(providerStr or DEFAULT_PROVIDER) .. "" .. tostring(itemID)
    local cached = valueCache[cacheKey]
    if cached ~= nil then
      return tonumber(cached) or 0
    end
  end

  local out = 0

  if provider == "tsm" then
    local v = GetTSMValue(itemID, itemLink, extra or DEFAULT_TSM_PRICE)
    if v then
      out = v
    end
  elseif provider == "auctionator" then
    local v = GetAuctionatorValue(itemID)
    if v ~= nil then
      out = v
    end
  else
    out = GetVendorSell(itemID) or 0
  end

  if cacheKey then
    valueCache[cacheKey] = tonumber(out) or 0
  end

  return tonumber(out) or 0
end

-- -------------------------------------------------------
-- Bags scan (sync)
-- -------------------------------------------------------
local function GetBagIDs()
  local ids = {}
  for bag = 0, (NUM_BAG_SLOTS or 4) do
    ids[#ids + 1] = bag
  end
  if Enum and Enum.BagIndex and Enum.BagIndex.ReagentBag then
    ids[#ids + 1] = Enum.BagIndex.ReagentBag
  end
  return ids
end

local function ScanBags(providerStr, filters, valueCache)
  local total = 0
  local byItem = {}
  local pricedStacks, pricedQty = 0, 0
  local skippedStacks, skippedQty = 0, 0
  local skippedEntries = {}

  if not (C_Container and C_Container.GetContainerNumSlots and C_Container.GetContainerItemInfo) then
    return total, byItem, pricedStacks, pricedQty, skippedStacks, skippedQty, skippedEntries
  end

  for _, bagID in ipairs(GetBagIDs()) do
    local slots = C_Container.GetContainerNumSlots(bagID) or 0
    for slot = 1, slots do
      local info = C_Container.GetContainerItemInfo(bagID, slot)
      if info and info.stackCount and info.stackCount > 0 then
        local count = tonumber(info.stackCount) or 0
        local link = info.hyperlink
        local itemID = info.itemID or (link and select(1, GetItemInfoInstant(link)) or nil)
        local quality = info.quality
        local isBound = IsItemBoundAtContainerSlot(bagID, slot)

        if isBound then
          skippedStacks = skippedStacks + 1
          skippedQty = skippedQty + count
          AddSkippedEntry(skippedEntries, itemID, link, count, "Bound")
        else
          local include, skipReason, category = ShouldIncludeItem(itemID, link, quality, filters)
          if not include then
            skippedStacks = skippedStacks + 1
            skippedQty = skippedQty + count
            AddSkippedEntry(skippedEntries, itemID, link, count, skipReason)
          else
            local perItem = GetItemUnitValue(itemID, link, providerStr, valueCache)
            if perItem and perItem > 0 then
              local stackValue = perItem * count
              total = total + stackValue

              pricedStacks = pricedStacks + 1
              pricedQty = pricedQty + count

              AddValuedEntry(byItem, itemID, link, count, stackValue, category)
            else
              skippedStacks = skippedStacks + 1
              skippedQty = skippedQty + count
              AddSkippedEntry(skippedEntries, itemID, link, count, GetNoValueReason(providerStr))
            end
          end
        end
      end
    end
  end

  return total, byItem, pricedStacks, pricedQty, skippedStacks, skippedQty, skippedEntries
end

-- -------------------------------------------------------
-- Personal bank scan (sync, requires bank open)
-- -------------------------------------------------------
local function IsBankOpen()
  if C_PlayerInteractionManager and Enum and Enum.PlayerInteractionType and Enum.PlayerInteractionType.Banker and C_PlayerInteractionManager.IsInteractingWithNpcOfType then
    local ok, isBanker = pcall(C_PlayerInteractionManager.IsInteractingWithNpcOfType, Enum.PlayerInteractionType.Banker)
    if ok and isBanker then
      return true
    end
  end

  if BankFrame and BankFrame.IsShown and BankFrame:IsShown() then
    return true
  end

  return false
end

local function GetBankIDs()
  local ids, seen = {}, {}

  local bankContainer = -1
  if Enum and Enum.BagIndex and Enum.BagIndex.Bank ~= nil then
    bankContainer = Enum.BagIndex.Bank
  elseif BANK_CONTAINER ~= nil then
    bankContainer = BANK_CONTAINER
  end

  if bankContainer ~= nil and not seen[bankContainer] then
    ids[#ids + 1] = bankContainer
    seen[bankContainer] = true
  end

  local start = (NUM_BAG_SLOTS or 4) + 1
  local n = NUM_BANKBAGSLOTS or 7
  for i = 0, n - 1 do
    local bagID = start + i
    if not seen[bagID] then
      ids[#ids + 1] = bagID
      seen[bagID] = true
    end
  end

  if REAGENTBANK_CONTAINER ~= nil and not seen[REAGENTBANK_CONTAINER] then
    ids[#ids + 1] = REAGENTBANK_CONTAINER
    seen[REAGENTBANK_CONTAINER] = true
  end
  if Enum and Enum.BagIndex and Enum.BagIndex.ReagentBank ~= nil and not seen[Enum.BagIndex.ReagentBank] then
    ids[#ids + 1] = Enum.BagIndex.ReagentBank
    seen[Enum.BagIndex.ReagentBank] = true
  end

  return ids
end

local function ScanBank(providerStr, filters, valueCache)
  local total = 0
  local byItem = {}
  local pricedStacks, pricedQty = 0, 0
  local skippedStacks, skippedQty = 0, 0
  local skippedEntries = {}

  if not (C_Container and C_Container.GetContainerNumSlots and C_Container.GetContainerItemInfo) then
    return total, byItem, pricedStacks, pricedQty, skippedStacks, skippedQty, skippedEntries
  end

  if not IsBankOpen() then
    return nil, nil, nil, nil, nil, nil, nil, "Personal bank must be open to scan."
  end

  for _, bagID in ipairs(GetBankIDs()) do
    local slots = C_Container.GetContainerNumSlots(bagID) or 0
    for slot = 1, slots do
      local info = C_Container.GetContainerItemInfo(bagID, slot)
      if info and info.stackCount and info.stackCount > 0 then
        local count = tonumber(info.stackCount) or 0
        local link = info.hyperlink
        local itemID = info.itemID or (link and select(1, GetItemInfoInstant(link)) or nil)
        local quality = info.quality
        local isBound = IsItemBoundAtContainerSlot(bagID, slot)

        if isBound then
          skippedStacks = skippedStacks + 1
          skippedQty = skippedQty + count
          AddSkippedEntry(skippedEntries, itemID, link, count, "Bound")
        else
          local include, skipReason, category = ShouldIncludeItem(itemID, link, quality, filters)
          if not include then
            skippedStacks = skippedStacks + 1
            skippedQty = skippedQty + count
            AddSkippedEntry(skippedEntries, itemID, link, count, skipReason)
          else
            local perItem = GetItemUnitValue(itemID, link, providerStr, valueCache)
            if perItem and perItem > 0 then
              local stackValue = perItem * count
              total = total + stackValue

              pricedStacks = pricedStacks + 1
              pricedQty = pricedQty + count

              AddValuedEntry(byItem, itemID, link, count, stackValue, category)
            else
              skippedStacks = skippedStacks + 1
              skippedQty = skippedQty + count
              AddSkippedEntry(skippedEntries, itemID, link, count, GetNoValueReason(providerStr))
            end
          end
        end
      end
    end
  end

  return total, byItem, pricedStacks, pricedQty, skippedStacks, skippedQty, skippedEntries
end

-- -------------------------------------------------------
-- Guild bank scan (async, no caching)
-- -------------------------------------------------------
local function IsGBOpen()
  if IsGuildBankFrameOpen then
    return IsGuildBankFrameOpen()
  end
  if GuildBankFrame and GuildBankFrame.IsShown and GuildBankFrame:IsShown() then
    return true
  end
  return false
end

local function TabIsViewable(tab)
  if not GetGuildBankTabInfo then return true end
  local _, _, isViewable = GetGuildBankTabInfo(tab)
  if isViewable == nil then return true end
  return isViewable and true or false
end

local function GetGBLink(tab, slot)
  if GetGuildBankItemLink then
    local link = GetGuildBankItemLink(tab, slot)
    if link then return link end
  end
  if C_TooltipInfo and C_TooltipInfo.GetGuildBankItem then
    local data = C_TooltipInfo.GetGuildBankItem(tab, slot)
    if data and data.hyperlink then
      return data.hyperlink
    end
  end
  return nil
end

local function ScanGBTab(tab, providerStr, byItem, filters, valueCache)
  local total = 0
  local pricedStacks, pricedQty = 0, 0
  local skippedStacks, skippedQty = 0, 0
  local skippedEntries = {}
  local foundAny = 0

  if not GetGuildBankItemInfo then
    return 0, 0, 0, 0, 0, 0, skippedEntries
  end

  for slot = 1, 98 do
    local _, count = GetGuildBankItemInfo(tab, slot)
    count = tonumber(count) or 0
    if count > 0 then
      foundAny = foundAny + 1

      local link = GetGBLink(tab, slot)
      local itemID = link and select(1, GetItemInfoInstant(link)) or nil
      local quality = GetItemQuality(itemID, link, nil)
      local include, skipReason, category = ShouldIncludeItem(itemID, link, quality, filters)

      if not include then
        skippedStacks = skippedStacks + 1
        skippedQty = skippedQty + count
        AddSkippedEntry(skippedEntries, itemID, link, count, skipReason)
      else
        local perItem = GetItemUnitValue(itemID, link, providerStr)

        if perItem and perItem > 0 then
          local stackValue = perItem * count
          total = total + stackValue

          pricedStacks = pricedStacks + 1
          pricedQty = pricedQty + count

          if link then
            local e = byItem[link]
            if not e then
              e = { count = 0, value = 0, category = category }
              byItem[link] = e
            elseif not e.category and category then
              e.category = category
            end
            e.count = e.count + count
            e.value = e.value + stackValue
          end
        else
          skippedStacks = skippedStacks + 1
          skippedQty = skippedQty + count
          AddSkippedEntry(skippedEntries, itemID, link, count, GetNoValueReason(providerStr))
        end
      end
    end
  end

  return total, pricedStacks, pricedQty, skippedStacks, skippedQty, foundAny, skippedEntries
end

BW._gb = BW._gb or {
  running = false,
  lastChangedTab = nil,
}

local GBEventFrame = BW._gbEventFrame
if not GBEventFrame then
  GBEventFrame = CreateFrame("Frame")
  BW._gbEventFrame = GBEventFrame

  GBEventFrame:RegisterEvent("GUILDBANKBAGSLOTS_CHANGED")
  GBEventFrame:RegisterEvent("GUILDBANKFRAME_CLOSED")
  GBEventFrame:SetScript("OnEvent", function(_, event, arg1)
    if event == "GUILDBANKBAGSLOTS_CHANGED" then
      BW._gb.lastChangedTab = tonumber(arg1)
      return
    end

    if event == "GUILDBANKFRAME_CLOSED" then
      if BW._gb.running then
        BW._gb.running = false
      end
      return
    end
  end)
end

local function StartGuildBankScan(providerStr, cb, filters)
  if BW._gb.running then
    if cb then
      SafeEmit(cb, "error", { scope="gb", source=providerStr, message="Guild bank scan already running." })
    end
    return
  end

  if InCombatLockdown and InCombatLockdown() then
    if cb then SafeEmit(cb, "error", { scope="gb", source=providerStr, message="Cannot scan guild bank in combat." }) end
    return
  end

  if not IsGBOpen() then
    if cb then SafeEmit(cb, "error", { scope="gb", source=providerStr, message="Guild bank must be open to scan." }) end
    return
  end

  if not (GetNumGuildBankTabs and SetCurrentGuildBankTab and QueryGuildBankTab) then
    if cb then SafeEmit(cb, "error", { scope="gb", source=providerStr, message="Guild bank API not available in this UI state." }) end
    return
  end

  local tabs = GetNumGuildBankTabs() or 0
  if tabs <= 0 then
    if cb then
      SafeEmit(cb, "done", {
        scope="gb", source=providerStr, total=0, bagsTotal=0, bankTotal=0, gbTotal=0, top={}, skippedItems={},
        byItem={}, skippedEntries={}, breakdown={}, skippedBreakdown={},
        pricedStacks=0, pricedQty=0, skippedStacks=0, skippedQty=0,
        bankScanned=false, cached=false, note="No guild bank tabs.", pending=false
      })
    end
    return
  end

  local origTab = GetCurrentGuildBankTab and GetCurrentGuildBankTab() or 1

  local state = {
    source = providerStr,
    filters = filters,
    tabs = tabs,
    tab = 1,
    tabTry = 0,
    valueCache = {},

    total = 0,
    byItem = {},
    skippedEntries = {},
    pricedStacks = 0,
    pricedQty = 0,
    skippedStacks = 0,
    skippedQty = 0,

    origTab = origTab,
  }

  BW._gb.running = true

  local function Finish(note)
    if SetCurrentGuildBankTab and state.origTab then
      SetCurrentGuildBankTab(state.origTab)
      if QueryGuildBankTab then
        QueryGuildBankTab(state.origTab)
      end
    end

    BW._gb.running = false

    if cb then
      SafeEmit(cb, "done", {
        scope="gb",
        source=state.source,
        total=state.total,
        bagsTotal=0,
        bankTotal=0,
        gbTotal=state.total,
        byItem=state.byItem,
        skippedEntries=state.skippedEntries,
        bagsByItem={}, bankByItem={}, gbByItem=state.byItem,
        bagsSkippedItems={}, bankSkippedItems={},
        gbSkippedItems=BuildSkippedList(state.skippedEntries, 1000),
        top=BuildTopList(state.byItem, 1000),
        breakdown=BuildBreakdownList(state.byItem, 1000),
        skippedItems=BuildSkippedList(state.skippedEntries, 1000),
        skippedBreakdown=BuildSkippedList(state.skippedEntries, 1000),
        pricedStacks=state.pricedStacks,
        pricedQty=state.pricedQty,
        skippedStacks=state.skippedStacks,
        skippedQty=state.skippedQty,
        bankScanned=false,
        cached=false,
        note=note,
        pending=false,
      })
    end
  end

  local function Step()
    if not BW._gb.running then return end

    if not IsGBOpen() then
      Finish("Guild bank closed during scan.")
      return
    end

    if state.tab > state.tabs then
      Finish(nil)
      return
    end

    if not TabIsViewable(state.tab) then
      state.tab = state.tab + 1
      state.tabTry = 0
      C_Timer.NewTimer(0.03, Step)
      return
    end

    if cb then
      SafeEmit(cb, "progress", { scope="gb", source=state.source, tab=state.tab, tabs=state.tabs, phase="query" })
    end

    BW._gb.lastChangedTab = nil
    SetCurrentGuildBankTab(state.tab)
    QueryGuildBankTab(state.tab)

    C_Timer.NewTimer(0.35, function()
      if not BW._gb.running then return end

      local changedOk = (BW._gb.lastChangedTab == state.tab)
      local t, ps, pq, ss, sq, foundAny, skippedEntries = ScanGBTab(state.tab, state.source, state.byItem, state.filters, state.valueCache)

      if (foundAny or 0) == 0 and not changedOk and state.tabTry < 4 then
        state.tabTry = state.tabTry + 1
        local wait = 0.40 + (state.tabTry * 0.30)
        QueryGuildBankTab(state.tab)
        if cb then
          SafeEmit(cb, "progress", { scope="gb", source=state.source, tab=state.tab, tabs=state.tabs, phase="retry" })
        end
        C_Timer.NewTimer(wait, Step)
        return
      end

      state.total = state.total + (t or 0)
      state.pricedStacks = state.pricedStacks + (ps or 0)
      state.pricedQty = state.pricedQty + (pq or 0)
      state.skippedStacks = state.skippedStacks + (ss or 0)
      state.skippedQty = state.skippedQty + (sq or 0)
      MergeSkippedEntries(state.skippedEntries, skippedEntries)

      state.tab = state.tab + 1
      state.tabTry = 0

      C_Timer.NewTimer(0.05, Step)
    end)
  end

  Step()
end

-- -------------------------------------------------------
-- Public Scan API (NO CACHING)
-- -------------------------------------------------------
function BW:Scan(scope, providerStr, cb, opt)
  opt = opt or {}
  scope = tostring(scope or "bags"):lower()
  providerStr = Trim(providerStr)
  if providerStr == "" then providerStr = DEFAULT_PROVIDER end

  local filters = NormalizeFilters(opt)

  if scope == "bags" then
    local scanValueCache = {}
    local total, byItem, ps, pq, ss, sq, skippedEntries = ScanBags(providerStr, filters, scanValueCache)
    if cb then
      local skipped = BuildSkippedList(skippedEntries, 1000)
      SafeEmit(cb, "done", {
        scope="bags", source=providerStr,
        total=total, bagsTotal=total, bankTotal=0, gbTotal=0,
        byItem=byItem, skippedEntries=skippedEntries,
        bagsByItem=byItem, bankByItem={}, gbByItem={},
        bagsSkippedItems=skipped, bankSkippedItems={}, gbSkippedItems={},
        top=BuildTopList(byItem, 1000),
        breakdown=BuildBreakdownList(byItem, 1000),
        skippedItems=skipped,
        skippedBreakdown=skipped,
        pricedStacks=ps, pricedQty=pq, skippedStacks=ss, skippedQty=sq,
        bankScanned=false,
        cached=false,
        pending=false,
      })
    end
    return
  end

  if scope == "bank" then
    local scanValueCache = {}
    local total, byItem, ps, pq, ss, sq, skippedEntries, err = ScanBank(providerStr, filters, scanValueCache)
    if err then
      if cb then SafeEmit(cb, "error", { scope="bank", source=providerStr, message=err }) end
      return
    end

    if cb then
      local skipped = BuildSkippedList(skippedEntries or {}, 1000)
      SafeEmit(cb, "done", {
        scope="bank", source=providerStr,
        total=total or 0, bagsTotal=0, bankTotal=total or 0, gbTotal=0,
        byItem=byItem or {}, skippedEntries=skippedEntries or {},
        bagsByItem={}, bankByItem=byItem or {}, gbByItem={},
        bagsSkippedItems={}, bankSkippedItems=skipped, gbSkippedItems={},
        top=BuildTopList(byItem or {}, 1000),
        breakdown=BuildBreakdownList(byItem or {}, 1000),
        skippedItems=skipped,
        skippedBreakdown=skipped,
        pricedStacks=ps or 0, pricedQty=pq or 0, skippedStacks=ss or 0, skippedQty=sq or 0,
        bankScanned=true,
        cached=false,
        pending=false,
      })
    end
    return
  end

  if scope == "bagsbank" then
    local scanValueCache = {}
    local bagTotal, bagByItem, bps, bpq, bss, bsq, bagSkipped = ScanBags(providerStr, filters, scanValueCache)
    local bankTotal, bankByItem, kps, kpq, kss, ksq, bankSkipped, err = ScanBank(providerStr, filters, scanValueCache)
    if err then
      if cb then SafeEmit(cb, "error", { scope="bagsbank", source=providerStr, message=err }) end
      return
    end

    local byItem = {}
    MergeByItem(byItem, bagByItem)
    MergeByItem(byItem, bankByItem)

    local skippedEntries = {}
    MergeSkippedEntries(skippedEntries, bagSkipped)
    MergeSkippedEntries(skippedEntries, bankSkipped)

    local total = (bagTotal or 0) + (bankTotal or 0)

    if cb then
      local bagsSkipped = BuildSkippedList(bagSkipped or {}, 1000)
      local bankSkippedList = BuildSkippedList(bankSkipped or {}, 1000)
      SafeEmit(cb, "done", {
        scope="bagsbank", source=providerStr,
        total=total, bagsTotal=bagTotal or 0, bankTotal=bankTotal or 0, gbTotal=0,
        byItem=byItem, skippedEntries=skippedEntries,
        bagsByItem=bagByItem, bankByItem=bankByItem, gbByItem={},
        bagsSkippedItems=bagsSkipped, bankSkippedItems=bankSkippedList, gbSkippedItems={},
        top=BuildTopList(byItem, 1000),
        breakdown=BuildBreakdownList(byItem, 1000),
        skippedItems=BuildSkippedList(skippedEntries, 1000),
        skippedBreakdown=BuildSkippedList(skippedEntries, 1000),
        pricedStacks=(bps or 0) + (kps or 0),
        pricedQty=(bpq or 0) + (kpq or 0),
        skippedStacks=(bss or 0) + (kss or 0),
        skippedQty=(bsq or 0) + (ksq or 0),
        bankScanned=true,
        cached=false,
        pending=false,
      })
    end
    return
  end

  if scope == "gb" then
    if cb then
      SafeEmit(cb, "progress", { scope="gb", source=providerStr, tab=0, tabs=(GetNumGuildBankTabs and GetNumGuildBankTabs() or 0), phase="start" })
    end
    StartGuildBankScan(providerStr, cb, filters)
    return
  end

  if scope == "all" then
    local scanValueCache = {}
    local bagTotal, bagByItem, bps, bpq, bss, bsq, bagSkipped = ScanBags(providerStr, filters, scanValueCache)

    local bankTotal, bankByItem, kps, kpq, kss, ksq, bankSkipped = 0, {}, 0, 0, 0, 0, {}
    local bankScanned = false
    if IsBankOpen() then
      local t, bi, ps, pq, ss, sq, skippedEntries, err = ScanBank(providerStr, filters, scanValueCache)
      if not err then
        bankTotal = tonumber(t) or 0
        bankByItem = bi or {}
        bankSkipped = skippedEntries or {}
        kps, kpq, kss, ksq = ps or 0, pq or 0, ss or 0, sq or 0
        bankScanned = true
      end
    end

    local combinedSkipped = {}
    MergeSkippedEntries(combinedSkipped, bagSkipped)
    if bankScanned then
      MergeSkippedEntries(combinedSkipped, bankSkipped)
    end

    if cb then
      local preScope = "bags"
      local preByItem = bagByItem
      local preNote = "Bags scanned (guild bank will add when scanned)."
      local preBagsSkipped = BuildSkippedList(bagSkipped or {}, 1000)
      local preBankSkipped = bankScanned and BuildSkippedList(bankSkipped or {}, 1000) or {}

      if bankScanned then
        preScope = "bagsbank"
        preNote = "Bags & bank scanned (guild bank will add when scanned)."
        preByItem = {}
        MergeByItem(preByItem, bagByItem)
        MergeByItem(preByItem, bankByItem)
      end

      SafeEmit(cb, "done", {
        scope=preScope, source=providerStr,
        total=(bagTotal or 0) + (bankTotal or 0), bagsTotal=bagTotal or 0, bankTotal=bankTotal or 0, gbTotal=0,
        byItem=preByItem, skippedEntries=combinedSkipped,
        bagsByItem=bagByItem, bankByItem=bankScanned and bankByItem or {}, gbByItem={},
        bagsSkippedItems=preBagsSkipped, bankSkippedItems=preBankSkipped, gbSkippedItems={},
        top=BuildTopList(preByItem, 1000),
        breakdown=BuildBreakdownList(preByItem, 1000),
        skippedItems=BuildSkippedList(combinedSkipped, 1000),
        skippedBreakdown=BuildSkippedList(combinedSkipped, 1000),
        pricedStacks=(bps or 0) + (kps or 0),
        pricedQty=(bpq or 0) + (kpq or 0),
        skippedStacks=(bss or 0) + (kss or 0),
        skippedQty=(bsq or 0) + (ksq or 0),
        bankScanned=bankScanned,
        cached=false,
        note=preNote,
        pending=true,
      })
    end

    local combinedByItem = {}
    MergeByItem(combinedByItem, bagByItem)
    if bankScanned then
      MergeByItem(combinedByItem, bankByItem)
    end

    StartGuildBankScan(providerStr, function(event, payload)
      if not cb then return end

      if event == "progress" then
        SafeEmit(cb, "progress", payload)
        return
      end

      if event == "error" then
        SafeEmit(cb, "error", payload)
        return
      end

      if event == "done" then
        if type(payload.byItem) == "table" then
          MergeByItem(combinedByItem, payload.byItem)
        elseif type(payload.top) == "table" then
          for i = 1, #payload.top do
            local e = payload.top[i]
            if type(e) == "table" and type(e.link) == "string" then
              local d = combinedByItem[e.link]
              if not d then
                combinedByItem[e.link] = {
                  count = tonumber(e.count) or 0,
                  value = tonumber(e.value) or 0,
                }
              else
                d.count = (tonumber(d.count) or 0) + (tonumber(e.count) or 0)
                d.value = (tonumber(d.value) or 0) + (tonumber(e.value) or 0)
              end
            end
          end
        end

        local finalSkipped = {}
        MergeSkippedEntries(finalSkipped, combinedSkipped)

        if type(payload.skippedItems) == "table" then
          for i = 1, #payload.skippedItems do
            local e = payload.skippedItems[i]
            if type(e) == "table" then
              AddSkippedEntry(finalSkipped, nil, e.link, e.count, e.reason)
              local key = tostring(e.reason or "Skipped") .. "" .. tostring(e.link or "") .. ""
              if finalSkipped[key] and e.name then
                finalSkipped[key].name = e.name
              end
            end
          end
        end

        local gbTotal = tonumber(payload.total) or 0
        local total = (bagTotal or 0) + (bankTotal or 0) + gbTotal
        local gbByItem = (type(payload.byItem) == "table") and payload.byItem or {}
        local gbSkipped = payload.gbSkippedItems or BuildSkippedList(payload.skippedEntries or {}, 1000)

        SafeEmit(cb, "done", {
          scope="all", source=providerStr,
          total=total, bagsTotal=bagTotal or 0, bankTotal=bankTotal or 0, gbTotal=gbTotal,
          byItem=combinedByItem, skippedEntries=finalSkipped,
          bagsByItem=bagByItem, bankByItem=bankScanned and bankByItem or {}, gbByItem=gbByItem,
          bagsSkippedItems=BuildSkippedList(bagSkipped or {}, 1000),
          bankSkippedItems=bankScanned and BuildSkippedList(bankSkipped or {}, 1000) or {},
          gbSkippedItems=gbSkipped,
          top=BuildTopList(combinedByItem, 1000),
          breakdown=BuildBreakdownList(combinedByItem, 1000),
          skippedItems=BuildSkippedList(finalSkipped, 1000),
          skippedBreakdown=BuildSkippedList(finalSkipped, 1000),
          pricedStacks=(bps or 0) + (kps or 0) + (payload.pricedStacks or 0),
          pricedQty=(bpq or 0) + (kpq or 0) + (payload.pricedQty or 0),
          skippedStacks=(bss or 0) + (kss or 0) + (payload.skippedStacks or 0),
          skippedQty=(bsq or 0) + (ksq or 0) + (payload.skippedQty or 0),
          bankScanned=bankScanned,
          cached=false,
          pending=false,
        })
      end
    end, filters)

    return
  end

  if cb then
    SafeEmit(cb, "error", { scope=scope, source=providerStr, message="Invalid scope: " .. tostring(scope) })
  end
end

-- -------------------------------------------------------
-- Init
-- -------------------------------------------------------
function BW:Init()
  if BW._inited then return end
  BW._inited = true

  local f = CreateFrame("Frame")
  f:RegisterEvent("PLAYER_ENTERING_WORLD")
  f:SetScript("OnEvent", function() end)
end