local _, ns = ...

local READY_TEXTURE = "Interface\\RaidFrame\\ReadyCheck-Ready"
local NOT_READY_TEXTURE = "Interface\\RaidFrame\\ReadyCheck-NotReady"
local THEME_MARBLE = "Interface\\FrameGeneral\\UI-Background-Marble"
local THEME_DARK = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark"
local THEME_BORDER = "Interface\\Tooltips\\UI-Tooltip-Border"
local THEME_FLAT = "Interface\\Buttons\\WHITE8X8"
local THEME_HOUSING_BG_TGA = "Interface\\AddOns\\DecorDrops\\Media\\HousingBackdrop.tga"
local THEME_HOUSING_BG_PNG = "Interface\\AddOns\\DecorDrops\\Media\\HousingBackdrop.png"
local THEME_HOUSING_BG_JPG = "Interface\\AddOns\\DecorDrops\\Media\\HousingBackdrop.jpg"
local ICON_BOOK_CATALOG = "Interface\\Icons\\INV_Misc_Book_09"
local ICON_PROFESSIONS = "Interface\\Icons\\INV_Inscription_Tradeskill01"
local ICON_TRACKER = "Interface\\GossipFrame\\AvailableQuestIcon"
local ICON_TRAINER = "Interface\\Icons\\INV_Misc_Book_11"
local ICON_LOCATION = "Interface\\Icons\\INV_Misc_Map_01"
local ICON_VENDOR = "Interface\\Icons\\INV_Misc_Coin_02"
local ICON_ACHIEVEMENT = "Interface\\Icons\\Achievement_Quests_Completed_08"
local ICON_QUEST = "Interface\\GossipFrame\\AvailableQuestIcon"
local ICON_REPUTATION = "Interface\\Icons\\Achievement_Reputation_01"
local 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 dungeonWaypointUID = nil
local pendingManualWaypointEntry = nil
local activeManualWaypointInstance = nil
local worldMapLayerHooked = false
local trainerMapNameCache = {}
local trainerMapIndexBuilt = false
local trainerMapIDsByName = {}
local auctionHousePriceSession = {
  active = false,
  snapshot = {},
}
local CUSTOM_ICON_TEXTURE = "Interface\\AddOns\\DecorDrops\\Media\\DecorDropsPortraitIcon_new.png"
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 },
}
local SEARCH_LABEL_X = 56
local SEARCH_LABEL_Y = -40
local SEARCH_BOX_WIDTH = 230
local SEARCH_TO_FILTER_GAP = 10
local RESOURCE_ICON_SIZE = 25
local RESOURCE_ICON_GAP = 8
local RESOURCE_TEXT_Y_OFFSET = -1
local MONEY_ICON_GOLD = "Interface\\MoneyFrame\\UI-GoldIcon"
local MONEY_ICON_SILVER = "Interface\\MoneyFrame\\UI-SilverIcon"
local MONEY_ICON_COPPER = "Interface\\MoneyFrame\\UI-CopperIcon"
local MONEY_ICON_SIZE_ROW = 13
local MONEY_ICON_SIZE_HEADER = 16
local PURCHASE_ICON_GOLD = "Interface\\Icons\\INV_Misc_Coin_01"
local PURCHASE_ICON_SILVER = "Interface\\Icons\\INV_Misc_Coin_03"
local PURCHASE_ICON_COPPER = "Interface\\Icons\\INV_Misc_Coin_05"
local PURCHASE_ICON_SIZE = 18
local ICON_VIEW_EYE = "Interface\\LFGFrame\\LFG-Eye"
local BASE_FRAME_WIDTH = 1140
local BASE_FRAME_HEIGHT = 700
local LEFT_LIST_WIDTH = 242
local SUBCATEGORY_LEFT_INDENT = 18
local 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",
}
local CURRENT_SEASON_GROUP = "Current Season"
local PLAYER_CLASS_TOKEN = select(2, UnitClass("player"))
local PLAYER_NAME = UnitName and UnitName("player") or ""
local IS_MAGE = PLAYER_CLASS_TOKEN == "MAGE"
local PLAYER_FACTION = UnitFactionGroup and UnitFactionGroup("player") or nil
local EXPANSION_TELEPORT_SPELL_IDS = {
  ["Classic"] = {
    Alliance = { 3561 }, -- Stormwind
    Horde = { 3567 }, -- Orgrimmar
  },
  ["The Burning Crusade"] = {
    Neutral = { 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
  },
}
local EXPANSION_TELEPORT_KEYWORDS = {
  ["Classic"] = { "Stormwind", "Orgrimmar" },
  ["The Burning Crusade"] = { "Shattrath" },
  ["Wrath of the Lich King"] = { "Dalaran" },
  ["Cataclysm"] = { "Theramore", "Stonard" },
  ["Mists of Pandaria"] = { "Vale of Eternal Blossoms" },
  ["Warlords of Draenor"] = { "Stormshield", "Warspear" },
  ["Legion"] = { "Dalaran" },
  ["Battle for Azeroth"] = { "Boralus", "Dazar'alor" },
  ["Shadowlands"] = { "Oribos" },
  ["Dragonflight"] = { "Valdrakken" },
  ["The War Within"] = { "Dornogal" },
}
local EXPANSION_TELEPORT_PLACEHOLDERS = {
  ["Midnight"] = true,
}
local INFO_PAGE_TEMPLATE = {
  {
    title = "Author",
    lines = {
      "In-Game: MetaWater2962",
      "Guild: Inglorious Besters",
      "Instagram: @ParallaxDevs (best form of contact)",
    },
  },
  {
    title = "Social Media",
    lines = {
      "Discord: TheParallaxDev",
      "Instagram: @ParallaxDevs",
    },
  },
  {
    title = "Support",
    lines = {
      "Bug Reports: Discord or Instagram",
      "Feedback: Instagram",
      "Version: [Addon version]",
    },
  },
  {
    title = "Changelog",
    lines = {
      "- [vX.Y.Z] [Summary of latest changes]",
      "- [Older updates can be listed here]",
    },
  },
  {
    title = "Notes",
    lines = {
      "[Any extra notes for players]",
    },
  },
}
local FEATURES_PAGE_TEMPLATE = {
  {
    key = "core",
    title = "Core Features",
    lines = {
      "- Scans and catalogs housing decorations from raids, dungeons, and professions.",
      "- 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, and Settings pages.",
      "- Left panel supports grouped category expansion, subcategory navigation, and tracker controls.",
      "- Mage feature: click expansion teleport icons to quickly travel to supported instance hubs.",
    },
  },
  {
    key = "data",
    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.",
    },
  },
  {
    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.",
    },
  },
}
local EXPANSION_ORDER_INDEX = {}
for idx, name in ipairs(EXPANSION_RELEASE_ORDER) do
  EXPANSION_ORDER_INDEX[name] = idx
end

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

local function SetAddonIcon(texture)
  texture:SetTexture(CUSTOM_ICON_TEXTURE)
  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

-- 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,
}

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

local function 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"
  end
  return "Source"
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._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

