local ADDON_NAME, ns = ...

DecorDropsDB = DecorDropsDB or {}

local function DeepCopy(value, seen)
  if type(value) ~= "table" then
    return value
  end
  seen = seen or {}
  if seen[value] then
    return seen[value]
  end
  local out = {}
  seen[value] = out
  for k, v in pairs(value) do
    out[DeepCopy(k, seen)] = DeepCopy(v, seen)
  end
  return out
end

local function CountAcquireRows(acquireData)
  local total = 0
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  if type(groupsByMode) ~= "table" then
    return total
  end
  for _, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        total = total + #(type(group) == "table" and group.items or {})
      end
    end
  end
  return total
end

local RESONANCE_CRYSTALS_CURRENCY_ID = 2815

local function IsCanonicalResonanceOnlyCost(costs)
  if type(costs) ~= "table" or #costs == 0 then
    return false
  end
  for _, cost in ipairs(costs) do
    if type(cost) ~= "table" then
      return false
    end
    local costType = string.lower(tostring(cost.costType or ""))
    local currencyID = tonumber(cost.currencyID) or 0
    local quantity = tonumber(cost.quantity) or 0
    if costType ~= "currency" or currencyID ~= RESONANCE_CRYSTALS_CURRENCY_ID or quantity <= 0 then
      return false
    end
  end
  return true
end

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

local function PurgeStaleResonanceGoldRows(acquireData)
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  local staticCostsByItemID = ns.staticVendorCosts and ns.staticVendorCosts.byItemID or nil
  if type(groupsByMode) ~= "table" or type(staticCostsByItemID) ~= "table" then
    return 0
  end

  local changed = 0
  for _, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        local rows = type(group) == "table" and group.items or nil
        if type(rows) == "table" then
          for _, row in ipairs(rows) do
            if type(row) == "table" then
              local itemID = tonumber(row.itemID) or 0
              local canonicalCosts = itemID > 0 and staticCostsByItemID[itemID] or nil
              if IsCanonicalResonanceOnlyCost(canonicalCosts) and HasStaleGoldWithoutResonance(row.purchaseCosts) then
                row.purchaseCosts = DeepCopy(canonicalCosts)
                changed = changed + 1
              end
            end
          end
        end
      end
    end
  end

  return changed
end

local PurgeNonQuestSourceRowsFromQuestMode

local ENDEAVOR_VENDOR_NPCS = {
  ["aeeshna"] = true,
  ["brother dovetail"] = true,
  ["harlowe marl"] = true,
  ["hesta forlath"] = true,
  ["hordranin"] = true,
  ["pascal-k1n6"] = true,
  ["the last architect"] = true,
}

local function NormalizeNameKey(name)
  local key = string.lower(tostring(name or ""))
  key = key:gsub("%s+", " ")
  key = key:gsub("^%s+", "")
  key = key:gsub("%s+$", "")
  return key
end

