local _, ns = ...

local UIC = ns._uiConst or {}
ns._uiConst = UIC
UIC.THEME_MARBLE = "Interface\\FrameGeneral\\UI-Background-Marble"
UIC.THEME_DARK = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark"
UIC.THEME_BORDER = "Interface\\Tooltips\\UI-Tooltip-Border"
UIC.THEME_FLAT = "Interface\\Buttons\\WHITE8X8"
UIC.THEME_HOUSING_BG_TGA = "Interface\\AddOns\\DecorDrops\\Media\\HousingBackdrop.tga"
UIC.THEME_HOUSING_BG_PNG = "Interface\\AddOns\\DecorDrops\\Media\\HousingBackdrop.png"
UIC.THEME_HOUSING_BG_JPG = "Interface\\AddOns\\DecorDrops\\Media\\HousingBackdrop.jpg"
UIC.ICON_BOOK_CATALOG = "Interface\\Icons\\INV_Misc_Book_09"
UIC.ICON_PROFESSIONS = "Interface\\Icons\\INV_Inscription_Tradeskill01"
UIC.ICON_TRACKER = "Interface\\AddOns\\DecorDrops\\Media\\tracking.png"
UIC.ICON_TRAINER = "Interface\\Icons\\INV_Misc_Book_11"
UIC.ICON_LOCATION = "Interface\\Icons\\INV_Misc_Map_01"
UIC.ICON_VENDOR = "Interface\\Icons\\INV_Misc_Coin_02"
UIC.ICON_ACHIEVEMENT = "Interface\\Icons\\Achievement_Quests_Completed_08"
UIC.ICON_QUEST = "Interface\\GossipFrame\\AvailableQuestIcon"
UIC.ICON_REPUTATION = "Interface\\Icons\\Achievement_Reputation_01"
UIC.ICON_ENDEAVOR = "Interface\\Icons\\INV_Misc_Note_01"
local PROFESSION_ICON_BY_NAME = {
  ["Alchemy"] = "Interface\\Icons\\Trade_Alchemy",
  ["Blacksmithing"] = "Interface\\Icons\\Trade_BlackSmithing",
  ["Cooking"] = "Interface\\Icons\\INV_Misc_Food_15",
  ["Enchanting"] = "Interface\\Icons\\Trade_Engraving",
  ["Engineering"] = "Interface\\Icons\\Trade_Engineering",
  ["Fishing"] = "Interface\\Icons\\Trade_Fishing",
  ["Herbalism"] = "Interface\\Icons\\Trade_Herbalism",
  ["Inscription"] = "Interface\\Icons\\INV_Inscription_Tradeskill01",
  ["Jewelcrafting"] = "Interface\\Icons\\INV_Misc_Gem_01",
  ["Leatherworking"] = "Interface\\Icons\\Trade_LeatherWorking",
  ["Mining"] = "Interface\\Icons\\Trade_Mining",
  ["Skinning"] = "Interface\\Icons\\INV_Misc_Pelt_Wolf_01",
  ["Tailoring"] = "Interface\\Icons\\Trade_Tailoring",
}
local TYPE_ICON_FALLBACK_TEXTURE = "Interface\\LFGFrame\\LFG-Eye"
local SWIRL_FALLBACK_COORDS = { 0.50, 0.75, 0.00, 1.00 }
local collectionStateCache = {}
local recipeResourceCache = {}
local reagentPriceCache = {}
local reagentPriceTooltip = nil
local trainerWaypointUID = nil
local pendingManualWaypointEntry = nil
local activeManualWaypointInstance = nil
local worldMapLayerHooked = false
local trainerMapNameCache = {}
local trainerMapIndexBuilt = false
local trainerMapIDsByName = {}
local auctionHousePriceSession = {
  active = false,
  snapshot = {},
}
local RARITY_FILTERS = {
  { quality = LE_ITEM_QUALITY_COMMON or 1, label = "Common" },
  { quality = LE_ITEM_QUALITY_UNCOMMON or 2, label = "Uncommon" },
  { quality = LE_ITEM_QUALITY_RARE or 3, label = "Rare" },
  { quality = LE_ITEM_QUALITY_EPIC or 4, label = "Epic" },
  { quality = LE_ITEM_QUALITY_LEGENDARY or 5, label = "Legendary" },
}
local ITEM_LAYOUT = {
  contentInsetX = 8,
  rowWidth = 770,
  rowHeight = 60,
  rowGap = 64,
  headerY = -120,
  item = { x = 4, width = 305 },
  source = { x = 322, width = 190 },
  rate = { x = 518, width = 74 },
  view = { x = 596, width = 44 },
  waypoint = { x = 646, width = 76 },
  track = { x = 730, width = 34 },
}
ns._VIEW_BUTTON_WIDTH = math.floor((ITEM_LAYOUT.view.width * 1.2) + 0.5)
ns._VIEW_BUTTON_HEIGHT = math.floor((24 * 1.2) + 0.5)
UIC.SEARCH_TO_FILTER_GAP = 10
UIC.RESOURCE_ICON_SIZE = 25
UIC.RESOURCE_ICON_GAP = 8
UIC.RESOURCE_TEXT_Y_OFFSET = -1
UIC.MONEY_ICON_GOLD = "Interface\\MoneyFrame\\UI-GoldIcon"
UIC.MONEY_ICON_SILVER = "Interface\\MoneyFrame\\UI-SilverIcon"
UIC.MONEY_ICON_COPPER = "Interface\\MoneyFrame\\UI-CopperIcon"
UIC.MONEY_ICON_SIZE_ROW = 13
UIC.MONEY_ICON_SIZE_HEADER = UIC.MONEY_ICON_SIZE_ROW
UIC.PURCHASE_ICON_SIZE = 18
UIC.AUCTION_HOUSE_NET_RATE = 0.95
UIC.ICON_VIEW_EYE = "Interface\\LFGFrame\\LFG-Eye"
ns._ITEM_RENDER_BATCH_SIZE = ns._ITEM_RENDER_BATCH_SIZE or 24
UIC.BASE_FRAME_WIDTH = 1140
UIC.BASE_FRAME_HEIGHT = 700
UIC.LEFT_LIST_WIDTH = 242
UIC.SUBCATEGORY_LEFT_INDENT = 18
UIC.SUBCATEGORY_ARROW_ANCHOR_OFFSET = 3
local TRAINER_MAP_NAME_OVERRIDES = {
  ["Elwynn Forest"] = 37,
  ["Orgrimmar"] = 85,
  ["Stormwind City"] = 84,
  ["Hellfire Peninsula"] = 100,
  ["Shattrath City"] = 111,
  ["Dalaran (Northrend)"] = 125,
  ["Jade Forest"] = 371,
  ["Valley of the Four Winds"] = 376,
  ["Stormshield"] = 622,
  ["Warspear"] = 624,
  ["Dalaran (Broken Isles)"] = 627,
  ["Boralus"] = 1161,
  ["Dazar'alor"] = 1165,
  ["Oribos"] = 1670,
  ["Valdrakken"] = 2112,
  ["Dornogal"] = 2339,
}
local EXPANSION_RELEASE_ORDER = {
  "Classic",
  "The Burning Crusade",
  "Wrath of the Lich King",
  "Cataclysm",
  "Mists of Pandaria",
  "Warlords of Draenor",
  "Legion",
  "Battle for Azeroth",
  "Shadowlands",
  "Dragonflight",
  "The War Within",
  "Midnight",
}
UIC.CURRENT_SEASON_GROUP = "Current Season"
ns._NEIGHBOURHOODS_GROUP = "Neighbourhoods"
ns._NEIGHBOURHOOD_ZONES = {
  ["founder's point"] = true,
  ["razorwind shores"] = true,
}
ns.MissionStatus = ns.MissionStatus or {
  refreshSeconds = 15,
  garrisonTypeWOD = (Enum and Enum.GarrisonType and Enum.GarrisonType.Type_6_0_Garrison) or 2,
  garrisonTypeWODNaval = (Enum and Enum.GarrisonType and Enum.GarrisonType.Type_6_2_Garrison) or 7,
  garrisonTypeOrderHall = (Enum and Enum.GarrisonType and Enum.GarrisonType.Type_7_0_Garrison) or 3,
  followerTypeWOD = (Enum and Enum.GarrisonFollowerType and (Enum.GarrisonFollowerType.FollowerType_6_0_GarrisonFollower or Enum.GarrisonFollowerType.FollowerType_6_0)) or 1,
  followerTypeWODNaval = (Enum and Enum.GarrisonFollowerType and (Enum.GarrisonFollowerType.FollowerType_6_0_Boat or Enum.GarrisonFollowerType.FollowerType_6_2_Boat)) or 2,
  followerTypeOrderHall = (Enum and Enum.GarrisonFollowerType and (Enum.GarrisonFollowerType.FollowerType_7_0_GarrisonFollower or Enum.GarrisonFollowerType.FollowerType_7_0)) or 4,
}
ns.MissionStatus.refreshSeconds = 15

function ns.MissionStatus.SafeMissionList(methodName, garrisonType)
  if not (C_Garrison and C_Garrison[methodName]) then
    return {}
  end
  local ok, data = pcall(C_Garrison[methodName], garrisonType)
  if not ok or type(data) ~= "table" then
    return {}
  end
  return data
end

function ns.MissionStatus.FormatHoursMinutesFromSeconds(totalSeconds)
  local seconds = math.max(0, math.floor(tonumber(totalSeconds) or 0))
  local totalMinutes = math.floor(seconds / 60)
  local hours = math.floor(totalMinutes / 60)
  local minutes = totalMinutes % 60
  if hours <= 0 then
    return string.format("%d Min", minutes)
  end
  return string.format("%d Hr %d Min", hours, minutes)
end

function ns.MissionStatus.NormalizeTimeTextToHoursMinutes(rawText)
  local text = tostring(rawText or "")
  if text == "" then
    return ""
  end
  local lower = string.lower(text)
  local h = tonumber((lower:match("(%d+)%s*h") or lower:match("(%d+)%s*hr") or lower:match("(%d+)%s*hour")))
  local m = tonumber((lower:match("(%d+)%s*m") or lower:match("(%d+)%s*min") or lower:match("(%d+)%s*minute")))
  if h or m then
    local hours = h or 0
    local minutes = m or 0
    if hours <= 0 then
      return string.format("%d Min", minutes)
    end
    return string.format("%d Hr %d Min", hours, minutes)
  end
  return text
end

function ns.MissionStatus.BuildMissionTimeText(mission, prefix)
  if type(mission) ~= "table" then
    return "N/A"
  end
  if prefix == "Complete" then
    return "0 Min"
  end
  local nowEpoch = time and time() or 0
  if prefix == "In Progress" then
    local dynamicSeconds = tonumber(mission.timeLeftSeconds)
      or tonumber(mission.secondsLeft)
      or tonumber(mission.offerTimeRemaining)
    if (not dynamicSeconds or dynamicSeconds <= 0) and nowEpoch > 0 then
      local missionEnd = tonumber(mission.missionEndTime) or tonumber(mission.offerEndTime)
      if missionEnd and missionEnd > nowEpoch then
        dynamicSeconds = missionEnd - nowEpoch
      elseif tonumber(mission.startTime) and tonumber(mission.durationSeconds) then
        local computedEnd = tonumber(mission.startTime) + tonumber(mission.durationSeconds)
        if computedEnd > nowEpoch then
          dynamicSeconds = computedEnd - nowEpoch
        end
      end
    end
    if dynamicSeconds and dynamicSeconds > 0 then
      return ns.MissionStatus.FormatHoursMinutesFromSeconds(dynamicSeconds)
    end
  end

  local timeLeft = tostring(mission.timeLeft or "")
  if timeLeft ~= "" and timeLeft ~= "0" and timeLeft ~= "0 sec" and timeLeft ~= "0 min" then
    return ns.MissionStatus.NormalizeTimeTextToHoursMinutes(timeLeft)
  end
  local seconds = tonumber(mission.durationSeconds)
  if seconds and seconds > 0 then
    return ns.MissionStatus.FormatHoursMinutesFromSeconds(seconds)
  end
  if prefix == "In Progress" then
    return "Refreshing..."
  end
  local duration = tostring(mission.duration or "")
  if duration ~= "" and duration ~= "0" then
    return ns.MissionStatus.NormalizeTimeTextToHoursMinutes(duration)
  end
  return "N/A"
end

function ns.MissionStatus.BuildMissionLine(prefix, mission)
  local name = tostring(type(mission) == "table" and mission.name or "Unknown Mission")
  local level = tonumber(type(mission) == "table" and (mission.level or mission.iLevel))
  local levelText = level and ("Lvl " .. tostring(level) .. " | ") or ""
  local followers = type(mission) == "table" and mission.followers
  local followerCount = tonumber(type(mission) == "table" and mission.numFollowers) or (type(followers) == "table" and #followers) or 0
  local rowIcon = "|TInterface\\Buttons\\UI-Panel-MinimizeButton-Up:12:12:0:0|t"
  if prefix == "Complete" then
    rowIcon = "|TInterface\\RaidFrame\\ReadyCheck-Ready:12:12:0:0|t"
  elseif prefix == "Available" then
    rowIcon = "|TInterface\\GossipFrame\\AvailableQuestIcon:12:12:0:0|t"
  elseif prefix == "In Progress" then
    rowIcon = "|TInterface\\Buttons\\UI-RefreshButton:12:12:0:0|t"
  end
  return string.format("%s |cffffffff%s%s|r  |cff9fd4ffTime: %s|r  |cff9fd4ffFollowers: %d|r", rowIcon, levelText, name, ns.MissionStatus.BuildMissionTimeText(mission, prefix), followerCount)
end

function ns.MissionStatus.BuildMissionSection(sectionTitle, garrisonType)
  local lines = {
    "|cff7c6a4f---------------------------------------------|r",
    string.format("|TInterface\\Icons\\INV_Misc_Map_01:16:16:0:0|t |cffffd25a%s|r", sectionTitle),
    "|cff7c6a4f---------------------------------------------|r",
    "",
  }
  local inProgress = ns.MissionStatus.SafeMissionList("GetInProgressMissions", garrisonType)
  local complete = ns.MissionStatus.SafeMissionList("GetCompleteMissions", garrisonType)
  local available = ns.MissionStatus.SafeMissionList("GetAvailableMissions", garrisonType)

  if #inProgress == 0 and #complete == 0 and #available == 0 then
    lines[#lines + 1] = "|cffb9ab93No mission data available on this character.|r"
    return lines
  end

  local groupAdded = false
  if #inProgress > 0 then
    lines[#lines + 1] = "|TInterface\\Buttons\\UI-RefreshButton:14:14:0:0|t |cff55ff55In Progress|r"
    for _, mission in ipairs(inProgress) do
      lines[#lines + 1] = ns.MissionStatus.BuildMissionLine("In Progress", mission)
    end
    groupAdded = true
  end
  if #complete > 0 then
    if groupAdded then
      lines[#lines + 1] = ""
    end
    lines[#lines + 1] = "|TInterface\\RaidFrame\\ReadyCheck-Ready:14:14:0:0|t |cff66ccffReady To Complete|r"
    for _, mission in ipairs(complete) do
      lines[#lines + 1] = ns.MissionStatus.BuildMissionLine("Complete", mission)
    end
    groupAdded = true
  end
  if #available > 0 then
    if groupAdded then
      lines[#lines + 1] = ""
    end
    lines[#lines + 1] = "|TInterface\\GossipFrame\\AvailableQuestIcon:14:14:0:0|t |cffffcc66Available|r"
    for _, mission in ipairs(available) do
      lines[#lines + 1] = ns.MissionStatus.BuildMissionLine("Available", mission)
    end
  end

  return lines
end

function ns.MissionStatus.BuildGarrisonText()
  local lines = {}
  local sections = {
    ns.MissionStatus.BuildMissionSection("Warlords Garrison Missions", ns.MissionStatus.garrisonTypeWOD),
    ns.MissionStatus.BuildMissionSection("Warlords Shipyard Missions", ns.MissionStatus.garrisonTypeWODNaval),
  }
  for _, sectionLines in ipairs(sections) do
    for _, line in ipairs(sectionLines) do
      lines[#lines + 1] = line
    end
    lines[#lines + 1] = ""
  end
  local stamp = (date and date("%Y-%m-%d %H:%M:%S")) or "unknown"
  lines[#lines + 1] = "|cffc7b38cLast updated:|r " .. tostring(stamp)
  return table.concat(lines, "\n")
end

function ns.MissionStatus.BuildOrderHallText()
  local lines = {}
  local sectionLines = ns.MissionStatus.BuildMissionSection("Legion Order Hall Missions", ns.MissionStatus.garrisonTypeOrderHall)
  for _, line in ipairs(sectionLines) do
    lines[#lines + 1] = line
  end
  lines[#lines + 1] = ""
  local stamp = (date and date("%Y-%m-%d %H:%M:%S")) or "unknown"
  lines[#lines + 1] = "|cffc7b38cLast updated:|r " .. tostring(stamp)
  return table.concat(lines, "\n")
end

ns.CurrencyStatus = ns.CurrencyStatus or {
  refreshSeconds = 30,
}
ns.CurrencyStatus.refreshSeconds = 30

function ns.CurrencyStatus.GetCurrencyListSizeSafe()
  local size = 0
  if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyListSize then
    local okSize, listSize = pcall(C_CurrencyInfo.GetCurrencyListSize)
    if okSize and tonumber(listSize) then
      size = tonumber(listSize)
    end
  elseif GetCurrencyListSize then
    local okSize, listSize = pcall(GetCurrencyListSize)
    if okSize and tonumber(listSize) then
      size = tonumber(listSize)
    end
  end
  return size
end

function ns.CurrencyStatus.GetCurrencyListInfoSafe(index)
  if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyListInfo then
    local okInfo, value = pcall(C_CurrencyInfo.GetCurrencyListInfo, index)
    if okInfo and type(value) == "table" then
      return value
    end
  elseif GetCurrencyListInfo then
    local okInfo, name, isHeader, isExpanded, isUnused, isWatched, count, icon, maxQuantity, canEarnPerWeek, quality, displayedAmount, itemID, currencyID = pcall(GetCurrencyListInfo, index)
    if okInfo then
      return {
        name = name,
        isHeader = isHeader,
        isHeaderExpanded = isExpanded,
        quantity = count,
        displayAmount = displayedAmount,
        iconFileID = icon,
        maxQuantity = maxQuantity,
        currencyTypesID = currencyID,
        itemID = itemID,
      }
    end
  end
  return nil
end

function ns.CurrencyStatus.SetCurrencyHeaderExpanded(index, shouldExpand)
  if not tonumber(index) then
    return false
  end
  if C_CurrencyInfo and C_CurrencyInfo.ExpandCurrencyList then
    local ok = pcall(C_CurrencyInfo.ExpandCurrencyList, index, shouldExpand and true or false)
    if ok then
      return true
    end
  end
  if shouldExpand then
    if ExpandCurrencyList then
      local ok = pcall(ExpandCurrencyList, index, 1)
      if ok then
        return true
      end
      ok = pcall(ExpandCurrencyList, index, true)
      if ok then
        return true
      end
    end
  else
    if CollapseCurrencyList then
      local ok = pcall(CollapseCurrencyList, index)
      if ok then
        return true
      end
    end
    if ExpandCurrencyList then
      local ok = pcall(ExpandCurrencyList, index, false)
      if ok then
        return true
      end
      ok = pcall(ExpandCurrencyList, index, 0)
      if ok then
        return true
      end
    end
  end
  return false
end

function ns._NormalizeCurrencyExpansionLabel(label)
  local text = tostring(label or "")
  if text == "" then
    return "Other"
  end
  local lower = string.lower(text)
  for _, expansion in ipairs(EXPANSION_RELEASE_ORDER) do
    local expLower = string.lower(expansion)
    if lower:find(expLower, 1, true) then
      return expansion
    end
  end
  return text
end

function ns._CurrencyExpansionSortLess(a, b)
  local ia = 0
  local ib = 0
  for idx, expansion in ipairs(EXPANSION_RELEASE_ORDER) do
    if expansion == a then
      ia = idx
    end
    if expansion == b then
      ib = idx
    end
  end
  if ia ~= ib then
    return ia > ib
  end
  return tostring(a or "") < tostring(b or "")
end

function ns.CurrencyStatus.BuildCharacterCurrencyData()
  local byExpansion = {}
  local order = {}
  local function EnsureBucket(expansion)
    local key = ns._NormalizeCurrencyExpansionLabel(expansion)
    if not byExpansion[key] then
      byExpansion[key] = {
        key = string.lower(tostring(key)),
        expansion = key,
        currenciesByID = {},
      }
      order[#order + 1] = key
    end
    return byExpansion[key]
  end

  local initialSize = ns.CurrencyStatus.GetCurrencyListSizeSafe()
  local collapsedHeaderNameCounts = {}
  local canMutateCurrencyHeaders = not (InCombatLockdown and InCombatLockdown())
  if canMutateCurrencyHeaders then
    for index = initialSize, 1, -1 do
      local info = ns.CurrencyStatus.GetCurrencyListInfoSafe(index)
      if type(info) == "table" and info.isHeader then
        local headerName = tostring(info.name or "")
        local expanded = info.isHeaderExpanded and true or false
        if not expanded then
          collapsedHeaderNameCounts[headerName] = (collapsedHeaderNameCounts[headerName] or 0) + 1
          ns.CurrencyStatus.SetCurrencyHeaderExpanded(index, true)
        end
      end
    end
  end

  local size = ns.CurrencyStatus.GetCurrencyListSizeSafe()

  local currentHeader = ""
  for index = 1, size do
    local info = ns.CurrencyStatus.GetCurrencyListInfoSafe(index)
    if type(info) == "table" then
      if info.isHeader then
        currentHeader = tostring(info.name or currentHeader or "")
      else
        local currencyID = tonumber(info.currencyTypesID or info.currencyID) or 0
        local quantity = tonumber(info.quantity or info.displayAmount) or 0
        local name = tostring(info.name or "")
        if currencyID > 0 and name ~= "" and quantity > 0 then
          local bucket = EnsureBucket(currentHeader)
          local existing = bucket.currenciesByID[currencyID]
          if not existing then
            bucket.currenciesByID[currencyID] = {
              currencyID = currencyID,
              name = name,
              quantity = quantity,
              icon = info.iconFileID,
            }
          elseif quantity > (tonumber(existing.quantity) or 0) then
            existing.quantity = quantity
            existing.icon = info.iconFileID or existing.icon
          end
        end
      end
    end
  end

  if canMutateCurrencyHeaders then
    local restoredSize = ns.CurrencyStatus.GetCurrencyListSizeSafe()
    for index = restoredSize, 1, -1 do
      local info = ns.CurrencyStatus.GetCurrencyListInfoSafe(index)
      if type(info) == "table" and info.isHeader and info.isHeaderExpanded then
        local headerName = tostring(info.name or "")
        local remaining = tonumber(collapsedHeaderNameCounts[headerName]) or 0
        if remaining > 0 then
          ns.CurrencyStatus.SetCurrencyHeaderExpanded(index, false)
          collapsedHeaderNameCounts[headerName] = remaining - 1
        end
      end
    end
  end

  table.sort(order, ns._CurrencyExpansionSortLess)
  local categories = {}
  for _, expansion in ipairs(order) do
    local bucket = byExpansion[expansion]
    local entries = {}
    for _, currency in pairs(bucket.currenciesByID or {}) do
      entries[#entries + 1] = currency
    end
    table.sort(entries, function(a, b)
      local qa = tonumber(a and a.quantity) or 0
      local qb = tonumber(b and b.quantity) or 0
      if qa ~= qb then
        return qa > qb
      end
      return tostring(a and a.name or "") < tostring(b and b.name or "")
    end)
    if #entries > 0 then
      categories[#categories + 1] = {
        key = bucket.key,
        expansion = bucket.expansion,
        currencies = entries,
      }
    end
  end

  return categories
end

function ns.ApplyVendorResourceFilter(frame, resourceName)
  if not frame then
    return
  end
  local function IsMoneyCurrency(text)
    local lower = string.lower(tostring(text or ""))
    lower = lower:gsub("[^%a]", "")
    return lower == "gold" or lower == "silver" or lower == "copper"
  end
  local isMoneyCurrency = IsMoneyCurrency(resourceName)
  if ns.SetActiveBottomTab then
    ns.SetActiveBottomTab(frame, "Catalog")
  end
  if ns.SetViewMode then
    ns.SetViewMode(frame, "vendor")
  end
  frame.filters = frame.filters or {}
  if isMoneyCurrency then
    frame.filters.search = ""
  else
    frame.filters.search = tostring(resourceName or "")
  end
  if frame.searchBox then
    frame.searchBox:SetText(frame.filters.search)
  end
  DecorDropsDB.search = frame.filters.search
  if frame.Refresh then
    frame:Refresh()
  end
  if not isMoneyCurrency and ns._SetAllExpansionsCollapsed then
    ns._SetAllExpansionsCollapsed(frame, false)
    if frame.RefreshLeftPanelOnly then
      frame:RefreshLeftPanelOnly()
    end
  end
end

function ns.MissionStatus.GetPanelSources(panelKind)
  local function BuildNavalFollowerTypeCandidates()
    local out = {}
    local seen = {}
    local function Add(value)
      local n = tonumber(value)
      if n and n > 0 and not seen[n] then
        out[#out + 1] = n
        seen[n] = true
      end
    end
    Add(ns.MissionStatus.followerTypeWODNaval)
    if Enum and type(Enum.GarrisonFollowerType) == "table" then
      for key, value in pairs(Enum.GarrisonFollowerType) do
        local keyLower = string.lower(tostring(key))
        if keyLower:find("6_2", 1, true) or keyLower:find("shipyard", 1, true) or keyLower:find("naval", 1, true) then
          Add(value)
        end
      end
    end
    return out
  end
  local function BuildNavalGarrisonTypeCandidates()
    local out = {}
    local seen = {}
    local function Add(value)
      local n = tonumber(value)
      if n and n > 0 and not seen[n] then
        out[#out + 1] = n
        seen[n] = true
      end
    end
    Add(ns.MissionStatus.garrisonTypeWODNaval)
    if Enum and type(Enum.GarrisonType) == "table" then
      for key, value in pairs(Enum.GarrisonType) do
        local keyLower = string.lower(tostring(key))
        if keyLower:find("6_2", 1, true) or keyLower:find("shipyard", 1, true) or keyLower:find("naval", 1, true) then
          Add(value)
        end
      end
    end
    return out
  end

  if panelKind == "garrisons" then
    return {
      { label = "Garrison", followerType = ns.MissionStatus.followerTypeWOD, garrisonType = ns.MissionStatus.garrisonTypeWOD },
      { label = "Shipyard", followerTypes = BuildNavalFollowerTypeCandidates(), garrisonTypes = BuildNavalGarrisonTypeCandidates() },
    }
  end
  return {
    { label = "Order Hall", followerType = ns.MissionStatus.followerTypeOrderHall, garrisonType = ns.MissionStatus.garrisonTypeOrderHall },
  }
end

function ns.MissionStatus.IsLikelyNavalMission(mission)
  if type(mission) ~= "table" then
    return false
  end
  local KNOWN_NAVAL_MISSION_NAMES = {
    ["no loss of suction"] = true,
    ["the sunset shore"] = true,
  }
  local function HasShipHint(value)
    local lower = string.lower(tostring(value or ""))
    if lower == "" then
      return false
    end
    return lower:find("ship", 1, true)
      or lower:find("naval", 1, true)
      or lower:find("shipyard", 1, true)
      or lower:find("vessel", 1, true)
      or lower:find("fleet", 1, true)
      or lower:find("oil", 1, true)
      or lower:find("sea", 1, true)
  end

  local missionName = string.lower(tostring(mission.name or ""))
  if KNOWN_NAVAL_MISSION_NAMES[missionName] then
    return true
  end

  if HasShipHint(mission.type)
      or HasShipHint(mission.typeName)
      or HasShipHint(mission.typeAtlas)
      or HasShipHint(mission.description)
      or HasShipHint(mission.offerDescription)
      or HasShipHint(mission.locString)
      or HasShipHint(mission.followerType) then
    return true
  end
  local lowerDescription = string.lower(tostring(mission.description or mission.offerDescription or ""))
  if lowerDescription:find("ship mission", 1, true) then
    return true
  end

  local followerTypeID = tonumber(mission.followerTypeID)
  if followerTypeID and Enum and type(Enum.GarrisonFollowerType) == "table" then
    for key, value in pairs(Enum.GarrisonFollowerType) do
      if tonumber(value) == followerTypeID then
        local keyLower = string.lower(tostring(key))
        if keyLower:find("ship", 1, true) or keyLower:find("6_2", 1, true) or keyLower:find("naval", 1, true) then
          return true
        end
      end
    end
  end

  return false
end

function ns.MissionStatus.BuildPanelCategoryData(panelKind)
  local data = {
    inprogress = {},
    complete = {},
    available = {},
  }
  local sources = ns.MissionStatus.GetPanelSources(panelKind)
  local seen = { inprogress = {}, complete = {}, available = {} }
  local function AddEntry(bucket, mission, prefix, sourceLabel)
    local missionID = tonumber(type(mission) == "table" and mission.missionID) or tonumber(type(mission) == "table" and mission.id) or 0
    local normalizedSource = tostring(sourceLabel or "")
    if panelKind == "garrisons" and (normalizedSource == "" or normalizedSource == "Garrison") and ns.MissionStatus.IsLikelyNavalMission(mission) then
      normalizedSource = "Shipyard"
    end
    local key = normalizedSource .. ":" .. tostring(missionID > 0 and missionID or tostring(type(mission) == "table" and mission.name or "unknown"))
    if seen[bucket][key] then
      return
    end
    seen[bucket][key] = true
    data[bucket][#data[bucket] + 1] = { mission = mission, prefix = prefix, sourceLabel = normalizedSource }
  end

  for _, source in ipairs(sources) do
    local missionTypes = {}
    local seenMissionType = {}
    local function AddMissionType(value)
      local n = tonumber(value)
      if n and n > 0 and not seenMissionType[n] then
        missionTypes[#missionTypes + 1] = n
        seenMissionType[n] = true
      end
    end
    if type(source.followerTypes) == "table" then
      for _, v in ipairs(source.followerTypes) do
        AddMissionType(v)
      end
    elseif source.followerType then
      AddMissionType(source.followerType)
    end
    if type(source.garrisonTypes) == "table" then
      for _, v in ipairs(source.garrisonTypes) do
        AddMissionType(v)
      end
    elseif source.garrisonType then
      AddMissionType(source.garrisonType)
    end

    for _, missionType in ipairs(missionTypes) do
      local inProgress = ns.MissionStatus.SafeMissionList("GetInProgressMissions", missionType)
      local complete = ns.MissionStatus.SafeMissionList("GetCompleteMissions", missionType)
      local available = ns.MissionStatus.SafeMissionList("GetAvailableMissions", missionType)
      for _, mission in ipairs(inProgress) do
        AddEntry("inprogress", mission, "In Progress", source.label)
      end
      for _, mission in ipairs(complete) do
        AddEntry("complete", mission, "Complete", source.label)
      end
      for _, mission in ipairs(available) do
        AddEntry("available", mission, "Available", source.label)
      end
    end
  end
  return data
end

function ns.MissionStatus.ExtractRewardTokens(mission)
  local out = {}
  local rewards = type(mission) == "table" and (mission.rewards or mission.offerRewards) or nil
  local hasXPToken = false
  local function AddToken(token)
    if type(token) ~= "table" then
      return
    end
    local icon = token.icon
    local iconNum = tonumber(icon)
    if not icon or icon == "" or (iconNum and iconNum <= 0) then
      return
    end
    out[#out + 1] = token
  end
  local function FindXP(value)
    local numeric = tonumber(value)
    if numeric and numeric > 0 then
      return math.floor(numeric + 0.5)
    end
    return nil
  end
  if type(rewards) ~= "table" then
    local missionXP = FindXP(type(mission) == "table" and (mission.followerXP or mission.garrFollowerXP or mission.baseFollowerXP or mission.xp))
    if missionXP then
      AddToken({
        icon = "Interface\\Icons\\XP_Icon",
        quantity = missionXP,
        quantityLabel = tostring(missionXP),
        tooltipTitle = "Follower XP",
        tooltipDetail = string.format("XP: %d", missionXP),
      })
    end
    return out
  end
  for _, reward in pairs(rewards) do
    if type(reward) == "table" then
      local rewardXP = FindXP(reward.followerXP or reward.garrFollowerXP or reward.baseFollowerXP or reward.xp)
      local token = {
        icon = tonumber(reward.iconFileID) or tonumber(reward.icon) or tonumber(reward.texture),
        itemID = tonumber(reward.itemID) or tonumber(reward.itemId),
        currencyID = tonumber(reward.currencyID) or tonumber(reward.currencyId),
        quantity = tonumber(reward.quantity) or tonumber(reward.amount) or tonumber(reward.qty) or 1,
        quantityLabel = tostring(tonumber(reward.quantity) or tonumber(reward.amount) or tonumber(reward.qty) or 1),
        tooltipTitle = tostring(reward.title or reward.name or "Reward"),
      }
      if (not token.icon or token.icon <= 0) and token.itemID and token.itemID > 0 and C_Item and C_Item.GetItemIconByID then
        token.icon = tonumber(C_Item.GetItemIconByID(token.itemID))
      end
      if (not token.icon or token.icon <= 0) and token.currencyID and token.currencyID > 0 and C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
        local info = C_CurrencyInfo.GetCurrencyInfo(token.currencyID)
        token.icon = tonumber(info and info.iconFileID)
      end
      if token.itemID and token.itemID > 0 and token.quantity and token.quantity > 0 then
        token.quantityLabel = "x" .. tostring(token.quantity)
      elseif token.currencyID and token.currencyID > 0 and token.quantity and token.quantity > 0 then
        token.quantityLabel = tostring(token.quantity)
      end
      if token.icon and token.icon ~= "" and (not tonumber(token.icon) or tonumber(token.icon) > 0) then
        AddToken(token)
      end
      if rewardXP and rewardXP > 0 then
        hasXPToken = true
        AddToken({
          icon = "Interface\\Icons\\XP_Icon",
          quantity = rewardXP,
          quantityLabel = tostring(rewardXP),
          tooltipTitle = "Follower XP",
          tooltipDetail = string.format("XP: %d", rewardXP),
        })
      end
    end
    if #out >= 5 then
      break
    end
  end
  if not hasXPToken then
    local missionXP = FindXP(type(mission) == "table" and (mission.followerXP or mission.garrFollowerXP or mission.baseFollowerXP or mission.xp))
    if missionXP then
      AddToken({
        icon = "Interface\\Icons\\XP_Icon",
        quantity = missionXP,
        quantityLabel = tostring(missionXP),
        tooltipTitle = "Follower XP",
        tooltipDetail = string.format("XP: %d", missionXP),
      })
    end
  end
  return out
end

function ns.MissionStatus.ExtractFollowerTokens(mission)
  local out = {}
  local followers = type(mission) == "table" and (mission.followers or mission.assignedFollowers or mission.followerInfo or mission.followerIds) or nil
  if type(followers) ~= "table" and type(mission) == "table" and C_Garrison and C_Garrison.GetMissionDeploymentInfo then
    local missionID = tonumber(mission.missionID or mission.id)
    if missionID and missionID > 0 then
      local ok, a, b, c, d, e = pcall(C_Garrison.GetMissionDeploymentInfo, missionID)
      if ok then
        local candidates = { a, b, c, d, e }
        for _, candidate in ipairs(candidates) do
          if type(candidate) == "table" then
            if type(candidate.followers) == "table" then
              followers = candidate.followers
              break
            end
            if type(candidate.followerIds) == "table" then
              followers = candidate.followerIds
              break
            end
            local hasFollowerShape = false
            for k, v in pairs(candidate) do
              if tonumber(k) or tonumber(v) or (type(v) == "table" and (v.followerID or v.garrFollowerID or v.id)) then
                hasFollowerShape = true
                break
              end
            end
            if hasFollowerShape then
              followers = candidate
              break
            end
          end
        end
      end
    end
  end
  if type(followers) ~= "table" then
    return out
  end
  local seenFollower = {}
  local missionFollowerTypeID = tonumber(type(mission) == "table" and (mission.followerTypeID or mission.followerType or mission.garrFollowerTypeID))
  for followerKey, follower in pairs(followers) do
    local icon = nil
    local followerName = nil
    local followerID = tonumber(follower) or tonumber(followerKey)
    local followerTypeID = missionFollowerTypeID
    if type(follower) == "table" then
      followerID = tonumber(follower.followerID) or tonumber(follower.garrFollowerID) or tonumber(follower.id) or tonumber(followerKey)
      followerTypeID = tonumber(follower.followerTypeID) or tonumber(follower.followerType) or tonumber(follower.garrFollowerTypeID) or missionFollowerTypeID
      icon = tonumber(follower.portraitIconID) or tonumber(follower.iconFileID) or tonumber(follower.iconID)
      followerName = tostring(follower.name or follower.followerName or "")
    end
    local dedupeKey = tostring(followerID or followerName or icon or "")
    if dedupeKey ~= "" and not seenFollower[dedupeKey] and ((followerID and followerID > 0) or (icon and icon > 0) or followerName ~= "") then
      seenFollower[dedupeKey] = true
      out[#out + 1] = { followerID = followerID, followerTypeID = followerTypeID, icon = icon, name = followerName }
    end
    if #out >= 5 then
      break
    end
  end
  return out
end

-- Imported follower type hints from popular mission addons (e.g. ElvUI missions),
-- embedded locally so DecorDrops does not depend on those addons.
local FALLBACK_FOLLOWER_TYPE_HINTS = {
  (Enum and Enum.GarrisonFollowerType and Enum.GarrisonFollowerType.FollowerType_6_0_Boat) or 0,
  (Enum and Enum.GarrisonFollowerType and Enum.GarrisonFollowerType.FollowerType_6_0_GarrisonFollower) or 0,
  (Enum and Enum.GarrisonFollowerType and Enum.GarrisonFollowerType.FollowerType_7_0_GarrisonFollower) or 0,
  (Enum and Enum.GarrisonFollowerType and Enum.GarrisonFollowerType.FollowerType_8_0_GarrisonFollower) or 0,
  (Enum and Enum.GarrisonFollowerType and Enum.GarrisonFollowerType.FollowerType_9_0_GarrisonFollower) or 0,
}

local function GetFollowerInfoAny(followerID, followerTypeID)
  if not (C_Garrison and C_Garrison.GetFollowerInfo) or not followerID then
    return nil
  end
  local candidateTypes = {}
  local seen = {}
  local function AddType(value)
    local n = tonumber(value)
    if n and n > 0 and not seen[n] then
      candidateTypes[#candidateTypes + 1] = n
      seen[n] = true
    end
  end
  AddType(followerTypeID)
  AddType(ns.MissionStatus.garrisonTypeWOD)
  AddType(ns.MissionStatus.garrisonTypeWODNaval)
  AddType(ns.MissionStatus.garrisonTypeOrderHall)
  for _, hint in ipairs(FALLBACK_FOLLOWER_TYPE_HINTS) do
    AddType(hint)
  end
  for i = 1, 10 do
    AddType(i)
  end

  local okSingle, infoSingle = pcall(C_Garrison.GetFollowerInfo, followerID)
  if okSingle and type(infoSingle) == "table" then
    return infoSingle
  end

  for _, typeID in ipairs(candidateTypes) do
    local okTyped, infoTyped = pcall(C_Garrison.GetFollowerInfo, typeID, followerID)
    if okTyped and type(infoTyped) == "table" then
      return infoTyped
    end
  end
  return nil
end

function ns.MissionStatus.GetFollowerIcon(followerID, fallbackIcon, followerTypeID)
  if C_Garrison and C_Garrison.GetFollowerPortraitIconID then
    local okPortrait, portrait = pcall(C_Garrison.GetFollowerPortraitIconID, followerID)
    local portraitID = tonumber(portrait)
    if okPortrait and portraitID and portraitID > 0 then
      return portraitID
    end
    local okPortraitTyped, portraitTyped = pcall(C_Garrison.GetFollowerPortraitIconID, followerTypeID, followerID)
    local portraitTypedID = tonumber(portraitTyped)
    if okPortraitTyped and portraitTypedID and portraitTypedID > 0 then
      return portraitTypedID
    end
  end
  local info = GetFollowerInfoAny(followerID, followerTypeID)
  if type(info) == "table" then
    return tonumber(info.portraitIconID) or tonumber(info.iconFileID) or tonumber(info.iconID) or tonumber(fallbackIcon)
  end
  return tonumber(fallbackIcon)
end

function ns.MissionStatus.SetFollowerTooltip(followerID, followerTypeID, fallbackName)
  if not GameTooltip then
    return
  end
  local info = GetFollowerInfoAny(followerID, followerTypeID)
  if type(info) ~= "table" then
    local fallbackLabel = tostring(fallbackName or "")
    if fallbackLabel ~= "" then
      GameTooltip:AddLine(fallbackLabel)
    elseif followerID then
      GameTooltip:AddLine("Follower " .. tostring(followerID))
    else
      GameTooltip:AddLine("Follower")
    end
    if followerTypeID then
      GameTooltip:AddLine("Type: " .. tostring(followerTypeID), 0.80, 0.80, 0.80)
    end
    return
  end
  local name = tostring(info.name or fallbackName or ("Follower " .. tostring(followerID)))
  GameTooltip:AddLine(name)
  if info.level then
    GameTooltip:AddLine("Level: " .. tostring(info.level), 0.80, 0.80, 0.80)
  end
  if info.iLevel then
    GameTooltip:AddLine("Item Level: " .. tostring(info.iLevel), 0.80, 0.80, 0.80)
  end
  if type(info.abilities) == "table" then
    for _, ability in ipairs(info.abilities) do
      if type(ability) == "table" then
        local abilityName = tostring(ability.name or "")
        if abilityName ~= "" then
          GameTooltip:AddLine("- " .. abilityName, 0.65, 0.85, 1.0)
        end
      end
    end
  end
end

local function IsPlayerMage()
  local classToken = select(2, UnitClass("player"))
  if classToken and classToken ~= "" then
    return classToken == "MAGE"
  end
  return false
end

local function GetPlayerFaction()
  local faction = UnitFactionGroup and UnitFactionGroup("player") or nil
  return faction or "Neutral"
end
local EXPANSION_TELEPORT_SPELL_IDS = {
  ["Classic"] = {
    Alliance = { 3561 }, -- Stormwind
    Horde = { 3567 }, -- Orgrimmar
  },
  ["The Burning Crusade"] = {
    Neutral = { 35715, 33690 }, -- Shattrath
  },
  ["Wrath of the Lich King"] = {
    Neutral = { 53140 }, -- Dalaran (Northrend)
  },
  ["Cataclysm"] = {
    Alliance = { 49359 }, -- Theramore
    Horde = { 49358 }, -- Stonard
  },
  ["Mists of Pandaria"] = {
    Alliance = { 132621 }, -- Vale of Eternal Blossoms (Alliance)
    Horde = { 132627 }, -- Vale of Eternal Blossoms (Horde)
  },
  ["Warlords of Draenor"] = {
    Alliance = { 176248 }, -- Stormshield
    Horde = { 176242 }, -- Warspear
  },
  ["Legion"] = {
    Neutral = { 224869 }, -- Dalaran (Broken Isles)
  },
  ["Battle for Azeroth"] = {
    Alliance = { 281400 }, -- Boralus
    Horde = { 281404 }, -- Dazar'alor
  },
  ["Shadowlands"] = {
    Neutral = { 344587 }, -- Oribos
  },
  ["Dragonflight"] = {
    Neutral = { 395277 }, -- Valdrakken
  },
  ["The War Within"] = {
    Neutral = { 446540 }, -- Dornogal
  },
  ["Midnight"] = {
    -- Placeholder until Blizzard finalizes the Midnight mage-teleport spellID.
    -- Keeping an unknown ID here ensures the UI shows "Spell not learned."
    Neutral = { 999999 },
  },
}
ns.ITEM_NAME_OVERRIDES = ns.ITEM_NAME_OVERRIDES or {
  [253255] = "Lunar Celebrant's Wide Pillow Roll",
  [253256] = "Lunar Celebrant's Pillow Roll",
}
ns.ITEM_ICON_OVERRIDES = ns.ITEM_ICON_OVERRIDES or {
  -- Temporary icon fallbacks for unresolved Midnight client records.
  [253255] = UIC.ICON_VENDOR,
  [253256] = UIC.ICON_VENDOR,
}
ns.SUPPORT_INSTAGRAM_URL = ns.SUPPORT_INSTAGRAM_URL or "https://www.instagram.com/theparallaxdev"
local INFO_PAGE_TEMPLATE = {
  {
    title = "Version",
    lines = {
      "DecorDrops v1.0.4",
    },
  },
  {
    title = "Player Highlights",
    lines = {
      "- You can run a quick manual scan from anywhere in the addon.",
      "- The Scan button stays available so you can refresh results whenever you want.",
      "- Tracker goals are easier to follow with clear source groups like Vendor, Quest, and Reputation.",
      "- Neighbourhood content is now grouped cleanly so seasonal browsing feels faster.",
    },
  },
  {
    title = "Data Updates",
    lines = {
      "- Reputation standing normalization corrected across legacy factions.",
      "- Renown/standing mismatches patched for modern factions.",
      "- Quest/vendor cross-link behavior tightened to avoid wrong-page bleed.",
    },
  },
  {
    title = "Changelog",
    lines = {
      "- v1.0.4: Promotions category hierarchy refined so Twitch Drops and Other/Shop now follow the same clean left-panel structure as other categories.",
      "- v1.0.4: Added Plushies grouping under Twitch Drops -> Warcraft for clearer promo item browsing.",
      "- v1.0.4: Housing Dashboard button attachment made more reliable and safer around combat state transitions.",
      "- v1.0.4: Improved combat safety guards to reduce protected-frame errors during heavy gameplay (raids, Mythic+, world combat).",
      "- v1.0.3: Added completion percentages in the left category list so progress is easier to read at a glance.",
      "- v1.0.3: Added a new Completion % toggle next to My Professions so you can quickly show or hide progress labels.",
      "- v1.0.3: Fixed tracker progress so values reflect tracked items on the Tracker page instead of inheriting from other tabs.",
      "- v1.0.3: Improved left-list readability with cleaner percent formatting and updated alignment for category progress labels.",
      "- v1.0.2: Added a dedicated Currency tab with expansion-based grouping so you can quickly see what each character has.",
      "- v1.0.2: Added resource-name searching and \"View Category\" shortcuts to jump from a currency to related vendor categories.",
      "- v1.0.2: Currency values now use readable number formatting (for example: 1,000 and 10,000).",
      "- v1.0.2: Added Garrisons and Order Halls mission dashboard tabs with cleaner category breakdowns and live status views.",
      "- v1.0.2: Updated mission panel visuals for better readability with improved spacing, sizing, and reward alignment.",
      "- v1.0.2: Improved Shipyard labeling across the addon for clearer mission source names.",
      "- v1.0.2: Currency capture now reads collapsed Blizzard currency headers automatically, so data appears without manual category expansion.",
      "- v1.0.1: Added Midnight waypoint support for instances and professions; trainer/instance mapID fallbacks improved.",
      "- v1.0.1: Mage teleport handling updated for unreleased Midnight spell IDs (shows Spell not learned).",
      "- v1.0.1: Source-page grouping fixed to prevent missing categories before manual Reset (removed API-pending re-bucketing).",
      "- v1.0.1: Promotional Unknown-location rows flattened to main grouping; temporary Unknown subcategory removed.",
      "- v1.0.1: Corrected profession source misclassifications (e.g., MoP Jewelcrafting, BfA Engineering records).",
      "- v1.0.1: Session/state UX polish: per-mode filter memory, reset consistency, and non-tracker login collapse defaults (tracker prefs preserved).",
    },
  },
  {
    title = "Data & Sources",
    lines = {
      "- Primary data sources: World of Warcraft client APIs and in-game scan results.",
      "- Extended catalog/resource enrichment informed by WoWhead research and static datasets.",
      "- Feature integration priorities are refined using guild feedback and live playtesting.",
      "- Profession total-cost column supports price tracking using Auctionator auction data with vendor fallback.",
    },
  },
}
local FEATURES_PAGE_TEMPLATE = {
  {
    key = "core",
    title = "Core Features",
    lines = {
      "- Scans and catalogs housing decorations across all pages (raids, dungeons, professions, vendor, achievement, quest, reputation, endeavor).",
      "- Tracks collected vs missing decorations and supports focused missing-only runs.",
      "- Includes a dedicated Tracker mode to save and manage targeted farm/craft goals.",
    },
  },
  {
    key = "ui",
    title = "User Interface",
    lines = {
      "- Multi-tab interface with Catalog, Info, Features, Bugs & Feedback, and Settings pages.",
      "- Left panel supports grouped category expansion, subcategory navigation, and tracker controls.",
      "- Source pages now share consistent headers, labels, and tracking behavior.",
    },
  },
  {
    key = "roadmap",
    title = "Roadmap",
    lines = {
      "- Expand static source coverage for future patches and new decoration categories.",
      "- Continue UX polish for list readability, column alignment, and tracker workflow speed.",
    },
  },
}
ns._KNOWN_ISSUES_PAGE_TEMPLATE = {
  {
    title = "Feedback List",
    lines = {
      "(empty)",
    },
  },
  {
    title = "Resolved UX Issues",
    lines = {
      "Fixed padding issues on Garrison display.",
    },
  },
  {
    title = "Resolved Major Bugs",
    lines = {
      "Fixed Garrison mission skip.",
    },
  },
  {
    title = "Resolved Minor Bugs",
    lines = {
      "Fixed misaligned Info page.",
      "Fixed padding issues within garrison menu.",
    },
  },
  {
    title = "Other & Misc.",
    lines = {
      "(empty)",
    },
  },
}
local EXPANSION_ORDER_INDEX = {}
for idx, name in ipairs(EXPANSION_RELEASE_ORDER) do
  EXPANSION_ORDER_INDEX[name] = idx
end

function ns.SourceExpansionSortLess(a, b)
  if a == "Cash Shop" and b ~= "Cash Shop" then
    return true
  end
  if b == "Cash Shop" and a ~= "Cash Shop" then
    return false
  end
  if a == "Twitch Drops" and b ~= "Twitch Drops" then
    return true
  end
  if b == "Twitch Drops" and a ~= "Twitch Drops" then
    return false
  end
  if a == "Preorder Bonuses" and b ~= "Preorder Bonuses" then
    return true
  end
  if b == "Preorder Bonuses" and a ~= "Preorder Bonuses" then
    return false
  end
  if a == "Login Bonuses" and b ~= "Login Bonuses" then
    return true
  end
  if b == "Login Bonuses" and a ~= "Login Bonuses" then
    return false
  end
  if a == "Brand Promotions" and b ~= "Brand Promotions" then
    return true
  end
  if b == "Brand Promotions" and a ~= "Brand Promotions" then
    return false
  end
  if a == "Other" and b == "Brand Promotions" then
    return false
  end
  if b == "Other" and a == "Brand Promotions" then
    return true
  end
  if a == ns._NEIGHBOURHOODS_GROUP and b ~= ns._NEIGHBOURHOODS_GROUP then
    return true
  end
  if b == ns._NEIGHBOURHOODS_GROUP and a ~= ns._NEIGHBOURHOODS_GROUP then
    return false
  end
  local ia = EXPANSION_ORDER_INDEX[a] or 0
  local ib = EXPANSION_ORDER_INDEX[b] or 0
  if ia ~= ib then
    return ia > ib
  end
  return a < b
end

local function SafeLower(text)
  if not text then
    return ""
  end
  return string.lower(text)
end

local function SetAddonIcon(texture)
  texture:SetTexture("Interface\\AddOns\\DecorDrops\\Media\\DecorDropsPortraitIcon_new.png")
  texture:SetTexCoord(0.02, 0.98, 0.02, 0.98)
  return true
end

local function StringContains(haystack, needle)
  return string.find(SafeLower(haystack), SafeLower(needle), 1, true) ~= nil
end

function ns._NormalizeSearchWhitespace(text)
  local value = tostring(text or "")
  value = value:gsub("^%s+", "")
  value = value:gsub("%s+$", "")
  value = value:gsub("%s+", " ")
  return value
end

function ns._ParseSearchQueryFilters(rawSearch)
  local query = ns._NormalizeSearchWhitespace(SafeLower(rawSearch))
  local resourceQuery = ""
  local value = query:match("resource%s*:%s*(.+)$") or query:match("resources%s*:%s*(.+)$")
  if value and value ~= "" then
    resourceQuery = ns._NormalizeSearchWhitespace(value)
    query = ns._NormalizeSearchWhitespace(query:gsub("resources?%s*:%s*.+$", ""))
  end
  return query, resourceQuery
end

function ns._NormalizeResourceSearchText(text)
  local value = SafeLower(text or "")
  value = value:gsub("%(s%)", "s")
  value = value:gsub("[^%w%s]", " ")
  value = ns._NormalizeSearchWhitespace(value)
  if value == "" then
    return ""
  end
  local out = {}
  for token in value:gmatch("%S+") do
    local t = token
    if #t > 3 and t:sub(-1) == "s" then
      t = t:sub(1, -2)
    end
    out[#out + 1] = t
  end
  return table.concat(out, " ")
end

function ns._ResourceSearchMatches(haystack, resourceQuery)
  local normalizedQuery = ns._NormalizeResourceSearchText(resourceQuery)
  if normalizedQuery == "" then
    return true
  end
  local normalizedHaystack = ns._NormalizeResourceSearchText(haystack)
  if normalizedHaystack == "" then
    return false
  end
  return string.find(normalizedHaystack, normalizedQuery, 1, true) ~= nil
end

-- New source tabs (Vendor/Achievement/Quest/Reputation/Endeavor) are isolated here
-- so legacy instance/profession/tracker tabs remain untouched.
local NEW_SOURCE_MODES = {
  vendor = true,
  achievement = true,
  quest = true,
  reputation = true,
  endeavor = true,
  promotion = true,
}
ns._SOURCE_MODE_ORDER = { "vendor", "achievement", "quest", "reputation", "endeavor", "promotion" }

function ns.IsSourceMode(mode)
  return NEW_SOURCE_MODES[tostring(mode or "")] == true
end

function ns.GetSourceModeLabel(mode)
  if mode == "vendor" then
    return "Vendor"
  elseif mode == "achievement" then
    return "Achievement"
  elseif mode == "quest" then
    return "Quest"
  elseif mode == "reputation" then
    return "Reputation"
  elseif mode == "endeavor" then
    return "Endeavor"
  elseif mode == "promotion" then
    return "Promotional"
  end
  return "Source"
end

ns._NormalizeCategoryLabel = function(value, fallback)
  local text = tostring(value or "")
  text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
  text = text:gsub("|r", "")
  text = text:gsub("%s+", " ")
  text = text:gsub("^%s+", ""):gsub("%s+$", "")
  if text == "" then
    text = tostring(fallback or "")
  end
  return text
end

ns._BuildCategoryKey = function(value, fallback)
  return string.lower(ns._NormalizeCategoryLabel(value, fallback))
end

ns._NormalizeSourceExpansionLabel = function(expansion, zoneName)
  return ns._NormalizeCategoryLabel(ns.GetSourceDisplayExpansion(expansion, zoneName), "Other")
end

ns._NormalizeSourceZoneLabel = function(zoneName)
  return ns._NormalizeCategoryLabel(zoneName, "Unknown Zone")
end

local function IsUnknownSourceLocationLabel(zoneName)
  local normalized = SafeLower(ns._NormalizeCategoryLabel(zoneName, ""))
  return normalized == "" or normalized == "unknown" or normalized == "unknown zone"
end

function ns.IsPromotionFlattenedExpansion(expansionName)
  local name = tostring(expansionName or "")
  return name == "Preorder Bonuses"
    or name == "Brand Promotions"
    or name == "Login Bonuses"
end

ns._BuildSourceRowSignature = function(row)
  if type(row) ~= "table" then
    return ""
  end
  return table.concat({
    tostring(tonumber(row.itemID) or 0),
    ns._BuildCategoryKey(row.sourceName or row.source or "", ""),
    ns._BuildCategoryKey(row.sourceDetail or row.zone or "", ""),
    tostring(tonumber(row.questID) or 0),
    tostring(tonumber(row.achievementID) or 0),
    tostring(tonumber(row.npcID) or 0),
  }, "|")
end

ns._BuildSourceEntryDisplaySignature = function(entry)
  if type(entry) ~= "table" then
    return ""
  end
  local mode = tostring(entry.acquireMode or entry.trackerKind or "")
  local label = ns._BuildCategoryKey(entry.label or "Source", "Source")
  local detail = ns._BuildCategoryKey(entry.detail or "", "")
  return mode .. "|" .. label .. "|" .. detail
end

ns._MergeSourceEntriesForDisplay = function(entries)
  if type(entries) ~= "table" or #entries == 0 then
    return entries or {}
  end

  local out = {}
  local bySignature = {}
  for _, entry in ipairs(entries) do
    local signature = ns._BuildSourceEntryDisplaySignature(entry)
    if signature == "" then
      out[#out + 1] = entry
    else
      local kept = bySignature[signature]
      if not kept then
        kept = {}
        for key, value in pairs(entry) do
          kept[key] = value
        end
        kept.rows = {}
        for _, row in ipairs(entry.rows or {}) do
          kept.rows[#kept.rows + 1] = row
        end
        bySignature[signature] = kept
        out[#out + 1] = kept
      else
        kept.rows = kept.rows or {}
        local seenRows = {}
        for _, row in ipairs(kept.rows) do
          seenRows[ns._BuildSourceRowSignature(row)] = true
        end
        for _, row in ipairs(entry.rows or {}) do
          local rowSig = ns._BuildSourceRowSignature(row)
          if rowSig ~= "" and not seenRows[rowSig] then
            kept.rows[#kept.rows + 1] = row
            seenRows[rowSig] = true
          end
        end
        if not kept.waypoint and entry.waypoint then
          kept.waypoint = entry.waypoint
        end
      end
    end
  end

  return out
end

function ns.GetSourceDisplayExpansion(expansion, zoneName)
  local exp = ns._NormalizeCategoryLabel(expansion, "Other")
  local zoneKey = ns._BuildCategoryKey(zoneName, "")
  local lowerExp = string.lower(exp)
  if lowerExp == "midnight" then
    exp = "Midnight"
  end
  if exp == "Midnight" and ns._NEIGHBOURHOOD_ZONES[zoneKey] then
    return ns._NEIGHBOURHOODS_GROUP
  end
  return exp
end

local achievementNameLookupBuilt = false
local achievementIDByNormalizedName = {}

local function NormalizeAchievementName(value)
  local text = tostring(value or "")
  text = string.lower(text)
  text = text:gsub("%s+", " ")
  text = text:gsub("^%s+", ""):gsub("%s+$", "")
  return text
end

local function BuildAchievementNameLookup()
  if achievementNameLookupBuilt then
    return
  end
  achievementNameLookupBuilt = true

  if not (GetCategoryList and GetCategoryNumAchievements and GetAchievementInfo) then
    return
  end

  local categories = { GetCategoryList() }
  for _, categoryID in ipairs(categories) do
    local numericCategoryID = tonumber(categoryID)
    if numericCategoryID and numericCategoryID > 0 then
      local okCount, count = pcall(GetCategoryNumAchievements, numericCategoryID, true)
      local num = (okCount and tonumber(count)) or 0
    for index = 1, num do
        local okInfo, achievementID, name = pcall(GetAchievementInfo, numericCategoryID, index)
        if okInfo then
          local normalized = NormalizeAchievementName(name)
          if achievementID and achievementID > 0 and normalized ~= "" and not achievementIDByNormalizedName[normalized] then
            achievementIDByNormalizedName[normalized] = achievementID
          end
        end
      end
    end
  end
end

local function ResolveAchievementIDForItem(item)
  local direct = tonumber(item and item.achievementID) or 0
  if direct > 0 then
    return direct
  end

  local sourceName = NormalizeAchievementName(item and (item.sourceName or item.source))
  if sourceName == "" then
    return nil
  end

  BuildAchievementNameLookup()
  local resolved = achievementIDByNormalizedName[sourceName]
  if resolved and resolved > 0 and type(item) == "table" then
    item.achievementID = resolved
  end
  return resolved
end

ns._tooltipReputationRequirementCache = ns._tooltipReputationRequirementCache or {}
ns._tooltipUnknownSourceCache = ns._tooltipUnknownSourceCache or {}
ns._knownRepStandingToMinRep = ns._knownRepStandingToMinRep or {
  Neutral = 4,
  Friendly = 5,
  Honored = 6,
  Revered = 7,
  Exalted = 8,
}

function ns.TrimSpaces(text)
  return tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "")
end

function ns.StripColorCodes(text)
  local s = tostring(text or "")
  s = s:gsub("|c%x%x%x%x%x%x%x%x", "")
  s = s:gsub("|r", "")
  return s
end

function ns.ParseReputationRequirementFromTooltipLine(text)
  local cleaned = ns.TrimSpaces(ns.StripColorCodes(text))
  if cleaned == "" then
    return nil
  end

  -- Common form: "Requires Wildhammer Clan - Honored"
  local factionName, standingText = cleaned:match("^[Rr]equires%s+(.+)%s+%-%s+(.+)$")
  if not factionName or not standingText then
    -- Alternate renown form: "Requires Renown 8 with Flame's Radiance"
    local renownLevel, renownFaction = cleaned:match("^[Rr]equires%s+[Rr]enown%s+(%d+)%s+[Ww]ith%s+(.+)$")
    if renownLevel and renownFaction then
      factionName = renownFaction
      standingText = "Renown " .. tostring(tonumber(renownLevel) or 0)
    end
  end
  if not factionName or not standingText then
    return nil
  end

  factionName = ns.TrimSpaces(factionName)
  standingText = ns.TrimSpaces(standingText)
  if factionName == "" or standingText == "" then
    return nil
  end

  local minRep = ns._knownRepStandingToMinRep[standingText]
  if not minRep then
    local renownLevel = tonumber(standingText:match("^[Rr]enown%s+(%d+)$"))
    if renownLevel and renownLevel > 0 then
      minRep = renownLevel
    end
  end

  return {
    factionName = factionName,
    standingText = standingText,
    minRep = minRep,
  }
end

function ns.GetTooltipReputationRequirement(itemID)
  local id = tonumber(itemID) or 0
  if id <= 0 or not C_TooltipInfo or not C_TooltipInfo.GetItemByID then
    return nil
  end
  if ns._tooltipReputationRequirementCache[id] then
    return ns._tooltipReputationRequirementCache[id]
  end

  local req = nil
  local tipData = C_TooltipInfo.GetItemByID(id)
  if tipData and tipData.lines then
    for _, line in ipairs(tipData.lines) do
      local parsed = ns.ParseReputationRequirementFromTooltipLine(line and (line.leftText or line.text))
      if parsed then
        req = parsed
        break
      end
    end
  end

  if req then
    ns._tooltipReputationRequirementCache[id] = req
  end
  return req
end

function ns.IsUnknownSourceTooltipLine(text, rightText)
  local leftCleaned = string.lower(ns.TrimSpaces(ns.StripColorCodes(text)))
  local rightCleaned = string.lower(ns.TrimSpaces(ns.StripColorCodes(rightText)))
  local combined = leftCleaned
  if rightCleaned ~= "" then
    combined = (leftCleaned ~= "" and (leftCleaned .. " " .. rightCleaned)) or rightCleaned
  end

  if combined == "" then
    return false
  end
  if combined == "source: unknown" then
    return true
  end
  if string.find(combined, "source:", 1, true) and string.find(combined, "unknown", 1, true) then
    return true
  end
  if leftCleaned == "source:" and rightCleaned == "unknown" then
    return true
  end
  return false
end

function ns.GetTooltipHasUnknownSource(itemID)
  local id = tonumber(itemID) or 0
  if id <= 0 then
    return false
  end
  local cached = ns._tooltipUnknownSourceCache[id]
  if cached ~= nil then
    return cached
  end

  local hasUnknown = false
  if C_TooltipInfo and C_TooltipInfo.GetItemByID then
    local tipData = C_TooltipInfo.GetItemByID(id)
    if tipData and tipData.lines then
      for _, line in ipairs(tipData.lines) do
        local leftText = line and (line.leftText or line.text) or nil
        local rightText = line and line.rightText or nil
        if ns.IsUnknownSourceTooltipLine(leftText, rightText) then
          hasUnknown = true
          break
        end
      end
    end
  end

  if not hasUnknown and CreateFrame then
    if not ns._collectionScanTooltip then
      local tt = CreateFrame("GameTooltip", "DecorDropsCollectionScanTooltip", nil, "GameTooltipTemplate")
      tt:SetOwner(UIParent or WorldFrame, "ANCHOR_NONE")
      ns._collectionScanTooltip = tt
    end
    local tt = ns._collectionScanTooltip
    if tt then
      tt:ClearLines()
      local ok = false
      if tt.SetItemByID then
        ok = pcall(tt.SetItemByID, tt, id)
      end
      if not ok then
        ok = pcall(tt.SetHyperlink, tt, "item:" .. tostring(id))
      end
      if ok then
        local tooltipName = tt:GetName()
        local lineCount = tonumber(tt:NumLines()) or 0
        for i = 1, lineCount do
          local left = _G[tooltipName .. "TextLeft" .. i]
          local right = _G[tooltipName .. "TextRight" .. i]
          local leftText = left and left.GetText and left:GetText() or nil
          local rightText = right and right.GetText and right:GetText() or nil
          if ns.IsUnknownSourceTooltipLine(leftText, rightText) then
            hasUnknown = true
            break
          end
        end
      end
      tt:Hide()
    end
  end

  -- Cache positives permanently; do not cache false because tooltip payload can be incomplete early.
  if hasUnknown then
    ns._tooltipUnknownSourceCache[id] = true
  else
    ns._tooltipUnknownSourceCache[id] = nil
  end
  return hasUnknown
end

local function GetAcquireStatusText(item, selectedEntry)
  if type(item) ~= "table" then
    return "-"
  end
  local collectedFn = ns and ns.GetWarbandCollectedState or nil
  local collected = type(collectedFn) == "function" and collectedFn(item) or false
  local questID = tonumber(item.questID)
  if questID and questID > 0 then
    return collected and "Completed" or "Incomplete"
  end
  local achievementID = ResolveAchievementIDForItem(item)
  if achievementID and achievementID > 0 then
    return collected and "Completed" or "Incomplete"
  end
  local liveRequirement = ns.GetTooltipReputationRequirement(item.itemID)
  local minRep = tonumber((liveRequirement and liveRequirement.minRep) or item.minRep) or 0
  local repStandingText = ns.TrimSpaces((liveRequirement and liveRequirement.standingText) or item.repStandingText or "")
  if repStandingText ~= "" and not repStandingText:match("^%s*[Rr]enown%s+%d+%s*$") then
    return repStandingText
  end
  if minRep > 0 or repStandingText:match("^%s*[Rr]enown%s+%d+%s*$") ~= nil then
    local isRenownRequirement = minRep >= 9
      or repStandingText:match("^%s*[Rr]enown%s+%d+%s*$") ~= nil
      or tostring(item.acquireStatus or ""):match("[Rr]enown%s+%d+") ~= nil
    if isRenownRequirement then
      local renownFromText = tonumber(repStandingText:match("^%s*[Rr]enown%s+(%d+)%s*$")) or 0
      local renownLevel = renownFromText > 0 and renownFromText or minRep
      return "Renown " .. tostring(renownLevel)
    end
    local repNames = {
      [4] = "Neutral",
      [5] = "Friendly",
      [6] = "Honored",
      [7] = "Revered",
      [8] = "Exalted",
    }
    return repNames[minRep] or "Reputation"
  end
  return item.acquireStatus or "-"
end

local function BuildNewSourceZoneKey(expansion, zoneName)
  return ns._BuildCategoryKey(expansion, "Other") .. "|" .. ns._BuildCategoryKey(zoneName, "Unknown Zone")
end

local CollapseState = {}

function CollapseState.GetScope(frame)
  local mode = frame and frame.viewMode or "instances"
  return tostring(mode or "instances")
end

function CollapseState.BuildScopedExpansionKey(frame, expansion)
  return string.lower(CollapseState.GetScope(frame) .. "|" .. ns._BuildCategoryKey(expansion, "Other"))
end

function CollapseState.BuildScopedZoneKey(frame, expansion, zoneName)
  return string.lower(CollapseState.GetScope(frame) .. "|" .. BuildNewSourceZoneKey(expansion, zoneName))
end

function CollapseState.IsExpansionCollapsed(frame, expansion)
  local collapsed = DecorDropsDB.collapsedExpansions or {}
  local scopedKey = CollapseState.BuildScopedExpansionKey(frame, expansion)
  if collapsed[scopedKey] ~= nil then
    return collapsed[scopedKey] and true or false
  end
  if collapsed[tostring(expansion or "Other")] ~= nil then
    return collapsed[tostring(expansion or "Other")] and true or false
  end
  if frame and frame._defaultCollapseNonTracker and CollapseState.GetScope(frame) ~= "tracker" then
    return true
  end
  return false
end

function CollapseState.SetExpansionCollapsed(frame, expansion, value)
  DecorDropsDB.collapsedExpansions = DecorDropsDB.collapsedExpansions or {}
  local collapsed = DecorDropsDB.collapsedExpansions
  local scopedKey = CollapseState.BuildScopedExpansionKey(frame, expansion)
  collapsed[scopedKey] = value and true or false
end

function CollapseState.IsSourceZoneCollapsed(frame, expansion, zoneName)
  local collapsedZones = DecorDropsDB.collapsedNewSourceZones or {}
  local scopedKey = CollapseState.BuildScopedZoneKey(frame, expansion, zoneName)
  if collapsedZones[scopedKey] ~= nil then
    return collapsedZones[scopedKey] and true or false
  end
  local legacyKey = BuildNewSourceZoneKey(expansion, zoneName)
  if collapsedZones[legacyKey] ~= nil then
    return collapsedZones[legacyKey] and true or false
  end
  if frame and frame._defaultCollapseNonTracker and CollapseState.GetScope(frame) ~= "tracker" then
    return true
  end
  return false
end

function CollapseState.SetSourceZoneCollapsed(frame, expansion, zoneName, value)
  DecorDropsDB.collapsedNewSourceZones = DecorDropsDB.collapsedNewSourceZones or {}
  local collapsedZones = DecorDropsDB.collapsedNewSourceZones
  local scopedKey = CollapseState.BuildScopedZoneKey(frame, expansion, zoneName)
  collapsedZones[scopedKey] = value and true or false
end

local function SetGoButtonVisual(button, enabled)
  if not button then
    return
  end

  if enabled then
    button:SetSize(ITEM_LAYOUT.waypoint.width, 24)
    button:SetText("Go")
    button:Enable()
    button:SetAlpha(1.0)
  else
    button:SetSize(ITEM_LAYOUT.waypoint.width, 24)
    button:SetText("Go")
    button:Disable()
    button:SetAlpha(0.6)
  end
end

local SetViewButtonHoverVisual
local function SetViewButtonVisual(button)
  if not button then
    return
  end
  button:SetText("")
  if not button._ddEye then
    button._ddEye = button:CreateTexture(nil, "ARTWORK")
    button._ddEye:SetPoint("CENTER", 0, 0)
  end
  button._ddEye:SetSize(16, 16)
  local tex = button._ddEye
  local atlasApplied = false
  if tex.SetAtlas then
    for _, atlasName in ipairs({
      "groupfinder-icon-eye",
      "groupfinder-eye-single",
      "groupfinder-eye-background",
    }) do
      local ok, applied = pcall(tex.SetAtlas, tex, atlasName, false)
      if ok and applied then
        atlasApplied = true
        break
      end
    end
  end
  if not atlasApplied then
    if tex.SetAtlas then
      pcall(tex.SetAtlas, tex, nil)
    end
    tex:SetTexture(UIC.ICON_VIEW_EYE)
    -- Some client builds pack this texture as a horizontal strip.
    tex:SetTexCoord(0.25, 0.50, 0.00, 1.00)
  else
    tex:SetTexCoord(0, 1, 0, 1)
  end
  button._ddEye:Show()
  SetViewButtonHoverVisual(button, false)
end

SetViewButtonHoverVisual = function(button, hovered)
  if not button or not button._ddEye then
    return
  end
  local tex = button._ddEye
  if hovered then
    tex:SetVertexColor(1.00, 0.97, 0.78, 1.00)
    tex:SetAlpha(1.00)
    tex:SetScale(1.10)
  else
    tex:SetVertexColor(1.00, 1.00, 1.00, 0.92)
    tex:SetAlpha(0.92)
    tex:SetScale(1.00)
  end
end

local function EntryUsesRenownRequirements(entry)
  if type(entry) ~= "table" then
    return false
  end
  for _, row in ipairs(entry.rows or {}) do
    local minRep = tonumber(type(row) == "table" and row.minRep) or 0
    local repStandingText = tostring(type(row) == "table" and row.repStandingText or "")
    local acquireStatus = tostring(type(row) == "table" and row.acquireStatus or "")
    if minRep >= 9
      or repStandingText:match("^%s*[Rr]enown%s+%d+%s*$")
      or acquireStatus:match("[Rr]enown%s+%d+") then
      return true
    end
  end
  return false
end

local function NormalizeFactionName(name)
  local text = tostring(name or "")
  text = string.lower(text)
  text = text:gsub("%s*%(%s*reputation%s*%)%s*$", "")
  text = text:gsub("%b()", "")
  text = text:gsub("[^%w%s]", "")
  text = text:gsub("%s+", " ")
  text = text:gsub("^%s+", "")
  text = text:gsub("%s+$", "")
  return text
end

local function BuildFactionSnapshotFromValues(name, standingID, barMin, barMax, barValue, factionID)
  return {
    name = tostring(name or ""),
    standingID = tonumber(standingID) or 0,
    barMin = tonumber(barMin) or 0,
    barMax = tonumber(barMax) or 0,
    barValue = tonumber(barValue) or 0,
    factionID = tonumber(factionID) or 0,
  }
end

local function BuildFactionSnapshotFromDataTable(data)
  if type(data) ~= "table" then
    return nil
  end
  local name = data.name or data.factionName
  local factionID = data.factionID or data.id
  local standingID = data.standingID or data.reaction or data.reactionLevel

  local barMin = data.barMin
    or data.currentReactionThreshold
    or data.reactionThresholdMin
    or 0
  local barMax = data.barMax
    or data.nextReactionThreshold
    or data.reactionThresholdMax
    or 1
  local barValue = data.barValue
    or data.currentStanding
    or data.reactionValue
    or data.currentValue
    or 0

  return BuildFactionSnapshotFromValues(name, standingID, barMin, barMax, barValue, factionID)
end

local function GetFactionSnapshotByID(factionID)
  local fid = tonumber(factionID) or 0
  if fid <= 0 then
    return nil
  end
  if GetFactionInfoByID then
    local name, _, standingID, barMin, barMax, barValue, _, _, _, _, _, _, _, id = GetFactionInfoByID(fid)
    if name and name ~= "" then
      return BuildFactionSnapshotFromValues(name, standingID, barMin, barMax, barValue, id or fid)
    end
  end
  if C_Reputation and C_Reputation.GetFactionDataByID then
    local ok, data = pcall(C_Reputation.GetFactionDataByID, fid)
    if ok and type(data) == "table" then
      local snap = BuildFactionSnapshotFromDataTable(data)
      if snap and snap.name ~= "" then
        return snap
      end
    end
  end
  return nil
end

local function GetFactionSnapshotByName(factionName, allowFuzzy)
  local target = NormalizeFactionName(factionName)
  if target == "" then
    return nil
  end
  local fuzzyAllowed = allowFuzzy ~= false

  local function checkCandidate(name, standingID, barMin, barMax, barValue, factionID)
    if not name or name == "" then
      return nil, false
    end
    local normalized = NormalizeFactionName(name)
    if normalized == target then
      return BuildFactionSnapshotFromValues(name, standingID, barMin, barMax, barValue, factionID), true
    end
    if fuzzyAllowed and #target >= 4 and (string.find(normalized, target, 1, true) or string.find(target, normalized, 1, true)) then
      return BuildFactionSnapshotFromValues(name, standingID, barMin, barMax, barValue, factionID), false
    end
    return nil, false
  end

  local fuzzy = nil
  if GetNumFactions and GetFactionInfo then
    local count = tonumber(GetNumFactions()) or 0
    for i = 1, count do
      local name, _, standingID, barMin, barMax, barValue, _, _, isHeader, _, _, _, _, factionID = GetFactionInfo(i)
      if not isHeader then
        local snap, exact = checkCandidate(name, standingID, barMin, barMax, barValue, factionID)
        if snap and exact then
          return snap
        end
        if snap and not fuzzy then
          fuzzy = snap
        end
      end
    end
  end

  if C_Reputation and C_Reputation.GetNumFactions and C_Reputation.GetFactionDataByIndex then
    local okCount, count = pcall(C_Reputation.GetNumFactions)
    count = (okCount and tonumber(count)) or 0
    for i = 1, count do
      local okData, data = pcall(C_Reputation.GetFactionDataByIndex, i)
      if okData and type(data) == "table" and not data.isHeader then
        local snapData = BuildFactionSnapshotFromDataTable(data)
        if snapData then
          local snap, exact = checkCandidate(snapData.name, snapData.standingID, snapData.barMin, snapData.barMax, snapData.barValue, snapData.factionID)
          if snap and exact then
            return snap
          end
          if snap and not fuzzy then
            fuzzy = snap
          end
        end
      end
    end
  end

  return fuzzy
end

local function ResolveFactionSnapshot(item, selectedEntry)
  local factionID = tonumber(item and item.factionID) or 0
  if factionID <= 0 and type(selectedEntry) == "table" then
    for _, row in ipairs(selectedEntry.rows or {}) do
      local rowFactionID = tonumber(row and row.factionID) or 0
      if rowFactionID > 0 then
        factionID = rowFactionID
        break
      end
    end
  end

  local reputationTargetName = ""
  if type(selectedEntry) == "table" and selectedEntry.acquireMode == "reputation" then
    reputationTargetName = tostring(selectedEntry.label or "")
    reputationTargetName = reputationTargetName:gsub("^%s+", ""):gsub("%s+$", "")
    if reputationTargetName ~= "" then
      local exactByName = GetFactionSnapshotByName(reputationTargetName, false)
      if exactByName then
        return exactByName
      end
    end
  end

  local byID = GetFactionSnapshotByID(factionID)
  if byID then
    if reputationTargetName ~= "" then
      local expected = NormalizeFactionName(reputationTargetName)
      local actual = NormalizeFactionName(byID.name)
      if actual == expected then
        return byID
      end
    else
      return byID
    end
  end

  local names = {}
  local function pushName(value)
    local text = tostring(value or "")
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    if text ~= "" then
      names[#names + 1] = text
    end
  end
  pushName(item and item.factionName)
  pushName(selectedEntry and selectedEntry.label)
  pushName(selectedEntry and selectedEntry.detail)
  pushName(item and item.sourceName)
  pushName(item and item.sourceDetail)
  pushName(item and item.source)
  if type(selectedEntry) == "table" then
    for _, row in ipairs(selectedEntry.rows or {}) do
      pushName(row and row.factionName)
    end
  end

  for _, name in ipairs(names) do
    local snap = GetFactionSnapshotByName(name, true)
    if snap then
      return snap
    end
  end

  return nil
end

local TRACKER_KIND_ORDER = {
  instances = 1,
  professions = 2,
  vendor = 3,
  reputation = 4,
  quest = 5,
  achievement = 6,
  endeavor = 7,
}

local function IsAcquireEntryComplete(entry)
  if type(entry) ~= "table" or type(entry.rows) ~= "table" or #entry.rows == 0 then
    return false
  end
  local collectedFn = ns and ns.GetWarbandCollectedState or nil
  if type(collectedFn) ~= "function" then
    return false
  end
  for _, item in ipairs(entry.rows) do
    if not collectedFn(item) then
      return false
    end
  end
  return true
end

local function BuildEntryCompletionSummary(entry)
  local rows = type(entry) == "table" and entry.rows or nil
  if type(rows) ~= "table" or #rows == 0 then
    return 0, 0, 0, false
  end
  local collectedFn = ns and ns.GetWarbandCollectedState or nil
  if type(collectedFn) ~= "function" then
    return 0, #rows, 0, false
  end
  local done = 0
  local total = #rows
  for _, row in ipairs(rows) do
    if collectedFn(row) then
      done = done + 1
    end
  end
  local function RoundPercentUp(numerator, denominator)
    if denominator <= 0 then
      return 0
    end
    local raw = (numerator / denominator) * 100
    return math.ceil(raw * 10) / 10
  end
  local pct = 0
  if total > 0 then
    pct = RoundPercentUp(done, total)
  end
  return done, total, pct, (total > 0 and done >= total)
end

local function AggregateCompletionPercent(entries)
  local doneTotal = 0
  local rowTotal = 0
  for _, entry in ipairs(entries or {}) do
    local done, total = BuildEntryCompletionSummary(entry)
    doneTotal = doneTotal + done
    rowTotal = rowTotal + total
  end
  if rowTotal <= 0 then
    return 0
  end
  return math.ceil(((doneTotal / rowTotal) * 100) * 10) / 10
end

local function BuildSourceCompletionMaps(entries)
  local zoneTotals = {}
  local expansionTotals = {}
  local byEntryKey = {}
  local byEntryPercent = {}

  for _, entry in ipairs(entries or {}) do
    local expansion = ns.GetSourceDisplayExpansion(entry.expansion, entry.zoneName)
    local zoneName = tostring(entry.zoneName or "Unknown Zone")
    local zoneKey = BuildNewSourceZoneKey(expansion, zoneName)
    local doneRows, totalRows, pct, done = BuildEntryCompletionSummary(entry)
    local entryKey = tostring(type(entry) == "table" and entry.key or "")
    if entryKey ~= "" then
      byEntryKey[entryKey] = done
      byEntryPercent[entryKey] = pct
    end

    zoneTotals[zoneKey] = zoneTotals[zoneKey] or { total = 0, done = 0 }
    zoneTotals[zoneKey].total = zoneTotals[zoneKey].total + totalRows
    zoneTotals[zoneKey].done = zoneTotals[zoneKey].done + doneRows

    expansionTotals[expansion] = expansionTotals[expansion] or { total = 0, done = 0 }
    expansionTotals[expansion].total = expansionTotals[expansion].total + totalRows
    expansionTotals[expansion].done = expansionTotals[expansion].done + doneRows
  end

  local byZoneKey = {}
  local byZonePercent = {}
  for key, totals in pairs(zoneTotals) do
    byZoneKey[key] = totals.total > 0 and totals.done >= totals.total
    byZonePercent[key] = totals.total > 0 and (math.ceil(((totals.done / totals.total) * 100) * 10) / 10) or 0
  end
  local byExpansion = {}
  local byExpansionPercent = {}
  for exp, totals in pairs(expansionTotals) do
    byExpansion[exp] = totals.total > 0 and totals.done >= totals.total
    byExpansionPercent[exp] = totals.total > 0 and (math.ceil(((totals.done / totals.total) * 100) * 10) / 10) or 0
  end

  return byZoneKey, byExpansion, byEntryKey, byZonePercent, byExpansionPercent, byEntryPercent
end

local function IsInstanceEntryComplete(entry)
  if type(entry) ~= "table" then
    return false
  end
  if type(entry.rows) ~= "table" or #entry.rows == 0 then
    return false
  end
  local collectedFn = ns and ns.GetWarbandCollectedState or nil
  for _, row in ipairs(entry.rows or {}) do
    local collected = type(collectedFn) == "function" and collectedFn(row) or false
    if not collected then
      return false
    end
  end
  return true
end

local function ResolveInstanceExpansionForCompletion(entry)
  local instance = type(entry) == "table" and entry.instance or nil
  if type(instance) == "table" then
    local expansion = tostring(instance.expansion or "")
    if expansion ~= "" then
      return expansion
    end
  end
  return "Other"
end

local function BuildInstanceCompletionMaps(entries)
  local byEntryKey = {}
  local expansionTotals = {}
  local byEntryPercent = {}

  for _, entry in ipairs(entries or {}) do
    local key = tostring(type(entry) == "table" and entry.key or "")
    local expansion = ResolveInstanceExpansionForCompletion(entry)
    local doneRows, totalRows, pct, done = BuildEntryCompletionSummary(entry)
    if key ~= "" then
      byEntryKey[key] = done
      byEntryPercent[key] = pct
    end
    expansionTotals[expansion] = expansionTotals[expansion] or { total = 0, done = 0 }
    expansionTotals[expansion].total = expansionTotals[expansion].total + totalRows
    expansionTotals[expansion].done = expansionTotals[expansion].done + doneRows
  end

  local byExpansion = {}
  local byExpansionPercent = {}
  for expansion, totals in pairs(expansionTotals) do
    byExpansion[expansion] = totals.total > 0 and totals.done >= totals.total
    byExpansionPercent[expansion] = totals.total > 0 and (math.ceil(((totals.done / totals.total) * 100) * 10) / 10) or 0
  end

  return byEntryKey, byExpansion, byEntryPercent, byExpansionPercent
end

local function BuildProfessionCompletionMaps(entries)
  local byEntryKey = {}
  local expansionTotals = {}
  local byEntryPercent = {}

  for _, entry in ipairs(entries or {}) do
    local key = tostring(type(entry) == "table" and entry.key or "")
    local expansion = ns._NormalizeCategoryLabel(entry and entry.profession and entry.profession.expansion, "Other")
    local doneRows, totalRows, pct, done = BuildEntryCompletionSummary(entry)
    if key ~= "" then
      byEntryKey[key] = done
      byEntryPercent[key] = pct
    end
    expansionTotals[expansion] = expansionTotals[expansion] or { total = 0, done = 0 }
    expansionTotals[expansion].total = expansionTotals[expansion].total + totalRows
    expansionTotals[expansion].done = expansionTotals[expansion].done + doneRows
  end

  local byExpansion = {}
  local byExpansionPercent = {}
  for expansion, totals in pairs(expansionTotals) do
    byExpansion[expansion] = totals.total > 0 and totals.done >= totals.total
    byExpansionPercent[expansion] = totals.total > 0 and (math.ceil(((totals.done / totals.total) * 100) * 10) / 10) or 0
  end

  return byEntryKey, byExpansion, byEntryPercent, byExpansionPercent
end

local function ResolveSourceEntryZoneName(entry, rows)
  if type(entry) == "table" and type(entry.waypoint) == "table" and entry.waypoint.zone and entry.waypoint.zone ~= "" then
    return tostring(entry.waypoint.zone)
  end

  local zones = {}
  local zoneCount = 0
  for _, item in ipairs(rows or {}) do
    local z = tostring(item and item.zone or "")
    if z ~= "" and not zones[z] then
      zones[z] = true
      zoneCount = zoneCount + 1
      if zoneCount > 1 then
        return "Multiple Zones"
      end
    end
  end

  if zoneCount == 1 then
    for zoneName in pairs(zones) do
      return zoneName
    end
  end

  if type(entry) == "table" and entry.detail and entry.detail ~= "" then
    return tostring(entry.detail)
  end

  return "Unknown Zone"
end

local function ClampValue(v, minV, maxV)
  if v < minV then
    return minV
  end
  if v > maxV then
    return maxV
  end
  return v
end

local function GetSavedWindowOpacity()
  local settings = DecorDropsDB and DecorDropsDB.settings or nil
  local value = settings and tonumber(settings.windowOpacity) or nil
  if not value then
    value = 1.0
  end
  return ClampValue(value, 0.35, 1.0)
end

local function SetSavedWindowOpacity(value)
  if not DecorDropsDB then
    return
  end

  DecorDropsDB.settings = DecorDropsDB.settings or {}
  local clamped = ClampValue(tonumber(value) or 1.0, 0.35, 1.0)
  DecorDropsDB.settings.windowOpacity = clamped

  if ns and ns.MainFrame then
    ns.MainFrame:SetAlpha(clamped)
  end
end

local function NormalizeCompareText(value)
  local text = string.lower(tostring(value or ""))
  text = text:gsub("^%s+", ""):gsub("%s+$", "")
  return text
end

function ns.SetQuestLinkButtonVisual(button, mode)
  if not button then
    return
  end
  local normal = button.GetNormalTexture and button:GetNormalTexture() or nil
  local pushed = button.GetPushedTexture and button:GetPushedTexture() or nil
  local setColor = function(tex, r, g, b, a)
    if tex and tex.SetVertexColor then
      tex:SetVertexColor(r, g, b, a or 1)
    end
  end
  if mode == "promotion" then
    setColor(normal, 1.00, 0.26, 0.26, 1.00)
    setColor(pushed, 1.00, 0.26, 0.26, 1.00)
  else
    setColor(normal, 1.00, 0.96, 0.55, 1.00)
    setColor(pushed, 1.00, 0.96, 0.55, 1.00)
  end
  button._ddLinkMode = mode
end

function ns.BuildNavigationLookupContext()
  local context = {
    questByQuestID = {},
    questByItemID = {},
    promotionByItemID = {},
  }

  local questEntries = ns.GetAcquireData and ns.GetAcquireData("quest") or {}
  if type(questEntries) == "table" then
    for _, entry in ipairs(questEntries) do
      local entryDetail = NormalizeCompareText(entry and entry.detail)
      local entryExpansion = NormalizeCompareText(entry and entry.expansion)
      local entryLabel = NormalizeCompareText(entry and entry.label)
      for _, row in ipairs(entry.rows or {}) do
        local questID = tonumber(row and row.questID) or 0
        local itemID = tonumber(row and row.itemID) or 0
        local candidate = {
          entry = entry,
          row = row,
          sourceName = NormalizeCompareText(row and row.sourceName),
          source = NormalizeCompareText(row and row.source),
          detail = entryDetail,
          expansion = entryExpansion,
          label = entryLabel,
        }
        if questID > 0 and not context.questByQuestID[questID] then
          context.questByQuestID[questID] = candidate
        end
        if itemID > 0 then
          context.questByItemID[itemID] = context.questByItemID[itemID] or {}
          context.questByItemID[itemID][#context.questByItemID[itemID] + 1] = candidate
        end
      end
    end
  end

  local promotionEntries = ns.GetAcquireData and ns.GetAcquireData("promotion") or {}
  if type(promotionEntries) == "table" then
    for _, entry in ipairs(promotionEntries) do
      local entryDetail = NormalizeCompareText(entry and entry.detail)
      local entryExpansion = NormalizeCompareText(entry and entry.expansion)
      for _, row in ipairs(entry.rows or {}) do
        local itemID = tonumber(row and row.itemID) or 0
        if itemID > 0 then
          context.promotionByItemID[itemID] = context.promotionByItemID[itemID] or {}
          context.promotionByItemID[itemID][#context.promotionByItemID[itemID] + 1] = {
            entry = entry,
            row = row,
            sourceName = NormalizeCompareText(row and row.sourceName),
            source = NormalizeCompareText(row and row.source),
            displaySource = NormalizeCompareText(row and row.displaySource),
            detail = entryDetail,
            expansion = entryExpansion,
          }
        end
      end
    end
  end

  return context
end

function ns.ResolveQuestNavigationTarget(item, selectedEntry, lookupContext)
  if ns.IsQuestGroupVendorUnlockLike and ns.IsQuestGroupVendorUnlockLike({ expansion = item and item.expansion, label = item and item.sourceName, rows = { item } }) then
    return nil
  end

  if type(lookupContext) ~= "table" then
    return nil
  end

  local wantedQuestID = tonumber(item and item.questID) or 0
  local wantedItemID = tonumber(item and item.itemID) or 0
  if wantedQuestID <= 0 and wantedItemID <= 0 then
    return nil
  end

  if wantedQuestID > 0 then
    local byQuest = lookupContext.questByQuestID and lookupContext.questByQuestID[wantedQuestID] or nil
    if byQuest and byQuest.entry and byQuest.row then
      return { entry = byQuest.entry, row = byQuest.row }
    end
  end
  if wantedItemID <= 0 then
    return nil
  end

  local wantedSourceName = NormalizeCompareText(item and item.sourceName)
  local wantedSource = NormalizeCompareText(item and item.source)
  local wantedDetail = NormalizeCompareText(item and item.sourceDetail)
  local wantedExpansion = NormalizeCompareText(selectedEntry and selectedEntry.expansion)
  local wantedLabel = NormalizeCompareText(selectedEntry and selectedEntry.label)

  local candidates = lookupContext.questByItemID and lookupContext.questByItemID[wantedItemID] or nil
  if type(candidates) ~= "table" then
    return nil
  end

  local best = nil
  local bestScore = -1
  for i = 1, #candidates do
    local candidate = candidates[i]
    local score = 100
    if wantedSourceName ~= "" and candidate.sourceName ~= "" and candidate.sourceName == wantedSourceName then score = score + 20 end
    if wantedSource ~= "" and candidate.source ~= "" and candidate.source == wantedSource then score = score + 10 end
    if wantedDetail ~= "" and candidate.detail ~= "" and candidate.detail == wantedDetail then score = score + 5 end
    if wantedExpansion ~= "" and candidate.expansion ~= "" and candidate.expansion == wantedExpansion then score = score + 2 end
    if wantedLabel ~= "" and candidate.label ~= "" and candidate.label == wantedLabel then score = score + 2 end
    if score > bestScore then
      bestScore = score
      best = { entry = candidate.entry, row = candidate.row }
    end
  end
  return best
end

function ns.ResolvePromotionNavigationTarget(item, selectedEntry, lookupContext)
  if type(lookupContext) ~= "table" then
    return nil
  end

  local wantedItemID = tonumber(item and item.itemID) or 0
  if wantedItemID <= 0 then
    return nil
  end

  local wantedSourceName = NormalizeCompareText(item and item.sourceName)
  local wantedSource = NormalizeCompareText(item and item.source)
  local wantedDisplaySource = NormalizeCompareText(item and item.displaySource)
  local wantedDetail = NormalizeCompareText(item and item.sourceDetail)
  local wantedExpansion = NormalizeCompareText(selectedEntry and selectedEntry.expansion)

  local candidates = lookupContext.promotionByItemID and lookupContext.promotionByItemID[wantedItemID] or nil
  if type(candidates) ~= "table" then
    return nil
  end

  local best = nil
  local bestScore = -1
  for i = 1, #candidates do
    local candidate = candidates[i]
    local score = 100
    if wantedSourceName ~= "" and candidate.sourceName ~= "" and candidate.sourceName == wantedSourceName then score = score + 12 end
    if wantedSource ~= "" and candidate.source ~= "" and candidate.source == wantedSource then score = score + 8 end
    if wantedDisplaySource ~= "" and candidate.displaySource ~= "" and candidate.displaySource == wantedDisplaySource then score = score + 8 end
    if wantedDetail ~= "" and candidate.detail ~= "" and candidate.detail == wantedDetail then score = score + 6 end
    if wantedExpansion ~= "" and candidate.expansion ~= "" and candidate.expansion == wantedExpansion then score = score + 2 end
    if score > bestScore then
      bestScore = score
      best = { entry = candidate.entry, row = candidate.row }
    end
  end

  return best
end

local RememberSelectedKeyForMode
local ApplySelectedKeyForCurrentMode

local function NavigateToQuestEntry(frame, questTarget)
  if not frame or not questTarget or not questTarget.entry or not questTarget.entry.key then
    return
  end
  ns.SetViewMode(frame, "quest")
  local targetZoneName = ResolveSourceEntryZoneName(questTarget.entry, questTarget.entry.rows)
  local targetExpansion = ns.GetSourceDisplayExpansion(questTarget.entry.expansion, targetZoneName)
  CollapseState.SetExpansionCollapsed(frame, targetExpansion, false)
  if targetZoneName and targetZoneName ~= "" then
    CollapseState.SetSourceZoneCollapsed(frame, targetExpansion, targetZoneName, false)
  end
  ApplySelectedKeyForCurrentMode(frame, questTarget.entry.key)
  if frame.Refresh then
    frame:Refresh()
    local selected = ns.FindSelectedEntry and ns.FindSelectedEntry(frame) or nil
    if not selected or selected.key ~= questTarget.entry.key then
      frame.filters.search = ""
      frame.filters.collected = "All"
      frame.filters.rarity = BuildDefaultRarityFilter()
      DecorDropsDB.search = frame.filters.search
      DecorDropsDB.collectedFilter = frame.filters.collected
      DecorDropsDB.rarityFilter = frame.filters.rarity
      ApplySelectedKeyForCurrentMode(frame, questTarget.entry.key)
      frame:Refresh()
    end
  end
end

function ns.NavigateToPromotionEntry(frame, promotionTarget)
  if not frame or not promotionTarget or not promotionTarget.entry or not promotionTarget.entry.key then
    return
  end
  ns.SetViewMode(frame, "promotion")
  local targetZoneName = ResolveSourceEntryZoneName(promotionTarget.entry, promotionTarget.entry.rows)
  local targetExpansion = ns.GetSourceDisplayExpansion(promotionTarget.entry.expansion, targetZoneName)
  CollapseState.SetExpansionCollapsed(frame, targetExpansion, false)
  if targetZoneName and targetZoneName ~= "" then
    CollapseState.SetSourceZoneCollapsed(frame, targetExpansion, targetZoneName, false)
  end
  ApplySelectedKeyForCurrentMode(frame, promotionTarget.entry.key)
  if frame.Refresh then
    frame:Refresh()
    local selected = ns.FindSelectedEntry and ns.FindSelectedEntry(frame) or nil
    if not selected or selected.key ~= promotionTarget.entry.key then
      frame.filters.search = ""
      frame.filters.collected = "All"
      frame.filters.rarity = BuildDefaultRarityFilter()
      DecorDropsDB.search = frame.filters.search
      DecorDropsDB.collectedFilter = frame.filters.collected
      DecorDropsDB.rarityFilter = frame.filters.rarity
      ApplySelectedKeyForCurrentMode(frame, promotionTarget.entry.key)
      frame:Refresh()
    end
  end
end

local function ExpansionName(instance)
  local expansion = instance and instance.expansion or nil
  if not expansion or expansion == "" or expansion == "Unknown Expansion" then
    return "Other"
  end
  if string.find(string.lower(expansion), "midnight", 1, true) then
    return "Midnight"
  end
  return expansion
end

local function InstanceKey(instance)
  return string.lower(
    ExpansionName(instance)
    .. "|" .. (instance.type or "Dungeon")
    .. "|" .. (instance.name or "")
    .. "|" .. tostring(instance.mapID or 0)
  )
end

local pendingItemNameRequests = {}
local ITEM_NAME_REQUEST_COOLDOWN_SECONDS = 10
local GetHousingCatalogInfoByItem

function ns.WasItemDataRecentlyRequested(itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return false
  end
  local now = GetTime and GetTime() or 0
  local lastRequest = tonumber(pendingItemNameRequests[numericItemID]) or 0
  return (now - lastRequest) < ITEM_NAME_REQUEST_COOLDOWN_SECONDS
end

function ns.MarkItemDataRequested(itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return
  end
  pendingItemNameRequests[numericItemID] = GetTime and GetTime() or 0
end

function ns.IsItemDataRequestPending(itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return false
  end
  return pendingItemNameRequests[numericItemID] ~= nil
end

function ns.ResolveItemDataRequest(itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return
  end
  pendingItemNameRequests[numericItemID] = nil
end

local function GetItemName(item)
  if item and item.itemID then
    local itemID = tonumber(item.itemID)
    local itemName = C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(item.itemID) or nil
    if itemName and itemName ~= "" then
      if itemID then ns.ResolveItemDataRequest(itemID) end
      return itemName
    end

    local itemNameClassic = GetItemInfo(item.itemID)
    if itemNameClassic and itemNameClassic ~= "" then
      if itemID then ns.ResolveItemDataRequest(itemID) end
      return itemNameClassic
    end

    if itemID and ns.ITEM_NAME_OVERRIDES and ns.ITEM_NAME_OVERRIDES[itemID] then
      return tostring(ns.ITEM_NAME_OVERRIDES[itemID])
    end

    local itemLink = item.itemLink or item.link
    if type(itemLink) == "string" and itemLink ~= "" then
      local linkedName = itemLink:match("%[(.-)%]")
      if linkedName and linkedName ~= "" then
        return linkedName
      end
    end

    if itemID and itemID > 0 and C_Item and C_Item.RequestLoadItemDataByID and not ns.WasItemDataRecentlyRequested(itemID) then
      ns.MarkItemDataRequested(itemID)
      C_Item.RequestLoadItemDataByID(itemID)
    end

    return "Item #" .. tostring(itemID)
  end

  if type(item) == "table" and type(item.name) == "string" and item.name ~= "" then
    return item.name
  end
  return "Unknown Item"
end

local function GetItemQuality(item)
  if item and item.itemID then
    if C_Item and C_Item.GetItemQualityByID then
      local quality = C_Item.GetItemQualityByID(item.itemID)
      if quality ~= nil then
        return quality
      end
    end

    local _, _, quality = GetItemInfo(item.itemID)
    if quality ~= nil then
      return quality
    end
  end

  return LE_ITEM_QUALITY_COMMON or 1
end

local function GetItemQualityColor(item)
  local quality = GetItemQuality(item)
  local color = ITEM_QUALITY_COLORS and ITEM_QUALITY_COLORS[quality] or nil
  if color and color.r and color.g and color.b then
    return color.r, color.g, color.b
  end

  return 1, 1, 1
end

local function BuildDefaultRarityFilter()
  local out = {}
  for _, info in ipairs(RARITY_FILTERS) do
    out[info.quality] = true
  end
  return out
end

local function NormalizeRarityFilter(input)
  local out = BuildDefaultRarityFilter()
  if type(input) ~= "table" then
    return out
  end

  local anyKnownKey = false
  for _, info in ipairs(RARITY_FILTERS) do
    if input[info.quality] ~= nil then
      anyKnownKey = true
      out[info.quality] = input[info.quality] and true or false
    end
  end

  if not anyKnownKey then
    return BuildDefaultRarityFilter()
  end
  return out
end

function ns.IsDefaultRarityFilter(input)
  local normalized = NormalizeRarityFilter(input)
  for _, info in ipairs(RARITY_FILTERS) do
    if normalized[info.quality] ~= true then
      return false
    end
  end
  return true
end

function ns.HasActiveProfessionFilter(frame)
  local selected = frame and frame.filters and frame.filters.professionMulti
  if type(selected) ~= "table" then
    return false
  end
  for _, enabled in pairs(selected) do
    if enabled then
      return true
    end
  end
  return false
end

function ns.HasNonDefaultFilters(frame)
  if not frame or type(frame.filters) ~= "table" then
    return false
  end
  local search = tostring(frame.filters.search or "")
  if search:match("%S") then
    return true
  end
  if tostring(frame.filters.type or "All") ~= "All" then
    return true
  end
  if tostring(frame.filters.collected or "All") ~= "All" then
    return true
  end
  if not ns.IsDefaultRarityFilter(frame.filters.rarity) then
    return true
  end
  if frame.filters.onlyKnownProfessions then
    return true
  end
  if ns.HasActiveProfessionFilter(frame) then
    return true
  end
  return false
end

function ns.ApplyDefaultFilters(frame, syncControls)
  if not frame then
    return
  end
  frame.filters = frame.filters or {}
  frame.filters.search = ""
  frame.filters.type = "All"
  frame.filters.collected = "All"
  frame.filters.rarity = BuildDefaultRarityFilter()
  frame.filters.onlyKnownProfessions = false
  frame.filters.professionMulti = {}

  if DecorDropsDB then
    DecorDropsDB.search = ""
    DecorDropsDB.typeFilter = "All"
    DecorDropsDB.collectedFilter = "All"
    DecorDropsDB.rarityFilter = BuildDefaultRarityFilter()
    DecorDropsDB.onlyKnownProfessions = false
    DecorDropsDB.professionFilter = {}
  end

  if not syncControls then
    return
  end
  frame._suppressFilterControlEvents = true
  if frame.searchBox then
    frame.searchBox:SetText("")
  end
  if frame.typeButton then
    frame.typeButton:SetText("Type: All")
  end
  if frame.collectedButton then
    frame.collectedButton:SetText("Show: All")
  end
  if frame.knownProfessionOnlyCheck then
    frame.knownProfessionOnlyCheck:SetChecked(false)
  end
  if frame.professionFilterMenu then
    frame.professionFilterMenu:Hide()
  end
  if frame.UpdateProfessionFilterButtonText then
    frame:UpdateProfessionFilterButtonText()
  end
  if frame.UpdateRarityLegendVisual then
    frame:UpdateRarityLegendVisual()
  end
  frame._suppressFilterControlEvents = nil
end

ns._CopyFilterState = function(filters)
  local source = type(filters) == "table" and filters or {}
  local professionMulti = {}
  if type(source.professionMulti) == "table" then
    for professionName, enabled in pairs(source.professionMulti) do
      if enabled then
        professionMulti[professionName] = true
      end
    end
  end
  return {
    search = tostring(source.search or ""),
    type = tostring(source.type or "All"),
    collected = tostring(source.collected or "All"),
    rarity = NormalizeRarityFilter(source.rarity),
    onlyKnownProfessions = source.onlyKnownProfessions and true or false,
    professionMulti = professionMulti,
  }
end

ns._SyncFilterControlsFromState = function(frame)
  if not frame then
    return
  end
  frame._suppressFilterControlEvents = true
  if frame.searchBox then
    frame.searchBox:SetText(tostring(frame.filters.search or ""))
  end
  if frame.typeButton then
    frame.typeButton:SetText("Type: " .. tostring(frame.filters.type or "All"))
  end
  if frame.collectedButton then
    frame.collectedButton:SetText("Show: " .. tostring(frame.filters.collected or "All"))
  end
  if frame.knownProfessionOnlyCheck then
    frame.knownProfessionOnlyCheck:SetChecked(frame.filters.onlyKnownProfessions and true or false)
  end
  if frame.professionFilterMenu then
    frame.professionFilterMenu:Hide()
  end
  if frame.UpdateProfessionFilterButtonText then
    frame:UpdateProfessionFilterButtonText()
  end
  if frame.UpdateRarityLegendVisual then
    frame:UpdateRarityLegendVisual()
  end
  frame._suppressFilterControlEvents = nil
end

ns._ApplyFilterState = function(frame, state, syncControls)
  if not frame then
    return
  end
  frame.filters = ns._CopyFilterState(state)
  if frame.filters.collected == "Missing" then
    frame.filters.collected = "Uncollected"
  end
  if DecorDropsDB then
    DecorDropsDB.search = frame.filters.search
    DecorDropsDB.typeFilter = frame.filters.type
    DecorDropsDB.collectedFilter = frame.filters.collected
    DecorDropsDB.rarityFilter = NormalizeRarityFilter(frame.filters.rarity)
    DecorDropsDB.onlyKnownProfessions = frame.filters.onlyKnownProfessions and true or false
    DecorDropsDB.professionFilter = frame.filters.professionMulti
  end
  if syncControls then
    ns._SyncFilterControlsFromState(frame)
  end
end

ns._ResetAllSessionFilters = function(frame, syncControls)
  if not frame then
    return
  end
  frame._sessionFiltersByMode = {}
  ns.ApplyDefaultFilters(frame, syncControls)
  local currentMode = frame.viewMode or "instances"
  frame._sessionFiltersByMode[currentMode] = ns._CopyFilterState(frame.filters)
end

local function GetItemIconTexture(item)
  if item.itemID then
    local itemID = tonumber(item.itemID)

    if item and item.icon then
      local directIcon = item.icon
      if (type(directIcon) == "number" and directIcon > 0) or (type(directIcon) == "string" and directIcon ~= "") then
        if itemID then ns.ResolveItemDataRequest(itemID) end
        return directIcon
      end
    end

    local catalogInfo = nil
    if itemID and C_HousingCatalog and type(C_HousingCatalog.GetCatalogEntryInfoByItem) == "function" then
      local itemLink = "item:" .. tostring(itemID)
      local signatures = {
        { itemLink, true },
        { itemLink, false },
        { itemID, true },
        { itemID, false },
      }
      for _, args in ipairs(signatures) do
        local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByItem, unpack(args))
        if ok and type(info) == "table" then
          catalogInfo = info
          break
        end
      end
    end
    if type(catalogInfo) == "table" then
      local function readIconCandidate(value)
        if type(value) == "number" and value > 0 then
          return value
        end
        if type(value) == "string" and value ~= "" then
          return value
        end
        if type(value) == "table" then
          local nested = tonumber(value.fileID) or tonumber(value.iconFileID) or tonumber(value.iconID)
          if nested and nested > 0 then
            return nested
          end
        end
        return nil
      end

      for _, key in ipairs({ "iconFileID", "iconID", "icon", "iconTexture", "textureFileID", "texture" }) do
        local parsed = readIconCandidate(catalogInfo[key])
        if parsed then
          return parsed
        end
      end

      for _, value in pairs(catalogInfo) do
        if type(value) == "table" then
          for _, key in ipairs({ "iconFileID", "iconID", "icon", "textureFileID", "texture" }) do
            local parsed = readIconCandidate(value[key])
            if parsed then
              return parsed
            end
          end
        end
      end
    end

    if C_Item and C_Item.GetItemIconByID then
      local texture = C_Item.GetItemIconByID(item.itemID)
      if texture then
        if itemID then ns.ResolveItemDataRequest(itemID) end
        return texture
      end
    end

    local texture = select(10, GetItemInfo(item.itemID))
    if texture then
      if itemID then ns.ResolveItemDataRequest(itemID) end
      return texture
    end

    if itemID and ns.ITEM_ICON_OVERRIDES and ns.ITEM_ICON_OVERRIDES[itemID] then
      ns.ResolveItemDataRequest(itemID)
      return ns.ITEM_ICON_OVERRIDES[itemID]
    end

    if itemID and itemID > 0 and C_Item and C_Item.RequestLoadItemDataByID and not ns.WasItemDataRecentlyRequested(itemID) then
      ns.MarkItemDataRequested(itemID)
      C_Item.RequestLoadItemDataByID(itemID)
    end
  end

  return 134400
end

local function FitTextToWidth(fontString, text, minSize, allowWrap, maxLines)
  if not fontString then
    return
  end

  local fontPath, baseSize, flags = fontString:GetFont()
  if not fontPath or not baseSize then
    fontString:SetText(text or "")
    return
  end

  local size = math.floor(baseSize + 0.5)
  local floorSize = minSize or math.max(8, size - 4)
  local lines = maxLines or 1

  if allowWrap then
    fontString:SetWordWrap(true)
    if fontString.SetMaxLines then
      fontString:SetMaxLines(lines)
    end
  else
    fontString:SetWordWrap(false)
    if fontString.SetMaxLines then
      fontString:SetMaxLines(1)
    end
  end

  fontString:SetFont(fontPath, size, flags)
  fontString:SetText(text or "")

  local function IsOverflowing()
    if allowWrap then
      local _, currentSize = fontString:GetFont()
      local lineHeight = (currentSize or size) * 1.35
      return fontString:GetStringHeight() > (lineHeight * lines + 1)
    end
    return fontString:GetStringWidth() > (fontString:GetWidth() - 2)
  end

  while size > floorSize and IsOverflowing() do
    size = size - 1
    fontString:SetFont(fontPath, size, flags)
  end
end

local function ParseCollectedFromValue(value)
  local valueType = type(value)

  if valueType == "boolean" then
    return value
  end

  if valueType == "number" then
    if value == 0 then
      return false
    end
    if value == 1 then
      return true
    end
    return nil
  end

  if valueType == "string" then
    local normalized = string.lower(value)
    if normalized == "owned" or normalized == "collected" or normalized == "known" or normalized == "yes" or normalized == "true" then
      return true
    end
    if normalized == "unowned" or normalized == "not collected" or normalized == "unknown" or normalized == "no" or normalized == "false" then
      return false
    end
    return nil
  end

  if valueType ~= "table" then
    return nil
  end

  if value.firstAcquisitionBonus ~= nil then
    local bonus = tonumber(value.firstAcquisitionBonus)
    if bonus == 0 then
      return true
    end
  end

  local quantity = tonumber(value.quantity or value.quantityOwned or value.ownedQuantity or value.numOwned or value.ownedCount) or 0
  local numPlaced = tonumber(value.numPlaced) or 0
  local remainingRedeemable = tonumber(value.remainingRedeemable) or 0
  if quantity > 0 or numPlaced > 0 or remainingRedeemable > 0 then
    return true
  end

  local entrySubtype = tonumber(value.entrySubtype)
  if not entrySubtype and type(value.entryID) == "table" then
    entrySubtype = tonumber(value.entryID.entrySubtype)
  end
  if entrySubtype then
    if entrySubtype >= 2 then
      return true
    end
    if entrySubtype == 1 then
      return false
    end
  end

  local boolKeys = {
    "isOwned",
    "owned",
    "isCollected",
    "collected",
    "isKnown",
    "known",
    "isUnlocked",
    "unlocked",
    "hasCollected",
  }

  for _, key in ipairs(boolKeys) do
    if value[key] ~= nil then
      local parsed = ParseCollectedFromValue(value[key])
      if parsed ~= nil then
        return parsed
      end
    end
  end

  local countKeys = {
    "quantityOwned",
    "ownedQuantity",
    "countOwned",
    "numOwned",
    "ownedCount",
  }

  for _, key in ipairs(countKeys) do
    if value[key] ~= nil then
      local n = tonumber(value[key])
      if n ~= nil then
        return n > 0
      end
    end
  end

  return nil
end

function ns.ParseCatalogOwnershipState(value)
  local valueType = type(value)
  if valueType == "boolean" then
    return value
  end
  if valueType == "number" then
    if value == 0 then return false end
    if value == 1 then return true end
    return nil
  end
  if valueType == "string" then
    local normalized = string.lower(value)
    if normalized == "owned" or normalized == "collected" or normalized == "known" or normalized == "yes" or normalized == "true" then
      return true
    end
    if normalized == "unowned" or normalized == "not collected" or normalized == "unknown" or normalized == "no" or normalized == "false" then
      return false
    end
    return nil
  end
  if valueType ~= "table" then
    return nil
  end

  -- Only accept explicit ownership flags here; broader "known" fields are often metadata.
  local boolKeys = { "isOwned", "owned", "isCollected", "collected", "hasCollected", "isUnlocked", "unlocked" }
  for _, key in ipairs(boolKeys) do
    if value[key] ~= nil then
      local parsed = ns.ParseCatalogOwnershipState(value[key])
      if parsed ~= nil then
        return parsed
      end
    end
  end

  -- "numPlaced" is placement-state, not ownership-state, and causes false positives.
  local countKeys = { "quantityOwned", "ownedQuantity", "countOwned", "numOwned", "ownedCount" }
  for _, key in ipairs(countKeys) do
    if value[key] ~= nil then
      local n = tonumber(value[key])
      if n ~= nil then
        return n > 0
      end
    end
  end

  local nestedKeys = { "entry", "entryInfo", "catalogEntry", "catalogInfo" }
  for _, key in ipairs(nestedKeys) do
    if type(value[key]) == "table" then
      local parsed = ns.ParseCatalogOwnershipState(value[key])
      if parsed ~= nil then
        return parsed
      end
    end
  end

  return nil
end

function ns.IsStrictCatalogOwnershipEnabled()
  local settings = DecorDropsDB and DecorDropsDB.settings or nil
  if type(settings) ~= "table" then
    return true
  end
  return settings.strictCatalogOwnership ~= false
end

GetHousingCatalogInfoByItem = function(itemID)
  if not itemID or not C_HousingCatalog or type(C_HousingCatalog.GetCatalogEntryInfoByItem) ~= "function" then
    return nil
  end

  local itemLink = "item:" .. tostring(itemID)
  local signatures = {
    { itemLink, true },
    { itemLink, false },
    { itemID, true },
    { itemID, false },
  }

  for _, args in ipairs(signatures) do
    local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByItem, unpack(args))
    if ok and type(info) == "table" then
      return info
    end
  end

  return nil
end

local function ExtractCatalogEntryIDFromValue(value)
  if type(value) == "number" and value > 1 then
    return value
  end

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

  local keys = {
    "entryID",
    "catalogEntryID",
    "catalogID",
    "id",
  }

  for _, key in ipairs(keys) do
    local n = tonumber(value[key])
    if n and n > 1 then
      return n
    end
  end

  return nil
end

local function ParseCollectedFromResults(...)
  local count = select("#", ...)
  for i = 1, count do
    local parsed = ParseCollectedFromValue(select(i, ...))
    if parsed ~= nil then
      return parsed
    end
  end
  return nil
end

local function GetOwnedItemCount(itemID)
  if not itemID then
    return 0
  end

  if GetItemCount then
    local ok, count = pcall(GetItemCount, itemID, true, true, false, true)
    if ok and type(count) == "number" then
      return count
    end

    ok, count = pcall(GetItemCount, itemID, true, true)
    if ok and type(count) == "number" then
      return count
    end
  end

  return 0
end

local function TryHousingCollectedStateByEntry(entryID)
  if not C_HousingCatalog or not entryID then
    return nil
  end

  local calls = {
    { fn = "GetCatalogEntryInfo", args = { entryID, true } },
    { fn = "GetCatalogEntryInfo", args = { entryID } },
    { fn = "GetCatalogEntryByID", args = { entryID, true } },
    { fn = "GetCatalogEntryByID", args = { entryID } },
  }

  for _, call in ipairs(calls) do
    local api = C_HousingCatalog[call.fn]
    if type(api) == "function" then
      local ok, a, b, c, d = pcall(api, unpack(call.args))
      if ok then
        local parsed = ParseCollectedFromResults(a, b, c, d)
        if parsed ~= nil then
          return parsed
        end
      end
    end
  end

  return nil
end

function ns.GetCatalogQuantityPlacedOwned(value, depth, visited)
  if value == nil then
    return nil
  end
  local valueType = type(value)
  if valueType ~= "table" then
    return nil
  end

  depth = tonumber(depth) or 0
  if depth > 5 then
    return nil
  end

  visited = visited or {}
  if visited[value] then
    return nil
  end
  visited[value] = true

  local sawCountField = false
  local countKeys = { "quantity", "numPlaced" }
  for _, key in ipairs(countKeys) do
    local v = value[key]
    if v ~= nil then
      local n = tonumber(v)
      if n ~= nil then
        sawCountField = true
        if n > 0 then
          return true
        end
      end
    end
  end
  if sawCountField then
    return false
  end

  for k, child in pairs(value) do
    if type(k) == "string" and type(child) == "table" then
      local parsed = ns.GetCatalogQuantityPlacedOwned(child, depth + 1, visited)
      if parsed ~= nil then
        return parsed
      end
    end
  end

  return nil
end

ns._PROMOTION_EXPANSION_STRICT_COLLECTION = ns._PROMOTION_EXPANSION_STRICT_COLLECTION or {
  ["cash shop"] = true,
  ["preorder bonuses"] = true,
  ["login bonuses"] = true,
  ["brand promotions"] = true,
  ["twitch drops"] = true,
}

ns._forceUncollectedItemIDs = ns._forceUncollectedItemIDs or {
  [262351] = true, -- Ornate Cosmic Rug (catalog false-positive in live client)
}

function ns.IsForceUncollectedItem(itemID)
  local id = tonumber(itemID) or 0
  if id <= 0 then
    return false
  end
  if ns._forceUncollectedItemIDs and ns._forceUncollectedItemIDs[id] then
    return true
  end
  local settings = DecorDropsDB and DecorDropsDB.settings
  local user = settings and settings.forceUncollectedItemIDs
  return type(user) == "table" and user[id] and true or false
end

function ns._NormalizeCollectionField(value)
  local text = string.lower(tostring(value or ""))
  text = text:gsub("^%s+", ""):gsub("%s+$", "")
  return text
end

function ns._IsStrictPromotionCollectionItem(item)
  if type(item) ~= "table" then
    return false
  end

  if ns._IsDenniaSilvertongueRow and ns._IsDenniaSilvertongueRow(item) then
    return true
  end

  local sourceType = tonumber(item.sourceType) or 0
  if sourceType == 10 then
    return true
  end

  local expansion = ns._NormalizeCollectionField(item.expansion)
  if ns._PROMOTION_EXPANSION_STRICT_COLLECTION[expansion] then
    return true
  end

  local sourceName = ns._NormalizeCollectionField(item.sourceName ~= nil and item.sourceName or item.source)
  if sourceName == "promotional" or sourceName == "shop" or sourceName == "dark portal" then
    return true
  end

  local displaySource = ns._NormalizeCollectionField(item.displaySource)
  if displaySource == "promotion" or displaySource == "preorder bonus" or displaySource == "twitch drop" then
    return true
  end

  return false
end

function ns._IsDenniaSilvertongueRow(item)
  if type(item) ~= "table" then
    return false
  end
  local npcName = ns._NormalizeCollectionField(item.npcName)
  if npcName == "dennia silvertongue" then
    return true
  end
  local sourceName = ns._NormalizeCollectionField(item.sourceName ~= nil and item.sourceName or item.source)
  if sourceName == "dennia silvertongue" then
    return true
  end
  return false
end

function ns._IsStrictCollectionEvidenceItem(item, hasUnknownSourceTooltip, strictPromotionCollection)
  if hasUnknownSourceTooltip then
    return true
  end
  if strictPromotionCollection then
    return true
  end
  if type(item) ~= "table" then
    return false
  end
  local displaySource = ns._NormalizeCollectionField(item.displaySource)
  if displaySource == "achievement" then
    return true
  end
  local sourceName = ns._NormalizeCollectionField(item.sourceName ~= nil and item.sourceName or item.source)
  if sourceName == "achievement" then
    return true
  end
  return false
end

local function GetWarbandCollectedState(item)
  if not item or not item.itemID then
    return false
  end

  if ns.IsForceUncollectedItem and ns.IsForceUncollectedItem(item.itemID) then
    collectionStateCache[item.itemID] = false
    return false
  end

  local strictCatalogOwnership = ns.IsStrictCatalogOwnershipEnabled and ns.IsStrictCatalogOwnershipEnabled() or true
  local strictPromotionCollection = ns._IsStrictPromotionCollectionItem and ns._IsStrictPromotionCollectionItem(item) or false

  local cached = collectionStateCache[item.itemID]
  if cached ~= nil then
    if cached then
      ns._cachedTrueUnknownRecheckDone = ns._cachedTrueUnknownRecheckDone or {}
      local alreadyChecked = ns._cachedTrueUnknownRecheckDone[item.itemID] and true or false
      if not alreadyChecked then
        ns._cachedTrueUnknownRecheckDone[item.itemID] = true
        if ns.GetTooltipHasUnknownSource and ns.GetTooltipHasUnknownSource(item.itemID) then
          collectionStateCache[item.itemID] = false
          return false
        end
      end
    end
    return cached
  end

  local function PersistOwnedCache(itemID)
    if not itemID or not DecorDropsDB then
      return
    end
    DecorDropsDB.collectionOwned = DecorDropsDB.collectionOwned or {}
    DecorDropsDB.collectionOwned[itemID] = true
  end

  local ownedItemCount = GetOwnedItemCount(item.itemID)
  local hasUnknownSourceTooltip = ns.GetTooltipHasUnknownSource and ns.GetTooltipHasUnknownSource(item.itemID) or false
  local strictCollectionEvidenceItem = ns._IsStrictCollectionEvidenceItem and ns._IsStrictCollectionEvidenceItem(item, hasUnknownSourceTooltip, strictPromotionCollection) or false
  local strictFailClosed = hasUnknownSourceTooltip and true or false
  if (not strictFailClosed) and strictPromotionCollection and ns._IsDenniaSilvertongueRow and ns._IsDenniaSilvertongueRow(item) then
    strictFailClosed = true
  end

  if strictCatalogOwnership and hasUnknownSourceTooltip then
    collectionStateCache[item.itemID] = false
    return false
  end

  if (not strictCatalogOwnership) and (not strictPromotionCollection) and DecorDropsDB and DecorDropsDB.collectionOwned and DecorDropsDB.collectionOwned[item.itemID] then
    if hasUnknownSourceTooltip and ownedItemCount <= 0 then
      collectionStateCache[item.itemID] = false
      return false
    end
    collectionStateCache[item.itemID] = true
    return true
  end

  local catalogInfo = GetHousingCatalogInfoByItem(item.itemID)
  if strictCollectionEvidenceItem and catalogInfo and ns.GetCatalogQuantityPlacedOwned then
    local strictOwnedByCounts = ns.GetCatalogQuantityPlacedOwned(catalogInfo)
    if strictOwnedByCounts ~= nil then
      collectionStateCache[item.itemID] = strictOwnedByCounts and true or false
      if strictOwnedByCounts then
        PersistOwnedCache(item.itemID)
      end
      return strictOwnedByCounts and true or false
    end
    if strictFailClosed then
      collectionStateCache[item.itemID] = false
      return false
    end
  end

  if catalogInfo then
    local parsedInfo = strictCatalogOwnership and (ns.ParseCatalogOwnershipState and ns.ParseCatalogOwnershipState(catalogInfo) or nil)
      or ParseCollectedFromValue(catalogInfo)
    if parsedInfo ~= nil then
      collectionStateCache[item.itemID] = parsedInfo
      if parsedInfo then
        if hasUnknownSourceTooltip and ownedItemCount <= 0 then
          collectionStateCache[item.itemID] = false
          return false
        end
        PersistOwnedCache(item.itemID)
      end
      return parsedInfo
    end

    local entryID = ExtractCatalogEntryIDFromValue(catalogInfo)
    if entryID then
      local byEntry = nil
      if strictCatalogOwnership then
        if C_HousingCatalog then
          local calls = {
            { fn = "GetCatalogEntryInfo", args = { entryID, true } },
            { fn = "GetCatalogEntryInfo", args = { entryID } },
            { fn = "GetCatalogEntryByID", args = { entryID, true } },
            { fn = "GetCatalogEntryByID", args = { entryID } },
          }
          for _, call in ipairs(calls) do
            local api = C_HousingCatalog[call.fn]
            if type(api) == "function" then
              local ok, a, b, c, d = pcall(api, unpack(call.args))
              if ok then
                if strictCollectionEvidenceItem and ns.GetCatalogQuantityPlacedOwned then
                  byEntry = ns.GetCatalogQuantityPlacedOwned(a)
                  if byEntry == nil then byEntry = ns.GetCatalogQuantityPlacedOwned(b) end
                  if byEntry == nil then byEntry = ns.GetCatalogQuantityPlacedOwned(c) end
                  if byEntry == nil then byEntry = ns.GetCatalogQuantityPlacedOwned(d) end
                end
                if (not strictFailClosed) and byEntry == nil then byEntry = ns.ParseCatalogOwnershipState and ns.ParseCatalogOwnershipState(a) end
                if (not strictFailClosed) and byEntry == nil then byEntry = ns.ParseCatalogOwnershipState and ns.ParseCatalogOwnershipState(b) end
                if (not strictFailClosed) and byEntry == nil then byEntry = ns.ParseCatalogOwnershipState and ns.ParseCatalogOwnershipState(c) end
                if (not strictFailClosed) and byEntry == nil then byEntry = ns.ParseCatalogOwnershipState and ns.ParseCatalogOwnershipState(d) end
                if byEntry ~= nil then
                  break
                end
              end
            end
          end
        end
      else
        byEntry = TryHousingCollectedStateByEntry(entryID)
      end
      if byEntry ~= nil then
        collectionStateCache[item.itemID] = byEntry
        if byEntry then
          if hasUnknownSourceTooltip and ownedItemCount <= 0 then
            collectionStateCache[item.itemID] = false
            return false
          end
          PersistOwnedCache(item.itemID)
        end
        return byEntry
      end

      if strictFailClosed then
        collectionStateCache[item.itemID] = false
        return false
      end
    end
  end

  if strictCatalogOwnership then
    collectionStateCache[item.itemID] = false
    return false
  end

  if ownedItemCount > 0 then
    collectionStateCache[item.itemID] = true
    PersistOwnedCache(item.itemID)
    return true
  end

  if strictPromotionCollection then
    collectionStateCache[item.itemID] = false
    return false
  end

  if C_TransmogCollection and C_TransmogCollection.PlayerHasTransmogByItemInfo then
    local ok, result = pcall(C_TransmogCollection.PlayerHasTransmogByItemInfo, item.itemID)
    if ok and result ~= nil then
      local parsed = result and true or false
      collectionStateCache[item.itemID] = parsed
      if parsed then
        PersistOwnedCache(item.itemID)
      end
      return parsed
    end
  end

  if C_TooltipInfo and C_TooltipInfo.GetItemByID then
    local tipData = C_TooltipInfo.GetItemByID(item.itemID)
    if tipData and tipData.lines then
      for _, line in ipairs(tipData.lines) do
        local text = line.leftText or line.text
        if text then
          local lowered = string.lower(text)
          if TRANSMOG_COLLECTED and string.find(text, TRANSMOG_COLLECTED, 1, true) then
            collectionStateCache[item.itemID] = true
            PersistOwnedCache(item.itemID)
            return true
          end
          if TRANSMOG_NOT_COLLECTED and string.find(text, TRANSMOG_NOT_COLLECTED, 1, true) then
            collectionStateCache[item.itemID] = false
            return false
          end
          if string.find(lowered, "not collected", 1, true) or string.find(lowered, "uncollected", 1, true) then
            collectionStateCache[item.itemID] = false
            return false
          end
          if string.find(lowered, "collected", 1, true) or string.find(lowered, "owned", 1, true) then
            collectionStateCache[item.itemID] = true
            PersistOwnedCache(item.itemID)
            return true
          end
        end
      end
    end
  end

  return false
end
ns.GetWarbandCollectedState = GetWarbandCollectedState

function ns.InvalidateCollectionCache(itemID, markCollected)
  if itemID then
    collectionStateCache[itemID] = nil
    if ns._cachedTrueUnknownRecheckDone then
      ns._cachedTrueUnknownRecheckDone[itemID] = nil
    end
    if ns._tooltipUnknownSourceCache then
      ns._tooltipUnknownSourceCache[itemID] = nil
    end
    if markCollected and DecorDropsDB then
      DecorDropsDB.collectionOwned = DecorDropsDB.collectionOwned or {}
      DecorDropsDB.collectionOwned[itemID] = true
    end
  else
    wipe(collectionStateCache)
    if ns._cachedTrueUnknownRecheckDone then
      wipe(ns._cachedTrueUnknownRecheckDone)
    end
    if ns._tooltipUnknownSourceCache then
      wipe(ns._tooltipUnknownSourceCache)
    end
  end
end

function ns.StartCollectionSessionSync(reason)
  if ns._collectionSessionSynced or ns._collectionSessionSyncRunning then
    return false
  end

  local acquireData = DecorDropsDB and DecorDropsDB.acquireData
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  if type(groupsByMode) ~= "table" then
    return false
  end

  ns._collectionSessionSyncRunning = true
  local strictByID = {}
  local normalByID = {}
  local queue = {}

  for _, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        local items = type(group) == "table" and group.items or nil
        if type(items) == "table" then
          for _, item in ipairs(items) do
            local itemID = tonumber(type(item) == "table" and item.itemID) or 0
            if itemID > 0 then
              local strictPromotion = ns._IsStrictPromotionCollectionItem and ns._IsStrictPromotionCollectionItem(item) or false
              local strictEvidence = ns._IsStrictCollectionEvidenceItem and ns._IsStrictCollectionEvidenceItem(item, false, strictPromotion) or false
              if strictEvidence then
                if not strictByID[itemID] then
                  strictByID[itemID] = item
                end
              elseif (not strictByID[itemID]) and (not normalByID[itemID]) then
                normalByID[itemID] = item
              end
            end
          end
        end
      end
    end
  end

  for _, item in pairs(strictByID) do
    queue[#queue + 1] = item
  end
  for itemID, item in pairs(normalByID) do
    if not strictByID[itemID] then
      queue[#queue + 1] = item
    end
  end

  if #queue == 0 then
    ns._collectionSessionSynced = true
    ns._collectionSessionSyncRunning = false
    return false
  end

  if C_HousingCatalog and C_HousingCatalog.CreateCatalogSearcher then
    local ok, searcher = pcall(C_HousingCatalog.CreateCatalogSearcher)
    if ok and searcher then
      if type(searcher.SetOwnedOnly) == "function" then
        pcall(searcher.SetOwnedOnly, searcher, false)
      end
      if type(searcher.SetCollected) == "function" then
        pcall(searcher.SetCollected, searcher, false)
      end
      if type(searcher.RunSearch) == "function" then
        pcall(searcher.RunSearch, searcher)
      end
      if type(searcher.Release) == "function" then
        pcall(searcher.Release, searcher)
      end
    end
  end

  local state = {
    reason = tostring(reason or "session"),
    queue = queue,
    index = 1,
    batchSize = 60,
  }
  ns._collectionSessionSyncState = state

  local function Step()
    if not ns._collectionSessionSyncState or ns._collectionSessionSyncState ~= state then
      ns._collectionSessionSyncRunning = false
      return
    end

    local total = #state.queue
    local i = 0
    while state.index <= total and i < state.batchSize do
      local item = state.queue[state.index]
      state.index = state.index + 1
      i = i + 1
      if type(item) == "table" then
        pcall(GetWarbandCollectedState, item)
      end
    end

    if state.index > total then
      ns._collectionSessionSyncState = nil
      ns._collectionSessionSyncRunning = false
      ns._collectionSessionSynced = true
      if ns.MainFrame and ns.MainFrame:IsShown() and ns.RefreshUI then
        ns.RefreshUI()
      end
      return
    end

    C_Timer.After(0.02, Step)
  end

  C_Timer.After(0.15, Step)
  return true
end

local function ClampCoord01(v)
  local n = tonumber(v)
  if not n then
    return 0.5
  end
  if n < 0.01 then
    return 0.01
  end
  if n > 0.99 then
    return 0.99
  end
  return n
end

local function TrimString(value)
  local text = tostring(value or "")
  text = text:gsub("^%s+", ""):gsub("%s+$", "")
  return text
end

local function NormalizeInstanceTypeLabel(value)
  local lower = string.lower(TrimString(value))
  if lower == "raid" then
    return "Raid"
  end
  return "Dungeon"
end

local function SaveManualInstanceWaypointOverride(instanceType, zoneName, instanceName, mapID, x, y)
  if not DecorDropsDB then
    return false
  end
  local keyBuilder = ns.BuildInstanceWaypointOverrideKey
  local normalizedName = TrimString(instanceName)
  local normalizedType = NormalizeInstanceTypeLabel(instanceType)
  local key = keyBuilder and keyBuilder(normalizedType, normalizedName) or nil
  if not key then
    print("|cffff5555DecorDrops: Missing instance name for manual waypoint override.|r")
    return false
  end
  local mapIDNum = tonumber(mapID)
  local xNum = tonumber(x)
  local yNum = tonumber(y)
  if not mapIDNum or not xNum or not yNum then
    print("|cffff5555DecorDrops: Invalid world map coordinates for override.|r")
    return false
  end

  DecorDropsDB.instanceWaypointOverrides = DecorDropsDB.instanceWaypointOverrides or {}
  DecorDropsDB.instanceWaypointOverrides[key] = {
    type = normalizedType,
    name = normalizedName,
    zoneName = TrimString(zoneName),
    mapID = mapIDNum,
    x = xNum,
    y = yNum,
    updatedAt = time and time() or nil,
  }
  print(string.format(
    "|cff66ccffDecorDrops|r Saved waypoint override for |cffffffff%s (%s)|r at mapID %d (%.1f, %.1f).",
    normalizedName,
    normalizedType,
    mapIDNum,
    xNum,
    yNum
  ))
  if ns.MainFrame and ns.MainFrame.Refresh then
    ns.MainFrame:Refresh()
  end
  return true
end

local function HideManualOverridePanel(frame)
  if not frame then
    return
  end
  activeManualWaypointInstance = nil
  if frame.manualOverridePanel then
    frame.manualOverridePanel:Hide()
  end
end

local function GetCurrentVisibleMapID()
  local mapID = nil
  if WorldMapFrame and WorldMapFrame:IsShown() and WorldMapFrame.GetMapID then
    mapID = tonumber(WorldMapFrame:GetMapID())
  end
  if (not mapID or mapID == 0) and C_Map and C_Map.GetBestMapForUnit then
    mapID = tonumber(C_Map.GetBestMapForUnit("player"))
  end
  return mapID
end

local function GetStaticPopupEditBox(popup)
  if type(popup) ~= "table" then
    return nil
  end
  return popup.editBox or popup.EditBox
end

if type(_G.StaticPopupDialogs) == "table" then
_G.StaticPopupDialogs["DECORDROPS_MANUAL_WP_MAPID"] = _G.StaticPopupDialogs["DECORDROPS_MANUAL_WP_MAPID"] or {
  text = "Manual waypoint override: enter mapID",
  button1 = "Continue",
  button2 = "Cancel",
  hasEditBox = true,
  timeout = 0,
  whileDead = true,
  hideOnEscape = true,
  preferredIndex = STATICPOPUP_NUMDIALOGS and (STATICPOPUP_NUMDIALOGS - 1) or 3,
  OnShow = function(self)
    if ns.PositionPopupOverMainFrame then
      ns.PositionPopupOverMainFrame(self)
    end
    local mapID = pendingManualWaypointEntry and pendingManualWaypointEntry.mapID or ""
    local editBox = GetStaticPopupEditBox(self)
    if not editBox then
      return
    end
    editBox:SetText(tostring(mapID))
    editBox:SetFocus()
    editBox:HighlightText()
  end,
  OnAccept = function(self)
    if not pendingManualWaypointEntry then
      return
    end
    local editBox = GetStaticPopupEditBox(self)
    if not editBox then
      return
    end
    pendingManualWaypointEntry.mapID = tonumber(TrimString(editBox:GetText())) or 0
    StaticPopup_Show("DECORDROPS_MANUAL_WP_X")
  end,
  OnCancel = function()
    pendingManualWaypointEntry = nil
  end,
  EditBoxOnEnterPressed = function(self)
    local parent = self:GetParent()
    if parent and parent.button1 and parent.button1:IsEnabled() then
      parent.button1:Click()
    end
  end,
}

_G.StaticPopupDialogs["DECORDROPS_MANUAL_WP_X"] = _G.StaticPopupDialogs["DECORDROPS_MANUAL_WP_X"] or {
  text = "Manual waypoint override: enter X coordinate (0-100)",
  button1 = "Continue",
  button2 = "Cancel",
  hasEditBox = true,
  timeout = 0,
  whileDead = true,
  hideOnEscape = true,
  preferredIndex = STATICPOPUP_NUMDIALOGS and (STATICPOPUP_NUMDIALOGS - 1) or 3,
  OnShow = function(self)
    if ns.PositionPopupOverMainFrame then
      ns.PositionPopupOverMainFrame(self)
    end
    local x = pendingManualWaypointEntry and pendingManualWaypointEntry.x or ""
    local editBox = GetStaticPopupEditBox(self)
    if not editBox then
      return
    end
    editBox:SetText(tostring(x))
    editBox:SetFocus()
    editBox:HighlightText()
  end,
  OnAccept = function(self)
    if not pendingManualWaypointEntry then
      return
    end
    local editBox = GetStaticPopupEditBox(self)
    if not editBox then
      return
    end
    pendingManualWaypointEntry.x = tonumber(TrimString(editBox:GetText())) or 0
    StaticPopup_Show("DECORDROPS_MANUAL_WP_Y")
  end,
  OnCancel = function()
    pendingManualWaypointEntry = nil
  end,
  EditBoxOnEnterPressed = function(self)
    local parent = self:GetParent()
    if parent and parent.button1 and parent.button1:IsEnabled() then
      parent.button1:Click()
    end
  end,
}

_G.StaticPopupDialogs["DECORDROPS_MANUAL_WP_Y"] = _G.StaticPopupDialogs["DECORDROPS_MANUAL_WP_Y"] or {
  text = "Manual waypoint override: enter Y coordinate (0-100)",
  button1 = "Save",
  button2 = "Cancel",
  hasEditBox = true,
  timeout = 0,
  whileDead = true,
  hideOnEscape = true,
  preferredIndex = STATICPOPUP_NUMDIALOGS and (STATICPOPUP_NUMDIALOGS - 1) or 3,
  OnShow = function(self)
    if ns.PositionPopupOverMainFrame then
      ns.PositionPopupOverMainFrame(self)
    end
    local y = pendingManualWaypointEntry and pendingManualWaypointEntry.y or ""
    local editBox = GetStaticPopupEditBox(self)
    if not editBox then
      return
    end
    editBox:SetText(tostring(y))
    editBox:SetFocus()
    editBox:HighlightText()
  end,
  OnAccept = function(self)
    if not pendingManualWaypointEntry then
      return
    end
    local editBox = GetStaticPopupEditBox(self)
    if not editBox then
      return
    end
    pendingManualWaypointEntry.y = tonumber(TrimString(editBox:GetText())) or 0
    SaveManualInstanceWaypointOverride(
      pendingManualWaypointEntry.instanceType,
      pendingManualWaypointEntry.zoneName,
      pendingManualWaypointEntry.instanceName,
      pendingManualWaypointEntry.mapID,
      pendingManualWaypointEntry.x,
      pendingManualWaypointEntry.y
    )
    pendingManualWaypointEntry = nil
  end,
  OnCancel = function()
    pendingManualWaypointEntry = nil
  end,
  EditBoxOnEnterPressed = function(self)
    local parent = self:GetParent()
    if parent and parent.button1 and parent.button1:IsEnabled() then
      parent.button1:Click()
    end
  end,
}

_G.StaticPopupDialogs["DECORDROPS_INSTAGRAM_URL"] = _G.StaticPopupDialogs["DECORDROPS_INSTAGRAM_URL"] or {
  text = "Copy and open this link in your browser:",
  button1 = "Close",
  hasEditBox = true,
  timeout = 0,
  whileDead = true,
  hideOnEscape = true,
  preferredIndex = STATICPOPUP_NUMDIALOGS and (STATICPOPUP_NUMDIALOGS - 1) or 3,
  OnShow = function(self, data)
    if ns.PositionPopupOverMainFrame then
      ns.PositionPopupOverMainFrame(self)
    end
    local url = tostring(data or ns.SUPPORT_INSTAGRAM_URL)
    local editBox = GetStaticPopupEditBox(self)
    if not editBox then
      return
    end
    editBox:SetText(url)
    editBox:SetFocus()
    editBox:HighlightText()
  end,
  EditBoxOnEscapePressed = function(self)
    local parent = self:GetParent()
    if parent then
      parent:Hide()
    end
  end,
  EditBoxOnEnterPressed = function(self)
    local parent = self:GetParent()
    if parent and parent.button1 then
      parent.button1:Click()
    end
  end,
}
end

local function ShowCopyableURL(url)
  local target = tostring(url or "")
  if target == "" then
    return
  end

  if type(_G.StaticPopup_Show) == "function" then
    _G.StaticPopup_Show("DECORDROPS_INSTAGRAM_URL", nil, nil, target)
  end
end

local function NormalizeMapNameKey(name)
  local lowered = SafeLower(name)
  lowered = string.gsub(lowered, "%b()", "")
  lowered = string.gsub(lowered, "[%s%-_]+", " ")
  lowered = string.gsub(lowered, "^%s+", "")
  lowered = string.gsub(lowered, "%s+$", "")
  return lowered
end

local function BuildTrainerMapNameIndex()
  if trainerMapIndexBuilt then
    return
  end
  trainerMapIndexBuilt = true

  for name, mapID in pairs(TRAINER_MAP_NAME_OVERRIDES) do
    local key = NormalizeMapNameKey(name)
    trainerMapIDsByName[key] = tonumber(mapID)
  end

  if not (C_Map and C_Map.GetMapInfo) then
    return
  end

  for mapID = 1, 2600 do
    local info = C_Map.GetMapInfo(mapID)
    if info and info.name and info.name ~= "" then
      local key = NormalizeMapNameKey(info.name)
      if key ~= "" and not trainerMapIDsByName[key] then
        trainerMapIDsByName[key] = mapID
      end
    end
  end
end

local function ParseTrainerLocation(locationText)
  if type(locationText) ~= "string" or locationText == "" then
    return nil, nil, nil
  end

  local zoneName = locationText
  local x, y = nil, nil
  local zone, cx, cy = string.match(locationText, "^(.-)%s*%[(%d+%.?%d*),%s*(%d+%.?%d*)%]%s*$")
  if zone and cx and cy then
    zoneName = zone
    x = tonumber(cx)
    y = tonumber(cy)
  end

  zoneName = string.gsub(zoneName or "", "^%s+", "")
  zoneName = string.gsub(zoneName or "", "%s+$", "")
  return zoneName, x, y
end

local function ResolveTrainerMapID(zoneName)
  if not zoneName or zoneName == "" then
    return nil
  end

  if trainerMapNameCache[zoneName] ~= nil then
    return trainerMapNameCache[zoneName]
  end

  BuildTrainerMapNameIndex()
  local mapID = TRAINER_MAP_NAME_OVERRIDES[zoneName]
  if not mapID then
    mapID = trainerMapIDsByName[NormalizeMapNameKey(zoneName)]
  end
  trainerMapNameCache[zoneName] = tonumber(mapID) or false
  return tonumber(mapID)
end

local function NormalizeTrainerEntry(rawEntry, fallbackFaction)
  if type(rawEntry) ~= "table" then
    return nil
  end
  local zoneName, x, y = ParseTrainerLocation(rawEntry.location)
  local mapID = ResolveTrainerMapID(zoneName)
  if (not mapID or mapID <= 0) and tonumber(rawEntry.mapID) and tonumber(rawEntry.mapID) > 0 then
    mapID = tonumber(rawEntry.mapID)
  end
  return {
    npcID = tonumber(rawEntry.npcID),
    name = rawEntry.name or "Unknown Trainer",
    note = rawEntry.note,
    faction = fallbackFaction,
    zoneName = zoneName or "",
    mapID = mapID,
    x = x,
    y = y,
  }
end

local function AddTrainerCandidate(list, raw, factionLabel)
  if type(raw) ~= "table" then
    return
  end
  if raw.npcID then
    local normalized = NormalizeTrainerEntry(raw, factionLabel)
    if normalized then
      list[#list + 1] = normalized
    end
    return
  end
  for _, candidate in ipairs(raw) do
    local normalized = NormalizeTrainerEntry(candidate, factionLabel)
    if normalized then
      list[#list + 1] = normalized
    end
  end
end

local function BuildTrainerFallbackIndex(byProfession)
  local out = {}
  if type(byProfession) ~= "table" then
    return out
  end

  for _, byExpansion in pairs(byProfession) do
    if type(byExpansion) == "table" then
      local combined = {}
      AddTrainerCandidate(combined, byExpansion.Alliance, "Alliance")
      AddTrainerCandidate(combined, byExpansion.Horde, "Horde")
      AddTrainerCandidate(combined, byExpansion.Both, "Both")
      for _, trainer in ipairs(combined) do
        local key = SafeLower(trainer and trainer.name or "")
        if key ~= "" and type(out[key]) ~= "table" then
          out[key] = trainer
        elseif key ~= "" and type(out[key]) == "table" then
          local current = out[key]
          local currentScore = ((current.mapID and 1 or 0) + (current.x and current.y and 1 or 0))
          local candidateScore = ((trainer.mapID and 1 or 0) + (trainer.x and trainer.y and 1 or 0))
          if candidateScore > currentScore then
            out[key] = trainer
          end
        end
      end
    end
  end
  return out
end

local function ApplyTrainerFallbackData(list, fallbackByName)
  if type(list) ~= "table" or type(fallbackByName) ~= "table" then
    return
  end
  for _, trainer in ipairs(list) do
    local key = SafeLower(trainer and trainer.name or "")
    local fallback = key ~= "" and fallbackByName[key] or nil
    if type(fallback) == "table" then
      if not trainer.mapID and fallback.mapID then
        trainer.mapID = fallback.mapID
      end
      if (not trainer.zoneName or trainer.zoneName == "") and fallback.zoneName and fallback.zoneName ~= "" then
        trainer.zoneName = fallback.zoneName
      end
      if not trainer.x and fallback.x then
        trainer.x = fallback.x
      end
      if not trainer.y and fallback.y then
        trainer.y = fallback.y
      end
    end
  end
end

local function AddTrainerCandidatesByFaction(out, byExpansion, faction)
  if type(byExpansion) ~= "table" then
    return
  end
  if faction == "Alliance" then
    AddTrainerCandidate(out, byExpansion.Alliance, "Alliance")
  elseif faction == "Horde" then
    AddTrainerCandidate(out, byExpansion.Horde, "Horde")
  else
    AddTrainerCandidate(out, byExpansion.Alliance, "Alliance")
    AddTrainerCandidate(out, byExpansion.Horde, "Horde")
  end
  AddTrainerCandidate(out, byExpansion.Both, "Both")
end

local function GetTrainerEntriesForProfession(professionName, expansionName)
  local db = ns.staticProfessionTrainers
  if type(db) ~= "table" then
    return {}
  end
  local byProfession = db[professionName]
  if type(byProfession) ~= "table" then
    return {}
  end
  local out = {}
  local faction = GetPlayerFaction()
  local byExpansion = byProfession[expansionName]
  if type(byExpansion) == "table" then
    AddTrainerCandidatesByFaction(out, byExpansion, faction)
  end

  if #out == 0 then
    local preferredOrder = {
      "Midnight",
      "The War Within",
      "Dragonflight",
      "Shadowlands",
      "Battle for Azeroth",
      "Legion",
      "Warlords of Draenor",
      "Mists of Pandaria",
      "Cataclysm",
      "Wrath of the Lich King",
      "The Burning Crusade",
      "Classic",
    }
    for _, expansion in ipairs(preferredOrder) do
      local candidateExpansion = byProfession[expansion]
      if type(candidateExpansion) == "table" then
        AddTrainerCandidatesByFaction(out, candidateExpansion, faction)
        if #out > 0 then
          break
        end
      end
    end
  end

  if #out == 0 then
    for _, candidateExpansion in pairs(byProfession) do
      if type(candidateExpansion) == "table" then
        AddTrainerCandidatesByFaction(out, candidateExpansion, faction)
        if #out > 0 then
          break
        end
      end
    end
  end

  ApplyTrainerFallbackData(out, BuildTrainerFallbackIndex(byProfession))

  table.sort(out, function(a, b)
    local aName = tostring(a and a.name or "")
    local bName = tostring(b and b.name or "")
    if aName ~= bName then
      return aName < bName
    end
    return tostring(a and a.zoneName or "") < tostring(b and b.zoneName or "")
  end)
  return out
end

local function ClearTrainerWaypoint()
  if TomTom and trainerWaypointUID then
    pcall(TomTom.RemoveWaypoint, TomTom, trainerWaypointUID)
  end
  trainerWaypointUID = nil
  if C_Map and C_Map.HasUserWaypoint and C_Map.HasUserWaypoint() then
    pcall(C_Map.ClearUserWaypoint)
  end
  if C_SuperTrack and C_SuperTrack.ClearAllSuperTracked then
    pcall(C_SuperTrack.ClearAllSuperTracked)
  end
end

local function OpenWorldMapForWaypoint(mapID)
  local function ResolveSafeWorldMapID(rawMapID)
    local numeric = tonumber(rawMapID)
    if numeric and C_Map and C_Map.GetMapInfo then
      local info = C_Map.GetMapInfo(numeric)
      if info then
        return numeric
      end
    end

    if C_Map and C_Map.GetBestMapForUnit and C_Map.GetMapInfo then
      local best = tonumber(C_Map.GetBestMapForUnit("player"))
      if best and C_Map.GetMapInfo(best) then
        return best
      end
    end

    -- Final fallback to a stable open-world map to avoid Blizzard MapUtil nil-mapInfo errors.
    if C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(619) then -- Broken Isles
      return 619
    end
    if C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(947) then -- Azeroth
      return 947
    end
    return nil
  end

  local function setMainFrameBehindMap()
    if not ns.MainFrame or not ns.MainFrame.IsShown or not ns.MainFrame:IsShown() then
      return
    end
    if ns.MainFrame._ddSavedStrata == nil and ns.MainFrame.GetFrameStrata then
      ns.MainFrame._ddSavedStrata = ns.MainFrame:GetFrameStrata()
    end
    if ns.MainFrame._ddSavedLevel == nil and ns.MainFrame.GetFrameLevel then
      ns.MainFrame._ddSavedLevel = ns.MainFrame:GetFrameLevel()
    end
    if ns.MainFrame.SetFrameStrata then
      ns.MainFrame:SetFrameStrata("MEDIUM")
    end
    if ns.MainFrame.SetFrameLevel then
      ns.MainFrame:SetFrameLevel(20)
    end
  end

  local function restoreMainFrameLayer()
    if not ns.MainFrame then
      return
    end
    if ns.MainFrame._ddSavedStrata and ns.MainFrame.SetFrameStrata then
      ns.MainFrame:SetFrameStrata(ns.MainFrame._ddSavedStrata)
    end
    if ns.MainFrame._ddSavedLevel and ns.MainFrame.SetFrameLevel then
      ns.MainFrame:SetFrameLevel(ns.MainFrame._ddSavedLevel)
    end
    ns.MainFrame._ddSavedStrata = nil
    ns.MainFrame._ddSavedLevel = nil
  end

  if not WorldMapFrame and WorldMapFrame_LoadUI then
    pcall(WorldMapFrame_LoadUI)
  end
  if not WorldMapFrame then
    return
  end
  if not worldMapLayerHooked and WorldMapFrame.HookScript then
    worldMapLayerHooked = true
    WorldMapFrame:HookScript("OnShow", function()
      setMainFrameBehindMap()
    end)
    WorldMapFrame:HookScript("OnHide", function()
      restoreMainFrameLayer()
    end)
  end
  setMainFrameBehindMap()
  if ShowUIPanel then
    if WorldMapFrame:IsShown() and HideUIPanel then
      pcall(HideUIPanel, WorldMapFrame)
    end
    pcall(ShowUIPanel, WorldMapFrame)
  elseif not WorldMapFrame:IsShown() and ToggleWorldMap then
    pcall(ToggleWorldMap)
  end
  local safeMapID = ResolveSafeWorldMapID(mapID)
  if safeMapID and WorldMapFrame.SetMapID then
    pcall(WorldMapFrame.SetMapID, WorldMapFrame, safeMapID)
  end
  if WorldMapFrame.Raise then
    pcall(WorldMapFrame.Raise, WorldMapFrame)
  end
end

local function SetTrainerWaypoint(trainer)
  if type(trainer) ~= "table" then
    return false
  end
  local mapID = tonumber(trainer.mapID)
  local x = tonumber(trainer.x)
  local y = tonumber(trainer.y)
  if not mapID or not x or not y then
    print("|cffff5555DecorDrops: Trainer waypoint unavailable (missing map/coordinates).|r")
    return false
  end

  ClearTrainerWaypoint()
  x = ClampCoord01(x / 100)
  y = ClampCoord01(y / 100)

  if TomTom and TomTom.AddWaypoint then
    local ok, uid = pcall(TomTom.AddWaypoint, TomTom, mapID, x, y, {
      title = trainer.name or "Trainer",
      from = "DecorDrops",
      persistent = false,
      minimap = true,
      world = true,
    })
    if ok and uid then
      trainerWaypointUID = uid
    end
  end

  if C_Map and C_Map.SetUserWaypoint and UiMapPoint then
    local point = UiMapPoint.CreateFromCoordinates(mapID, x, y)
    if point then
      pcall(C_Map.SetUserWaypoint, point)
      if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
        pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, true)
      end
    end
  end

  local trainerName = tostring(trainer.name or "Unknown Trainer")
  local zoneName = tostring(trainer.zoneName or "Unknown Zone")
  print(string.format("|cff66ccffDecorDrops|r Waypoint created for |cffffffff%s|r at |cffdcb26a%s|r (%.1f, %.1f).", trainerName, zoneName, x * 100, y * 100))
  OpenWorldMapForWaypoint(mapID)

  return true
end

function ns.GetMapName(mapID)
  if not mapID or mapID == 0 then
    return "Unknown Map"
  end
  if C_Map and C_Map.GetMapInfo then
    local info = C_Map.GetMapInfo(mapID)
    if info and info.name then
      return info.name
    end
  end
  return "Unknown Map"
end

local function HasEntranceCoords(instance)
  return instance
    and instance.entrance
    and instance.entrance.x ~= nil
    and instance.entrance.y ~= nil
end

local function GetInstanceEntranceZone(instance)
  if type(instance) ~= "table" then
    return "Unknown Map"
  end
  local zoneName = tostring(instance.zoneName or "")
  if zoneName ~= "" then
    return zoneName
  end
  return ns.GetMapName(instance.mapID)
end

local function NormalizeInstanceMapKey(name)
  local key = string.lower(tostring(name or ""))
  key = key:gsub("[’`]", "'")
  key = key:gsub("%s+", " ")
  key = key:gsub("^%s+", "")
  key = key:gsub("%s+$", "")
  return key
end

local instanceOpenMapLookup = nil

local function BuildInstanceOpenMapLookup()
  if instanceOpenMapLookup then
    return instanceOpenMapLookup
  end
  instanceOpenMapLookup = {}
  local function importSource(src)
    if type(src) ~= "table" then
      return
    end
    for name, mapID in pairs(src) do
      local key = NormalizeInstanceMapKey(name)
      local numeric = tonumber(mapID)
      if key ~= "" and numeric and numeric > 0 then
        instanceOpenMapLookup[key] = numeric
      end
    end
  end
  importSource(DecorDropsDB and DecorDropsDB.instanceOpenMapIDs)
  importSource(ns and ns.staticInstanceOpenMapIDs)
  return instanceOpenMapLookup
end

ns.LEGION_INSTANCE_OPEN_MAP_IDS = ns.LEGION_INSTANCE_OPEN_MAP_IDS or {
  ["assault on violet hold"] = 627,
  ["black rook hold"] = 641,
  ["cathedral of eternal night"] = 646,
  ["cathedral of the night"] = 646,
  ["court of stars"] = 680,
  ["darkheart thicket"] = 641,
  ["eye of azshara"] = 630,
  ["halls of valor"] = 634,
  ["maw of souls"] = 634,
  ["neltharion's lair"] = 650,
  ["return to karazhan"] = 42,
  ["seat of the triumvirate"] = 882,
  ["the seat of the triumvirate"] = 882,
  ["the arcway"] = 680,
  ["the emerald nightmare"] = 641,
  ["the nighthold"] = 680,
  ["tomb of sargeras"] = 646,
  ["trial of valor"] = 634,
  ["antorus, the burning throne"] = 885,
}

function ns.GetInstanceOpenMapID(instanceName)
  local key = NormalizeInstanceMapKey(instanceName)
  if key == "" then
    return nil
  end
  local imported = BuildInstanceOpenMapLookup()
  local mapID = tonumber(imported[key] or 0)
  if mapID and mapID > 0 then
    return mapID
  end
  mapID = tonumber(ns.LEGION_INSTANCE_OPEN_MAP_IDS[key] or 0)
  if mapID and mapID > 0 then
    return mapID
  end
  if key:find("^the ", 1, true) == 1 then
    local shortKey = key:sub(5)
    mapID = tonumber(imported[shortKey] or ns.LEGION_INSTANCE_OPEN_MAP_IDS[shortKey] or 0)
    if mapID and mapID > 0 then
      return mapID
    end
  else
    local theKey = "the " .. key
    mapID = tonumber(imported[theKey] or ns.LEGION_INSTANCE_OPEN_MAP_IDS[theKey] or 0)
    if mapID and mapID > 0 then
      return mapID
    end
  end
  return nil
end

local function OpenInstanceMap(instance)
  if type(instance) ~= "table" or not tonumber(instance.mapID) then
    print("|cffff5555DecorDrops: No map is available for this instance.|r")
    return
  end
  local mapID = tonumber(instance.mapID)
  local staticMapID = ns.GetInstanceOpenMapID(instance.name)
  if staticMapID and staticMapID > 0 then
    mapID = staticMapID
  end
  OpenWorldMapForWaypoint(mapID)
end

local function ShowItemTooltip(button, item)
  GameTooltip:SetOwner(button, "ANCHOR_RIGHT")

  if item.itemID then
    GameTooltip:SetItemByID(item.itemID)
  else
    GameTooltip:AddLine(item.name or "Unknown Item")
  end

  local collected = GetWarbandCollectedState(item)
  if collected then
    GameTooltip:AddLine("|cff00b4ffWarband collected: Yes|r")
  else
    GameTooltip:AddLine("|cff00b4ffWarband collected: No|r")
  end

  GameTooltip:Show()
end

local function EnsureReagentPriceTooltip()
  if reagentPriceTooltip then
    return reagentPriceTooltip
  end
  reagentPriceTooltip = CreateFrame("GameTooltip", "DecorDropsReagentPriceTooltip", UIParent, "GameTooltipTemplate")
  reagentPriceTooltip:SetOwner(UIParent, "ANCHOR_NONE")
  return reagentPriceTooltip
end

local function ParseMoneyFromTooltipText(text)
  if not text or text == "" then
    return nil
  end

  local cleaned = tostring(text)
  local g = tonumber(cleaned:match("(%d+)%s*[gG]"))
  local s = tonumber(cleaned:match("(%d+)%s*[sS]"))
  local c = tonumber(cleaned:match("(%d+)%s*[cC]"))
  if g or s or c then
    return (g or 0) * 10000 + (s or 0) * 100 + (c or 0)
  end

  local a, b, d = cleaned:match("(%d+)%D+(%d+)%D+(%d+)")
  if a and b and d then
    return (tonumber(a) or 0) * 10000 + (tonumber(b) or 0) * 100 + (tonumber(d) or 0)
  end

  return nil
end

local function GetAuctionatorUnitPrice(itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return nil
  end

  local api = Auctionator and Auctionator.API and Auctionator.API.v1
  if type(api) ~= "table" then
    return nil
  end

  local caller = "DecorDrops"
  local function ReadPrice(fn, ...)
    if type(fn) ~= "function" then
      return nil
    end
    local ok, result = pcall(fn, ...)
    local numeric = tonumber(result)
    if ok and numeric and numeric > 0 then
      return numeric
    end
    return nil
  end

  local price = ReadPrice(api.GetAuctionPriceByItemID, caller, numericItemID)
    or ReadPrice(api.GetAuctionPriceByItemID, api, caller, numericItemID)
  if price then
    reagentPriceCache[numericItemID] = {
      unitPrice = price,
      fromAuction = true,
    }
    return price
  end

  local itemLink = C_Item and C_Item.GetItemLinkByID and C_Item.GetItemLinkByID(numericItemID) or nil
  if not itemLink and GetItemInfo then
    local _, link = GetItemInfo(numericItemID)
    itemLink = link
  end

  if itemLink then
    price = ReadPrice(api.GetAuctionPriceByItemLink, caller, itemLink)
      or ReadPrice(api.GetAuctionPriceByItemLink, api, caller, itemLink)
    if price then
      reagentPriceCache[numericItemID] = {
        unitPrice = price,
        fromAuction = true,
      }
      return price
    end
  end

  return nil
end

function ns.IsAuctionatorAvailable()
  return Auctionator and Auctionator.API and Auctionator.API.v1 and true or false
end

local function BuildAuctionProbeItemIDs(limit)
  local maxCount = tonumber(limit) or 40
  if maxCount < 1 then
    maxCount = 1
  end
  local out, seen = {}, {}

  for itemID, _ in pairs(reagentPriceCache) do
    local numeric = tonumber(itemID)
    if numeric and numeric > 0 and not seen[numeric] then
      out[#out + 1] = numeric
      seen[numeric] = true
      if #out >= maxCount then
        return out
      end
    end
  end

  local profData = ns.GetProfessionData and ns.GetProfessionData() or {}
  for _, prof in ipairs(profData) do
    for _, item in ipairs(prof.decorations or {}) do
      for _, reagent in ipairs(item.resources or {}) do
        local numeric = tonumber(reagent and reagent.itemID)
        if numeric and numeric > 0 and not seen[numeric] then
          out[#out + 1] = numeric
          seen[numeric] = true
          if #out >= maxCount then
            return out
          end
        end
      end
    end
  end

  return out
end

function ns.HasAuctionatorPriceIndex()
  if not ns.IsAuctionatorAvailable or not ns.IsAuctionatorAvailable() then
    return false
  end

  local settings = DecorDropsDB and DecorDropsDB.settings or nil
  if settings and tonumber(settings.lastAuctionPriceSeenAt) then
    return true
  end

  for _, itemID in ipairs(BuildAuctionProbeItemIDs(50)) do
    local price = GetAuctionatorUnitPrice(itemID)
    if tonumber(price) and price > 0 then
      if settings then
        settings.lastAuctionPriceSeenAt = time and time() or nil
      end
      return true
    end
  end

  return false
end

local function BuildAuctionPriceSnapshot()
  local snapshot = {}
  local seenAny = false
  for itemID, record in pairs(reagentPriceCache) do
    if type(record) == "table" and record.fromAuction then
      local current = GetAuctionatorUnitPrice(itemID)
      local numeric = tonumber(current)
      if numeric and numeric > 0 then
        snapshot[tonumber(itemID)] = numeric
        seenAny = true
      end
    end
  end
  if not seenAny then
    return {}
  end
  return snapshot
end

local function AuctionSnapshotsDiffer(before, after)
  before = before or {}
  after = after or {}

  for itemID, oldPrice in pairs(before) do
    local newPrice = after[itemID]
    if tonumber(newPrice or 0) ~= tonumber(oldPrice or 0) then
      return true
    end
  end
  for itemID, newPrice in pairs(after) do
    local oldPrice = before[itemID]
    if tonumber(oldPrice or 0) ~= tonumber(newPrice or 0) then
      return true
    end
  end
  return false
end

function ns.OnAuctionHouseShow()
  auctionHousePriceSession.active = true
  auctionHousePriceSession.snapshot = BuildAuctionPriceSnapshot()
end

function ns.OnAuctionHouseClosed()
  if not auctionHousePriceSession.active then
    return
  end
  auctionHousePriceSession.active = false

  local before = auctionHousePriceSession.snapshot or {}
  auctionHousePriceSession.snapshot = {}

  local after = BuildAuctionPriceSnapshot()
  if not AuctionSnapshotsDiffer(before, after) then
    return
  end

  wipe(reagentPriceCache)
  if ns.RefreshUI and ns.MainFrame and ns.MainFrame:IsShown() then
    ns.RefreshUI()
  end
end

local function FormatMoneyShort(copper)
  local value = tonumber(copper) or 0
  if value <= 0 then
    return "-"
  end
  local whole = math.floor(value + 0.5)
  local gold = math.floor(whole / 10000)
  local silver = math.floor((whole % 10000) / 100)
  local copperRemainder = math.floor(whole % 100)

  -- Gold present: show gold only, rounding any remainder up to next gold.
  if gold > 0 then
    if (silver > 0) or (copperRemainder > 0) then
      gold = gold + 1
    end
    if gold >= 10000 then
      local short = string.format("%.1fk", gold / 1000)
      short = short:gsub("%.0k$", "k")
      return string.format("%s |T%s:%d:%d:0:0|t", short, UIC.MONEY_ICON_GOLD, UIC.MONEY_ICON_SIZE_ROW, UIC.MONEY_ICON_SIZE_ROW)
    end
    return string.format("%d |T%s:%d:%d:0:0|t", gold, UIC.MONEY_ICON_GOLD, UIC.MONEY_ICON_SIZE_ROW, UIC.MONEY_ICON_SIZE_ROW)
  end

  -- No gold, silver present: show silver only, rounding copper into silver.
  if silver > 0 then
    if copperRemainder > 0 then
      silver = silver + 1
    end
    return string.format("%d |T%s:%d:%d:0:0|t", silver, UIC.MONEY_ICON_SILVER, UIC.MONEY_ICON_SIZE_ROW, UIC.MONEY_ICON_SIZE_ROW)
  end

  -- Copper-only value.
  return string.format("%d |T%s:%d:%d:0:0|t", copperRemainder, UIC.MONEY_ICON_COPPER, UIC.MONEY_ICON_SIZE_ROW, UIC.MONEY_ICON_SIZE_ROW)
end

local function BuildRoundedMoneyDisplay(copper)
  local value = tonumber(copper) or 0
  if value <= 0 then
    return nil
  end

  local whole = math.floor(value + 0.5)
  local gold = math.floor(whole / 10000)
  local silver = math.floor((whole % 10000) / 100)
  local copperRemainder = math.floor(whole % 100)

  if gold > 0 then
    if (silver > 0) or (copperRemainder > 0) then
      gold = gold + 1
    end
    local label = tostring(gold)
    if gold >= 10000 then
      label = string.format("%.1fk", gold / 1000):gsub("%.0k$", "k")
    end
    return {
      label = label,
      icon = UIC.MONEY_ICON_GOLD,
      iconSize = UIC.MONEY_ICON_SIZE_ROW,
      name = "Gold",
      tooltip = "Gold cost (rounded)",
      sortKey = "gold|0",
    }
  end

  if silver > 0 then
    if copperRemainder > 0 then
      silver = silver + 1
    end
    return {
      label = tostring(silver),
      icon = UIC.MONEY_ICON_SILVER,
      iconSize = UIC.MONEY_ICON_SIZE_ROW,
      name = "Silver",
      tooltip = "Silver cost (rounded)",
      sortKey = "gold|1",
    }
  end

  return {
    label = tostring(copperRemainder),
    icon = UIC.MONEY_ICON_COPPER,
    iconSize = UIC.MONEY_ICON_SIZE_ROW,
    name = "Copper",
    tooltip = "Copper cost",
    sortKey = "gold|2",
  }
end

local function InsertItemLink(item)
  if not item.itemID then
    return
  end

  local itemLink = C_Item and C_Item.GetItemLinkByID and C_Item.GetItemLinkByID(item.itemID) or nil
  if not itemLink then
    local _, link = GetItemInfo(item.itemID)
    itemLink = link
  end

  if itemLink then
    ChatEdit_InsertLink(itemLink)
  end
end

local function OpenItemPreview(item)
  if not item or not item.itemID then
    print("|cffff5555DecorDrops: No item to preview.|r")
    return
  end

  local itemLink = C_Item and C_Item.GetItemLinkByID and C_Item.GetItemLinkByID(item.itemID) or nil
  if not itemLink then
    local _, link = GetItemInfo(item.itemID)
    itemLink = link
  end

  if not itemLink then
    print("|cffff5555DecorDrops: Item preview unavailable (item not cached yet).|r")
    return
  end

  if DressUpItemLink then
    DressUpItemLink(itemLink)
    return
  end

  if HandleModifiedItemClick then
    HandleModifiedItemClick(itemLink)
    return
  end

  print("|cffff5555DecorDrops: 3D preview is not available in this client.|r")
end

local function OpenAchievementByID(achievementID)
  local id = tonumber(achievementID)
  if not id or id <= 0 then
    return false
  end
  if OpenAchievementFrameToAchievement then
    local ok = pcall(OpenAchievementFrameToAchievement, id)
    if ok then
      return true
    end
  end
  if not AchievementFrame and AchievementFrame_LoadUI then
    pcall(AchievementFrame_LoadUI)
  end
  if AchievementFrame and ShowUIPanel then
    pcall(ShowUIPanel, AchievementFrame)
  elseif ToggleAchievementFrame then
    pcall(ToggleAchievementFrame)
  end
  if AchievementFrame_SelectAchievement then
    local ok = pcall(AchievementFrame_SelectAchievement, id)
    return ok and true or false
  end
  return false
end

local function UpdateReputationSummaryPanel(frame, selectedEntry)
  if not frame or not frame.reputationMini then
    return
  end
  frame.reputationMini:Hide()
  if not selectedEntry or selectedEntry.acquireMode ~= "reputation" then
    return
  end

  local sampleItem = (selectedEntry.rows and selectedEntry.rows[1]) or {}
  local snap = ResolveFactionSnapshot(sampleItem, selectedEntry)

  local name = selectedEntry.label or "Reputation"
  local value, minValue, maxValue = 0, 0, 1
  local statusText = "Faction Unmet"
  local isRenown = EntryUsesRenownRequirements(selectedEntry)

  if snap then
    if snap.name and snap.name ~= "" then
      name = snap.name
    end
    minValue = tonumber(snap.barMin) or 0
    maxValue = tonumber(snap.barMax) or 1
    value = tonumber(snap.barValue) or 0

    if isRenown and snap.factionID > 0 and C_MajorFactions and C_MajorFactions.GetMajorFactionData then
      local majorData = C_MajorFactions.GetMajorFactionData(snap.factionID)
      local renown = majorData and tonumber(majorData.renownLevel) or 0
      statusText = string.format("Renown %d", renown)
      -- Use a simple renown bar range for stable visuals when threshold data is unavailable.
      minValue, maxValue, value = 0, math.max(1, renown), math.max(0, renown)
    else
      local standingID = tonumber(snap.standingID) or 0
      local standingNames = {
        [1] = "Hated",
        [2] = "Hostile",
        [3] = "Unfriendly",
        [4] = "Neutral",
        [5] = "Friendly",
        [6] = "Honored",
        [7] = "Revered",
        [8] = "Exalted",
      }
      statusText = standingNames[standingID] or "Reputation"

      -- Blizzard rep APIs can report capped Exalted ranges in different shapes.
      -- Force a full bar so Exalted always appears complete in the mini tracker.
      if standingID >= 8 then
        minValue, maxValue, value = 0, 1, 1
      else
        if maxValue <= minValue then
          maxValue = minValue + 1
        end
        if value < minValue then
          value = minValue
        elseif value > maxValue then
          value = maxValue
        end
      end
    end
  end

  frame.reputationMiniName:SetText(name)
  frame.reputationMiniText:SetText(statusText)
  frame.reputationMiniBar:SetMinMaxValues(minValue, maxValue > minValue and maxValue or (minValue + 1))
  frame.reputationMiniBar:SetValue(value)
  if isRenown and snap and snap.factionID > 0 and C_MajorFactions and C_MajorFactions.GetMajorFactionData then
    frame.reputationMiniBar:SetStatusBarColor(0.20, 0.78, 0.92, 1.0) -- cyan renown bar
  else
    local standingID = tonumber(snap and snap.standingID) or 0
    standingID = tonumber(standingID) or 0
    local barColor = (FACTION_BAR_COLORS and FACTION_BAR_COLORS[standingID]) or nil
    if barColor then
      frame.reputationMiniBar:SetStatusBarColor(barColor.r or 0.18, barColor.g or 0.80, barColor.b or 0.26, 1.0)
    else
      frame.reputationMiniBar:SetStatusBarColor(0.18, 0.80, 0.26, 1.0)
    end
  end
  frame.reputationMini:Show()
end

local function ItemMatchesFilters(frame, instance, item)
  local query = SafeLower(frame.filters.search or "")
  local sourceName = item.source or ""
  local itemName = GetItemName(item)
  local itemIDText = tostring(tonumber(item and item.itemID) or 0)

  if query ~= "" then
    local instanceHit = StringContains(instance.name, query)
    local itemHit = StringContains(itemName, query)
    local itemIDHit = StringContains(itemIDText, query)
    local sourceHit = StringContains(sourceName, query)
    if not (instanceHit or itemHit or itemIDHit or sourceHit) then
      return false
    end
  end

  if frame.filters.rarity then
    local quality = GetItemQuality(item)
    if frame.filters.rarity[quality] == false then
      return false
    end
  end

  if frame.filters.collected ~= "All" then
    local collected = GetWarbandCollectedState(item)
    if frame.filters.collected == "Collected" and not collected then
      return false
    end
    if frame.filters.collected == "Uncollected" and collected then
      return false
    end
  end

  return true
end

local function BuildResourceInlineText(resources)
  if type(resources) ~= "table" or #resources == 0 then
    return "-"
  end

  local parts = {}
  for i, reagent in ipairs(resources) do
    local qty = tonumber(reagent.quantity) or 1
    local itemID = tonumber(reagent.itemID)
    local name = reagent.name
    if (not name or name == "") and itemID and C_Item and C_Item.GetItemNameByID then
      name = C_Item.GetItemNameByID(itemID)
    end
    parts[#parts + 1] = string.format("%s x%d", name or "Item", qty)
    if i >= 4 then
      break
    end
  end
  return table.concat(parts, ", ")
end

local function NormalizeResourceEntries(resources)
  if type(resources) ~= "table" then
    return {}
  end

  local totals = {}
  local custom = {}
  for _, reagent in ipairs(resources) do
    local itemID = tonumber(reagent and reagent.itemID)
    if itemID and itemID > 0 then
      local qty = tonumber(reagent.quantity) or 1
      totals[itemID] = (totals[itemID] or 0) + math.max(1, qty)
    elseif type(reagent) == "table" then
      custom[#custom + 1] = {
        itemID = tonumber(reagent.itemID),
        quantity = reagent.quantity,
        quantityLabel = reagent.quantityLabel,
        icon = reagent.icon,
        iconSize = tonumber(reagent.iconSize),
        name = reagent.name,
        tooltip = reagent.tooltip,
        sortKey = tostring(reagent.sortKey or reagent.name or tostring(#custom + 1)),
      }
    end
  end

  local out = {}
  for itemID, quantity in pairs(totals) do
    out[#out + 1] = {
      itemID = itemID,
      quantity = quantity,
    }
  end
  table.sort(out, function(a, b)
    return (a.itemID or 0) < (b.itemID or 0)
  end)
  table.sort(custom, function(a, b)
    return tostring(a.sortKey or "") < tostring(b.sortKey or "")
  end)
  for _, entry in ipairs(custom) do
    out[#out + 1] = entry
  end
  return out
end

local function HasRenderablePurchaseCosts(costs)
  if type(costs) ~= "table" or #costs == 0 then
    return false
  end
  for _, cost in ipairs(costs) do
    local costType = string.lower(tostring(cost and cost.costType or ""))
    if costType == "item" then
      if (tonumber(cost.itemID) or 0) > 0 and (tonumber(cost.quantity) or 0) > 0 then
        return true
      end
    elseif costType == "currency" then
      if (tonumber(cost.currencyID) or 0) > 0 and (tonumber(cost.quantity) or 0) > 0 then
        return true
      end
    elseif costType == "gold" then
      if (tonumber(cost.copper) or 0) > 0 then
        return true
      end
    elseif costType == "homecurrency" then
      if (tonumber(cost.quantity) or 0) > 0 then
        return true
      end
    end
  end
  return false
end

local staticAcquireVendorCostsByItemID = nil
local function BuildStaticAcquireVendorCostCache()
  if type(staticAcquireVendorCostsByItemID) == "table" then
    return staticAcquireVendorCostsByItemID
  end
  staticAcquireVendorCostsByItemID = {}
  local byMode = type(ns.staticAcquireData) == "table" and ns.staticAcquireData.groupsByMode or nil
  local vendorGroups = type(byMode) == "table" and byMode.vendor or nil
  if type(vendorGroups) ~= "table" then
    return staticAcquireVendorCostsByItemID
  end
  for _, group in ipairs(vendorGroups) do
    for _, row in ipairs(type(group) == "table" and group.items or {}) do
      local itemID = tonumber(type(row) == "table" and row.itemID) or 0
      local costs = type(row) == "table" and row.purchaseCosts or nil
      if itemID > 0 and staticAcquireVendorCostsByItemID[itemID] == nil and HasRenderablePurchaseCosts(costs) then
        staticAcquireVendorCostsByItemID[itemID] = costs
      end
    end
  end
  return staticAcquireVendorCostsByItemID
end

local function ResolvePurchaseCosts(item)
  if type(item) ~= "table" then
    return nil
  end

  local costs = type(item.purchaseCosts) == "table" and item.purchaseCosts or nil
  if HasRenderablePurchaseCosts(costs) then
    return costs
  end

  local itemID = tonumber(item.itemID) or 0
  local staticVendorCosts = ns.staticVendorCosts and ns.staticVendorCosts.byItemID or nil
  if itemID > 0 and type(staticVendorCosts) == "table" and HasRenderablePurchaseCosts(staticVendorCosts[itemID]) then
    return staticVendorCosts[itemID]
  end

  local staticAcquireCosts = BuildStaticAcquireVendorCostCache()
  if itemID > 0 and type(staticAcquireCosts) == "table" and HasRenderablePurchaseCosts(staticAcquireCosts[itemID]) then
    return staticAcquireCosts[itemID]
  end

  return nil
end

local hearthsteelCurrencyIconCache = nil

local function ResolveHearthsteelCurrencyIcon()
  if hearthsteelCurrencyIconCache ~= nil then
    return hearthsteelCurrencyIconCache
  end

  local defaultIcon = "Interface\\AddOns\\DecorDrops\\Media\\Hearthsteel.tga"
  local normalizedTarget = "hearthsteel"
  local chosenIcon = defaultIcon

  local overrideCurrencyID = tonumber(DecorDropsDB and DecorDropsDB.settings and DecorDropsDB.settings.hearthsteelCurrencyID) or 0
  if overrideCurrencyID > 0 and C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
    local info = C_CurrencyInfo.GetCurrencyInfo(overrideCurrencyID)
    local iconID = info and tonumber(info.iconFileID) or 0
    if iconID > 0 then
      hearthsteelCurrencyIconCache = iconID
      return hearthsteelCurrencyIconCache
    end
  end

  if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
    local info = C_CurrencyInfo.GetCurrencyInfo(3316)
    local name = string.lower(tostring(info and info.name or ""))
    local iconID = info and tonumber(info.iconFileID) or 0
    if name == normalizedTarget and iconID > 0 then
      hearthsteelCurrencyIconCache = iconID
      return hearthsteelCurrencyIconCache
    end
  end

  if ns.CurrencyStatus
    and ns.CurrencyStatus.GetCurrencyListSizeSafe
    and ns.CurrencyStatus.GetCurrencyListInfoSafe then
    local size = ns.CurrencyStatus.GetCurrencyListSizeSafe()
    for index = 1, tonumber(size) or 0 do
      local info = ns.CurrencyStatus.GetCurrencyListInfoSafe(index)
      if type(info) == "table" and not info.isHeader then
        local name = string.lower(tostring(info.name or ""))
        if name == normalizedTarget then
          local iconID = tonumber(info.iconFileID) or 0
          if iconID > 0 then
            chosenIcon = iconID
            break
          end
        end
      end
    end
  end

  hearthsteelCurrencyIconCache = chosenIcon
  return hearthsteelCurrencyIconCache
end

local function BuildPurchaseCostEntries(item)
  local hearthsteelName = "Hearthsteel"
  local hearthsteelQty = 500
  local hearthsteelIcon = ResolveHearthsteelCurrencyIcon()
  local costs = ResolvePurchaseCosts(item)
  local itemNameKey = string.lower(tostring(GetItemName(item) or ""))
  local isHearthsteelPlushie = (itemNameKey == "beloved lion plushie" or itemNameKey == "beloved wolf plushie")
  if type(costs) ~= "table" then
    costs = {}
  end

  local out = {}
  local hasHearthsteelCost = false
  for _, cost in ipairs(costs) do
    local costType = string.lower(tostring(cost and cost.costType or ""))
    if costType == "item" then
      local itemID = tonumber(cost.itemID)
      local qty = tonumber(cost.quantity) or 1
      if itemID and itemID > 0 and qty > 0 then
        out[#out + 1] = {
          itemID = itemID,
          quantity = qty,
          sortKey = "item|" .. tostring(itemID),
        }
      end
    elseif costType == "currency" then
      local currencyID = tonumber(cost.currencyID)
      local qty = tonumber(cost.quantity) or 0
      if currencyID and currencyID > 0 and qty > 0 then
        local info = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(currencyID) or nil
        local resolvedIcon = info and info.iconFileID or UIC.ICON_LOCATION
        if string.lower(tostring(info and info.name or "")) == string.lower(hearthsteelName) then
          resolvedIcon = hearthsteelIcon
        end
        out[#out + 1] = {
          quantity = qty,
          quantityLabel = "x" .. tostring(qty),
          icon = resolvedIcon,
          iconSize = UIC.PURCHASE_ICON_SIZE,
          name = info and info.name or ("Currency " .. tostring(currencyID)),
          tooltip = "Required currency",
          sortKey = "currency|" .. tostring(currencyID),
        }
      end
    elseif costType == "gold" then
      local copper = tonumber(cost.copper) or 0
      if copper > 0 then
        local moneyEntry = BuildRoundedMoneyDisplay(copper)
        if moneyEntry then
          out[#out + 1] = {
            quantity = tonumber(moneyEntry.label) or 1,
            quantityLabel = tostring(moneyEntry.label),
            icon = moneyEntry.icon,
            iconSize = moneyEntry.iconSize,
            name = moneyEntry.name,
            tooltip = moneyEntry.tooltip,
            sortKey = moneyEntry.sortKey,
          }
        end
      end
    elseif costType == "homecurrency" then
      local qty = tonumber(cost.quantity) or 0
      if qty > 0 then
        local name = tostring(cost.name or hearthsteelName)
        local nameKey = string.lower(name)
        local shouldAppend = true
        if nameKey == string.lower(hearthsteelName) then
          if not isHearthsteelPlushie then
            -- Keep Hearthsteel constrained to explicit plushie targets.
            shouldAppend = false
          else
            hasHearthsteelCost = true
          end
        end
        if shouldAppend then
          local iconValue = cost.icon or hearthsteelIcon
          if nameKey == string.lower(hearthsteelName) then
            iconValue = hearthsteelIcon
          end
          out[#out + 1] = {
            quantity = qty,
            quantityLabel = (cost.quantityLabel and tostring(cost.quantityLabel)) or ("x" .. tostring(qty)),
            icon = iconValue,
            iconSize = UIC.PURCHASE_ICON_SIZE,
            name = name,
            tooltip = "Required currency",
            sortKey = "homecurrency|" .. string.lower(name),
          }
        end
      end
    end
  end

  if isHearthsteelPlushie and not hasHearthsteelCost then
    out[#out + 1] = {
      quantity = hearthsteelQty,
      quantityLabel = "x" .. tostring(hearthsteelQty),
      icon = hearthsteelIcon,
      iconSize = UIC.PURCHASE_ICON_SIZE,
      name = hearthsteelName,
      tooltip = "Required currency",
      sortKey = "homecurrency|" .. string.lower(hearthsteelName),
    }
  end

  return NormalizeResourceEntries(out)
end

function ns._BuildItemResourceSearchText(item)
  if type(item) ~= "table" then
    return ""
  end

  local parts = {}
  local directResources = BuildResourceInlineText(NormalizeResourceEntries(item.resources))
  if directResources ~= "-" then
    parts[#parts + 1] = directResources
  end

  local purchaseResources = BuildPurchaseCostEntries(item)
  if #purchaseResources > 0 then
    local purchaseText = BuildResourceInlineText(purchaseResources)
    if purchaseText ~= "-" then
      parts[#parts + 1] = purchaseText
    end
  end

  if item.sourceName and item.sourceName ~= "" then
    parts[#parts + 1] = tostring(item.sourceName)
  end
  if item.sourceDetail and item.sourceDetail ~= "" then
    parts[#parts + 1] = tostring(item.sourceDetail)
  end

  return table.concat(parts, " ")
end

function ns.GetGoldOnlyPurchaseCopper(item)
  local costs = ResolvePurchaseCosts(item)
  if type(costs) ~= "table" then
    return nil
  end

  local totalCopper = 0
  local hasGold = false
  for _, cost in ipairs(costs) do
    local costType = string.lower(tostring(cost and cost.costType or ""))
    if costType == "gold" then
      local copper = tonumber(cost.copper) or 0
      if copper > 0 then
        totalCopper = totalCopper + copper
        hasGold = true
      end
    else
      return nil
    end
  end

  if hasGold and totalCopper > 0 then
    return totalCopper
  end
  return nil
end

local function ComputeResourceAuctionTotalCost(resources)
  local entries = NormalizeResourceEntries(resources)
  if #entries == 0 then
    return nil, false
  end

  local total = 0
  local pricedAny = false
  for _, entry in ipairs(entries) do
    local unitPrice = GetAuctionatorUnitPrice(entry.itemID)
    local qty = tonumber(entry.quantity) or 1
    if unitPrice and unitPrice > 0 and qty > 0 then
      total = total + (unitPrice * qty)
      pricedAny = true
    end
  end

  if not pricedAny then
    return nil, false
  end
  return total, true
end

local function ComputeProfessionProfitCopper(item, totalCost)
  local grossItemValue = GetAuctionatorUnitPrice(item and item.itemID)
  if not grossItemValue or grossItemValue <= 0 or not totalCost or totalCost <= 0 then
    return nil, grossItemValue, totalCost
  end
  local netItemValue = math.floor((grossItemValue * UIC.AUCTION_HOUSE_NET_RATE) + 0.5)
  return netItemValue - totalCost, netItemValue, totalCost
end

local function FormatSignedMoneyShort(copper)
  local value = tonumber(copper)
  if not value then
    return "-"
  end
  if value >= 0 then
    return FormatMoneyShort(value)
  end
  return "-" .. tostring(FormatMoneyShort(math.abs(value)))
end

local function BuildResourcesFromRecipeSchematic(recipeID)
  local id = tonumber(recipeID)
  if not id or id <= 0 then
    return {}
  end
  if recipeResourceCache[id] then
    return recipeResourceCache[id]
  end
  if not (C_TradeSkillUI and C_TradeSkillUI.GetRecipeSchematic) then
    recipeResourceCache[id] = {}
    return recipeResourceCache[id]
  end

  local ok, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, id, false)
  if not ok or type(schematic) ~= "table" then
    recipeResourceCache[id] = {}
    return recipeResourceCache[id]
  end

  local totals = {}
  for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do
    local qty = tonumber(slot.quantityRequired or slot.requiredQuantity or slot.quantity) or 0
    local chosen = nil
    for _, reagent in ipairs(slot.reagents or {}) do
      local reagentItemID = tonumber(reagent and (reagent.itemID or reagent.reagentItemID))
      if reagentItemID and reagentItemID > 0 then
        chosen = reagent
        break
      end
    end
    if chosen then
      local reagentItemID = tonumber(chosen.itemID or chosen.reagentItemID)
      local finalQty = qty
      if finalQty <= 0 then
        finalQty = tonumber(chosen.quantity or chosen.quantityRequired or chosen.requiredQuantity) or 1
      end
      totals[reagentItemID] = (totals[reagentItemID] or 0) + math.max(1, finalQty)
    end
  end

  local out = {}
  for itemID, quantity in pairs(totals) do
    out[#out + 1] = { itemID = itemID, quantity = quantity }
  end
  table.sort(out, function(a, b)
    return (a.itemID or 0) < (b.itemID or 0)
  end)

  recipeResourceCache[id] = out
  return out
end

local function GetRenderableResourcesForItem(item)
  local normalized = NormalizeResourceEntries(item and item.resources)
  if #normalized > 0 then
    return normalized
  end
  if item and ns.GetStaticRecipeData then
    local staticData = ns.GetStaticRecipeData(item.itemID, item.recipeID)
    if staticData and (not item.recipeID) and staticData.recipeID then
      item.recipeID = staticData.recipeID
    end
    if staticData and type(staticData.resources) == "table" and #staticData.resources > 0 then
      item.resources = staticData.resources
      return NormalizeResourceEntries(item.resources)
    end
  end
  local fallback = BuildResourcesFromRecipeSchematic(item and item.recipeID)
  if #fallback > 0 and item then
    item.resources = fallback
  end
  return fallback
end

local function BuildCharacterProfessionNames()
  local knownByName = {}
  local ordered = {}

  if not (GetProfessions and GetProfessionInfo) then
    return knownByName, ordered
  end

  local profs = { GetProfessions() }
  for _, profIndex in ipairs(profs) do
    if profIndex then
      local name = GetProfessionInfo(profIndex)
      if name and name ~= "" then
        local lowered = SafeLower(name)
        if lowered ~= "" and not knownByName[lowered] then
          knownByName[lowered] = true
          ordered[#ordered + 1] = lowered
        end
      end
    end
  end

  return knownByName, ordered
end

local function CharacterKnowsProfession(knownByName, orderedKnownNames, professionName)
  local lowered = SafeLower(professionName)
  if lowered == "" then
    return false
  end
  if knownByName[lowered] then
    return true
  end
  for _, knownName in ipairs(orderedKnownNames) do
    if StringContains(knownName, lowered) or StringContains(lowered, knownName) then
      return true
    end
  end
  return false
end

local function IsProfessionFilterEnabled(frame, professionName)
  local selected = frame and frame.filters and frame.filters.professionMulti
  if type(selected) ~= "table" then
    return true
  end
  local hasAny = false
  for _ in pairs(selected) do
    hasAny = true
    break
  end
  if not hasAny then
    return true
  end
  return selected[tostring(professionName or "")] == true
end

local function BuildProfessionFilterList()
  local all = ns.GetProfessionData and ns.GetProfessionData() or {}
  local seen = {}
  local out = {}
  for _, prof in ipairs(all) do
    local name = prof and prof.profession or nil
    if name and name ~= "" and not seen[name] then
      seen[name] = true
      out[#out + 1] = name
    end
  end
  table.sort(out)
  return out
end

local function GetTrackedItemsDB()
  DecorDropsDB.trackedItems = DecorDropsDB.trackedItems or {}
  DecorDropsDB.trackedItems.instances = DecorDropsDB.trackedItems.instances or {}
  DecorDropsDB.trackedItems.professions = DecorDropsDB.trackedItems.professions or {}
  DecorDropsDB.trackedItems.vendor = DecorDropsDB.trackedItems.vendor or {}
  DecorDropsDB.trackedItems.achievement = DecorDropsDB.trackedItems.achievement or {}
  DecorDropsDB.trackedItems.quest = DecorDropsDB.trackedItems.quest or {}
  DecorDropsDB.trackedItems.reputation = DecorDropsDB.trackedItems.reputation or {}
  DecorDropsDB.trackedItems.endeavor = DecorDropsDB.trackedItems.endeavor or {}

  -- Migration: Neighbourhood Decor Treasure Hunt entries were previously tracked as quest.
  local questTracked = DecorDropsDB.trackedItems.quest
  local endeavorTracked = DecorDropsDB.trackedItems.endeavor
  if type(questTracked) == "table" and type(endeavorTracked) == "table" then
    for sourceKey, sourceBucket in pairs(questTracked) do
      local key = string.lower(tostring(sourceKey or ""))
      if key:find("|decor treasure hunt|", 1, true) and key:sub(-6) == "|quest" and type(sourceBucket) == "table" then
        local endeavorKey = tostring(sourceKey):gsub("|quest$", "|endeavor")
        if endeavorKey ~= "" then
          endeavorTracked[endeavorKey] = endeavorTracked[endeavorKey] or {}
          for itemID, tracked in pairs(sourceBucket) do
            if tracked then
              endeavorTracked[endeavorKey][itemID] = true
            end
          end
          questTracked[sourceKey] = nil
        end
      end
    end
  end

  return DecorDropsDB.trackedItems
end

function ns.IsQuestEntryActuallyEndeavor(entry)
  if ns.IsQuestGroupVendorUnlockLike then
    return ns.IsQuestGroupVendorUnlockLike(entry)
  end
  return false
end

local function GetEntrySourceKey(entry, mode)
  if ns.IsSourceMode(mode) then
    return entry and entry.sourceKey or entry and entry.key or nil
  end

  if mode == "professions" then
    if entry and entry.sourceKey then
      return entry.sourceKey
    end
    local prof = entry and entry.profession or nil
    if not prof then
      return nil
    end
    return string.lower((prof.expansion or "Other") .. "|profession|" .. (prof.profession or ""))
  end

  if entry and entry.sourceKey then
    return entry.sourceKey
  end
  if entry and entry.instance then
    return InstanceKey(entry.instance)
  end
  return nil
end

local function IsEntryItemTracked(entry, mode, itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return false
  end
  local sourceKey = GetEntrySourceKey(entry, mode)
  if not sourceKey then
    return false
  end
  local trackedDB = GetTrackedItemsDB()
  local bucket = trackedDB[mode]
  local sourceBucket = bucket and bucket[sourceKey]
  return sourceBucket and sourceBucket[numericItemID] and true or false
end

local function ToggleTrackedEntryItem(entry, mode, itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return
  end
  local sourceKey = GetEntrySourceKey(entry, mode)
  if not sourceKey then
    return
  end

  local trackedDB = GetTrackedItemsDB()
  trackedDB[mode][sourceKey] = trackedDB[mode][sourceKey] or {}
  local sourceBucket = trackedDB[mode][sourceKey]

  if sourceBucket[numericItemID] then
    sourceBucket[numericItemID] = nil
  else
    sourceBucket[numericItemID] = true
  end

  local hasAny = false
  for _ in pairs(sourceBucket) do
    hasAny = true
    break
  end
  if not hasAny then
    trackedDB[mode][sourceKey] = nil
  end
end

local function BuildFilteredInstances(frame)
  if frame.viewMode == "tracker" then
    local filtered = {}
    local trackedDB = GetTrackedItemsDB()
    local instanceData = ns.GetData and ns.GetData() or ns.data or {}
    local professionData = ns.GetProfessionData and ns.GetProfessionData() or {}
    local query, resourceQuery = ns._ParseSearchQueryFilters(frame.filters.search or "")

    for _, instance in ipairs(instanceData) do
      local sourceKey = InstanceKey(instance)
      local trackedItems = trackedDB.instances[sourceKey]
      if trackedItems then
        local rows = {}
        for _, item in ipairs(instance.decorations or {}) do
          local numericItemID = tonumber(item and item.itemID)
          if resourceQuery == "" and numericItemID and trackedItems[numericItemID] and ItemMatchesFilters(frame, instance, item) then
            rows[#rows + 1] = item
          end
        end
        if #rows > 0 then
          filtered[#filtered + 1] = {
            key = "tracker|instances|" .. sourceKey,
            trackerKind = "instances",
            sourceKey = sourceKey,
            instance = instance,
            rows = rows,
          }
        end
      end
    end

    for _, prof in ipairs(professionData) do
      if IsProfessionFilterEnabled(frame, prof.profession) then
      local sourceKey = string.lower((prof.expansion or "Other") .. "|profession|" .. (prof.profession or ""))
      local trackedItems = trackedDB.professions[sourceKey]
      if trackedItems then
        local rows = {}
        for _, item in ipairs(prof.decorations or {}) do
          local numericItemID = tonumber(item and item.itemID)
          if numericItemID and trackedItems[numericItemID] then
            local itemName = GetItemName(item)
            local itemIDText = tostring(numericItemID)
            local sourceName = query ~= "" and BuildResourceInlineText(item.resources) or ""
            local searchHit = query == ""
              or StringContains(itemName, query)
              or StringContains(itemIDText, query)
              or StringContains(prof.profession, query)
              or StringContains(prof.expansion, query)
              or StringContains(sourceName, query)
            if resourceQuery ~= "" then
              searchHit = false
            end
            if searchHit then
              local qualityAllowed = true
              if frame.filters.rarity then
                local quality = GetItemQuality(item)
                qualityAllowed = frame.filters.rarity[quality] ~= false
              end
              local collectedAllowed = true
              if frame.filters.collected ~= "All" then
                local collected = GetWarbandCollectedState(item)
                if frame.filters.collected == "Collected" and not collected then
                  collectedAllowed = false
                elseif frame.filters.collected == "Uncollected" and collected then
                  collectedAllowed = false
                end
              end
              if qualityAllowed and collectedAllowed then
                rows[#rows + 1] = item
              end
            end
          end
        end
        if #rows > 0 then
          filtered[#filtered + 1] = {
            key = "tracker|professions|" .. sourceKey,
            trackerKind = "professions",
            sourceKey = sourceKey,
            profession = prof,
            rows = rows,
          }
        end
      end
      end
    end

    for mode, _ in pairs(NEW_SOURCE_MODES) do
      local sourceEntries = ns.GetAcquireData and ns.GetAcquireData(mode) or {}
      for _, sourceEntry in ipairs(sourceEntries) do
        if mode == "quest" and ns.IsQuestEntryActuallyEndeavor and ns.IsQuestEntryActuallyEndeavor(sourceEntry) then
          -- Skip miscategorized Neighborhood vendor-unlock rows from Quest tracking.
        else
        local sourceKey = sourceEntry.key
        local trackedItems = trackedDB[mode] and trackedDB[mode][sourceKey]
        if trackedItems then
          local rows = {}
          for _, item in ipairs(sourceEntry.rows or {}) do
            local numericItemID = tonumber(item and item.itemID)
            if numericItemID and trackedItems[numericItemID] then
              local itemName = GetItemName(item)
              local itemIDText = tostring(numericItemID)
              local searchHit = query == ""
                or StringContains(itemName, query)
                or StringContains(itemIDText, query)
                or StringContains(item.source, query)
                or StringContains(item.sourceName, query)
                or StringContains(item.sourceDetail, query)
                or StringContains(item.zone, query)
                or StringContains(item.npcName, query)
                or StringContains(sourceEntry.label, query)
                or StringContains(sourceEntry.expansion, query)
                or ((mode == "vendor" or mode == "promotion") and StringContains(ns._BuildItemResourceSearchText(item), query))
              if resourceQuery ~= "" then
                if mode == "vendor" or mode == "promotion" then
                  searchHit = searchHit and ns._ResourceSearchMatches(ns._BuildItemResourceSearchText(item), resourceQuery)
                else
                  searchHit = false
                end
              end
              if searchHit then
                local qualityAllowed = true
                if frame.filters.rarity then
                  local quality = GetItemQuality(item)
                  qualityAllowed = frame.filters.rarity[quality] ~= false
                end
                local collectedAllowed = true
                if frame.filters.collected ~= "All" then
                  local collected = GetWarbandCollectedState(item)
                  if frame.filters.collected == "Collected" and not collected then
                    collectedAllowed = false
                  elseif frame.filters.collected == "Uncollected" and collected then
                    collectedAllowed = false
                  end
                end
                if qualityAllowed and collectedAllowed then
                  rows[#rows + 1] = item
                end
              end
            end
          end
        if #rows > 0 then
          local zoneName = ResolveSourceEntryZoneName(sourceEntry, rows)
          if mode == "promotion" and type(sourceEntry) == "table" and sourceEntry.detail and sourceEntry.detail ~= "" then
            zoneName = tostring(sourceEntry.detail)
          end
          local entryLabel = sourceEntry.label or "Source"
          local entryDetail = sourceEntry.detail or ""
          if mode == "promotion" and ns.IsPromotionFlattenedExpansion and ns.IsPromotionFlattenedExpansion(sourceEntry.expansion) then
            local preorderBucket = tostring(sourceEntry.detail or sourceEntry.label or "Promotion")
            entryLabel = preorderBucket
            entryDetail = ""
            zoneName = ""
          end
          filtered[#filtered + 1] = {
            key = "tracker|" .. mode .. "|" .. sourceKey,
            trackerKind = mode,
            sourceKey = sourceKey,
            acquireMode = mode,
            acquireCategory = sourceEntry.category or ns.GetSourceModeLabel(mode),
            expansion = sourceEntry.expansion or "Other",
            zoneName = zoneName,
            label = entryLabel,
            detail = entryDetail,
            waypoint = sourceEntry.waypoint,
            rows = rows,
          }
        end
        end
        end
      end
    end

    table.sort(filtered, function(a, b)
      if a.trackerKind ~= b.trackerKind then
        local oa = TRACKER_KIND_ORDER[a.trackerKind] or 999
        local ob = TRACKER_KIND_ORDER[b.trackerKind] or 999
        if oa ~= ob then
          return oa < ob
        end
      end
      if a.trackerKind == "professions" then
        local expA = a.profession and a.profession.expansion or "Other"
        local expB = b.profession and b.profession.expansion or "Other"
        local ia = EXPANSION_ORDER_INDEX[expA] or 0
        local ib = EXPANSION_ORDER_INDEX[expB] or 0
        if ia ~= ib then
          return ia > ib
        end
        return (a.profession and a.profession.profession or "") < (b.profession and b.profession.profession or "")
      end
      if ns.IsSourceMode(a.trackerKind) then
        local expA = a.expansion or "Other"
        local expB = b.expansion or "Other"
        local ia = EXPANSION_ORDER_INDEX[expA] or 0
        local ib = EXPANSION_ORDER_INDEX[expB] or 0
        if ia ~= ib then
          return ia > ib
        end
        if (a.zoneName or "") ~= (b.zoneName or "") then
          return (a.zoneName or "") < (b.zoneName or "")
        end
        return (a.label or "") < (b.label or "")
      end
      local expA = ExpansionName(a.instance)
      local expB = ExpansionName(b.instance)
      local ia = EXPANSION_ORDER_INDEX[expA] or 0
      local ib = EXPANSION_ORDER_INDEX[expB] or 0
      if ia ~= ib then
        return ia > ib
      end
      return (a.instance and a.instance.name or "") < (b.instance and b.instance.name or "")
    end)

    return filtered
  end

  if frame.viewMode == "professions" then
    local filtered = {}
    local all = ns.GetProfessionData and ns.GetProfessionData() or {}
    local query = SafeLower(frame.filters.search or "")
    local onlyKnownProfessions = frame.filters.onlyKnownProfessions and true or false
    local knownByName, orderedKnownNames = BuildCharacterProfessionNames()

    for _, prof in ipairs(all) do
      local includeProfession = true
      if onlyKnownProfessions then
        includeProfession = CharacterKnowsProfession(knownByName, orderedKnownNames, prof.profession)
      end
      if includeProfession then
        includeProfession = IsProfessionFilterEnabled(frame, prof.profession)
      end

      if includeProfession then
        local rows = {}
        for _, item in ipairs(prof.decorations or {}) do
          local itemName = GetItemName(item)
          local itemIDText = tostring(tonumber(item and item.itemID) or 0)
          local sourceName = query ~= "" and BuildResourceInlineText(item.resources) or ""
          local searchHit = query == ""
            or StringContains(itemName, query)
            or StringContains(itemIDText, query)
            or StringContains(prof.profession, query)
            or StringContains(prof.expansion, query)
            or StringContains(sourceName, query)

          if searchHit then
            local qualityAllowed = true
            if frame.filters.rarity then
              local quality = GetItemQuality(item)
              qualityAllowed = frame.filters.rarity[quality] ~= false
            end

            local collectedAllowed = true
            if frame.filters.collected ~= "All" then
              local collected = GetWarbandCollectedState(item)
              if frame.filters.collected == "Collected" and not collected then
                collectedAllowed = false
              elseif frame.filters.collected == "Uncollected" and collected then
                collectedAllowed = false
              end
            end

            if qualityAllowed and collectedAllowed then
              rows[#rows + 1] = item
            end
          end
        end

        if #rows > 0 then
          filtered[#filtered + 1] = {
            key = string.lower((prof.expansion or "Other") .. "|profession|" .. (prof.profession or "")),
            profession = prof,
            rows = rows,
          }
        end
      end
    end

    table.sort(filtered, function(a, b)
      local expA = a.profession.expansion or "Other"
      local expB = b.profession.expansion or "Other"
      local ia = EXPANSION_ORDER_INDEX[expA] or 0
      local ib = EXPANSION_ORDER_INDEX[expB] or 0
      if ia ~= ib then
        return ia > ib
      end
      return (a.profession.profession or "") < (b.profession.profession or "")
    end)

    return filtered
  end

  if ns.IsSourceMode(frame.viewMode) then
    local filtered = {}
    local all = ns.GetAcquireData and ns.GetAcquireData(frame.viewMode) or {}
    local query, resourceQuery = ns._ParseSearchQueryFilters(frame.filters.search or "")

    for _, entry in ipairs(all) do
      if frame.viewMode == "quest" and ns.IsQuestEntryActuallyEndeavor and ns.IsQuestEntryActuallyEndeavor(entry) then
        -- Skip miscategorized Neighborhood vendor-unlock rows from Quest mode.
      else
        local rows = {}
        for _, item in ipairs(entry.rows or {}) do
          local numericItemID = tonumber(item and item.itemID) or 0
          if not (frame.viewMode == "vendor" and numericItemID == 248941) then
            local itemName = GetItemName(item)
            local itemIDText = tostring(numericItemID)
            local searchHit = query == ""
              or StringContains(itemName, query)
              or StringContains(itemIDText, query)
              or StringContains(item.source, query)
              or StringContains(item.sourceName, query)
              or StringContains(item.sourceDetail, query)
              or StringContains(item.zone, query)
              or StringContains(item.npcName, query)
              or StringContains(entry.label, query)
              or StringContains(entry.expansion, query)
              or ((frame.viewMode == "vendor" or frame.viewMode == "promotion") and StringContains(ns._BuildItemResourceSearchText(item), query))
            if resourceQuery ~= "" then
              if frame.viewMode == "vendor" or frame.viewMode == "promotion" then
                searchHit = searchHit and ns._ResourceSearchMatches(ns._BuildItemResourceSearchText(item), resourceQuery)
              else
                searchHit = false
              end
            end

            if searchHit then
              local qualityAllowed = true
              if frame.filters.rarity then
                local quality = GetItemQuality(item)
                qualityAllowed = frame.filters.rarity[quality] ~= false
              end

              local collectedAllowed = true
              if frame.filters.collected ~= "All" then
                local collected = GetWarbandCollectedState(item)
                if frame.filters.collected == "Collected" and not collected then
                  collectedAllowed = false
                elseif frame.filters.collected == "Uncollected" and collected then
                  collectedAllowed = false
                end
              end

              if qualityAllowed and collectedAllowed then
                rows[#rows + 1] = item
              end
            end
          end
        end

        if #rows > 0 then
          local zoneName = ResolveSourceEntryZoneName(entry, rows)
          if frame.viewMode == "promotion" and type(entry) == "table" and entry.detail and entry.detail ~= "" then
            zoneName = tostring(entry.detail)
          end
          local entryLabel = entry.label or "Source"
          local entryDetail = entry.detail or ""
          if frame.viewMode == "promotion" and ns.IsPromotionFlattenedExpansion and ns.IsPromotionFlattenedExpansion(entry.expansion) then
            local preorderBucket = tostring(entry.detail or entry.label or "Promotion")
            entryLabel = preorderBucket
            entryDetail = ""
            zoneName = ""
          end
          filtered[#filtered + 1] = {
            key = entry.key,
            sourceKey = entry.key,
            acquireMode = frame.viewMode,
            acquireCategory = entry.category or ns.GetSourceModeLabel(frame.viewMode),
            expansion = entry.expansion or "Other",
            zoneName = zoneName,
            label = entryLabel,
            detail = entryDetail,
            waypoint = entry.waypoint,
            rows = rows,
          }
        end
      end
    end

    table.sort(filtered, function(a, b)
      local expA = a.expansion or "Other"
      local expB = b.expansion or "Other"
      local ia = EXPANSION_ORDER_INDEX[expA] or 0
      local ib = EXPANSION_ORDER_INDEX[expB] or 0
      if ia ~= ib then
        return ia > ib
      end
      if (a.zoneName or "") ~= (b.zoneName or "") then
        return (a.zoneName or "") < (b.zoneName or "")
      end
      return (a.label or "") < (b.label or "")
    end)
    return filtered
  end

  local filtered = {}
  local allInstances = ns.GetData and ns.GetData() or ns.data or {}

  for _, instance in ipairs(allInstances) do
    if frame.filters.type == "All" or frame.filters.type == instance.type then
      local rows = {}
      for _, item in ipairs(instance.decorations or {}) do
        if ItemMatchesFilters(frame, instance, item) then
          rows[#rows + 1] = item
        end
      end

      if #rows > 0 then
        filtered[#filtered + 1] = {
          key = InstanceKey(instance),
          instance = instance,
          rows = rows,
        }
      end
    end
  end

  table.sort(filtered, function(a, b)
    local expA = ExpansionName(a.instance)
    local expB = ExpansionName(b.instance)
    if expA ~= expB then
      return expA < expB
    end
    return a.instance.name < b.instance.name
  end)

  return filtered
end

local function CreateListButton(parent)
  local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
  btn:SetSize(UIC.LEFT_LIST_WIDTH, 28)
  if btn.RegisterForClicks then
    btn:RegisterForClicks("LeftButtonDown")
  end
  if btn.SetNormalTexture then
    btn:SetNormalTexture(UIC.THEME_FLAT)
    local nt = btn.GetNormalTexture and btn:GetNormalTexture() or nil
    if nt then
      nt:SetAllPoints()
      nt:SetVertexColor(1, 1, 1, 0)
    end
  end
  if btn.SetPushedTexture then
    btn:SetPushedTexture(UIC.THEME_FLAT)
    local pt = btn.GetPushedTexture and btn:GetPushedTexture() or nil
    if pt then
      pt:SetAllPoints()
      pt:SetVertexColor(1, 1, 1, 0)
    end
  end
  if btn.SetHighlightTexture then
    btn:SetHighlightTexture(UIC.THEME_FLAT)
    local ht = btn.GetHighlightTexture and btn:GetHighlightTexture() or nil
    if ht then
      ht:SetAllPoints()
      ht:SetVertexColor(1, 1, 1, 0)
    end
  end
  btn:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  btn:SetBackdropColor(0.05, 0.05, 0.05, 0.98)
  btn:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.90)

  btn.highlight = btn:CreateTexture(nil, "HIGHLIGHT")
  btn.highlight:SetAllPoints()
  btn.highlight:SetColorTexture(0.76, 0.64, 0.42, 0.10)
  if btn.highlight.SetBlendMode then
    btn.highlight:SetBlendMode("BLEND")
  end

  btn.selectedGlow = btn:CreateTexture(nil, "ARTWORK", nil, 1)
  btn.selectedGlow:SetAllPoints()
  btn.selectedGlow:SetTexture(UIC.THEME_FLAT)
  btn.selectedGlow:SetVertexColor(1.00, 0.86, 0.46, 0.00)
  btn.selectedGlow:Hide()

  btn.selectedBar = btn:CreateTexture(nil, "OVERLAY", nil, 2)
  btn.selectedBar:SetPoint("TOPLEFT", 2, -2)
  btn.selectedBar:SetPoint("BOTTOMLEFT", 2, 2)
  btn.selectedBar:SetWidth(3)
  btn.selectedBar:SetTexture(UIC.THEME_FLAT)
  btn.selectedBar:SetVertexColor(1.00, 0.88, 0.48, 0.00)
  btn.selectedBar:Hide()

  btn.typeIcon = btn:CreateTexture(nil, "ARTWORK")
  btn.typeIcon:SetSize(18, 18)
  btn.typeIcon:SetPoint("LEFT", 8, 0)

  btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  btn.text:SetPoint("LEFT", btn.typeIcon, "RIGHT", 6, 0)
  btn.text:SetPoint("RIGHT", -72, 0)
  btn.text:SetJustifyH("LEFT")
  btn.text:SetWordWrap(false)
  if btn.text.SetMaxLines then
    btn.text:SetMaxLines(1)
  end

  btn.completionText = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  btn.completionText:SetPoint("RIGHT", -10, 0)
  btn.completionText:SetJustifyH("RIGHT")
  btn.completionText:SetTextColor(0.76, 0.76, 0.72)
  do
    local fontPath, fontSize, fontFlags = btn.completionText:GetFont()
    if fontPath and fontSize then
      btn.completionText:SetFont(fontPath, math.max(8, fontSize * 0.95), fontFlags)
    end
  end
  btn.completionText:Hide()

  btn.branchArrow = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  btn.branchArrow:SetPoint("LEFT", btn, "LEFT", -(UIC.SUBCATEGORY_LEFT_INDENT - UIC.SUBCATEGORY_ARROW_ANCHOR_OFFSET), 0)
  btn.branchArrow:SetText("->")
  btn.branchArrow:SetTextColor(1.00, 0.90, 0.62)
  btn.branchArrow:Hide()

  return btn
end

local function TrySetAtlas(icon, atlasNames)
  if not icon or not icon.SetAtlas then
    return false
  end

  for _, atlas in ipairs(atlasNames) do
    local ok, result = pcall(icon.SetAtlas, icon, atlas, false)
    if ok and result then
      return true
    end
  end

  return false
end

local function SetInstanceTypeIcon(icon, instanceType)
  local width = tonumber(icon and icon.GetWidth and icon:GetWidth()) or 0
  local useCustomPortal = width >= 24
  if useCustomPortal and icon:SetTexture("Interface\\AddOns\\DecorDrops\\Media\\DungeonRaidPortal.png") then
    if icon.SetAtlas then
      icon:SetAtlas(nil)
    end
    icon:SetTexCoord(0.00, 1.00, 0.00, 1.00)
    if icon.SetDesaturated then
      icon:SetDesaturated(false)
    end
    icon:SetVertexColor(1, 1, 1, 1)
    return
  end

  local atlasApplied = false

  atlasApplied = TrySetAtlas(icon, {
    "groupfinder-icon-dungeon",
    "groupfinder-eye-dungeon",
    "Dungeon",
  })

  if not atlasApplied then
    icon:SetTexture(TYPE_ICON_FALLBACK_TEXTURE)
    icon:SetTexCoord(unpack(SWIRL_FALLBACK_COORDS))
    icon:SetVertexColor(1, 1, 1, 1)
  else
    icon:SetTexCoord(0, 1, 0, 1)
  end
  if icon.SetDesaturated then
    icon:SetDesaturated(true)
  end

  if instanceType == "Raid" then
    icon:SetVertexColor(0.20, 0.95, 0.35, 0.95)
  else
    icon:SetVertexColor(0.20, 0.55, 1.00, 0.95)
  end
end

local function SetInstancesModeTabIcon(icon)
  if not icon then
    return
  end
  if icon.SetAtlas then
    icon:SetAtlas(nil)
  end
  icon:SetTexCoord(0.00, 1.00, 0.00, 1.00)
  if icon.SetDesaturated then
    icon:SetDesaturated(false)
  end
  icon:SetVertexColor(1, 1, 1, 1)
  -- Primary icon provided for raid/dungeon page tab.
  icon:SetTexture("Interface\\AddOns\\DecorDrops\\Media\\DungeonRaidPortal.png")
  -- Safety fallback to a shipped addon texture if the custom file is missing.
  if not icon:GetTexture() or icon:GetTexture() == "" then
    icon:SetTexture("Interface\\AddOns\\DecorDrops\\Media\\tracking.png")
  end
end

local function GetProfessionIconTexture(professionName, fallbackIcon)
  local mapped = professionName and PROFESSION_ICON_BY_NAME[professionName] or nil
  if mapped and mapped ~= "" then
    return mapped
  end
  if fallbackIcon and fallbackIcon ~= "" then
    return fallbackIcon
  end
  return UIC.ICON_PROFESSIONS
end

local function CreateExpansionHeader(parent)
  local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
  btn:SetSize(UIC.LEFT_LIST_WIDTH, 30)
  -- Reserve the right-side strip for teleport button clicks so header toggle
  -- cannot consume the same click.
  if btn.SetHitRectInsets then
    btn:SetHitRectInsets(0, 24, 0, 0)
  end
  if btn.RegisterForClicks then
    btn:RegisterForClicks("LeftButtonUp")
  end
  if btn.SetNormalTexture then
    btn:SetNormalTexture(UIC.THEME_FLAT)
    local nt = btn.GetNormalTexture and btn:GetNormalTexture() or nil
    if nt then
      nt:SetAllPoints()
      nt:SetVertexColor(1, 1, 1, 0)
    end
  end
  if btn.SetPushedTexture then
    btn:SetPushedTexture(UIC.THEME_FLAT)
    local pt = btn.GetPushedTexture and btn:GetPushedTexture() or nil
    if pt then
      pt:SetAllPoints()
      pt:SetVertexColor(1, 1, 1, 0)
    end
  end
  if btn.SetHighlightTexture then
    btn:SetHighlightTexture(UIC.THEME_FLAT)
    local ht = btn.GetHighlightTexture and btn:GetHighlightTexture() or nil
    if ht then
      ht:SetAllPoints()
      ht:SetVertexColor(1, 1, 1, 0)
    end
  end
  btn:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  btn:SetBackdropColor(0.05, 0.05, 0.05, 0.98)
  btn:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.92)

  btn.arrow = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  btn.arrow:SetPoint("LEFT", 8, 0)
  btn.arrow:SetTextColor(1.00, 0.93, 0.78)

  btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  btn.text:SetPoint("LEFT", btn.arrow, "RIGHT", 6, 0)
  btn.text:SetJustifyH("LEFT")
  btn.text:SetWordWrap(false)
  if btn.text.SetMaxLines then
    btn.text:SetMaxLines(1)
  end
  btn.text:SetTextColor(1.00, 0.93, 0.78)

  btn.completionText = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  btn.completionText:SetPoint("RIGHT", -8, 0)
  btn.completionText:SetJustifyH("RIGHT")
  btn.completionText:SetTextColor(0.76, 0.76, 0.72)
  do
    local fontPath, fontSize, fontFlags = btn.completionText:GetFont()
    if fontPath and fontSize then
      btn.completionText:SetFont(fontPath, math.max(8, fontSize * 0.95), fontFlags)
    end
  end
  btn.completionText:Hide()
  -- Let long category text consume all space up to completion percentage.
  btn.text:ClearAllPoints()
  btn.text:SetPoint("LEFT", btn.arrow, "RIGHT", 6, 0)
  btn.text:SetPoint("RIGHT", btn.completionText, "LEFT", -6, 0)

  btn.hover = btn:CreateTexture(nil, "ARTWORK", nil, 1)
  btn.hover:SetAllPoints()
  btn.hover:SetTexture(UIC.THEME_FLAT)
  btn.hover:SetVertexColor(1.00, 0.84, 0.30, 0.06)
  btn.hover:Hide()
  btn:SetScript("OnEnter", function(self)
    if self.hover then
      self.hover:Show()
    end
  end)
  btn:SetScript("OnLeave", function(self)
    if self.hover then
      self.hover:Hide()
    end
  end)

  return btn
end

ns.CurrencyStatus.pageTextScale = 1.20
ns.CurrencyStatus.pageIconScale = 1.35
ns.MissionStatus.garrisonPageTextScale = 1.20
ns.MissionStatus.garrisonCategoryTextScale = 1.15
ns.MissionStatus.garrisonRewardIconScale = 1.35

function ns.ScaleCurrencyFontString(fontString)
  if not fontString or fontString._ddCurrencyScaled then
    return
  end
  local fontPath, fontSize, fontFlags = fontString:GetFont()
  if not fontPath or not fontSize then
    return
  end
  local scaled = math.max(8, fontSize * (ns.CurrencyStatus.pageTextScale or 1.20))
  fontString:SetFont(fontPath, scaled, fontFlags)
  fontString._ddCurrencyScaled = true
end

function ns.ScaleGarrisonDisplayFontString(fontString)
  if not fontString or fontString._ddGarrisonDisplayScaled then
    return
  end
  local fontPath, fontSize, fontFlags = fontString:GetFont()
  if not fontPath or not fontSize then
    return
  end
  local scaled = math.max(8, fontSize * (ns.MissionStatus.garrisonPageTextScale or 1.20))
  fontString:SetFont(fontPath, scaled, fontFlags)
  fontString._ddGarrisonDisplayScaled = true
end

function ns.ScaleGarrisonCategoryFontString(fontString)
  if not fontString or fontString._ddGarrisonCategoryScaled then
    return
  end
  local fontPath, fontSize, fontFlags = fontString:GetFont()
  if not fontPath or not fontSize then
    return
  end
  local scaled = math.max(8, fontSize * (ns.MissionStatus.garrisonCategoryTextScale or 1.15))
  fontString:SetFont(fontPath, scaled, fontFlags)
  fontString._ddGarrisonCategoryScaled = true
end

local function FormatCurrencyAmount(value)
  local numberValue = tonumber(value) or 0
  local rounded = math.floor(numberValue + 0.5)
  if BreakUpLargeNumbers then
    return BreakUpLargeNumbers(rounded)
  end
  local sign = ""
  if rounded < 0 then
    sign = "-"
    rounded = math.abs(rounded)
  end
  local text = tostring(rounded)
  local formatted = text:reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
  return sign .. formatted
end

function ns.CreateCurrencyStatusPanel(frame)
  if not frame then
    return nil
  end
  local panel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  panel:SetPoint("TOPLEFT", 20, -82)
  panel:SetPoint("BOTTOMRIGHT", -24, 8)
  panel:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  panel:SetBackdropColor(0.18, 0.14, 0.10, 0.56)
  panel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  panel:Hide()

  panel.title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  panel.title:SetPoint("TOP", panel, "TOP", 0, -14)
  panel.title:SetText("Currency")
  panel.title:SetJustifyH("CENTER")
  panel.title:SetTextColor(0.98, 0.90, 0.74)
  ns.ScaleCurrencyFontString(panel.title)

  panel.subtitle = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  panel.subtitle:SetPoint("TOP", panel.title, "BOTTOM", 0, -12)
  panel.subtitle:SetText("Character currency breakdown by expansion.")
  panel.subtitle:SetJustifyH("CENTER")
  panel.subtitle:SetTextColor(0.84, 0.92, 1.00)
  ns.ScaleCurrencyFontString(panel.subtitle)

  local topAccent = panel:CreateTexture(nil, "ARTWORK", nil, 2)
  topAccent:SetPoint("TOPLEFT", panel.subtitle, "BOTTOMLEFT", -300, -8)
  topAccent:SetPoint("TOPRIGHT", panel.subtitle, "BOTTOMRIGHT", 300, -8)
  topAccent:SetHeight(2)
  topAccent:SetTexture(UIC.THEME_FLAT)
  topAccent:SetVertexColor(0.96, 0.78, 0.36, 0.45)

  panel.leftPane = CreateFrame("Frame", nil, panel, "BackdropTemplate")
  panel.leftPane:SetPoint("TOPLEFT", panel, "TOPLEFT", 10, -88)
  panel.leftPane:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 10, 8)
  panel.leftPane:SetWidth(280)
  panel.leftPane:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  panel.leftPane:SetBackdropColor(0.18, 0.14, 0.10, 0.52)
  panel.leftPane:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)

  panel.rightPane = CreateFrame("Frame", nil, panel, "BackdropTemplate")
  panel.rightPane:SetPoint("TOPLEFT", panel.leftPane, "TOPRIGHT", 10, 0)
  panel.rightPane:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -8, 8)
  panel.rightPane:SetBackdrop({
    bgFile = UIC.THEME_FLAT,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  panel.rightPane:SetBackdropColor(0.06, 0.05, 0.04, 0.38)
  panel.rightPane:SetBackdropBorderColor(0.78, 0.66, 0.44, 0.60)

  panel.headerName = panel.rightPane:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  panel.headerName:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", 12, -12)
  panel.headerName:SetText("Currency")
  panel.headerName:SetTextColor(0.90, 0.88, 0.76)
  panel.headerName:SetJustifyH("LEFT")
  ns.ScaleCurrencyFontString(panel.headerName)

  panel.headerQty = panel.rightPane:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  panel.headerQty:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", 350, -12)
  panel.headerQty:SetText("Amount")
  panel.headerQty:SetTextColor(0.90, 0.88, 0.76)
  panel.headerQty:SetJustifyH("LEFT")
  ns.ScaleCurrencyFontString(panel.headerQty)

  panel.headerAction = panel.rightPane:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  panel.headerAction:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", 470, -12)
  panel.headerAction:SetWidth(168)
  panel.headerAction:SetText("Category")
  panel.headerAction:SetTextColor(0.90, 0.88, 0.76)
  panel.headerAction:SetJustifyH("CENTER")
  ns.ScaleCurrencyFontString(panel.headerAction)

  panel.rowsScroll = CreateFrame("ScrollFrame", "DecorDropsCurrencyRowsScrollFrame", panel.rightPane, "UIPanelScrollFrameTemplate")
  panel.rowsScroll:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", 6, -34)
  panel.rowsScroll:SetPoint("BOTTOMRIGHT", panel.rightPane, "BOTTOMRIGHT", -22, 6)
  panel.rowsContent = CreateFrame("Frame", nil, panel.rowsScroll)
  panel.rowsContent:ClearAllPoints()
  panel.rowsContent:SetPoint("TOPLEFT", panel.rowsScroll, "TOPLEFT", 0, 0)
  panel.rowsContent:SetSize(792, 1)
  panel.rowsScroll:SetScrollChild(panel.rowsContent)
  panel.rowsScroll._ddScrollBar = _G["DecorDropsCurrencyRowsScrollFrameScrollBar"]
  panel.rowsScroll._ddScrollBarAnchorSide = "right"
  panel.rowsScroll._ddScrollBarXOffset = 8
  ns._ApplyScrollBarOpacity(panel.rowsScroll, 0.74)

  panel.emptyText = panel.rightPane:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  panel.emptyText:SetPoint("TOP", panel.rightPane, "TOP", 0, -72)
  panel.emptyText:SetPoint("LEFT", panel.rightPane, "LEFT", 16, 0)
  panel.emptyText:SetPoint("RIGHT", panel.rightPane, "RIGHT", -16, 0)
  panel.emptyText:SetJustifyH("CENTER")
  panel.emptyText:SetTextColor(0.88, 0.84, 0.74)
  panel.emptyText:SetText("No currencies found for this character.")
  ns.ScaleCurrencyFontString(panel.emptyText)
  panel.emptyText:Hide()

  panel.categoryButtons = {}
  panel.rows = {}
  panel._ddSelectedCategory = nil
  panel._lastRefreshAt = 0
  panel._categories = {}

  return panel
end

function ns.RefreshCurrencyStatusPanel(frame, force)
  local panel = frame and frame.currencyPanel
  if not panel then
    return
  end
  local now = GetTime and GetTime() or 0
  if not force and panel._lastRefreshAt and (now - panel._lastRefreshAt) < (ns.CurrencyStatus.refreshSeconds or 30) then
    return
  end
  panel._lastRefreshAt = now

  local categories = ns.CurrencyStatus.BuildCharacterCurrencyData()
  panel._categories = categories
  local byKey = {}
  for _, category in ipairs(categories) do
    byKey[category.key] = category
  end

  if (not panel._ddSelectedCategory) or (not byKey[panel._ddSelectedCategory]) then
    panel._ddSelectedCategory = categories[1] and categories[1].key or nil
  end

  for i, category in ipairs(categories) do
    local btn = panel.categoryButtons[i]
    if not btn then
      btn = CreateExpansionHeader(panel.leftPane)
      panel.categoryButtons[i] = btn
      btn:SetScript("OnClick", function(selfBtn)
        panel._ddSelectedCategory = selfBtn._ddCategoryKey
        ns.RefreshCurrencyStatusPanel(frame, true)
      end)
    end
    btn._ddCategoryKey = category.key
    btn:ClearAllPoints()
    btn:SetPoint("TOPLEFT", panel.leftPane, "TOPLEFT", 10, -14 - ((i - 1) * 36))
    btn:SetPoint("TOPRIGHT", panel.leftPane, "TOPRIGHT", -20, -14 - ((i - 1) * 36))
    local active = panel._ddSelectedCategory == category.key
    btn.arrow:SetText("+")
    btn.text:SetText(string.format("%s (%d)", tostring(category.expansion or "Other"), #(category.currencies or {})))
    btn:SetBackdropColor(active and 0.20 or 0.05, active and 0.17 or 0.05, active and 0.12 or 0.05, active and 0.98 or 0.98)
    btn:SetBackdropBorderColor(active and 1.00 or 0.86, active and 0.86 or 0.72, active and 0.24 or 0.46, active and 0.95 or 0.92)
    btn.text:SetTextColor(active and 1.00 or 0.92, active and 0.93 or 0.84, active and 0.62 or 0.68)
    btn.arrow:SetTextColor(active and 1.00 or 0.92, active and 0.93 or 0.84, active and 0.62 or 0.68)
    ns.ScaleCurrencyFontString(btn.text)
    ns.ScaleCurrencyFontString(btn.arrow)
    -- Currency categories do not use the right-side completion text; reclaim
    -- that space so long expansion names can render without early truncation.
    btn.text:ClearAllPoints()
    btn.text:SetPoint("LEFT", btn.arrow, "RIGHT", 6, 0)
    btn.text:SetPoint("RIGHT", btn, "RIGHT", -12, 0)
    if not btn._ddCurrencyCategoryFontSized then
      local fontPath, fontSize, fontFlags = btn.text:GetFont()
      if fontPath and fontSize then
        btn.text:SetFont(fontPath, math.max(9, fontSize * 0.88), fontFlags)
      end
      btn._ddCurrencyCategoryFontSized = true
    end
    btn:Show()
  end
  for i = #categories + 1, #panel.categoryButtons do
    panel.categoryButtons[i]:Hide()
  end

  local selected = byKey[panel._ddSelectedCategory]
  local rows = selected and selected.currencies or {}
  for index, currency in ipairs(rows) do
    local row = panel.rows[index]
    if not row then
      row = CreateFrame("Frame", nil, panel.rowsContent, "BackdropTemplate")
      row:SetSize(786, 46)
      row:SetBackdrop({
        bgFile = UIC.THEME_FLAT,
        edgeFile = UIC.THEME_BORDER,
        edgeSize = 8,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
      })
      row:SetBackdropColor(0.08, 0.07, 0.06, 0.42)
      row:SetBackdropBorderColor(0.46, 0.40, 0.30, 0.38)

      row.icon = row:CreateTexture(nil, "ARTWORK")
      row.icon:SetSize(18 * ns.CurrencyStatus.pageIconScale, 18 * ns.CurrencyStatus.pageIconScale)
      row.icon:SetPoint("LEFT", row, "LEFT", 10, 0)

      row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.nameText:SetPoint("LEFT", row, "LEFT", 40, 0)
      row.nameText:SetWidth(300)
      row.nameText:SetJustifyH("LEFT")
      ns.ScaleCurrencyFontString(row.nameText)

      row.quantityText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.quantityText:SetPoint("LEFT", row, "LEFT", 350, 0)
      row.quantityText:SetWidth(90)
      row.quantityText:SetJustifyH("LEFT")
      row.quantityText:SetTextColor(0.62, 0.86, 1.0)
      ns.ScaleCurrencyFontString(row.quantityText)

      row.actionButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
      row.actionButton:SetSize(168, 24)
      row.actionButton:SetPoint("LEFT", row, "LEFT", 470, 0)
      row.actionButton:SetText("View Category")
      local actionButtonText = row.actionButton:GetFontString()
      if actionButtonText then
        ns.ScaleCurrencyFontString(actionButtonText)
      end
      row.actionButton:SetScript("OnClick", function(selfBtn)
        local resourceName = selfBtn._ddCurrencyName
        if panel._onCurrencySelect and resourceName and resourceName ~= "" then
          panel._onCurrencySelect(resourceName)
        end
      end)

      panel.rows[index] = row
    end

    row:ClearAllPoints()
    row:SetPoint("TOPLEFT", panel.rowsContent, "TOPLEFT", 4, -((index - 1) * 52))
    row.icon:SetTexture(currency.icon or UIC.ICON_LOCATION)
    row.nameText:SetText(tostring(currency.name or "Currency"))
    row.quantityText:SetText(FormatCurrencyAmount(currency.quantity))
    row.actionButton:SetText("View Category")
    row.actionButton._ddCurrencyName = tostring(currency.name or "")
    row:Show()
  end

  for index = #rows + 1, #panel.rows do
    panel.rows[index]:Hide()
  end
  if panel.emptyText then
    panel.emptyText:SetShown(#rows == 0)
  end
  panel.rowsContent:SetHeight(math.max(1, (#rows * 52) + 10))
end

local function ResetPooledButtonState(button)
  if not button then
    return
  end
  if button.SetButtonState then
    pcall(button.SetButtonState, button, "NORMAL")
  end
  if button.UnlockHighlight then
    pcall(button.UnlockHighlight, button)
  end
  if button.hover then
    button.hover:Hide()
  end
end

local function IsSpellKnownForPlayer(spellID)
  if not spellID then
    return false
  end
  local cache = ns._teleportSpellKnownCache
  if type(cache) == "table" and cache._ready then
    return cache[tonumber(spellID) or 0] == true
  end
  if IsSpellKnownOrOverridesKnown and IsSpellKnownOrOverridesKnown(spellID) then
    return true
  end
  if IsSpellKnown and IsSpellKnown(spellID) then
    return true
  end
  if IsPlayerSpell and IsPlayerSpell(spellID) then
    return true
  end
  return false
end

function ns.PrepareTeleportSpellCache()
  if type(ns._teleportSpellKnownCache) == "table" and ns._teleportSpellKnownCache._ready then
    return
  end

  local cache = {}
  if not IsPlayerMage() then
    cache._ready = true
    ns._teleportSpellKnownCache = cache
    return
  end

  local seen = {}
  for _, byFaction in pairs(EXPANSION_TELEPORT_SPELL_IDS or {}) do
    if type(byFaction) == "table" then
      for _, ids in pairs(byFaction) do
        if type(ids) == "table" then
          for _, spellID in ipairs(ids) do
            local numeric = tonumber(spellID)
            if numeric and numeric > 0 and not seen[numeric] then
              seen[numeric] = true
              cache[numeric] = (IsSpellKnownOrOverridesKnown and IsSpellKnownOrOverridesKnown(numeric))
                or (IsSpellKnown and IsSpellKnown(numeric))
                or (IsPlayerSpell and IsPlayerSpell(numeric))
                or false
            end
          end
        end
      end
    end
  end

  cache._ready = true
  ns._teleportSpellKnownCache = cache
end

local function GetPreferredTeleportSpellID(expansionName)
  local map = EXPANSION_TELEPORT_SPELL_IDS[expansionName]
  if not map then
    return nil
  end

  local function selectBestSpellID(ids)
    if type(ids) ~= "table" then
      return nil
    end
    for _, spellID in ipairs(ids) do
      local numeric = tonumber(spellID)
      if numeric and IsSpellKnownForPlayer(numeric) then
        return numeric
      end
    end
    local first = tonumber(ids[1])
    if first and first > 0 then
      return first
    end
    return nil
  end

  local playerFaction = GetPlayerFaction()
  local factionIDs = playerFaction and map[playerFaction] or nil
  local chosen = selectBestSpellID(factionIDs)
  if chosen then
    return chosen
  end

  local neutralIDs = map.Neutral
  chosen = selectBestSpellID(neutralIDs)
  if chosen then
    return chosen
  end

  local opposite = playerFaction == "Alliance" and "Horde" or "Alliance"
  local oppositeIDs = map[opposite]
  chosen = selectBestSpellID(oppositeIDs)
  if chosen then
    return chosen
  end

  return nil
end

local function GetSpellNameByID(spellID)
  if not spellID then
    return nil
  end
  if C_Spell and C_Spell.GetSpellName then
    local name = C_Spell.GetSpellName(spellID)
    if name and name ~= "" then
      return name
    end
  end
  if GetSpellInfo then
    local name = GetSpellInfo(spellID)
    if name and name ~= "" then
      return name
    end
  end
  return nil
end

function ns.IsTeleportNotLearnedPreviewEnabled()
  if not (ns.IsDeveloperToolsEnabled and ns.IsDeveloperToolsEnabled()) then
    return false
  end
  local settings = DecorDropsDB and DecorDropsDB.settings or nil
  return settings and settings.testTeleportNotLearned and true or false
end

function ns.HideTrainerTeleportHint(frame)
  if frame and frame.trainerTeleportHint then
    frame.trainerTeleportHint:Hide()
  end
end

function ns.SetTrainerTeleportUnavailableVisual(frame, spellName)
  if not frame or not frame.trainerWaypointButton then
    return
  end
  local button = frame.trainerWaypointButton
  button:SetText("Teleport")
  button:SetScript("OnClick", nil)
  button:Disable()
  button:EnableMouse(true)
  button:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    GameTooltip:AddLine("|cffff4d4dYou have not learned this spell.|r")
    if spellName and spellName ~= "" then
      GameTooltip:AddLine(string.format("|cffffcc66Required: %s|r", tostring(spellName)))
    end
    GameTooltip:Show()
  end)
  button:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  if frame.trainerTeleportHint then
    frame.trainerTeleportHint:SetText("Spell not learned.")
    frame.trainerTeleportHint:Show()
  end
end

function ns.UpdateTrainerActionButtonLayout(frame)
  if not frame or not frame.trainerWaypointButton then
    return
  end

  -- Waypoint clearing is now global via the top-bar "Clear Waypoints" action.
  local waypointButton = frame.trainerWaypointButton
  waypointButton:ClearAllPoints()
  waypointButton:SetPoint("RIGHT", frame.trainerPanel, "RIGHT", -8, 0)

  if frame.trainerTeleportHint then
    frame.trainerTeleportHint:ClearAllPoints()
    frame.trainerTeleportHint:SetPoint("BOTTOM", waypointButton, "TOP", 0, 10)
  end
end

function ns.GetInstanceCapitalTeleport(instance)
  if not IsPlayerMage() then
    return nil, nil, nil
  end
  local expansionName = ExpansionName(instance)
  if expansionName == nil or expansionName == "" then
    return nil, nil, nil
  end
  local spellID = GetPreferredTeleportSpellID(expansionName)
  if not spellID or not IsSpellKnownForPlayer(spellID) then
    return nil, nil, nil
  end
  return spellID, (GetSpellNameByID(spellID) or "Teleport"), expansionName
end

function ns.EnsureTeleportConfirmDialog()
  if ns._teleportConfirmDialog then
    return ns._teleportConfirmDialog
  end

  local dlg = CreateFrame("Frame", "DecorDropsTeleportConfirmDialog", UIParent, "BackdropTemplate")
  dlg:SetSize(390, 136)
  dlg:SetPoint("CENTER")
  dlg:SetToplevel(true)
  dlg:SetFrameStrata("FULLSCREEN_DIALOG")
  dlg:SetFrameLevel(1200)
  dlg:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 14,
    insets = { left = 3, right = 3, top = 3, bottom = 3 },
  })
  dlg:SetBackdropColor(0.04, 0.04, 0.04, 0.98)
  dlg:SetBackdropBorderColor(0.90, 0.76, 0.46, 0.95)
  dlg:SetScript("OnShow", function(self)
    if ns.PositionPopupOverMainFrame then
      ns.PositionPopupOverMainFrame(self)
    else
      self:ClearAllPoints()
      self:SetPoint("CENTER")
    end
  end)
  dlg:Hide()

  dlg.title = dlg:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  dlg.title:SetPoint("TOP", 0, -14)
  dlg.title:SetTextColor(0.98, 0.88, 0.62)
  dlg.title:SetText("Confirm Teleport")

  dlg.text = dlg:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  dlg.text:SetPoint("TOPLEFT", 16, -42)
  dlg.text:SetPoint("TOPRIGHT", -16, -42)
  dlg.text:SetJustifyH("LEFT")
  dlg.text:SetJustifyV("TOP")
  dlg.text:SetWordWrap(true)
  dlg.text:SetTextColor(0.88, 0.88, 0.88)

  -- Reference-addon style secure cast setup: secure action button + spell attribute.
  dlg.confirmButton = CreateFrame("Button", nil, dlg, "UIPanelButtonTemplate,SecureActionButtonTemplate")
  dlg.confirmButton:SetSize(120, 24)
  dlg.confirmButton:SetPoint("BOTTOMRIGHT", -14, 10)
  dlg.confirmButton:SetText("Teleport")
  dlg.confirmButton:RegisterForClicks("AnyUp", "AnyDown")
  dlg.confirmButton:SetScript("PostClick", function()
    dlg:Hide()
    if ns.MainFrame and ns.MainFrame.IsShown and ns.MainFrame:IsShown() then
      ns.MainFrame:Hide()
    end
  end)

  dlg.cancelButton = CreateFrame("Button", nil, dlg, "UIPanelButtonTemplate")
  dlg.cancelButton:SetSize(120, 24)
  dlg.cancelButton:SetPoint("RIGHT", dlg.confirmButton, "LEFT", -8, 0)
  dlg.cancelButton:SetText("Cancel")
  dlg.cancelButton:SetScript("OnClick", function()
    dlg:Hide()
  end)

  ns._teleportConfirmDialog = dlg
  return dlg
end

function ns.ShowTeleportConfirmDialog(expansionName, spellID, spellName)
  local numericSpellID = tonumber(spellID)
  if not numericSpellID or numericSpellID <= 0 then
    print("|cffff5555DecorDrops: Teleport is unavailable right now.|r")
    return
  end
  if InCombatLockdown and InCombatLockdown() then
    print("|cffffcc66DecorDrops|r Teleport confirmation is unavailable during combat. Try again after combat.")
    return
  end

  local dlg = ns.EnsureTeleportConfirmDialog()
  local resolvedName = tostring(spellName or GetSpellNameByID(numericSpellID) or ("Spell " .. tostring(numericSpellID)))
  dlg.text:SetText(string.format(
    "This will cast |cff66ccff%s|r and teleport you to the |cffffffcc%s|r capital city, not directly to the dungeon/raid.",
    resolvedName,
    tostring(expansionName or "expansion")
  ))

  if dlg.confirmButton and dlg.confirmButton.SetAttribute then
    dlg.confirmButton:SetAttribute("type", nil)
    dlg.confirmButton:SetAttribute("spell", nil)
    dlg.confirmButton:SetAttribute("type", "spell")
    dlg.confirmButton:SetAttribute("spell", numericSpellID)
  end

  if dlg.Raise then
    dlg:Raise()
  end
  dlg:Show()
end

function ns._HideResourceButtons(row)
  if not row or not row.resourceButtons then
    return
  end
  for _, btn in ipairs(row.resourceButtons) do
    btn.itemID = nil
    btn.quantity = nil
    btn.displayName = nil
    btn.tooltipText = nil
    btn:Hide()
  end
end

function ns._SetResourceButtons(row, resources, forcedIconSize)
  if not row or not row.resourceButtons then
    return false
  end

  local entries = NormalizeResourceEntries(resources)
  if #entries == 0 then
    ns._HideResourceButtons(row)
    return false
  end

  local shown = math.min(#entries, #row.resourceButtons)
  local iconSize = tonumber(forcedIconSize) or UIC.RESOURCE_ICON_SIZE
  local gap = UIC.RESOURCE_ICON_GAP
  local slotWidths = {}
  local totalWidth = 0

  for i, btn in ipairs(row.resourceButtons) do
    if i <= shown then
      local entry = entries[i]
      local icon = nil
      local entrySize = tonumber(entry.iconSize) or iconSize
      if entry.itemID and C_Item and C_Item.GetItemIconByID then
        icon = C_Item.GetItemIconByID(entry.itemID)
      end
      if not icon then
        icon = entry.icon
      end
      btn.itemID = entry.itemID
      btn.quantity = entry.quantity
      btn.displayName = entry.name
      btn.tooltipText = entry.tooltip
      btn:SetSize(entrySize, entrySize)
      btn.icon:SetTexture(icon or 134400)
      if entry.quantityLabel and entry.quantityLabel ~= "" then
        btn.count:SetText(entry.quantityLabel)
      else
        btn.count:SetText("x" .. tostring(entry.quantity or 1))
      end
      local labelWidth = math.ceil((btn.count.GetStringWidth and btn.count:GetStringWidth()) or 0)
      local slotWidth = math.max(entrySize, labelWidth + 8)
      slotWidths[i] = slotWidth
      totalWidth = totalWidth + slotWidth
      btn:Show()
    else
      btn.itemID = nil
      btn.quantity = nil
      btn:Hide()
    end
  end

  totalWidth = totalWidth + ((shown - 1) * gap)
  local startX = math.floor((ITEM_LAYOUT.source.width - totalWidth) * 0.5)
  local cursorX = startX
  for i = 1, shown do
    local btn = row.resourceButtons[i]
    local entry = entries[i]
    local entrySize = tonumber(entry.iconSize) or iconSize
    local slotWidth = slotWidths[i] or entrySize
    btn:ClearAllPoints()
    btn:SetPoint("LEFT", row.resourceContainer, "LEFT", cursorX + math.floor((slotWidth - entrySize) * 0.5), 1)
    cursorX = cursorX + slotWidth + gap
  end

  return true
end

local function BuildItemRow(parent)
  local row = CreateFrame("Frame", nil, parent, "BackdropTemplate")
  row:SetSize(ITEM_LAYOUT.rowWidth, ITEM_LAYOUT.rowHeight)
  row:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  row:SetBackdropColor(0.76, 0.62, 0.46, 0.93)
  row:SetBackdropBorderColor(0.88, 0.74, 0.48, 0.90)

  row.itemHitbox = CreateFrame("Button", nil, row)
  row.itemHitbox:SetSize(ITEM_LAYOUT.item.width, ITEM_LAYOUT.rowHeight - 8)
  row.itemHitbox:SetPoint("LEFT", ITEM_LAYOUT.item.x, 0)

  row.collectedIcon = row.itemHitbox:CreateTexture(nil, "OVERLAY", nil, 7)
  row.collectedIcon:SetSize(16, 16)
  row.collectedIcon:SetPoint("LEFT", 6, 0)

  row.itemIcon = row.itemHitbox:CreateTexture(nil, "ARTWORK")
  row.itemIcon:SetSize(48, 48)
  row.itemIcon:SetPoint("LEFT", 28, 0)

  row.itemText = row.itemHitbox:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  row.itemText:SetPoint("LEFT", row.itemHitbox, "LEFT", 86, -2)
  row.itemText:SetWidth(ITEM_LAYOUT.item.width - 122)
  row.itemText:SetJustifyH("LEFT")
  row.itemText:SetWordWrap(true)
  if row.itemText.SetMaxLines then
    row.itemText:SetMaxLines(2)
  end

  row.questLinkButton = CreateFrame("Button", nil, row.itemHitbox)
  row.questLinkButton:SetSize(16, 16)
  row.questLinkButton:SetPoint("LEFT", row.itemText, "RIGHT", 4, 0)
  row.questLinkButton:SetHitRectInsets(-3, -3, -3, -3)
  row.questLinkButton:SetFrameLevel(row.itemHitbox:GetFrameLevel() + 20)
  row.questLinkButton:EnableMouse(true)
  if row.questLinkButton.RegisterForClicks then
    row.questLinkButton:RegisterForClicks("LeftButtonDown", "LeftButtonUp")
  end
  if row.questLinkButton.SetPropagateMouseClicks then
    row.questLinkButton:SetPropagateMouseClicks(false)
  end
  row.questLinkButton:SetNormalTexture(UIC.ICON_QUEST)
  row.questLinkButton:SetPushedTexture(UIC.ICON_QUEST)
  row.questLinkButton:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
  ns.SetQuestLinkButtonVisual(row.questLinkButton, "quest")
  row.questLinkButton:Hide()
  row.questLinkButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    if self._ddLinkMode == "promotion" then
      GameTooltip:AddLine("Promotional Source")
      GameTooltip:AddLine("Open the Promotions page for this item.", 0.90, 0.82, 0.64, true)
    else
      GameTooltip:AddLine("Quest Unlock")
      GameTooltip:AddLine("Open the Quest page for this item's unlock source.", 0.90, 0.82, 0.64, true)
    end
    GameTooltip:Show()
  end)
  row.questLinkButton:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  row.sourceText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  row.sourceText:SetPoint("CENTER", row, "LEFT", ITEM_LAYOUT.source.x + (ITEM_LAYOUT.source.width / 2), 0)
  row.sourceText:SetWidth(ITEM_LAYOUT.source.width)
  row.sourceText:SetJustifyH("CENTER")
  row.sourceText:SetWordWrap(false)
  if row.sourceText.SetMaxLines then
    row.sourceText:SetMaxLines(1)
  end

  row.resourceContainer = CreateFrame("Frame", nil, row)
  row.resourceContainer:SetSize(ITEM_LAYOUT.source.width, ITEM_LAYOUT.rowHeight - 8)
  row.resourceContainer:SetPoint("CENTER", row, "LEFT", ITEM_LAYOUT.source.x + (ITEM_LAYOUT.source.width / 2), 3)

  row.resourceButtons = {}
  for i = 1, 5 do
    local btn = CreateFrame("Button", nil, row.resourceContainer)
    btn:SetSize(UIC.RESOURCE_ICON_SIZE, UIC.RESOURCE_ICON_SIZE)
    btn:Hide()
    btn.icon = btn:CreateTexture(nil, "ARTWORK")
    btn.icon:SetAllPoints()
    btn.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
    btn.count = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    btn.count:SetPoint("TOP", btn, "BOTTOM", 0, UIC.RESOURCE_TEXT_Y_OFFSET)
    btn.count:SetJustifyH("CENTER")
    btn.count:SetTextColor(1.00, 0.92, 0.70)
    btn:SetScript("OnEnter", function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      if self.itemID then
        if GameTooltip.SetItemByID then
          GameTooltip:SetItemByID(self.itemID)
        else
          GameTooltip:SetHyperlink("item:" .. tostring(self.itemID))
        end
      else
        GameTooltip:AddLine(self.displayName or "Required Cost")
        if self.tooltipText and self.tooltipText ~= "" then
          GameTooltip:AddLine(self.tooltipText, 0.80, 0.80, 0.80, true)
        end
      end
      if self.quantity and tonumber(self.quantity) then
        GameTooltip:AddLine(string.format("Required: x%d", tonumber(self.quantity)), 0.90, 0.82, 0.64)
      end
      GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function()
      GameTooltip_Hide()
    end)
    row.resourceButtons[#row.resourceButtons + 1] = btn
  end

  row.rateText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  row.rateText:SetPoint("CENTER", row, "LEFT", ITEM_LAYOUT.rate.x + (ITEM_LAYOUT.rate.width / 2), 0)
  row.rateText:SetWidth(ITEM_LAYOUT.rate.width)
  row.rateText:SetJustifyH("CENTER")
  row.rateText:SetWordWrap(false)
  if row.rateText.SetMaxLines then
    row.rateText:SetMaxLines(1)
  end

  row.viewButton = CreateFrame("Button", nil, row)
  row.viewButton:SetSize(ns._VIEW_BUTTON_WIDTH, ns._VIEW_BUTTON_HEIGHT)
  row.viewButton:SetPoint("LEFT", ITEM_LAYOUT.view.x, 0)
  SetViewButtonVisual(row.viewButton)
  row.viewButton:SetScript("OnEnter", function(self)
    SetViewButtonHoverVisual(self, true)
  end)
  row.viewButton:SetScript("OnLeave", function(self)
    SetViewButtonHoverVisual(self, false)
  end)

  row.wpButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
  row.wpButton:SetSize(ITEM_LAYOUT.waypoint.width, 24)
  row.wpButton:SetPoint("LEFT", ITEM_LAYOUT.waypoint.x, 0)
  SetGoButtonVisual(row.wpButton, false)

  row.trackCheck = CreateFrame("CheckButton", nil, row, "UICheckButtonTemplate")
  row.trackCheck:SetSize(18, 18)
  row.trackCheck:SetPoint("CENTER", row, "LEFT", ITEM_LAYOUT.track.x + (ITEM_LAYOUT.track.width / 2), 0)
  do
    local normal = row.trackCheck.GetNormalTexture and row.trackCheck:GetNormalTexture() or nil
    if normal then
      normal:SetVertexColor(0.96, 0.88, 0.58, 0.95)
      normal:SetAlpha(0.95)
    end
    local checked = row.trackCheck.GetCheckedTexture and row.trackCheck:GetCheckedTexture() or nil
    if checked then
      checked:SetVertexColor(1.00, 0.96, 0.70, 1.00)
      checked:SetAlpha(1.00)
    end
    local highlight = row.trackCheck.GetHighlightTexture and row.trackCheck:GetHighlightTexture() or nil
    if highlight then
      highlight:SetVertexColor(1.00, 0.92, 0.45, 0.32)
      highlight:SetAlpha(0.80)
    end
  end
  row.trackCheck:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    GameTooltip:AddLine("Track Item")
    GameTooltip:AddLine("Add or remove this decoration from Tracker.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  row.trackCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  row.recipeText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  row.recipeText:SetPoint("CENTER", row, "LEFT", ITEM_LAYOUT.waypoint.x + (ITEM_LAYOUT.waypoint.width / 2), 0)
  row.recipeText:SetWidth(ITEM_LAYOUT.waypoint.width)
  row.recipeText:SetJustifyH("CENTER")
  row.recipeText:Hide()

  return row
end

function ns.FindSelectedEntry(frame)
  local wantedKey = DecorDropsDB and DecorDropsDB.selectedKey or nil
  if type(wantedKey) ~= "string" or wantedKey == "" then
    return nil
  end
  for _, entry in ipairs(frame.filtered or {}) do
    if entry.key == wantedKey then
      return entry
    end
  end
  return nil
end

function ns.EnsureSelection(frame)
  if #frame.filtered == 0 then
    DecorDropsDB.selectedKey = nil
    return
  end

  local selected = ns.FindSelectedEntry(frame)
  if not selected then
    DecorDropsDB.selectedKey = nil
  end
end

RememberSelectedKeyForMode = function(frame, mode, key)
  if not frame then
    return
  end
  frame._sessionSelectedKeyByMode = frame._sessionSelectedKeyByMode or {}
  frame._sessionSelectedKeyByMode[tostring(mode or "instances")] = key
end

ApplySelectedKeyForCurrentMode = function(frame, key)
  if not frame then
    return
  end
  local mode = frame.viewMode or "instances"
  local normalizedKey = (type(key) == "string" and key ~= "") and key or nil
  DecorDropsDB.selectedKey = normalizedKey
  RememberSelectedKeyForMode(frame, mode, normalizedKey)
end

function ns.BuildGroupedLeftRows(frame)
  if frame.viewMode == "tracker" then
    local instanceEntries = {}
    local professionEntries = {}
    local sourceEntriesByMode = {}
    local sourceModeOrder = ns._SOURCE_MODE_ORDER

    for _, entry in ipairs(frame.filtered or {}) do
      if entry.trackerKind == "professions" then
        professionEntries[#professionEntries + 1] = entry
      elseif ns.IsSourceMode(entry.trackerKind) then
        local sourceMode = tostring(entry.trackerKind or "")
        sourceEntriesByMode[sourceMode] = sourceEntriesByMode[sourceMode] or {}
        sourceEntriesByMode[sourceMode][#sourceEntriesByMode[sourceMode] + 1] = entry
      else
        instanceEntries[#instanceEntries + 1] = entry
      end
    end

    local rows = {}
    local trackerCollapsed = DecorDropsDB.collapsedTrackerSections or {}
    local instancesKey = "instances"
    local professionsKey = "professions"

    if #instanceEntries > 0 then
      rows[#rows + 1] = { kind = "trackerSection", title = "Raids / Dungeons Tracking", sectionKey = instancesKey }
      if not trackerCollapsed[instancesKey] then
        for _, entry in ipairs(instanceEntries) do
          rows[#rows + 1] = { kind = "trackerInstance", entry = entry }
        end
      end
    end
    if #professionEntries > 0 then
      rows[#rows + 1] = { kind = "trackerSection", title = "Crafting Tracking", sectionKey = professionsKey }
      if not trackerCollapsed[professionsKey] then
        for _, entry in ipairs(professionEntries) do
          rows[#rows + 1] = { kind = "trackerProfession", entry = entry }
        end
      end
    end

    for _, sourceMode in ipairs(sourceModeOrder) do
      local sourceEntries = sourceEntriesByMode[sourceMode] or {}
      if #sourceEntries > 0 then
        local sourceSectionKey = "source:" .. sourceMode
        rows[#rows + 1] = {
          kind = "trackerSection",
          title = string.format("%s Tracking", ns.GetSourceModeLabel(sourceMode)),
          sectionKey = sourceSectionKey,
        }
        if not trackerCollapsed[sourceSectionKey] then
          local groups = {}
          local order = {}
          for _, entry in ipairs(sourceEntries) do
            local expansion = ns._NormalizeSourceExpansionLabel(entry.expansion, entry.zoneName)
            local expansionKey = ns._BuildCategoryKey(expansion, "Other")
            if not groups[expansionKey] then
              groups[expansionKey] = { expansion = expansion, zones = {}, zoneOrder = {}, seen = {} }
              order[#order + 1] = expansionKey
            end
            local zoneName = ns._NormalizeSourceZoneLabel(entry.zoneName)
            local promoteUnknownZone = (sourceMode == "promotion") and IsUnknownSourceLocationLabel(zoneName)
            local zoneKey = promoteUnknownZone and "__promotion_main__" or ns._BuildCategoryKey(zoneName, "Unknown Zone")
            local zoneBucket = groups[expansionKey].zones[zoneKey]
            if not zoneBucket then
              zoneBucket = { zoneName = promoteUnknownZone and "" or zoneName, entries = {} }
              groups[expansionKey].zones[zoneKey] = zoneBucket
              groups[expansionKey].zoneOrder[#groups[expansionKey].zoneOrder + 1] = zoneKey
            end
            local entrySig = tostring(zoneKey) .. "|" .. tostring(entry.key or "")
            if not groups[expansionKey].seen[entrySig] then
              groups[expansionKey].seen[entrySig] = true
              zoneBucket.entries[#zoneBucket.entries + 1] = entry
            end
          end

          table.sort(order, function(a, b)
            return ns.SourceExpansionSortLess(groups[a].expansion, groups[b].expansion)
          end)

          for _, expansionKey in ipairs(order) do
            local expansion = groups[expansionKey].expansion
            local expansionCollapseKey = string.lower("tracker|" .. sourceMode .. "|" .. tostring(expansion or "Other"))
            rows[#rows + 1] = {
              kind = "header",
              expansion = expansion,
              expansionCollapseKey = expansionCollapseKey,
              isSourceMode = true,
              isCurrentSeason = expansion == ns._NEIGHBOURHOODS_GROUP,
              trackerSubHeader = true,
            }
            if not CollapseState.IsExpansionCollapsed(frame, expansionCollapseKey) then
              table.sort(groups[expansionKey].zoneOrder, function(a, b)
                local left = groups[expansionKey].zones[a] and groups[expansionKey].zones[a].zoneName or ""
                local right = groups[expansionKey].zones[b] and groups[expansionKey].zones[b].zoneName or ""
                return left < right
              end)
              for _, zoneLabelKey in ipairs(groups[expansionKey].zoneOrder) do
                local zoneName = groups[expansionKey].zones[zoneLabelKey].zoneName
                local zoneCollapsed = false
                if zoneName ~= "" then
                  local uiZoneKey = BuildNewSourceZoneKey(expansion, zoneName)
                  rows[#rows + 1] = {
                    kind = "newSourceZone",
                    expansion = expansion,
                    expansionCollapseKey = expansionCollapseKey,
                    zoneName = zoneName,
                    zoneKey = uiZoneKey,
                    trackerSubHeader = true,
                  }
                  zoneCollapsed = CollapseState.IsSourceZoneCollapsed(frame, expansionCollapseKey, zoneName)
                end
                if not zoneCollapsed then
                  local zoneEntries = groups[expansionKey].zones[zoneLabelKey] and groups[expansionKey].zones[zoneLabelKey].entries or {}
                  zoneEntries = ns._MergeSourceEntriesForDisplay(zoneEntries)
                  table.sort(zoneEntries, function(a, b)
                    return (a.label or "") < (b.label or "")
                  end)
                  for _, entry in ipairs(zoneEntries) do
                    rows[#rows + 1] = {
                      kind = "source",
                      entry = entry,
                      zoneName = zoneName,
                      expansion = expansion,
                      trackerSubHeader = true,
                    }
                  end
                end
              end
            end
          end
        end
      end
    end

    return rows
  end

  if frame.viewMode == "professions" then
    local groups = {}
    local order = {}
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = ns._NormalizeCategoryLabel(entry.profession and entry.profession.expansion, "Other")
      local expansionKey = ns._BuildCategoryKey(expansion, "Other")
      if not groups[expansionKey] then
        groups[expansionKey] = { expansion = expansion, entries = {} }
        order[#order + 1] = expansionKey
      end
      groups[expansionKey].entries[#groups[expansionKey].entries + 1] = entry
    end

    table.sort(order, function(a, b)
      local ia = EXPANSION_ORDER_INDEX[groups[a].expansion] or 0
      local ib = EXPANSION_ORDER_INDEX[groups[b].expansion] or 0
      if ia ~= ib then
        return ia > ib
      end
      return groups[a].expansion < groups[b].expansion
    end)

    local rows = {}
    for _, expansionKey in ipairs(order) do
      local expansion = groups[expansionKey].expansion
      rows[#rows + 1] = { kind = "header", expansion = expansion, isProfessionMode = true }
      if not CollapseState.IsExpansionCollapsed(frame, expansion) then
        table.sort(groups[expansionKey].entries, function(a, b)
          return (a.profession.profession or "") < (b.profession.profession or "")
        end)
        for _, entry in ipairs(groups[expansionKey].entries) do
          rows[#rows + 1] = { kind = "profession", entry = entry }
        end
      end
    end
    return rows
  end

  if ns.IsSourceMode(frame.viewMode) then
    if frame.viewMode == "endeavor" then
      local groups = {}
      local order = {}
      for _, entry in ipairs(frame.filtered or {}) do
        local zoneName = ns._NormalizeSourceZoneLabel(entry.zoneName)
        local zoneKey = ns._BuildCategoryKey(zoneName, "Unknown Zone")
        if not groups[zoneKey] then
          groups[zoneKey] = { zoneName = zoneName, entries = {}, seen = {} }
          order[#order + 1] = zoneKey
        end
        local entrySig = tostring(entry.key or "")
        if not groups[zoneKey].seen[entrySig] then
          groups[zoneKey].seen[entrySig] = true
          groups[zoneKey].entries[#groups[zoneKey].entries + 1] = entry
        end
      end

      table.sort(order, function(a, b)
        return groups[a].zoneName < groups[b].zoneName
      end)

      local rows = {}
      for _, zoneKey in ipairs(order) do
        local zoneName = groups[zoneKey].zoneName
        rows[#rows + 1] = {
          kind = "header",
          expansion = zoneName,
          isSourceMode = true,
          isCurrentSeason = true,
        }
        if not CollapseState.IsExpansionCollapsed(frame, zoneName) then
          local zoneEntries = groups[zoneKey].entries or {}
          zoneEntries = ns._MergeSourceEntriesForDisplay(zoneEntries)
          table.sort(zoneEntries, function(a, b)
            return (a.label or "") < (b.label or "")
          end)
          for _, entry in ipairs(zoneEntries) do
            rows[#rows + 1] = { kind = "source", entry = entry, zoneName = zoneName, expansion = zoneName }
          end
        end
      end
      return rows
    end

    local groups = {}
    local order = {}
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = ns._NormalizeSourceExpansionLabel(entry.expansion, entry.zoneName)
      local expansionKey = ns._BuildCategoryKey(expansion, "Other")
      if not groups[expansionKey] then
        groups[expansionKey] = { expansion = expansion, zones = {}, zoneOrder = {}, seen = {} }
        order[#order + 1] = expansionKey
      end
      local zoneName = ns._NormalizeSourceZoneLabel(entry.zoneName)
      local promoteUnknownZone = (frame.viewMode == "promotion") and IsUnknownSourceLocationLabel(zoneName)
      local zoneKey = promoteUnknownZone and "__promotion_main__" or ns._BuildCategoryKey(zoneName, "Unknown Zone")
      local zoneBucket = groups[expansionKey].zones[zoneKey]
      if not zoneBucket then
        zoneBucket = { zoneName = promoteUnknownZone and "" or zoneName, entries = {} }
        groups[expansionKey].zones[zoneKey] = zoneBucket
        groups[expansionKey].zoneOrder[#groups[expansionKey].zoneOrder + 1] = zoneKey
      end
      local entrySig = tostring(zoneKey) .. "|" .. tostring(entry.key or "")
      if not groups[expansionKey].seen[entrySig] then
        groups[expansionKey].seen[entrySig] = true
        zoneBucket.entries[#zoneBucket.entries + 1] = entry
      end
    end

    table.sort(order, function(a, b)
      return ns.SourceExpansionSortLess(groups[a].expansion, groups[b].expansion)
    end)

    local rows = {}
    for _, expansionKey in ipairs(order) do
      local expansion = groups[expansionKey].expansion
      rows[#rows + 1] = {
        kind = "header",
        expansion = expansion,
        isSourceMode = true,
        isCurrentSeason = expansion == ns._NEIGHBOURHOODS_GROUP,
      }
      if not CollapseState.IsExpansionCollapsed(frame, expansion) then
        table.sort(groups[expansionKey].zoneOrder, function(a, b)
          local left = groups[expansionKey].zones[a] and groups[expansionKey].zones[a].zoneName or ""
          local right = groups[expansionKey].zones[b] and groups[expansionKey].zones[b].zoneName or ""
          return left < right
        end)
        for _, zoneLabelKey in ipairs(groups[expansionKey].zoneOrder) do
          local zoneName = groups[expansionKey].zones[zoneLabelKey].zoneName
          local zoneCollapsed = false
          if zoneName ~= "" then
            local zoneKey = BuildNewSourceZoneKey(expansion, zoneName)
            rows[#rows + 1] = {
              kind = "newSourceZone",
              expansion = expansion,
              zoneName = zoneName,
              zoneKey = zoneKey,
            }
            zoneCollapsed = CollapseState.IsSourceZoneCollapsed(frame, expansion, zoneName)
          end
          if not zoneCollapsed then
            local zoneEntries = groups[expansionKey].zones[zoneLabelKey] and groups[expansionKey].zones[zoneLabelKey].entries or {}
            zoneEntries = ns._MergeSourceEntriesForDisplay(zoneEntries)
            table.sort(zoneEntries, function(a, b)
              return (a.label or "") < (b.label or "")
            end)
            for _, entry in ipairs(zoneEntries) do
              rows[#rows + 1] = { kind = "source", entry = entry, zoneName = zoneName, expansion = expansion }
            end
          end
        end
      end
    end
    return rows
  end

  local groups = {}
  local order = {}
  local currentSeasonLookup = {}
  local currentSeasonEntries = {}
  local currentSeasonSeen = {}

  if C_ChallengeMode and C_ChallengeMode.GetMapTable and C_ChallengeMode.GetMapUIInfo then
    local mapTable = C_ChallengeMode.GetMapTable() or {}
    for _, challengeMapID in ipairs(mapTable) do
      local mapName = C_ChallengeMode.GetMapUIInfo(challengeMapID)
      if mapName and mapName ~= "" then
        currentSeasonLookup[SafeLower(mapName)] = true
      end
    end
  end

  for _, entry in ipairs(frame.filtered) do
    local expansion = ExpansionName(entry.instance)
    if not groups[expansion] then
      groups[expansion] = {}
      order[#order + 1] = expansion
    end
    groups[expansion][#groups[expansion] + 1] = entry

    if (expansion == UIC.CURRENT_SEASON_GROUP or currentSeasonLookup[SafeLower(entry.instance.name)])
      and not currentSeasonSeen[entry.key] then
      currentSeasonEntries[#currentSeasonEntries + 1] = entry
      currentSeasonSeen[entry.key] = true
    end
  end

  table.sort(order, function(a, b)
    local ia = EXPANSION_ORDER_INDEX[a] or 0
    local ib = EXPANSION_ORDER_INDEX[b] or 0
    if ia ~= ib then
      return ia > ib
    end
    return a < b
  end)
  for _, expansion in ipairs(order) do
    local seenInGroup = {}
    local deduped = {}
    for _, entry in ipairs(groups[expansion]) do
      local sig = SafeLower((entry.instance.type or "") .. "|" .. (entry.instance.name or ""))
      if not seenInGroup[sig] then
        deduped[#deduped + 1] = entry
        seenInGroup[sig] = true
      end
    end
    groups[expansion] = deduped

    table.sort(groups[expansion], function(a, b)
      if a.instance.type ~= b.instance.type then
        if a.instance.type == "Raid" then
          return true
        end
        if b.instance.type == "Raid" then
          return false
        end
      end
      return a.instance.name < b.instance.name
    end)
  end

  local rows = {}
  if #currentSeasonEntries > 0 then
    local seenSeason = {}
    local seasonDeduped = {}
    for _, entry in ipairs(currentSeasonEntries) do
      local sig = SafeLower((entry.instance.type or "") .. "|" .. (entry.instance.name or ""))
      if not seenSeason[sig] then
        seasonDeduped[#seasonDeduped + 1] = entry
        seenSeason[sig] = true
      end
    end
    currentSeasonEntries = seasonDeduped

    table.sort(currentSeasonEntries, function(a, b)
      if a.instance.type ~= b.instance.type then
        if a.instance.type == "Raid" then
          return true
        end
        if b.instance.type == "Raid" then
          return false
        end
      end
      return a.instance.name < b.instance.name
    end)

    rows[#rows + 1] = { kind = "header", expansion = UIC.CURRENT_SEASON_GROUP, isCurrentSeason = true }
    if not CollapseState.IsExpansionCollapsed(frame, UIC.CURRENT_SEASON_GROUP) then
      for _, entry in ipairs(currentSeasonEntries) do
        rows[#rows + 1] = { kind = "instance", entry = entry, inCurrentSeason = true }
      end
    end
  end

  for _, expansion in ipairs(order) do
    if expansion ~= UIC.CURRENT_SEASON_GROUP then
    rows[#rows + 1] = { kind = "header", expansion = expansion }
    if not CollapseState.IsExpansionCollapsed(frame, expansion) then
      for _, entry in ipairs(groups[expansion]) do
        rows[#rows + 1] = { kind = "instance", entry = entry }
      end
    end
    end
  end

  return rows
end

local function FormatCompletionPercentText(value)
  local pct = tonumber(value) or 0
  if pct < 0 then
    pct = 0
  elseif pct > 100 then
    pct = 100
  end
  local roundedInt = math.floor(pct + 0.5)
  if math.abs(pct - roundedInt) < 0.0001 then
    return string.format("%d%%", roundedInt)
  end
  return string.format("%.1f%%", pct)
end

function ns.RefreshInstanceList(frame)
  local content = frame.instanceListContent
  local selectedKey = DecorDropsDB.selectedKey
  local rows = ns.BuildGroupedLeftRows(frame)
  local listWidth = UIC.LEFT_LIST_WIDTH
  local sourceCompletionByZoneKey, sourceCompletionByExpansion, sourceCompletionByEntryKey = {}, {}, {}
  local sourcePercentByZoneKey, sourcePercentByExpansion, sourcePercentByEntryKey = {}, {}, {}
  local sourceCompletionByHeaderKey, sourcePercentByHeaderKey = {}, {}
  local instanceCompletionByEntryKey, instanceCompletionByExpansion = {}, {}
  local instancePercentByEntryKey, instancePercentByExpansion = {}, {}
  local professionCompletionByEntryKey, professionCompletionByExpansion = {}, {}
  local professionPercentByEntryKey, professionPercentByExpansion = {}, {}
  local trackerSectionPercentByKey = {}
  local showCompletionPercent = frame.showCompletionPercent and frame.viewMode ~= "promotion"
  do
    local isSourceLikeView = ns.IsSourceMode(frame.viewMode)
    local isInstanceMode = frame.viewMode == "instances"
    local isProfessionMode = frame.viewMode == "professions"
    local isTrackerMode = frame.viewMode == "tracker"
    if isSourceLikeView then
      local cached = frame._sourceCompletionCache
      if cached and cached.filteredRef == frame.filtered then
        sourceCompletionByZoneKey = cached.byZoneKey or {}
        sourceCompletionByExpansion = cached.byExpansion or {}
        sourceCompletionByEntryKey = cached.byEntryKey or {}
        sourcePercentByZoneKey = cached.percentByZoneKey or {}
        sourcePercentByExpansion = cached.percentByExpansion or {}
        sourcePercentByEntryKey = cached.percentByEntryKey or {}
      else
        local sourceEntries = {}
        for _, entry in ipairs(frame.filtered or {}) do
          if ns.IsSourceMode(frame.viewMode) then
            sourceEntries[#sourceEntries + 1] = entry
          end
        end
        sourceCompletionByZoneKey, sourceCompletionByExpansion, sourceCompletionByEntryKey, sourcePercentByZoneKey, sourcePercentByExpansion, sourcePercentByEntryKey = BuildSourceCompletionMaps(sourceEntries)
        frame._sourceCompletionCache = {
          filteredRef = frame.filtered,
          byZoneKey = sourceCompletionByZoneKey,
          byExpansion = sourceCompletionByExpansion,
          byEntryKey = sourceCompletionByEntryKey,
          percentByZoneKey = sourcePercentByZoneKey,
          percentByExpansion = sourcePercentByExpansion,
          percentByEntryKey = sourcePercentByEntryKey,
        }
      end
      if frame.viewMode == "endeavor" then
        local headerTotals = {}
        for _, entry in ipairs(frame.filtered or {}) do
          local headerKey = tostring(ns._NormalizeSourceZoneLabel(entry and entry.zoneName) or "Unknown Zone")
          local doneRows, totalRows = BuildEntryCompletionSummary(entry)
          headerTotals[headerKey] = headerTotals[headerKey] or { done = 0, total = 0 }
          headerTotals[headerKey].done = headerTotals[headerKey].done + doneRows
          headerTotals[headerKey].total = headerTotals[headerKey].total + totalRows
        end
        for headerKey, totals in pairs(headerTotals) do
          local total = tonumber(totals.total) or 0
          local done = tonumber(totals.done) or 0
          sourceCompletionByHeaderKey[headerKey] = total > 0 and done >= total
          sourcePercentByHeaderKey[headerKey] = total > 0 and (math.ceil(((done / total) * 100) * 10) / 10) or 0
        end
      end
    elseif isInstanceMode then
      local cached = frame._instanceCompletionCache
      if cached and cached.filteredRef == frame.filtered then
        instanceCompletionByEntryKey = cached.byEntryKey or {}
        instanceCompletionByExpansion = cached.byExpansion or {}
        instancePercentByEntryKey = cached.percentByEntryKey or {}
        instancePercentByExpansion = cached.percentByExpansion or {}
      else
        instanceCompletionByEntryKey, instanceCompletionByExpansion, instancePercentByEntryKey, instancePercentByExpansion = BuildInstanceCompletionMaps(frame.filtered or {})
        frame._instanceCompletionCache = {
          filteredRef = frame.filtered,
          byEntryKey = instanceCompletionByEntryKey,
          byExpansion = instanceCompletionByExpansion,
          percentByEntryKey = instancePercentByEntryKey,
          percentByExpansion = instancePercentByExpansion,
        }
      end
    elseif isProfessionMode then
      local cached = frame._professionCompletionCache
      if cached and cached.filteredRef == frame.filtered then
        professionCompletionByEntryKey = cached.byEntryKey or {}
        professionCompletionByExpansion = cached.byExpansion or {}
        professionPercentByEntryKey = cached.percentByEntryKey or {}
        professionPercentByExpansion = cached.percentByExpansion or {}
      else
        professionCompletionByEntryKey, professionCompletionByExpansion, professionPercentByEntryKey, professionPercentByExpansion = BuildProfessionCompletionMaps(frame.filtered or {})
        frame._professionCompletionCache = {
          filteredRef = frame.filtered,
          byEntryKey = professionCompletionByEntryKey,
          byExpansion = professionCompletionByExpansion,
          percentByEntryKey = professionPercentByEntryKey,
          percentByExpansion = professionPercentByExpansion,
        }
      end
    elseif isTrackerMode then
      local cached = frame._trackerCompletionCache
      if cached and cached.filteredRef == frame.filtered then
        sourceCompletionByZoneKey = cached.sourceByZoneKey or {}
        sourceCompletionByExpansion = cached.sourceByExpansion or {}
        sourceCompletionByEntryKey = cached.sourceByEntryKey or {}
        sourcePercentByZoneKey = cached.sourcePercentByZoneKey or {}
        sourcePercentByExpansion = cached.sourcePercentByExpansion or {}
        sourcePercentByEntryKey = cached.sourcePercentByEntryKey or {}
        instanceCompletionByEntryKey = cached.instanceByEntryKey or {}
        instanceCompletionByExpansion = cached.instanceByExpansion or {}
        instancePercentByEntryKey = cached.instancePercentByEntryKey or {}
        instancePercentByExpansion = cached.instancePercentByExpansion or {}
        professionCompletionByEntryKey = cached.professionByEntryKey or {}
        professionCompletionByExpansion = cached.professionByExpansion or {}
        professionPercentByEntryKey = cached.professionPercentByEntryKey or {}
        professionPercentByExpansion = cached.professionPercentByExpansion or {}
        trackerSectionPercentByKey = cached.sectionPercentByKey or {}
      else
        local trackerInstanceEntries = {}
        local trackerProfessionEntries = {}
        local trackerSourceEntries = {}
        local trackerSourceEntriesByMode = {}

        for _, entry in ipairs(frame.filtered or {}) do
          if ns.IsSourceMode(entry.trackerKind) then
            trackerSourceEntries[#trackerSourceEntries + 1] = entry
            local modeKey = tostring(entry.trackerKind or "")
            trackerSourceEntriesByMode[modeKey] = trackerSourceEntriesByMode[modeKey] or {}
            trackerSourceEntriesByMode[modeKey][#trackerSourceEntriesByMode[modeKey] + 1] = entry
          elseif entry.trackerKind == "professions" then
            trackerProfessionEntries[#trackerProfessionEntries + 1] = entry
          else
            trackerInstanceEntries[#trackerInstanceEntries + 1] = entry
          end
        end

        sourceCompletionByZoneKey, sourceCompletionByExpansion, sourceCompletionByEntryKey, sourcePercentByZoneKey, sourcePercentByExpansion, sourcePercentByEntryKey = BuildSourceCompletionMaps(trackerSourceEntries)
        instanceCompletionByEntryKey, instanceCompletionByExpansion, instancePercentByEntryKey, instancePercentByExpansion = BuildInstanceCompletionMaps(trackerInstanceEntries)
        professionCompletionByEntryKey, professionCompletionByExpansion, professionPercentByEntryKey, professionPercentByExpansion = BuildProfessionCompletionMaps(trackerProfessionEntries)

        trackerSectionPercentByKey.instances = AggregateCompletionPercent(trackerInstanceEntries)
        trackerSectionPercentByKey.professions = AggregateCompletionPercent(trackerProfessionEntries)
        for modeKey, entries in pairs(trackerSourceEntriesByMode) do
          trackerSectionPercentByKey["source:" .. tostring(modeKey)] = AggregateCompletionPercent(entries)
        end

        frame._trackerCompletionCache = {
          filteredRef = frame.filtered,
          sourceByZoneKey = sourceCompletionByZoneKey,
          sourceByExpansion = sourceCompletionByExpansion,
          sourceByEntryKey = sourceCompletionByEntryKey,
          sourcePercentByZoneKey = sourcePercentByZoneKey,
          sourcePercentByExpansion = sourcePercentByExpansion,
          sourcePercentByEntryKey = sourcePercentByEntryKey,
          instanceByEntryKey = instanceCompletionByEntryKey,
          instanceByExpansion = instanceCompletionByExpansion,
          instancePercentByEntryKey = instancePercentByEntryKey,
          instancePercentByExpansion = instancePercentByExpansion,
          professionByEntryKey = professionCompletionByEntryKey,
          professionByExpansion = professionCompletionByExpansion,
          professionPercentByEntryKey = professionPercentByEntryKey,
          professionPercentByExpansion = professionPercentByExpansion,
          sectionPercentByKey = trackerSectionPercentByKey,
        }
      end
    end
  end

  local function ApplyCompletionLabel(fontString, pctValue)
    if not fontString then
      return
    end
    if showCompletionPercent and pctValue ~= nil then
      fontString:SetText(FormatCompletionPercentText(pctValue))
      fontString:Show()
    else
      fontString:SetText("")
      fontString:Hide()
    end
  end

  if frame and frame.instanceListScroll and frame.instanceListScroll.GetWidth then
    local measured = frame.instanceListScroll:GetWidth() or 0
    if measured > 0 then
      listWidth = math.max(UIC.LEFT_LIST_WIDTH, math.floor(measured - 8))
    end
  end

  for i, rowData in ipairs(rows) do
    if not frame.instanceButtons[i] then
      frame.instanceButtons[i] = {}
    end

    local rowSlot = frame.instanceButtons[i]
    local y = -((i - 1) * 32)

    if rowData.kind == "header" or rowData.kind == "trackerSection" then
      if not rowSlot.header then
        local header = CreateExpansionHeader(content)
        header.specialGlow = header:CreateTexture(nil, "ARTWORK", nil, 1)
        header.specialGlow:SetPoint("TOPLEFT", 2, -2)
        header.specialGlow:SetPoint("BOTTOMLEFT", 2, 2)
        header.specialGlow:SetWidth(3)
        header.specialGlow:SetTexture(UIC.THEME_FLAT)
        header.specialGlow:SetVertexColor(1.00, 0.86, 0.42, 0.00)
        header.specialGlow:Hide()

        header:SetScript("OnClick", function(self)
          if self.sectionKey then
            DecorDropsDB.collapsedTrackerSections = DecorDropsDB.collapsedTrackerSections or {}
            local current = DecorDropsDB.collapsedTrackerSections[self.sectionKey]
            DecorDropsDB.collapsedTrackerSections[self.sectionKey] = not current
            frame:RefreshLeftPanelOnly()
            return
          end
          if not self.expansion then
            return
          end
          local collapseKey = self.expansionCollapseKey or self.expansion
          local current = CollapseState.IsExpansionCollapsed(frame, collapseKey)
          CollapseState.SetExpansionCollapsed(frame, collapseKey, not current)
          frame:RefreshLeftPanelOnly()
        end)
        rowSlot.header = header
      end

      if rowSlot.instance then
        rowSlot.instance:Hide()
      end

      local header = rowSlot.header
      ResetPooledButtonState(header)
      ApplyCompletionLabel(header.completionText, nil)
      if rowData.kind == "trackerSection" then
        header.expansion = nil
        header.expansionCollapseKey = nil
        header.sectionKey = rowData.sectionKey
        local trackerCollapsed = DecorDropsDB.collapsedTrackerSections or {}
        local isCollapsed = trackerCollapsed[rowData.sectionKey] and true or false
        header.arrow:SetText(isCollapsed and "+" or "-")
        header.text:SetText(rowData.title or "Tracking")
        header:SetBackdropColor(0.05, 0.05, 0.05, 0.98)
        header:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.92)
        header.text:SetTextColor(1.00, 0.93, 0.78)
        header.arrow:SetTextColor(1.00, 0.93, 0.78)
        ApplyCompletionLabel(header.completionText, trackerSectionPercentByKey[tostring(rowData.sectionKey or "")])
        if header.specialGlow then
          header.specialGlow:Hide()
        end
      else
        header.sectionKey = nil
        local collapseKey = rowData.expansionCollapseKey or rowData.expansion
        local isCollapsed = CollapseState.IsExpansionCollapsed(frame, collapseKey)
        header.expansion = rowData.expansion
        header.expansionCollapseKey = collapseKey
        header.arrow:SetText(isCollapsed and "+" or "-")
        header.text:SetText(rowData.expansion)
        local sourceExpansionComplete = rowData.isSourceMode and sourceCompletionByExpansion[tostring(rowData.expansion or "")] == true
        if rowData.isSourceMode and frame.viewMode == "endeavor" then
          sourceExpansionComplete = sourceCompletionByHeaderKey[tostring(rowData.expansion or "")] == true
        end
        local instanceExpansionComplete = ((frame.viewMode == "instances") or (frame.viewMode == "tracker" and not rowData.isSourceMode and not rowData.isProfessionMode))
          and instanceCompletionByExpansion[tostring(rowData.expansion or "")] == true
        local professionExpansionComplete = (rowData.isProfessionMode or frame.viewMode == "professions")
          and professionCompletionByExpansion[tostring(rowData.expansion or "")] == true
        local headerPercent = nil
        if rowData.isSourceMode then
          if frame.viewMode == "endeavor" then
            headerPercent = sourcePercentByHeaderKey[tostring(rowData.expansion or "")]
          else
            headerPercent = sourcePercentByExpansion[tostring(rowData.expansion or "")]
          end
        elseif rowData.isProfessionMode or frame.viewMode == "professions" then
          headerPercent = professionPercentByExpansion[tostring(rowData.expansion or "")]
        elseif frame.viewMode == "instances" then
          headerPercent = instancePercentByExpansion[tostring(rowData.expansion or "")]
        end
        ApplyCompletionLabel(header.completionText, headerPercent)
        if sourceExpansionComplete or instanceExpansionComplete or professionExpansionComplete then
          header:SetBackdropColor(0.05, 0.10, 0.05, 0.98)
          header:SetBackdropBorderColor(0.30, 0.82, 0.42, 0.95)
          header.text:SetTextColor(0.76, 1.00, 0.76)
          header.arrow:SetTextColor(0.76, 1.00, 0.76)
          if header.specialGlow then
            header.specialGlow:Show()
            header.specialGlow:SetVertexColor(0.30, 0.92, 0.46, 0.45)
          end
        elseif rowData.isCurrentSeason then
          header:SetBackdropColor(0.07, 0.06, 0.05, 0.98)
          header:SetBackdropBorderColor(0.98, 0.86, 0.44, 0.96)
          header.text:SetTextColor(1.00, 0.90, 0.62)
          header.arrow:SetTextColor(1.00, 0.90, 0.62)
          if header.specialGlow then
            header.specialGlow:Show()
            header.specialGlow:SetVertexColor(1.00, 0.86, 0.42, 0.75)
          end
        else
          header:SetBackdropColor(0.05, 0.05, 0.05, 0.98)
          header:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.92)
          header.text:SetTextColor(1.00, 0.93, 0.78)
          header.arrow:SetTextColor(1.00, 0.93, 0.78)
          if header.specialGlow then
            header.specialGlow:Hide()
          end
        end
      end
      header:SetWidth(listWidth)
      local headerIndent = 0
      if rowData.kind == "header" and frame.viewMode == "tracker" and rowData.trackerSubHeader then
        headerIndent = UIC.SUBCATEGORY_LEFT_INDENT
      end
      header:SetWidth(listWidth - headerIndent)
      header:SetPoint("TOPLEFT", headerIndent, y)
      header:Show()
    else
      if not rowSlot.instance then
        local btn = CreateListButton(content)
        btn:SetScript("OnClick", function(self)
          ApplySelectedKeyForCurrentMode(frame, self.entryKey)
          ns.RefreshInstanceList(frame)
          ns.RefreshItems(frame)
        end)
        rowSlot.instance = btn
      end

      if rowSlot.header then
        rowSlot.header:Hide()
      end

      local btn = rowSlot.instance
      ResetPooledButtonState(btn)
      ApplyCompletionLabel(btn.completionText, nil)
      if rowData.kind == "newSourceZone" then
        btn.entryKey = nil
        local zoneIndent = UIC.SUBCATEGORY_LEFT_INDENT
        if frame.viewMode == "tracker" and rowData.trackerSubHeader then
          zoneIndent = zoneIndent + UIC.SUBCATEGORY_LEFT_INDENT
        end
        btn:SetSize(listWidth - zoneIndent, 28)
        btn:SetPoint("TOPLEFT", zoneIndent, y)
        if btn.branchArrow then
          local collapseExpansion = rowData.expansionCollapseKey or rowData.expansion
          local isCollapsed = CollapseState.IsSourceZoneCollapsed(frame, collapseExpansion, rowData.zoneName)
          btn.branchArrow:Show()
          btn.branchArrow:SetText(isCollapsed and "+" or "-")
          btn.branchArrow:SetTextColor(1.00, 0.90, 0.62)
        end
        if btn.typeIcon.SetAtlas then
          btn.typeIcon:SetAtlas(nil)
        end
        btn.typeIcon:ClearAllPoints()
        btn.typeIcon:SetSize(14, 14)
        btn.typeIcon:SetPoint("LEFT", 8, 0)
        btn.typeIcon:SetTexture(UIC.ICON_LOCATION)
        btn.typeIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        btn.typeIcon:SetVertexColor(1, 1, 1, 1)
        btn.text:SetText(tostring(rowData.zoneName or "Unknown Zone"))
        btn.text:ClearAllPoints()
        btn.text:SetPoint("LEFT", btn.typeIcon, "RIGHT", 6, 0)
        btn.text:SetPoint("RIGHT", btn.completionText, "LEFT", -6, 0)
        local zoneComplete = sourceCompletionByZoneKey[tostring(rowData.zoneKey or "")] == true
        ApplyCompletionLabel(btn.completionText, sourcePercentByZoneKey[tostring(rowData.zoneKey or "")])
        if zoneComplete then
          btn:SetBackdropColor(0.04, 0.10, 0.04, 0.95)
          btn:SetBackdropBorderColor(0.28, 0.80, 0.40, 0.90)
          btn.text:SetTextColor(0.78, 1.00, 0.78)
        else
          btn:SetBackdropColor(0.04, 0.04, 0.04, 0.95)
          btn:SetBackdropBorderColor(0.66, 0.56, 0.38, 0.88)
          btn.text:SetTextColor(0.95, 0.88, 0.72)
        end
        if btn.selectedGlow then
          btn.selectedGlow:Hide()
        end
        if btn.selectedBar then
          btn.selectedBar:Hide()
        end
        btn:SetScript("OnClick", function(self)
          local collapseExpansion = self.expansionCollapseKey or self.expansion
          local current = CollapseState.IsSourceZoneCollapsed(frame, collapseExpansion, self.zoneName)
          CollapseState.SetSourceZoneCollapsed(frame, collapseExpansion, self.zoneName, not current)
          frame:RefreshLeftPanelOnly()
        end)
        btn.zoneKey = rowData.zoneKey
        btn.expansion = rowData.expansion
        btn.expansionCollapseKey = rowData.expansionCollapseKey
        btn.zoneName = rowData.zoneName
      else
        local entry = rowData.entry
        btn.zoneKey = nil
        btn:SetScript("OnClick", function(self)
          ApplySelectedKeyForCurrentMode(frame, self.entryKey)
          ns.RefreshInstanceList(frame)
          ns.RefreshItems(frame)
        end)
        btn.entryKey = entry.key
        local indent = UIC.SUBCATEGORY_LEFT_INDENT
        local promotionHasNoZoneParent = frame.viewMode == "promotion"
          and (rowData.zoneName == nil or tostring(rowData.zoneName) == "")
        if rowData.kind == "source" then
          if frame.viewMode == "endeavor" then
            indent = UIC.SUBCATEGORY_LEFT_INDENT
          elseif ns.IsSourceMode(frame.viewMode) or frame.viewMode == "tracker" then
            indent = UIC.SUBCATEGORY_LEFT_INDENT * 2
          end
          if frame.viewMode == "promotion" and ns.IsPromotionFlattenedExpansion and ns.IsPromotionFlattenedExpansion(rowData.expansion) then
            indent = UIC.SUBCATEGORY_LEFT_INDENT
          elseif promotionHasNoZoneParent then
            indent = UIC.SUBCATEGORY_LEFT_INDENT
          end
        end
        if frame.viewMode == "tracker" and rowData.trackerSubHeader then
          indent = indent + UIC.SUBCATEGORY_LEFT_INDENT
        end
        btn:SetSize(listWidth - indent, 28)
        btn:SetPoint("TOPLEFT", indent, y)
        if btn.branchArrow then
          local showSourceArrow = true
          if rowData.kind == "source"
            and frame.viewMode == "promotion"
            and (
              (ns.IsPromotionFlattenedExpansion and ns.IsPromotionFlattenedExpansion(rowData.expansion))
              or promotionHasNoZoneParent
            ) then
            showSourceArrow = false
          end
          if showSourceArrow then
            btn.branchArrow:Show()
            btn.branchArrow:SetText("->")
            btn.branchArrow:SetTextColor(1.00, 0.90, 0.62)
          else
            btn.branchArrow:Hide()
          end
        end
        if rowData.kind == "profession" or rowData.kind == "trackerProfession" then
        btn.text:ClearAllPoints()
        btn.text:SetPoint("LEFT", btn.typeIcon, "RIGHT", 6, 0)
        btn.text:SetPoint("RIGHT", btn.completionText, "LEFT", -6, 0)
        if btn.typeIcon.SetAtlas then
          btn.typeIcon:SetAtlas(nil)
        end
        btn.typeIcon:ClearAllPoints()
        btn.typeIcon:SetSize(16, 16)
        btn.typeIcon:SetPoint("LEFT", 9, 0)
        btn.text:SetText(string.format("%s (%d)", entry.profession.profession or "Profession", #entry.rows))
        ApplyCompletionLabel(btn.completionText, professionPercentByEntryKey[tostring(entry.key or "")])
        btn.typeIcon:SetTexture(GetProfessionIconTexture(entry.profession.profession, entry.profession.icon))
        btn.typeIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        if btn.typeIcon.SetDesaturated then
          btn.typeIcon:SetDesaturated(false)
        end
        btn.typeIcon:SetVertexColor(1, 1, 1, 1)
        elseif rowData.kind == "source" then
          btn.text:ClearAllPoints()
          btn.text:SetPoint("LEFT", btn.typeIcon, "RIGHT", 6, 0)
          btn.text:SetPoint("RIGHT", btn.completionText, "LEFT", -6, 0)
          local mode = (frame.viewMode == "tracker" and entry.trackerKind) or frame.viewMode
          local displayLabel = ns._NormalizeCategoryLabel(entry.label, "Source")
          if btn.typeIcon.SetAtlas then
            btn.typeIcon:SetAtlas(nil)
          end
          btn.typeIcon:ClearAllPoints()
          btn.typeIcon:SetSize(16, 16)
          btn.typeIcon:SetPoint("LEFT", 9, 0)
          btn.text:SetText(string.format("%s (%d)", displayLabel, #entry.rows))
          if mode == "vendor" then
            btn.typeIcon:SetTexture(UIC.ICON_VENDOR)
          elseif mode == "achievement" then
            btn.typeIcon:SetTexture(UIC.ICON_ACHIEVEMENT)
          elseif mode == "quest" then
            btn.typeIcon:SetTexture(UIC.ICON_QUEST)
          elseif mode == "reputation" then
            btn.typeIcon:SetTexture(UIC.ICON_REPUTATION)
          else
            btn.typeIcon:SetTexture(UIC.ICON_ENDEAVOR)
          end
          btn.typeIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
          if btn.typeIcon.SetDesaturated then
            btn.typeIcon:SetDesaturated(false)
          end
          btn.typeIcon:SetVertexColor(1, 1, 1, 1)
          ApplyCompletionLabel(btn.completionText, sourcePercentByEntryKey[tostring(entry.key or "")])
        else
          btn.text:ClearAllPoints()
          btn.text:SetPoint("LEFT", btn.typeIcon, "RIGHT", 4, 0)
          btn.text:SetPoint("RIGHT", btn.completionText, "LEFT", -6, 0)
          btn.typeIcon:ClearAllPoints()
          btn.typeIcon:SetSize(16, 16)
          btn.typeIcon:SetPoint("LEFT", 9, 0)
          btn.text:SetText(string.format("%s (%d)", entry.instance.name, #entry.rows))
          SetInstanceTypeIcon(btn.typeIcon, entry.instance.type)
          ApplyCompletionLabel(btn.completionText, instancePercentByEntryKey[tostring(entry.key or "")])
        end

        local sourceEntryComplete = false
        if rowData.kind == "source" then
          sourceEntryComplete = sourceCompletionByEntryKey[tostring(entry.key or "")] == true
          if sourceEntryComplete == false and sourceCompletionByEntryKey[tostring(entry.key or "")] == nil then
            sourceEntryComplete = IsAcquireEntryComplete(entry)
          end
        end
        local instanceEntryComplete = (rowData.kind == "instance" or rowData.kind == "trackerInstance")
          and instanceCompletionByEntryKey[tostring(entry.key or "")] == true
        local professionEntryComplete = (rowData.kind == "profession" or rowData.kind == "trackerProfession")
          and professionCompletionByEntryKey[tostring(entry.key or "")] == true
        local entryComplete = sourceEntryComplete or instanceEntryComplete or professionEntryComplete
        if entry.key == selectedKey then
          if entryComplete then
            btn:SetBackdropColor(0.12, 0.26, 0.12, 0.98)
            btn:SetBackdropBorderColor(0.34, 0.94, 0.52, 1.00)
            btn.text:SetTextColor(0.86, 1.00, 0.86)
          else
            btn:SetBackdropColor(0.98, 0.82, 0.52, 0.98)
            btn:SetBackdropBorderColor(1.00, 0.90, 0.56, 1.00)
            btn.text:SetTextColor(1.00, 0.97, 0.86)
          end
          if btn.typeIcon then
            btn.typeIcon:SetAlpha(1.0)
          end
          if btn.selectedGlow then
            btn.selectedGlow:Show()
            if entryComplete then
              btn.selectedGlow:SetVertexColor(0.30, 0.94, 0.46, 0.16)
            else
              btn.selectedGlow:SetVertexColor(1.00, 0.86, 0.46, 0.08)
            end
          end
          if btn.selectedBar then
            btn.selectedBar:Show()
            if entryComplete then
              btn.selectedBar:SetVertexColor(0.34, 0.96, 0.52, 1.00)
            else
              btn.selectedBar:SetVertexColor(1.00, 0.88, 0.48, 0.95)
            end
          end
        else
          if entryComplete then
            btn:SetBackdropColor(0.05, 0.11, 0.05, 0.98)
            btn:SetBackdropBorderColor(0.30, 0.82, 0.42, 0.90)
            btn.text:SetTextColor(0.82, 0.98, 0.82)
          else
            btn:SetBackdropColor(0.05, 0.05, 0.05, 0.98)
            btn:SetBackdropBorderColor(0.82, 0.68, 0.42, 0.88)
            btn.text:SetTextColor(1.00, 0.93, 0.78)
          end
          if btn.typeIcon then
            btn.typeIcon:SetAlpha(0.95)
          end
          if btn.selectedGlow then
            btn.selectedGlow:Hide()
          end
          if btn.selectedBar then
            btn.selectedBar:Hide()
          end
        end
      end

      btn:Show()
    end
  end

  for i = #rows + 1, #frame.instanceButtons do
    local rowSlot = frame.instanceButtons[i]
    if rowSlot.header then
      rowSlot.header:Hide()
    end
    if rowSlot.instance then
      rowSlot.instance:Hide()
    end
  end

  content:SetWidth(listWidth)
  content:SetHeight(math.max(1, #rows * 32))
  ns.RefreshScrollBarVisibility(frame.instanceListScroll)
end

function ns._GetSelectedEntryExpansionName(entry)
  if type(entry) ~= "table" then
    return nil
  end
  if type(entry.profession) == "table" and entry.profession.expansion then
    return entry.profession.expansion
  end
  if entry.expansion then
    return entry.expansion
  end
  if type(entry.instance) == "table" and entry.instance.expansion then
    return entry.instance.expansion
  end
  return nil
end

function ns._IsMidnightExpansion(expansionName)
  local value = string.lower(tostring(expansionName or ""))
  value = value:gsub("^%s+", ""):gsub("%s+$", "")
  return value == "midnight"
end

function ns._ShowMidnightWaypointPlaceholder(frame, modeLabel)
  if not frame or not frame.trainerPanel then
    return
  end
  frame.trainerIcon:SetTexture(UIC.ICON_LOCATION)
  frame.trainerName:SetText("Midnight Waypoints")
  frame.trainerLocation:SetText(string.format("%s waypoint data coming soon.", tostring(modeLabel or "Category")))
  if frame.trainerFaction then
    frame.trainerFaction:SetText("")
  end
  if frame.trainerWaypointButton then
    frame.trainerWaypointButton:SetScript("OnClick", nil)
    frame.trainerWaypointButton:SetScript("OnEnter", nil)
    frame.trainerWaypointButton:SetScript("OnLeave", nil)
    frame.trainerWaypointButton:Disable()
  end
  if frame.trainerManualButton then
    frame.trainerManualButton:SetScript("OnClick", nil)
    frame.trainerManualButton:Disable()
    frame.trainerManualButton:Hide()
  end
  if frame.manualOverridePanel then
    frame.manualOverridePanel:Hide()
  end
  ns.HideTrainerTeleportHint(frame)
  ns.UpdateTrainerActionButtonLayout(frame)
  frame.trainerPanel:Show()
end

function ns._ShowNoSelectedEntryState(frame)
  local hasRows = frame and frame.filtered and #frame.filtered > 0
  if hasRows then
    frame.instanceName:SetText("Select a category")
    frame.entranceText:SetText("Pick an entry on the left to load items.")
  else
    frame.instanceName:SetText("No matching decorations")
    if frame.viewMode == "professions" then
      frame.entranceText:SetText("Try broadening your filters. Developers can run /decordrops devscan professions to refresh source data.")
    elseif frame.viewMode == "tracker" then
      frame.entranceText:SetText("Track items from raids, dungeons, or professions to see them here")
    elseif ns.IsSourceMode(frame.viewMode) then
      frame.entranceText:SetText("No source rows match current filters. Try broadening filters or run /decordrops rebuildacquire")
    else
      frame.entranceText:SetText("Try broadening your filters. Developers can run /decordrops devscan to refresh source data.")
    end
  end
  if frame.dungeonWaypointButton then
    frame.dungeonWaypointButton:Disable()
    frame.dungeonWaypointButton:SetScript("OnClick", nil)
    frame.dungeonWaypointButton:SetShown(false)
  end
  if frame.trainerPanel then
    ns.HideTrainerTeleportHint(frame)
    frame.trainerPanel:Hide()
  end
  if frame.reputationMini then
    frame.reputationMini:Hide()
  end
  HideManualOverridePanel(frame)
  frame.itemListContent:SetHeight(1)
  for _, row in ipairs(frame.itemRows) do
    row:Hide()
  end
end

function ns._ConfigureProfessionSelectionHeader(frame, selectedEntry, isMidnightSelected)
  local prof = selectedEntry.profession
  if frame.viewMode == "tracker" then
    frame.instanceName:SetText(string.format("%s (Crafting Tracking)", prof.profession or "Profession"))
  else
    frame.instanceName:SetText(string.format("%s (Profession)", prof.profession or "Profession"))
  end
  frame.entranceText:SetText(string.format("%s -> Crafting Decorations", prof.expansion or "Other"))
  if frame.dungeonWaypointButton then
    frame.dungeonWaypointButton:Disable()
    frame.dungeonWaypointButton:SetScript("OnClick", nil)
    frame.dungeonWaypointButton:SetShown(false)
  end

  if not frame.trainerPanel then
    return
  end
  local trainers = GetTrainerEntriesForProfession(
    selectedEntry.profession and selectedEntry.profession.profession,
    selectedEntry.profession and selectedEntry.profession.expansion
  )
  if isMidnightSelected and #trainers == 0 then
    ns._ShowMidnightWaypointPlaceholder(frame, "Profession")
    return
  end
  local trainer = trainers[1]
  if not trainer then
    ns.HideTrainerTeleportHint(frame)
    frame.trainerPanel:Hide()
    return
  end

  local trainerName = trainer.name or "Unknown Trainer"
  local locationText = trainer.zoneName or "Unknown Zone"
  if trainer.x and trainer.y then
    locationText = string.format("%s [%.1f, %.1f]", locationText, trainer.x, trainer.y)
  end
  frame.trainerName:SetText(trainerName)
  frame.trainerLocation:SetText(locationText)
  frame.trainerFaction:SetText("")
  ns.HideTrainerTeleportHint(frame)
  frame.trainerWaypointButton:SetScript("OnClick", function()
    SetTrainerWaypoint(trainer)
  end)
  frame.trainerWaypointButton:SetScript("OnEnter", nil)
  frame.trainerWaypointButton:SetScript("OnLeave", nil)
  frame.trainerWaypointButton:SetText("Waypoint")
  if frame.trainerManualButton then
    frame.trainerManualButton:SetScript("OnClick", nil)
    frame.trainerManualButton:Disable()
    frame.trainerManualButton:Hide()
  end
  HideManualOverridePanel(frame)
  frame.trainerWaypointButton:Enable()
  ns.UpdateTrainerActionButtonLayout(frame)
  frame.trainerPanel:Show()
end

function ns._ConfigureSourceSelectionHeader(frame, selectedEntry, activeModeForSelection)
  local modeLabel = ns.GetSourceModeLabel(activeModeForSelection)
  frame.instanceName:SetText(string.format("%s (%s)", tostring(selectedEntry.label or "Source"), modeLabel))
  local headerPath = tostring(selectedEntry.expansion or "Other") .. " -> " .. tostring(selectedEntry.detail or modeLabel)
  frame.entranceText:SetText(headerPath)
  if frame.dungeonWaypointButton then
    frame.dungeonWaypointButton:Disable()
    frame.dungeonWaypointButton:SetScript("OnClick", nil)
    frame.dungeonWaypointButton:SetShown(false)
  end

  if not frame.trainerPanel then
    return
  end

  local wp = selectedEntry.waypoint
  if not (type(wp) == "table" and wp.mapID and wp.mapID > 0 and wp.x and wp.y) then
    ns.HideTrainerTeleportHint(frame)
    frame.trainerPanel:Hide()
    return
  end

  frame.trainerIcon:SetTexture(UIC.ICON_LOCATION)
  frame.trainerName:SetText(tostring(wp.npcName or selectedEntry.label or "Waypoint"))
  frame.trainerLocation:SetText(string.format("%s [%.1f, %.1f]", tostring(wp.zone or "Unknown Zone"), (wp.x or 0), (wp.y or 0)))
  frame.trainerFaction:SetText("")
  ns.HideTrainerTeleportHint(frame)
  frame.trainerWaypointButton:SetScript("OnClick", function()
    SetTrainerWaypoint({
      name = wp.npcName or selectedEntry.label or "Waypoint",
      zoneName = wp.zone or "Unknown Zone",
      mapID = wp.mapID,
      x = wp.x,
      y = wp.y,
    })
  end)
  frame.trainerWaypointButton:SetScript("OnEnter", nil)
  frame.trainerWaypointButton:SetScript("OnLeave", nil)
  frame.trainerWaypointButton:SetText("Waypoint")
  if frame.trainerManualButton then
    frame.trainerManualButton:SetScript("OnClick", nil)
    frame.trainerManualButton:Disable()
    frame.trainerManualButton:Hide()
  end
  HideManualOverridePanel(frame)
  frame.trainerWaypointButton:Enable()
  ns.UpdateTrainerActionButtonLayout(frame)
  frame.trainerPanel:Show()
end

function ns._ConfigureInstanceSelectionHeader(frame, selectedEntry, isMidnightSelected)
  local instance = selectedEntry.instance
  local expansion = ExpansionName(instance)
  local mapName = GetInstanceEntranceZone(instance)
  frame.instanceName:SetText(string.format("%s (%s)", instance.name, instance.type))
  if HasEntranceCoords(instance) then
    frame.entranceText:SetText(string.format("%s -> %s -> %.1f, %.1f", expansion, mapName, instance.entrance.x, instance.entrance.y))
  else
    frame.entranceText:SetText(string.format("%s -> %s -> Coords unknown", expansion, mapName))
  end

  if frame.dungeonWaypointButton then
    frame.dungeonWaypointButton:Disable()
    frame.dungeonWaypointButton:SetScript("OnClick", nil)
    frame.dungeonWaypointButton:SetShown(false)
  end
  if not frame.trainerPanel then
    return
  end

  if frame.trainerManualButton then
    frame.trainerManualButton:SetScript("OnClick", nil)
    frame.trainerManualButton:Disable()
    frame.trainerManualButton:Hide()
  end
  HideManualOverridePanel(frame)

  if isMidnightSelected then
    local staticMapID = ns.GetInstanceOpenMapID and ns.GetInstanceOpenMapID(instance and instance.name) or nil
    local hasUsableMapData = (staticMapID and staticMapID > 0)
      or (instance and tonumber(instance.mapID) and tonumber(instance.mapID) > 0)
      or HasEntranceCoords(instance)
    if not hasUsableMapData then
      ns._ShowMidnightWaypointPlaceholder(frame, "Dungeon/Raid")
      return
    end
  end

  local locationText = mapName or "Unknown Map"
  if HasEntranceCoords(instance) then
    locationText = string.format("%s [%.1f, %.1f]", locationText, instance.entrance.x, instance.entrance.y)
  else
    locationText = string.format("%s [Coords unknown]", locationText)
  end

  frame.trainerIcon:SetTexture(UIC.ICON_LOCATION)
  frame.trainerName:SetText(string.format("%s Entrance", tostring(instance and instance.name or "Instance")))
  frame.trainerLocation:SetText(locationText)
  frame.trainerFaction:SetText("")
  ns.HideTrainerTeleportHint(frame)

  if IsPlayerMage() then
    local expansionName = ExpansionName(instance)
    local spellID = GetPreferredTeleportSpellID(expansionName)
    local spellName = spellID and (GetSpellNameByID(spellID) or "Teleport") or "Teleport"
    local spellKnown = spellID and IsSpellKnownForPlayer(spellID)
    local forcePreview = ns.IsTeleportNotLearnedPreviewEnabled and ns.IsTeleportNotLearnedPreviewEnabled()
    if forcePreview or not spellKnown then
      ns.SetTrainerTeleportUnavailableVisual(frame, spellName)
    else
    frame.trainerWaypointButton:SetText("Teleport")
    frame.trainerWaypointButton:Enable()
    frame.trainerWaypointButton:SetScript("OnClick", function()
      local tpSpellID, tpSpellName, tpExpansion = ns.GetInstanceCapitalTeleport(instance)
      if tpSpellID then
        ns.ShowTeleportConfirmDialog(tpExpansion, tpSpellID, tpSpellName)
      else
        OpenInstanceMap(instance)
      end
    end)
      frame.trainerWaypointButton:SetScript("OnEnter", nil)
      frame.trainerWaypointButton:SetScript("OnLeave", nil)
    end
  else
    frame.trainerWaypointButton:SetText("Open Map")
    frame.trainerWaypointButton:Enable()
    frame.trainerWaypointButton:SetScript("OnClick", function()
      OpenInstanceMap(instance)
    end)
    frame.trainerWaypointButton:SetScript("OnEnter", nil)
    frame.trainerWaypointButton:SetScript("OnLeave", nil)
  end
  ns.UpdateTrainerActionButtonLayout(frame)
  frame.trainerPanel:Show()
end

function ns._ConfigureLocationRow(row, rowData)
  local labelName = "Location"
  local zoneText = "-"
  local rateText = "-"
  local iconTexture = UIC.ICON_TRAINER
  local onWaypoint = nil
  local onEnter = nil
  local waypointButtonText = "Go"

  if rowData.kind == "trainer" then
    local trainer = rowData.trainer
    labelName = trainer and trainer.name or "Unknown Trainer"
    zoneText = trainer and trainer.zoneName or "Unknown Zone"
    if trainer and trainer.x and trainer.y then
      zoneText = string.format("%s [%.1f, %.1f]", zoneText, trainer.x, trainer.y)
    end
    rateText = trainer and trainer.faction or ""
    if trainer and trainer.note and trainer.note ~= "" then
      rateText = (rateText ~= "" and (rateText .. " - ") or "") .. trainer.note
    end
    if rateText == "" then
      rateText = "-"
    end
    iconTexture = UIC.ICON_TRAINER
    onWaypoint = function()
      SetTrainerWaypoint(trainer)
    end
    onEnter = function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      GameTooltip:AddLine(labelName)
      GameTooltip:AddLine(zoneText, 0.90, 0.82, 0.64, true)
      if trainer and trainer.note and trainer.note ~= "" then
        GameTooltip:AddLine(trainer.note, 0.80, 0.80, 0.80, true)
      end
      if trainer and trainer.mapID and trainer.x and trainer.y then
        GameTooltip:AddLine("|cff66ff66Click Waypoint to navigate|r")
      else
        GameTooltip:AddLine("|cffffcc66No waypoint coordinates available|r")
      end
      GameTooltip:Show()
    end
  else
    local locInstance = rowData.instance
    labelName = string.format("%s Entrance", tostring(locInstance and locInstance.name or "Instance"))
    local mapName = GetInstanceEntranceZone(locInstance)
    if HasEntranceCoords(locInstance) then
      zoneText = string.format("%s [%.1f, %.1f]", mapName, locInstance.entrance.x, locInstance.entrance.y)
    else
      zoneText = string.format("%s [Coords unknown]", mapName)
    end
    rateText = string.format("%s - %s", tostring(locInstance and locInstance.type or "Dungeon"), ExpansionName(locInstance))
    iconTexture = UIC.ICON_LOCATION
    if IsPlayerMage() then
      local tpSpellID, tpSpellName, tpExpansion = ns.GetInstanceCapitalTeleport(locInstance)
      if tpSpellID then
        waypointButtonText = "Teleport"
        onWaypoint = function()
          ns.ShowTeleportConfirmDialog(tpExpansion, tpSpellID, tpSpellName)
        end
      else
        waypointButtonText = "Open Map"
        onWaypoint = function()
          OpenInstanceMap(locInstance)
        end
      end
    else
      waypointButtonText = "Open Map"
      onWaypoint = function()
        OpenInstanceMap(locInstance)
      end
    end
    onEnter = function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      GameTooltip:AddLine(labelName)
      GameTooltip:AddLine(zoneText, 0.90, 0.82, 0.64, true)
      if waypointButtonText == "Teleport" then
        GameTooltip:AddLine("|cff66ff66Click Teleport for capital-cast confirmation|r")
      elseif waypointButtonText == "Open Map" then
        GameTooltip:AddLine("|cff66ff66Click Open Map to view this instance map|r")
      else
        GameTooltip:AddLine("|cff66ff66Click Waypoint to navigate|r")
      end
      GameTooltip:Show()
    end
  end

  row.collectedIcon:Hide()
  row.itemIcon:SetTexture(iconTexture)
  row.itemIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
  FitTextToWidth(row.itemText, labelName, 7, true, 2)
  row.itemText:SetTextColor(0.55, 0.78, 1.00)
  ns._HideResourceButtons(row)
  row.sourceText:SetText(zoneText)
  row.sourceText:Show()
  row.rateText:SetJustifyH("CENTER")
  row.rateText:SetText(rateText)
  row.itemHitbox:SetScript("OnClick", nil)
  row.itemHitbox:SetScript("OnEnter", onEnter)
  row.itemHitbox:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)
  SetViewButtonVisual(row.viewButton)
  row.viewButton:SetScript("OnClick", nil)
  SetGoButtonVisual(row.wpButton, true)
  row.wpButton:SetText(waypointButtonText)
  row.wpButton:SetScript("OnClick", onWaypoint)
  row.recipeText:Hide()
  row.trackCheck:Hide()
  if row.questLinkButton then
    row.questLinkButton:Hide()
    row.questLinkButton:SetScript("OnClick", nil)
  end
end

function ns._ConfigureItemRow(frame, row, item, selectedEntry, activeModeForSelection, isProfessionMode, isSourceModeSelection, lookupContext)
  local collected = GetWarbandCollectedState(item)
  local itemName = GetItemName(item)
  local qR, qG, qB = GetItemQualityColor(item)
  local professionPricing = nil

  row.collectedIcon:Show()
  row.collectedIcon:SetTexture(collected and "Interface\\RaidFrame\\ReadyCheck-Ready" or "Interface\\RaidFrame\\ReadyCheck-NotReady")
  row.itemIcon:SetTexture(GetItemIconTexture(item))
  row.itemIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
  FitTextToWidth(row.itemText, itemName, 7, true, 2)
  row.itemText:SetTextColor(qR, qG, qB)
  row.rateText:SetJustifyH("CENTER")
  row.recipeText:Hide()
  row.wpButton:Show()
  if row.recipeText and row.recipeText.SetFontObject then
    row.recipeText:SetFontObject("GameFontHighlightSmall")
  end
  row.viewButton:ClearAllPoints()
  row.viewButton:SetSize(ns._VIEW_BUTTON_WIDTH, ns._VIEW_BUTTON_HEIGHT)
  row.viewButton:SetPoint("LEFT", ITEM_LAYOUT.view.x, 0)
  row.recipeText:ClearAllPoints()
  row.recipeText:SetPoint("CENTER", row, "LEFT", ITEM_LAYOUT.waypoint.x + (ITEM_LAYOUT.waypoint.width / 2), 0)
  row.recipeText:SetWidth(ITEM_LAYOUT.waypoint.width)

  if isProfessionMode then
    row.viewButton:ClearAllPoints()
    row.viewButton:SetSize(ns._VIEW_BUTTON_WIDTH, ns._VIEW_BUTTON_HEIGHT)
    row.viewButton:SetPoint("LEFT", ITEM_LAYOUT.waypoint.x + math.floor((ITEM_LAYOUT.waypoint.width - ITEM_LAYOUT.view.width) * 0.5), 0)
    row.recipeText:ClearAllPoints()
    row.recipeText:SetPoint("CENTER", row, "LEFT", ITEM_LAYOUT.view.x + (ITEM_LAYOUT.view.width / 2), 0)
    row.recipeText:SetWidth(ITEM_LAYOUT.waypoint.width)
    if row.recipeText and row.recipeText.SetFontObject then
      row.recipeText:SetFontObject("GameFontHighlight")
    end

    local resourceEntries = GetRenderableResourcesForItem(item)
    local showingResourceIcons = ns._SetResourceButtons(row, resourceEntries)
    if showingResourceIcons then
      row.sourceText:SetText("")
      row.sourceText:Hide()
    else
      row.sourceText:SetText(BuildResourceInlineText(resourceEntries))
      row.sourceText:Show()
    end
    local totalCost, hasPricedReagent = ComputeResourceAuctionTotalCost(resourceEntries)
    local profitCopper, itemAuctionValue = ComputeProfessionProfitCopper(item, totalCost)
    local auctionatorAvailable = ns.IsAuctionatorAvailable and ns.IsAuctionatorAvailable() or false
    local hasAnyAuctionData = ((tonumber(itemAuctionValue) or 0) > 0) or hasPricedReagent
    professionPricing = {
      auctionatorAvailable = auctionatorAvailable and true or false,
      hasAnyAuctionData = hasAnyAuctionData and true or false,
    }

    row.rateText:SetText(FormatMoneyShort(totalCost))
    row.recipeText:SetText(FormatSignedMoneyShort(profitCopper))
    if profitCopper and profitCopper >= 0 then
      row.recipeText:SetTextColor(0.66, 1.00, 0.66)
    elseif profitCopper and profitCopper < 0 then
      row.recipeText:SetTextColor(1.00, 0.62, 0.62)
    else
      row.recipeText:SetTextColor(0.90, 0.90, 0.90)
    end
    row.recipeText:Show()

    SetGoButtonVisual(row.wpButton, false)
    row.wpButton:SetText("-")
    row.wpButton:SetScript("OnClick", nil)
    row.wpButton:Hide()
  else
    ns._HideResourceButtons(row)
    row.sourceText:Show()
    if isSourceModeSelection then
      local sourceNameKey = ns._NormalizeCollectionField(item and item.sourceName or item and item.source)
      local displaySourceKey = ns._NormalizeCollectionField(item and item.displaySource)
      local forcePromotionSourceText = (activeModeForSelection == "promotion")
        and (sourceNameKey == "twitch drops" or displaySourceKey == "twitch drops" or displaySourceKey == "twitch drop")
      local goldOnlyCopper = (not forcePromotionSourceText) and ns.GetGoldOnlyPurchaseCopper and ns.GetGoldOnlyPurchaseCopper(item) or nil
      if goldOnlyCopper and goldOnlyCopper > 0 then
        ns._HideResourceButtons(row)
        row.sourceText:Show()
        row.sourceText:SetText(FormatMoneyShort(goldOnlyCopper))
      else
        local purchaseEntries = forcePromotionSourceText and {} or BuildPurchaseCostEntries(item)
        local showingPurchaseIcons = (not forcePromotionSourceText) and ns._SetResourceButtons(row, purchaseEntries, UIC.PURCHASE_ICON_SIZE) or false
        if showingPurchaseIcons then
          row.sourceText:SetText("")
          row.sourceText:Hide()
        else
          local sourceLabel = item.displaySource
          if forcePromotionSourceText and (sourceLabel == nil or sourceLabel == "") then
            sourceLabel = "Twitch Drops"
          end
          if sourceLabel == nil or sourceLabel == "" then
            sourceLabel = item.sourceDetail
          end
          if sourceLabel == nil or sourceLabel == "" then
            sourceLabel = item.sourceName
          end
          if sourceLabel == nil or sourceLabel == "" then
            sourceLabel = item.source
          end
          if sourceLabel == nil or sourceLabel == "" then
            sourceLabel = "-"
          end
          FitTextToWidth(row.sourceText, sourceLabel, 8, false, 1)
        end
      end
      if selectedEntry.acquireMode == "achievement" then
        local achieved = collected and true or false
        row.rateText:SetText(achieved and "|TInterface\\RaidFrame\\ReadyCheck-Ready:14:14:0:0|t" or "|TInterface\\RaidFrame\\ReadyCheck-NotReady:14:14:0:0|t")
      else
        local statusText = tostring(GetAcquireStatusText(item, selectedEntry) or "-")
        if statusText:match("^[Qq]uest%s+[Uu]nl")
          or statusText:match("^[Qq]uest%s+[Cc]omplete")
          or statusText:match("^[Aa]chievement%s+[Mm]issing")
          or statusText:match("^[Aa]chievement%s+[Cc]omplete")
          or statusText:match("^[Ii]ncomplete$")
          or statusText:match("^[Cc]ompleted$")
          or activeModeForSelection == "quest"
          or activeModeForSelection == "achievement" then
          statusText = collected and "Completed" or "Incomplete"
        end
        row.rateText:SetText(statusText)
      end
    else
      FitTextToWidth(row.sourceText, item.source or "-", 8, false, 1)
      local rate = tonumber(item.dropRate)
      if not rate or rate <= 0 then
        rate = 100.0
      end
      row.rateText:SetText(string.format("%0.1f%%", rate))
    end
  end

  row.itemHitbox:SetScript("OnClick", function()
    InsertItemLink(item)
  end)
  row.itemHitbox:SetScript("OnEnter", function(self)
    ShowItemTooltip(self, item)
    if isProfessionMode and professionPricing then
      if not professionPricing.auctionatorAvailable or not professionPricing.hasAnyAuctionData then
        GameTooltip:AddLine("|cffffcc66Auctionator is required to populate Cost and Profit for professions.|r", 1, 1, 1, true)
      end
      if professionPricing.auctionatorAvailable and not professionPricing.hasAnyAuctionData then
        GameTooltip:AddLine("|cffffcc66No Auctionator market data found yet for this item/resources.|r", 1, 1, 1, true)
      end
      GameTooltip:AddLine("|cffffcc66Profession profit applies a 5% Auction House cut.|r", 1, 1, 1, true)
      GameTooltip:Show()
    end
  end)
  row.itemHitbox:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  if row.questLinkButton then
    row.questLinkButton:Hide()
    row.questLinkButton:SetScript("OnClick", nil)
    row.questLinkButton:SetScript("OnMouseDown", nil)
    ns.SetQuestLinkButtonVisual(row.questLinkButton, "quest")
    if isSourceModeSelection then
      local openFn = nil
      if activeModeForSelection == "vendor" then
        local promotionTarget = ns.ResolvePromotionNavigationTarget(item, selectedEntry, lookupContext)
        if promotionTarget and promotionTarget.entry and promotionTarget.entry.key then
          ns.SetQuestLinkButtonVisual(row.questLinkButton, "promotion")
          openFn = function()
            ns.NavigateToPromotionEntry(frame, promotionTarget)
          end
        end
      end
      if not openFn and activeModeForSelection ~= "quest" then
        local questTarget = ns.ResolveQuestNavigationTarget(item, selectedEntry, lookupContext)
        if questTarget and questTarget.entry and questTarget.entry.key then
          ns.SetQuestLinkButtonVisual(row.questLinkButton, "quest")
          openFn = function()
            NavigateToQuestEntry(frame, questTarget)
          end
        end
      end
      if openFn then
        row.questLinkButton:Show()
        row.questLinkButton:SetScript("OnClick", openFn)
        row.questLinkButton:SetScript("OnMouseDown", openFn)
      end
    end
  end

  SetViewButtonVisual(row.viewButton)
  row.viewButton:SetScript("OnClick", function()
    OpenItemPreview(item)
  end)

  if not isProfessionMode then
    if isSourceModeSelection and item.mapID and item.mapID > 0 and item.x and item.y then
      SetGoButtonVisual(row.wpButton, true)
      row.wpButton:SetScript("OnClick", function()
        SetTrainerWaypoint({
          name = item.npcName or item.sourceName or "Source",
          zoneName = item.zone or item.sourceDetail or "Unknown Zone",
          mapID = item.mapID,
          x = item.x,
          y = item.y,
        })
      end)
    else
      local configuredGo = false
      if isSourceModeSelection and selectedEntry.acquireMode == "achievement" then
        local achievementID = ResolveAchievementIDForItem(item)
        if achievementID and achievementID > 0 then
          SetGoButtonVisual(row.wpButton, true)
          row.wpButton:SetScript("OnClick", function()
            if not OpenAchievementByID(achievementID) then
              print("|cffffcc66DecorDrops: Could not open achievement UI for this item.|r")
            end
          end)
          configuredGo = true
        end
      elseif activeModeForSelection == "instances" and selectedEntry.instance then
        if HasEntranceCoords(selectedEntry.instance) and selectedEntry.instance.mapID and selectedEntry.instance.mapID > 0 then
          SetGoButtonVisual(row.wpButton, true)
          row.wpButton:SetText("Waypoint")
          row.wpButton:SetScript("OnClick", function()
            SetTrainerWaypoint({
              name = tostring(selectedEntry.instance.name or "Instance Entrance"),
              zoneName = GetInstanceEntranceZone(selectedEntry.instance),
              mapID = selectedEntry.instance.mapID,
              x = selectedEntry.instance.entrance.x,
              y = selectedEntry.instance.entrance.y,
            })
          end)
          configuredGo = true
        else
          SetGoButtonVisual(row.wpButton, false)
          row.wpButton:SetScript("OnClick", nil)
        end
      end
      if not configuredGo then
        SetGoButtonVisual(row.wpButton, false)
        row.wpButton:SetText("-")
        row.wpButton:SetScript("OnClick", nil)
      end
    end
  end

  row.trackCheck:SetScript("OnClick", nil)
  row.trackCheck:Show()
  local tracked = IsEntryItemTracked(selectedEntry, activeModeForSelection, item.itemID)
  row.trackCheck._updating = true
  row.trackCheck:SetChecked(tracked and true or false)
  row.trackCheck._updating = false
  row.trackCheck:SetScript("OnClick", function(selfCheck)
    if selfCheck._updating then
      return
    end
    ToggleTrackedEntryItem(selectedEntry, activeModeForSelection, item.itemID)
    frame:Refresh()
    local nowTracked = IsEntryItemTracked(selectedEntry, activeModeForSelection, item.itemID)
    if nowTracked then
      print("|cff66ccffDecorDrops|r added to Tracker.")
    else
      print("|cff66ccffDecorDrops|r removed from Tracker.")
    end
  end)
end

function ns._ConfigureDisplayRow(frame, poolIndex, absoluteIndex, rowData, selectedEntry, activeModeForSelection, isProfessionMode, isSourceModeSelection, lookupContext)
  if not frame.itemRows[poolIndex] then
    frame.itemRows[poolIndex] = BuildItemRow(frame.itemListContent)
  end

  local row = frame.itemRows[poolIndex]
  row:SetPoint("TOPLEFT", 0, -((absoluteIndex - 1) * ITEM_LAYOUT.rowGap))
  if rowData.kind == "trainer" or rowData.kind == "instanceLocation" then
    ns._ConfigureLocationRow(row, rowData)
  else
    local item = (type(rowData) == "table" and rowData.item) and rowData.item or rowData
    ns._ConfigureItemRow(frame, row, item, selectedEntry, activeModeForSelection, isProfessionMode, isSourceModeSelection, lookupContext)
  end
  row:Show()
end

function ns._HideUnusedItemRows(frame, usedCount)
  for i = usedCount + 1, #frame.itemRows do
    frame.itemRows[i]:Hide()
  end
end

function ns._RenderVisibleItemRows(frame, resetToTop)
  if not frame or not frame.itemListScroll then
    return
  end
  if resetToTop then
    frame.itemListScroll:SetVerticalScroll(0)
  end

  local displayRows = frame._displayRows or {}
  local totalRows = #displayRows
  if totalRows <= 0 then
    ns._HideUnusedItemRows(frame, 0)
    return
  end

  local scrollOffset = frame.itemListScroll:GetVerticalScroll() or 0
  local viewportHeight = frame.itemListScroll:GetHeight() or 0
  local firstIndex = math.max(1, math.floor(scrollOffset / ITEM_LAYOUT.rowGap) + 1)
  local visibleCount = math.max(1, math.ceil(viewportHeight / ITEM_LAYOUT.rowGap) + 2)
  local lastIndex = math.min(totalRows, firstIndex + visibleCount - 1)

  local poolIndex = 1
  for absoluteIndex = firstIndex, lastIndex do
    ns._ConfigureDisplayRow(
      frame,
      poolIndex,
      absoluteIndex,
      displayRows[absoluteIndex],
      frame._displaySelectedEntry,
      frame._displayActiveMode,
      frame._displayIsProfessionMode,
      frame._displayIsSourceModeSelection,
      frame._displayLookupContext
    )
    poolIndex = poolIndex + 1
  end
  ns._HideUnusedItemRows(frame, poolIndex - 1)
end

function ns.RefreshItems(frame)
  if InCombatLockdown and InCombatLockdown() then
    ns._pendingCombatRefresh = true
    return
  end
  ns.UpdateHeaderLabels(frame)
  local selectedEntry = ns.FindSelectedEntry(frame)

  if not selectedEntry then
    ns._ShowNoSelectedEntryState(frame)
    return
  end

  local activeModeForSelection = (frame.viewMode == "tracker" and selectedEntry.trackerKind) or frame.viewMode
  local isProfessionMode = activeModeForSelection == "professions"
  local isSourceModeSelection = ns.IsSourceMode(activeModeForSelection)
  local selectedExpansion = ns._GetSelectedEntryExpansionName(selectedEntry)
  local isMidnightSelected = ns._IsMidnightExpansion(selectedExpansion)
  UpdateReputationSummaryPanel(frame, isSourceModeSelection and selectedEntry or nil)
  if isProfessionMode then
    ns._ConfigureProfessionSelectionHeader(frame, selectedEntry, isMidnightSelected)
  elseif isSourceModeSelection then
    ns._ConfigureSourceSelectionHeader(frame, selectedEntry, activeModeForSelection)
  else
    ns._ConfigureInstanceSelectionHeader(frame, selectedEntry, isMidnightSelected)
  end

  local displayRows = selectedEntry.rows or {}
  local resetToTop = frame._renderedSelectedKey ~= tostring(selectedEntry.key or "")
  frame._renderedSelectedKey = tostring(selectedEntry.key or "")
  frame._displayRows = displayRows
  frame._displaySelectedEntry = selectedEntry
  frame._displayActiveMode = activeModeForSelection
  frame._displayIsProfessionMode = isProfessionMode and true or false
  frame._displayIsSourceModeSelection = isSourceModeSelection and true or false
  frame._displayLookupContext = ns.BuildNavigationLookupContext and ns.BuildNavigationLookupContext() or nil

  frame.itemListContent:SetWidth(ITEM_LAYOUT.rowWidth)
  frame.itemListContent:SetHeight(math.max(1, #displayRows * ITEM_LAYOUT.rowGap))
  ns.RefreshScrollBarVisibility(frame.itemListScroll)
  ns._RenderVisibleItemRows(frame, resetToTop)
end

function ns.BuildHeader(frame)
  local headers = {
    { text = "Item", x = ITEM_LAYOUT.item.x, width = ITEM_LAYOUT.item.width },
    { text = "Source", x = ITEM_LAYOUT.source.x, width = ITEM_LAYOUT.source.width },
    { text = "Status", x = ITEM_LAYOUT.rate.x, width = ITEM_LAYOUT.rate.width },
    { text = "View", x = ITEM_LAYOUT.view.x, width = ITEM_LAYOUT.view.width },
    { text = "Waypoint", x = ITEM_LAYOUT.waypoint.x, width = ITEM_LAYOUT.waypoint.width },
    { text = "Track", x = ITEM_LAYOUT.track.x, width = ITEM_LAYOUT.track.width },
  }

  frame.headerTexts = {}
  for _, h in ipairs(headers) do
    local fs = frame.rightPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    fs:SetPoint("TOPLEFT", ITEM_LAYOUT.contentInsetX + 2 + h.x, ITEM_LAYOUT.headerY)
    fs:SetWidth(h.width)
    fs:SetJustifyH("CENTER")
    fs:SetWordWrap(false)
    if fs.SetMaxLines then
      fs:SetMaxLines(1)
    end
    fs:SetText(h.text)
    fs:SetTextColor(0.84, 0.92, 1.00)
    frame.headerTexts[h.text] = fs
  end
end

function ns.UpdateHeaderLabels(frame)
  if not frame or not frame.headerTexts then
    return
  end
  local selectedEntry = ns.FindSelectedEntry and ns.FindSelectedEntry(frame) or nil
  local activeModeForHeaders = frame.viewMode
  if frame.viewMode == "tracker" and selectedEntry and selectedEntry.trackerKind then
    activeModeForHeaders = tostring(selectedEntry.trackerKind)
  end

  local professionLike = activeModeForHeaders == "professions"
  local sourceLike = ns.IsSourceMode(activeModeForHeaders)

  if professionLike then
    if frame.headerTexts["Source"] then frame.headerTexts["Source"]:SetText("Resource List") end
    if frame.headerTexts["Status"] then
      frame.headerTexts["Status"]:SetText(string.format("Total  |T%s:%d:%d:0:0|t", UIC.MONEY_ICON_GOLD, UIC.MONEY_ICON_SIZE_HEADER, UIC.MONEY_ICON_SIZE_HEADER))
    end
    if frame.headerTexts["View"] then
      frame.headerTexts["View"]:SetText("Profit")
    end
    if frame.headerTexts["Waypoint"] then
      frame.headerTexts["Waypoint"]:SetText("View")
    end
  elseif sourceLike then
    if frame.headerTexts["Source"] then
      if activeModeForHeaders == "vendor" or activeModeForHeaders == "reputation" or activeModeForHeaders == "endeavor" then
        frame.headerTexts["Source"]:SetText("Cost")
      else
        frame.headerTexts["Source"]:SetText("Source")
      end
    end
    if frame.headerTexts["Status"] then
      local statusLabel = "Status"
      if selectedEntry and activeModeForHeaders == "achievement" then
        statusLabel = "Obtained"
      elseif selectedEntry and activeModeForHeaders == "reputation" and EntryUsesRenownRequirements(selectedEntry) then
        statusLabel = "Renown"
      end
      frame.headerTexts["Status"]:SetText(statusLabel)
    end
    if frame.headerTexts["Waypoint"] then
      frame.headerTexts["Waypoint"]:SetText("Waypoint")
    end
    if frame.headerTexts["View"] then
      frame.headerTexts["View"]:SetText("View")
    end
  else
    if frame.headerTexts["Source"] then frame.headerTexts["Source"]:SetText("Drops From") end
    if frame.headerTexts["Status"] then frame.headerTexts["Status"]:SetText("Rate") end
    if frame.headerTexts["Waypoint"] then frame.headerTexts["Waypoint"]:SetText("Waypoint") end
    if frame.headerTexts["View"] then frame.headerTexts["View"]:SetText("View") end
  end
end

function ns._AnyExpansionExpanded(frame)
  if frame.viewMode == "tracker" then
    local trackerCollapsed = DecorDropsDB.collapsedTrackerSections or {}
    local has = {
      instances = false,
      professions = false,
      vendor = false,
      achievement = false,
      quest = false,
      reputation = false,
      endeavor = false,
    }
    for _, entry in ipairs(frame.filtered or {}) do
      local kind = tostring(entry.trackerKind or "instances")
      if has[kind] ~= nil then
        has[kind] = true
      end
    end

    if has.instances and not trackerCollapsed.instances then
      return true
    end
    if has.professions and not trackerCollapsed.professions then
      return true
    end

    for _, sourceMode in ipairs(ns._SOURCE_MODE_ORDER) do
      if has[sourceMode] then
        local sourceSectionKey = "source:" .. sourceMode
        if not trackerCollapsed[sourceSectionKey] then
          local expansionSeen = {}
          local zoneSeen = {}
          for _, entry in ipairs(frame.filtered or {}) do
            if entry.trackerKind == sourceMode then
              local expansion = ns._NormalizeSourceExpansionLabel(entry.expansion, entry.zoneName)
              local expansionCollapseKey = string.lower("tracker|" .. sourceMode .. "|" .. tostring(expansion or "Other"))
              if not expansionSeen[expansionCollapseKey] then
                expansionSeen[expansionCollapseKey] = true
                if not CollapseState.IsExpansionCollapsed(frame, expansionCollapseKey) then
                  return true
                end
              end
              local zoneName = ns._NormalizeSourceZoneLabel(entry.zoneName)
              local zoneCollapseKey = string.lower(expansionCollapseKey .. "|" .. tostring(zoneName or "Unknown Zone"))
              if not zoneSeen[zoneCollapseKey] then
                zoneSeen[zoneCollapseKey] = true
                if not CollapseState.IsSourceZoneCollapsed(frame, expansionCollapseKey, zoneName) then
                  return true
                end
              end
            end
          end
        end
      end
    end
    return false
  end

  if frame.viewMode == "professions" then
    local seen = {}
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = ns._NormalizeCategoryLabel(entry.profession and entry.profession.expansion, "Other")
      if not seen[expansion] then
        seen[expansion] = true
        if not CollapseState.IsExpansionCollapsed(frame, expansion) then
          return true
        end
      end
    end
    return false
  end

  if ns.IsSourceMode(frame.viewMode) then
    local seen = {}
    local zoneSeen = {}
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = frame.viewMode == "endeavor"
        and ns._NormalizeSourceZoneLabel(entry.zoneName)
        or ns._NormalizeSourceExpansionLabel(entry.expansion, entry.zoneName)
      local zoneName = ns._NormalizeSourceZoneLabel(entry.zoneName)
      if not seen[expansion] then
        seen[expansion] = true
        if not CollapseState.IsExpansionCollapsed(frame, expansion) then
          return true
        end
      end
      local zoneKey = BuildNewSourceZoneKey(expansion, zoneName)
      if not zoneSeen[zoneKey] then
        zoneSeen[zoneKey] = true
        if not CollapseState.IsSourceZoneCollapsed(frame, expansion, zoneName) then
          return true
        end
      end
    end
    return false
  end

  local seen = {}
  local hasCurrentSeason = false
  local currentSeasonLookup = {}

  if C_ChallengeMode and C_ChallengeMode.GetMapTable and C_ChallengeMode.GetMapUIInfo then
    local mapTable = C_ChallengeMode.GetMapTable() or {}
    for _, challengeMapID in ipairs(mapTable) do
      local mapName = C_ChallengeMode.GetMapUIInfo(challengeMapID)
      if mapName and mapName ~= "" then
        currentSeasonLookup[SafeLower(mapName)] = true
      end
    end
  end

  for _, entry in ipairs(frame.filtered or {}) do
    local expansion = ExpansionName(entry.instance)
    if not seen[expansion] then
      seen[expansion] = true
      if not CollapseState.IsExpansionCollapsed(frame, expansion) then
        return true
      end
    end

    if not hasCurrentSeason and (expansion == UIC.CURRENT_SEASON_GROUP or currentSeasonLookup[SafeLower(entry.instance.name)]) then
      hasCurrentSeason = true
    end
  end

  if hasCurrentSeason and not CollapseState.IsExpansionCollapsed(frame, UIC.CURRENT_SEASON_GROUP) then
    return true
  end

  return false
end

function ns._SetAllExpansionsCollapsed(frame, collapse)
  if frame.viewMode == "tracker" then
    DecorDropsDB.collapsedTrackerSections = DecorDropsDB.collapsedTrackerSections or {}
    local trackerCollapsed = DecorDropsDB.collapsedTrackerSections
    local has = {
      instances = false,
      professions = false,
      vendor = false,
      achievement = false,
      quest = false,
      reputation = false,
      endeavor = false,
    }
    for _, entry in ipairs(frame.filtered or {}) do
      local kind = tostring(entry.trackerKind or "instances")
      if has[kind] ~= nil then
        has[kind] = true
      end
    end

    if has.instances then
      trackerCollapsed.instances = collapse and true or nil
    end
    if has.professions then
      trackerCollapsed.professions = collapse and true or nil
    end

    for _, sourceMode in ipairs(ns._SOURCE_MODE_ORDER) do
      if has[sourceMode] then
        local sourceSectionKey = "source:" .. sourceMode
        trackerCollapsed[sourceSectionKey] = collapse and true or nil
        for _, entry in ipairs(frame.filtered or {}) do
          if entry.trackerKind == sourceMode then
            local expansion = ns._NormalizeSourceExpansionLabel(entry.expansion, entry.zoneName)
            local zoneName = ns._NormalizeSourceZoneLabel(entry.zoneName)
            local expansionCollapseKey = string.lower("tracker|" .. sourceMode .. "|" .. tostring(expansion or "Other"))
            CollapseState.SetExpansionCollapsed(frame, expansionCollapseKey, collapse and true or false)
            CollapseState.SetSourceZoneCollapsed(frame, expansionCollapseKey, zoneName, collapse and true or false)
          end
        end
      end
    end
    return
  end

  if frame.viewMode == "professions" then
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = ns._NormalizeCategoryLabel(entry.profession and entry.profession.expansion, "Other")
      CollapseState.SetExpansionCollapsed(frame, expansion, collapse and true or false)
    end
    return
  end

  if ns.IsSourceMode(frame.viewMode) then
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = frame.viewMode == "endeavor"
        and ns._NormalizeSourceZoneLabel(entry.zoneName)
        or ns._NormalizeSourceExpansionLabel(entry.expansion, entry.zoneName)
      local zoneName = ns._NormalizeSourceZoneLabel(entry.zoneName)
      CollapseState.SetExpansionCollapsed(frame, expansion, collapse and true or false)
      CollapseState.SetSourceZoneCollapsed(frame, expansion, zoneName, collapse and true or false)
    end
    return
  end

  local currentSeasonLookup = {}
  local hasCurrentSeason = false

  if C_ChallengeMode and C_ChallengeMode.GetMapTable and C_ChallengeMode.GetMapUIInfo then
    local mapTable = C_ChallengeMode.GetMapTable() or {}
    for _, challengeMapID in ipairs(mapTable) do
      local mapName = C_ChallengeMode.GetMapUIInfo(challengeMapID)
      if mapName and mapName ~= "" then
        currentSeasonLookup[SafeLower(mapName)] = true
      end
    end
  end

  for _, entry in ipairs(frame.filtered or {}) do
    local expansion = ExpansionName(entry.instance)
    CollapseState.SetExpansionCollapsed(frame, expansion, collapse and true or false)
    if not hasCurrentSeason and (expansion == UIC.CURRENT_SEASON_GROUP or currentSeasonLookup[SafeLower(entry.instance.name)]) then
      hasCurrentSeason = true
    end
  end

  if hasCurrentSeason then
    CollapseState.SetExpansionCollapsed(frame, UIC.CURRENT_SEASON_GROUP, collapse and true or false)
  end
end

function ns._UpdateExpandCollapseButtonVisual(frame)
  if not frame or not frame.expandCollapseButton then
    return
  end

  local expanded = ns._AnyExpansionExpanded(frame)
  frame.expandCollapseButton:SetText(expanded and "|cffffd25a-|r" or "|cffffd25a+|r")

  local fs = frame.expandCollapseButton.GetFontString and frame.expandCollapseButton:GetFontString() or nil
  if fs then
    fs:SetTextColor(1.0, 0.82, 0.35)
  end
end

function ns._GetScrollBar(scrollFrame)
  if not scrollFrame then
    return nil
  end
  return scrollFrame._ddScrollBar
    or scrollFrame.ScrollBar
    or scrollFrame.scrollBar
    or (_G and scrollFrame.GetName and _G[scrollFrame:GetName() .. "ScrollBar"])
end

function ns._ConfigureScrollBarPosition(scrollFrame)
  local bar = ns._GetScrollBar(scrollFrame)
  if not bar then
    return
  end
  local xOffset = tonumber(scrollFrame and scrollFrame._ddScrollBarXOffset) or -4
  local anchorSide = scrollFrame and scrollFrame._ddScrollBarAnchorSide or "right"
  local topPoint = anchorSide == "left" and "TOPLEFT" or "TOPRIGHT"
  local bottomPoint = anchorSide == "left" and "BOTTOMLEFT" or "BOTTOMRIGHT"
  bar:ClearAllPoints()
  bar:SetPoint("TOP", scrollFrame, topPoint, xOffset, -16)
  bar:SetPoint("BOTTOM", scrollFrame, bottomPoint, xOffset, 16)
end

function ns._ApplyScrollBarOpacity(scrollFrame, alpha)
  if not scrollFrame then
    return
  end

  local a = alpha or 1.0
  local bar = ns._GetScrollBar(scrollFrame)
  if not bar then
    return
  end

  ns._ConfigureScrollBarPosition(scrollFrame)
  bar:Show()
  bar:SetAlpha(a)

  local textures = {
    bar.TrackBG,
    bar.Track,
    bar.ThumbTexture,
    bar.Bg,
    bar.Background,
    bar.Top,
    bar.Bottom,
    bar.Middle,
  }
  for _, tex in ipairs(textures) do
    if tex and tex.SetAlpha then
      tex:SetAlpha(a)
    end
    if tex and tex.Show then
      tex:Show()
    end
  end

  if bar.ScrollUpButton then
    bar.ScrollUpButton:SetAlpha(a)
    bar.ScrollUpButton:Show()
  end
  if bar.ScrollDownButton then
    bar.ScrollDownButton:SetAlpha(a)
    bar.ScrollDownButton:Show()
  end
end

function ns.RefreshScrollBarVisibility(scrollFrame)
  if not scrollFrame then
    return
  end
  local bar = ns._GetScrollBar(scrollFrame)
  if not bar then
    return
  end

  local frameH = scrollFrame.GetHeight and (tonumber(scrollFrame:GetHeight()) or 0) or 0
  local child = scrollFrame.GetScrollChild and scrollFrame:GetScrollChild() or nil
  local childH = (child and child.GetHeight and tonumber(child:GetHeight())) or 0
  local range = 0
  if scrollFrame.GetVerticalScrollRange then
    range = tonumber(scrollFrame:GetVerticalScrollRange()) or 0
  end
  -- Blizzard's vertical range can lag one frame; use child-vs-frame overflow as primary signal.
  local hasScroll = (childH - frameH) > 1 or range > 1

  local upBtn = bar.ScrollUpButton
    or (_G and bar.GetName and _G[bar:GetName() .. "ScrollUpButton"])
  local downBtn = bar.ScrollDownButton
    or (_G and bar.GetName and _G[bar:GetName() .. "ScrollDownButton"])
  local thumb = bar.ThumbTexture
    or (_G and bar.GetName and _G[bar:GetName() .. "ThumbTexture"])

  bar:Show()
  bar:SetWidth(16)
  if bar.EnableMouse then
    bar:EnableMouse(hasScroll)
  end
  bar:SetAlpha(hasScroll and 1.0 or 0.85)
  if upBtn then
    upBtn:SetShown(true)
    if upBtn.EnableMouse then
      upBtn:EnableMouse(hasScroll)
    end
    upBtn:SetAlpha(hasScroll and 1.0 or 0.80)
  end
  if downBtn then
    downBtn:SetShown(true)
    if downBtn.EnableMouse then
      downBtn:EnableMouse(hasScroll)
    end
    downBtn:SetAlpha(hasScroll and 1.0 or 0.80)
  end
  if thumb then
    thumb:SetShown(true)
    thumb:SetAlpha(hasScroll and 1.0 or 0.65)
  end
end

function ns.ApplyFrameScaleAndClamp(frame)
  if not frame or not UIParent then
    return
  end

  local screenW = UIParent:GetWidth() or 0
  local screenH = UIParent:GetHeight() or 0
  if screenW <= 0 or screenH <= 0 then
    return
  end

  local pad = 28
  local scaleW = (screenW - pad) / UIC.BASE_FRAME_WIDTH
  local scaleH = (screenH - pad) / UIC.BASE_FRAME_HEIGHT
  local targetScale = math.min(1.0, scaleW, scaleH)
  targetScale = math.max(0.56, targetScale)

  frame:SetScale(targetScale)
  frame:SetClampedToScreen(true)

  local left, right, top, bottom = frame:GetLeft(), frame:GetRight(), frame:GetTop(), frame:GetBottom()
  if not left or not right or not top or not bottom then
    return
  end

  local nudgeX = 0
  local nudgeY = 0
  local edge = 6

  if left < edge then
    nudgeX = edge - left
  elseif right > (screenW - edge) then
    nudgeX = (screenW - edge) - right
  end

  if bottom < edge then
    nudgeY = edge - bottom
  elseif top > (screenH - edge) then
    nudgeY = (screenH - edge) - top
  end

  if nudgeX ~= 0 or nudgeY ~= 0 then
    local centerX = (left + right) * 0.5 + nudgeX
    local centerY = (top + bottom) * 0.5 + nudgeY
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "BOTTOMLEFT", centerX, centerY)
  end
end

function ns.ReflowMainFrame()
  if ns.MainFrame then
    ns.ApplyFrameScaleAndClamp(ns.MainFrame)
  end
end

function ns.SetBottomTabVisual(button, active)
  if not button then
    return
  end

  if active then
    if PanelTemplates_SelectTab then
      PanelTemplates_SelectTab(button)
    end
    if button.text then
      button.text:SetTextColor(1.00, 0.93, 0.62)
    elseif button.GetFontString and button:GetFontString() then
      button:GetFontString():SetTextColor(1.00, 0.93, 0.62)
    end
    button:SetAlpha(1.0)
    if button.glow then
      button.glow:Show()
    end
  else
    if PanelTemplates_DeselectTab then
      PanelTemplates_DeselectTab(button)
    end
    if button.text then
      button.text:SetTextColor(0.86, 0.78, 0.64)
    elseif button.GetFontString and button:GetFontString() then
      button:GetFontString():SetTextColor(0.86, 0.78, 0.64)
    end
    button:SetAlpha(0.97)
    if button.glow then
      button.glow:Hide()
    end
  end
end

function ns.UpdateModeTabVisuals(frame)
  if not frame or not frame.modeInstancesButton or not frame.modeProfessionsButton or not frame.modeTrackerButton then
    return
  end

  local highlightMode = frame.activeTab == "Catalog"
  local activeMode = frame.viewMode
  if activeMode ~= "professions"
    and activeMode ~= "tracker"
    and not ns.IsSourceMode(activeMode) then
    activeMode = "instances"
  end

  local function ApplyModeTabVisual(btn, active)
    if not btn then
      return
    end
    if btn.SetBackdropColor then
      if active then
        btn:SetBackdropColor(0.20, 0.17, 0.12, 0.98)
        btn:SetBackdropBorderColor(1.00, 0.86, 0.24, 0.95)
      else
        btn:SetBackdropColor(0.06, 0.06, 0.06, 0.94)
        btn:SetBackdropBorderColor(0.40, 0.38, 0.34, 0.90)
      end
    end
    if btn.SelectedTexture then
      btn.SelectedTexture:SetShown(active)
    end
    if btn.OuterGlow then
      btn.OuterGlow:SetShown(active)
    end
    if btn.leftHalf then
      btn.leftHalf:SetAlpha(active and 1.0 or 0.70)
    end
    if btn.rightHalf then
      btn.rightHalf:SetAlpha(active and 1.0 or 0.70)
    end
    if btn.icon then
      btn.icon:SetAlpha(active and 1.0 or 0.70)
    end
    btn:SetAlpha(active and 1.0 or 0.92)
  end

  ApplyModeTabVisual(frame.modeInstancesButton, highlightMode and activeMode == "instances")
  ApplyModeTabVisual(frame.modeProfessionsButton, highlightMode and activeMode == "professions")
  ApplyModeTabVisual(frame.modeTrackerButton, highlightMode and activeMode == "tracker")
  ApplyModeTabVisual(frame.modeVendorButton, highlightMode and activeMode == "vendor")
  ApplyModeTabVisual(frame.modeAchievementButton, highlightMode and activeMode == "achievement")
  ApplyModeTabVisual(frame.modeQuestButton, highlightMode and activeMode == "quest")
  ApplyModeTabVisual(frame.modeReputationButton, highlightMode and activeMode == "reputation")
  ApplyModeTabVisual(frame.modeEndeavorButton, highlightMode and activeMode == "endeavor")
  ApplyModeTabVisual(frame.modePromotionButton, highlightMode and activeMode == "promotion")
end

function ns.UpdateTopFilterControlsLayout(frame)
  if not frame or not frame.searchBox or not frame.collectedButton or not frame.scanButton or not frame.resetButton then
    return
  end

  local mode = frame.viewMode
  if mode ~= "professions" and mode ~= "tracker" and not ns.IsSourceMode(mode) then
    mode = "instances"
  end

  local anchorButton = frame.typeButton
  if mode == "professions" and frame.professionFilterButton then
    anchorButton = frame.professionFilterButton
  elseif ns.IsSourceMode(mode) then
    anchorButton = nil
  end

  if anchorButton then
    anchorButton:ClearAllPoints()
    anchorButton:SetPoint("LEFT", frame.searchBox, "RIGHT", UIC.SEARCH_TO_FILTER_GAP, 0)
    frame.collectedButton:ClearAllPoints()
    frame.collectedButton:SetPoint("LEFT", anchorButton, "RIGHT", 8, 0)
  else
    frame.collectedButton:ClearAllPoints()
    frame.collectedButton:SetPoint("LEFT", frame.searchBox, "RIGHT", UIC.SEARCH_TO_FILTER_GAP, 0)
  end

  local showScanButton = ns.IsDeveloperToolsEnabled and ns.IsDeveloperToolsEnabled()
  frame.scanButton:SetShown(showScanButton)
  frame.scanButton:ClearAllPoints()
  if showScanButton then
    frame.scanButton:SetPoint("LEFT", frame.collectedButton, "RIGHT", 8, 0)
  end
  frame.resetButton:ClearAllPoints()
  if showScanButton then
    frame.resetButton:SetPoint("LEFT", frame.scanButton, "RIGHT", 8, 0)
  else
    frame.resetButton:SetPoint("LEFT", frame.collectedButton, "RIGHT", 8, 0)
  end
  if frame.clearWaypointsButton then
    frame.clearWaypointsButton:ClearAllPoints()
    frame.clearWaypointsButton:SetPoint("LEFT", frame.resetButton, "RIGHT", 8, 0)
  end
end

function ns.SetActiveBottomTab(frame, tabKey)
  DecorDropsDB = DecorDropsDB or {}
  DecorDropsDB.settings = DecorDropsDB.settings or {}
  local settings = DecorDropsDB and DecorDropsDB.settings or nil
  local showGarrisonTab = settings and settings.showGarrisonStatusTab == true
  local showOrderHallTab = settings and settings.showOrderHallStatusTab == true

  if tabKey == "Garrisons" and not showGarrisonTab then
    tabKey = "Catalog"
  elseif tabKey == "OrderHalls" and not showOrderHallTab then
    tabKey = "Catalog"
  end

  frame.activeTab = tabKey or "Catalog"
  local showCatalog = frame.activeTab == "Catalog"
  local showInfo = frame.activeTab == "Info"
  local showFeatures = frame.activeTab == "Features"
  local showKnownIssues = frame.activeTab == "KnownIssues"
  local showSettings = frame.activeTab == "Settings"
  local showGarrisons = frame.activeTab == "Garrisons"
  local showOrderHalls = frame.activeTab == "OrderHalls"
  local showCurrency = frame.activeTab == "Currency"

  if frame.catalogWidgets then
    for _, widget in ipairs(frame.catalogWidgets) do
      if widget then
        widget:SetShown(showCatalog)
      end
    end
  end
  if frame.professionFilterMenu and not showCatalog then
    frame.professionFilterMenu:Hide()
  end

  if frame.infoPanel then
    frame.infoPanel:SetShown(showInfo)
  end

  if frame.featuresPanel then
    frame.featuresPanel:SetShown(showFeatures)
  end

  if frame.knownIssuesPanel then
    frame.knownIssuesPanel:SetShown(showKnownIssues)
  end

  if frame.settingsPanel then
    frame.settingsPanel:SetShown(showSettings)
  end
  if frame.garrisonsPanel then
    frame.garrisonsPanel:SetShown(showGarrisons and showGarrisonTab)
  end
  if frame.orderHallsPanel then
    frame.orderHallsPanel:SetShown(showOrderHalls and showOrderHallTab)
  end
  if frame.currencyPanel then
    frame.currencyPanel:SetShown(showCurrency)
  end

  ns.SetBottomTabVisual(frame.infoTab, showInfo)
  ns.SetBottomTabVisual(frame.featuresTab, showFeatures)
  ns.SetBottomTabVisual(frame.knownIssuesTab, showKnownIssues)
  ns.SetBottomTabVisual(frame.settingsTab, showSettings)
  if frame.garrisonsTab then
    ns.SetBottomTabVisual(frame.garrisonsTab, showGarrisons and showGarrisonTab)
  end
  if frame.orderHallsTab then
    ns.SetBottomTabVisual(frame.orderHallsTab, showOrderHalls and showOrderHallTab)
  end
  if frame.currencyTab then
    ns.SetBottomTabVisual(frame.currencyTab, showCurrency)
  end
  if (showGarrisons and showGarrisonTab) or (showOrderHalls and showOrderHallTab) then
    if frame.RefreshMissionStatusPanels then
      frame:RefreshMissionStatusPanels(true)
    end
  end
  if showCurrency and ns.RefreshCurrencyStatusPanel then
    ns.RefreshCurrencyStatusPanel(frame, true)
  end
  if showSettings and frame.RefreshSettingsCategoryLayout then
    frame:RefreshSettingsCategoryLayout()
  end
  ns.UpdateModeTabVisuals(frame)
end

function ns.SetViewMode(frame, mode)
  if not frame then
    return
  end
  local priorSuppress = frame._suppressFilterControlEvents
  frame._suppressFilterControlEvents = true
  if frame.searchBox and frame.searchBox.HasFocus and frame.searchBox:HasFocus() and frame.searchBox.ClearFocus then
    frame.searchBox:ClearFocus()
  end
  local isInitialModeSet = not frame._hasInitializedViewMode
  local previousMode = frame.viewMode or DecorDropsDB.viewMode or "instances"
  frame._sessionFiltersByMode = frame._sessionFiltersByMode or {}
  if DecorDropsDB.selectedKey then
    RememberSelectedKeyForMode(frame, previousMode, DecorDropsDB.selectedKey)
  end
  if not isInitialModeSet then
    frame._sessionFiltersByMode[previousMode] = ns._CopyFilterState(frame.filters)
  end
  if mode ~= "professions" and mode ~= "tracker" and not ns.IsSourceMode(mode) then
    mode = "instances"
  end
  frame.viewMode = mode
  DecorDropsDB.viewMode = frame.viewMode
  if isInitialModeSet then
    ns._ApplyFilterState(frame, frame.filters, true)
    frame._sessionFiltersByMode[frame.viewMode] = ns._CopyFilterState(frame.filters)
  else
    local restoredFilters = frame._sessionFiltersByMode[frame.viewMode]
    if restoredFilters then
      ns._ApplyFilterState(frame, restoredFilters, true)
    else
      ns.ApplyDefaultFilters(frame, true)
      frame._sessionFiltersByMode[frame.viewMode] = ns._CopyFilterState(frame.filters)
    end
  end
  frame._hasInitializedViewMode = true
  if frame._sessionSelectedKeyByMode then
    local restoredKey = frame._sessionSelectedKeyByMode[frame.viewMode]
    if type(restoredKey) == "string" and restoredKey ~= "" then
      DecorDropsDB.selectedKey = restoredKey
    else
      DecorDropsDB.selectedKey = nil
    end
  else
    DecorDropsDB.selectedKey = nil
  end
  ns.SetActiveBottomTab(frame, "Catalog")

  if frame.typeButton then
    frame.typeButton:SetShown(frame.viewMode == "instances")
  end
  if frame.professionFilterButton then
    frame.professionFilterButton:SetShown(frame.viewMode == "professions")
  end
  if frame.professionFilterMenu and frame.viewMode ~= "professions" then
    frame.professionFilterMenu:Hide()
  end
  if frame.scanButton then
    local showScanButton = ns.IsDeveloperToolsEnabled and ns.IsDeveloperToolsEnabled()
    frame.scanButton:SetShown(showScanButton)
  end
  if frame.knownProfessionOnlyCheck then
    frame.knownProfessionOnlyCheck:SetShown(frame.viewMode == "professions")
  end
  if frame.knownProfessionOnlyLabel then
    frame.knownProfessionOnlyLabel:SetShown(frame.viewMode == "professions")
  end
  local showCompletionToggle = (frame.viewMode ~= "promotion")
    and (frame.viewMode == "instances" or frame.viewMode == "professions" or frame.viewMode == "tracker" or ns.IsSourceMode(frame.viewMode))
  if frame.showCompletionPercentCheck then
    frame.showCompletionPercentCheck:SetShown(showCompletionToggle)
    frame.showCompletionPercentCheck:ClearAllPoints()
    if frame.viewMode == "professions" and frame.knownProfessionOnlyLabel then
      frame.showCompletionPercentCheck:SetPoint("LEFT", frame.knownProfessionOnlyLabel, "RIGHT", 14, 0)
    else
      frame.showCompletionPercentCheck:SetPoint("BOTTOMRIGHT", -108, 5)
    end
  end
  if frame.showCompletionPercentLabel then
    frame.showCompletionPercentLabel:SetShown(showCompletionToggle)
    frame.showCompletionPercentLabel:ClearAllPoints()
    if frame.showCompletionPercentCheck then
      frame.showCompletionPercentLabel:SetPoint("LEFT", frame.showCompletionPercentCheck, "RIGHT", 2, 1)
    end
  end
  if frame.expandCollapseButton then
    frame.expandCollapseButton:SetShown(true)
  end
  if frame.leftTitle then
    if frame.viewMode == "professions" then
      frame.leftTitle:SetText("Professions")
    elseif frame.viewMode == "tracker" then
      frame.leftTitle:SetText("Tracker")
    elseif ns.IsSourceMode(frame.viewMode) then
      frame.leftTitle:SetText(ns.GetSourceModeLabel(frame.viewMode))
    else
      frame.leftTitle:SetText("Instances")
    end
  end

  ns.UpdateTopFilterControlsLayout(frame)
  ns.UpdateModeTabVisuals(frame)
  if frame.UpdateProfessionFilterButtonText then
    frame:UpdateProfessionFilterButtonText()
  end

  ns.UpdateHeaderLabels(frame)
  frame._suppressFilterControlEvents = priorSuppress
  if frame.Refresh then
    frame:Refresh()
  end
end

function ns.BuildInfoPanelText()
  local parts = { "|cff00b4ffDecorDrops|r" }

  local function VersionSortKey(versionText)
    local a, b, c = tostring(versionText or ""):match("^(%d+)%.(%d+)%.(%d+)$")
    return (tonumber(a) or 0) * 1000000 + (tonumber(b) or 0) * 1000 + (tonumber(c) or 0)
  end

  local function AppendChangelogDropdown(section)
    local grouped = {}
    local order = {}
    for _, rawLine in ipairs(section.lines or {}) do
      local line = tostring(rawLine or "")
      local version, note = line:match("^%- v([%d%.]+):%s*(.+)$")
      if version and note then
        if not grouped[version] then
          grouped[version] = {}
          order[#order + 1] = version
        end
        grouped[version][#grouped[version] + 1] = "- " .. tostring(note)
      end
    end

    table.sort(order, function(a, b)
      return VersionSortKey(a) > VersionSortKey(b)
    end)

    local latestVersion = order[1]
    for _, version in ipairs(order) do
      local entries = grouped[version] or {}
      local isOpen = version == latestVersion
      if isOpen then
        parts[#parts + 1] = string.format("|cffffd25a[-] v%s|r", version)
        for _, entry in ipairs(entries) do
          parts[#parts + 1] = entry
        end
      else
        parts[#parts + 1] = string.format("|cffffd25a[+] v%s|r", version)
      end
      parts[#parts + 1] = ""
    end
  end

  for _, section in ipairs(INFO_PAGE_TEMPLATE) do
    parts[#parts + 1] = ""
    parts[#parts + 1] = string.format("|cffffd25a%s|r", section.title or "Section")
    if tostring(section.title or "") == "Changelog" then
      AppendChangelogDropdown(section)
    else
      for _, line in ipairs(section.lines or {}) do
        parts[#parts + 1] = tostring(line)
      end
    end
  end
  return table.concat(parts, "\n")
end

function ns.BuildInfoSectionList()
  local sections = {}
  for _, section in ipairs(INFO_PAGE_TEMPLATE or {}) do
    local title = tostring(section.title or "Section")
    if title == "Version" then
      -- Rendered separately above the collapsible list.
    elseif title == "Changelog" then
      local grouped = {}
      local order = {}
      for _, rawLine in ipairs(section.lines or {}) do
        local line = tostring(rawLine or "")
        local version, note = line:match("^%- v([%d%.]+):%s*(.+)$")
        if version and note then
          if not grouped[version] then
            grouped[version] = {}
            order[#order + 1] = version
          end
          grouped[version][#grouped[version] + 1] = "- " .. tostring(note)
        end
      end
      table.sort(order, function(a, b)
        local amajor, aminor, apatch = tostring(a):match("^(%d+)%.(%d+)%.(%d+)$")
        local bmajor, bminor, bpatch = tostring(b):match("^(%d+)%.(%d+)%.(%d+)$")
        local ak = (tonumber(amajor) or 0) * 1000000 + (tonumber(aminor) or 0) * 1000 + (tonumber(apatch) or 0)
        local bk = (tonumber(bmajor) or 0) * 1000000 + (tonumber(bminor) or 0) * 1000 + (tonumber(bpatch) or 0)
        return ak > bk
      end)
      for index, version in ipairs(order) do
        sections[#sections + 1] = {
          key = "changelog_" .. tostring(version),
          title = "Changelog v" .. tostring(version),
          lines = grouped[version] or {},
          defaultExpanded = index == 1,
        }
      end
    else
      sections[#sections + 1] = {
        key = "info_" .. title:gsub("%s+", "_"):lower(),
        title = title,
        lines = section.lines or {},
        defaultExpanded = false,
      }
    end
  end
  return sections
end

function ns.GetInfoVersionText()
  for _, section in ipairs(INFO_PAGE_TEMPLATE or {}) do
    if tostring(section.title or "") == "Version" then
      local first = section.lines and section.lines[1]
      if first and tostring(first) ~= "" then
        return tostring(first)
      end
    end
  end
  return "DecorDrops"
end

function ns.BuildInfoPanel(frame)
  if not frame or not frame.infoPanel or not frame.infoContent then
    return
  end

  frame.infoRows = frame.infoRows or {}
  frame.infoExpanded = frame.infoExpanded or {}

  local sections = ns.BuildInfoSectionList()
  local content = frame.infoContent
  -- Dedicated Info-page layout (independent from other scroll pages).
  local contentWidth = 1028
  local bodyWidth = 1028
  local sectionOffsetX = 10
  local headerHeight = 34
  local headerGapSouth = 10 -- includes requested +4px extra south padding.
  local bodyGapSouth = 16
  local y = -10

  for i, section in ipairs(sections) do
    local row = frame.infoRows[i]
    if not row then
      row = {}
      frame.infoRows[i] = row
    end

    if not row.header then
      row.header = CreateFrame("Button", nil, content, "BackdropTemplate")
      row.header:SetSize(contentWidth, headerHeight)
      row.header:SetBackdrop({
        bgFile = UIC.THEME_MARBLE,
        edgeFile = UIC.THEME_BORDER,
        edgeSize = 10,
        insets = { left = 2, right = 2, top = 2, bottom = 2 },
      })
      row.header:SetScript("OnClick", function(selfBtn)
        local key = selfBtn._ddSectionKey
        if key and frame.infoExpanded then
          frame.infoExpanded[key] = not frame.infoExpanded[key]
          ns.BuildInfoPanel(frame)
        end
      end)

      row.header.arrow = row.header:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.header.arrow:SetPoint("RIGHT", row.header, "RIGHT", -12, 0)
      row.header.arrow:SetJustifyH("RIGHT")

      row.header.title = row.header:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
      row.header.title:SetPoint("LEFT", row.header, "LEFT", 18, 0)
      row.header.title:SetPoint("RIGHT", row.header, "RIGHT", -36, 0)
      row.header.title:SetJustifyH("CENTER")
      local fontPath, fontSize, flags = row.header.title:GetFont()
      if fontPath and fontSize and row.header.title.SetFont then
        row.header.title:SetFont(fontPath, math.max(10, fontSize * 1.10), flags)
      end
    end

    if not row.body then
      row.body = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.body:SetWidth(bodyWidth)
      row.body:SetJustifyH("CENTER")
      row.body:SetJustifyV("TOP")
      local fontPath, _, flags = row.body:GetFont()
      if fontPath and row.body.SetFont then
        row.body:SetFont(fontPath, 15, flags)
      end
    end

    if frame.infoExpanded[section.key] == nil then
      frame.infoExpanded[section.key] = section.defaultExpanded and true or false
    end
    local expanded = frame.infoExpanded[section.key] and true or false

    row.header._ddSectionKey = section.key
    row.header.title:SetText(tostring(section.title or "Section"))
    row.header.arrow:SetText(expanded and "[-]" or "[+]")
    row.header.title:SetTextColor(1.00, 0.93, 0.78)
    row.header.arrow:SetTextColor(1.00, 0.84, 0.22)
    row.header:SetBackdropColor(0.64, 0.52, 0.38, 0.92)
    row.header:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.90)
    row.header:SetPoint("TOPLEFT", content, "TOPLEFT", sectionOffsetX, y)
    row.header:Show()
    y = y - headerHeight - headerGapSouth

    if expanded then
      row.body:SetPoint("TOPLEFT", content, "TOPLEFT", sectionOffsetX, y)
      row.body:SetText(table.concat(section.lines or {}, "\n"))
      row.body:Show()
      y = y - math.max(26, row.body:GetStringHeight() + bodyGapSouth)
    else
      row.body:Hide()
    end
  end

  for i = #sections + 1, #frame.infoRows do
    local row = frame.infoRows[i]
    if row.header then
      row.header:Hide()
    end
    if row.body then
      row.body:Hide()
    end
  end

  content:SetSize(contentWidth, math.max(1, -y + 12))
  if frame.infoScroll and frame.infoScroll.SetVerticalScroll then
    frame.infoScroll:SetVerticalScroll(0)
  end
end

function ns.BuildFeaturesPanel(frame)
  if not frame or not frame.featuresPanel then
    return
  end

  frame.featuresRows = frame.featuresRows or {}

  local content = frame.featuresContent
  local contentWidth = 1048
  local bodyWidth = 1026
  local y = -10
  for i, section in ipairs(FEATURES_PAGE_TEMPLATE) do
    local row = frame.featuresRows[i]
    if not row then
      row = {}
      frame.featuresRows[i] = row
    end

    if not row.header then
      row.header = CreateFrame("Frame", nil, content, "BackdropTemplate")
      row.header:SetSize(contentWidth, 30)
      row.header:SetBackdrop({
        bgFile = UIC.THEME_MARBLE,
        edgeFile = UIC.THEME_BORDER,
        edgeSize = 10,
        insets = { left = 2, right = 2, top = 2, bottom = 2 },
      })
      row.header.title = row.header:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
      row.header.title:SetPoint("CENTER", row.header, "CENTER", 0, 0)
      row.header.title:SetJustifyH("CENTER")
    end
    if not row.body then
      row.body = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.body:SetWidth(bodyWidth)
      row.body:SetJustifyH("CENTER")
      row.body:SetJustifyV("TOP")
      local fontPath, _, flags = row.body:GetFont()
      if fontPath and row.body.SetFont then
        row.body:SetFont(fontPath, 15, flags)
      end
    end

    row.header.title:SetText(section.title)
    row.header:SetBackdropColor(0.64, 0.52, 0.38, 0.92)
    row.header:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.90)
    row.header:SetPoint("TOP", content, "TOP", 0, y)
    row.header:Show()
    y = y - 36

    row.body:SetPoint("TOP", content, "TOP", 0, y)
    row.body:SetText(table.concat(section.lines or {}, "\n"))
    row.body:Show()
    y = y - math.max(30, row.body:GetStringHeight() + 18)
  end

  for i = #FEATURES_PAGE_TEMPLATE + 1, #frame.featuresRows do
    local row = frame.featuresRows[i]
    if row.header then
      row.header:Hide()
    end
    if row.body then
      row.body:Hide()
    end
  end

  content:SetSize(contentWidth, math.max(1, -y + 12))
end

function ns.BuildKnownIssuesPanel(frame)
  if not frame or not frame.knownIssuesPanel then
    return
  end
  frame.knownIssuesRows = frame.knownIssuesRows or {}
  if frame.knownIssuesBody then
    frame.knownIssuesBody:Hide()
  end

  local panel = frame.knownIssuesPanel
  local y = -66
  local headerToBodyGap = 8
  local sectionGap = 14

  for index, section in ipairs(ns._KNOWN_ISSUES_PAGE_TEMPLATE or {}) do
    local row = frame.knownIssuesRows[index]
    if not row then
      row = {}
      frame.knownIssuesRows[index] = row
    end

    if not row.header then
      row.header = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
      row.header:SetJustifyH("CENTER")
      row.header:SetTextColor(1.00, 0.82, 0.35)
      local fontPath, fontSize, fontFlags = row.header:GetFont()
      if fontPath and fontSize then
        row.header:SetFont(fontPath, math.max(9, fontSize * 1.05), fontFlags)
      end
    end
    if not row.body then
      row.body = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.body:SetWidth(940)
      row.body:SetJustifyH("CENTER")
      row.body:SetJustifyV("TOP")
      row.body:SetTextColor(0.95, 0.95, 0.95)
      row.body:SetWordWrap(true)
    end

    local sectionTitle = tostring(section.title or "Section")
    local normalizedTitle = SafeLower(sectionTitle)
    local lines = {}
    for _, line in ipairs(section.lines or {}) do
      local text = tostring(line or "")
      if text ~= "(empty)" and text ~= "" then
        if StringContains(normalizedTitle, "resolved") then
          text = "|TInterface\\RaidFrame\\ReadyCheck-Ready:14:14:0:0:64:64:0:64:0:64|t " .. text
        elseif StringContains(normalizedTitle, "major bug report") then
          text = "|TInterface\\RaidFrame\\ReadyCheck-NotReady:14:14:0:0:64:64:0:64:0:64|t " .. text
        end
      end
      lines[#lines + 1] = text
    end

    row.header:ClearAllPoints()
    row.header:SetPoint("TOP", panel, "TOP", 0, y)
    row.header:SetText(sectionTitle)
    row.header:Show()
    y = y - math.max(18, row.header:GetStringHeight()) - headerToBodyGap

    row.body:ClearAllPoints()
    row.body:SetPoint("TOP", panel, "TOP", 0, y)
    row.body:SetText(table.concat(lines, "\n"))
    row.body:Show()
    y = y - math.max(14, row.body:GetStringHeight()) - sectionGap
  end

  for i = #(ns._KNOWN_ISSUES_PAGE_TEMPLATE or {}) + 1, #frame.knownIssuesRows do
    local row = frame.knownIssuesRows[i]
    if row then
      if row.header then
        row.header:Hide()
      end
      if row.body then
        row.body:Hide()
      end
    end
  end
end

function ns.IsOrderHallIntroductionComplete()
  local orderHallType = ns.MissionStatus and ns.MissionStatus.garrisonTypeOrderHall
  if not orderHallType or not C_Garrison then
    return false
  end
  if C_Garrison.GetAvailableMissions then
    local okAvail, missions = pcall(C_Garrison.GetAvailableMissions, orderHallType)
    if okAvail and type(missions) == "table" then
      return true
    end
  end
  if C_Garrison.GetInProgressMissions then
    local okProg, missions = pcall(C_Garrison.GetInProgressMissions, orderHallType)
    if okProg and type(missions) == "table" and #missions > 0 then
      return true
    end
  end
  if C_Garrison.GetCompleteMissions then
    local okComp, missions = pcall(C_Garrison.GetCompleteMissions, orderHallType)
    if okComp and type(missions) == "table" and #missions > 0 then
      return true
    end
  end
  return false
end

function ns.CreateUI()
  local frame = CreateFrame("Frame", "DecorDropsMainFrame", UIParent, "PortraitFrameTemplate")
  frame:SetSize(UIC.BASE_FRAME_WIDTH, UIC.BASE_FRAME_HEIGHT)
  frame:SetPoint("CENTER")
  frame:SetToplevel(true)
  frame:SetFrameStrata("DIALOG")
  frame:SetFrameLevel(200)
  frame:SetMovable(true)
  frame:EnableMouse(true)
  frame:SetClampedToScreen(true)
  frame._defaultCollapseNonTracker = true
  frame:RegisterForDrag("LeftButton")
  frame:SetScript("OnDragStart", frame.StartMoving)
  frame:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    ns.ApplyFrameScaleAndClamp(self)
  end)
  frame:SetScript("OnShow", function(self)
    self:Raise()
  end)
  frame:SetAlpha(GetSavedWindowOpacity())
  frame:Hide()

  if UISpecialFrames then
    local registered = false
    for _, name in ipairs(UISpecialFrames) do
      if name == "DecorDropsMainFrame" then
        registered = true
        break
      end
    end
    if not registered then
      table.insert(UISpecialFrames, "DecorDropsMainFrame")
    end
  end

  frame.bg = frame:CreateTexture(nil, "ARTWORK", nil, -8)
  frame.bg:SetAllPoints()
  local bgSet = frame.bg:SetTexture(UIC.THEME_HOUSING_BG_TGA)
  if not bgSet then
    bgSet = frame.bg:SetTexture(UIC.THEME_HOUSING_BG_PNG)
  end
  if not bgSet then
    bgSet = frame.bg:SetTexture(UIC.THEME_HOUSING_BG_JPG)
  end
  if not bgSet then
    frame.bg:SetTexture(UIC.THEME_MARBLE)
  end
  frame.bg:SetTexCoord(0.02, 0.98, 0.04, 0.96)
  frame.bg:SetVertexColor(0.74, 0.72, 0.68, 1.00)

  frame.bgShade = frame:CreateTexture(nil, "ARTWORK", nil, -7)
  frame.bgShade:SetAllPoints()
  frame.bgShade:SetTexture(UIC.THEME_FLAT)
  frame.bgShade:SetVertexColor(0.08, 0.06, 0.04, 0.16)

  if frame.TitleText then
    frame.TitleText:SetText("DecorDrops")
  elseif frame.TitleContainer and frame.TitleContainer.TitleText then
    frame.TitleContainer.TitleText:SetText("DecorDrops")
  end
  frame.portrait = frame:CreateTexture(nil, "ARTWORK")
  SetAddonIcon(frame.portrait)
  frame.portrait:SetSize(56, 56)
  frame.portrait:SetPoint("TOPLEFT", -5, 7)

  -- Login default: non-tracker pages should start collapsed each session.
  -- Preserve tracker collapse preferences only.
  do
    DecorDropsDB.collapsedExpansions = DecorDropsDB.collapsedExpansions or {}
    for key in pairs(DecorDropsDB.collapsedExpansions) do
      if type(key) ~= "string" or string.sub(string.lower(key), 1, 8) ~= "tracker|" then
        DecorDropsDB.collapsedExpansions[key] = nil
      end
    end
    DecorDropsDB.collapsedNewSourceZones = DecorDropsDB.collapsedNewSourceZones or {}
    for key in pairs(DecorDropsDB.collapsedNewSourceZones) do
      if type(key) ~= "string" or string.sub(string.lower(key), 1, 8) ~= "tracker|" then
        DecorDropsDB.collapsedNewSourceZones[key] = nil
      end
    end
  end

  frame.filters = {
    search = DecorDropsDB.search or "",
    type = DecorDropsDB.typeFilter or "All",
    collected = DecorDropsDB.collectedFilter or "All",
    rarity = NormalizeRarityFilter(DecorDropsDB.rarityFilter),
    onlyKnownProfessions = DecorDropsDB.onlyKnownProfessions and true or false,
    professionMulti = DecorDropsDB.professionFilter or {},
  }
  frame.showCompletionPercent = DecorDropsDB.showCompletionPercent ~= false
  if type(frame.filters.professionMulti) ~= "table" then
    frame.filters.professionMulti = {}
  end
  if frame.filters.collected == "Missing" then
    frame.filters.collected = "Uncollected"
    DecorDropsDB.collectedFilter = "Uncollected"
  end
  DecorDropsDB.professionFilter = frame.filters.professionMulti
  DecorDropsDB.rarityFilter = NormalizeRarityFilter(DecorDropsDB.rarityFilter)

  frame.qualityLegend = CreateFrame("Frame", nil, frame)
  frame.qualityLegend:SetPoint("TOPLEFT", 82, -60)
  frame.qualityLegend:SetSize(560, 18)
  frame.qualityLegend.tokens = {}

  local prev = nil
  for _, rarity in ipairs(RARITY_FILTERS) do
    local token = CreateFrame("Frame", nil, frame.qualityLegend, "BackdropTemplate")
    token:SetHeight(16)
    token:EnableMouse(true)
    token.quality = rarity.quality
    token.label = rarity.label
    token:SetBackdrop({
      bgFile = UIC.THEME_MARBLE,
      edgeFile = UIC.THEME_BORDER,
      edgeSize = 10,
      insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })

    token.hover = token:CreateTexture(nil, "HIGHLIGHT")
    token.hover:SetAllPoints()
    token.hover:SetColorTexture(1.0, 0.9, 0.5, 0.08)

    token.text = token:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    token.text:SetPoint("CENTER", token, "CENTER", 0, 0)
    token.text:SetJustifyH("LEFT")
    token.text:SetText(token.label)
    token:SetWidth(token.text:GetStringWidth() + 12)

    if prev then
      token:SetPoint("LEFT", prev, "RIGHT", 4, 0)
    else
      token:SetPoint("LEFT", frame.qualityLegend, "LEFT", 0, 0)
    end
    prev = token

    token:SetScript("OnEnter", function(self)
      GameTooltip:SetOwner(self, "ANCHOR_TOP")
      local enabled = frame.filters.rarity and frame.filters.rarity[self.quality]
      GameTooltip:AddLine(self.label)
      GameTooltip:AddLine(enabled and "|cff66ff66Click to hide this rarity|r" or "|cffffcc66Click to show this rarity|r")
      GameTooltip:Show()
    end)
    token:SetScript("OnLeave", function()
      GameTooltip_Hide()
    end)
    token:SetScript("OnMouseUp", function(self)
      frame.filters.rarity[self.quality] = not frame.filters.rarity[self.quality]
      DecorDropsDB.rarityFilter = frame.filters.rarity
      if frame.UpdateRarityLegendVisual then
        frame:UpdateRarityLegendVisual()
      end
      frame:Refresh()
    end)

    frame.qualityLegend.tokens[#frame.qualityLegend.tokens + 1] = token
  end

  function frame:UpdateRarityLegendVisual()
    for _, token in ipairs(self.qualityLegend.tokens or {}) do
      local active = self.filters.rarity and self.filters.rarity[token.quality]
      local color = ITEM_QUALITY_COLORS and ITEM_QUALITY_COLORS[token.quality]
      if active and color then
        token.text:SetTextColor(color.r, color.g, color.b, 1.0)
      elseif active then
        token.text:SetTextColor(1, 1, 1, 1.0)
      else
        token.text:SetTextColor(0.55, 0.55, 0.55, 0.95)
      end
      if active then
        token:SetBackdropColor(0.15, 0.11, 0.07, 0.68)
        token:SetBackdropBorderColor(0.90, 0.75, 0.44, 0.92)
      else
        token:SetBackdropColor(0.10, 0.09, 0.08, 0.55)
        token:SetBackdropBorderColor(0.38, 0.34, 0.30, 0.80)
      end
      token:SetAlpha(active and 1.0 or 0.9)
    end
  end
  frame:UpdateRarityLegendVisual()

  frame.searchLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.searchLabel:SetPoint("TOPLEFT", 56, -40)
  frame.searchLabel:SetText("Search")

  frame.searchBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
  frame.searchBox:SetSize(230, 24)
  frame.searchBox:SetPoint("LEFT", frame.searchLabel, "RIGHT", 8, 0)
  frame.searchBox:SetAutoFocus(false)
  frame.searchBox:SetText(frame.filters.search)

  frame.typeButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  frame.typeButton:SetSize(100, 24)
  frame.typeButton:SetPoint("LEFT", frame.searchBox, "RIGHT", UIC.SEARCH_TO_FILTER_GAP, 0)
  frame.typeButton:SetText("Type: " .. frame.filters.type)

  frame.professionFilterButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  frame.professionFilterButton:SetSize(150, 24)
  frame.professionFilterButton:SetPoint("LEFT", frame.searchBox, "RIGHT", UIC.SEARCH_TO_FILTER_GAP, 0)
  frame.professionFilterButton:SetText("Professions: All")
  frame.professionFilterButton:Hide()

  frame.collectedButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  frame.collectedButton:SetSize(148, 24)
  frame.collectedButton:SetPoint("LEFT", frame.typeButton, "RIGHT", 8, 0)
  frame.collectedButton:SetText("Show: " .. frame.filters.collected)

  frame.scanButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  frame.scanButton:SetSize(80, 24)
  frame.scanButton:SetPoint("LEFT", frame.collectedButton, "RIGHT", 8, 0)
  frame.scanButton:SetText("Dev Scan")

  frame.resetButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  frame.resetButton:SetSize(80, 24)
  frame.resetButton:SetPoint("LEFT", frame.scanButton, "RIGHT", 8, 0)
  frame.resetButton:SetText("Reset")

  frame.clearWaypointsButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  frame.clearWaypointsButton:SetSize(154, 24)
  frame.clearWaypointsButton:SetPoint("LEFT", frame.resetButton, "RIGHT", 8, 0)
  frame.clearWaypointsButton:SetText("Clear Waypoints")

  frame.professionFilterMenu = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
  frame.professionFilterMenu:SetSize(240, 280)
  frame.professionFilterMenu:SetPoint("TOPLEFT", frame.professionFilterButton, "BOTTOMLEFT", 0, -4)
  frame.professionFilterMenu:SetFrameStrata("TOOLTIP")
  frame.professionFilterMenu:SetFrameLevel((frame:GetFrameLevel() or 1) + 80)
  if frame.professionFilterMenu.SetToplevel then
    frame.professionFilterMenu:SetToplevel(true)
  end
  if frame.professionFilterMenu.SetClampedToScreen then
    frame.professionFilterMenu:SetClampedToScreen(true)
  end
  frame.professionFilterMenu:EnableMouse(true)
  if frame.professionFilterMenu.SetPropagateMouseClicks then
    frame.professionFilterMenu:SetPropagateMouseClicks(false)
  end
  frame.professionFilterMenu:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.professionFilterMenu:SetBackdropColor(0.08, 0.08, 0.08, 0.98)
  frame.professionFilterMenu:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  frame.professionFilterMenu:Hide()

  frame.professionFilterMenuTitle = frame.professionFilterMenu:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.professionFilterMenuTitle:SetPoint("TOPLEFT", 10, -10)
  frame.professionFilterMenuTitle:SetText("Profession Filter")
  frame.professionFilterMenuTitle:SetTextColor(0.98, 0.90, 0.74)

  frame.professionFilterSelectAll = CreateFrame("Button", nil, frame.professionFilterMenu, "UIPanelButtonTemplate")
  frame.professionFilterSelectAll:SetSize(106, 22)
  frame.professionFilterSelectAll:SetPoint("TOPLEFT", 8, -30)
  frame.professionFilterSelectAll:SetText("Select All")

  frame.professionFilterClearAll = CreateFrame("Button", nil, frame.professionFilterMenu, "UIPanelButtonTemplate")
  frame.professionFilterClearAll:SetSize(106, 22)
  frame.professionFilterClearAll:SetPoint("TOPRIGHT", -8, -30)
  frame.professionFilterClearAll:SetText("Clear All")

  frame.professionFilterScrollContainer = CreateFrame("Frame", nil, frame.professionFilterMenu, "BackdropTemplate")
  frame.professionFilterScrollContainer:SetPoint("TOPLEFT", 8, -58)
  frame.professionFilterScrollContainer:SetPoint("BOTTOMRIGHT", -8, 10)
  frame.professionFilterScrollContainer:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.professionFilterScrollContainer:SetBackdropColor(0.04, 0.04, 0.04, 0.88)
  frame.professionFilterScrollContainer:SetBackdropBorderColor(0.56, 0.48, 0.36, 0.72)

  frame.professionFilterScroll = CreateFrame("ScrollFrame", nil, frame.professionFilterScrollContainer, "UIPanelScrollFrameTemplate")
  frame.professionFilterScroll:SetPoint("TOPLEFT", 4, -4)
  frame.professionFilterScroll:SetPoint("BOTTOMRIGHT", -24, 4)
  frame.professionFilterScroll:SetClipsChildren(true)
  frame.professionFilterContent = CreateFrame("Frame", nil, frame.professionFilterScroll)
  frame.professionFilterContent:SetSize(200, 1)
  frame.professionFilterScroll:SetScrollChild(frame.professionFilterContent)
  frame.professionFilterScroll:EnableMouseWheel(true)
  frame.professionFilterScroll:SetScript("OnMouseWheel", function(self, delta)
    local current = self:GetVerticalScroll() or 0
    local maxScroll = self:GetVerticalScrollRange() or 0
    local nextValue = current - ((tonumber(delta) or 0) * 22)
    if nextValue < 0 then
      nextValue = 0
    elseif nextValue > maxScroll then
      nextValue = maxScroll
    end
    self:SetVerticalScroll(nextValue)
  end)
  do
    local bar = frame.professionFilterScroll.ScrollBar
      or _G[frame.professionFilterScroll:GetName() .. "ScrollBar"]
    if bar then
      bar:ClearAllPoints()
      bar:SetPoint("TOPRIGHT", frame.professionFilterScrollContainer, "TOPRIGHT", -3, -5)
      bar:SetPoint("BOTTOMRIGHT", frame.professionFilterScrollContainer, "BOTTOMRIGHT", -3, 5)
      bar:SetWidth(14)
    end
  end
  frame.professionFilterChecks = {}

  function frame:UpdateProfessionFilterButtonText()
    local selected = self.filters and self.filters.professionMulti or {}
    local names = {}
    for name, enabled in pairs(selected or {}) do
      if enabled then
        names[#names + 1] = name
      end
    end
    table.sort(names)
    if #names == 0 then
      self.professionFilterButton:SetText("Professions: All")
    elseif #names == 1 then
      self.professionFilterButton:SetText("Professions: " .. tostring(names[1]))
    else
      self.professionFilterButton:SetText(string.format("Professions: %d selected", #names))
    end
  end

  function frame:BuildProfessionFilterMenu()
    local professionNames = BuildProfessionFilterList()
    for i, professionName in ipairs(professionNames) do
      local check = self.professionFilterChecks[i]
      if not check then
        check = CreateFrame("CheckButton", nil, self.professionFilterContent, "UICheckButtonTemplate")
        check:SetSize(18, 18)
        check.label = self.professionFilterContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        check.label:SetPoint("LEFT", check, "RIGHT", 4, 0)
        check.label:SetTextColor(0.92, 0.84, 0.68)
        self.professionFilterChecks[i] = check
      end
      check:ClearAllPoints()
      check:SetPoint("TOPLEFT", 6, -((i - 1) * 22))
      check.professionName = professionName
      check:SetChecked(self.filters.professionMulti[professionName] and true or false)
      check.label:SetText(professionName)
      check:SetScript("OnClick", function(selfCheck)
        local profName = selfCheck.professionName
        if not profName then
          return
        end
        if selfCheck:GetChecked() then
          self.filters.professionMulti[profName] = true
        else
          self.filters.professionMulti[profName] = nil
        end
        DecorDropsDB.professionFilter = self.filters.professionMulti
        self:UpdateProfessionFilterButtonText()
        self:Refresh()
      end)
      check:Show()
      check.label:Show()
    end

    for i = #professionNames + 1, #self.professionFilterChecks do
      local check = self.professionFilterChecks[i]
      if check then
        check:Hide()
        if check.label then
          check.label:Hide()
        end
      end
    end

    local contentHeight = math.max(1, #professionNames * 22)
    self.professionFilterContent:SetSize(200, contentHeight)
    self.professionFilterScroll:SetVerticalScroll(0)
  end

  frame.modeTabRail = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.modeTabRail:SetSize(44, 390)
  frame.modeTabRail:SetPoint("TOPRIGHT", frame, "TOPLEFT", -1, -84)
  frame.modeTabRail:SetBackdrop(nil)

  frame.modeInstancesButton = CreateFrame("Button", nil, frame.modeTabRail, "BackdropTemplate")
  frame.modeInstancesButton:SetSize(40, 40)
  frame.modeInstancesButton:SetPoint("TOPLEFT", 2, -49)
  frame.modeInstancesButton:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.modeInstancesButton:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
  frame.modeInstancesButton:SetBackdropBorderColor(0.44, 0.42, 0.38, 0.90)
  frame.modeInstancesButton:SetHighlightTexture(UIC.THEME_FLAT)
  if frame.modeInstancesButton:GetHighlightTexture() then
    frame.modeInstancesButton:GetHighlightTexture():SetAllPoints()
    frame.modeInstancesButton:GetHighlightTexture():SetVertexColor(1.00, 0.82, 0.20, 0.08)
    if frame.modeInstancesButton:GetHighlightTexture().SetBlendMode then
      frame.modeInstancesButton:GetHighlightTexture():SetBlendMode("BLEND")
    end
  end
  frame.modeInstancesButton.SelectedTexture = frame.modeInstancesButton:CreateTexture(nil, "ARTWORK")
  frame.modeInstancesButton.SelectedTexture:SetAllPoints()
  frame.modeInstancesButton.SelectedTexture:SetTexture(UIC.THEME_FLAT)
  frame.modeInstancesButton.SelectedTexture:SetVertexColor(1.00, 0.82, 0.20, 0.28)
  frame.modeInstancesButton.SelectedTexture:Hide()
  frame.modeInstancesButton.OuterGlow = frame.modeInstancesButton:CreateTexture(nil, "OVERLAY", nil, 3)
  frame.modeInstancesButton.OuterGlow:SetPoint("TOPLEFT", frame.modeInstancesButton, "TOPLEFT", 0, 0)
  frame.modeInstancesButton.OuterGlow:SetPoint("BOTTOMRIGHT", frame.modeInstancesButton, "BOTTOMRIGHT", 0, 0)
  frame.modeInstancesButton.OuterGlow:SetTexture(UIC.THEME_FLAT)
  frame.modeInstancesButton.OuterGlow:SetVertexColor(1.00, 0.84, 0.22, 0.05)
  frame.modeInstancesButton.OuterGlow:Hide()

  frame.modeInstancesButton.icon = frame.modeInstancesButton:CreateTexture(nil, "ARTWORK")
  frame.modeInstancesButton.icon:ClearAllPoints()
  frame.modeInstancesButton.icon:SetPoint("TOPLEFT", 4, -5)
  frame.modeInstancesButton.icon:SetPoint("BOTTOMRIGHT", -6, 5)
  SetInstancesModeTabIcon(frame.modeInstancesButton.icon)

  frame.modeInstancesButton:SetScript("OnClick", function()
    ns.SetViewMode(frame, "instances")
  end)
  frame.modeInstancesButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:AddLine("Raids & Dungeons")
    GameTooltip:Show()
  end)
  frame.modeInstancesButton:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.modeProfessionsButton = CreateFrame("Button", nil, frame.modeTabRail, "BackdropTemplate")
  frame.modeProfessionsButton:SetSize(40, 40)
  frame.modeProfessionsButton:SetPoint("TOPLEFT", 2, -92)
  frame.modeProfessionsButton:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.modeProfessionsButton:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
  frame.modeProfessionsButton:SetBackdropBorderColor(0.44, 0.42, 0.38, 0.90)
  frame.modeProfessionsButton:SetHighlightTexture(UIC.THEME_FLAT)
  if frame.modeProfessionsButton:GetHighlightTexture() then
    frame.modeProfessionsButton:GetHighlightTexture():SetAllPoints()
    frame.modeProfessionsButton:GetHighlightTexture():SetVertexColor(1.00, 0.82, 0.20, 0.08)
    if frame.modeProfessionsButton:GetHighlightTexture().SetBlendMode then
      frame.modeProfessionsButton:GetHighlightTexture():SetBlendMode("BLEND")
    end
  end
  frame.modeProfessionsButton.SelectedTexture = frame.modeProfessionsButton:CreateTexture(nil, "ARTWORK")
  frame.modeProfessionsButton.SelectedTexture:SetAllPoints()
  frame.modeProfessionsButton.SelectedTexture:SetTexture(UIC.THEME_FLAT)
  frame.modeProfessionsButton.SelectedTexture:SetVertexColor(1.00, 0.82, 0.20, 0.28)
  frame.modeProfessionsButton.SelectedTexture:Hide()
  frame.modeProfessionsButton.OuterGlow = frame.modeProfessionsButton:CreateTexture(nil, "OVERLAY", nil, 3)
  frame.modeProfessionsButton.OuterGlow:SetPoint("TOPLEFT", frame.modeProfessionsButton, "TOPLEFT", 0, 0)
  frame.modeProfessionsButton.OuterGlow:SetPoint("BOTTOMRIGHT", frame.modeProfessionsButton, "BOTTOMRIGHT", 0, 0)
  frame.modeProfessionsButton.OuterGlow:SetTexture(UIC.THEME_FLAT)
  frame.modeProfessionsButton.OuterGlow:SetVertexColor(1.00, 0.84, 0.22, 0.05)
  frame.modeProfessionsButton.OuterGlow:Hide()
  frame.modeProfessionsButton.icon = frame.modeProfessionsButton:CreateTexture(nil, "ARTWORK")
  frame.modeProfessionsButton.icon:SetPoint("TOPLEFT", 5, -5)
  frame.modeProfessionsButton.icon:SetPoint("BOTTOMRIGHT", -5, 5)
  frame.modeProfessionsButton.icon:SetTexture(UIC.ICON_PROFESSIONS)
  frame.modeProfessionsButton.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
  frame.modeProfessionsButton.icon:SetVertexColor(1, 1, 1, 1)
  frame.modeProfessionsButton:SetScript("OnClick", function()
    ns.SetViewMode(frame, "professions")
  end)
  frame.modeProfessionsButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:AddLine("Professions")
    GameTooltip:Show()
  end)
  frame.modeProfessionsButton:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.modeTrackerButton = CreateFrame("Button", nil, frame.modeTabRail, "BackdropTemplate")
  frame.modeTrackerButton:SetSize(40, 40)
  frame.modeTrackerButton:SetPoint("TOPLEFT", 2, -6)
  frame.modeTrackerButton:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.modeTrackerButton:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
  frame.modeTrackerButton:SetBackdropBorderColor(0.44, 0.42, 0.38, 0.90)
  frame.modeTrackerButton:SetHighlightTexture(UIC.THEME_FLAT)
  if frame.modeTrackerButton:GetHighlightTexture() then
    frame.modeTrackerButton:GetHighlightTexture():SetAllPoints()
    frame.modeTrackerButton:GetHighlightTexture():SetVertexColor(1.00, 0.82, 0.20, 0.08)
    if frame.modeTrackerButton:GetHighlightTexture().SetBlendMode then
      frame.modeTrackerButton:GetHighlightTexture():SetBlendMode("BLEND")
    end
  end
  frame.modeTrackerButton.SelectedTexture = frame.modeTrackerButton:CreateTexture(nil, "ARTWORK")
  frame.modeTrackerButton.SelectedTexture:SetAllPoints()
  frame.modeTrackerButton.SelectedTexture:SetTexture(UIC.THEME_FLAT)
  frame.modeTrackerButton.SelectedTexture:SetVertexColor(1.00, 0.82, 0.20, 0.28)
  frame.modeTrackerButton.SelectedTexture:Hide()
  frame.modeTrackerButton.OuterGlow = frame.modeTrackerButton:CreateTexture(nil, "OVERLAY", nil, 3)
  frame.modeTrackerButton.OuterGlow:SetPoint("TOPLEFT", frame.modeTrackerButton, "TOPLEFT", 0, 0)
  frame.modeTrackerButton.OuterGlow:SetPoint("BOTTOMRIGHT", frame.modeTrackerButton, "BOTTOMRIGHT", 0, 0)
  frame.modeTrackerButton.OuterGlow:SetTexture(UIC.THEME_FLAT)
  frame.modeTrackerButton.OuterGlow:SetVertexColor(1.00, 0.84, 0.22, 0.05)
  frame.modeTrackerButton.OuterGlow:Hide()
  frame.modeTrackerButton.icon = frame.modeTrackerButton:CreateTexture(nil, "ARTWORK")
  frame.modeTrackerButton.icon:SetPoint("TOPLEFT", 2, -1)
  frame.modeTrackerButton.icon:SetPoint("BOTTOMRIGHT", 0, 1)
  frame.modeTrackerButton.icon:SetTexture(UIC.ICON_TRACKER)
  frame.modeTrackerButton.icon:SetTexCoord(0.00, 1.00, 0.00, 1.00)
  frame.modeTrackerButton.icon:SetVertexColor(1, 1, 1, 1)
  frame.modeTrackerButton:SetScript("OnClick", function()
    ns.SetViewMode(frame, "tracker")
  end)
  frame.modeTrackerButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:AddLine("Tracker")
    GameTooltip:Show()
  end)
  frame.modeTrackerButton:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  local function CreateSourceModeButton(offsetY, texturePath, tooltipText, modeKey)
    local btn = CreateFrame("Button", nil, frame.modeTabRail, "BackdropTemplate")
    btn:SetSize(40, 40)
    btn:SetPoint("TOPLEFT", 2, offsetY)
    btn:SetBackdrop({
      bgFile = UIC.THEME_DARK,
      edgeFile = UIC.THEME_BORDER,
      edgeSize = 10,
      insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    btn:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
    btn:SetBackdropBorderColor(0.44, 0.42, 0.38, 0.90)
    btn:SetHighlightTexture(UIC.THEME_FLAT)
    if btn:GetHighlightTexture() then
      btn:GetHighlightTexture():SetAllPoints()
      btn:GetHighlightTexture():SetVertexColor(1.00, 0.82, 0.20, 0.08)
      if btn:GetHighlightTexture().SetBlendMode then
        btn:GetHighlightTexture():SetBlendMode("BLEND")
      end
    end
    btn.SelectedTexture = btn:CreateTexture(nil, "ARTWORK")
    btn.SelectedTexture:SetAllPoints()
    btn.SelectedTexture:SetTexture(UIC.THEME_FLAT)
    btn.SelectedTexture:SetVertexColor(1.00, 0.82, 0.20, 0.28)
    btn.SelectedTexture:Hide()
    btn.OuterGlow = btn:CreateTexture(nil, "OVERLAY", nil, 3)
    btn.OuterGlow:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
    btn.OuterGlow:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
    btn.OuterGlow:SetTexture(UIC.THEME_FLAT)
    btn.OuterGlow:SetVertexColor(1.00, 0.84, 0.22, 0.05)
    btn.OuterGlow:Hide()
    btn.icon = btn:CreateTexture(nil, "ARTWORK")
    btn.icon:SetPoint("TOPLEFT", 7, -7)
    btn.icon:SetPoint("BOTTOMRIGHT", -7, 7)
    if modeKey == "quest" then
      btn.icon:ClearAllPoints()
      btn.icon:SetPoint("TOPLEFT", 6, -7)
      btn.icon:SetPoint("BOTTOMRIGHT", -8, 7)
    end
    btn.icon:SetTexture(texturePath)
    btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    btn.icon:SetVertexColor(1, 1, 1, 1)
    btn:SetScript("OnClick", function()
      ns.SetViewMode(frame, modeKey)
    end)
    btn:SetScript("OnEnter", function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      GameTooltip:AddLine(tooltipText)
      GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function()
      GameTooltip_Hide()
    end)
    return btn
  end

  frame.modeVendorButton = CreateSourceModeButton(-135, UIC.ICON_VENDOR, "Vendor", "vendor")
  frame.modeAchievementButton = CreateSourceModeButton(-178, UIC.ICON_ACHIEVEMENT, "Achievement", "achievement")
  frame.modeQuestButton = CreateSourceModeButton(-221, UIC.ICON_QUEST, "Quest", "quest")
  frame.modeReputationButton = CreateSourceModeButton(-264, UIC.ICON_REPUTATION, "Reputation", "reputation")
  frame.modeEndeavorButton = CreateSourceModeButton(-307, UIC.ICON_ENDEAVOR, "Endeavor", "endeavor")
  frame.modePromotionButton = CreateSourceModeButton(-350, "Interface\\Icons\\INV_Misc_Coin_17", "Promotional", "promotion")

  frame.leftPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.leftPanel:SetSize(280, 590)
  frame.leftPanel:SetPoint("TOPLEFT", 20, -82)
  frame.leftPanel:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.leftPanel:SetBackdropColor(0.18, 0.14, 0.10, 0.52)
  frame.leftPanel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  if frame.leftPanel.SetClipsChildren then
    frame.leftPanel:SetClipsChildren(false)
  end
  frame.modeTabRail:ClearAllPoints()
  frame.modeTabRail:SetPoint("TOPRIGHT", frame, "TOPLEFT", -1, -82)

  frame.leftTitle = frame.leftPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.leftTitle:SetPoint("TOPLEFT", 12, -10)
  frame.leftTitle:SetText("Instances")
  frame.leftTitle:SetTextColor(0.98, 0.90, 0.74)

  frame.expandCollapseButton = CreateFrame("Button", nil, frame.leftPanel, "UIPanelButtonTemplate")
  frame.expandCollapseButton:SetSize(24, 20)
  frame.expandCollapseButton:SetPoint("TOPRIGHT", -30, -6)
  frame.expandCollapseButton:SetText("|cffffd25a-|r")
  frame.expandCollapseButton:SetNormalFontObject("GameFontNormal")
  frame.expandCollapseButton:SetHighlightFontObject("GameFontNormal")
  frame.expandCollapseButton:SetDisabledFontObject("GameFontDisable")

  frame.instanceListScroll = CreateFrame("ScrollFrame", "DecorDropsInstanceListScrollFrame", frame.leftPanel, "UIPanelScrollFrameTemplate")
  frame.instanceListScroll:SetPoint("TOPLEFT", 10, -32)
  frame.instanceListScroll:SetPoint("BOTTOMRIGHT", -20, 34)
  if frame.instanceListScroll.SetClipsChildren then
    frame.instanceListScroll:SetClipsChildren(false)
  end

  frame.instanceListContent = CreateFrame("Frame", nil, frame.instanceListScroll)
  frame.instanceListContent:SetSize(UIC.LEFT_LIST_WIDTH, 1)
  if frame.instanceListContent.SetClipsChildren then
    frame.instanceListContent:SetClipsChildren(true)
  end
  frame.instanceListScroll:SetScrollChild(frame.instanceListContent)
  frame.instanceListScroll._ddScrollBar = _G["DecorDropsInstanceListScrollFrameScrollBar"]
  frame.instanceListScroll._ddScrollBarAnchorSide = "right"
  frame.instanceListScroll._ddScrollBarXOffset = 2
  ns._ApplyScrollBarOpacity(frame.instanceListScroll, 1.0)
  C_Timer.After(0, function()
    ns.RefreshScrollBarVisibility(frame.instanceListScroll)
  end)

  frame.knownProfessionOnlyCheck = CreateFrame("CheckButton", nil, frame.leftPanel, "UICheckButtonTemplate")
  frame.knownProfessionOnlyCheck:SetPoint("BOTTOMLEFT", 10, 5)
  frame.knownProfessionOnlyCheck:SetChecked(frame.filters.onlyKnownProfessions and true or false)
  frame.knownProfessionOnlyCheck:SetScript("OnClick", function(selfBtn)
    local checked = selfBtn:GetChecked() and true or false
    frame.filters.onlyKnownProfessions = checked
    DecorDropsDB.onlyKnownProfessions = checked
    frame:Refresh()
  end)
  frame.knownProfessionOnlyCheck:SetScript("OnEnter", function(selfBtn)
    GameTooltip:SetOwner(selfBtn, "ANCHOR_TOPLEFT")
    GameTooltip:AddLine("My Professions")
    GameTooltip:AddLine("Show profession decorations only for professions this character knows.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  frame.knownProfessionOnlyCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.knownProfessionOnlyLabel = frame.leftPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.knownProfessionOnlyLabel:SetPoint("LEFT", frame.knownProfessionOnlyCheck, "RIGHT", 2, 1)
  frame.knownProfessionOnlyLabel:SetText("My Professions")
  frame.knownProfessionOnlyLabel:SetTextColor(0.92, 0.84, 0.68)

  frame.showCompletionPercentCheck = CreateFrame("CheckButton", nil, frame.leftPanel, "UICheckButtonTemplate")
  frame.showCompletionPercentCheck:SetPoint("LEFT", frame.knownProfessionOnlyLabel, "RIGHT", 14, 0)
  frame.showCompletionPercentCheck:SetChecked(frame.showCompletionPercent and true or false)
  frame.showCompletionPercentCheck:SetScript("OnClick", function(selfBtn)
    local checked = selfBtn:GetChecked() and true or false
    frame.showCompletionPercent = checked
    DecorDropsDB.showCompletionPercent = checked
    frame:RefreshLeftPanelOnly()
  end)
  frame.showCompletionPercentCheck:SetScript("OnEnter", function(selfBtn)
    GameTooltip:SetOwner(selfBtn, "ANCHOR_TOPLEFT")
    GameTooltip:AddLine("Completion %")
    GameTooltip:AddLine("Show completion percentage in the left category tree on non-promotional pages.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  frame.showCompletionPercentCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.showCompletionPercentLabel = frame.leftPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.showCompletionPercentLabel:SetPoint("LEFT", frame.showCompletionPercentCheck, "RIGHT", 2, 1)
  frame.showCompletionPercentLabel:SetText("Completion %")
  frame.showCompletionPercentLabel:SetTextColor(0.86, 0.86, 0.82)

  frame.rightPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.rightPanel:SetSize(810, 590)
  frame.rightPanel:SetPoint("TOPLEFT", frame.leftPanel, "TOPRIGHT", 12, 0)
  frame.rightPanel:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.rightPanel:SetBackdropColor(0.18, 0.14, 0.10, 0.52)
  frame.rightPanel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  if frame.rightPanel.SetClipsChildren then
    frame.rightPanel:SetClipsChildren(false)
  end

  frame.qualityLegend:ClearAllPoints()
  frame.qualityLegend:SetPoint("BOTTOMLEFT", frame.leftPanel, "TOPLEFT", 0, 1)
  frame.qualityLegend:SetPoint("BOTTOMRIGHT", frame.rightPanel, "TOPRIGHT", 0, 1)

  frame.instanceName = frame.rightPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  frame.instanceName:SetPoint("TOPLEFT", 12, -12)
  frame.instanceName:SetText("Select an instance")
  frame.instanceName:SetTextColor(0.97, 0.88, 0.68)

  frame.entranceText = frame.rightPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.entranceText:SetPoint("TOPLEFT", frame.instanceName, "BOTTOMLEFT", 0, -6)
  frame.entranceText:SetText("Entrance: -")
  frame.entranceText:SetTextColor(0.90, 0.82, 0.64)

  frame.reputationMini = CreateFrame("Frame", nil, frame.rightPanel, "BackdropTemplate")
  frame.reputationMini:SetSize(220, 34)
  frame.reputationMini:SetPoint("TOPRIGHT", -16, -12)
  frame.reputationMini:SetBackdrop({
    bgFile = UIC.THEME_DARK,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.reputationMini:SetBackdropColor(0.06, 0.06, 0.06, 0.92)
  frame.reputationMini:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.86)
  frame.reputationMini:Hide()

  frame.reputationMiniName = frame.reputationMini:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.reputationMiniName:SetPoint("TOPLEFT", 8, -3)
  frame.reputationMiniName:SetWidth(150)
  frame.reputationMiniName:SetJustifyH("LEFT")
  frame.reputationMiniName:SetTextColor(0.92, 0.84, 0.68)

  frame.reputationMiniText = frame.reputationMini:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.reputationMiniText:SetPoint("TOPRIGHT", -8, -3)
  frame.reputationMiniText:SetJustifyH("RIGHT")
  frame.reputationMiniText:SetTextColor(0.90, 0.90, 0.90)

  frame.reputationMiniBar = CreateFrame("StatusBar", nil, frame.reputationMini)
  frame.reputationMiniBar:SetStatusBarTexture("Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar")
  frame.reputationMiniBar:SetPoint("TOPLEFT", 8, -15)
  frame.reputationMiniBar:SetPoint("TOPRIGHT", -8, -15)
  frame.reputationMiniBar:SetHeight(12)
  frame.reputationMiniBar:SetMinMaxValues(0, 1)
  frame.reputationMiniBar:SetValue(0)

  frame.reputationMiniBarBg = frame.reputationMiniBar:CreateTexture(nil, "BACKGROUND")
  frame.reputationMiniBarBg:SetAllPoints()
  frame.reputationMiniBarBg:SetTexture(UIC.THEME_FLAT)
  frame.reputationMiniBarBg:SetVertexColor(0.08, 0.08, 0.08, 0.85)

  frame.trainerPanel = CreateFrame("Frame", nil, frame.rightPanel, "BackdropTemplate")
  frame.trainerPanel:SetSize(784, 34)
  frame.trainerPanel:SetPoint("TOPLEFT", 8, -56)
  frame.trainerPanel.bg = frame.trainerPanel:CreateTexture(nil, "BACKGROUND")
  frame.trainerPanel.bg:SetAllPoints()
  frame.trainerPanel.bg:SetTexture(UIC.THEME_FLAT)
  frame.trainerPanel.bg:SetVertexColor(0.04, 0.04, 0.04, 0.72)
  frame.trainerPanel.topLine = frame.trainerPanel:CreateTexture(nil, "BORDER")
  frame.trainerPanel.topLine:SetPoint("TOPLEFT", 0, 0)
  frame.trainerPanel.topLine:SetPoint("TOPRIGHT", 0, 0)
  frame.trainerPanel.topLine:SetHeight(1)
  frame.trainerPanel.topLine:SetTexture(UIC.THEME_FLAT)
  frame.trainerPanel.topLine:SetVertexColor(0.86, 0.72, 0.46, 0.85)
  frame.trainerPanel.bottomLine = frame.trainerPanel:CreateTexture(nil, "BORDER")
  frame.trainerPanel.bottomLine:SetPoint("BOTTOMLEFT", 0, 0)
  frame.trainerPanel.bottomLine:SetPoint("BOTTOMRIGHT", 0, 0)
  frame.trainerPanel.bottomLine:SetHeight(1)
  frame.trainerPanel.bottomLine:SetTexture(UIC.THEME_FLAT)
  frame.trainerPanel.bottomLine:SetVertexColor(0.86, 0.72, 0.46, 0.85)
  frame.trainerPanel:Hide()

  frame.trainerIcon = frame.trainerPanel:CreateTexture(nil, "ARTWORK")
  frame.trainerIcon:SetSize(18, 18)
  frame.trainerIcon:SetPoint("LEFT", 8, 0)
  frame.trainerIcon:SetTexture(UIC.ICON_TRAINER)
  frame.trainerIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

  frame.trainerName = frame.trainerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.trainerName:SetPoint("LEFT", frame.trainerIcon, "RIGHT", 8, 0)
  frame.trainerName:SetWidth(230)
  frame.trainerName:SetJustifyH("LEFT")
  frame.trainerName:SetTextColor(0.55, 0.78, 1.00)

  frame.trainerLocation = frame.trainerPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.trainerLocation:SetPoint("LEFT", frame.trainerName, "RIGHT", 8, 0)
  frame.trainerLocation:SetWidth(368)
  frame.trainerLocation:SetJustifyH("LEFT")
  frame.trainerLocation:SetTextColor(0.92, 0.84, 0.68)

  frame.trainerFaction = frame.trainerPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.trainerFaction:SetPoint("LEFT", frame.trainerLocation, "RIGHT", 8, 0)
  frame.trainerFaction:SetWidth(90)
  frame.trainerFaction:SetJustifyH("LEFT")
  frame.trainerFaction:SetTextColor(0.90, 0.90, 0.90)
  frame.trainerFaction:Hide()

  frame.trainerManualButton = CreateFrame("Button", nil, frame.trainerPanel, "UIPanelButtonTemplate")
  frame.trainerManualButton:SetSize(76, 20)
  frame.trainerManualButton:SetPoint("RIGHT", -174, 0)
  frame.trainerManualButton:SetText("Manual")

  frame.trainerWaypointButton = CreateFrame("Button", nil, frame.trainerPanel, "UIPanelButtonTemplate")
  frame.trainerWaypointButton:SetSize(84, 20)
  frame.trainerWaypointButton:SetText("Waypoint")

  frame.trainerTeleportHint = frame.rightPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.trainerTeleportHint:SetPoint("BOTTOM", frame.trainerWaypointButton, "TOP", 0, 8)
  frame.trainerTeleportHint:SetJustifyH("CENTER")
  frame.trainerTeleportHint:SetTextColor(1.00, 0.84, 0.22)
  frame.trainerTeleportHint:SetText("Spell not learned.")
  frame.trainerTeleportHint:Hide()

  ns.UpdateTrainerActionButtonLayout(frame)

  frame.manualOverridePanel = CreateFrame("Frame", nil, frame.rightPanel, "BackdropTemplate")
  frame.manualOverridePanel:SetSize(784, 26)
  frame.manualOverridePanel:SetPoint("TOPLEFT", 8, -92)
  frame.manualOverridePanel:SetBackdrop({
    bgFile = UIC.THEME_FLAT,
    edgeFile = UIC.THEME_FLAT,
    tile = false,
    edgeSize = 1,
  })
  frame.manualOverridePanel:SetBackdropColor(0.04, 0.04, 0.04, 0.72)
  frame.manualOverridePanel:SetBackdropBorderColor(0.86, 0.72, 0.46, 0.85)
  frame.manualOverridePanel:Hide()

  frame.manualOverrideTitle = frame.manualOverridePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.manualOverrideTitle:SetPoint("LEFT", 8, 0)
  frame.manualOverrideTitle:SetText("Manual Waypoint:")
  frame.manualOverrideTitle:SetTextColor(0.92, 0.84, 0.68)

  frame.manualMapIDInput = CreateFrame("EditBox", nil, frame.manualOverridePanel, "InputBoxTemplate")
  frame.manualMapIDInput:SetSize(70, 20)
  frame.manualMapIDInput:SetPoint("LEFT", frame.manualOverrideTitle, "RIGHT", 10, 0)
  frame.manualMapIDInput:SetAutoFocus(false)
  frame.manualMapIDInput:SetNumeric(true)
  frame.manualMapIDInput:SetMaxLetters(5)
  frame.manualMapIDInput:SetTextInsets(4, 4, 0, 0)

  frame.manualMapIDLabel = frame.manualOverridePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.manualMapIDLabel:SetPoint("BOTTOMLEFT", frame.manualMapIDInput, "TOPLEFT", 0, 2)
  frame.manualMapIDLabel:SetText("MapID")

  frame.manualXInput = CreateFrame("EditBox", nil, frame.manualOverridePanel, "InputBoxTemplate")
  frame.manualXInput:SetSize(58, 20)
  frame.manualXInput:SetPoint("LEFT", frame.manualMapIDInput, "RIGHT", 14, 0)
  frame.manualXInput:SetAutoFocus(false)
  frame.manualXInput:SetNumeric(false)
  frame.manualXInput:SetMaxLetters(6)
  frame.manualXInput:SetTextInsets(4, 4, 0, 0)

  frame.manualXLabel = frame.manualOverridePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.manualXLabel:SetPoint("BOTTOMLEFT", frame.manualXInput, "TOPLEFT", 0, 2)
  frame.manualXLabel:SetText("X")

  frame.manualYInput = CreateFrame("EditBox", nil, frame.manualOverridePanel, "InputBoxTemplate")
  frame.manualYInput:SetSize(58, 20)
  frame.manualYInput:SetPoint("LEFT", frame.manualXInput, "RIGHT", 10, 0)
  frame.manualYInput:SetAutoFocus(false)
  frame.manualYInput:SetNumeric(false)
  frame.manualYInput:SetMaxLetters(6)
  frame.manualYInput:SetTextInsets(4, 4, 0, 0)

  frame.manualYLabel = frame.manualOverridePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.manualYLabel:SetPoint("BOTTOMLEFT", frame.manualYInput, "TOPLEFT", 0, 2)
  frame.manualYLabel:SetText("Y")

  frame.manualUseMapButton = CreateFrame("Button", nil, frame.manualOverridePanel, "UIPanelButtonTemplate")
  frame.manualUseMapButton:SetSize(96, 20)
  frame.manualUseMapButton:SetPoint("LEFT", frame.manualYInput, "RIGHT", 10, 0)
  frame.manualUseMapButton:SetText("Use MapID")
  frame.manualUseMapButton:SetScript("OnClick", function()
    local mapID = GetCurrentVisibleMapID()
    if not mapID or mapID <= 0 then
      print("|cffff5555DecorDrops: Could not detect current mapID. Open the world map to a zone first.|r")
      return
    end
    frame.manualMapIDInput:SetText(tostring(mapID))
    print("|cff66ccffDecorDrops|r Loaded mapID " .. tostring(mapID) .. " into manual override.")
  end)

  frame.manualSaveButton = CreateFrame("Button", nil, frame.manualOverridePanel, "UIPanelButtonTemplate")
  frame.manualSaveButton:SetSize(58, 20)
  frame.manualSaveButton:SetPoint("RIGHT", -70, 0)
  frame.manualSaveButton:SetText("Save")

  frame.manualCancelButton = CreateFrame("Button", nil, frame.manualOverridePanel, "UIPanelButtonTemplate")
  frame.manualCancelButton:SetSize(58, 20)
  frame.manualCancelButton:SetPoint("RIGHT", -8, 0)
  frame.manualCancelButton:SetText("Cancel")

  frame.manualSaveButton:SetScript("OnClick", function()
    if not activeManualWaypointInstance then
      HideManualOverridePanel(frame)
      return
    end
    local mapID = tonumber(TrimString(frame.manualMapIDInput:GetText() or ""))
    local x = tonumber(TrimString(frame.manualXInput:GetText() or ""))
    local y = tonumber(TrimString(frame.manualYInput:GetText() or ""))
    if not mapID or mapID <= 0 or not x or not y then
      print("|cffff5555DecorDrops: Enter valid MapID, X, and Y values.|r")
      return
    end
    if x < 0 or x > 100 or y < 0 or y > 100 then
      print("|cffff5555DecorDrops: X and Y must be between 0 and 100.|r")
      return
    end
    SaveManualInstanceWaypointOverride(
      activeManualWaypointInstance.type or "Dungeon",
      ns.GetMapName(activeManualWaypointInstance.mapID),
      activeManualWaypointInstance.name,
      mapID,
      x,
      y
    )
    HideManualOverridePanel(frame)
  end)

  frame.manualCancelButton:SetScript("OnClick", function()
    HideManualOverridePanel(frame)
  end)

  frame.dungeonWaypointButton = CreateFrame("Button", nil, frame.rightPanel, "UIPanelButtonTemplate")
  frame.dungeonWaypointButton:SetSize(170, 24)
  frame.dungeonWaypointButton:SetPoint("TOPRIGHT", -20, -18)
  frame.dungeonWaypointButton:SetText("Set Waypoint")
  frame.dungeonWaypointButton:SetShown(false)

  ns.BuildHeader(frame)

  frame.itemListScroll = CreateFrame("ScrollFrame", "DecorDropsItemListScrollFrame", frame.rightPanel, "UIPanelScrollFrameTemplate")
  frame.itemListScroll:SetPoint("TOPLEFT", ITEM_LAYOUT.contentInsetX + 2, -142)
  frame.itemListScroll:SetPoint("BOTTOMRIGHT", -26, 10)
  if frame.itemListScroll.SetClipsChildren then
    frame.itemListScroll:SetClipsChildren(false)
  end

  frame.itemListContent = CreateFrame("Frame", nil, frame.itemListScroll)
  frame.itemListContent:SetSize(ITEM_LAYOUT.rowWidth, 1)
  if frame.itemListContent.SetClipsChildren then
    frame.itemListContent:SetClipsChildren(true)
  end
  frame.itemListScroll:SetScrollChild(frame.itemListContent)
  frame.itemListScroll:SetScript("OnVerticalScroll", function(self, offset)
    self:SetVerticalScroll(offset)
    ns.RefreshScrollBarVisibility(self)
    if ns._RenderVisibleItemRows then
      ns._RenderVisibleItemRows(frame, false)
    end
  end)
  frame.itemListScroll._ddScrollBar = _G["DecorDropsItemListScrollFrameScrollBar"]
  frame.itemListScroll._ddScrollBarAnchorSide = "right"
  frame.itemListScroll._ddScrollBarXOffset = 8
  ns._ApplyScrollBarOpacity(frame.itemListScroll, 1.0)
  C_Timer.After(0, function()
    ns.RefreshScrollBarVisibility(frame.itemListScroll)
  end)

  frame.catalogWidgets = {
    frame.qualityLegend,
    frame.searchLabel,
    frame.searchBox,
    frame.typeButton,
    frame.professionFilterButton,
    frame.collectedButton,
    frame.scanButton,
    frame.resetButton,
    frame.clearWaypointsButton,
    frame.leftPanel,
    frame.rightPanel,
  }

  frame.infoPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.infoPanel:SetPoint("TOPLEFT", 20, -82)
  frame.infoPanel:SetPoint("BOTTOMRIGHT", -24, 8)
  frame.infoPanel:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.infoPanel:SetBackdropColor(0.18, 0.14, 0.10, 0.56)
  frame.infoPanel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  frame.infoPanel:Hide()

  frame.infoTitle = frame.infoPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  frame.infoTitle:SetPoint("TOP", frame.infoPanel, "TOP", 0, -14)
  frame.infoTitle:SetText("DecorDrops Info")
  frame.infoTitle:SetJustifyH("CENTER")
  frame.infoTitle:SetTextColor(0.98, 0.90, 0.74)

  frame.infoVersion = frame.infoPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  frame.infoVersion:SetPoint("TOP", frame.infoTitle, "BOTTOM", 0, -8)
  frame.infoVersion:SetJustifyH("CENTER")
  frame.infoVersion:SetTextColor(0.84, 0.92, 1.00)
  frame.infoVersion:SetText(ns.GetInfoVersionText())

  frame.infoScroll = CreateFrame("ScrollFrame", "DecorDropsInfoScrollFrame", frame.infoPanel, "UIPanelScrollFrameTemplate")
  frame.infoScroll:SetPoint("TOPLEFT", frame.infoPanel, "TOPLEFT", 8, -68)
  frame.infoScroll:SetPoint("BOTTOMRIGHT", frame.infoPanel, "BOTTOMRIGHT", -28, 74)
  frame.infoContent = CreateFrame("Frame", nil, frame.infoScroll)
  frame.infoContent:ClearAllPoints()
  -- Slight right shift to align section frames beneath centered Info header.
  frame.infoContent:SetPoint("TOPLEFT", frame.infoScroll, "TOPLEFT", 12, 0)
  frame.infoContent:SetSize(1028, 1)
  frame.infoScroll:SetScrollChild(frame.infoContent)
  frame.infoScroll._ddScrollBar = _G["DecorDropsInfoScrollFrameScrollBar"]
  frame.infoScroll._ddScrollBarAnchorSide = "right"
  frame.infoScroll._ddScrollBarXOffset = 0
  ns._ApplyScrollBarOpacity(frame.infoScroll, 0.74)
  ns.BuildInfoPanel(frame)

  frame.infoFeedbackLabel = frame.infoPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.infoFeedbackLabel:SetPoint("BOTTOM", frame.infoPanel, "BOTTOM", 0, 46)
  frame.infoFeedbackLabel:SetJustifyH("CENTER")
  frame.infoFeedbackLabel:SetTextColor(0.98, 0.90, 0.74)
  frame.infoFeedbackLabel:SetText("Report a Bug / Suggest Feedback:")

  frame.infoInstagramButton = CreateFrame("Button", nil, frame.infoPanel, "UIPanelButtonTemplate")
  frame.infoInstagramButton:SetSize(160, 26)
  frame.infoInstagramButton:SetPoint("BOTTOM", frame.infoPanel, "BOTTOM", 0, 16)
  frame.infoInstagramButton:SetText("Instagram")
  frame.infoInstagramButton:SetScript("OnClick", function()
    ShowCopyableURL(ns.SUPPORT_INSTAGRAM_URL)
  end)

  frame.featuresPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.featuresPanel:SetPoint("TOPLEFT", 20, -82)
  frame.featuresPanel:SetPoint("BOTTOMRIGHT", -24, 8)
  frame.featuresPanel:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.featuresPanel:SetBackdropColor(0.18, 0.14, 0.10, 0.56)
  frame.featuresPanel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  frame.featuresPanel:Hide()

  frame.featuresTitle = frame.featuresPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  frame.featuresTitle:SetPoint("TOP", frame.featuresPanel, "TOP", 0, -14)
  frame.featuresTitle:SetText("DecorDrops Features")
  frame.featuresTitle:SetJustifyH("CENTER")
  frame.featuresTitle:SetTextColor(0.98, 0.90, 0.74)

  frame.featuresScroll = CreateFrame("ScrollFrame", nil, frame.featuresPanel, "UIPanelScrollFrameTemplate")
  frame.featuresScroll:SetPoint("TOPLEFT", 8, -42)
  frame.featuresScroll:SetPoint("BOTTOMRIGHT", -28, 8)
  frame.featuresContent = CreateFrame("Frame", nil, frame.featuresScroll)
  frame.featuresContent:SetSize(1048, 1)
  frame.featuresScroll:SetScrollChild(frame.featuresContent)
  ns._ApplyScrollBarOpacity(frame.featuresScroll, 0.74)
  ns.BuildFeaturesPanel(frame)

  frame.knownIssuesPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.knownIssuesPanel:SetPoint("TOPLEFT", 20, -82)
  frame.knownIssuesPanel:SetPoint("BOTTOMRIGHT", -24, 8)
  frame.knownIssuesPanel:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.knownIssuesPanel:SetBackdropColor(0.18, 0.14, 0.10, 0.56)
  frame.knownIssuesPanel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  frame.knownIssuesPanel:Hide()

  frame.knownIssuesTitle = frame.knownIssuesPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  frame.knownIssuesTitle:SetPoint("TOP", frame.knownIssuesPanel, "TOP", 0, -14)
  frame.knownIssuesTitle:SetText("Bugs & Feedback")
  frame.knownIssuesTitle:SetJustifyH("CENTER")
  frame.knownIssuesTitle:SetTextColor(0.98, 0.90, 0.74)

  frame.knownIssuesBody = frame.knownIssuesPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  frame.knownIssuesBody:SetPoint("TOP", frame.knownIssuesTitle, "BOTTOM", 0, -12)
  frame.knownIssuesBody:SetPoint("LEFT", frame.knownIssuesPanel, "LEFT", 20, 0)
  frame.knownIssuesBody:SetPoint("RIGHT", frame.knownIssuesPanel, "RIGHT", -20, 0)
  frame.knownIssuesBody:SetJustifyH("CENTER")
  frame.knownIssuesBody:SetJustifyV("TOP")
  ns.BuildKnownIssuesPanel(frame)

  frame.settingsPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.settingsPanel:SetPoint("TOPLEFT", 20, -82)
  frame.settingsPanel:SetPoint("BOTTOMRIGHT", -24, 8)
  frame.settingsPanel:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.settingsPanel:SetBackdropColor(0.18, 0.14, 0.10, 0.56)
  frame.settingsPanel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
  frame.settingsPanel:Hide()

  local function CreateMissionStatusPanel(titleText, panelKind)
    local panel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    panel:SetPoint("TOPLEFT", 20, -82)
    panel:SetPoint("BOTTOMRIGHT", -24, 8)
    panel:SetBackdrop({
      bgFile = UIC.THEME_MARBLE,
      edgeFile = UIC.THEME_BORDER,
      edgeSize = 12,
      insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    panel:SetBackdropColor(0.18, 0.14, 0.10, 0.56)
    panel:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)
    panel:Hide()

    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOP", panel, "TOP", 0, -14)
    title:SetText(titleText)
    title:SetJustifyH("CENTER")
    title:SetTextColor(0.98, 0.90, 0.74)
    if panelKind == "garrisons" or panelKind == "orderhalls" then
      ns.ScaleGarrisonDisplayFontString(title)
    end

    local subtitle = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    subtitle:SetPoint("TOP", title, "BOTTOM", 0, -12)
    subtitle:SetText("Mission dashboard. In Progress refreshes every 15 seconds.")
    subtitle:SetJustifyH("CENTER")
    subtitle:SetTextColor(0.84, 0.92, 1.00)
    if panelKind == "garrisons" or panelKind == "orderhalls" then
      ns.ScaleGarrisonDisplayFontString(subtitle)
    end

    local topAccent = panel:CreateTexture(nil, "ARTWORK", nil, 2)
    topAccent:SetPoint("TOPLEFT", subtitle, "BOTTOMLEFT", -300, -8)
    topAccent:SetPoint("TOPRIGHT", subtitle, "BOTTOMRIGHT", 300, -8)
    topAccent:SetHeight(2)
    topAccent:SetTexture(UIC.THEME_FLAT)
    topAccent:SetVertexColor(0.96, 0.78, 0.36, 0.45)

    panel._ddPanelKind = panelKind
    if not panel._ddSelectedCategory then
      panel._ddSelectedCategory = panelKind == "garrisons" and "garrison_inprogress" or "inprogress"
    end

    panel.leftPane = CreateFrame("Frame", nil, panel, "BackdropTemplate")
    panel.leftPane:SetPoint("TOPLEFT", panel, "TOPLEFT", 10, -88)
    panel.leftPane:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 10, 8)
    panel.leftPane:SetWidth(280)
    panel.leftPane:SetBackdrop({
      bgFile = UIC.THEME_MARBLE,
      edgeFile = UIC.THEME_BORDER,
      edgeSize = 12,
      insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    panel.leftPane:SetBackdropColor(0.18, 0.14, 0.10, 0.52)
    panel.leftPane:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)

    panel.rightPane = CreateFrame("Frame", nil, panel, "BackdropTemplate")
    panel.rightPane:SetPoint("TOPLEFT", panel.leftPane, "TOPRIGHT", 10, 0)
    panel.rightPane:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -8, 8)
    panel.rightPane:SetBackdrop({
      bgFile = UIC.THEME_FLAT,
      edgeFile = UIC.THEME_BORDER,
      edgeSize = 10,
      insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    panel.rightPane:SetBackdropColor(0.06, 0.05, 0.04, 0.38)
    panel.rightPane:SetBackdropBorderColor(0.78, 0.66, 0.44, 0.60)

    panel.categoryButtons = panel.categoryButtons or {}
    panel.categoryHeaders = panel.categoryHeaders or {}
    local function CreateCategoryHeader(label, yOffset)
      local fs = panel.leftPane:CreateFontString(nil, "OVERLAY", "GameFontNormal")
      fs:SetPoint("TOPLEFT", panel.leftPane, "TOPLEFT", 12, yOffset)
      fs:SetPoint("TOPRIGHT", panel.leftPane, "TOPRIGHT", -12, yOffset)
      fs:SetJustifyH("LEFT")
      fs:SetTextColor(0.98, 0.90, 0.74)
      fs:SetText(label)
      if panelKind == "garrisons" then
        ns.ScaleGarrisonCategoryFontString(fs)
      end
      panel.categoryHeaders[#panel.categoryHeaders + 1] = fs
      return fs
    end
    local function CreateCategoryButton(key, label, r, g, b, yOffset)
      local btn = CreateExpansionHeader(panel.leftPane)
      btn:SetPoint("TOPLEFT", panel.leftPane, "TOPLEFT", 10, yOffset)
      btn:SetPoint("TOPRIGHT", panel.leftPane, "TOPRIGHT", -20, yOffset)
      btn._ddColor = { r = r, g = g, b = b }
      btn.key = key
      btn.labelText = label
      btn.arrow:SetText("+")
      if panelKind == "garrisons" then
        ns.ScaleGarrisonCategoryFontString(btn.text)
        ns.ScaleGarrisonCategoryFontString(btn.arrow)
      end
      btn.specialGlow = btn:CreateTexture(nil, "ARTWORK", nil, 1)
      btn.specialGlow:SetPoint("TOPLEFT", 2, -2)
      btn.specialGlow:SetPoint("BOTTOMLEFT", 2, 2)
      btn.specialGlow:SetWidth(3)
      btn.specialGlow:SetTexture(UIC.THEME_FLAT)
      btn.specialGlow:SetVertexColor(1.00, 0.86, 0.42, 0.00)
      btn.specialGlow:Hide()
      btn:SetScript("OnClick", function(selfBtn)
        panel._ddSelectedCategory = selfBtn.key
        if frame.RefreshMissionStatusPanels then
          frame:RefreshMissionStatusPanels(true)
        end
      end)
      panel.categoryButtons[key] = btn
    end

    if panelKind == "garrisons" then
      CreateCategoryHeader("Garrison Missions", -14)
      CreateCategoryButton("garrison_complete", "Completed", 0.18, 0.82, 0.28, -36)
      CreateCategoryButton("garrison_inprogress", "In Progress", 0.98, 0.76, 0.20, -72)
      CreateCategoryButton("garrison_available", "Available", 0.26, 0.60, 0.96, -108)

      CreateCategoryHeader("Shipyard Missions", -154)
      CreateCategoryButton("naval_complete", "Completed", 0.18, 0.82, 0.28, -176)
      CreateCategoryButton("naval_inprogress", "In Progress", 0.98, 0.76, 0.20, -212)
      CreateCategoryButton("naval_available", "Available", 0.26, 0.60, 0.96, -248)
    else
      CreateCategoryButton("complete", "Completed", 0.18, 0.82, 0.28, -12)
      CreateCategoryButton("inprogress", "In Progress", 0.98, 0.76, 0.20, -52)
      CreateCategoryButton("available", "Available", 0.26, 0.60, 0.96, -92)
    end

    panel.headerMission = panel.rightPane:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panel.headerMission:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", 12, -12)
    panel.headerMission:SetText("Mission Name")
    panel.headerMission:SetTextColor(0.90, 0.88, 0.76)
    panel.headerMission:SetJustifyH("LEFT")
    if panelKind == "garrisons" or panelKind == "orderhalls" then
      ns.ScaleGarrisonDisplayFontString(panel.headerMission)
    end

    panel.headerTime = panel.rightPane:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panel.headerTime:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", 320, -12)
    panel.headerTime:SetText("Time Remaining")
    panel.headerTime:SetTextColor(0.90, 0.88, 0.76)
    panel.headerTime:SetJustifyH("LEFT")
    if panelKind == "garrisons" or panelKind == "orderhalls" then
      ns.ScaleGarrisonDisplayFontString(panel.headerTime)
    end

    panel.headerRewards = panel.rightPane:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panel.headerRewards:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", (panelKind == "garrisons" or panelKind == "orderhalls") and 520 or 470, -12)
    panel.headerRewards:SetText("Rewards")
    panel.headerRewards:SetTextColor(0.90, 0.88, 0.76)
    panel.headerRewards:SetJustifyH("LEFT")
    if panelKind == "garrisons" or panelKind == "orderhalls" then
      ns.ScaleGarrisonDisplayFontString(panel.headerRewards)
    end

    local scrollName = (panelKind == "garrisons" and "DecorDropsGarrisonRowsScrollFrame")
      or (panelKind == "orderhalls" and "DecorDropsOrderHallsRowsScrollFrame")
      or nil
    panel.rowsScroll = CreateFrame("ScrollFrame", scrollName, panel.rightPane, "UIPanelScrollFrameTemplate")
    panel.rowsScroll:SetPoint("TOPLEFT", panel.rightPane, "TOPLEFT", 6, -34)
    panel.rowsScroll:SetPoint("BOTTOMRIGHT", panel.rightPane, "BOTTOMRIGHT", -22, 6)
    panel.rowsContent = CreateFrame("Frame", nil, panel.rowsScroll)
    panel.rowsContent:ClearAllPoints()
    panel.rowsContent:SetPoint("TOPLEFT", panel.rowsScroll, "TOPLEFT", 0, 0)
    panel.rowsContent:SetSize(792, 1)
    panel.rowsScroll:SetScrollChild(panel.rowsContent)
    if scrollName then
      panel.rowsScroll._ddScrollBar = _G[scrollName .. "ScrollBar"]
      panel.rowsScroll._ddScrollBarAnchorSide = "right"
      panel.rowsScroll._ddScrollBarXOffset = 8
    end
    ns._ApplyScrollBarOpacity(panel.rowsScroll, 0.74)
    panel.rows = panel.rows or {}

    return panel
  end

  frame.garrisonsPanel = CreateMissionStatusPanel("Garrisons", "garrisons")
  frame.orderHallsPanel = CreateMissionStatusPanel("Order Halls", "orderhalls")
  frame.currencyPanel = ns.CreateCurrencyStatusPanel(frame)
  frame.currencyPanel._onCurrencySelect = function(resourceName)
    ns.ApplyVendorResourceFilter(frame, resourceName)
  end

  frame.settingsTitle = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  frame.settingsTitle:SetPoint("TOP", frame.settingsPanel, "TOP", 0, -14)
  frame.settingsTitle:SetText("DecorDrops Settings")
  frame.settingsTitle:SetJustifyH("CENTER")
  frame.settingsTitle:SetTextColor(0.98, 0.90, 0.74)

  frame.settingsSubtitle = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  frame.settingsSubtitle:SetPoint("TOP", frame.settingsTitle, "BOTTOM", 0, -12)
  frame.settingsSubtitle:SetText("Settings")
  frame.settingsSubtitle:SetJustifyH("CENTER")
  frame.settingsSubtitle:SetTextColor(0.84, 0.92, 1.00)

  frame.settingsHelp = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.settingsHelp:SetPoint("TOP", frame.settingsSubtitle, "BOTTOM", 0, -6)
  frame.settingsHelp:SetPoint("LEFT", frame.settingsPanel, "LEFT", 20, 0)
  frame.settingsHelp:SetPoint("RIGHT", frame.settingsPanel, "RIGHT", -20, 0)
  frame.settingsHelp:SetText("All settings are saved locally and persist across sessions.")
  frame.settingsHelp:SetJustifyH("CENTER")
  frame.settingsHelp:SetTextColor(0.95, 0.88, 0.76)

  frame.settingsLeftPane = CreateFrame("Frame", nil, frame.settingsPanel, "BackdropTemplate")
  frame.settingsLeftPane:SetPoint("TOPLEFT", frame.settingsPanel, "TOPLEFT", 10, -88)
  frame.settingsLeftPane:SetPoint("BOTTOMLEFT", frame.settingsPanel, "BOTTOMLEFT", 10, 8)
  frame.settingsLeftPane:SetWidth(280)
  frame.settingsLeftPane:SetBackdrop({
    bgFile = UIC.THEME_MARBLE,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.settingsLeftPane:SetBackdropColor(0.18, 0.14, 0.10, 0.52)
  frame.settingsLeftPane:SetBackdropBorderColor(0.88, 0.74, 0.46, 0.92)

  frame.settingsRightPane = CreateFrame("Frame", nil, frame.settingsPanel, "BackdropTemplate")
  frame.settingsRightPane:SetPoint("TOPLEFT", frame.settingsLeftPane, "TOPRIGHT", 10, 0)
  frame.settingsRightPane:SetPoint("BOTTOMRIGHT", frame.settingsPanel, "BOTTOMRIGHT", -8, 8)
  frame.settingsRightPane:SetBackdrop({
    bgFile = UIC.THEME_FLAT,
    edgeFile = UIC.THEME_BORDER,
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  frame.settingsRightPane:SetBackdropColor(0.06, 0.05, 0.04, 0.38)
  frame.settingsRightPane:SetBackdropBorderColor(0.78, 0.66, 0.44, 0.60)

  frame.settingsHelp:ClearAllPoints()
  frame.settingsHelp:SetPoint("TOP", frame.settingsRightPane, "TOP", 0, -10)
  frame.settingsHelp:SetPoint("LEFT", frame.settingsRightPane, "LEFT", 20, 0)
  frame.settingsHelp:SetPoint("RIGHT", frame.settingsRightPane, "RIGHT", -20, 0)

  frame.settingsCategoryAuction = CreateExpansionHeader(frame.settingsLeftPane)
  frame.settingsCategoryAuction:SetPoint("TOPLEFT", frame.settingsLeftPane, "TOPLEFT", 10, -18)
  frame.settingsCategoryAuction:SetPoint("TOPRIGHT", frame.settingsLeftPane, "TOPRIGHT", -20, -18)
  frame.settingsCategoryAuction.text:SetText("Auction Scan Reminders")
  frame.settingsCategoryAuction.arrow:SetText("")
  frame.settingsCategoryAuction.arrow:Hide()

  frame.settingsCategoryMissions = CreateExpansionHeader(frame.settingsLeftPane)
  frame.settingsCategoryMissions:SetPoint("TOPLEFT", frame.settingsLeftPane, "TOPLEFT", 10, -56)
  frame.settingsCategoryMissions:SetPoint("TOPRIGHT", frame.settingsLeftPane, "TOPRIGHT", -20, -56)
  frame.settingsCategoryMissions.text:SetText("Garrison/Legion")
  frame.settingsCategoryMissions.arrow:SetText("")
  frame.settingsCategoryMissions.arrow:Hide()

  frame.settingsCategoryUI = CreateExpansionHeader(frame.settingsLeftPane)
  frame.settingsCategoryUI:SetPoint("TOPLEFT", frame.settingsLeftPane, "TOPLEFT", 10, -94)
  frame.settingsCategoryUI:SetPoint("TOPRIGHT", frame.settingsLeftPane, "TOPRIGHT", -20, -94)
  frame.settingsCategoryUI.text:SetText("UI Settings")
  frame.settingsCategoryUI.arrow:SetText("")
  frame.settingsCategoryUI.arrow:Hide()

  frame._ddSettingsCategory = frame._ddSettingsCategory or "auction"

  frame.settingsCategoryAuction:SetScript("OnClick", function()
    frame._ddSettingsCategory = "auction"
    if frame.RefreshSettingsCategoryLayout then
      frame:RefreshSettingsCategoryLayout()
    end
  end)
  frame.settingsCategoryMissions:SetScript("OnClick", function()
    frame._ddSettingsCategory = "missions"
    if frame.RefreshSettingsCategoryLayout then
      frame:RefreshSettingsCategoryLayout()
    end
  end)
  frame.settingsCategoryUI:SetScript("OnClick", function()
    frame._ddSettingsCategory = "ui"
    if frame.RefreshSettingsCategoryLayout then
      frame:RefreshSettingsCategoryLayout()
    end
  end)

  frame.opacityLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.opacityLabel:SetPoint("TOP", frame.settingsHelp, "BOTTOM", 0, -20)
  frame.opacityLabel:SetText("Window Opacity")
  frame.opacityLabel:SetJustifyH("CENTER")
  frame.opacityLabel:SetTextColor(0.98, 0.90, 0.74)

  frame.opacitySlider = CreateFrame("Slider", nil, frame.settingsPanel, "OptionsSliderTemplate")
  frame.opacitySlider:SetWidth(320)
  frame.opacitySlider:SetPoint("TOP", frame.opacityLabel, "BOTTOM", 0, -14)
  frame.opacitySlider:SetMinMaxValues(35, 100)
  frame.opacitySlider:SetValueStep(1)
  if frame.opacitySlider.SetObeyStepOnDrag then
    frame.opacitySlider:SetObeyStepOnDrag(true)
  end
  if frame.opacitySlider.Low then
    frame.opacitySlider.Low:SetText("35%")
  end
  if frame.opacitySlider.High then
    frame.opacitySlider.High:SetText("100%")
  end
  if frame.opacitySlider.Text then
    frame.opacitySlider.Text:SetText("")
  end

  frame.opacityValue = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.opacityValue:SetPoint("TOP", frame.opacitySlider, "BOTTOM", 0, -8)
  frame.opacityValue:SetJustifyH("CENTER")
  frame.opacityValue:SetTextColor(0.84, 0.92, 1.00)

  function frame:ApplyWindowOpacity(percentValue)
    local pct = ClampValue(math.floor((tonumber(percentValue) or 100) + 0.5), 35, 100)
    local alpha = pct / 100
    SetSavedWindowOpacity(alpha)
    if self.opacityValue then
      self.opacityValue:SetText(string.format("%d%%", pct))
    end
  end

  frame.opacitySlider:SetScript("OnValueChanged", function(selfSlider, value)
    if not frame._initializingOpacitySlider then
      frame:ApplyWindowOpacity(value)
    end
  end)
  frame._initializingOpacitySlider = true
  frame.opacitySlider:SetValue(math.floor(GetSavedWindowOpacity() * 100 + 0.5))
  frame._initializingOpacitySlider = false
  frame:ApplyWindowOpacity(frame.opacitySlider:GetValue())

  frame.priceReminderCheck = CreateFrame("CheckButton", nil, frame.settingsPanel, "UICheckButtonTemplate")
  frame.priceReminderCheck:SetPoint("TOP", frame.opacityValue, "BOTTOM", -70, -16)
  frame.priceReminderCheck:SetChecked(DecorDropsDB.settings.enablePriceReminders ~= false)
  frame.priceReminderCheck:SetScript("OnClick", function(selfBtn)
    local enabled = selfBtn:GetChecked() and true or false
    DecorDropsDB.settings.enablePriceReminders = enabled
  end)
  frame.priceReminderCheck:SetScript("OnEnter", function(selfBtn)
    GameTooltip:SetOwner(selfBtn, "ANCHOR_TOPLEFT")
    GameTooltip:AddLine("Price Reminder Popup")
    GameTooltip:AddLine("When enabled, DecorDrops shows a price reminder popup based on your reminder interval.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  frame.priceReminderCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.priceReminderLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.priceReminderLabel:SetPoint("LEFT", frame.priceReminderCheck, "RIGHT", 2, 1)
  frame.priceReminderLabel:SetText("Enable price reminders")
  frame.priceReminderLabel:SetTextColor(0.92, 0.84, 0.68)

  frame.priceReminderDaysLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.priceReminderDaysLabel:SetPoint("TOP", frame.priceReminderCheck, "BOTTOM", 70, -16)
  frame.priceReminderDaysLabel:SetText("Reminder Interval (Days)")
  frame.priceReminderDaysLabel:SetJustifyH("CENTER")
  frame.priceReminderDaysLabel:SetTextColor(0.98, 0.90, 0.74)

  frame.priceReminderDaysSlider = CreateFrame("Slider", nil, frame.settingsPanel, "OptionsSliderTemplate")
  frame.priceReminderDaysSlider:SetWidth(320)
  frame.priceReminderDaysSlider:SetPoint("TOP", frame.priceReminderDaysLabel, "BOTTOM", 0, -14)
  frame.priceReminderDaysSlider:SetMinMaxValues(7, 180)
  frame.priceReminderDaysSlider:SetValueStep(1)
  if frame.priceReminderDaysSlider.SetObeyStepOnDrag then
    frame.priceReminderDaysSlider:SetObeyStepOnDrag(true)
  end
  if frame.priceReminderDaysSlider.Low then
    frame.priceReminderDaysSlider.Low:SetText("7")
  end
  if frame.priceReminderDaysSlider.High then
    frame.priceReminderDaysSlider.High:SetText("180")
  end
  if frame.priceReminderDaysSlider.Text then
    frame.priceReminderDaysSlider.Text:SetText("")
  end

  frame.priceReminderDaysValue = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.priceReminderDaysValue:SetPoint("TOP", frame.priceReminderDaysSlider, "BOTTOM", 0, -8)
  frame.priceReminderDaysValue:SetJustifyH("CENTER")
  frame.priceReminderDaysValue:SetTextColor(0.84, 0.92, 1.00)

  local function ApplyPriceReminderDays(dayValue)
    local days = ClampValue(math.floor((tonumber(dayValue) or 30) + 0.5), 7, 180)
    DecorDropsDB.settings.priceReminderDays = days
    if frame.priceReminderDaysValue then
      frame.priceReminderDaysValue:SetText(string.format("%d days", days))
    end
  end

  frame.priceReminderDaysSlider:SetScript("OnValueChanged", function(_, value)
    if not frame._initializingPriceReminderSlider then
      ApplyPriceReminderDays(value)
    end
  end)
  frame._initializingPriceReminderSlider = true
  frame.priceReminderDaysSlider:SetValue(tonumber(DecorDropsDB.settings.priceReminderDays) or 30)
  frame._initializingPriceReminderSlider = false
  ApplyPriceReminderDays(frame.priceReminderDaysSlider:GetValue())

  frame.missionAnimationSkipCheck = CreateFrame("CheckButton", nil, frame.settingsPanel, "UICheckButtonTemplate")
  frame.missionAnimationSkipCheck:SetPoint("TOP", frame.priceReminderDaysValue, "BOTTOM", -70, -16)
  frame.missionAnimationSkipCheck:SetChecked(DecorDropsDB.settings.enableMissionAnimationSkip and true or false)
  frame.missionAnimationSkipCheck:SetScript("OnClick", function(selfBtn)
    local enabled = selfBtn:GetChecked() and true or false
    DecorDropsDB.settings.enableMissionAnimationSkip = enabled
    if ns.SetMissionAnimationSkipEnabled then
      ns.SetMissionAnimationSkipEnabled(enabled)
    end
  end)
  frame.missionAnimationSkipCheck:SetScript("OnEnter", function(selfBtn)
    GameTooltip:SetOwner(selfBtn, "ANCHOR_TOPLEFT")
    GameTooltip:AddLine("Enable mission completion hotkey helper")
    GameTooltip:AddLine("Shows a mission-completion prompt and allows your selected hotkey to advance one completion step at a time for WoD Garrison, WoD Shipyard, and Legion Order Hall screens only.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  frame.missionAnimationSkipCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.missionAnimationSkipLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.missionAnimationSkipLabel:SetPoint("LEFT", frame.missionAnimationSkipCheck, "RIGHT", 2, 1)
  frame.missionAnimationSkipLabel:SetText("Enable mission completion hotkey (WoD/Legion)")
  frame.missionAnimationSkipLabel:SetTextColor(0.92, 0.84, 0.68)

  frame.missionHotkeyLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  frame.missionHotkeyLabel:SetPoint("TOPLEFT", frame.missionAnimationSkipCheck, "BOTTOMLEFT", 26, -16)
  frame.missionHotkeyLabel:SetText("Mission hotkey")
  frame.missionHotkeyLabel:SetTextColor(0.98, 0.90, 0.74)

  frame.missionHotkeyInput = CreateFrame("EditBox", nil, frame.settingsPanel, "InputBoxTemplate")
  frame.missionHotkeyInput:SetSize(120, 24)
  frame.missionHotkeyInput:SetPoint("LEFT", frame.missionHotkeyLabel, "RIGHT", 12, 0)
  frame.missionHotkeyInput:SetAutoFocus(false)
  frame.missionHotkeyInput:SetMaxLetters(20)
  frame.missionHotkeyInput:SetText((ns.GetMissionCompleteHotkey and ns.GetMissionCompleteHotkey()) or "SPACE")

  local function ApplyMissionHotkeyInput()
    local raw = tostring(frame.missionHotkeyInput and frame.missionHotkeyInput:GetText() or "")
    if raw == "" then
      raw = "SPACE"
    end
    if ns.SetMissionCompleteHotkey then
      ns.SetMissionCompleteHotkey(raw)
    end
    if frame.missionHotkeyInput and ns.GetMissionCompleteHotkey then
      frame.missionHotkeyInput:SetText(ns.GetMissionCompleteHotkey())
    end
  end

  frame.missionHotkeyInput:SetScript("OnEnterPressed", function(selfBox)
    selfBox:ClearFocus()
    ApplyMissionHotkeyInput()
  end)
  frame.missionHotkeyInput:SetScript("OnEditFocusLost", function()
    ApplyMissionHotkeyInput()
  end)
  frame.missionHotkeyInput:SetScript("OnEscapePressed", function(selfBox)
    selfBox:ClearFocus()
    if ns.GetMissionCompleteHotkey then
      selfBox:SetText(ns.GetMissionCompleteHotkey())
    end
  end)

  frame.missionHotkeyHint = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.missionHotkeyHint:SetPoint("TOPLEFT", frame.missionHotkeyLabel, "BOTTOMLEFT", 0, -4)
  frame.missionHotkeyHint:SetText("Examples: SPACE, ENTER, CTRL-SPACE")
  frame.missionHotkeyHint:SetTextColor(0.84, 0.92, 1.00)

  frame.showGarrisonsTabCheck = CreateFrame("CheckButton", nil, frame.settingsPanel, "UICheckButtonTemplate")
  frame.showGarrisonsTabCheck:SetPoint("TOP", frame.missionHotkeyHint, "BOTTOM", -26, -10)
  frame.showGarrisonsTabCheck:SetChecked(DecorDropsDB.settings.showGarrisonStatusTab and true or false)
  frame.showGarrisonsTabCheck:SetScript("OnClick", function(selfBtn)
    DecorDropsDB.settings.showGarrisonStatusTab = selfBtn:GetChecked() and true or false
    if frame.RefreshAuxiliaryTabVisibility then
      frame:RefreshAuxiliaryTabVisibility()
    end
  end)
  frame.showGarrisonsTabCheck:SetScript("OnEnter", function(selfBtn)
    GameTooltip:SetOwner(selfBtn, "ANCHOR_TOPLEFT")
    GameTooltip:AddLine("Show Garrisons tab")
    GameTooltip:AddLine("Displays read-only mission progress for Warlords garrison and shipyard missions.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  frame.showGarrisonsTabCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.showGarrisonsTabLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.showGarrisonsTabLabel:SetPoint("LEFT", frame.showGarrisonsTabCheck, "RIGHT", 2, 1)
  frame.showGarrisonsTabLabel:SetText("Show Garrisons tab")
  frame.showGarrisonsTabLabel:SetTextColor(0.92, 0.84, 0.68)

  frame.showOrderHallsTabCheck = CreateFrame("CheckButton", nil, frame.settingsPanel, "UICheckButtonTemplate")
  frame.showOrderHallsTabCheck:SetPoint("TOP", frame.showGarrisonsTabCheck, "BOTTOM", 0, -10)
  frame.showOrderHallsTabCheck:SetChecked(DecorDropsDB.settings.showOrderHallStatusTab and true or false)
  frame.showOrderHallsTabCheck:SetScript("OnClick", function(selfBtn)
    DecorDropsDB.settings.showOrderHallStatusTab = selfBtn:GetChecked() and true or false
    if frame.RefreshAuxiliaryTabVisibility then
      frame:RefreshAuxiliaryTabVisibility()
    end
  end)
  frame.showOrderHallsTabCheck:SetScript("OnEnter", function(selfBtn)
    GameTooltip:SetOwner(selfBtn, "ANCHOR_TOPLEFT")
    GameTooltip:AddLine("Show Order Halls tab")
    GameTooltip:AddLine("Displays read-only mission progress for Legion order hall missions.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  frame.showOrderHallsTabCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.showOrderHallsTabLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.showOrderHallsTabLabel:SetPoint("LEFT", frame.showOrderHallsTabCheck, "RIGHT", 2, 1)
  frame.showOrderHallsTabLabel:SetText("Show Order Halls tab")
  frame.showOrderHallsTabLabel:SetTextColor(0.92, 0.84, 0.68)

  frame.testReminderButton = CreateFrame("Button", nil, frame.settingsPanel, "UIPanelButtonTemplate")
  frame.testReminderButton:SetSize(160, 24)
  frame.testReminderButton:SetPoint("TOP", frame.showOrderHallsTabCheck, "BOTTOM", 70, -18)
  frame.testReminderButton:SetText("Test Reminder Now")
  frame.testReminderButton:SetScript("OnClick", function()
    if ns.MaybeShowPriceReminder then
      ns.MaybeShowPriceReminder(true)
    end
  end)
  frame.testReminderButton:SetShown((ns.IsDeveloperToolsEnabled and ns.IsDeveloperToolsEnabled()) and ((UnitName and UnitName("player")) or "") == "Tyaoskad")

  frame.teleportNotLearnedPreviewCheck = CreateFrame("CheckButton", nil, frame.settingsPanel, "UICheckButtonTemplate")
  frame.teleportNotLearnedPreviewCheck:SetPoint("TOP", frame.testReminderButton, "BOTTOM", -86, -14)
  frame.teleportNotLearnedPreviewCheck:SetChecked(DecorDropsDB.settings.testTeleportNotLearned and true or false)
  frame.teleportNotLearnedPreviewCheck:SetScript("OnClick", function(selfBtn)
    local enabled = selfBtn:GetChecked() and true or false
    DecorDropsDB.settings.testTeleportNotLearned = enabled
    if ns.MainFrame and ns.MainFrame.Refresh then
      ns.MainFrame:Refresh()
    end
  end)
  frame.teleportNotLearnedPreviewCheck:SetScript("OnEnter", function(selfBtn)
    GameTooltip:SetOwner(selfBtn, "ANCHOR_TOPLEFT")
    GameTooltip:AddLine("Teleport Not Learned Preview")
    GameTooltip:AddLine("For mage instance entries, force the disabled Teleport button UI preview.", 0.90, 0.82, 0.64, true)
    GameTooltip:Show()
  end)
  frame.teleportNotLearnedPreviewCheck:SetScript("OnLeave", function()
    GameTooltip_Hide()
  end)

  frame.teleportNotLearnedPreviewLabel = frame.settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  frame.teleportNotLearnedPreviewLabel:SetPoint("LEFT", frame.teleportNotLearnedPreviewCheck, "RIGHT", 2, 1)
  frame.teleportNotLearnedPreviewLabel:SetText("Preview Teleport not learned UI")
  frame.teleportNotLearnedPreviewLabel:SetTextColor(0.92, 0.84, 0.68)

  local function RefreshDeveloperSettingsVisibility()
    local showDevSettings = ns.IsDeveloperToolsEnabled and ns.IsDeveloperToolsEnabled()
    frame.teleportNotLearnedPreviewCheck:SetShown(showDevSettings)
    frame.teleportNotLearnedPreviewLabel:SetShown(showDevSettings)
    frame.testReminderButton:SetShown(showDevSettings and ((UnitName and UnitName("player")) or "") == "Tyaoskad")
    if not showDevSettings then
      DecorDropsDB.settings.testTeleportNotLearned = false
      frame.teleportNotLearnedPreviewCheck:SetChecked(false)
    end
  end
  frame.RefreshDeveloperSettingsVisibility = RefreshDeveloperSettingsVisibility
  RefreshDeveloperSettingsVisibility()

  frame.RefreshSettingsCategoryLayout = function(self)
    local active = self._ddSettingsCategory or "auction"

    local function StyleSettingsCategoryButton(btn, isActive)
      if not btn then
        return
      end
      btn.arrow:SetText("")
      btn.arrow:Hide()
      btn:SetBackdropColor(isActive and 0.34 or 0.05, isActive and 0.25 or 0.05, isActive and 0.08 or 0.05, 0.98)
      btn:SetBackdropBorderColor(isActive and 1.00 or 0.86, isActive and 0.84 or 0.72, isActive and 0.20 or 0.46, isActive and 0.98 or 0.92)
      btn.text:SetTextColor(isActive and 1.00 or 0.92, isActive and 0.95 or 0.84, isActive and 0.46 or 0.68)
      if btn.hover then
        btn.hover:SetShown(isActive)
        btn.hover:SetVertexColor(1.00, 0.82, 0.10, isActive and 0.14 or 0.06)
      end
    end

    StyleSettingsCategoryButton(self.settingsCategoryAuction, active == "auction")
    StyleSettingsCategoryButton(self.settingsCategoryMissions, active == "missions")
    StyleSettingsCategoryButton(self.settingsCategoryUI, active == "ui")

    local function HideControl(control)
      if control then
        control:Hide()
      end
    end
    local function ShowControl(control)
      if control then
        control:Show()
      end
    end

    HideControl(self.opacityLabel)
    HideControl(self.opacitySlider)
    HideControl(self.opacityValue)
    HideControl(self.priceReminderCheck)
    HideControl(self.priceReminderLabel)
    HideControl(self.priceReminderDaysLabel)
    HideControl(self.priceReminderDaysSlider)
    HideControl(self.priceReminderDaysValue)
    HideControl(self.missionAnimationSkipCheck)
    HideControl(self.missionAnimationSkipLabel)
    HideControl(self.missionHotkeyLabel)
    HideControl(self.missionHotkeyInput)
    HideControl(self.missionHotkeyHint)
    HideControl(self.showGarrisonsTabCheck)
    HideControl(self.showGarrisonsTabLabel)
    HideControl(self.showOrderHallsTabCheck)
    HideControl(self.showOrderHallsTabLabel)
    HideControl(self.testReminderButton)
    HideControl(self.teleportNotLearnedPreviewCheck)
    HideControl(self.teleportNotLearnedPreviewLabel)

    if active == "auction" then
      self.priceReminderCheck:ClearAllPoints()
      self.priceReminderCheck:SetPoint("TOP", self.settingsRightPane, "TOP", -90, -56)
      self.priceReminderLabel:ClearAllPoints()
      self.priceReminderLabel:SetPoint("LEFT", self.priceReminderCheck, "RIGHT", 2, 1)
      self.priceReminderDaysLabel:ClearAllPoints()
      self.priceReminderDaysLabel:SetPoint("TOP", self.priceReminderCheck, "BOTTOM", 90, -18)
      self.priceReminderDaysSlider:ClearAllPoints()
      self.priceReminderDaysSlider:SetPoint("TOP", self.priceReminderDaysLabel, "BOTTOM", 0, -14)
      self.priceReminderDaysValue:ClearAllPoints()
      self.priceReminderDaysValue:SetPoint("TOP", self.priceReminderDaysSlider, "BOTTOM", 0, -8)
      self.testReminderButton:ClearAllPoints()
      self.testReminderButton:SetPoint("TOP", self.priceReminderDaysValue, "BOTTOM", 0, -18)

      ShowControl(self.priceReminderCheck)
      ShowControl(self.priceReminderLabel)
      ShowControl(self.priceReminderDaysLabel)
      ShowControl(self.priceReminderDaysSlider)
      ShowControl(self.priceReminderDaysValue)
      if self.RefreshDeveloperSettingsVisibility then
        self:RefreshDeveloperSettingsVisibility()
      end
    elseif active == "missions" then
      local orderHallUnlocked = ns.IsOrderHallIntroductionComplete and ns.IsOrderHallIntroductionComplete() or false
      local rowTop = -62
      local leftPad = 26
      local rowGap = 36
      self.missionAnimationSkipCheck:ClearAllPoints()
      self.missionAnimationSkipCheck:SetPoint("TOPLEFT", self.settingsRightPane, "TOPLEFT", leftPad, rowTop)
      self.missionAnimationSkipLabel:ClearAllPoints()
      self.missionAnimationSkipLabel:SetPoint("LEFT", self.missionAnimationSkipCheck, "RIGHT", 2, 1)
      self.showGarrisonsTabCheck:ClearAllPoints()
      self.showGarrisonsTabCheck:SetPoint("TOPLEFT", self.missionAnimationSkipCheck, "BOTTOMLEFT", 0, -rowGap)
      self.showGarrisonsTabLabel:ClearAllPoints()
      self.showGarrisonsTabLabel:SetPoint("LEFT", self.showGarrisonsTabCheck, "RIGHT", 2, 1)
      self.showOrderHallsTabCheck:ClearAllPoints()
      self.showOrderHallsTabCheck:SetPoint("TOPLEFT", self.showGarrisonsTabCheck, "BOTTOMLEFT", 0, -rowGap)
      self.showOrderHallsTabLabel:ClearAllPoints()
      self.showOrderHallsTabLabel:SetPoint("LEFT", self.showOrderHallsTabCheck, "RIGHT", 2, 1)

      local function BoostOptionLabel(fs)
        if not fs or fs._ddSettingsBoosted then
          return
        end
        local path, size, flags = fs:GetFont()
        if path and size and fs.SetFont then
          fs:SetFont(path, math.max(10, size * 1.10), flags)
        end
        fs._ddSettingsBoosted = true
      end
      BoostOptionLabel(self.missionAnimationSkipLabel)
      BoostOptionLabel(self.showGarrisonsTabLabel)
      BoostOptionLabel(self.showOrderHallsTabLabel)

      self.missionHotkeyLabel:ClearAllPoints()
      self.missionHotkeyLabel:SetPoint("LEFT", self.showGarrisonsTabLabel, "RIGHT", 170, 0)
      self.missionHotkeyInput:ClearAllPoints()
      self.missionHotkeyInput:SetPoint("LEFT", self.missionHotkeyLabel, "RIGHT", 12, 0)
      self.missionHotkeyHint:ClearAllPoints()
      self.missionHotkeyHint:SetPoint("TOPLEFT", self.missionHotkeyLabel, "BOTTOMLEFT", 0, -4)

      if orderHallUnlocked then
        self.showOrderHallsTabCheck:Enable()
        self.showOrderHallsTabLabel:SetText("Show Order Halls tab")
        self.showOrderHallsTabLabel:SetTextColor(0.92, 0.84, 0.68)
      else
        self.showOrderHallsTabCheck:SetChecked(false)
        DecorDropsDB.settings.showOrderHallStatusTab = false
        self.showOrderHallsTabCheck:Disable()
        self.showOrderHallsTabLabel:SetText("Show Order Halls tab |cffff5555(Introduction to Class Hall questline not completed)|r")
        self.showOrderHallsTabLabel:SetTextColor(0.92, 0.84, 0.68)
      end

      ShowControl(self.missionAnimationSkipCheck)
      ShowControl(self.missionAnimationSkipLabel)
      ShowControl(self.showGarrisonsTabCheck)
      ShowControl(self.showGarrisonsTabLabel)
      ShowControl(self.showOrderHallsTabCheck)
      ShowControl(self.showOrderHallsTabLabel)
      ShowControl(self.missionHotkeyLabel)
      ShowControl(self.missionHotkeyInput)
      ShowControl(self.missionHotkeyHint)
    else
      self.opacityLabel:ClearAllPoints()
      self.opacityLabel:SetPoint("TOP", self.settingsRightPane, "TOP", 0, -56)
      self.opacitySlider:ClearAllPoints()
      self.opacitySlider:SetPoint("TOP", self.opacityLabel, "BOTTOM", 0, -14)
      self.opacityValue:ClearAllPoints()
      self.opacityValue:SetPoint("TOP", self.opacitySlider, "BOTTOM", 0, -8)
      self.teleportNotLearnedPreviewCheck:ClearAllPoints()
      self.teleportNotLearnedPreviewCheck:SetPoint("TOP", self.opacityValue, "BOTTOM", -86, -14)

      ShowControl(self.opacityLabel)
      ShowControl(self.opacitySlider)
      ShowControl(self.opacityValue)
      if self.RefreshDeveloperSettingsVisibility then
        self:RefreshDeveloperSettingsVisibility()
      end
    end
  end
  frame:RefreshSettingsCategoryLayout()

  local function CreateBottomTab(parent, label)
    local tab = CreateFrame("Button", nil, parent, "PanelTabButtonTemplate")
    tab:SetText(label)
    if PanelTemplates_TabResize and tab.GetFontString and tab:GetFontString() then
      PanelTemplates_TabResize(tab, 0, nil, 100, nil, tab:GetFontString():GetStringWidth())
    end
    tab.glow = tab:CreateTexture(nil, "ARTWORK", nil, 1)
    tab.glow:SetPoint("TOPLEFT", 5, -4)
    tab.glow:SetPoint("BOTTOMRIGHT", -5, 3)
    tab.glow:SetTexture(UIC.THEME_FLAT)
    tab.glow:SetVertexColor(1.00, 0.82, 0.18, 0.08)
    tab.glow:Hide()
    tab.text = tab.GetFontString and tab:GetFontString() or nil
    return tab
  end

  frame.bottomBar = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.bottomBar:SetPoint("TOPLEFT", frame, "BOTTOMLEFT", 18, 0)
  frame.bottomBar:SetPoint("TOPRIGHT", frame, "BOTTOMRIGHT", -18, 0)
  frame.bottomBar:SetHeight(1)
  frame.bottomBar:Hide()

  frame.infoTab = CreateBottomTab(frame, "Info")
  frame.infoTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Info")
  end)

  frame.featuresTab = CreateBottomTab(frame, "Features")
  frame.featuresTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Features")
  end)

  frame.knownIssuesTab = CreateBottomTab(frame, "Bugs & Feedback")
  frame.knownIssuesTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "KnownIssues")
  end)

  frame.garrisonsTab = CreateBottomTab(frame, "Garrisons")
  frame.garrisonsTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Garrisons")
  end)

  frame.orderHallsTab = CreateBottomTab(frame, "Order Halls")
  frame.orderHallsTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "OrderHalls")
  end)

  frame.currencyTab = CreateBottomTab(frame, "Currency")
  frame.currencyTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Currency")
  end)

  frame.settingsTab = CreateBottomTab(frame, "Settings")
  frame.settingsTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Settings")
  end)

  function frame:LayoutBottomTabs()
    DecorDropsDB = DecorDropsDB or {}
    DecorDropsDB.settings = DecorDropsDB.settings or {}
    local spacing = 8
    for _, tab in ipairs({ self.infoTab, self.featuresTab, self.knownIssuesTab, self.garrisonsTab, self.orderHallsTab, self.currencyTab, self.settingsTab }) do
      if tab then
        tab:Hide()
      end
    end

    -- Left-side tabs.
    local leftAnchor = nil
    if self.currencyTab then
      self.currencyTab:ClearAllPoints()
      self.currencyTab:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 26, 1)
      self.currencyTab:Show()
      leftAnchor = self.currencyTab
    end

    -- Optional mission status tabs.
    if self.orderHallsTab and DecorDropsDB.settings.showOrderHallStatusTab then
      self.orderHallsTab:ClearAllPoints()
      if leftAnchor then
        self.orderHallsTab:SetPoint("LEFT", leftAnchor, "RIGHT", spacing, 0)
      else
        self.orderHallsTab:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 26, 1)
      end
      self.orderHallsTab:Show()
      leftAnchor = self.orderHallsTab
    end
    if self.garrisonsTab and DecorDropsDB.settings.showGarrisonStatusTab then
      self.garrisonsTab:ClearAllPoints()
      if leftAnchor then
        self.garrisonsTab:SetPoint("LEFT", leftAnchor, "RIGHT", spacing, 0)
      else
        self.garrisonsTab:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 26, 1)
      end
      self.garrisonsTab:Show()
    end

    -- Right-side core tabs (always visible).
    local rightTabs = { self.settingsTab, self.knownIssuesTab, self.featuresTab, self.infoTab }
    local rightAnchor = nil
    for _, tab in ipairs(rightTabs) do
      tab:ClearAllPoints()
      if not rightAnchor then
        tab:SetPoint("TOPRIGHT", self, "BOTTOMRIGHT", -26, 1)
      else
        tab:SetPoint("RIGHT", rightAnchor, "LEFT", -spacing, 0)
      end
      tab:Show()
      rightAnchor = tab
    end
  end

  function frame:RefreshMissionStatusPanels(force)
    local now = GetTime and GetTime() or 0
    if not force and self._lastMissionStatusRefreshAt and now - self._lastMissionStatusRefreshAt < ns.MissionStatus.refreshSeconds then
      return
    end
    self._lastMissionStatusRefreshAt = now

    local function EnsureMissionRow(panel, index)
      local row = panel.rows[index]
      if row then
        return row
      end
      local isGarrisonsPanel = panel and (panel._ddPanelKind == "garrisons" or panel._ddPanelKind == "orderhalls")
      local isGarrisonItemList = panel and panel._ddPanelKind == "garrisons"
      local rowScale = isGarrisonItemList and 0.90 or 1.0
      local iconSize = isGarrisonsPanel and ((24 * ns.MissionStatus.garrisonRewardIconScale) * rowScale) or 24
      local iconStep = isGarrisonsPanel and (iconSize + 10) or 30
      local rewardsStartX = isGarrisonsPanel and 520 or 470
      row = CreateFrame("Frame", nil, panel.rowsContent, "BackdropTemplate")
      row:SetSize(786, isGarrisonsPanel and (58 * rowScale) or 42)
      row:SetBackdrop({
        bgFile = UIC.THEME_FLAT,
        edgeFile = UIC.THEME_BORDER,
        edgeSize = 8,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
      })
      row:SetBackdropColor(0.08, 0.07, 0.06, 0.42)
      row:SetBackdropBorderColor(0.46, 0.40, 0.30, 0.38)

      row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.nameText:SetPoint("LEFT", row, "LEFT", 10, 0)
      row.nameText:SetWidth(300)
      row.nameText:SetJustifyH("LEFT")
      if isGarrisonsPanel then
        ns.ScaleGarrisonDisplayFontString(row.nameText)
        if isGarrisonItemList then
          row.nameText:SetScale(rowScale)
        end
      end

      row.timeText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
      row.timeText:SetPoint("LEFT", row, "LEFT", 320, 0)
      row.timeText:SetWidth(isGarrisonsPanel and 176 or 132)
      row.timeText:SetJustifyH("LEFT")
      row.timeText:SetTextColor(0.62, 0.86, 1.0)
      if isGarrisonsPanel then
        ns.ScaleGarrisonDisplayFontString(row.timeText)
        if isGarrisonItemList then
          row.timeText:SetScale(rowScale)
        end
      end

      row.rewards = {}
      for i = 1, 4 do
        local icon = CreateFrame("Button", nil, row)
        icon:SetSize(iconSize, iconSize)
        icon:SetPoint("LEFT", row, "LEFT", rewardsStartX + ((i - 1) * iconStep), 6)
        icon.tex = icon:CreateTexture(nil, "ARTWORK")
        icon.tex:SetAllPoints()
        icon.count = icon:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        icon.count:SetPoint("TOP", icon, "BOTTOM", 0, -4)
        icon.count:SetJustifyH("CENTER")
        icon.count:SetTextColor(1.00, 0.95, 0.72)
        if isGarrisonsPanel then
          ns.ScaleGarrisonDisplayFontString(icon.count)
          if isGarrisonItemList then
            icon.count:SetScale(rowScale)
          end
        end
        icon.count:Hide()
        icon:Hide()
        row.rewards[i] = icon
      end

      panel.rows[index] = row
      return row
    end

    local function RefreshMissionDashboardPanel(panel)
      if not panel then
        return
      end
      local categoryData = ns.MissionStatus.BuildPanelCategoryData(panel._ddPanelKind)

      local categoryConfig = nil
      if panel._ddPanelKind == "garrisons" then
        categoryConfig = {
          { key = "garrison_complete", label = "Completed", status = "complete", source = "Garrison" },
          { key = "garrison_inprogress", label = "In Progress", status = "inprogress", source = "Garrison" },
          { key = "garrison_available", label = "Available", status = "available", source = "Garrison" },
          { key = "naval_complete", label = "Completed", status = "complete", source = "Shipyard" },
          { key = "naval_inprogress", label = "In Progress", status = "inprogress", source = "Shipyard" },
          { key = "naval_available", label = "Available", status = "available", source = "Shipyard" },
        }
        if not panel.categoryButtons[panel._ddSelectedCategory] then
          panel._ddSelectedCategory = "garrison_inprogress"
        end
      else
        categoryConfig = {
          { key = "complete", label = "Completed", status = "complete", source = nil },
          { key = "inprogress", label = "In Progress", status = "inprogress", source = nil },
          { key = "available", label = "Available", status = "available", source = nil },
        }
        if not panel.categoryButtons[panel._ddSelectedCategory] then
          panel._ddSelectedCategory = "inprogress"
        end
      end

      local statusColors = {
        complete = { 0.18, 0.82, 0.28 },
        inprogress = { 0.98, 0.76, 0.20 },
        available = { 0.26, 0.60, 0.96 },
      }

      local selectedEntries = {}
      local categoryCounts = {}
      for _, cfg in ipairs(categoryConfig) do
        local bucket = categoryData[cfg.status] or {}
        local count = 0
        for _, entry in ipairs(bucket) do
          local matchesSource = (cfg.source == nil) or (tostring(entry.sourceLabel or "") == tostring(cfg.source))
          if matchesSource then
            count = count + 1
            if cfg.key == panel._ddSelectedCategory then
              selectedEntries[#selectedEntries + 1] = entry
            end
          end
        end
        categoryCounts[cfg.key] = count
      end

      for _, cfg in ipairs(categoryConfig) do
        local key = cfg.key
        local btn = panel.categoryButtons[key]
        if btn then
          local color = statusColors[cfg.status] or { 0.8, 0.8, 0.8 }
          local active = key == panel._ddSelectedCategory
          local tintByStatus = panel._ddPanelKind == "garrisons" or panel._ddPanelKind == "orderhalls"
          btn.arrow:SetText("+")
          if tintByStatus then
            btn:SetBackdropColor(color[1], color[2], color[3], active and 0.18 or 0.10)
            btn:SetBackdropBorderColor(color[1], color[2], color[3], active and 0.95 or 0.60)
          else
            btn:SetBackdropColor(0.05, 0.05, 0.05, 0.98)
            btn:SetBackdropBorderColor(active and color[1] or 0.86, active and color[2] or 0.72, active and color[3] or 0.46, active and 0.95 or 0.92)
          end
          btn.text:SetText(string.format("%s (%d)", cfg.label, categoryCounts[key] or 0))
          if tintByStatus then
            btn.text:SetTextColor(active and 1.00 or color[1], active and 1.00 or color[2], active and 1.00 or color[3])
            btn.arrow:SetTextColor(active and 1.00 or color[1], active and 1.00 or color[2], active and 1.00 or color[3])
          else
            btn.text:SetTextColor(active and color[1] or 1.00, active and color[2] or 0.93, active and color[3] or 0.78)
            btn.arrow:SetTextColor(active and color[1] or 1.00, active and color[2] or 0.93, active and color[3] or 0.78)
          end
          if btn.specialGlow then
            if active then
              btn.specialGlow:Show()
              btn.specialGlow:SetVertexColor(color[1], color[2], color[3], 0.70)
            else
              btn.specialGlow:Hide()
            end
          end
        end
      end

      for key, btn in pairs(panel.categoryButtons or {}) do
        local matched = false
        for _, cfg in ipairs(categoryConfig) do
          if cfg.key == key then
            matched = true
            break
          end
        end
        if not matched then
          btn:Hide()
        else
          btn:Show()
        end
      end

      local entries = selectedEntries

      for i, entry in ipairs(entries) do
        local row = EnsureMissionRow(panel, i)
        local rowStep = panel._ddPanelKind == "garrisons" and 54 or 44
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", panel.rowsContent, "TOPLEFT", 4, -((i - 1) * rowStep))
        row:Show()

        local mission = entry.mission
        local missionName = tostring(type(mission) == "table" and mission.name or "Unknown Mission")
        row.nameText:SetText(missionName)
        row.timeText:SetText(ns.MissionStatus.BuildMissionTimeText(mission, entry.prefix))

        local rewards = ns.MissionStatus.ExtractRewardTokens(mission)
        for iconIndex = 1, 4 do
          local iconBtn = row.rewards[iconIndex]
          local token = rewards[iconIndex]
          if token then
            iconBtn.tex:SetTexture(token.icon)
            iconBtn:Show()
            iconBtn._ddItemID = token.itemID
            iconBtn._ddCurrencyID = token.currencyID
            iconBtn._ddQuantity = token.quantity
            iconBtn._ddTooltipTitle = token.tooltipTitle
            iconBtn._ddTooltipDetail = token.tooltipDetail
            if token.quantityLabel and token.quantityLabel ~= "" and token.quantityLabel ~= "1" then
              local numericQuantity = tonumber(token.quantityLabel)
              if numericQuantity then
                iconBtn.count:SetText(FormatCurrencyAmount(numericQuantity))
              else
                iconBtn.count:SetText(tostring(token.quantityLabel))
              end
              iconBtn.count:Show()
            else
              iconBtn.count:Hide()
            end
            iconBtn:SetScript("OnEnter", function(selfBtn)
              GameTooltip:SetOwner(selfBtn, "ANCHOR_RIGHT")
              if selfBtn._ddItemID and selfBtn._ddItemID > 0 then
                GameTooltip:SetItemByID(selfBtn._ddItemID)
              elseif selfBtn._ddCurrencyID and C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
                local info = C_CurrencyInfo.GetCurrencyInfo(selfBtn._ddCurrencyID)
                GameTooltip:AddLine(tostring(info and info.name or ("Currency " .. tostring(selfBtn._ddCurrencyID))))
              elseif selfBtn._ddTooltipTitle and selfBtn._ddTooltipTitle ~= "" then
                GameTooltip:AddLine(tostring(selfBtn._ddTooltipTitle))
              else
                GameTooltip:AddLine("Reward")
              end
              if selfBtn._ddTooltipDetail and selfBtn._ddTooltipDetail ~= "" then
                GameTooltip:AddLine(tostring(selfBtn._ddTooltipDetail), 0.80, 0.80, 0.80, true)
              end
              if selfBtn._ddQuantity and selfBtn._ddQuantity > 1 then
                GameTooltip:AddLine("Quantity: " .. tostring(selfBtn._ddQuantity), 0.80, 0.80, 0.80)
              end
              GameTooltip:Show()
            end)
            iconBtn:SetScript("OnLeave", function()
              GameTooltip_Hide()
            end)
          else
            iconBtn.count:Hide()
            iconBtn._ddItemID = nil
            iconBtn._ddCurrencyID = nil
            iconBtn._ddQuantity = nil
            iconBtn._ddTooltipTitle = nil
            iconBtn._ddTooltipDetail = nil
            iconBtn:Hide()
          end
        end

      end

      for i = #entries + 1, #panel.rows do
        panel.rows[i]:Hide()
      end
      local rowStep = panel._ddPanelKind == "garrisons" and 54 or 44
      panel.rowsContent:SetHeight(math.max(1, (#entries * rowStep) + 10))
    end

    RefreshMissionDashboardPanel(self.garrisonsPanel)
    RefreshMissionDashboardPanel(self.orderHallsPanel)
  end

  function frame:RefreshAuxiliaryTabVisibility()
    DecorDropsDB = DecorDropsDB or {}
    DecorDropsDB.settings = DecorDropsDB.settings or {}
    DecorDropsDB.settings.showGarrisonStatusTab = DecorDropsDB.settings.showGarrisonStatusTab and true or false
    DecorDropsDB.settings.showOrderHallStatusTab = DecorDropsDB.settings.showOrderHallStatusTab and true or false

    if self.showGarrisonsTabCheck then
      self.showGarrisonsTabCheck:SetChecked(DecorDropsDB.settings.showGarrisonStatusTab)
    end
    if self.showOrderHallsTabCheck then
      self.showOrderHallsTabCheck:SetChecked(DecorDropsDB.settings.showOrderHallStatusTab)
    end

    self:LayoutBottomTabs()

    if self.activeTab == "Garrisons" and not DecorDropsDB.settings.showGarrisonStatusTab then
      ns.SetActiveBottomTab(self, "Catalog")
      return
    end
    if self.activeTab == "OrderHalls" and not DecorDropsDB.settings.showOrderHallStatusTab then
      ns.SetActiveBottomTab(self, "Catalog")
      return
    end

    if (self.activeTab == "Garrisons" and DecorDropsDB.settings.showGarrisonStatusTab)
        or (self.activeTab == "OrderHalls" and DecorDropsDB.settings.showOrderHallStatusTab) then
      self:RefreshMissionStatusPanels(true)
    end
    if self.activeTab == "Currency" and ns.RefreshCurrencyStatusPanel then
      ns.RefreshCurrencyStatusPanel(self, true)
    end
  end

  frame:RefreshAuxiliaryTabVisibility()

  frame.instanceButtons = {}
  frame.itemRows = {}
  frame:UpdateProfessionFilterButtonText()

  function frame:Refresh()
    if InCombatLockdown and InCombatLockdown() then
      ns._pendingCombatRefresh = true
      return
    end
    if self.RefreshAuxiliaryTabVisibility then
      self:RefreshAuxiliaryTabVisibility()
    end
    if self.missionAnimationSkipCheck then
      self.missionAnimationSkipCheck:SetChecked(DecorDropsDB.settings.enableMissionAnimationSkip and true or false)
    end
    if self.missionHotkeyInput and ns.GetMissionCompleteHotkey then
      self.missionHotkeyInput:SetText(ns.GetMissionCompleteHotkey())
    end
    if self.RefreshDeveloperSettingsVisibility then
      self:RefreshDeveloperSettingsVisibility()
    end
    if self.activeTab == "Settings" and self.RefreshSettingsCategoryLayout then
      self:RefreshSettingsCategoryLayout()
    end
    self._sourceCompletionCache = nil
    self.filtered = BuildFilteredInstances(self)
    ns._UpdateExpandCollapseButtonVisual(self)
    ns.EnsureSelection(self)
    ns.RefreshInstanceList(self)
    ns.RefreshItems(self)
    if (self.activeTab == "Garrisons" and DecorDropsDB.settings.showGarrisonStatusTab)
        or (self.activeTab == "OrderHalls" and DecorDropsDB.settings.showOrderHallStatusTab) then
      self:RefreshMissionStatusPanels(false)
    end
    if self.activeTab == "Currency" and ns.RefreshCurrencyStatusPanel then
      ns.RefreshCurrencyStatusPanel(self, false)
    end
  end

  frame._missionStatusElapsed = 0
  frame._currencyStatusElapsed = 0
  frame:HookScript("OnUpdate", function(selfFrame, elapsed)
    if not selfFrame:IsShown() then
      selfFrame._missionStatusElapsed = 0
      selfFrame._currencyStatusElapsed = 0
      return
    end
    if selfFrame.activeTab == "Currency" then
      selfFrame._missionStatusElapsed = 0
      selfFrame._currencyStatusElapsed = (tonumber(selfFrame._currencyStatusElapsed) or 0) + (tonumber(elapsed) or 0)
      if selfFrame._currencyStatusElapsed < (ns.CurrencyStatus.refreshSeconds or 30) then
        return
      end
      selfFrame._currencyStatusElapsed = 0
      if ns.RefreshCurrencyStatusPanel then
        ns.RefreshCurrencyStatusPanel(selfFrame, false)
      end
      return
    end
    selfFrame._currencyStatusElapsed = 0
    if selfFrame.activeTab ~= "Garrisons" and selfFrame.activeTab ~= "OrderHalls" then
      selfFrame._missionStatusElapsed = 0
      return
    end
    selfFrame._missionStatusElapsed = (tonumber(selfFrame._missionStatusElapsed) or 0) + (tonumber(elapsed) or 0)
    if selfFrame._missionStatusElapsed < ns.MissionStatus.refreshSeconds then
      return
    end
    selfFrame._missionStatusElapsed = 0
    if selfFrame.RefreshMissionStatusPanels then
      selfFrame:RefreshMissionStatusPanels(false)
    end
  end)

  function frame:RefreshLeftPanelOnly()
    ns._UpdateExpandCollapseButtonVisual(self)
    ns.RefreshInstanceList(self)
  end

  frame.expandCollapseButton:SetScript("OnClick", function(selfBtn)
    local doCollapse = ns._AnyExpansionExpanded(frame)
    ns._SetAllExpansionsCollapsed(frame, doCollapse)
    frame:RefreshLeftPanelOnly()
  end)

  frame.searchBox:SetScript("OnEnterPressed", function(selfEdit)
    if frame._suppressFilterControlEvents then
      return
    end
    frame.filters.search = selfEdit:GetText() or ""
    DecorDropsDB.search = frame.filters.search
    frame:Refresh()
    selfEdit:ClearFocus()
  end)

  frame.searchBox:SetScript("OnEditFocusLost", function(selfEdit)
    if frame._suppressFilterControlEvents then
      return
    end
    frame.filters.search = selfEdit:GetText() or ""
    DecorDropsDB.search = frame.filters.search
    frame:Refresh()
  end)

  frame.typeButton:SetScript("OnClick", function(selfBtn)
    if frame.filters.type == "All" then
      frame.filters.type = "Dungeon"
    elseif frame.filters.type == "Dungeon" then
      frame.filters.type = "Raid"
    else
      frame.filters.type = "All"
    end
    DecorDropsDB.typeFilter = frame.filters.type
    selfBtn:SetText("Type: " .. frame.filters.type)
    frame:Refresh()
  end)

  frame.professionFilterButton:SetScript("OnClick", function()
    if frame.professionFilterMenu:IsShown() then
      frame.professionFilterMenu:Hide()
      return
    end
    frame.professionFilterMenu:ClearAllPoints()
    frame.professionFilterMenu:SetPoint("TOPLEFT", frame.professionFilterButton, "BOTTOMLEFT", 0, -4)
    frame.professionFilterMenu:SetFrameStrata("TOOLTIP")
    frame.professionFilterMenu:SetFrameLevel((frame:GetFrameLevel() or 1) + 80)
    frame:BuildProfessionFilterMenu()
    frame.professionFilterMenu:Show()
  end)

  frame.professionFilterSelectAll:SetScript("OnClick", function()
    local selected = {}
    local names = BuildProfessionFilterList()
    for _, name in ipairs(names) do
      selected[name] = true
    end
    frame.filters.professionMulti = selected
    DecorDropsDB.professionFilter = selected
    frame:BuildProfessionFilterMenu()
    frame:UpdateProfessionFilterButtonText()
    frame:Refresh()
  end)

  frame.professionFilterClearAll:SetScript("OnClick", function()
    frame.filters.professionMulti = {}
    DecorDropsDB.professionFilter = frame.filters.professionMulti
    frame:BuildProfessionFilterMenu()
    frame:UpdateProfessionFilterButtonText()
    frame:Refresh()
  end)

  frame.collectedButton:SetScript("OnClick", function(selfBtn)
    if frame.filters.collected == "All" then
      frame.filters.collected = "Collected"
    elseif frame.filters.collected == "Collected" then
      frame.filters.collected = "Uncollected"
    else
      frame.filters.collected = "All"
    end
    DecorDropsDB.collectedFilter = frame.filters.collected
    selfBtn:SetText("Show: " .. frame.filters.collected)
    frame:Refresh()
  end)

  frame.scanButton:SetScript("OnClick", function()
    if not (ns.IsDeveloperToolsEnabled and ns.IsDeveloperToolsEnabled()) then
      print("|cffffcc66DecorDrops|r Manual scan is developer-only in this build.")
      return
    end
    local mode = "all"
    if ns.RequestScan then
      ns.RequestScan(mode)
    elseif ns.RunAutoScan then
      ns.RunAutoScan(mode)
    end
  end)

  frame.clearWaypointsButton:SetScript("OnClick", function()
    ClearTrainerWaypoint()
    print("|cff66ccffDecorDrops|r Cleared all active waypoints.")
  end)

  frame.resetButton:SetScript("OnClick", function()
    ns._ResetAllSessionFilters(frame, true)
    frame:Refresh()
  end)

  ns.MainFrame = frame
  if not ns._uiScaleWatcher then
    ns._uiScaleWatcher = CreateFrame("Frame")
    ns._uiScaleWatcher:RegisterEvent("DISPLAY_SIZE_CHANGED")
    ns._uiScaleWatcher:RegisterEvent("UI_SCALE_CHANGED")
    ns._uiScaleWatcher:RegisterEvent("CVAR_UPDATE")
    ns._uiScaleWatcher:SetScript("OnEvent", function(_, event, arg1)
      if event == "CVAR_UPDATE" then
        local cvar = string.lower(tostring(arg1 or ""))
        if cvar ~= "uiscale" and cvar ~= "useuiscale" then
          return
        end
      end
      ns.ReflowMainFrame()
    end)
  end
  C_Timer.After(0, ns.ReflowMainFrame)
  ns.SetActiveBottomTab(frame, "Catalog")
  ns.SetViewMode(frame, "tracker")
end

function ns.RefreshUI()
  if ns.MainFrame and ns.MainFrame.Refresh then
    ns.MainFrame:Refresh()
  end
end