local function GetAcquireStatusText(item, selectedEntry)
  if type(item) ~= "table" then
    return "-"
  end
  local questID = tonumber(item.questID)
  if questID and questID > 0 and C_QuestLog and C_QuestLog.IsQuestFlaggedCompleted then
    return C_QuestLog.IsQuestFlaggedCompleted(questID) and "Quest Complete" or "Incomplete"
  end
  local achievementID = ResolveAchievementIDForItem(item)
  if achievementID and achievementID > 0 then
    if C_AchievementInfo and C_AchievementInfo.IsAchievementCompleted then
      local ok, completed = pcall(C_AchievementInfo.IsAchievementCompleted, achievementID)
      if ok and completed ~= nil then
        return completed and "Achievement Complete" or "Achievement Missing"
      end
    end
    if GetAchievementInfo then
      local _, _, _, completed = GetAchievementInfo(achievementID)
      return completed and "Achievement Complete" or "Achievement Missing"
    end
  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 string.lower(tostring(expansion or "Other") .. "|" .. tostring(zoneName or "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) .. "|" .. tostring(expansion or "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
  return collapsed[tostring(expansion or "Other")] and true or 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
  return collapsedZones[BuildNewSourceZoneKey(expansion, zoneName)] and true or 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(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 IsRenownExpansion(expansionName)
  local v = string.lower(tostring(expansionName or ""))
  return v == "dragonflight" or v == "the war within" or v == "midnight"
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 IsAchievementObtained(item)
  local id = ResolveAchievementIDForItem(item) or 0
  if id <= 0 then
    return false
  end
  if C_AchievementInfo and C_AchievementInfo.IsAchievementCompleted then
    local ok, completed = pcall(C_AchievementInfo.IsAchievementCompleted, id)
    if ok and completed ~= nil then
      return completed and true or false
    end
  end
  if GetAchievementInfo then
    local _, _, _, completed = GetAchievementInfo(id)
    return completed and true or false
  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 BuildSourceCompletionMaps(entries)
  local byEntryKey = {}
  local zoneTotals = {}
  local expansionTotals = {}

  for _, entry in ipairs(entries or {}) do
    local entryKey = tostring(entry.key or "")
    local expansion = tostring(entry.expansion or "Other")
    local zoneName = tostring(entry.zoneName or "Unknown Zone")
    local zoneKey = BuildNewSourceZoneKey(expansion, zoneName)
    local done = IsAcquireEntryComplete(entry)
    byEntryKey[entryKey] = done

    zoneTotals[zoneKey] = zoneTotals[zoneKey] or { total = 0, done = 0 }
    zoneTotals[zoneKey].total = zoneTotals[zoneKey].total + 1
    if done then
      zoneTotals[zoneKey].done = zoneTotals[zoneKey].done + 1
    end

    expansionTotals[expansion] = expansionTotals[expansion] or { total = 0, done = 0 }
    expansionTotals[expansion].total = expansionTotals[expansion].total + 1
    if done then
      expansionTotals[expansion].done = expansionTotals[expansion].done + 1
    end
  end

  local byZoneKey = {}
  for key, totals in pairs(zoneTotals) do
    byZoneKey[key] = totals.total > 0 and totals.done >= totals.total
  end
  local byExpansion = {}
  for exp, totals in pairs(expansionTotals) do
    byExpansion[exp] = totals.total > 0 and totals.done >= totals.total
  end

  return byEntryKey, byZoneKey, byExpansion
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

local function ResolveQuestNavigationTarget(item, selectedEntry)
  local questEntries = ns.GetAcquireData and ns.GetAcquireData("quest") or {}
  if type(questEntries) ~= "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
    for _, entry in ipairs(questEntries) do
      for _, row in ipairs(entry.rows or {}) do
        if tonumber(row and row.questID) == wantedQuestID then
          return { entry = entry, row = row }
        end
      end
    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 best = nil
  local bestScore = -1
  for _, entry in ipairs(questEntries) do
    for _, row in ipairs(entry.rows or {}) do
      local rowItemID = tonumber(row and row.itemID) or 0
      if rowItemID == wantedItemID then
        local score = 0
        local rowSourceName = NormalizeCompareText(row and row.sourceName)
        local rowSource = NormalizeCompareText(row and row.source)
        local entryDetail = NormalizeCompareText(entry and entry.detail)
        local rowExpansion = NormalizeCompareText(entry and entry.expansion)
        local entryLabel = NormalizeCompareText(entry and entry.label)
        score = score + 100
        if wantedSourceName ~= "" and rowSourceName ~= "" and rowSourceName == wantedSourceName then score = score + 20 end
        if wantedSource ~= "" and rowSource ~= "" and rowSource == wantedSource then score = score + 10 end
        if wantedDetail ~= "" and entryDetail ~= "" and entryDetail == wantedDetail then score = score + 5 end
        if wantedExpansion ~= "" and rowExpansion ~= "" and rowExpansion == wantedExpansion then score = score + 2 end
        if wantedLabel ~= "" and entryLabel ~= "" and entryLabel == wantedLabel then score = score + 2 end
        if score > bestScore then
          bestScore = score
          best = { entry = entry, row = row }
        end
      end
    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 targetExpansion = tostring(questTarget.entry.expansion or "Other")
  CollapseState.SetExpansionCollapsed(frame, targetExpansion, false)
  local targetZoneName = ResolveSourceEntryZoneName(questTarget.entry, questTarget.entry.rows)
  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

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 function GetItemName(item)
  if item.itemID then
    local itemID = tonumber(item.itemID)
    if itemID and itemID > 0 and C_Item and C_Item.RequestLoadItemDataByID then
      local now = GetTime and GetTime() or 0
      local lastRequest = tonumber(pendingItemNameRequests[itemID]) or 0
      if (now - lastRequest) >= ITEM_NAME_REQUEST_COOLDOWN_SECONDS then
        pendingItemNameRequests[itemID] = now
        C_Item.RequestLoadItemDataByID(itemID)
      end
    end
    local itemName = C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(item.itemID) or nil
    if itemName and itemName ~= "" then
      if itemID then
        pendingItemNameRequests[itemID] = nil
      end
      return itemName
    end

    local itemNameClassic = GetItemInfo(item.itemID)
    if itemNameClassic and itemNameClassic ~= "" then
      if itemID then
        pendingItemNameRequests[itemID] = nil
      end
      return itemNameClassic
    end
  end

  return item.name or "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

local function GetItemIconTexture(item)
  if item.itemID then
    if C_Item and C_Item.GetItemIconByID then
      local texture = C_Item.GetItemIconByID(item.itemID)
      if texture then
        return texture
      end
    end

    local texture = select(10, GetItemInfo(item.itemID))
    if texture then
      return texture
    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

local function GetHousingCatalogInfoByItem(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

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

  local cached = collectionStateCache[item.itemID]
  if cached ~= nil then
    return cached
  end

  if DecorDropsDB and DecorDropsDB.collectionOwned and DecorDropsDB.collectionOwned[item.itemID] then
    collectionStateCache[item.itemID] = true
    return true
  end

  local catalogInfo = GetHousingCatalogInfoByItem(item.itemID)
  if catalogInfo then
    local parsedInfo = ParseCollectedFromValue(catalogInfo)
    if parsedInfo ~= nil then
      collectionStateCache[item.itemID] = parsedInfo
      if parsedInfo and DecorDropsDB then
        DecorDropsDB.collectionOwned = DecorDropsDB.collectionOwned or {}
        DecorDropsDB.collectionOwned[item.itemID] = true
      end
      return parsedInfo
    end

    local entryID = ExtractCatalogEntryIDFromValue(catalogInfo)
    if entryID then
      local byEntry = TryHousingCollectedStateByEntry(entryID)
      if byEntry ~= nil then
        collectionStateCache[item.itemID] = byEntry
        if byEntry and DecorDropsDB then
          DecorDropsDB.collectionOwned = DecorDropsDB.collectionOwned or {}
          DecorDropsDB.collectionOwned[item.itemID] = true
        end
        return byEntry
      end
    end
  end

  if GetOwnedItemCount(item.itemID) > 0 then
    collectionStateCache[item.itemID] = true
    if DecorDropsDB then
      DecorDropsDB.collectionOwned = DecorDropsDB.collectionOwned or {}
      DecorDropsDB.collectionOwned[item.itemID] = true
    end
    return true
  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
      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
            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
            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 markCollected and DecorDropsDB then
      DecorDropsDB.collectionOwned = DecorDropsDB.collectionOwned or {}
      DecorDropsDB.collectionOwned[itemID] = true
    end
  else
    wipe(collectionStateCache)
  end
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 BeginManualOverrideForInstance(frame, instance)
  if type(instance) ~= "table" or not frame or not frame.manualOverridePanel then
    return
  end
  activeManualWaypointInstance = instance
  if frame.manualMapIDInput and frame.manualXInput and frame.manualYInput then
    frame.manualMapIDInput:SetText(tostring(tonumber(instance.mapID) or ""))
    frame.manualXInput:SetText(tostring((instance.entrance and tonumber(instance.entrance.x)) or ""))
    frame.manualYInput:SetText(tostring((instance.entrance and tonumber(instance.entrance.y)) or ""))
  end
  frame.manualOverridePanel:Show()
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

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)
    local mapID = pendingManualWaypointEntry and pendingManualWaypointEntry.mapID or ""
    self.editBox:SetText(tostring(mapID))
    self.editBox:SetFocus()
    self.editBox:HighlightText()
  end,
  OnAccept = function(self)
    if not pendingManualWaypointEntry then
      return
    end
    pendingManualWaypointEntry.mapID = tonumber(TrimString(self.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)
    local x = pendingManualWaypointEntry and pendingManualWaypointEntry.x or ""
    self.editBox:SetText(tostring(x))
    self.editBox:SetFocus()
    self.editBox:HighlightText()
  end,
  OnAccept = function(self)
    if not pendingManualWaypointEntry then
      return
    end
    pendingManualWaypointEntry.x = tonumber(TrimString(self.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)
    local y = pendingManualWaypointEntry and pendingManualWaypointEntry.y or ""
    self.editBox:SetText(tostring(y))
    self.editBox:SetFocus()
    self.editBox:HighlightText()
  end,
  OnAccept = function(self)
    if not pendingManualWaypointEntry then
      return
    end
    pendingManualWaypointEntry.y = tonumber(TrimString(self.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,
}
end

local function ResolveWaypointMap(mapID, x, y)
  if not mapID or not UiMapPoint then
    return nil, nil, nil
  end

  local tried = {}
  local currentMap = mapID
  local currentX, currentY = x, y

  for _ = 1, 12 do
    if not currentMap or tried[currentMap] then
      break
    end
    tried[currentMap] = true

    local point = UiMapPoint.CreateFromCoordinates(currentMap, currentX, currentY)
    if point then
      return currentMap, currentX, currentY
    end

    if not C_Map or not C_Map.GetMapInfo then
      break
    end
    local mapInfo = C_Map.GetMapInfo(currentMap)
    if not mapInfo or not mapInfo.parentMapID or mapInfo.parentMapID == 0 then
      break
    end

    currentMap = mapInfo.parentMapID
    currentX, currentY = 0.50, 0.50
  end

  return nil, nil, nil
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)
  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 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 byExpansion = byProfession[expansionName]
  if type(byExpansion) ~= "table" then
    return {}
  end

  local out = {}
  local faction = PLAYER_FACTION
  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")

  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 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
  if mapID and WorldMapFrame.SetMapID then
    pcall(WorldMapFrame.SetMapID, WorldMapFrame, mapID)
  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

local pendingWaypointInstance = nil
local waypointQueueFrame = nil

local function ClearDungeonWaypoint()
  if TomTom and dungeonWaypointUID then
    pcall(TomTom.RemoveWaypoint, TomTom, dungeonWaypointUID)
  end
  dungeonWaypointUID = 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 ApplyDungeonWaypoint(instance)
  if not instance or not instance.mapID then
    print("|cffff5555DecorDrops: Missing map data for waypoint.|r")
    return false
  end

  if not C_Map or not C_Map.SetUserWaypoint or not UiMapPoint then
    print("|cffff5555DecorDrops: Waypoints are not available in this client.|r")
    return false
  end

  local x, y = 0.50, 0.50
  local hasExactCoords = false
  if instance.entrance and instance.entrance.x and instance.entrance.y then
    x = ClampCoord01(instance.entrance.x / 100)
    y = ClampCoord01(instance.entrance.y / 100)
    hasExactCoords = true
  end

  local mapID, resolvedX, resolvedY = ResolveWaypointMap(instance.mapID, x, y)
  if not mapID then
    print("|cffff5555DecorDrops: Could not create waypoint for this map.|r")
    return false
  end

  local point = UiMapPoint.CreateFromCoordinates(mapID, resolvedX, resolvedY)
  if not point then
    print("|cffff5555DecorDrops: Could not create waypoint point.|r")
    return false
  end

  local ok = pcall(C_Map.SetUserWaypoint, point)
  if not ok then
    print("|cffff5555DecorDrops: Failed to set waypoint.|r")
    return false
  end

  if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
    pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, true)
  end
  OpenWorldMapForWaypoint(mapID)

  if not hasExactCoords then
    print("|cffffcc66DecorDrops: Exact entrance coordinates unavailable; set map center waypoint.|r")
  elseif mapID ~= instance.mapID then
    print("|cffffcc66DecorDrops: Set waypoint on parent map for this instance.|r")
  end

  return true, mapID, resolvedX, resolvedY
end

local function SetDungeonWaypoint(instance)
  local function ApplyAndConfirm(targetInstance)
    local applied, mapID, x, y = ApplyDungeonWaypoint(targetInstance)
    if not applied then
      return
    end

    if TomTom and TomTom.AddWaypoint and mapID and x and y then
      if dungeonWaypointUID then
        pcall(TomTom.RemoveWaypoint, TomTom, dungeonWaypointUID)
        dungeonWaypointUID = nil
      end
      local ok, uid = pcall(TomTom.AddWaypoint, TomTom, mapID, x, y, {
        title = tostring(targetInstance and targetInstance.name or "Instance"),
        from = "DecorDrops",
        persistent = false,
        minimap = true,
        world = true,
      })
      if ok and uid then
        dungeonWaypointUID = uid
      end
    end

    local displayName = tostring(targetInstance and targetInstance.name or "Instance")
    local mapName = ns.GetMapName(mapID or (targetInstance and targetInstance.mapID))
    local cx = tonumber(x) and (x * 100) or 0
    local cy = tonumber(y) and (y * 100) or 0
    print(string.format("|cff66ccffDecorDrops|r Waypoint created for |cffffffff%s|r at |cffdcb26a%s|r (%.1f, %.1f).", displayName, tostring(mapName or "Unknown Map"), cx, cy))
  end

  if InCombatLockdown and InCombatLockdown() then
    pendingWaypointInstance = instance
    print("|cffffcc66DecorDrops: Waypoint queued until combat ends.|r")
    if not waypointQueueFrame then
      waypointQueueFrame = CreateFrame("Frame")
      waypointQueueFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
      waypointQueueFrame:SetScript("OnEvent", function()
        if pendingWaypointInstance then
          local queued = pendingWaypointInstance
          pendingWaypointInstance = nil
          ApplyAndConfirm(queued)
        end
      end)
    end
    return
  end

  ApplyAndConfirm(instance)
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 HasWaypointData(instance)
  return instance and instance.mapID and instance.mapID ~= 0
end

local function HasEntranceCoords(instance)
  return instance
    and instance.entrance
    and instance.entrance.x ~= nil
    and instance.entrance.y ~= nil
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
    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
      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 GetUnitPriceForItem(itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return nil
  end

  local cached = reagentPriceCache[numericItemID]
  if cached and cached.fromAuction and cached.unitPrice and cached.unitPrice > 0 then
    return cached.unitPrice
  end

  local auctionPrice = GetAuctionatorUnitPrice(numericItemID)
  if auctionPrice and auctionPrice > 0 and DecorDropsDB and DecorDropsDB.settings then
    DecorDropsDB.settings.lastAuctionPriceSeenAt = time and time() or DecorDropsDB.settings.lastAuctionPriceSeenAt
  end
  local vendorPrice = nil

  local tooltip = EnsureReagentPriceTooltip()
  tooltip:ClearLines()
  tooltip:SetOwner(UIParent, "ANCHOR_NONE")
  tooltip:SetHyperlink("item:" .. tostring(numericItemID))

  local tooltipName = tooltip:GetName()
  if tooltipName then
    for i = 1, tooltip:NumLines() do
      local left = _G[tooltipName .. "TextLeft" .. i]
      local right = _G[tooltipName .. "TextRight" .. i]
      local leftText = left and left:GetText() or nil
      local rightText = right and right:GetText() or nil
      local lowered = SafeLower(leftText or "")
      local rightLowered = SafeLower(rightText or "")

      -- Auctionator/value lines may be double-column or single-column depending on tooltip source.
      if lowered ~= "" and StringContains(lowered, "auction") then
        auctionPrice = ParseMoneyFromTooltipText(rightText) or ParseMoneyFromTooltipText(leftText) or auctionPrice
      elseif lowered ~= "" and (StringContains(lowered, "vendor") or StringContains(lowered, "sell price")) then
        vendorPrice = ParseMoneyFromTooltipText(rightText) or ParseMoneyFromTooltipText(leftText) or vendorPrice
      elseif rightLowered ~= "" and StringContains(rightLowered, "auction") then
        auctionPrice = ParseMoneyFromTooltipText(leftText) or ParseMoneyFromTooltipText(rightText) or auctionPrice
      elseif rightLowered ~= "" and (StringContains(rightLowered, "vendor") or StringContains(rightLowered, "sell price")) then
        vendorPrice = ParseMoneyFromTooltipText(leftText) or ParseMoneyFromTooltipText(rightText) or vendorPrice
      end
    end
  end
  tooltip:Hide()

  if (not vendorPrice or vendorPrice <= 0) and GetItemInfo then
    local _, _, _, _, _, _, _, _, _, _, sellPrice = GetItemInfo(numericItemID)
    if tonumber(sellPrice) and sellPrice > 0 then
      vendorPrice = sellPrice
    end
  end

  local fromAuction = auctionPrice and auctionPrice > 0
  local unitPrice = fromAuction and auctionPrice or vendorPrice
  reagentPriceCache[numericItemID] = {
    unitPrice = unitPrice or 0,
    fromAuction = fromAuction and true or false,
  }
  return unitPrice
end

local function FormatMoneyShort(copper)
  local value = tonumber(copper) or 0
  if value <= 0 then
    return "-"
  end
  local gold = math.floor(value / 10000)
  local silver = math.floor((value % 10000) / 100)
  local copperRemainder = math.floor(value % 100)
  local parts = {}
  if gold > 0 then
    parts[#parts + 1] = string.format("%d|T%s:%d:%d:0:0|t", gold, MONEY_ICON_GOLD, MONEY_ICON_SIZE_ROW, MONEY_ICON_SIZE_ROW)
  end
  if silver > 0 then
    parts[#parts + 1] = string.format("%d|T%s:%d:%d:0:0|t", silver, MONEY_ICON_SILVER, MONEY_ICON_SIZE_ROW, MONEY_ICON_SIZE_ROW)
  end
  if copperRemainder > 0 then
    parts[#parts + 1] = string.format("%d|T%s:%d:%d:0:0|t", copperRemainder, MONEY_ICON_COPPER, MONEY_ICON_SIZE_ROW, MONEY_ICON_SIZE_ROW)
  end
  if #parts == 0 then
    return "-"
  end
  return table.concat(parts, " ")
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)

  if query ~= "" then
    local instanceHit = StringContains(instance.name, query)
    local itemHit = StringContains(itemName, query)
    local sourceHit = StringContains(sourceName, query)
    if not (instanceHit or itemHit 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 BuildPurchaseCostEntries(item)
  if type(item) ~= "table" or type(item.purchaseCosts) ~= "table" then
    return {}
  end

  local out = {}
  for _, cost in ipairs(item.purchaseCosts) 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
        out[#out + 1] = {
          quantity = qty,
          quantityLabel = "x" .. tostring(qty),
          icon = info and info.iconFileID or ICON_LOCATION,
          iconSize = 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 g = math.floor(copper / 10000)
        local s = math.floor((copper % 10000) / 100)
        local c = math.floor(copper % 100)
        if g > 0 then
          out[#out + 1] = {
            quantity = g,
            quantityLabel = tostring(g) .. "g",
            icon = PURCHASE_ICON_GOLD,
            iconSize = PURCHASE_ICON_SIZE,
            name = "Gold",
            tooltip = "Gold cost",
            sortKey = "gold|1",
          }
        end
        if s > 0 then
          out[#out + 1] = {
            quantity = s,
            quantityLabel = tostring(s) .. "s",
            icon = PURCHASE_ICON_SILVER,
            iconSize = PURCHASE_ICON_SIZE,
            name = "Silver",
            tooltip = "Silver cost",
            sortKey = "gold|2",
          }
        end
        if c > 0 then
          out[#out + 1] = {
            quantity = c,
            quantityLabel = tostring(c) .. "c",
            icon = PURCHASE_ICON_COPPER,
            iconSize = PURCHASE_ICON_SIZE,
            name = "Copper",
            tooltip = "Copper cost",
            sortKey = "gold|3",
          }
        end
      end
    end
  end

  return NormalizeResourceEntries(out)
end

local function ComputeResourceTotalCost(resources)
  local entries = NormalizeResourceEntries(resources)
  if #entries == 0 then
    return nil
  end

  local total = 0
  local pricedAny = false
  for _, entry in ipairs(entries) do
    local unitPrice = GetUnitPriceForItem(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
  end
  return total
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 {}
  return DecorDropsDB.trackedItems
end

local function GetEntrySourceKey(entry, mode)
  if 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 {}

    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 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

    local query = SafeLower(frame.filters.search or "")
    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 sourceName = query ~= "" and BuildResourceInlineText(item.resources) or ""
            local searchHit = query == ""
              or StringContains(itemName, 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
        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
        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 searchHit = query == ""
                or StringContains(itemName, 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)
              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)
            filtered[#filtered + 1] = {
              key = "tracker|" .. mode .. "|" .. sourceKey,
              trackerKind = mode,
              sourceKey = sourceKey,
              acquireMode = mode,
              acquireCategory = sourceEntry.category or GetSourceModeLabel(mode),
              expansion = sourceEntry.expansion or "Other",
              zoneName = zoneName,
              label = sourceEntry.label or "Source",
              detail = sourceEntry.detail or "",
              waypoint = sourceEntry.waypoint,
              rows = rows,
            }
          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 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 sourceName = query ~= "" and BuildResourceInlineText(item.resources) or ""
          local searchHit = query == ""
            or StringContains(itemName, 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 IsSourceMode(frame.viewMode) then
    local filtered = {}
    local all = ns.GetAcquireData and ns.GetAcquireData(frame.viewMode) or {}
    local query = SafeLower(frame.filters.search or "")

    for _, entry in ipairs(all) do
      local rows = {}
      for _, item in ipairs(entry.rows or {}) do
        local itemName = GetItemName(item)
        local searchHit = query == ""
          or StringContains(itemName, 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)

        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
        local zoneName = ResolveSourceEntryZoneName(entry, rows)
        filtered[#filtered + 1] = {
          key = entry.key,
          sourceKey = entry.key,
          acquireMode = frame.viewMode,
          acquireCategory = entry.category or GetSourceModeLabel(frame.viewMode),
          expansion = entry.expansion or "Other",
          zoneName = zoneName,
          label = entry.label or "Source",
          detail = entry.detail or "",
          waypoint = entry.waypoint,
          rows = rows,
        }
      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(LEFT_LIST_WIDTH, 28)
  if btn.RegisterForClicks then
    btn:RegisterForClicks("LeftButtonDown")
  end
  if btn.SetNormalTexture then
    btn:SetNormalTexture(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(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(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 = THEME_DARK,
    edgeFile = 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(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(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", -10, 0)
  btn.text:SetJustifyH("LEFT")
  btn.text:SetWordWrap(false)
  if btn.text.SetMaxLines then
    btn.text:SetMaxLines(1)
  end

  btn.branchArrow = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  btn.branchArrow:SetPoint("LEFT", btn, "LEFT", -(SUBCATEGORY_LEFT_INDENT - 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, true)
    if ok and result then
      return true
    end
  end

  return false
end

local function SetInstanceTypeIcon(icon, instanceType)
  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 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 ICON_PROFESSIONS
end

local function CreateExpansionHeader(parent)
  local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
  btn:SetSize(LEFT_LIST_WIDTH, 30)
  if btn.RegisterForClicks then
    btn:RegisterForClicks("LeftButtonDown")
  end
  if btn.SetNormalTexture then
    btn:SetNormalTexture(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(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(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 = THEME_DARK,
    edgeFile = 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:SetPoint("RIGHT", -40, 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.hover = btn:CreateTexture(nil, "ARTWORK", nil, 1)
  btn.hover:SetAllPoints()
  btn.hover:SetTexture(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

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 FindMageTeleportSpellSlot(expansionName)
  if not IS_MAGE then
    return nil, nil
  end

  local keywords = EXPANSION_TELEPORT_KEYWORDS[expansionName]
  if not keywords or #keywords == 0 then
    return nil, nil
  end

  if not GetNumSpellTabs or not GetSpellTabInfo or not GetSpellBookItemInfo or not GetSpellBookItemName then
    return nil, nil
  end

  local numTabs = GetNumSpellTabs() or 0
  local teleportSlot, teleportName = nil, nil
  local genericSlot, genericName = nil, nil
  for tabIndex = 1, numTabs do
    local _, _, offset, numSlots = GetSpellTabInfo(tabIndex)
    offset = offset or 0
    numSlots = numSlots or 0
    for slot = offset + 1, offset + numSlots do
      local itemType = GetSpellBookItemInfo(slot, BOOKTYPE_SPELL)
      if itemType == "SPELL" then
        local spellName = GetSpellBookItemName(slot, BOOKTYPE_SPELL)
        if spellName and spellName ~= "" then
          local lowerName = SafeLower(spellName)
          for _, keyword in ipairs(keywords) do
            if StringContains(lowerName, keyword) then
              if StringContains(lowerName, "teleport") and not StringContains(lowerName, "portal") then
                return slot, spellName
              end
              if not teleportSlot and not genericSlot then
                genericSlot, genericName = slot, spellName
              end
              if not teleportSlot and StringContains(lowerName, "teleport") then
                teleportSlot, teleportName = slot, spellName
              end
            end
          end
        end
      end
    end
  end

  if teleportSlot then
    return teleportSlot, teleportName
  end
  if genericSlot then
    return genericSlot, genericName
  end
  return nil, nil
end

local function IsSpellKnownForPlayer(spellID)
  if not spellID then
    return false
  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

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

  local factionIDs = PLAYER_FACTION and map[PLAYER_FACTION] or nil
  if factionIDs and factionIDs[1] then
    return factionIDs[1]
  end

  local neutralIDs = map.Neutral
  if neutralIDs and neutralIDs[1] then
    return neutralIDs[1]
  end

  local opposite = PLAYER_FACTION == "Alliance" and "Horde" or "Alliance"
  local oppositeIDs = map[opposite]
  if oppositeIDs and oppositeIDs[1] then
    return oppositeIDs[1]
  end

  return nil
end

local function GetTeleportIconForExpansion(expansionName)
  local spellID = GetPreferredTeleportSpellID(expansionName)
  if spellID then
    if C_Spell and C_Spell.GetSpellTexture then
      local texture = C_Spell.GetSpellTexture(spellID)
      if texture then
        return texture
      end
    end
    if GetSpellTexture then
      local texture = GetSpellTexture(spellID)
      if texture then
        return texture
      end
    end
  end

  local slot = FindMageTeleportSpellSlot(expansionName)
  if slot and GetSpellBookItemTexture then
    local texture = GetSpellBookItemTexture(slot, BOOKTYPE_SPELL)
    if texture then
      return texture
    end
  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

local function ConfigureTeleportActionButton(button, expansionName)
  if not button then
    return
  end

  button.castSpellName = nil
  if EXPANSION_TELEPORT_PLACEHOLDERS[expansionName] then
    button.spellID = nil
    button.spellName = nil
    button.expansionName = expansionName
    button.canSecureCast = false
    button.isPlaceholder = true
    return
  end

  local slot, bookSpellName = FindMageTeleportSpellSlot(expansionName)
  local spellID = GetPreferredTeleportSpellID(expansionName)
  local spellName = bookSpellName or GetSpellNameByID(spellID)
  button.spellID = spellID
  button.spellName = spellName
  button.expansionName = expansionName
  button.canSecureCast = false
  button.isPlaceholder = false

  if bookSpellName then
    button.castSpellName = bookSpellName
    button.canSecureCast = true
  elseif spellID and IsSpellKnownForPlayer(spellID) then
    local byIDName = GetSpellNameByID(spellID)
    button.castSpellName = byIDName
    button.canSecureCast = true
  elseif spellName then
    button.castSpellName = spellName
    button.canSecureCast = true
  else
    button.castSpellName = nil
    button.canSecureCast = false
  end
end

local function 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

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

  local entries = NormalizeResourceEntries(resources)
  if #entries == 0 then
    HideResourceButtons(row)
    return false
  end

  local shown = math.min(#entries, #row.resourceButtons)
  local iconSize = tonumber(forcedIconSize) or RESOURCE_ICON_SIZE
  local gap = 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 = THEME_MARBLE,
    edgeFile = 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(ICON_QUEST)
  row.questLinkButton:SetPushedTexture(ICON_QUEST)
  row.questLinkButton:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
  row.questLinkButton:Hide()
  row.questLinkButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:AddLine("Quest Unlock")
    GameTooltip:AddLine("Open the Quest page for this item's unlock source.", 0.90, 0.82, 0.64, true)
    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(RESOURCE_ICON_SIZE, 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, 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("RIGHT")
  row.rateText:SetWordWrap(false)
  if row.rateText.SetMaxLines then
    row.rateText:SetMaxLines(1)
  end

  row.viewButton = CreateFrame("Button", nil, row)
  row.viewButton:SetSize(ITEM_LAYOUT.view.width, 24)
  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)
  for _, entry in ipairs(frame.filtered or {}) do
    if entry.key == DecorDropsDB.selectedKey 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"
  DecorDropsDB.selectedKey = key
  RememberSelectedKeyForMode(frame, mode, key)
end

function ns.BuildGroupedLeftRows(frame)
  if frame.viewMode == "tracker" then
    local instanceEntries = {}
    local professionEntries = {}
    local sourceEntriesByMode = {}
    local sourceModeOrder = { "vendor", "achievement", "quest", "reputation", "endeavor" }

    for _, entry in ipairs(frame.filtered or {}) do
      if entry.trackerKind == "professions" then
        professionEntries[#professionEntries + 1] = entry
      elseif 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", GetSourceModeLabel(sourceMode)),
          sectionKey = sourceSectionKey,
        }
        if not trackerCollapsed[sourceSectionKey] then
          local groups = {}
          local order = {}
          for _, entry in ipairs(sourceEntries) do
            local expansion = entry.expansion or "Other"
            if not groups[expansion] then
              groups[expansion] = { zones = {}, zoneOrder = {} }
              order[#order + 1] = expansion
            end
            local zoneName = entry.zoneName or "Unknown Zone"
            local zoneBucket = groups[expansion].zones[zoneName]
            if not zoneBucket then
              zoneBucket = {}
              groups[expansion].zones[zoneName] = zoneBucket
              groups[expansion].zoneOrder[#groups[expansion].zoneOrder + 1] = zoneName
            end
            zoneBucket[#zoneBucket + 1] = entry
          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 expansionCollapseKey = string.lower("tracker|" .. sourceMode .. "|" .. tostring(expansion or "Other"))
            rows[#rows + 1] = {
              kind = "header",
              expansion = expansion,
              expansionCollapseKey = expansionCollapseKey,
              isSourceMode = true,
              trackerSubHeader = true,
            }
            if not CollapseState.IsExpansionCollapsed(frame, expansionCollapseKey) then
              table.sort(groups[expansion].zoneOrder, function(a, b)
                return tostring(a or "") < tostring(b or "")
              end)
              for _, zoneName in ipairs(groups[expansion].zoneOrder) do
                local zoneKey = BuildNewSourceZoneKey(expansion, zoneName)
                rows[#rows + 1] = {
                  kind = "newSourceZone",
                  expansion = expansion,
                  expansionCollapseKey = expansionCollapseKey,
                  zoneName = zoneName,
                  zoneKey = zoneKey,
                  trackerSubHeader = true,
                }
                if not CollapseState.IsSourceZoneCollapsed(frame, expansionCollapseKey, zoneName) then
                  local zoneEntries = groups[expansion].zones[zoneName] or {}
                  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 = entry.profession and entry.profession.expansion or "Other"
      if not groups[expansion] then
        groups[expansion] = {}
        order[#order + 1] = expansion
      end
      groups[expansion][#groups[expansion] + 1] = entry
    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)

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

  if IsSourceMode(frame.viewMode) then
    local groups = {}
    local order = {}
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = entry.expansion or "Other"
      if not groups[expansion] then
        groups[expansion] = { zones = {}, zoneOrder = {} }
        order[#order + 1] = expansion
      end
      local zoneName = entry.zoneName or "Unknown Zone"
      local zoneBucket = groups[expansion].zones[zoneName]
      if not zoneBucket then
        zoneBucket = {}
        groups[expansion].zones[zoneName] = zoneBucket
        groups[expansion].zoneOrder[#groups[expansion].zoneOrder + 1] = zoneName
      end
      zoneBucket[#zoneBucket + 1] = entry
    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)

    local rows = {}
    for _, expansion in ipairs(order) do
      rows[#rows + 1] = { kind = "header", expansion = expansion, isSourceMode = true }
      if not CollapseState.IsExpansionCollapsed(frame, expansion) then
        table.sort(groups[expansion].zoneOrder, function(a, b)
          return tostring(a or "") < tostring(b or "")
        end)
        for _, zoneName in ipairs(groups[expansion].zoneOrder) do
          local zoneKey = BuildNewSourceZoneKey(expansion, zoneName)
          rows[#rows + 1] = {
            kind = "newSourceZone",
            expansion = expansion,
            zoneName = zoneName,
            zoneKey = zoneKey,
          }
          if not CollapseState.IsSourceZoneCollapsed(frame, expansion, zoneName) then
            local zoneEntries = groups[expansion].zones[zoneName] or {}
            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 == 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 = CURRENT_SEASON_GROUP, isCurrentSeason = true }
    if not CollapseState.IsExpansionCollapsed(frame, 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 ~= 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

function ns.RefreshInstanceList(frame)
  local content = frame.instanceListContent
  local selectedKey = DecorDropsDB.selectedKey
  local rows = ns.BuildGroupedLeftRows(frame)
  local listWidth = LEFT_LIST_WIDTH
  local sourceCompletionByEntryKey, sourceCompletionByZoneKey, sourceCompletionByExpansion = {}, {}, {}
  do
    local sourceEntries = {}
    for _, entry in ipairs(frame.filtered or {}) do
      if IsSourceMode(frame.viewMode) or (frame.viewMode == "tracker" and IsSourceMode(entry.trackerKind)) then
        sourceEntries[#sourceEntries + 1] = entry
      end
    end
    sourceCompletionByEntryKey, sourceCompletionByZoneKey, sourceCompletionByExpansion = BuildSourceCompletionMaps(sourceEntries)
  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(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.tpButton = CreateFrame("Button", nil, header, "UIPanelButtonTemplate")
        header.tpButton:SetSize(18, 18)
        header.tpButton:SetPoint("RIGHT", -6, 0)
        header.tpButton:RegisterForClicks("AnyUp", "AnyDown")
        header.tpButton:SetFrameLevel(header:GetFrameLevel() + 20)
        header.tpButton:EnableMouse(true)
        if header.tpButton.SetPropagateMouseClicks then
          header.tpButton:SetPropagateMouseClicks(false)
        end
        header.tpButton:SetText("")
        header.tpButton.icon = header.tpButton:CreateTexture(nil, "ARTWORK")
        header.tpButton.icon:SetPoint("TOPLEFT", 2, -2)
        header.tpButton.icon:SetPoint("BOTTOMRIGHT", -2, 2)
        header.tpButton.icon:SetTexCoord(0.10, 0.90, 0.10, 0.90)
        header.tpButton.icon:SetTexture(ICON_BOOK_CATALOG)
        header.tpButton:SetScript("OnClick", function(self)
          if self.isPlaceholder then
            print(string.format("|cffffcc66DecorDrops: %s teleport is a placeholder and will be available when Midnight goes live.|r", tostring(self.expansionName)))
            return
          end
          if self.spellName and self.spellName ~= "" then
            print(string.format("|cff66ccffDecorDrops|r Teleport helper: cast |cffffffff%s|r manually from your spellbook/hotbar.", tostring(self.spellName)))
            return
          end
          local factionLabel = PLAYER_FACTION or "your faction"
          print(string.format("|cffffcc66DecorDrops: Teleport for %s (%s) is not learned or not available.|r", tostring(self.expansionName), tostring(factionLabel)))
        end)
        header.tpButton:SetScript("OnEnter", function(self)
          GameTooltip:SetOwner(self, "ANCHOR_TOP")
          if self.isPlaceholder then
            GameTooltip:AddLine("Teleport")
            GameTooltip:AddLine("|cffffcc66Coming soon (placeholder)|r")
            GameTooltip:Show()
            return
          end
          if self.spellName then
            GameTooltip:AddLine(self.spellName)
            GameTooltip:AddLine("|cff66ff66Click for cast hint|r")
          else
            GameTooltip:AddLine("Teleport")
            GameTooltip:AddLine("|cffffcc66Spell not learned|r")
          end
          GameTooltip:Show()
        end)
        header.tpButton:SetScript("OnLeave", function()
          GameTooltip_Hide()
        end)

        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(THEME_FLAT)
        header.specialGlow:SetVertexColor(1.00, 0.86, 0.42, 0.00)
        header.specialGlow:Hide()

        header:SetScript("OnClick", function(self)
          if self.tpButton and self.tpButton:IsShown() and self.tpButton.IsMouseOver and self.tpButton:IsMouseOver() then
            return
          end
          if self.sectionKey then
            DecorDropsDB.collapsedTrackerSections = DecorDropsDB.collapsedTrackerSections or {}
            local current = DecorDropsDB.collapsedTrackerSections[self.sectionKey]
            DecorDropsDB.collapsedTrackerSections[self.sectionKey] = not current
            frame:Refresh()
            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:Refresh()
        end)
        rowSlot.header = header
      end

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

      local header = rowSlot.header
      ResetPooledButtonState(header)
      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)
        if header.tpButton then
          header.tpButton:SetShown(false)
        end
        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
        if header.tpButton then
          header.tpButton.expansion = rowData.expansion
          ConfigureTeleportActionButton(header.tpButton, rowData.expansion)
          local iconTexture = GetTeleportIconForExpansion(rowData.expansion)
          if header.tpButton.icon then
            header.tpButton.icon:SetTexture(iconTexture or ICON_BOOK_CATALOG)
            if iconTexture then
              header.tpButton.icon:SetVertexColor(1, 1, 1, 1)
            else
              header.tpButton.icon:SetVertexColor(1.00, 0.84, 0.22, 1.00)
            end
          end
          local canShowTeleport = frame.viewMode ~= "professions"
            and frame.viewMode ~= "tracker"
            and not IsSourceMode(frame.viewMode)
            and IS_MAGE
            and rowData.expansion ~= CURRENT_SEASON_GROUP
            and (EXPANSION_TELEPORT_KEYWORDS[rowData.expansion] ~= nil or EXPANSION_TELEPORT_PLACEHOLDERS[rowData.expansion])
          header.tpButton:SetShown(canShowTeleport)
        end
        header.arrow:SetText(isCollapsed and "+" or "-")
        header.text:SetText(rowData.expansion)
        local sourceExpansionComplete = rowData.isSourceMode and sourceCompletionByExpansion[tostring(rowData.expansion or "")] == true
        if sourceExpansionComplete 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 = 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)
          frame:Refresh()
        end)
        rowSlot.instance = btn
      end

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

      local btn = rowSlot.instance
      ResetPooledButtonState(btn)
      if rowData.kind == "newSourceZone" then
        btn.entryKey = nil
        local zoneIndent = SUBCATEGORY_LEFT_INDENT
        if frame.viewMode == "tracker" and rowData.trackerSubHeader then
          zoneIndent = zoneIndent + 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(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"))
        local zoneComplete = sourceCompletionByZoneKey[tostring(rowData.zoneKey or "")] == true
        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:Refresh()
        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)
          frame:Refresh()
        end)
        btn.entryKey = entry.key
        local indent = SUBCATEGORY_LEFT_INDENT
        if (IsSourceMode(frame.viewMode) or frame.viewMode == "tracker") and rowData.kind == "source" then
          indent = SUBCATEGORY_LEFT_INDENT * 2
        end
        if frame.viewMode == "tracker" and rowData.trackerSubHeader then
          indent = indent + SUBCATEGORY_LEFT_INDENT
        end
        btn:SetSize(listWidth - indent, 28)
        btn:SetPoint("TOPLEFT", indent, y)
        if btn.branchArrow then
          btn.branchArrow:Show()
          btn.branchArrow:SetText("->")
          btn.branchArrow:SetTextColor(1.00, 0.90, 0.62)
        end
        if rowData.kind == "profession" or rowData.kind == "trackerProfession" then
        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))
        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
          local mode = (frame.viewMode == "tracker" and entry.trackerKind) or frame.viewMode
          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.label or "Source", #entry.rows))
          if mode == "vendor" then
            btn.typeIcon:SetTexture(ICON_VENDOR)
          elseif mode == "achievement" then
            btn.typeIcon:SetTexture(ICON_ACHIEVEMENT)
          elseif mode == "quest" then
            btn.typeIcon:SetTexture(ICON_QUEST)
          elseif mode == "reputation" then
            btn.typeIcon:SetTexture(ICON_REPUTATION)
          else
            btn.typeIcon:SetTexture(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)
        else
          btn.typeIcon:ClearAllPoints()
          btn.typeIcon:SetSize(18, 18)
          btn.typeIcon:SetPoint("LEFT", 8, 0)
          btn.text:SetText(string.format("%s (%d)", entry.instance.name, #entry.rows))
          SetInstanceTypeIcon(btn.typeIcon, entry.instance.type)
        end

        local sourceEntryComplete = (rowData.kind == "source") and sourceCompletionByEntryKey[tostring(entry.key or "")] == true
        if entry.key == selectedKey then
          if sourceEntryComplete 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 sourceEntryComplete 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 sourceEntryComplete 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 sourceEntryComplete 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)
  C_Timer.After(0, function()
    ns.RefreshScrollBarVisibility(frame.instanceListScroll)
  end)
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(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:Disable()
  end
  if frame.trainerClearButton then
    frame.trainerClearButton:SetScript("OnClick", nil)
    frame.trainerClearButton: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
  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 or run /decordrops scan professions")
    elseif frame.viewMode == "tracker" then
      frame.entranceText:SetText("Track items from raids, dungeons, or professions to see them here")
    elseif 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 or run /decordrops scan")
    end
  end
  if frame.dungeonWaypointButton then
    frame.dungeonWaypointButton:Disable()
    frame.dungeonWaypointButton:SetScript("OnClick", nil)
    frame.dungeonWaypointButton:SetShown(false)
  end
  if frame.trainerPanel then
    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
  if isMidnightSelected then
    ns._ShowMidnightWaypointPlaceholder(frame, "Profession")
    return
  end

  local trainers = GetTrainerEntriesForProfession(
    selectedEntry.profession and selectedEntry.profession.profession,
    selectedEntry.profession and selectedEntry.profession.expansion
  )
  local trainer = trainers[1]
  if not trainer then
    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("")
  frame.trainerWaypointButton:SetScript("OnClick", function()
    SetTrainerWaypoint(trainer)
  end)
  frame.trainerClearButton:SetScript("OnClick", function()
    ClearTrainerWaypoint()
  end)
  if frame.trainerManualButton then
    frame.trainerManualButton:SetScript("OnClick", nil)
    frame.trainerManualButton:Disable()
    frame.trainerManualButton:Hide()
  end
  HideManualOverridePanel(frame)
  frame.trainerWaypointButton:Enable()
  frame.trainerClearButton:Enable()
  frame.trainerPanel:Show()
end

function ns._ConfigureSourceSelectionHeader(frame, selectedEntry, activeModeForSelection)
  local modeLabel = 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
    frame.trainerPanel:Hide()
    return
  end

  frame.trainerIcon:SetTexture(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("")
  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.trainerClearButton:SetScript("OnClick", function()
    ClearTrainerWaypoint()
  end)
  if frame.trainerManualButton then
    frame.trainerManualButton:SetScript("OnClick", nil)
    frame.trainerManualButton:Disable()
    frame.trainerManualButton:Hide()
  end
  HideManualOverridePanel(frame)
  frame.trainerWaypointButton:Enable()
  frame.trainerClearButton:Enable()
  frame.trainerPanel:Show()
end

function ns._ConfigureInstanceSelectionHeader(frame, selectedEntry, isMidnightSelected)
  local instance = selectedEntry.instance
  local expansion = ExpansionName(instance)
  local mapName = ns.GetMapName(instance.mapID)
  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 isMidnightSelected then
    ns._ShowMidnightWaypointPlaceholder(frame, "Dungeon/Raid")
    if frame.trainerManualButton then
      frame.trainerManualButton:SetScript("OnClick", nil)
      frame.trainerManualButton:Disable()
      frame.trainerManualButton:Hide()
    end
    HideManualOverridePanel(frame)
    return
  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(ICON_LOCATION)
  frame.trainerName:SetText(string.format("%s Entrance", tostring(instance and instance.name or "Instance")))
  frame.trainerLocation:SetText(locationText)
  frame.trainerFaction:SetText("")

  if HasWaypointData(instance) then
    frame.trainerWaypointButton:Enable()
  else
    frame.trainerWaypointButton:Disable()
  end
  frame.trainerWaypointButton:SetScript("OnClick", function()
    SetDungeonWaypoint(instance)
  end)
  frame.trainerClearButton:SetScript("OnClick", function()
    ClearDungeonWaypoint()
  end)
  if frame.trainerManualButton then
    frame.trainerManualButton:SetScript("OnClick", function()
      if frame.manualOverridePanel and frame.manualOverridePanel:IsShown() and activeManualWaypointInstance == instance then
        HideManualOverridePanel(frame)
      else
        BeginManualOverrideForInstance(frame, instance)
      end
    end)
    frame.trainerManualButton:Enable()
    frame.trainerManualButton:Show()
  end
  frame.trainerClearButton:Enable()
  frame.trainerPanel:Show()
end

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

  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 = 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 = ns.GetMapName(locInstance and locInstance.mapID)
    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 = ICON_LOCATION
    onWaypoint = function()
      SetDungeonWaypoint(locInstance)
    end
    onEnter = function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      GameTooltip:AddLine(labelName)
      GameTooltip:AddLine(zoneText, 0.90, 0.82, 0.64, true)
      GameTooltip:AddLine("|cff66ff66Click Waypoint to navigate|r")
      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)
  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: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)
  local collected = GetWarbandCollectedState(item)
  local itemName = GetItemName(item)
  local qR, qG, qB = GetItemQualityColor(item)

  row.collectedIcon:Show()
  row.collectedIcon:SetTexture(collected and READY_TEXTURE or NOT_READY_TEXTURE)
  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)
  if isProfessionMode then
    row.rateText:SetJustifyH("RIGHT")
  else
    row.rateText:SetJustifyH("CENTER")
  end

  if isProfessionMode then
    local resourceEntries = GetRenderableResourcesForItem(item)
    local showingResourceIcons = SetResourceButtons(row, resourceEntries)
    if showingResourceIcons then
      row.sourceText:SetText("")
      row.sourceText:Hide()
    else
      row.sourceText:SetText(BuildResourceInlineText(resourceEntries))
      row.sourceText:Show()
    end
    local totalCost = ComputeResourceTotalCost(resourceEntries)
    row.rateText:SetText(FormatMoneyShort(totalCost))
  else
    HideResourceButtons(row)
    row.sourceText:Show()
    if isSourceModeSelection then
      local purchaseEntries = BuildPurchaseCostEntries(item)
      local showingPurchaseIcons = SetResourceButtons(row, purchaseEntries, PURCHASE_ICON_SIZE)
      if showingPurchaseIcons then
        row.sourceText:SetText("")
        row.sourceText:Hide()
      else
        local sourceLabel = item.sourceDetail
        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
      if selectedEntry.acquireMode == "achievement" then
        local achieved = IsAchievementObtained(item)
        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
        row.rateText:SetText(GetAcquireStatusText(item, selectedEntry))
      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)
  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)
    if isSourceModeSelection and activeModeForSelection ~= "quest" then
      local questTarget = ResolveQuestNavigationTarget(item, selectedEntry)
      if questTarget and questTarget.entry and questTarget.entry.key then
        local function openQuestTarget()
          NavigateToQuestEntry(frame, questTarget)
        end
        row.questLinkButton:Show()
        row.questLinkButton:SetScript("OnClick", openQuestTarget)
        row.questLinkButton:SetScript("OnMouseDown", openQuestTarget)
      end
    end
  end

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

  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
      SetGoButtonVisual(row.wpButton, true)
      row.wpButton:SetScript("OnClick", function()
        SetDungeonWaypoint(selectedEntry.instance)
      end)
      configuredGo = true
    end
    if not configuredGo then
      SetGoButtonVisual(row.wpButton, false)
      row.wpButton:SetScript("OnClick", nil)
    end
  end

  row.recipeText:Hide()
  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.RefreshItems(frame)
  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 = 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 = {}
  for _, item in ipairs(selectedEntry.rows or {}) do
    displayRows[#displayRows + 1] = { kind = "item", item = item }
  end

  for i, rowData in ipairs(displayRows) do
    if not frame.itemRows[i] then
      frame.itemRows[i] = BuildItemRow(frame.itemListContent)
    end

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

    row:Show()
  end

  for i = #displayRows + 1, #frame.itemRows do
    frame.itemRows[i]:Hide()
  end

  frame.itemListContent:SetWidth(ITEM_LAYOUT.rowWidth)
  frame.itemListContent:SetHeight(math.max(1, #displayRows * ITEM_LAYOUT.rowGap))
  ns.RefreshScrollBarVisibility(frame.itemListScroll)
  C_Timer.After(0, function()
    ns.RefreshScrollBarVisibility(frame.itemListScroll)
  end)
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 professionLike = frame.viewMode == "professions"
    or (frame.viewMode == "tracker" and selectedEntry and selectedEntry.trackerKind == "professions")
  local sourceLike = IsSourceMode(frame.viewMode)

  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", MONEY_ICON_GOLD, MONEY_ICON_SIZE_HEADER, MONEY_ICON_SIZE_HEADER))
    end
  elseif sourceLike then
    if frame.headerTexts["Source"] then frame.headerTexts["Source"]:SetText("Source") end
    if frame.headerTexts["Status"] then
      local statusLabel = "Status"
      if selectedEntry and selectedEntry.acquireMode == "achievement" then
        statusLabel = "Obtained"
      elseif selectedEntry and selectedEntry.acquireMode == "reputation" and EntryUsesRenownRequirements(selectedEntry) then
        statusLabel = "Renown"
      end
      frame.headerTexts["Status"]:SetText(statusLabel)
    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
  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({ "vendor", "achievement", "quest", "reputation", "endeavor" }) 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 = entry.expansion or "Other"
              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 = entry.zoneName or "Unknown Zone"
              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 = entry.profession and entry.profession.expansion or "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 IsSourceMode(frame.viewMode) then
    local seen = {}
    local zoneSeen = {}
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = entry.expansion or "Other"
      if not seen[expansion] then
        seen[expansion] = true
        if not CollapseState.IsExpansionCollapsed(frame, expansion) then
          return true
        end
      end
      local zoneKey = BuildNewSourceZoneKey(expansion, entry.zoneName or "Unknown Zone")
      if not zoneSeen[zoneKey] then
        zoneSeen[zoneKey] = true
        if not CollapseState.IsSourceZoneCollapsed(frame, expansion, entry.zoneName or "Unknown Zone") 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 == CURRENT_SEASON_GROUP or currentSeasonLookup[SafeLower(entry.instance.name)]) then
      hasCurrentSeason = true
    end
  end

  if hasCurrentSeason and not CollapseState.IsExpansionCollapsed(frame, 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({ "vendor", "achievement", "quest", "reputation", "endeavor" }) 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 = entry.expansion or "Other"
            local expansionCollapseKey = string.lower("tracker|" .. sourceMode .. "|" .. tostring(expansion or "Other"))
            CollapseState.SetExpansionCollapsed(frame, expansionCollapseKey, collapse and true or false)
            CollapseState.SetSourceZoneCollapsed(frame, expansionCollapseKey, entry.zoneName or "Unknown Zone", 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 = entry.profession and entry.profession.expansion or "Other"
      CollapseState.SetExpansionCollapsed(frame, expansion, collapse and true or false)
    end
    return
  end

  if IsSourceMode(frame.viewMode) then
    for _, entry in ipairs(frame.filtered or {}) do
      local expansion = entry.expansion or "Other"
      CollapseState.SetExpansionCollapsed(frame, expansion, collapse and true or false)
      CollapseState.SetSourceZoneCollapsed(frame, expansion, entry.zoneName or "Unknown Zone", 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 == CURRENT_SEASON_GROUP or currentSeasonLookup[SafeLower(entry.instance.name)]) then
      hasCurrentSeason = true
    end
  end

  if hasCurrentSeason then
    CollapseState.SetExpansionCollapsed(frame, 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) / BASE_FRAME_WIDTH
  local scaleH = (screenH - pad) / 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 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")
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 IsSourceMode(mode) then
    mode = "instances"
  end

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

  if anchorButton then
    anchorButton:ClearAllPoints()
    anchorButton:SetPoint("LEFT", frame.searchBox, "RIGHT", 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", SEARCH_TO_FILTER_GAP, 0)
  end

  frame.scanButton:ClearAllPoints()
  frame.scanButton:SetPoint("LEFT", frame.collectedButton, "RIGHT", 8, 0)

  frame.resetButton:ClearAllPoints()
  frame.resetButton:SetPoint("LEFT", frame.scanButton, "RIGHT", 8, 0)
end

function ns.SetActiveBottomTab(frame, tabKey)
  frame.activeTab = tabKey or "Catalog"
  local showCatalog = frame.activeTab == "Catalog"
  local showInfo = frame.activeTab == "Info"
  local showFeatures = frame.activeTab == "Features"
  local showSettings = frame.activeTab == "Settings"

  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.settingsPanel then
    frame.settingsPanel:SetShown(showSettings)
  end

  ns.SetBottomTabVisual(frame.infoTab, showInfo)
  ns.SetBottomTabVisual(frame.featuresTab, showFeatures)
  ns.SetBottomTabVisual(frame.settingsTab, showSettings)
  ns.UpdateModeTabVisuals(frame)
end

function ns.SetViewMode(frame, mode)
  if not frame then
    return
  end
  local previousMode = frame.viewMode or DecorDropsDB.viewMode or "instances"
  if DecorDropsDB.selectedKey then
    RememberSelectedKeyForMode(frame, previousMode, DecorDropsDB.selectedKey)
  end
  if mode ~= "professions" and mode ~= "tracker" and not IsSourceMode(mode) then
    mode = "instances"
  end
  frame.viewMode = mode
  DecorDropsDB.viewMode = frame.viewMode
  if frame._sessionSelectedKeyByMode then
    DecorDropsDB.selectedKey = frame._sessionSelectedKeyByMode[frame.viewMode]
  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
    frame.scanButton:SetShown(frame.viewMode ~= "tracker" and not IsSourceMode(frame.viewMode))
  end
  if frame.knownProfessionOnlyCheck then
    frame.knownProfessionOnlyCheck:SetShown(frame.viewMode == "professions")
  end
  if frame.knownProfessionOnlyLabel then
    frame.knownProfessionOnlyLabel:SetShown(frame.viewMode == "professions")
  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 IsSourceMode(frame.viewMode) then
      frame.leftTitle:SetText(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)
  if frame.Refresh then
    frame:Refresh()
  end
end

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

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

  frame.featuresCollapsed = frame.featuresCollapsed or {}
  frame.featuresRows = frame.featuresRows or {}

  local rows = {}
  for _, section in ipairs(FEATURES_PAGE_TEMPLATE) do
    rows[#rows + 1] = { kind = "header", section = section }
    if not frame.featuresCollapsed[section.key] then
      rows[#rows + 1] = { kind = "body", section = section }
    end
  end

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

    if rowData.kind == "header" then
      if not row.header then
        row.header = CreateFrame("Button", nil, content, "BackdropTemplate")
        row.header:SetSize(contentWidth, 30)
        row.header:SetBackdrop({
          bgFile = THEME_MARBLE,
          edgeFile = THEME_BORDER,
          edgeSize = 10,
          insets = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        row.header.arrow = row.header:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        row.header.arrow:SetPoint("RIGHT", row.header, "CENTER", -8, 0)
        row.header.title = row.header:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        row.header.title:SetPoint("LEFT", row.header, "CENTER", 2, 0)
        row.header.title:SetPoint("RIGHT", row.header, "RIGHT", -8, 0)
        row.header.title:SetJustifyH("CENTER")
        row.header:SetScript("OnClick", function(self)
          local key = self.sectionKey
          frame.featuresCollapsed[key] = not frame.featuresCollapsed[key]
          ns.BuildFeaturesPanel(frame)
        end)
      end

      if row.body then
        row.body:Hide()
      end
      local collapsed = frame.featuresCollapsed[rowData.section.key]
      row.header.sectionKey = rowData.section.key
      row.header.arrow:SetText(collapsed and "+" or "-")
      row.header.title:SetText(rowData.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
    else
      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 row.header then
        row.header:Hide()
      end
      row.body:SetPoint("TOP", content, "TOP", 0, y)
      row.body:SetText(table.concat(rowData.section.lines or {}, "\n"))
      row.body:Show()
      y = y - math.max(30, row.body:GetStringHeight() + 16)
    end
  end

  for i = #rows + 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.CreateUI()
  local frame = CreateFrame("Frame", "DecorDropsMainFrame", UIParent, "PortraitFrameTemplate")
  frame:SetSize(BASE_FRAME_WIDTH, 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: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(THEME_HOUSING_BG_TGA)
  if not bgSet then
    bgSet = frame.bg:SetTexture(THEME_HOUSING_BG_PNG)
  end
  if not bgSet then
    bgSet = frame.bg:SetTexture(THEME_HOUSING_BG_JPG)
  end
  if not bgSet then
    frame.bg:SetTexture(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(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)

  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 {},
  }
  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 = THEME_MARBLE,
      edgeFile = 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", SEARCH_LABEL_X, SEARCH_LABEL_Y)
  frame.searchLabel:SetText("Search")

  frame.searchBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
  frame.searchBox:SetSize(SEARCH_BOX_WIDTH, 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", 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", 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("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.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 = THEME_DARK,
    edgeFile = 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.professionFilterScroll = CreateFrame("ScrollFrame", nil, frame.professionFilterMenu, "UIPanelScrollFrameTemplate")
  frame.professionFilterScroll:SetPoint("TOPLEFT", 8, -58)
  frame.professionFilterScroll:SetPoint("BOTTOMRIGHT", -24, 10)
  frame.professionFilterContent = CreateFrame("Frame", nil, frame.professionFilterScroll)
  frame.professionFilterContent:SetSize(200, 1)
  frame.professionFilterScroll:SetScrollChild(frame.professionFilterContent)
  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

    self.professionFilterContent:SetHeight(math.max(1, #professionNames * 22))
  end

  frame.modeTabRail = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.modeTabRail:SetSize(44, 136)
  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 = THEME_DARK,
    edgeFile = 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(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(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(THEME_FLAT)
  frame.modeInstancesButton.OuterGlow:SetVertexColor(1.00, 0.84, 0.22, 0.05)
  frame.modeInstancesButton.OuterGlow:Hide()

  frame.modeInstancesButton.leftHalf = frame.modeInstancesButton:CreateTexture(nil, "ARTWORK")
  frame.modeInstancesButton.leftHalf:ClearAllPoints()
  frame.modeInstancesButton.leftHalf:SetSize(14, 14)
  frame.modeInstancesButton.leftHalf:SetPoint("LEFT", 6, 0)
  SetInstanceTypeIcon(frame.modeInstancesButton.leftHalf, "Raid")

  frame.modeInstancesButton.rightHalf = frame.modeInstancesButton:CreateTexture(nil, "ARTWORK")
  frame.modeInstancesButton.rightHalf:ClearAllPoints()
  frame.modeInstancesButton.rightHalf:SetSize(14, 14)
  frame.modeInstancesButton.rightHalf:SetPoint("RIGHT", -6, 0)
  SetInstanceTypeIcon(frame.modeInstancesButton.rightHalf, "Dungeon")

  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 = THEME_DARK,
    edgeFile = 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(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(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(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(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 = THEME_DARK,
    edgeFile = 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(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(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(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", 8, -6)
  frame.modeTrackerButton.icon:SetPoint("BOTTOMRIGHT", -8, 6)
  frame.modeTrackerButton.icon:SetTexture(ICON_TRACKER)
  frame.modeTrackerButton.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
  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 = THEME_DARK,
      edgeFile = 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(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(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(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)
    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, ICON_VENDOR, "Vendor", "vendor")
  frame.modeAchievementButton = CreateSourceModeButton(-178, ICON_ACHIEVEMENT, "Achievement", "achievement")
  frame.modeQuestButton = CreateSourceModeButton(-221, ICON_QUEST, "Quest", "quest")
  frame.modeReputationButton = CreateSourceModeButton(-264, ICON_REPUTATION, "Reputation", "reputation")
  frame.modeEndeavorButton = CreateSourceModeButton(-307, ICON_ENDEAVOR, "Endeavor", "endeavor")

  frame.leftPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.leftPanel:SetSize(280, 590)
  frame.leftPanel:SetPoint("TOPLEFT", 20, -82)
  frame.leftPanel:SetBackdrop({
    bgFile = THEME_MARBLE,
    edgeFile = 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(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("Only 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("Only my professions")
  frame.knownProfessionOnlyLabel:SetTextColor(0.92, 0.84, 0.68)

  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 = THEME_MARBLE,
    edgeFile = 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, 28)
  frame.reputationMini:SetPoint("TOPRIGHT", -16, -12)
  frame.reputationMini:SetBackdrop({
    bgFile = THEME_DARK,
    edgeFile = 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(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(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(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(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(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:SetPoint("RIGHT", -86, 0)
  frame.trainerWaypointButton:SetText("Waypoint")

  frame.trainerClearButton = CreateFrame("Button", nil, frame.trainerPanel, "UIPanelButtonTemplate")
  frame.trainerClearButton:SetSize(66, 20)
  frame.trainerClearButton:SetPoint("RIGHT", -8, 0)
  frame.trainerClearButton:SetText("Clear")

  frame.manualOverridePanel = CreateFrame("Frame", nil, frame.rightPanel, "BackdropTemplate")
  frame.manualOverridePanel:SetSize(784, 26)
  frame.manualOverridePanel:SetPoint("TOPLEFT", 8, -92)
  frame.manualOverridePanel:SetBackdrop({
    bgFile = THEME_FLAT,
    edgeFile = 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._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.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 = THEME_MARBLE,
    edgeFile = 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.infoBody = frame.infoPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  frame.infoBody:SetPoint("TOP", frame.infoTitle, "BOTTOM", 0, -12)
  frame.infoBody:SetPoint("LEFT", frame.infoPanel, "LEFT", 20, 0)
  frame.infoBody:SetPoint("RIGHT", frame.infoPanel, "RIGHT", -20, 0)
  frame.infoBody:SetJustifyH("CENTER")
  frame.infoBody:SetJustifyV("TOP")
  frame.infoBody:SetText(ns.BuildInfoPanelText())

  frame.featuresPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.featuresPanel:SetPoint("TOPLEFT", 20, -82)
  frame.featuresPanel:SetPoint("BOTTOMRIGHT", -24, 8)
  frame.featuresPanel:SetBackdrop({
    bgFile = THEME_MARBLE,
    edgeFile = 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.settingsPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
  frame.settingsPanel:SetPoint("TOPLEFT", 20, -82)
  frame.settingsPanel:SetPoint("BOTTOMRIGHT", -24, 8)
  frame.settingsPanel:SetBackdrop({
    bgFile = THEME_MARBLE,
    edgeFile = 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()

  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.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.testReminderButton = CreateFrame("Button", nil, frame.settingsPanel, "UIPanelButtonTemplate")
  frame.testReminderButton:SetSize(160, 24)
  frame.testReminderButton:SetPoint("TOP", frame.priceReminderDaysValue, "BOTTOM", 0, -18)
  frame.testReminderButton:SetText("Test Reminder Now")
  frame.testReminderButton:SetScript("OnClick", function()
    if ns.MaybeShowPriceReminder then
      ns.MaybeShowPriceReminder(true)
    end
  end)
  frame.testReminderButton:SetShown(PLAYER_NAME == "Tyaoskad")

  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(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:SetPoint("TOPLEFT", frame, "BOTTOMLEFT", 26, -1)
  frame.infoTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Info")
  end)

  frame.featuresTab = CreateBottomTab(frame, "Features")
  frame.featuresTab:SetPoint("LEFT", frame.infoTab, "RIGHT", 8, 0)
  frame.featuresTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Features")
  end)

  frame.settingsTab = CreateBottomTab(frame, "Settings")
  frame.settingsTab:SetPoint("LEFT", frame.featuresTab, "RIGHT", 8, 0)
  frame.settingsTab:SetScript("OnClick", function()
    ns.SetActiveBottomTab(frame, "Settings")
  end)

  frame.instanceButtons = {}
  frame.itemRows = {}
  frame:UpdateProfessionFilterButtonText()

  function frame:Refresh()
    self.filtered = BuildFilteredInstances(self)
    ns._UpdateExpandCollapseButtonVisual(self)
    ns.EnsureSelection(self)
    ns.RefreshInstanceList(self)
    ns.RefreshItems(self)
  end

  frame.expandCollapseButton:SetScript("OnClick", function(selfBtn)
    local doCollapse = ns._AnyExpansionExpanded(frame)
    ns._SetAllExpansionsCollapsed(frame, doCollapse)
    ns._UpdateExpandCollapseButtonVisual(frame)
    frame:Refresh()
  end)

  frame.searchBox:SetScript("OnEnterPressed", function(selfEdit)
    frame.filters.search = selfEdit:GetText() or ""
    DecorDropsDB.search = frame.filters.search
    frame:Refresh()
    selfEdit:ClearFocus()
  end)

  frame.searchBox:SetScript("OnEditFocusLost", function(selfEdit)
    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()
    local mode = "instances"
    if frame.viewMode == "professions" then
      mode = "professions"
    end
    if ns.RequestScan then
      ns.RequestScan(mode)
    elseif ns.RunAutoScan then
      ns.RunAutoScan(mode)
    end
  end)

  frame.resetButton:SetScript("OnClick", function()
    frame.filters.search = ""
    frame.filters.type = "All"
    frame.filters.collected = "All"
    frame.filters.rarity = BuildDefaultRarityFilter()
    frame.filters.onlyKnownProfessions = false
    frame.filters.professionMulti = {}

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

    frame.searchBox:SetText("")
    frame.typeButton:SetText("Type: All")
    frame.collectedButton:SetText("Show: All")
    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: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, DecorDropsDB.viewMode or "instances")
end

function ns.RefreshUI()
  if ns.MainFrame and ns.MainFrame.Refresh then
    ns.MainFrame:Refresh()
  end
end