local function EnsureEndeavorGroupsFromVendor(acquireData)
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  if type(groupsByMode) ~= "table" then
    return 0
  end
  local vendorGroups = groupsByMode.vendor
  if type(vendorGroups) ~= "table" then
    return 0
  end

  groupsByMode.endeavor = groupsByMode.endeavor or {}
  local endeavorGroups = groupsByMode.endeavor
  if type(endeavorGroups) ~= "table" then
    return 0
  end

  local existing = {}
  for _, group in ipairs(endeavorGroups) do
    if type(group) == "table" then
      local key = tostring(group.key or "")
      if key ~= "" then
        existing[key] = true
      end
    end
  end

  local added = 0
  for _, vendorGroup in ipairs(vendorGroups) do
    if type(vendorGroup) == "table" then
      local expansion = tostring(vendorGroup.expansion or "")
      local label = tostring(vendorGroup.label or (vendorGroup.waypoint and vendorGroup.waypoint.npcName) or "")
      local npcKey = NormalizeNameKey(label)
      if expansion == "Midnight" and ENDEAVOR_VENDOR_NPCS[npcKey] then
        local clone = DeepCopy(vendorGroup)
        clone.mode = "endeavor"
        clone.category = "Endeavor"
        local baseKey = tostring(clone.key or "")
        if baseKey ~= "" then
          clone.key = baseKey:gsub("|vendor$", "|endeavor")
        end
        if tostring(clone.key or "") == "" then
          clone.key = string.lower(
            tostring(clone.expansion or "Other")
              .. "|"
              .. tostring(clone.label or "Unknown")
              .. "|"
              .. tostring(clone.detail or "Unknown")
              .. "|endeavor"
          )
        end
        if not existing[clone.key] then
          for _, item in ipairs(type(clone.items) == "table" and clone.items or {}) do
            if type(item) == "table" then
              item.category = "Endeavor"
              if tostring(item.acquireStatus or "") == "" then
                item.acquireStatus = "Vendor"
              end
            end
          end
          endeavorGroups[#endeavorGroups + 1] = clone
          existing[clone.key] = true
          added = added + 1
        end
      end
    end
  end

  table.sort(endeavorGroups, function(a, b)
    local expA = tostring(a and a.expansion or "")
    local expB = tostring(b and b.expansion or "")
    if expA ~= expB then
      return expA < expB
    end
    return tostring(a and a.label or "") < tostring(b and b.label or "")
  end)

  return added
end

local function ReclassifyDecorTreasureHuntQuestGroups(acquireData)
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  if type(groupsByMode) ~= "table" then
    return 0
  end
  local questGroups = groupsByMode.quest
  if type(questGroups) ~= "table" then
    return 0
  end

  groupsByMode.endeavor = groupsByMode.endeavor or {}
  local endeavorGroups = groupsByMode.endeavor
  if type(endeavorGroups) ~= "table" then
    return 0
  end

  local existing = {}
  for _, group in ipairs(endeavorGroups) do
    if type(group) == "table" then
      local key = tostring(group.key or "")
      if key ~= "" then
        existing[key] = true
      end
    end
  end

  local moved = 0
  for i = #questGroups, 1, -1 do
    local group = questGroups[i]
    if type(group) == "table" then
      local expansion = tostring(group.expansion or "")
      local labelKey = NormalizeNameKey(group.label or "")
      local items = type(group.items) == "table" and group.items or {}
      local hasItems = #items > 0
      local allNoQuestID = true
      local hasPurchaseCosts = false
      local matchesTreasureHunt = (labelKey == "decor treasure hunt")

      for _, row in ipairs(items) do
        if tonumber(type(row) == "table" and row.questID) and tonumber(row.questID) > 0 then
          allNoQuestID = false
          break
        end
        if type(row) == "table" then
          if type(row.purchaseCosts) == "table" and #row.purchaseCosts > 0 then
            hasPurchaseCosts = true
          elseif tonumber(row.price) and tonumber(row.price) > 0 then
            hasPurchaseCosts = true
          elseif tonumber(row.cost) and tonumber(row.cost) > 0 then
            hasPurchaseCosts = true
          end
          if not matchesTreasureHunt then
            local rowSourceKey = NormalizeNameKey(row.sourceName or row.source or "")
            if rowSourceKey == "decor treasure hunt" then
              matchesTreasureHunt = true
            end
          end
        end
      end

      if expansion == "Midnight" and matchesTreasureHunt and hasItems and allNoQuestID and hasPurchaseCosts then
        local clone = DeepCopy(group)
        clone.mode = "endeavor"
        clone.category = "Endeavor"
        local baseKey = tostring(clone.key or "")
        if baseKey ~= "" then
          clone.key = baseKey:gsub("|quest$", "|endeavor")
        end
        if tostring(clone.key or "") == "" then
          clone.key = string.lower(
            tostring(clone.expansion or "Other")
              .. "|"
              .. tostring(clone.label or "Unknown")
              .. "|"
              .. tostring(clone.detail or "Unknown")
              .. "|endeavor"
          )
        end

        if not existing[clone.key] then
          for _, row in ipairs(type(clone.items) == "table" and clone.items or {}) do
            if type(row) == "table" then
              row.category = "Endeavor"
              row.acquireStatus = "Vendor"
            end
          end
          endeavorGroups[#endeavorGroups + 1] = clone
          existing[clone.key] = true
        end

        table.remove(questGroups, i)
        moved = moved + 1
      end
    end
  end

  if moved > 0 then
    table.sort(endeavorGroups, function(a, b)
      local expA = tostring(a and a.expansion or "")
      local expB = tostring(b and b.expansion or "")
      if expA ~= expB then
        return expA < expB
      end
      return tostring(a and a.label or "") < tostring(b and b.label or "")
    end)
  end

  return moved
end

local function SeedAcquireDataFromStatic(force)
  if type(ns.staticAcquireData) ~= "table" then
    return false, "Static acquire data unavailable."
  end
  if type(ns.staticAcquireData.groupsByMode) ~= "table" then
    return false, "Static acquire data is invalid."
  end

  local dbAcquire = type(DecorDropsDB.acquireData) == "table" and DecorDropsDB.acquireData or nil
  local dbVersion = tonumber(dbAcquire and dbAcquire.version) or 0
  local staticVersion = tonumber(ns.staticAcquireData.version) or 0

  if not force and dbAcquire and type(dbAcquire.groupsByMode) == "table" then
    local dbSource = tostring(dbAcquire.source or "")
    if next(dbAcquire.groupsByMode) ~= nil and dbVersion >= staticVersion and dbSource == "StaticAcquireData" then
      return false, "Local acquire data is up to date."
    end
  end

  local copied = DeepCopy(ns.staticAcquireData)
  copied.source = "StaticAcquireData"
  EnsureEndeavorGroupsFromVendor(copied)
  ReclassifyDecorTreasureHuntQuestGroups(copied)
  copied.importedAt = tonumber(copied.importedAt) or (time and time() or 0)
  copied.totalItems = CountAcquireRows(copied)
  DecorDropsDB.acquireData = copied
  return true, "Loaded local source data: " .. tostring(copied.totalItems) .. " item source rows."
end

local DEV_TOOLS_UNLOCK_TOKEN = "dd-dev-2026"
local DEV_TOOLS_OWNER_ACCOUNT_IDS = {
  ["139612251"] = true,
  ["139612251#1"] = true,
}

local function AddDeveloperAccountCandidate(out, value)
  if value == nil then
    return
  end
  local raw = tostring(value)
  if raw == "" then
    return
  end
  out[raw] = true
  local numericPrefix = raw:match("^(%d+)")
  if numericPrefix and numericPrefix ~= "" then
    out[numericPrefix] = true
  end
end

local function BuildDeveloperAccountCandidates()
  local out = {}

  if C_BattleNet and C_BattleNet.GetAccountInfo then
    local ok, info = pcall(C_BattleNet.GetAccountInfo)
    if ok and type(info) == "table" then
      AddDeveloperAccountCandidate(out, info.accountID)
      AddDeveloperAccountCandidate(out, info.bnetAccountID)
      AddDeveloperAccountCandidate(out, info.gameAccountID)
      AddDeveloperAccountCandidate(out, info.accountGUID)
      if type(info.gameAccountInfo) == "table" then
        AddDeveloperAccountCandidate(out, info.gameAccountInfo.gameAccountID)
        AddDeveloperAccountCandidate(out, info.gameAccountInfo.wowProjectID)
      end
    end
  end

  if BNGetInfo then
    local ok, presenceID, _, battleTag = pcall(BNGetInfo)
    if ok then
      AddDeveloperAccountCandidate(out, presenceID)
      AddDeveloperAccountCandidate(out, battleTag)
    end
  end

  if C_CVar and C_CVar.GetCVar then
    AddDeveloperAccountCandidate(out, C_CVar.GetCVar("accountName"))
  elseif GetCVar then
    AddDeveloperAccountCandidate(out, GetCVar("accountName"))
  end

  return out
end

local function BuildDeveloperCharacterKey()
  local name = UnitName and UnitName("player") or "Unknown"
  local realm = GetRealmName and GetRealmName() or ""
  if realm and realm ~= "" then
    return tostring(name) .. "-" .. tostring(realm)
  end
  return tostring(name)
end

local function EnsureDeveloperDefaults()
  DecorDropsDB.developer = DecorDropsDB.developer or {}
  DecorDropsDB.developer.enabledByChar = DecorDropsDB.developer.enabledByChar or {}
  DecorDropsDB.developer.vendorAutoSyncByAccount = DecorDropsDB.developer.vendorAutoSyncByAccount or {}
  DecorDropsDB.developer.snapshots = DecorDropsDB.developer.snapshots or {}
  DecorDropsDB.developer.deltaScanState = DecorDropsDB.developer.deltaScanState or {}
end

local function IsDeveloperAccountAuthorized()
  if type(DecorDropsDB) ~= "table" then
    return false
  end
  EnsureDeveloperDefaults()

  local accountCandidates = BuildDeveloperAccountCandidates()
  for candidate in pairs(accountCandidates) do
    if DEV_TOOLS_OWNER_ACCOUNT_IDS[candidate] then
      DecorDropsDB.developer.lastAuthorizedAccountID = candidate
      return true
    end
  end

  return false
end
ns.IsDeveloperAccountAuthorized = IsDeveloperAccountAuthorized

local function IsDeveloperToolsEnabled()
  if type(DecorDropsDB) ~= "table" then
    return false
  end
  EnsureDeveloperDefaults()
  if not IsDeveloperAccountAuthorized() then
    return false
  end
  local key = BuildDeveloperCharacterKey()
  return DecorDropsDB.developer.enabledByChar[key] == true
end
ns.IsDeveloperToolsEnabled = IsDeveloperToolsEnabled

local function SetDeveloperToolsEnabled(enabled)
  if type(DecorDropsDB) ~= "table" then
    return false
  end
  EnsureDeveloperDefaults()
  if enabled and not IsDeveloperAccountAuthorized() then
    return false
  end
  local key = BuildDeveloperCharacterKey()
  if enabled then
    DecorDropsDB.developer.enabledByChar[key] = true
  else
    DecorDropsDB.developer.enabledByChar[key] = nil
  end
  return true
end

local function BuildDeveloperAccountPreferenceKey()
  EnsureDeveloperDefaults()
  if IsDeveloperAccountAuthorized() then
    local accountID = tostring(DecorDropsDB.developer.lastAuthorizedAccountID or "")
    if accountID ~= "" then
      return accountID
    end
  end
  return BuildDeveloperCharacterKey()
end

local function IsDeveloperVendorAutoSyncEnabled()
  if type(DecorDropsDB) ~= "table" then
    return false
  end
  EnsureDeveloperDefaults()
  local key = BuildDeveloperAccountPreferenceKey()
  if key == nil or key == "" then
    return false
  end
  return DecorDropsDB.developer.vendorAutoSyncByAccount[key] == true
end

local function SetDeveloperVendorAutoSyncEnabled(enabled)
  if type(DecorDropsDB) ~= "table" then
    return false
  end
  EnsureDeveloperDefaults()
  local key = BuildDeveloperAccountPreferenceKey()
  if key == nil or key == "" then
    return false
  end
  if enabled then
    DecorDropsDB.developer.vendorAutoSyncByAccount[key] = true
  else
    DecorDropsDB.developer.vendorAutoSyncByAccount[key] = nil
  end
  return true
end

local function EnsureDefaults()
  DecorDropsDB.search = DecorDropsDB.search or ""
  DecorDropsDB.typeFilter = DecorDropsDB.typeFilter or "All"
  DecorDropsDB.collectedFilter = DecorDropsDB.collectedFilter or "All"
  DecorDropsDB.rarityFilter = DecorDropsDB.rarityFilter or {}
  -- Per-login UX: start with no active selection until the player clicks one.
  DecorDropsDB.selectedKey = nil
  DecorDropsDB.collapsedExpansions = DecorDropsDB.collapsedExpansions or {}
  DecorDropsDB.collapsedNewSourceZones = DecorDropsDB.collapsedNewSourceZones or {}
  DecorDropsDB.collapsedTrackerSections = DecorDropsDB.collapsedTrackerSections or {}
  DecorDropsDB.autoData = DecorDropsDB.autoData or {}
  DecorDropsDB.autoProfessionData = DecorDropsDB.autoProfessionData or {}
  DecorDropsDB.viewMode = DecorDropsDB.viewMode or "tracker"
  DecorDropsDB.minimap = DecorDropsDB.minimap or { angle = 225 }
  DecorDropsDB.collectionOwned = DecorDropsDB.collectionOwned or {}
  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 {}
  DecorDropsDB.instanceWaypointOverrides = DecorDropsDB.instanceWaypointOverrides or {}
  DecorDropsDB.instanceOpenMapIDs = DecorDropsDB.instanceOpenMapIDs or {}
  if type(ns.staticInstanceOpenMapIDs) == "table" then
    for name, mapID in pairs(ns.staticInstanceOpenMapIDs) do
      if DecorDropsDB.instanceOpenMapIDs[name] == nil then
        DecorDropsDB.instanceOpenMapIDs[name] = tonumber(mapID) or mapID
      end
    end
  end
  if type(ns.staticInstanceWaypoints) == "table" and type(ns.BuildInstanceWaypointOverrideKey) == "function" then
    local function UpsertStaticWaypointOverride(instanceType, instanceName, mapID, x, y, zoneName)
      local key = ns.BuildInstanceWaypointOverrideKey(instanceType, instanceName)
      if not key then
        return
      end
      local existing = DecorDropsDB.instanceWaypointOverrides[key]
      local hasCompleteExisting = type(existing) == "table"
        and tonumber(existing.mapID) and tonumber(existing.mapID) > 0
        and tonumber(existing.x) and tonumber(existing.y)
      if hasCompleteExisting then
        return
      end
      DecorDropsDB.instanceWaypointOverrides[key] = {
        mapID = mapID,
        x = x,
        y = y,
        zoneName = tostring(zoneName or ""),
        source = "StaticInstanceWaypoints",
      }
    end

    for _, waypoint in ipairs(ns.staticInstanceWaypoints) do
      local mapID = tonumber(waypoint.mapID)
      local x = tonumber(waypoint.x)
      local y = tonumber(waypoint.y)
      local rawName = tostring(waypoint.name or "")
      local trimmedName = rawName:gsub("^%s+", ""):gsub("%s+$", "")
      if mapID and mapID > 0 and x and y and trimmedName ~= "" then
        UpsertStaticWaypointOverride(waypoint.type, trimmedName, mapID, x, y, waypoint.zoneName)
        local lowerName = string.lower(trimmedName)
        if lowerName:find("^the ", 1, true) == 1 then
          UpsertStaticWaypointOverride(waypoint.type, trimmedName:sub(5), mapID, x, y, waypoint.zoneName)
        else
          UpsertStaticWaypointOverride(waypoint.type, "The " .. trimmedName, mapID, x, y, waypoint.zoneName)
        end
      end
    end
  end
  DecorDropsDB.acquireData = DecorDropsDB.acquireData or { groupsByMode = {} }
  DecorDropsDB.settings = DecorDropsDB.settings or {}
  DecorDropsDB.professionFilter = DecorDropsDB.professionFilter or {}
  EnsureDeveloperDefaults()
  DecorDropsDB.settings.enablePriceReminders = DecorDropsDB.settings.enablePriceReminders ~= false
  DecorDropsDB.settings.testTeleportNotLearned = DecorDropsDB.settings.testTeleportNotLearned and true or false
  DecorDropsDB.settings.enableMissionAnimationSkip = DecorDropsDB.settings.enableMissionAnimationSkip and true or false
  DecorDropsDB.settings.missionCompleteHotkey = tostring(DecorDropsDB.settings.missionCompleteHotkey or "SPACE")
  DecorDropsDB.settings.showGarrisonStatusTab = DecorDropsDB.settings.showGarrisonStatusTab and true or false
  DecorDropsDB.settings.showOrderHallStatusTab = DecorDropsDB.settings.showOrderHallStatusTab and true or false
  DecorDropsDB.settings.priceReminderDays = tonumber(DecorDropsDB.settings.priceReminderDays) or 30
  if DecorDropsDB.settings.priceReminderDays < 7 then
    DecorDropsDB.settings.priceReminderDays = 7
  elseif DecorDropsDB.settings.priceReminderDays > 180 then
    DecorDropsDB.settings.priceReminderDays = 180
  end
  DecorDropsDB.settings.priceReminderFirstLoginAt = tonumber(DecorDropsDB.settings.priceReminderFirstLoginAt)
  DecorDropsDB.settings.lastPriceReminderAt = tonumber(DecorDropsDB.settings.lastPriceReminderAt)
  DecorDropsDB.settings.lastAuctionPriceSeenAt = tonumber(DecorDropsDB.settings.lastAuctionPriceSeenAt)
  local opacity = tonumber(DecorDropsDB.settings.windowOpacity)
  if not opacity then
    opacity = 1.0
  end
  if opacity < 0.35 then
    opacity = 0.35
  elseif opacity > 1.0 then
    opacity = 1.0
  end
  DecorDropsDB.settings.windowOpacity = opacity
  SeedAcquireDataFromStatic(false)
  EnsureEndeavorGroupsFromVendor(ns.staticAcquireData)
  ReclassifyDecorTreasureHuntQuestGroups(ns.staticAcquireData)
  local endeavorAdded = EnsureEndeavorGroupsFromVendor(DecorDropsDB.acquireData)
  local treasureHuntMoved = ReclassifyDecorTreasureHuntQuestGroups(DecorDropsDB.acquireData)
  local purgedQuestSourceRows = PurgeNonQuestSourceRowsFromQuestMode(DecorDropsDB.acquireData)
  local purgedStaleGoldRows = PurgeStaleResonanceGoldRows(DecorDropsDB.acquireData)
  if (endeavorAdded > 0 or treasureHuntMoved > 0 or purgedQuestSourceRows > 0 or purgedStaleGoldRows > 0) and type(DecorDropsDB.acquireData) == "table" then
    DecorDropsDB.acquireData.source = "StaticAcquireData"
    DecorDropsDB.acquireData.importedAt = time and time() or DecorDropsDB.acquireData.importedAt
    DecorDropsDB.acquireData.totalItems = CountAcquireRows(DecorDropsDB.acquireData)
  end
end

local missionSkipPulseFrame = CreateFrame("Frame")
missionSkipPulseFrame:Hide()
local missionSkipPulseElapsed = 0
local MISSION_SKIP_PULSE_INTERVAL = 0.20
local MISSION_SKIP_BUTTON_DEBOUNCE_SECONDS = 0.18
local lastMissionSkipButton = nil
local lastMissionSkipClickAt = 0
local missionHotkeyListener = CreateFrame("Frame")
missionHotkeyListener:Hide()
missionHotkeyListener:SetAllPoints(UIParent)
missionHotkeyListener:SetFrameStrata("TOOLTIP")
missionHotkeyListener:EnableKeyboard(true)
if missionHotkeyListener.SetPropagateKeyboardInput then
  missionHotkeyListener:SetPropagateKeyboardInput(true)
end
local MISSION_SKIP_GLOBAL_ROOTS = {
  "GarrisonMissionFrame",         -- WoD garrison missions
  "GarrisonShipyardMissionFrame", -- WoD shipyard missions
  "OrderHallMissionFrame",        -- Legion order hall missions
}
local MISSION_COMPLETE_DIALOG_PATHS = {
  { "Missions", "CompleteDialog" },
  { "Missions", "CompleteDialog", "BorderFrame" },
  { "MissionTab", "MissionList", "CompleteDialog" },
  { "MissionTab", "MissionList", "CompleteDialog", "BorderFrame" },
}
local MISSION_COMPLETE_FRAME_PATHS = {
  { "MissionComplete" },
}
local MISSION_ADVANCE_BUTTON_PATHS = {
  { "Missions", "CompleteDialog", "BorderFrame", "ViewButton" },
  { "MissionTab", "MissionList", "CompleteDialog", "BorderFrame", "ViewButton" },
  { "Missions", "CompleteDialog", "BorderFrame", "CompleteMissionButton" },
  { "MissionTab", "MissionList", "CompleteDialog", "BorderFrame", "CompleteMissionButton" },
}
local MISSION_CLOSE_BUTTON_PATHS = {
  { "Missions", "CompleteDialog", "BorderFrame", "CloseButton" },
  { "MissionTab", "MissionList", "CompleteDialog", "BorderFrame", "CloseButton" },
}

local function IsMissionAnimationSkipEnabled()
  if type(DecorDropsDB) ~= "table" then
    return false
  end
  local settings = DecorDropsDB.settings
  return type(settings) == "table" and settings.enableMissionAnimationSkip == true
end
ns.IsMissionAnimationSkipEnabled = IsMissionAnimationSkipEnabled

local function NormalizeMissionHotkey(value)
  local text = string.upper(tostring(value or "")):gsub("^%s+", ""):gsub("%s+$", "")
  text = text:gsub("%+", "-")
  text = text:gsub("%s+", "")
  if text == "" then
    return "SPACE"
  end
  if text == "SPACEBAR" or text == " " then
    return "SPACE"
  end
  return text
end

local function GetMissionCompleteHotkey()
  if type(DecorDropsDB) ~= "table" then
    return "SPACE"
  end
  local settings = DecorDropsDB.settings
  if type(settings) ~= "table" then
    return "SPACE"
  end
  local normalized = NormalizeMissionHotkey(settings.missionCompleteHotkey)
  settings.missionCompleteHotkey = normalized
  return normalized
end
ns.GetMissionCompleteHotkey = GetMissionCompleteHotkey

function ns.SetMissionCompleteHotkey(value)
  if type(DecorDropsDB) ~= "table" then
    return
  end
  DecorDropsDB.settings = DecorDropsDB.settings or {}
  DecorDropsDB.settings.missionCompleteHotkey = NormalizeMissionHotkey(value)
  if ns.UpdateMissionAnimationSkipPulse then
    ns.UpdateMissionAnimationSkipPulse()
  end
end

local function ResolveMissionPath(rootFrame, path)
  local node = rootFrame
  for _, key in ipairs(path) do
    if type(node) ~= "table" then
      return nil
    end
    node = node[key]
  end
  return node
end

local function ResolveFirstMissionPath(rootFrame, paths)
  for _, path in ipairs(paths or {}) do
    local node = ResolveMissionPath(rootFrame, path)
    if node then
      return node
    end
  end
  return nil
end

local function ResolveMissionDialogAnchor(rootFrame, globalName)
  local anchor = ResolveFirstMissionPath(rootFrame, MISSION_COMPLETE_DIALOG_PATHS)
  if type(anchor) == "table" and anchor.IsShown and anchor:IsShown() then
    return anchor
  end

  local missionsGlobal = type(globalName) == "string" and _G[globalName .. "Missions"] or nil
  if type(missionsGlobal) == "table" then
    local dialog = missionsGlobal.CompleteDialog
    if type(dialog) == "table" and dialog.IsShown and dialog:IsShown() then
      local border = dialog.BorderFrame
      if type(border) == "table" and border.IsShown and border:IsShown() then
        return border
      end
      return dialog
    end
  end
  return nil
end

local function IsSafeMissionActionButton(button)
  if type(button) ~= "table" or not button.IsObjectType or not button:IsObjectType("Button") then
    return false
  end
  if button.IsForbidden and button:IsForbidden() then
    return false
  end
  if not (button.IsShown and button:IsShown()) then
    return false
  end
  if button.IsEnabled and not button:IsEnabled() then
    return false
  end
  if not button.Click then
    return false
  end
  return true
end

local function IsMissionRootVisible(rootFrame)
  return type(rootFrame) == "table"
    and rootFrame.IsShown and rootFrame:IsShown()
    and rootFrame.IsVisible and rootFrame:IsVisible()
end

local function TryClickMissionButton(button)
  if not IsSafeMissionActionButton(button) then
    return false
  end
  local now = GetTime and GetTime() or 0
  if button == lastMissionSkipButton and (now - lastMissionSkipClickAt) < MISSION_SKIP_BUTTON_DEBOUNCE_SECONDS then
    return false
  end
  local ok = pcall(button.Click, button)
  if ok then
    lastMissionSkipButton = button
    lastMissionSkipClickAt = now
  end
  return ok == true
end

local function ResolveNextMissionButton(rootFrame)
  if type(rootFrame) ~= "table" then
    return nil
  end
  local missionComplete = rootFrame.MissionComplete
  local nextButton = missionComplete and missionComplete.NextMissionButton
  if not IsSafeMissionActionButton(nextButton) then
    return nil
  end
  if type(missionComplete.currentIndex) ~= "number" then
    return nil
  end
  return nextButton
end

local function ResolveMissionAdvanceButton(rootFrame)
  if not IsMissionRootVisible(rootFrame) then
    return nil
  end
  for _, path in ipairs(MISSION_ADVANCE_BUTTON_PATHS) do
    local candidate = ResolveMissionPath(rootFrame, path)
    if IsSafeMissionActionButton(candidate) then
      return candidate
    end
  end
  return ResolveNextMissionButton(rootFrame)
end

local function ResolveMissionCloseButton(rootFrame)
  for _, path in ipairs(MISSION_CLOSE_BUTTON_PATHS) do
    local candidate = ResolveMissionPath(rootFrame, path)
    if IsSafeMissionActionButton(candidate) then
      return candidate
    end
  end
  return nil
end

local function TryAdvanceMissionByUserInput()
  if not IsMissionAnimationSkipEnabled() then
    return false
  end
  for _, globalName in ipairs(MISSION_SKIP_GLOBAL_ROOTS) do
    local root = _G[globalName]
    if IsMissionRootVisible(root) and ResolveMissionDialogAnchor(root, globalName) then
      local button = ResolveMissionAdvanceButton(root) or ResolveMissionCloseButton(root)
      if button and TryClickMissionButton(button) then
        return true
      end
    end
  end
  for _, globalName in ipairs(MISSION_SKIP_GLOBAL_ROOTS) do
    local root = _G[globalName]
    if IsMissionRootVisible(root) then
      local button = ResolveMissionAdvanceButton(root) or ResolveMissionCloseButton(root)
      if button and TryClickMissionButton(button) then
        return true
      end
    end
  end
  return false
end

function ns.TryAdvanceMissionCompletionByHotkey()
  return TryAdvanceMissionByUserInput()
end

local function BuildMissionHintText()
  local key = GetMissionCompleteHotkey()
  return string.format("PRESS %s TO SKIP MISSION REPORT ANIMATIONS", tostring(key))
end

local function HideMissionPromptButtons()
  for _, globalName in ipairs(MISSION_SKIP_GLOBAL_ROOTS) do
    local root = _G[globalName]
    if type(root) == "table" and type(root._ddMissionHotkeyButton) == "table" then
      root._ddMissionHotkeyButton:Hide()
    end
    if type(root) == "table" then
      if type(root._ddMissionHintOverlay) == "table" then
        root._ddMissionHintOverlay:Hide()
      end
      if type(root._ddMissionHintOverlayFrame) == "table" then
        root._ddMissionHintOverlayFrame:Hide()
      end
      if type(root._ddHiddenMissionHints) == "table" then
        for _, hint in ipairs(root._ddHiddenMissionHints) do
          if type(hint) == "table" and hint.SetAlpha then
            hint:SetAlpha(1.0)
          end
        end
        wipe(root._ddHiddenMissionHints)
      end
      if type(root._ddMissionHintOriginal) == "table" and root._ddMissionHintOriginal.SetAlpha then
        root._ddMissionHintOriginal:SetAlpha(1.0)
      end
      root._ddMissionHintOriginal = nil
    end
  end
end

local function EnsureMissionHintOverlay(rootFrame, originalHint)
  if type(rootFrame) ~= "table" or type(originalHint) ~= "table" then
    return nil
  end
  local parent = originalHint.GetParent and originalHint:GetParent() or nil
  if type(parent) ~= "table" then
    return nil
  end
  local overlay = rootFrame._ddMissionHintOverlay
  if type(overlay) ~= "table" then
    overlay = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    rootFrame._ddMissionHintOverlay = overlay
  elseif overlay.GetParent and overlay:GetParent() ~= parent and overlay.SetParent then
    overlay:SetParent(parent)
  end
  if overlay.ClearAllPoints then
    overlay:ClearAllPoints()
  end
  if overlay.SetAllPoints then
    overlay:SetAllPoints(originalHint)
  end
  if overlay.SetJustifyH and originalHint.GetJustifyH then
    overlay:SetJustifyH(originalHint:GetJustifyH() or "CENTER")
  end
  if overlay.SetJustifyV and originalHint.GetJustifyV then
    overlay:SetJustifyV(originalHint:GetJustifyV() or "MIDDLE")
  end
  if overlay.SetWordWrap and originalHint.CanNonSpaceWrap and originalHint:CanNonSpaceWrap() ~= nil then
    overlay:SetWordWrap(true)
  end
  return overlay
end

local function ApplyMissionHintStyle(rootFrame, fontString)
  if type(fontString) ~= "table" or type(rootFrame) ~= "table" then
    return
  end
  local overlay = EnsureMissionHintOverlay(rootFrame, fontString)
  if type(overlay) ~= "table" then
    return
  end
  rootFrame._ddMissionHintOriginal = fontString
  if fontString.SetAlpha then
    fontString:SetAlpha(0.0)
  end
  if overlay.SetText then
    overlay:SetText(BuildMissionHintText())
  end
  if overlay.SetTextColor then
    overlay:SetTextColor(0.66, 0.90, 1.00, 1.00)
  end
  if overlay.SetShadowColor then
    overlay:SetShadowColor(0.05, 0.25, 0.70, 1.00)
  end
  if overlay.SetShadowOffset then
    overlay:SetShadowOffset(1, -1)
  end
  if overlay.SetFont and STANDARD_TEXT_FONT then
    overlay:SetFont(STANDARD_TEXT_FONT, 24, "OUTLINE")
  end
  if overlay.Show then
    overlay:Show()
  end
end

local function ShowMissionHintOverlay(rootFrame, anchorFrame, yOffset)
  if type(rootFrame) ~= "table" or type(anchorFrame) ~= "table" then
    return
  end
  local overlayFrame = rootFrame._ddMissionHintOverlayFrame
  if type(overlayFrame) ~= "table" then
    overlayFrame = CreateFrame("Frame", nil, UIParent)
    overlayFrame:SetFrameStrata("FULLSCREEN_DIALOG")
    overlayFrame:SetFrameLevel(4000)
    overlayFrame:EnableMouse(false)
    overlayFrame.bg = overlayFrame:CreateTexture(nil, "BACKGROUND")
    overlayFrame.bg:SetAllPoints()
    overlayFrame.bg:SetTexture("Interface\\Buttons\\WHITE8X8")
    overlayFrame.bg:SetVertexColor(0.02, 0.10, 0.22, 0.72)
    overlayFrame.topLine = overlayFrame:CreateTexture(nil, "ARTWORK")
    overlayFrame.topLine:SetPoint("TOPLEFT", 3, -2)
    overlayFrame.topLine:SetPoint("TOPRIGHT", -3, -2)
    overlayFrame.topLine:SetHeight(1)
    overlayFrame.topLine:SetTexture("Interface\\Buttons\\WHITE8X8")
    overlayFrame.topLine:SetVertexColor(0.55, 0.85, 1.00, 0.85)
    overlayFrame.bottomLine = overlayFrame:CreateTexture(nil, "ARTWORK")
    overlayFrame.bottomLine:SetPoint("BOTTOMLEFT", 3, 2)
    overlayFrame.bottomLine:SetPoint("BOTTOMRIGHT", -3, 2)
    overlayFrame.bottomLine:SetHeight(1)
    overlayFrame.bottomLine:SetTexture("Interface\\Buttons\\WHITE8X8")
    overlayFrame.bottomLine:SetVertexColor(0.20, 0.55, 0.92, 0.95)
    overlayFrame.text = overlayFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    rootFrame._ddMissionHintOverlayFrame = overlayFrame
  end

  local overlay = overlayFrame.text
  if type(overlay) ~= "table" then
    return
  end
  overlayFrame:ClearAllPoints()
  overlayFrame:SetPoint("BOTTOM", anchorFrame, "BOTTOM", 0, tonumber(yOffset) or 20)
  overlayFrame:SetSize(math.max(320, (tonumber(anchorFrame.GetWidth and anchorFrame:GetWidth()) or 500) - 80), 58)
  if overlay.ClearAllPoints then
    overlay:ClearAllPoints()
  end
  overlay:SetPoint("CENTER", overlayFrame, "CENTER", 0, 0)
  overlay:SetWidth((overlayFrame.GetWidth and overlayFrame:GetWidth()) or 460)
  overlay:SetJustifyH("CENTER")
  overlay:SetJustifyV("MIDDLE")
  overlay:SetWordWrap(true)
  overlay:SetText(BuildMissionHintText())
  overlay:SetTextColor(0.42, 0.86, 1.00, 1.00)
  overlay:SetShadowColor(0.00, 0.36, 1.00, 1.00)
  overlay:SetShadowOffset(1, -1)
  if overlay.SetFont and STANDARD_TEXT_FONT then
    overlay:SetFont(STANDARD_TEXT_FONT, 30, "OUTLINE")
  end
  overlayFrame:Show()
  overlay:Show()
end

local function FindMissionHintFontString(frame, visited, depth)
  if type(frame) ~= "table" then
    return nil
  end
  visited = visited or {}
  if visited[frame] then
    return nil
  end
  visited[frame] = true
  depth = tonumber(depth) or 0
  if depth > 8 then
    return nil
  end

  if frame.GetRegions then
    local regions = { frame:GetRegions() }
    for _, region in ipairs(regions) do
      if type(region) == "table" and region.GetObjectType and region:GetObjectType() == "FontString" then
        local text = string.lower(tostring(region.GetText and region:GetText() or ""))
        if text:find("space bar", 1, true)
          or text:find("skip mission report", 1, true)
          or (text:find("skip", 1, true) and text:find("mission", 1, true) and text:find("animation", 1, true))
        then
          return region
        end
      end
    end
  end

  if frame.GetChildren then
    local children = { frame:GetChildren() }
    for _, child in ipairs(children) do
      local found = FindMissionHintFontString(child, visited, depth + 1)
      if found then
        return found
      end
    end
  end

  return nil
end

local function HideDefaultMissionHintText(rootFrame, frame, visited, depth)
  if type(rootFrame) ~= "table" or type(frame) ~= "table" then
    return
  end
  visited = visited or {}
  if visited[frame] then
    return
  end
  visited[frame] = true
  depth = tonumber(depth) or 0
  if depth > 8 then
    return
  end

  rootFrame._ddHiddenMissionHints = rootFrame._ddHiddenMissionHints or {}

  if frame.GetRegions then
    local regions = { frame:GetRegions() }
    for _, region in ipairs(regions) do
      if type(region) == "table" and region.GetObjectType and region:GetObjectType() == "FontString" then
        local text = string.lower(tostring(region.GetText and region:GetText() or ""))
        if text:find("space bar", 1, true)
          or text:find("skip mission report", 1, true)
          or (text:find("skip", 1, true) and text:find("mission", 1, true) and text:find("animation", 1, true))
        then
          if region.SetAlpha then
            region:SetAlpha(0.0)
            rootFrame._ddHiddenMissionHints[#rootFrame._ddHiddenMissionHints + 1] = region
          end
        end
      end
    end
  end

  if frame.GetChildren then
    local children = { frame:GetChildren() }
    for _, child in ipairs(children) do
      HideDefaultMissionHintText(rootFrame, child, visited, depth + 1)
    end
  end
end

local function UpdateMissionHotkeyPrompt()
  HideMissionPromptButtons()
  if not IsMissionAnimationSkipEnabled() then
    return false
  end
  local hasContext = false
  for _, globalName in ipairs(MISSION_SKIP_GLOBAL_ROOTS) do
    local root = _G[globalName]
    if IsMissionRootVisible(root) then
      hasContext = true
      local dialogBorder = ResolveMissionDialogAnchor(root, globalName)
      local missionCompleteFrame = ResolveFirstMissionPath(root, MISSION_COMPLETE_FRAME_PATHS)
      local missionsGlobal = _G[globalName .. "Missions"]

      if type(missionCompleteFrame) == "table" then
        HideDefaultMissionHintText(root, missionCompleteFrame)
      end
      if type(dialogBorder) == "table" then
        HideDefaultMissionHintText(root, dialogBorder)
      end
      if type(missionsGlobal) == "table" then
        HideDefaultMissionHintText(root, missionsGlobal)
      end

      if type(missionCompleteFrame) == "table"
        and missionCompleteFrame.IsShown and missionCompleteFrame:IsShown()
        and missionCompleteFrame.IsVisible and missionCompleteFrame:IsVisible()
      then
        ShowMissionHintOverlay(root, missionCompleteFrame, 16)
      elseif type(dialogBorder) == "table" and dialogBorder.IsShown and dialogBorder:IsShown() then
        ShowMissionHintOverlay(root, dialogBorder, 8)
      else
        -- Guaranteed fallback: show our custom hint near the bottom of the mission frame.
        ShowMissionHintOverlay(root, root, 84)
      end
    end
  end
  return hasContext
end

local function BuildPressedHotkey(key)
  local keyText = string.upper(tostring(key or ""))
  if keyText == " " or keyText == "SPACEBAR" then
    keyText = "SPACE"
  end
  if keyText == "" then
    return ""
  end
  local parts = {}
  if IsControlKeyDown and IsControlKeyDown() then
    parts[#parts + 1] = "CTRL"
  end
  if IsAltKeyDown and IsAltKeyDown() then
    parts[#parts + 1] = "ALT"
  end
  if IsShiftKeyDown and IsShiftKeyDown() then
    parts[#parts + 1] = "SHIFT"
  end
  parts[#parts + 1] = keyText
  return table.concat(parts, "-")
end

missionHotkeyListener:SetScript("OnKeyDown", function(self, key)
  if not IsMissionAnimationSkipEnabled() then
    if self.SetPropagateKeyboardInput then
      self:SetPropagateKeyboardInput(true)
    end
    return
  end
  if ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow() then
    if self.SetPropagateKeyboardInput then
      self:SetPropagateKeyboardInput(true)
    end
    return
  end
  local configured = GetMissionCompleteHotkey()
  local pressed = NormalizeMissionHotkey(BuildPressedHotkey(key))
  local matched = pressed == configured
  if self.SetPropagateKeyboardInput then
    self:SetPropagateKeyboardInput(not matched)
  end
  if matched then
    TryAdvanceMissionByUserInput()
  end
end)

local function PulseMissionAnimationSkip()
  local hasPrompt = UpdateMissionHotkeyPrompt()
  if hasPrompt and IsMissionAnimationSkipEnabled() then
    missionHotkeyListener:Show()
  else
    missionHotkeyListener:Hide()
  end
end

missionSkipPulseFrame:SetScript("OnUpdate", function(self, elapsed)
  if not IsMissionAnimationSkipEnabled() then
    missionHotkeyListener:Hide()
    HideMissionPromptButtons()
    self:Hide()
    return
  end
  missionSkipPulseElapsed = missionSkipPulseElapsed + (tonumber(elapsed) or 0)
  if missionSkipPulseElapsed < MISSION_SKIP_PULSE_INTERVAL then
    return
  end
  missionSkipPulseElapsed = 0
  PulseMissionAnimationSkip()
end)

function ns.UpdateMissionAnimationSkipPulse()
  missionSkipPulseElapsed = 0
  if IsMissionAnimationSkipEnabled() then
    missionSkipPulseFrame:Show()
    PulseMissionAnimationSkip()
  else
    missionHotkeyListener:Hide()
    HideMissionPromptButtons()
    missionSkipPulseFrame:Hide()
  end
end

function ns.SetMissionAnimationSkipEnabled(enabled)
  if type(DecorDropsDB) ~= "table" then
    return
  end
  DecorDropsDB.settings = DecorDropsDB.settings or {}
  DecorDropsDB.settings.enableMissionAnimationSkip = enabled and true or false
  ns.UpdateMissionAnimationSkipPulse()
end

local REP_STANDING_NAMES = {
  [4] = "Neutral",
  [5] = "Friendly",
  [6] = "Honored",
  [7] = "Revered",
  [8] = "Exalted",
}
local REP_STANDING_TO_MINREP = {
  Neutral = 4,
  Friendly = 5,
  Honored = 6,
  Revered = 7,
  Exalted = 8,
}
local KNOWN_RENOWN_FACTION_IDS = {
  [2507] = true, -- Dragonscale Expedition
  [2590] = true, -- Council of Dornogal
  [2653] = true, -- Cartels of Undermine
  [2658] = true, -- K'aresh Trust
  [2688] = true, -- Flame's Radiance
}
local KNOWN_RENOWN_FACTION_NAMES = {
  ["dragonscale expedition"] = true,
  ["maruuk centaur"] = true,
  ["iskaara tuskarr"] = true,
  ["valdrakken accord"] = true,
  ["loamm niffen"] = true,
  ["dream wardens"] = true,
  ["council of dornogal"] = true,
  ["the assembly of the deeps"] = true,
  ["hallowfall arathi"] = true,
  ["the severed threads"] = true,
  ["flame's radiance"] = true,
  ["cartels of undermine"] = true,
  ["k'aresh trust"] = true,
  ["gallagio loyalty rewards club"] = true,
}
local REPUTATION_REQUIREMENT_OVERRIDES = {
  -- Known fix: Red Crane Kite (Order of the Cloud Serpent) requires Revered.
  [247730] = { minRep = 7, repStandingText = "Revered", factionName = "Order of the Cloud Serpent" },
}
local QUEST_UNLOCK_STATUS_TEXT = "Quest Unlock"
local QUEST_UNLOCK_VENDOR_OVERRIDES = {
  -- Quest reward unlocks that also have a deterministic post-quest vendor source.
  [245984] = {
    expansion = "Battle for Azeroth",
    npcName = "Caspian",
    npcID = 252313,
    mapID = 942,
    x = 59.52,
    y = 69.61,
    zone = "Stormsong Valley",
    faction = "A",
    factionName = "",
    factionID = 0,
    minRep = 0,
    repStandingText = "",
    sourceType = 3,
    purchaseCosts = {
      { costType = "currency", currencyID = 1560, quantity = 350 },
    },
  },
}

local function IsVendorCatalogArea(itemID)
  local info = ns.GetStaticDecorInfo and ns.GetStaticDecorInfo(itemID) or nil
  local area = string.lower(tostring(type(info) == "table" and info.area or ""))
  return area == "vendor"
end

local function IsQuestUnlockBackfillCandidate(itemID)
  local key = tonumber(itemID) or 0
  if key <= 0 then
    return false
  end
  if type(QUEST_UNLOCK_VENDOR_OVERRIDES[key]) == "table" then
    return true
  end
  return IsVendorCatalogArea(key)
end

local function BuildRepStandingText(minRep)
  local rank = tonumber(minRep) or 0
  if rank >= 9 then
    return "Renown " .. tostring(rank)
  end
  return REP_STANDING_NAMES[rank] or ""
end

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

local function ParseRenownFromText(repText)
  local n = tostring(repText or ""):match("^[Rr]enown%s+(%d+)$")
  return tonumber(n) or 0
end

local function NormalizeFactionKey(name)
  local key = string.lower(tostring(name or ""))
  key = key:gsub("%s+", " ")
  key = key:gsub("^%s+", "")
  key = key:gsub("%s+$", "")
  return key
end

local function IsKnownRenownFaction(row)
  if type(row) ~= "table" then
    return false
  end
  local factionID = tonumber(row.factionID) or 0
  if factionID > 0 and KNOWN_RENOWN_FACTION_IDS[factionID] then
    return true
  end
  local factionName = NormalizeFactionKey(row.factionName)
  if factionName ~= "" and KNOWN_RENOWN_FACTION_NAMES[factionName] then
    return true
  end
  return false
end

local function ResolveRepStandingText(item)
  local explicit = TrimText(type(item) == "table" and item.repStandingText or "")
  if explicit ~= "" then
    return explicit
  end
  return BuildRepStandingText(tonumber(type(item) == "table" and item.minRep) or 0)
end

local function BuildAcquireItemStatus(item)
  if not item then
    return "-"
  end

  if item.questID and item.questID > 0 and C_QuestLog and C_QuestLog.IsQuestFlaggedCompleted then
    local completed = C_QuestLog.IsQuestFlaggedCompleted(item.questID)
    return completed and "Quest Complete" or "Incomplete"
  end

  if item.achievementID and item.achievementID > 0 and C_AchievementInfo and C_AchievementInfo.IsAchievementCompleted then
    local completed = C_AchievementInfo.IsAchievementCompleted(item.achievementID)
    return completed and "Achievement Complete" or "Achievement Missing"
  end

  if item.minRep and item.minRep > 0 then
    local repText = ResolveRepStandingText(item)
    if repText ~= "" and item.factionName and item.factionName ~= "" then
      return repText .. " (" .. tostring(item.factionName) .. ")"
    end
    return repText ~= "" and repText or "Reputation Required"
  end

  if item.category == "Vendor" then
    return "Vendor"
  end
  return "-"
end

local function NormalizeAcquireReputationData(acquireData)
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  if type(groupsByMode) ~= "table" then
    return 0
  end

  local changed = 0
  for _, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        for _, row in ipairs(type(group) == "table" and group.items or {}) do
          if type(row) == "table" then
            local minRep = tonumber(row.minRep) or 0
            local repText = tostring(row.repStandingText or "")
            local itemID = tonumber(row.itemID) or 0
            local didChange = false

            local override = REPUTATION_REQUIREMENT_OVERRIDES[itemID]
            if override and (override.factionName == nil or tostring(row.factionName or "") == tostring(override.factionName)) then
              if tonumber(row.minRep) ~= tonumber(override.minRep) then
                row.minRep = tonumber(override.minRep) or row.minRep
                minRep = tonumber(row.minRep) or 0
                didChange = true
              end
              if tostring(row.repStandingText or "") ~= tostring(override.repStandingText or "") then
                row.repStandingText = tostring(override.repStandingText or "")
                repText = tostring(row.repStandingText or "")
                didChange = true
              end
            end

            local normalizedRepText = TrimText(repText)
            local expectedMin = REP_STANDING_TO_MINREP[normalizedRepText]
            local renownFromText = ParseRenownFromText(normalizedRepText)
            local isKnownRenown = IsKnownRenownFaction(row)

            if expectedMin and minRep ~= expectedMin then
              row.minRep = expectedMin
              minRep = expectedMin
              didChange = true
            elseif renownFromText > 0 and minRep ~= renownFromText then
              row.minRep = renownFromText
              minRep = renownFromText
              didChange = true
            end

            if isKnownRenown and minRep > 0 then
              local expectedRenownText = "Renown " .. tostring(minRep)
              if normalizedRepText ~= expectedRenownText then
                row.repStandingText = expectedRenownText
                didChange = true
              end
            elseif normalizedRepText == "" and minRep >= 4 and minRep <= 8 then
              local expectedText = REP_STANDING_NAMES[minRep] or ""
              if expectedText ~= "" then
                row.repStandingText = expectedText
                didChange = true
              end
            elseif normalizedRepText == "" and minRep >= 9 then
              local expectedRenownText = "Renown " .. tostring(minRep)
              row.repStandingText = expectedRenownText
              didChange = true
            end

            if didChange then
              row.acquireStatus = BuildAcquireItemStatus(row)
              changed = changed + 1
            end
          end
        end
      end
    end
  end

  return changed
end
ns.NormalizeAcquireReputationData = NormalizeAcquireReputationData

local function ParseNPCIDFromGUID(guid)
  local unitType, _, _, _, _, npcID = strsplit("-", tostring(guid or ""))
  if unitType ~= "Creature" and unitType ~= "Vehicle" then
    return nil
  end
  local id = tonumber(npcID)
  if id and id > 0 then
    return id
  end
  return nil
end

local function GetPlayerMapContext()
  local mapID = C_Map and C_Map.GetBestMapForUnit and tonumber(C_Map.GetBestMapForUnit("player")) or 0
  local zoneName = ""
  if mapID and mapID > 0 and C_Map and C_Map.GetMapInfo then
    local info = C_Map.GetMapInfo(mapID)
    zoneName = tostring(info and info.name or "")
  end
  local px, py = nil, nil
  if mapID and mapID > 0 and C_Map and C_Map.GetPlayerMapPosition then
    local pos = C_Map.GetPlayerMapPosition(mapID, "player")
    if pos and pos.GetXY then
      local x, y = pos:GetXY()
      px = (tonumber(x) or 0) * 100
      py = (tonumber(y) or 0) * 100
    end
  end
  return mapID, zoneName, px, py
end

local function FindVendorGroupByNPCID(acquireData, npcID)
  local groups = acquireData and acquireData.groupsByMode and acquireData.groupsByMode.vendor
  if type(groups) ~= "table" then
    return nil
  end
  for _, group in ipairs(groups) do
    local wp = type(group) == "table" and group.waypoint or nil
    if type(wp) == "table" and tonumber(wp.npcID) == tonumber(npcID) then
      return group
    end
  end
  return nil
end

local function BuildAcquireItemTemplatesByMode(acquireData, itemID)
  local out = {}
  local target = tonumber(itemID) or 0
  if target <= 0 then
    return out
  end
  local groupsByMode = acquireData and acquireData.groupsByMode
  if type(groupsByMode) ~= "table" then
    return out
  end
  for mode, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        for _, row in ipairs(type(group) == "table" and group.items or {}) do
          if tonumber(type(row) == "table" and row.itemID) == target then
            out[mode] = out[mode] or {}
            out[mode][#out[mode] + 1] = {
              group = group,
              row = row,
            }
          end
        end
      end
    end
  end
  return out
end

local function HasAnyAcquireTemplates(templates)
  if type(templates) ~= "table" then
    return false
  end
  for _, rows in pairs(templates) do
    if type(rows) == "table" and #rows > 0 then
      return true
    end
  end
  return false
end

local function FindGroupByKey(groupsByMode, mode, key)
  local groups = groupsByMode and groupsByMode[mode]
  if type(groups) ~= "table" then
    return nil
  end
  for _, group in ipairs(groups) do
    if type(group) == "table" and tostring(group.key or "") == tostring(key or "") then
      return group
    end
  end
  return nil
end

local EnsureVendorItemSeen

local function EnsureModeGroupFromTemplate(groupsByMode, mode, templateGroup)
  groupsByMode[mode] = groupsByMode[mode] or {}
  local existing = FindGroupByKey(groupsByMode, mode, templateGroup and templateGroup.key)
  if existing then
    EnsureVendorItemSeen(existing)
    return existing
  end
  local group = {
    key = templateGroup and templateGroup.key or "",
    mode = mode,
    category = templateGroup and templateGroup.category or mode,
    expansion = templateGroup and templateGroup.expansion or "Other",
    label = templateGroup and templateGroup.label or "Source",
    detail = templateGroup and templateGroup.detail or "",
    waypoint = type(templateGroup and templateGroup.waypoint) == "table" and DeepCopy(templateGroup.waypoint) or nil,
    items = {},
    itemSeen = {},
  }
  groupsByMode[mode][#groupsByMode[mode] + 1] = group
  return group
end

local function AddRowToGroup(group, row)
  if type(group) ~= "table" or type(row) ~= "table" then
    return false
  end
  EnsureVendorItemSeen(group)
  local itemID = tonumber(row.itemID) or 0
  if itemID <= 0 then
    return false
  end
  if group.itemSeen[itemID] then
    return false
  end
  local cloned = DeepCopy(row)
  cloned.acquireStatus = BuildAcquireItemStatus(cloned)
  group.items[#group.items + 1] = cloned
  group.itemSeen[itemID] = true
  return true
end

local function FindRowByItemID(rows, itemID)
  local target = tonumber(itemID) or 0
  if target <= 0 then
    return nil
  end
  for _, row in ipairs(rows or {}) do
    if tonumber(type(row) == "table" and row.itemID) == target then
      return row
    end
  end
  return nil
end

local function AcquireDataHasItemID(acquireData, itemID)
  local target = tonumber(itemID) or 0
  if target <= 0 then
    return false
  end
  local groupsByMode = acquireData and acquireData.groupsByMode
  if type(groupsByMode) ~= "table" then
    return false
  end
  for _, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        for _, row in ipairs(type(group) == "table" and group.items or {}) do
          if tonumber(type(row) == "table" and row.itemID) == target then
            return true
          end
        end
      end
    end
  end
  return false
end

local function AcquireDataHasItemIDInMode(acquireData, mode, itemID)
  local target = tonumber(itemID) or 0
  if target <= 0 then
    return false
  end
  local groupsByMode = acquireData and acquireData.groupsByMode
  local groups = type(groupsByMode) == "table" and groupsByMode[mode] or nil
  if type(groups) ~= "table" then
    return false
  end
  for _, group in ipairs(groups) do
    for _, row in ipairs(type(group) == "table" and group.items or {}) do
      if tonumber(type(row) == "table" and row.itemID) == target then
        return true
      end
    end
  end
  return false
end

local function RunQuestUnlockCoverageAudit(acquireData)
  local unlocks = type(ns.staticQuestUnlocks) == "table" and ns.staticQuestUnlocks or {}
  local rewards = type(ns.staticQuestRewards) == "table" and ns.staticQuestRewards or {}
  local rawScope = {}
  local inScope = {}
  local total = 0
  local skippedNonVendor = 0
  local missingQuest = {}
  local missingAny = {}
  local missingVendor = {}

  for itemID in pairs(unlocks) do
    local numericID = tonumber(itemID) or 0
    if numericID > 0 then
      rawScope[numericID] = true
    end
  end
  for itemID, reward in pairs(rewards) do
    local numericID = tonumber(itemID) or 0
    local sourceType = tonumber(type(reward) == "table" and reward.sourceType) or 0
    if numericID > 0 and sourceType == 4 then
      rawScope[numericID] = true
    end
  end

  for itemID in pairs(rawScope) do
    if IsQuestUnlockBackfillCandidate(itemID) then
      inScope[itemID] = true
    else
      skippedNonVendor = skippedNonVendor + 1
    end
  end

  for itemID in pairs(inScope) do
    total = total + 1
    if not AcquireDataHasItemIDInMode(acquireData, "quest", itemID) then
      missingQuest[#missingQuest + 1] = itemID
    end
    if not AcquireDataHasItemID(acquireData, itemID) then
      missingAny[#missingAny + 1] = itemID
    end
    if not AcquireDataHasItemIDInMode(acquireData, "vendor", itemID) then
      missingVendor[#missingVendor + 1] = itemID
    end
  end

  table.sort(missingQuest)
  table.sort(missingAny)
  table.sort(missingVendor)

  print("|cff66ccffDecorDrops|r Unlock audit: " .. tostring(total) .. " vendor-unlock candidates checked, skipped non-vendor unlock-like entries = " .. tostring(skippedNonVendor) .. ".")
  print("|cff66ccffDecorDrops|r Unlock audit: missing in quest mode = " .. tostring(#missingQuest) .. ", missing in vendor mode = " .. tostring(#missingVendor) .. ", missing in all modes = " .. tostring(#missingAny) .. ".")

  if #missingQuest > 0 then
    local preview = {}
    for i = 1, math.min(20, #missingQuest) do
      preview[#preview + 1] = tostring(missingQuest[i])
    end
    print("|cff66ccffDecorDrops|r Missing quest itemIDs: " .. table.concat(preview, ", "))
  end
  if #missingAny > 0 then
    local preview = {}
    for i = 1, math.min(20, #missingAny) do
      preview[#preview + 1] = tostring(missingAny[i])
    end
    print("|cff66ccffDecorDrops|r Missing from all modes itemIDs: " .. table.concat(preview, ", "))
  end
  if #missingVendor > 0 then
    local preview = {}
    for i = 1, math.min(20, #missingVendor) do
      preview[#preview + 1] = tostring(missingVendor[i])
    end
    print("|cff66ccffDecorDrops|r Missing vendor itemIDs: " .. table.concat(preview, ", "))
  end
end

local function PurgeQuestUnlockRowsOutsideVendorScope(acquireData)
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  local questGroups = type(groupsByMode) == "table" and groupsByMode.quest or nil
  if type(questGroups) ~= "table" then
    return 0
  end

  local removed = 0
  for gi = #questGroups, 1, -1 do
    local group = questGroups[gi]
    local items = type(group) == "table" and type(group.items) == "table" and group.items or nil
    if type(items) == "table" then
      for ii = #items, 1, -1 do
        local row = items[ii]
        local isQuestUnlock = tostring(type(row) == "table" and row.acquireStatus or "") == QUEST_UNLOCK_STATUS_TEXT
        local itemID = tonumber(type(row) == "table" and row.itemID) or 0
        if isQuestUnlock and itemID > 0 and not IsQuestUnlockBackfillCandidate(itemID) then
          table.remove(items, ii)
          removed = removed + 1
        end
      end
      if type(group.itemSeen) == "table" then
        wipe(group.itemSeen)
        for _, row in ipairs(items) do
          local itemID = tonumber(type(row) == "table" and row.itemID) or 0
          if itemID > 0 then
            group.itemSeen[itemID] = true
          end
        end
      end
      if #items == 0 then
        table.remove(questGroups, gi)
      end
    end
  end
  return removed
end

EnsureVendorItemSeen = function(group)
  group.itemSeen = group.itemSeen or {}
  if next(group.itemSeen) ~= nil then
    return
  end
  for _, row in ipairs(group.items or {}) do
    local itemID = tonumber(type(row) == "table" and row.itemID)
    if itemID and itemID > 0 then
      group.itemSeen[itemID] = true
    end
  end
end

local function BuildCostsForMerchantIndex(index)
  local costs = {}
  local staticCosts = ns.staticVendorCosts and ns.staticVendorCosts.byItemID or nil
  local link = GetMerchantItemLink and GetMerchantItemLink(index)
  local itemID = tonumber(type(link) == "string" and link:match("item:(%d+)") or nil)
  if itemID and itemID > 0 and type(staticCosts) == "table" and type(staticCosts[itemID]) == "table" then
    return DeepCopy(staticCosts[itemID]), itemID
  end

  local seen = {}
  local function pushCost(entry)
    if type(entry) ~= "table" then
      return
    end
    local sig = ""
    local costType = tostring(entry.costType or "")
    if costType == "gold" then
      sig = "g:" .. tostring(tonumber(entry.copper) or 0)
    elseif costType == "currency" then
      sig = "c:" .. tostring(tonumber(entry.currencyID) or 0) .. ":" .. tostring(tonumber(entry.quantity) or 0)
    elseif costType == "item" then
      sig = "i:" .. tostring(tonumber(entry.itemID) or 0) .. ":" .. tostring(tonumber(entry.quantity) or 0)
    end
    if sig ~= "" and not seen[sig] then
      seen[sig] = true
      costs[#costs + 1] = entry
    end
  end

  if GetMerchantItemCostInfo and GetMerchantItemCostItem then
    local numCosts = tonumber(GetMerchantItemCostInfo(index)) or 0
    for costIndex = 1, numCosts do
      local texture, amount, costLink = GetMerchantItemCostItem(index, costIndex)
      local quantity = tonumber(amount) or 0
      if quantity > 0 then
        local linkText = tostring(costLink or "")
        local currencyID = tonumber(linkText:match("currency:(%d+)"))
        if currencyID and currencyID > 0 then
          pushCost({
            costType = "currency",
            currencyID = currencyID,
            quantity = quantity,
          })
        else
          local costItemID = tonumber(linkText:match("item:(%d+)"))
          if costItemID and costItemID > 0 then
            pushCost({
              costType = "item",
              itemID = costItemID,
              quantity = quantity,
            })
          end
        end
      end
    end
  end

  local price = select(3, GetMerchantItemInfo and GetMerchantItemInfo(index) or nil)
  local copper = tonumber(price) or 0
  if copper > 0 then
    pushCost({ costType = "gold", copper = copper })
  end
  return costs, itemID
end

local function FindSampleRowForItem(acquireData, itemID)
  local target = tonumber(itemID) or 0
  if target <= 0 then
    return nil, nil
  end
  local groupsByMode = acquireData and acquireData.groupsByMode
  if type(groupsByMode) ~= "table" then
    return nil, nil
  end
  local modePriority = { "vendor", "reputation", "quest", "achievement", "endeavor", "instances", "professions" }
  for _, mode in ipairs(modePriority) do
    local groups = groupsByMode[mode]
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        for _, row in ipairs(type(group) == "table" and group.items or {}) do
          if tonumber(type(row) == "table" and row.itemID) == target then
            return row, group
          end
        end
      end
    end
  end
  for _, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        for _, row in ipairs(type(group) == "table" and group.items or {}) do
          if tonumber(type(row) == "table" and row.itemID) == target then
            return row, group
          end
        end
      end
    end
  end
  return nil, nil
end

local function EnsureQuestUnlockRow(acquireData, itemID)
  local rewards = type(ns.staticQuestRewards) == "table" and ns.staticQuestRewards or nil
  local unlocks = type(ns.staticQuestUnlocks) == "table" and ns.staticQuestUnlocks or nil
  local unlock = rewards and rewards[tonumber(itemID) or 0] or nil
  if type(unlock) ~= "table" then
    unlock = unlocks and unlocks[tonumber(itemID) or 0] or nil
  end
  if type(unlock) ~= "table" or tostring(unlock.name or "") == "" then
    return false
  end
  local sampleRow, sampleGroup = FindSampleRowForItem(acquireData, itemID)
  local catalogInfo = ns.GetStaticDecorInfo and ns.GetStaticDecorInfo(itemID) or nil

  local qExpansion = tostring(unlock.expansion
    or (sampleRow and sampleRow.expansion)
    or (sampleGroup and sampleGroup.expansion)
    or (catalogInfo and catalogInfo.expansion)
    or "Other")
  local qLabel = tostring(unlock.name or "Quest Unlock")
  local qDetail = tostring(unlock.detail
    or (sampleRow and sampleRow.sourceDetail)
    or (sampleRow and sampleRow.zone)
    or (sampleGroup and sampleGroup.detail)
    or "")
  local wpMapID = tonumber(sampleRow and sampleRow.mapID) or tonumber(sampleGroup and sampleGroup.waypoint and sampleGroup.waypoint.mapID) or 0
  local wpX = tonumber(sampleRow and sampleRow.x) or tonumber(sampleGroup and sampleGroup.waypoint and sampleGroup.waypoint.x) or nil
  local wpY = tonumber(sampleRow and sampleRow.y) or tonumber(sampleGroup and sampleGroup.waypoint and sampleGroup.waypoint.y) or nil
  local wpZone = tostring((sampleRow and sampleRow.zone) or (sampleGroup and sampleGroup.detail) or qDetail or "")

  local qKey = string.lower(table.concat({
    qExpansion, qLabel, qDetail, wpZone, tostring(wpMapID), "quest",
  }, "|"))
  local questGroup = EnsureModeGroupFromTemplate(acquireData.groupsByMode, "quest", {
    key = qKey,
    mode = "quest",
    category = "Quest",
    expansion = qExpansion,
    label = qLabel,
    detail = qDetail,
    waypoint = {
      mapID = wpMapID > 0 and wpMapID or nil,
      x = wpX,
      y = wpY,
      zone = wpZone ~= "" and wpZone or nil,
      npcID = tonumber(sampleRow and sampleRow.npcID) or nil,
      npcName = tostring((sampleRow and sampleRow.npcName) or ""),
      title = tostring((sampleRow and sampleRow.npcName) or qLabel),
    },
  })

  local questRow = {
    itemID = tonumber(itemID) or 0,
    sourceType = 3,
    sourceName = qLabel,
    sourceDetail = qDetail,
    source = qLabel,
    category = "Quest",
    expansion = qExpansion,
    mapID = wpMapID > 0 and wpMapID or nil,
    x = wpX,
    y = wpY,
    npcID = tonumber(sampleRow and sampleRow.npcID) or nil,
    npcName = tostring((sampleRow and sampleRow.npcName) or ""),
    zone = wpZone,
    faction = "",
    factionName = "",
    factionID = 0,
    minRep = 0,
    questID = tonumber(sampleRow and sampleRow.questID) or 0,
    achievementID = 0,
    repStandingText = "",
    purchaseCosts = {},
  }
  local existing = FindRowByItemID(questGroup.items, itemID)
  if existing then
    local changed = false
    local function setIfDiff(field, value)
      if existing[field] ~= value then
        existing[field] = value
        changed = true
      end
    end
    setIfDiff("sourceType", questRow.sourceType)
    setIfDiff("sourceName", questRow.sourceName)
    setIfDiff("sourceDetail", questRow.sourceDetail)
    setIfDiff("source", questRow.source)
    setIfDiff("category", "Quest")
    setIfDiff("expansion", questRow.expansion)
    setIfDiff("mapID", questRow.mapID)
    setIfDiff("x", questRow.x)
    setIfDiff("y", questRow.y)
    setIfDiff("npcID", questRow.npcID)
    setIfDiff("npcName", questRow.npcName)
    setIfDiff("zone", questRow.zone)
    if type(existing.purchaseCosts) ~= "table" or #existing.purchaseCosts > 0 then
      existing.purchaseCosts = {}
      changed = true
    end
    existing.acquireStatus = "Quest Unlock"
    return changed
  end
  questRow.acquireStatus = "Quest Unlock"
  return AddRowToGroup(questGroup, questRow)
end

local function EnsureQuestUnlockVendorRow(acquireData, itemID)
  if type(acquireData) ~= "table" or type(acquireData.groupsByMode) ~= "table" then
    return false
  end

  local itemKey = tonumber(itemID) or 0
  if itemKey <= 0 then
    return false
  end

  local override = QUEST_UNLOCK_VENDOR_OVERRIDES[itemKey]
  if type(override) ~= "table" then
    return false
  end

  local rewards = type(ns.staticQuestRewards) == "table" and ns.staticQuestRewards or nil
  local unlocks = type(ns.staticQuestUnlocks) == "table" and ns.staticQuestUnlocks or nil
  local unlock = rewards and rewards[itemKey] or nil
  if type(unlock) ~= "table" then
    unlock = unlocks and unlocks[itemKey] or nil
  end

  local expansion = tostring(override.expansion or (unlock and unlock.expansion) or "Other")
  local sourceName = tostring((unlock and unlock.name) or override.sourceName or "Quest Unlock")
  local sourceDetail = tostring((unlock and unlock.detail) or override.sourceDetail or override.zone or "")
  local npcName = tostring(override.npcName or "")
  local mapID = tonumber(override.mapID) or 0
  local zoneName = tostring(override.zone or sourceDetail or "")

  if npcName == "" or zoneName == "" or mapID <= 0 then
    return false
  end

  local groupKey = string.lower(table.concat({
    expansion, npcName, zoneName, zoneName, tostring(mapID), "vendor",
  }, "|"))
  local group = EnsureModeGroupFromTemplate(acquireData.groupsByMode, "vendor", {
    key = groupKey,
    mode = "vendor",
    category = "Vendor",
    expansion = expansion,
    label = npcName,
    detail = zoneName,
    waypoint = {
      mapID = mapID,
      x = tonumber(override.x),
      y = tonumber(override.y),
      zone = zoneName,
      npcID = tonumber(override.npcID),
      npcName = npcName,
      title = npcName,
    },
  })

  local row = FindRowByItemID(group.items, itemKey)
  local desiredPurchaseCosts = type(override.purchaseCosts) == "table" and DeepCopy(override.purchaseCosts) or {}
  local desired = {
    itemID = itemKey,
    sourceType = tonumber(override.sourceType) or 3,
    sourceName = sourceName,
    sourceDetail = sourceDetail,
    source = sourceName,
    category = "Vendor",
    expansion = expansion,
    mapID = mapID,
    x = tonumber(override.x),
    y = tonumber(override.y),
    npcID = tonumber(override.npcID),
    npcName = npcName,
    zone = zoneName,
    faction = tostring(override.faction or ""),
    factionName = tostring(override.factionName or ""),
    factionID = tonumber(override.factionID) or 0,
    minRep = tonumber(override.minRep) or 0,
    questID = 0,
    achievementID = 0,
    repStandingText = tostring(override.repStandingText or ""),
    purchaseCosts = desiredPurchaseCosts,
  }

  if row then
    local changed = false
    local function setField(field, value)
      if row[field] ~= value then
        row[field] = value
        changed = true
      end
    end
    setField("sourceType", desired.sourceType)
    setField("sourceName", desired.sourceName)
    setField("sourceDetail", desired.sourceDetail)
    setField("source", desired.source)
    setField("category", desired.category)
    setField("expansion", desired.expansion)
    setField("mapID", desired.mapID)
    setField("x", desired.x)
    setField("y", desired.y)
    setField("npcID", desired.npcID)
    setField("npcName", desired.npcName)
    setField("zone", desired.zone)
    setField("faction", desired.faction)
    setField("factionName", desired.factionName)
    setField("factionID", desired.factionID)
    setField("minRep", desired.minRep)
    setField("questID", 0)
    setField("achievementID", 0)
    setField("repStandingText", desired.repStandingText)
    if type(row.purchaseCosts) ~= "table" or #row.purchaseCosts == 0 then
      row.purchaseCosts = DeepCopy(desired.purchaseCosts)
      changed = true
    end
    if changed then
      row.acquireStatus = BuildAcquireItemStatus(row)
    end
    return changed
  end

  return AddRowToGroup(group, desired)
end

local function BackfillQuestUnlockRows(acquireData)
  if type(acquireData) ~= "table" or type(acquireData.groupsByMode) ~= "table" then
    return 0
  end
  local unlocks = type(ns.staticQuestUnlocks) == "table" and ns.staticQuestUnlocks or {}
  local rewards = type(ns.staticQuestRewards) == "table" and ns.staticQuestRewards or {}
  if next(unlocks) == nil and next(rewards) == nil then
    return 0
  end
  local added = 0
  local function tryItem(itemID)
    if not IsQuestUnlockBackfillCandidate(itemID) then
      return
    end
    local hasAny = AcquireDataHasItemID(acquireData, itemID)
    local hasCatalog = (ns.GetStaticDecorInfo and ns.GetStaticDecorInfo(itemID)) and true or false
    local hasVendorOverride = EnsureQuestUnlockVendorRow(acquireData, itemID) and true or false
    if (hasAny or hasCatalog or hasVendorOverride) and EnsureQuestUnlockRow(acquireData, itemID) then
      added = added + 1
    end
  end
  for itemID, _ in pairs(rewards) do
    tryItem(itemID)
  end
  for itemID, _ in pairs(unlocks) do
    tryItem(itemID)
  end
  return added
end
ns.BackfillQuestUnlockRows = BackfillQuestUnlockRows

local function FindQuestGroupByQuestID(acquireData, questID)
  local qid = tonumber(questID) or 0
  if qid <= 0 then
    return nil
  end
  local questGroups = acquireData and acquireData.groupsByMode and acquireData.groupsByMode.quest
  if type(questGroups) ~= "table" then
    return nil
  end
  for _, group in ipairs(questGroups) do
    for _, row in ipairs(type(group) == "table" and group.items or {}) do
      if tonumber(type(row) == "table" and row.questID) == qid then
        return group
      end
    end
  end
  return nil
end

local function BackfillQuestRowsFromExistingQuestIDs(acquireData)
  if type(acquireData) ~= "table" or type(acquireData.groupsByMode) ~= "table" then
    return 0
  end
  local groupsByMode = acquireData.groupsByMode
  local added = 0
  local generatedGroupByQuestID = {}

  local function ensureGeneratedGroup(row, group, questID)
    local qid = tonumber(questID) or 0
    if qid <= 0 then
      return nil
    end
    if generatedGroupByQuestID[qid] then
      return generatedGroupByQuestID[qid]
    end

    local qExpansion = tostring((row and row.expansion) or (group and group.expansion) or "Other")
    local qLabel = tostring((row and row.sourceName) or (row and row.source) or ("Quest " .. tostring(qid)))
    local qDetail = tostring((row and row.sourceDetail) or (row and row.zone) or (group and group.detail) or "")
    local wpMapID = tonumber(row and row.mapID) or tonumber(group and group.waypoint and group.waypoint.mapID) or 0
    local wpX = tonumber(row and row.x) or tonumber(group and group.waypoint and group.waypoint.x) or nil
    local wpY = tonumber(row and row.y) or tonumber(group and group.waypoint and group.waypoint.y) or nil
    local wpZone = tostring((row and row.zone) or (group and group.detail) or qDetail or "")
    local qKey = string.lower(table.concat({
      qExpansion, "quest-id", tostring(qid), qDetail, wpZone, tostring(wpMapID), "quest",
    }, "|"))

    local questGroup = EnsureModeGroupFromTemplate(groupsByMode, "quest", {
      key = qKey,
      mode = "quest",
      category = "Quest",
      expansion = qExpansion,
      label = qLabel,
      detail = qDetail,
      waypoint = {
        mapID = wpMapID > 0 and wpMapID or nil,
        x = wpX,
        y = wpY,
        zone = wpZone ~= "" and wpZone or nil,
        npcID = tonumber(row and row.npcID) or nil,
        npcName = tostring((row and row.npcName) or ""),
        title = tostring((row and row.npcName) or qLabel),
      },
    })
    generatedGroupByQuestID[qid] = questGroup
    return questGroup
  end

  for mode, groups in pairs(groupsByMode) do
    local shouldUseMode = mode ~= "quest" and mode ~= "instances" and mode ~= "professions"
    if shouldUseMode and type(groups) == "table" then
      for _, group in ipairs(groups) do
        for _, row in ipairs(type(group) == "table" and group.items or {}) do
          local qid = tonumber(type(row) == "table" and row.questID) or 0
          local itemID = tonumber(type(row) == "table" and row.itemID) or 0
          if qid > 0 and itemID > 0 then
            local questGroup = FindQuestGroupByQuestID(acquireData, qid) or ensureGeneratedGroup(row, group, qid)
            if questGroup then
              local questRow = {
                itemID = itemID,
                sourceType = 3,
                sourceName = tostring(row.sourceName or row.source or ("Quest " .. tostring(qid))),
                sourceDetail = tostring(row.sourceDetail or row.zone or (group and group.detail) or ""),
                source = tostring(row.source or row.sourceName or ("Quest " .. tostring(qid))),
                category = "Quest",
                expansion = tostring(row.expansion or (group and group.expansion) or "Other"),
                mapID = tonumber(row.mapID) or nil,
                x = tonumber(row.x) or nil,
                y = tonumber(row.y) or nil,
                npcID = tonumber(row.npcID) or nil,
                npcName = tostring(row.npcName or ""),
                zone = tostring(row.zone or (group and group.detail) or ""),
                faction = "",
                factionName = "",
                factionID = 0,
                minRep = 0,
                questID = qid,
                achievementID = 0,
                repStandingText = "",
                purchaseCosts = {},
                acquireStatus = tostring(row.acquireStatus or QUEST_UNLOCK_STATUS_TEXT),
              }
              if AddRowToGroup(questGroup, questRow) then
                added = added + 1
              end
            end
          end
        end
      end
    end
  end

  return added
end
ns.BackfillQuestRowsFromExistingQuestIDs = BackfillQuestRowsFromExistingQuestIDs

PurgeNonQuestSourceRowsFromQuestMode = function(acquireData)
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  local questGroups = type(groupsByMode) == "table" and groupsByMode.quest or nil
  if type(questGroups) ~= "table" then
    return 0
  end

  local removed = 0
  for gi = #questGroups, 1, -1 do
    local group = questGroups[gi]
    local items = type(group) == "table" and type(group.items) == "table" and group.items or nil
    if type(items) == "table" then
      for ii = #items, 1, -1 do
        local row = items[ii]
        local sourceType = tonumber(type(row) == "table" and row.sourceType) or 0
        if sourceType == 4 then
          table.remove(items, ii)
          removed = removed + 1
        end
      end
      if type(group.itemSeen) == "table" then
        wipe(group.itemSeen)
        for _, row in ipairs(items) do
          local itemID = tonumber(type(row) == "table" and row.itemID) or 0
          if itemID > 0 then
            group.itemSeen[itemID] = true
          end
        end
      end
      if #items == 0 then
        table.remove(questGroups, gi)
      end
    end
  end
  return removed
end

local function SyncVendorItemsFromMerchant()
  if not DecorDropsDB or type(DecorDropsDB.acquireData) ~= "table" then
    return
  end
  local groupsByMode = DecorDropsDB.acquireData.groupsByMode
  if type(groupsByMode) ~= "table" or type(groupsByMode.vendor) ~= "table" then
    return
  end

  local npcName = UnitName and tostring(UnitName("npc") or "") or ""
  local npcID = ParseNPCIDFromGUID(UnitGUID and UnitGUID("npc") or nil)
  if npcName == "" or not npcID then
    return
  end

  local group = FindVendorGroupByNPCID(DecorDropsDB.acquireData, npcID)
  if not group then
    return
  end

  EnsureVendorItemSeen(group)
  local mapID, zoneName, px, py = GetPlayerMapContext()
  local count = GetMerchantNumItems and tonumber(GetMerchantNumItems()) or 0
  if count <= 0 then
    return
  end

  local catalog = ns.staticDecorCatalog and ns.staticDecorCatalog.byItemID or nil
  local staticTemplates = type(ns.staticAcquireData) == "table" and ns.staticAcquireData or nil
  local staticQuestUnlocks = type(ns.staticQuestUnlocks) == "table" and ns.staticQuestUnlocks or {}
  local added = 0
  local updated = 0
  local questAdded = 0
  for i = 1, count do
    local costs, itemID = BuildCostsForMerchantIndex(i)
    if itemID and itemID > 0 then
      local inCatalog = type(catalog) == "table" and type(catalog[itemID]) == "table"
      if inCatalog or AcquireDataHasItemID(DecorDropsDB.acquireData, itemID) then
        local templates = BuildAcquireItemTemplatesByMode(staticTemplates, itemID)
        if not HasAnyAcquireTemplates(templates) then
          templates = BuildAcquireItemTemplatesByMode(DecorDropsDB.acquireData, itemID)
        end
        if not HasAnyAcquireTemplates(templates) then
          local unlock = staticQuestUnlocks[itemID]
          if type(unlock) == "table" and tostring(unlock.name or "") ~= "" then
            local qExpansion = tostring(unlock.expansion or group.expansion or "Other")
            local qLabel = tostring(unlock.name or "Quest Unlock")
            local qDetail = tostring(unlock.detail or zoneName or group.detail or "")
            local wpMapID = mapID and mapID > 0 and mapID or tonumber(group.waypoint and group.waypoint.mapID) or 0
            local wpZone = zoneName ~= "" and zoneName or tostring(group.detail or "")
            local qKey = string.lower(table.concat({
              qExpansion,
              qLabel,
              qDetail,
              wpZone,
              tostring(wpMapID),
              "quest",
            }, "|"))
            templates.quest = {
              {
                group = {
                  key = qKey,
                  mode = "quest",
                  category = "Quest",
                  expansion = qExpansion,
                  label = qLabel,
                  detail = qDetail,
                  waypoint = {
                    mapID = wpMapID > 0 and wpMapID or nil,
                    x = px or tonumber(group.waypoint and group.waypoint.x) or nil,
                    y = py or tonumber(group.waypoint and group.waypoint.y) or nil,
                    zone = wpZone ~= "" and wpZone or nil,
                    npcID = npcID,
                    npcName = npcName,
                    title = npcName,
                  },
                },
                row = {
                  itemID = itemID,
                  sourceType = 3,
                  sourceName = qLabel,
                  sourceDetail = qDetail,
                  source = qLabel,
                  category = "Quest",
                  expansion = qExpansion,
                  mapID = wpMapID > 0 and wpMapID or nil,
                  x = px or tonumber(group.waypoint and group.waypoint.x) or nil,
                  y = py or tonumber(group.waypoint and group.waypoint.y) or nil,
                  npcID = npcID,
                  npcName = npcName,
                  zone = wpZone,
                  faction = "",
                  factionName = "",
                  factionID = 0,
                  minRep = 0,
                  questID = 0,
                  achievementID = 0,
                  repStandingText = "",
                  purchaseCosts = DeepCopy(costs),
                },
              },
            }
          end
        end
        local vendorTemplate = templates.vendor and templates.vendor[1] and templates.vendor[1].row or nil
        local row = FindRowByItemID(group.items, itemID)
        if row then
          local changed = false
          if vendorTemplate and type(vendorTemplate) == "table" then
            local function mergeIfMissing(field)
              if (row[field] == nil or row[field] == "" or row[field] == 0) and vendorTemplate[field] ~= nil and vendorTemplate[field] ~= "" and vendorTemplate[field] ~= 0 then
                row[field] = DeepCopy(vendorTemplate[field])
                changed = true
              end
            end
            mergeIfMissing("sourceType")
            mergeIfMissing("sourceName")
            mergeIfMissing("sourceDetail")
            mergeIfMissing("source")
            mergeIfMissing("faction")
            mergeIfMissing("factionName")
            mergeIfMissing("factionID")
            mergeIfMissing("minRep")
            mergeIfMissing("questID")
            mergeIfMissing("achievementID")
          end
          if (type(row.purchaseCosts) ~= "table" or #row.purchaseCosts == 0) and type(costs) == "table" and #costs > 0 then
            row.purchaseCosts = DeepCopy(costs)
            changed = true
          end
          if row.repStandingText == nil or row.repStandingText == "" then
            local repText = BuildRepStandingText(tonumber(row.minRep) or 0)
            if repText ~= "" then
              row.repStandingText = repText
              changed = true
            end
          end
          local unlock = staticQuestUnlocks[itemID]
          if type(unlock) == "table" and tostring(unlock.name or "") ~= "" then
            local minRep = tonumber(row.minRep) or 0
            local factionID = tonumber(row.factionID) or 0
            local questID = tonumber(row.questID) or 0
            if minRep <= 0 and factionID <= 0 and questID <= 0 then
              row.sourceType = 4
              row.sourceName = tostring(unlock.name or row.sourceName or "")
              row.source = row.sourceName ~= "" and row.sourceName or row.source
              row.sourceDetail = tostring(unlock.detail or row.sourceDetail or row.zone or "")
              if row.acquireStatus == nil or row.acquireStatus == "" or row.acquireStatus == "Vendor" then
                row.acquireStatus = QUEST_UNLOCK_STATUS_TEXT
              end
              changed = true
            end
          end
          if row.zone == nil or row.zone == "" then
            row.zone = zoneName ~= "" and zoneName or tostring(group.detail or "")
            changed = true
          end
          if changed then
            row.acquireStatus = BuildAcquireItemStatus(row)
            updated = updated + 1
          end
        else
          row = vendorTemplate and DeepCopy(vendorTemplate) or {
          itemID = itemID,
          sourceType = 5,
          sourceName = npcName,
          sourceDetail = zoneName ~= "" and zoneName or tostring(group.detail or ""),
          source = npcName,
          category = "Vendor",
          expansion = tostring(group.expansion or (catalog[itemID] and catalog[itemID].expansion) or "Other"),
          mapID = mapID and mapID > 0 and mapID or tonumber(group.waypoint and group.waypoint.mapID) or nil,
          x = px or tonumber(group.waypoint and group.waypoint.x) or nil,
          y = py or tonumber(group.waypoint and group.waypoint.y) or nil,
          npcID = npcID,
          npcName = npcName,
          zone = zoneName ~= "" and zoneName or tostring(group.detail or ""),
          faction = "",
          factionName = "",
          factionID = 0,
          minRep = 0,
          questID = 0,
          achievementID = 0,
          repStandingText = "",
          purchaseCosts = costs,
        }
          row.itemID = itemID
          row.category = "Vendor"
          row.expansion = tostring(row.expansion or group.expansion or (catalog[itemID] and catalog[itemID].expansion) or "Other")
          row.mapID = mapID and mapID > 0 and mapID or tonumber(group.waypoint and group.waypoint.mapID) or row.mapID
          row.x = px or tonumber(group.waypoint and group.waypoint.x) or row.x
          row.y = py or tonumber(group.waypoint and group.waypoint.y) or row.y
          row.npcID = npcID
          row.npcName = npcName
          row.zone = zoneName ~= "" and zoneName or tostring(group.detail or row.zone or "")
          row.faction = row.faction or ""
          row.factionName = row.factionName or ""
          row.factionID = tonumber(row.factionID) or 0
          row.minRep = tonumber(row.minRep) or 0
          row.questID = tonumber(row.questID) or 0
          row.achievementID = tonumber(row.achievementID) or 0
          row.purchaseCosts = (#(row.purchaseCosts or {}) > 0) and row.purchaseCosts or costs
          row.repStandingText = BuildRepStandingText(row.minRep)
          if row.sourceName == nil or row.sourceName == "" then
            row.sourceName = npcName
          end
          if row.source == nil or row.source == "" then
            row.source = row.sourceName
          end
          if row.sourceDetail == nil or row.sourceDetail == "" then
            row.sourceDetail = row.zone
          end
          row.acquireStatus = BuildAcquireItemStatus(row)
          group.items[#group.items + 1] = row
          group.itemSeen[itemID] = true
          added = added + 1
        end

        -- Preserve multi-source visibility: same item can appear in quest/achievement/reputation pages too.
        for _, mode in ipairs({ "quest", "achievement", "reputation", "endeavor" }) do
          local modeTemplates = templates[mode]
          if type(modeTemplates) == "table" then
            for _, tpl in ipairs(modeTemplates) do
              local targetGroup = EnsureModeGroupFromTemplate(groupsByMode, mode, tpl.group)
              AddRowToGroup(targetGroup, tpl.row)
            end
          end
        end
        if EnsureQuestUnlockRow(DecorDropsDB.acquireData, itemID) then
          questAdded = questAdded + 1
        end
      end
    end
  end

  -- Deterministic quest-unlock backfill for all known unlock-capable rows in local acquire data.
  questAdded = questAdded + BackfillQuestUnlockRows(DecorDropsDB.acquireData)
  questAdded = questAdded + BackfillQuestRowsFromExistingQuestIDs(DecorDropsDB.acquireData)
  local purgedQuestSourceRows = PurgeNonQuestSourceRowsFromQuestMode(DecorDropsDB.acquireData)
  local repNormalized = NormalizeAcquireReputationData(DecorDropsDB.acquireData)
  local purgedStaleGoldRows = PurgeStaleResonanceGoldRows(DecorDropsDB.acquireData)

  if added > 0 or updated > 0 or questAdded > 0 or repNormalized > 0 or purgedQuestSourceRows > 0 or purgedStaleGoldRows > 0 then
    for _, groups in pairs(groupsByMode) do
      if type(groups) == "table" then
        for _, g in ipairs(groups) do
          table.sort(g.items or {}, function(a, b)
            return (tonumber(a and a.itemID) or 0) < (tonumber(b and b.itemID) or 0)
          end)
        end
      end
    end
    DecorDropsDB.acquireData.source = "StaticAcquireData"
    DecorDropsDB.acquireData.importedAt = time and time() or DecorDropsDB.acquireData.importedAt
    DecorDropsDB.acquireData.totalItems = CountAcquireRows(DecorDropsDB.acquireData)
    print("|cff66ccffDecorDrops|r Vendor sync: added " .. tostring(added) .. ", updated " .. tostring(updated) .. ", quest-linked " .. tostring(questAdded) .. ", quest-pruned " .. tostring(purgedQuestSourceRows) .. ", rep-fixed " .. tostring(repNormalized) .. ", stale-gold-fixed " .. tostring(purgedStaleGoldRows) .. " item(s) for " .. npcName .. ".")
    ns.SafeRefreshMainFrame()
  end
end

local function RebuildAcquireDataFromLocal()
  local ok, msg = SeedAcquireDataFromStatic(true)
  if not ok then
    return ok, msg
  end
  if type(DecorDropsDB) ~= "table" or type(DecorDropsDB.acquireData) ~= "table" then
    return true, msg
  end

  local seededQuestUnlocks = BackfillQuestUnlockRows(DecorDropsDB.acquireData)
  local seededQuestIDs = BackfillQuestRowsFromExistingQuestIDs(DecorDropsDB.acquireData)
  local purgedNonVendorQuestUnlockRows = PurgeQuestUnlockRowsOutsideVendorScope(DecorDropsDB.acquireData)
  local purgedQuestSourceRows = PurgeNonQuestSourceRowsFromQuestMode(DecorDropsDB.acquireData)
  local normalizedRepRows = NormalizeAcquireReputationData(DecorDropsDB.acquireData)
  local purgedStaleGoldRows = PurgeStaleResonanceGoldRows(DecorDropsDB.acquireData)

  if seededQuestUnlocks > 0 or seededQuestIDs > 0 or purgedNonVendorQuestUnlockRows > 0 or purgedQuestSourceRows > 0 or normalizedRepRows > 0 or purgedStaleGoldRows > 0 then
    DecorDropsDB.acquireData.importedAt = time and time() or DecorDropsDB.acquireData.importedAt
    DecorDropsDB.acquireData.totalItems = CountAcquireRows(DecorDropsDB.acquireData)
  end

  local suffix = string.format(
    " Backfill: quest unlock rows %d, questID rows %d, non-vendor quest-unlock pruned rows %d, quest-pruned rows %d, rep rows %d, stale-gold rows %d.",
    tonumber(seededQuestUnlocks) or 0,
    tonumber(seededQuestIDs) or 0,
    tonumber(purgedNonVendorQuestUnlockRows) or 0,
    tonumber(purgedQuestSourceRows) or 0,
    tonumber(normalizedRepRows) or 0,
    tonumber(purgedStaleGoldRows) or 0
  )
  return true, tostring(msg or "Acquire data rebuilt.") .. suffix
end
ns.RebuildAcquireDataFromLocal = RebuildAcquireDataFromLocal

local function BuildPriceReminderMessage()
  local hasAuctionator = ns.IsAuctionatorAvailable and ns.IsAuctionatorAvailable() or false
  local hasIndex = ns.HasAuctionatorPriceIndex and ns.HasAuctionatorPriceIndex() or false

  if not hasAuctionator then
    return "DecorDrops price tracking: Auctionator is not detected. Install Auctionator, then run an Auction House scan to populate pricing fields."
  end

  if not hasIndex then
    return "DecorDrops price tracking: No Auctionator pricing index found yet. Run an Auction House scan to populate pricing fields."
  end

  return "DecorDrops reminder: pricing may be outdated. Run an Auction House scan in Auctionator to refresh crafted total-cost fields."
end

function ns.PositionPopupOverMainFrame(popupFrame)
  if type(popupFrame) ~= "table" then
    return
  end

  local mainFrame = ns.MainFrame
  local anchorFrame = (mainFrame and mainFrame.IsShown and mainFrame:IsShown()) and mainFrame or UIParent

  if popupFrame.SetParent then
    popupFrame:SetParent(UIParent)
  end
  if popupFrame.SetFrameStrata then
    popupFrame:SetFrameStrata("FULLSCREEN_DIALOG")
  end
  if popupFrame.SetToplevel then
    popupFrame:SetToplevel(true)
  end
  if popupFrame.SetFrameLevel then
    local anchorLevel = (anchorFrame and anchorFrame.GetFrameLevel and anchorFrame:GetFrameLevel()) or 0
    local popupLevel = (popupFrame.GetFrameLevel and popupFrame:GetFrameLevel()) or 0
    popupFrame:SetFrameLevel(math.max(anchorLevel + 30, popupLevel))
  end

  if popupFrame.ClearAllPoints and popupFrame.SetPoint then
    popupFrame:ClearAllPoints()
    popupFrame:SetPoint("CENTER", anchorFrame, "CENTER", 0, 0)
  end
  if popupFrame.Raise then
    popupFrame:Raise()
  end
end

local function MaybeShowPriceReminder(forceShow)
  if not DecorDropsDB or not DecorDropsDB.settings then
    return
  end

  local now = time and time() or nil
  if not now then
    return
  end

  local settings = DecorDropsDB.settings
  if not settings.priceReminderFirstLoginAt then
    settings.priceReminderFirstLoginAt = now
  end
  if settings.enablePriceReminders == false then
    return
  end

  local intervalDays = tonumber(settings.priceReminderDays) or 30
  if intervalDays < 7 then intervalDays = 7 end
  if intervalDays > 180 then intervalDays = 180 end
  settings.priceReminderDays = intervalDays
  local intervalSeconds = intervalDays * 24 * 60 * 60

  local baseline = tonumber(settings.lastPriceReminderAt) or tonumber(settings.priceReminderFirstLoginAt) or now
  local due = (now - baseline) >= intervalSeconds
  if not settings.lastPriceReminderAt then
    due = true
  end
  if not forceShow and not due then
    return
  end

  local message = BuildPriceReminderMessage()
  if not message or message == "" then
    return
  end

  local dialogs = _G.StaticPopupDialogs
  if type(dialogs) ~= "table" then
    return
  end
  dialogs["DECORDROPS_PRICE_REMINDER"] = dialogs["DECORDROPS_PRICE_REMINDER"] or {
    button1 = "OK",
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = STATICPOPUP_NUMDIALOGS and (STATICPOPUP_NUMDIALOGS - 1) or 3,
  }
  dialogs["DECORDROPS_PRICE_REMINDER"].text = message
  local popup = StaticPopup_Show("DECORDROPS_PRICE_REMINDER")
  if popup and ns.PositionPopupOverMainFrame then
    ns.PositionPopupOverMainFrame(popup)
  end

  if not forceShow then
    settings.lastPriceReminderAt = now
  end
end
ns.MaybeShowPriceReminder = MaybeShowPriceReminder

function ns.ToggleMainFrame()
  if not ns.MainFrame and ns.CreateUI then
    local ok, err = pcall(ns.CreateUI)
    if not ok then
      print("|cffff5555DecorDrops UI error:|r " .. tostring(err))
      return
    end
  end

  if not ns.MainFrame then
    print("|cffff5555DecorDrops: UI frame not available.|r")
    return
  end

  if ns.MainFrame:IsShown() then
    ns.MainFrame:Hide()
  else
    ns.MainFrame:Show()
    if ns.MaybeShowPriceReminder then
      ns.MaybeShowPriceReminder()
    end
  end
end

local function FindButtonByText(rootFrame, targetText, depth, visited)
  if not rootFrame or not targetText then
    return nil
  end
  local rootType = type(rootFrame)
  if rootType ~= "table" and rootType ~= "userdata" then
    return nil
  end
  depth = depth or 0
  if depth > 6 then
    return nil
  end
  visited = visited or {}
  if visited[rootFrame] then
    return nil
  end
  visited[rootFrame] = true

  local objectType = nil
  if rootFrame.GetObjectType then
    local okObjectType, value = pcall(rootFrame.GetObjectType, rootFrame)
    if okObjectType then
      objectType = value
    end
  end

  if objectType == "Button" and rootFrame.GetText then
    local okText, textValue = pcall(rootFrame.GetText, rootFrame)
    local text = okText and tostring(textValue or "") or ""
    if text == targetText then
      return rootFrame
    end
  end

  local children = {}
  if rootFrame.GetChildren then
    local okChildren, value = pcall(function()
      return { rootFrame:GetChildren() }
    end)
    if okChildren and type(value) == "table" then
      children = value
    end
  end
  for _, child in ipairs(children) do
    local found = FindButtonByText(child, targetText, depth + 1, visited)
    if found then
      return found
    end
  end
  return nil
end

local function TryAttachHousingDashboardDecorDropsButton()
  local openFinderButton = nil
  local candidateFrames = {
    _G.HousingDashboardFrame,
    _G.HousingDashboard,
    _G.BlizzardHousingDashboardFrame,
    _G.HousingFrame,
  }

  for _, candidate in ipairs(candidateFrames) do
    if candidate and candidate.GetChildren then
      openFinderButton = FindButtonByText(candidate, "Open House Finder")
      if openFinderButton then
        break
      end
    end
  end

  if not openFinderButton then
    local rootFrames = { UIParent:GetChildren() }
    for _, root in ipairs(rootFrames) do
      openFinderButton = FindButtonByText(root, "Open House Finder")
      if openFinderButton then
        break
      end
    end
  end

  if not openFinderButton then
    return false
  end

  local parent = openFinderButton:GetParent()
  if not parent then
    return false
  end

  local button = ns._housingDecorDropsButton
  if not button then
    button = CreateFrame("Button", "DecorDropsHousingDashboardButton", parent, "UIPanelButtonTemplate")
    button:SetText("Open DecorDrops")
    button:SetScript("OnClick", function()
      if ns.ToggleMainFrame then
        ns.ToggleMainFrame()
      end
    end)
    ns._housingDecorDropsButton = button
  end

  button:ClearAllPoints()
  button:SetSize(openFinderButton:GetWidth(), openFinderButton:GetHeight())
  button:SetPoint("RIGHT", openFinderButton, "LEFT", -8, 0)
  button:SetParent(parent)
  button:SetFrameStrata(openFinderButton:GetFrameStrata())
  button:SetFrameLevel(math.max(1, (openFinderButton:GetFrameLevel() or 1)))
  button:Show()

  if not parent._ddHousingDecorDropsHooked then
    parent:HookScript("OnShow", function()
      C_Timer.After(0, function()
        TryAttachHousingDashboardDecorDropsButton()
      end)
    end)
    parent._ddHousingDecorDropsHooked = true
  end

  if not openFinderButton._ddHousingDecorDropsHooked then
    openFinderButton:HookScript("OnShow", function()
      C_Timer.After(0, function()
        TryAttachHousingDashboardDecorDropsButton()
      end)
    end)
    openFinderButton:HookScript("OnSizeChanged", function()
      C_Timer.After(0, function()
        TryAttachHousingDashboardDecorDropsButton()
      end)
    end)
    openFinderButton._ddHousingDecorDropsHooked = true
  end

  return true
end
ns.TryAttachHousingDashboardDecorDropsButton = TryAttachHousingDashboardDecorDropsButton

local eventFrame = CreateFrame("Frame")
local function SafeRegisterEvent(frame, eventName)
  pcall(frame.RegisterEvent, frame, eventName)
end

SafeRegisterEvent(eventFrame, "ADDON_LOADED")
SafeRegisterEvent(eventFrame, "PLAYER_LOGIN")
SafeRegisterEvent(eventFrame, "BAG_UPDATE_DELAYED")
SafeRegisterEvent(eventFrame, "CHAT_MSG_LOOT")
SafeRegisterEvent(eventFrame, "TRADE_SKILL_DATA_SOURCE_CHANGED")
SafeRegisterEvent(eventFrame, "TRADE_SKILL_LIST_UPDATE")
SafeRegisterEvent(eventFrame, "AUCTION_HOUSE_SHOW")
SafeRegisterEvent(eventFrame, "AUCTION_HOUSE_CLOSED")
SafeRegisterEvent(eventFrame, "MERCHANT_SHOW")
SafeRegisterEvent(eventFrame, "GET_ITEM_INFO_RECEIVED")
SafeRegisterEvent(eventFrame, "ITEM_DATA_LOAD_RESULT")
SafeRegisterEvent(eventFrame, "PLAYER_REGEN_ENABLED")
SafeRegisterEvent(eventFrame, "PLAYER_ENTERING_WORLD")

local housingEvents = {
  "HOUSING_STORAGE_UPDATED",
  "NEW_HOUSING_ITEM_ACQUIRED",
  "HOUSING_DECOR_PLACE_SUCCESS",
  "HOUSING_DECOR_REMOVED",
}

for _, eventName in ipairs(housingEvents) do
  SafeRegisterEvent(eventFrame, eventName)
end

function ns.SafeRefreshMainFrame()
  if not (ns.RefreshUI and ns.MainFrame and ns.MainFrame:IsShown()) then
    return
  end
  if InCombatLockdown and InCombatLockdown() then
    ns._pendingCombatRefresh = true
    return
  end
  ns._pendingCombatRefresh = false
  ns.RefreshUI()
  ns._lastMainRefreshAt = GetTime and GetTime() or 0
end

local itemDataRefreshQueued = false
local itemDataRefreshPending = false
local ITEM_DATA_REFRESH_MIN_INTERVAL = 0.90
local function QueueItemDataRefresh(itemID)
  local numericItemID = tonumber(itemID)
  local hasPendingCheck = type(ns.IsItemDataRequestPending) == "function"
  if numericItemID and hasPendingCheck and not ns.IsItemDataRequestPending(numericItemID) then
    return
  end
  if itemDataRefreshQueued or itemDataRefreshPending then
    return
  end
  local now = GetTime and GetTime() or 0
  local last = tonumber(ns._lastMainRefreshAt) or 0
  local wait = ITEM_DATA_REFRESH_MIN_INTERVAL - (now - last)
  if wait < 0.05 then
    wait = 0.05
  end
  itemDataRefreshPending = true
  itemDataRefreshQueued = true
  local function run()
    itemDataRefreshPending = false
    itemDataRefreshQueued = false
    local frame = ns.MainFrame
    if not (frame and frame.IsShown and frame:IsShown()) then
      return
    end
    if InCombatLockdown and InCombatLockdown() then
      ns._pendingCombatRefresh = true
      return
    end
    if ns.FindSelectedEntry and ns.RefreshItems then
      local selected = ns.FindSelectedEntry(frame)
      if selected then
        ns.RefreshItems(frame)
        return
      end
    end
    ns.SafeRefreshMainFrame()
  end
  if C_Timer and C_Timer.After then
    C_Timer.After(wait, run)
  else
    run()
  end
end

local function BuildRowSignatureForDelta(row)
  local purchaseBits = {}
  for _, cost in ipairs(type(row) == "table" and row.purchaseCosts or {}) do
    local costType = tostring(cost and cost.costType or "")
    purchaseBits[#purchaseBits + 1] = table.concat({
      costType,
      tostring(tonumber(cost and cost.currencyID) or 0),
      tostring(tonumber(cost and cost.itemID) or 0),
      tostring(tonumber(cost and cost.quantity) or 0),
      tostring(tonumber(cost and cost.copper) or 0),
    }, ":")
  end
  table.sort(purchaseBits)
  return table.concat({
    tostring(tonumber(row and row.sourceType) or 0),
    tostring(row and row.sourceName or ""),
    tostring(row and row.sourceDetail or row and row.zone or ""),
    tostring(tonumber(row and row.mapID) or 0),
    tostring(tonumber(row and row.npcID) or 0),
    tostring(tonumber(row and row.questID) or 0),
    tostring(tonumber(row and row.achievementID) or 0),
    tostring(tonumber(row and row.minRep) or 0),
    tostring(tonumber(row and row.factionID) or 0),
    table.concat(purchaseBits, ","),
  }, "|")
end

local function BuildAcquireDeltaSnapshot(acquireData)
  local out = { createdAt = time and time() or 0, entries = {}, modeCounts = {} }
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or nil
  if type(groupsByMode) ~= "table" then
    return out
  end
  for mode, groups in pairs(groupsByMode) do
    if type(groups) == "table" then
      for _, group in ipairs(groups) do
        local groupKey = tostring(type(group) == "table" and group.key or "")
        for _, row in ipairs(type(group) == "table" and group.items or {}) do
          local itemID = tonumber(type(row) == "table" and row.itemID) or 0
          if itemID > 0 then
            local entryKey = table.concat({ tostring(mode), groupKey, tostring(itemID) }, "|")
            out.entries[entryKey] = BuildRowSignatureForDelta(row)
            out.modeCounts[mode] = (out.modeCounts[mode] or 0) + 1
          end
        end
      end
    end
  end
  return out
end

local function SaveDeveloperDeltaSnapshot()
  EnsureDeveloperDefaults()
  local snapshot = BuildAcquireDeltaSnapshot(DecorDropsDB and DecorDropsDB.acquireData)
  DecorDropsDB.developer.snapshots.last = snapshot
  local total = 0
  for _ in pairs(snapshot.entries or {}) do
    total = total + 1
  end
  print("|cff66ccffDecorDrops|r Dev snapshot saved: " .. tostring(total) .. " rows.")
end

local function RunDeveloperDeltaDiff()
  EnsureDeveloperDefaults()
  local before = DecorDropsDB.developer.snapshots.last
  if type(before) ~= "table" or type(before.entries) ~= "table" then
    print("|cffffcc66DecorDrops|r No dev snapshot found. Run /decordrops devsnapshot first.")
    return
  end
  local now = BuildAcquireDeltaSnapshot(DecorDropsDB and DecorDropsDB.acquireData)
  local addedByMode, removedByMode, changedByMode = {}, {}, {}
  local added, removed, changed = 0, 0, 0
  local function modeFromEntryKey(entryKey)
    return tostring(entryKey or ""):match("^([^|]+)|") or "unknown"
  end
  for key, sig in pairs(now.entries or {}) do
    local old = before.entries[key]
    local mode = modeFromEntryKey(key)
    if old == nil then
      added = added + 1
      addedByMode[mode] = (addedByMode[mode] or 0) + 1
    elseif old ~= sig then
      changed = changed + 1
      changedByMode[mode] = (changedByMode[mode] or 0) + 1
    end
  end
  for key in pairs(before.entries or {}) do
    if now.entries[key] == nil then
      local mode = modeFromEntryKey(key)
      removed = removed + 1
      removedByMode[mode] = (removedByMode[mode] or 0) + 1
    end
  end
  print("|cff66ccffDecorDrops|r Dev diff: added " .. tostring(added) .. ", removed " .. tostring(removed) .. ", changed " .. tostring(changed) .. ".")
  local function PrintModeCounts(label, bucket)
    local chunks = {}
    for mode, count in pairs(bucket) do
      chunks[#chunks + 1] = tostring(mode) .. "=" .. tostring(count)
    end
    table.sort(chunks)
    if #chunks > 0 then
      print("|cff66ccffDecorDrops|r " .. label .. ": " .. table.concat(chunks, ", "))
    end
  end
  PrintModeCounts("Dev diff added", addedByMode)
  PrintModeCounts("Dev diff removed", removedByMode)
  PrintModeCounts("Dev diff changed", changedByMode)
end

local function RunDeveloperDataAudit()
  local acquireData = DecorDropsDB and DecorDropsDB.acquireData
  local groupsByMode = type(acquireData) == "table" and acquireData.groupsByMode or {}
  local duplicateCount = 0
  local missingWaypointCount = 0
  local duplicatePreview = {}
  local waypointPreview = {}
  for mode, groups in pairs(groupsByMode) do
    local seenByItem = {}
    for _, group in ipairs(type(groups) == "table" and groups or {}) do
      local gKey = tostring(type(group) == "table" and group.key or "")
      for _, row in ipairs(type(group) == "table" and group.items or {}) do
        local itemID = tonumber(type(row) == "table" and row.itemID) or 0
        if itemID > 0 then
          local itemKey = tostring(mode) .. "|" .. tostring(itemID)
          seenByItem[itemKey] = seenByItem[itemKey] or {}
          seenByItem[itemKey][gKey] = true
          if (mode == "vendor" or mode == "reputation")
            and ((tonumber(row.mapID) or 0) <= 0 or tonumber(row.x) == nil or tonumber(row.y) == nil) then
            missingWaypointCount = missingWaypointCount + 1
            if #waypointPreview < 15 then
              waypointPreview[#waypointPreview + 1] = tostring(mode) .. ":" .. tostring(itemID)
            end
          end
        end
      end
    end
    for itemKey, groupsForItem in pairs(seenByItem) do
      local count = 0
      for _ in pairs(groupsForItem) do
        count = count + 1
      end
      if count > 1 then
        duplicateCount = duplicateCount + 1
        if #duplicatePreview < 15 then
          duplicatePreview[#duplicatePreview + 1] = itemKey .. " (" .. tostring(count) .. " groups)"
        end
      end
    end
  end
  print("|cff66ccffDecorDrops|r Dev audit: duplicate item/group collisions = " .. tostring(duplicateCount) .. ", vendor/reputation rows missing waypoint data = " .. tostring(missingWaypointCount) .. ".")
  if #duplicatePreview > 0 then
    print("|cff66ccffDecorDrops|r Duplicate preview: " .. table.concat(duplicatePreview, ", "))
  end
  if #waypointPreview > 0 then
    print("|cff66ccffDecorDrops|r Missing waypoint preview: " .. table.concat(waypointPreview, ", "))
  end
  RunQuestUnlockCoverageAudit(acquireData)
end

local function HandleSlashCommand(msg)
  local input = (msg or ""):match("^%s*(.-)%s*$")
  local cmd, arg = string.match(string.lower(input or ""), "^(%S+)%s*(.-)$")
  cmd = cmd or ""
  arg = arg or ""
  if cmd == "devunlock" then
    if arg == DEV_TOOLS_UNLOCK_TOKEN then
      if SetDeveloperToolsEnabled(true) then
        print("|cff66ccffDecorDrops|r Developer tools enabled for this character.")
        ns.SafeRefreshMainFrame()
      else
        print("|cffff5555DecorDrops:|r Developer tools are account-restricted and unavailable on this account.")
      end
    else
      print("|cffff5555DecorDrops:|r Invalid developer token.")
    end
    return
  end
  if cmd == "devmode" then
    if arg == "on" or arg == "enable" or arg == "enabled" then
      if SetDeveloperToolsEnabled(true) then
        print("|cff66ccffDecorDrops|r Developer mode enabled for this character.")
        ns.SafeRefreshMainFrame()
      else
        print("|cffff5555DecorDrops:|r Developer mode is account-restricted and unavailable on this account.")
      end
      return
    end
    if arg == "off" or arg == "disable" or arg == "disabled" then
      SetDeveloperToolsEnabled(false)
      print("|cff66ccffDecorDrops|r Developer mode disabled for this character.")
      ns.SafeRefreshMainFrame()
      return
    end
    print("|cffffcc66DecorDrops|r Usage: /decordrops devmode on|off")
    return
  end
  if cmd == "devlock" then
    SetDeveloperToolsEnabled(false)
    print("|cff66ccffDecorDrops|r Developer tools disabled for this character.")
    ns.SafeRefreshMainFrame()
    return
  end
  if cmd == "devstatus" then
    local accountAuthorized = IsDeveloperAccountAuthorized()
    local lastAccount = tostring(DecorDropsDB and DecorDropsDB.developer and DecorDropsDB.developer.lastAuthorizedAccountID or "")
    if lastAccount == "" then
      lastAccount = "n/a"
    end
    print("|cff66ccffDecorDrops|r Developer tools: " .. (IsDeveloperToolsEnabled() and "enabled" or "disabled") .. " for " .. BuildDeveloperCharacterKey() .. ". Account authorized: " .. (accountAuthorized and "yes" or "no") .. " (id: " .. lastAccount .. "). Merchant auto-sync: " .. (IsDeveloperVendorAutoSyncEnabled() and "on" or "off") .. ".")
    return
  end
  if cmd == "devvendorsync" or cmd == "devmerchantsync" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Developer tools are disabled. Use /decordrops devmode on.")
      return
    end
    if arg == "on" or arg == "enable" or arg == "enabled" then
      SetDeveloperVendorAutoSyncEnabled(true)
      print("|cff66ccffDecorDrops|r Developer vendor auto-sync enabled for this account.")
      return
    end
    if arg == "off" or arg == "disable" or arg == "disabled" then
      SetDeveloperVendorAutoSyncEnabled(false)
      print("|cff66ccffDecorDrops|r Developer vendor auto-sync disabled for this account.")
      return
    end
    print("|cff66ccffDecorDrops|r Developer vendor auto-sync is currently " .. (IsDeveloperVendorAutoSyncEnabled() and "on" or "off") .. ". Usage: /decordrops devvendorsync on|off")
    return
  end
  if cmd == "devhelp" then
    print("|cff66ccffDecorDrops|r Dev commands: devmode on|off, devstatus, devvendorsync on|off, devscan [all|instances|professions], devsnapshot, devdiff, devaudit, devrefresh vendor, devlock.")
    return
  end
  if cmd == "devsnapshot" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Developer tools are disabled. Use /decordrops devmode on.")
      return
    end
    SaveDeveloperDeltaSnapshot()
    return
  end
  if cmd == "devdiff" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Developer tools are disabled. Use /decordrops devmode on.")
      return
    end
    RunDeveloperDeltaDiff()
    return
  end
  if cmd == "devaudit" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Developer tools are disabled. Use /decordrops devmode on.")
      return
    end
    RunDeveloperDataAudit()
    return
  end
  if cmd == "devrefresh" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Developer tools are disabled. Use /decordrops devmode on.")
      return
    end
    if arg == "vendor" then
      if not (MerchantFrame and MerchantFrame:IsShown()) then
        print("|cffffcc66DecorDrops|r Open a merchant window first, then run /decordrops devrefresh vendor.")
        return
      end
      SyncVendorItemsFromMerchant()
      return
    end
    print("|cffffcc66DecorDrops|r Usage: /decordrops devrefresh vendor")
    return
  end
  if cmd == "devscan" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Developer tools are disabled. Use /decordrops devmode on.")
      return
    end
    local mode = "all"
    if arg == "prof" or arg == "profession" or arg == "professions" then
      mode = "professions"
    elseif arg == "inst" or arg == "instance" or arg == "instances" then
      mode = "instances"
    end
    if ns.RequestScan then
      ns.RequestScan(mode)
    elseif ns.RunAutoScan then
      ns.RunAutoScan(mode)
    end
    return
  end
  if cmd == "scan" or cmd == "compile" or cmd == "compilecatalog" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Manual scan is developer-only in this build.")
      return
    end
    local mode = "all"
    local argNum = tonumber(arg)
    if argNum and argNum > 0 then
      DecorDropsDB.scan = DecorDropsDB.scan or {}
      DecorDropsDB.scan.catalogRecordMax = math.floor(argNum + 0.5)
      print("|cff66ccffDecorDrops|r Catalog compile max record set to " .. tostring(DecorDropsDB.scan.catalogRecordMax))
    end
    if cmd == "compile" or cmd == "compilecatalog" then
      mode = "professions"
    elseif arg == "all" then
      mode = "all"
    elseif arg == "prof" or arg == "profession" or arg == "professions" then
      mode = "professions"
    elseif arg == "inst" or arg == "instance" or arg == "instances" then
      mode = "instances"
    end
    if ns.RequestScan then
      ns.RequestScan(mode)
    elseif ns.RunAutoScan then
      ns.RunAutoScan(mode)
    end
    return
  end

  if cmd == "clearauto" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r clearauto is developer-only in this build.")
      return
    end
    if ns.ClearAutoData then
      ns.ClearAutoData()
    end
    return
  end

  if cmd == "mapid" then
    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
    if mapID and mapID > 0 then
      print("|cff66ccffDecorDrops|r Current MapID: |cffffffff" .. tostring(mapID) .. "|r")
    else
      print("|cffff5555DecorDrops: Unable to determine current MapID.|r")
    end
    return
  end

  if cmd == "rebuildacquire" or cmd == "importacquire" or cmd == "acquireimport" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Source-data import is developer-only in this build.")
      return
    end
    local ok, msg = RebuildAcquireDataFromLocal()
    if ok then
      print("|cff66ccffDecorDrops|r " .. tostring(msg))
      ns.SafeRefreshMainFrame()
    else
      print("|cffff5555DecorDrops:|r " .. tostring(msg))
    end
    return
  end

  if cmd == "auditunlocks" or cmd == "unlockaudit" then
    if not IsDeveloperToolsEnabled() then
      print("|cffffcc66DecorDrops|r Unlock audit is developer-only in this build.")
      return
    end
    RunQuestUnlockCoverageAudit(DecorDropsDB and DecorDropsDB.acquireData)
    return
  end

  if ns.ToggleMainFrame then
    ns.ToggleMainFrame()
  end
end

local function RegisterSlashCommands()
  SLASH_DECORDROPS1 = "/decordrops"
  SLASH_DECORDROPS2 = "/ddrops"
  SLASH_DECORDROPS3 = "/decor"
  SlashCmdList.DECORDROPS = HandleSlashCommand
end

eventFrame:SetScript("OnEvent", function(_, event, arg1)
  if event == "ADDON_LOADED" and arg1 == ADDON_NAME then
    EnsureDefaults()
  elseif event == "PLAYER_LOGIN" then
    EnsureDefaults()
    if ns.UpdateMissionAnimationSkipPulse then
      ns.UpdateMissionAnimationSkipPulse()
    end
    local seededQuestUnlocks = BackfillQuestUnlockRows(DecorDropsDB.acquireData)
    local seededQuestIDs = BackfillQuestRowsFromExistingQuestIDs(DecorDropsDB.acquireData)
    local purgedNonVendorQuestUnlockRows = PurgeQuestUnlockRowsOutsideVendorScope(DecorDropsDB.acquireData)
    local purgedQuestSourceRows = PurgeNonQuestSourceRowsFromQuestMode(DecorDropsDB.acquireData)
    local normalizedRepRows = NormalizeAcquireReputationData(DecorDropsDB.acquireData)
    local purgedStaleGoldRows = PurgeStaleResonanceGoldRows(DecorDropsDB.acquireData)
    if seededQuestUnlocks > 0 or seededQuestIDs > 0 or normalizedRepRows > 0 or purgedNonVendorQuestUnlockRows > 0 or purgedQuestSourceRows > 0 or purgedStaleGoldRows > 0 then
      DecorDropsDB.acquireData.importedAt = time and time() or DecorDropsDB.acquireData.importedAt
      DecorDropsDB.acquireData.totalItems = CountAcquireRows(DecorDropsDB.acquireData)
      print("|cff66ccffDecorDrops|r Data backfill: quest unlock rows " .. tostring(seededQuestUnlocks) .. ", questID rows " .. tostring(seededQuestIDs) .. ", non-vendor quest-unlock pruned rows " .. tostring(purgedNonVendorQuestUnlockRows) .. ", quest-pruned rows " .. tostring(purgedQuestSourceRows) .. ", rep rows " .. tostring(normalizedRepRows) .. ", stale-gold rows " .. tostring(purgedStaleGoldRows) .. ".")
    end

    RegisterSlashCommands()

    if ns.CreateUI then
      local ok, err = pcall(ns.CreateUI)
      if not ok then
        print("|cffff5555DecorDrops UI error:|r " .. tostring(err))
      end
    end
    if ns.CreateMinimapButton then
      local ok, err = pcall(ns.CreateMinimapButton)
      if not ok then
        print("|cffff5555DecorDrops minimap error:|r " .. tostring(err))
      end
    end
    C_Timer.After(1.0, function()
      if ns.TryAttachHousingDashboardDecorDropsButton then
        ns.TryAttachHousingDashboardDecorDropsButton()
      end
    end)
  elseif event == "ADDON_LOADED" then
    if ns.TryAttachHousingDashboardDecorDropsButton then
      C_Timer.After(0.2, function()
        ns.TryAttachHousingDashboardDecorDropsButton()
      end)
    end
  elseif event == "PLAYER_ENTERING_WORLD" then
    if ns.TryAttachHousingDashboardDecorDropsButton then
      C_Timer.After(1.5, function()
        ns.TryAttachHousingDashboardDecorDropsButton()
      end)
    end
  elseif event == "BAG_UPDATE_DELAYED" then
    ns.SafeRefreshMainFrame()
  elseif event == "CHAT_MSG_LOOT" then
    if ns.InvalidateCollectionCache then
      -- CHAT_MSG_LOOT payloads may be secret strings; avoid string ops on arg1 to prevent taint errors.
      ns.InvalidateCollectionCache(nil, false)
    end
    ns.SafeRefreshMainFrame()
  elseif event == "TRADE_SKILL_DATA_SOURCE_CHANGED" or event == "TRADE_SKILL_LIST_UPDATE" then
    ns.SafeRefreshMainFrame()
  elseif event == "AUCTION_HOUSE_SHOW" then
    if ns.OnAuctionHouseShow then
      ns.OnAuctionHouseShow()
    end
  elseif event == "AUCTION_HOUSE_CLOSED" then
    if ns.OnAuctionHouseClosed then
      ns.OnAuctionHouseClosed()
    end
  elseif event == "MERCHANT_SHOW" then
    if IsDeveloperToolsEnabled() and IsDeveloperVendorAutoSyncEnabled() then
      SyncVendorItemsFromMerchant()
    end
  elseif event == "GET_ITEM_INFO_RECEIVED" or event == "ITEM_DATA_LOAD_RESULT" then
    local itemID = tonumber(arg1)
    QueueItemDataRefresh(itemID)
    if itemID and ns.ResolveItemDataRequest then
      ns.ResolveItemDataRequest(itemID)
    end
  elseif event == "HOUSING_STORAGE_UPDATED"
      or event == "NEW_HOUSING_ITEM_ACQUIRED"
      or event == "HOUSING_DECOR_PLACE_SUCCESS"
      or event == "HOUSING_DECOR_REMOVED" then
    if ns.TryAttachHousingDashboardDecorDropsButton then
      ns.TryAttachHousingDashboardDecorDropsButton()
    end
    if ns.InvalidateCollectionCache then
      ns.InvalidateCollectionCache()
    end
    ns.SafeRefreshMainFrame()
  elseif event == "PLAYER_REGEN_ENABLED" then
    if ns._pendingCombatRefresh then
      ns.SafeRefreshMainFrame()
    end
  end
end)
