local _, ns = ...

local POPUP_KEY = "DECORDROPS_CONFIRM_SCAN"
local MANUAL_SCAN_COOLDOWN_SECONDS = 60
local TRADE_SKILL_SCAN_INTERVAL_SECONDS = 0.20

local scanFrame = CreateFrame("Frame")
local scanState = nil
local BuildResourceListForRecipe

local function PrintInfo(msg)
  local line = "|cff66ccffDecorDrops|r " .. tostring(msg or "")
  if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
    DEFAULT_CHAT_FRAME:AddMessage(line)
    return
  end
  print(line)
end

local function ShowCompletionPrompt(text)
  if UIErrorsFrame and UIErrorsFrame.AddMessage then
    UIErrorsFrame:AddMessage(text, 0.35, 0.85, 1.0, 4.0)
  end
  if PlaySound and SOUNDKIT and SOUNDKIT.READY_CHECK then
    PlaySound(SOUNDKIT.READY_CHECK, "Master")
  end
end

local function NowMS()
  if type(debugprofilestop) == "function" then
    return debugprofilestop()
  end
  return (GetTime() or 0) * 1000
end

local function EnsureScanDefaults()
  DecorDropsDB.scan = DecorDropsDB.scan or {}
  if DecorDropsDB.scan.interval == nil then
    DecorDropsDB.scan.interval = 0.15
  end
  if DecorDropsDB.scan.frameBudgetMs == nil then
    DecorDropsDB.scan.frameBudgetMs = 1.2
  end
  if DecorDropsDB.scan.lastScanAt == nil then
    DecorDropsDB.scan.lastScanAt = 0
  end
  DecorDropsDB.scan.lastScanAtByMode = DecorDropsDB.scan.lastScanAtByMode or {}
  local legacy = tonumber(DecorDropsDB.scan.lastScanAt) or 0
  if legacy > 0 then
    if not DecorDropsDB.scan.lastScanAtByMode.instances then
      DecorDropsDB.scan.lastScanAtByMode.instances = legacy
    end
    if not DecorDropsDB.scan.lastScanAtByMode.professions then
      DecorDropsDB.scan.lastScanAtByMode.professions = legacy
    end
  end
end

local function CooldownKeyForMode(scanMode)
  if scanMode == "all" then
    return "instances"
  end
  if scanMode == "professions" then
    return "professions"
  end
  if scanMode == "instances" then
    return "instances"
  end
  return "instances"
end

local function ScanModeLabel(scanMode)
  if scanMode == "professions" then
    return "profession"
  end
  if scanMode == "instances" then
    return "dungeon/raid"
  end
  return "full"
end

local function EnsureTradeSkillLoaded()
  if C_AddOns and C_AddOns.IsAddOnLoaded and C_AddOns.LoadAddOn then
    if not C_AddOns.IsAddOnLoaded("Blizzard_Professions") then
      C_AddOns.LoadAddOn("Blizzard_Professions")
    end
  else
    if not IsAddOnLoaded("Blizzard_Professions") then
      LoadAddOn("Blizzard_Professions")
    end
  end
end

local function EnsureJournalLoaded()
  if C_AddOns and C_AddOns.IsAddOnLoaded and C_AddOns.LoadAddOn then
    if not C_AddOns.IsAddOnLoaded("Blizzard_EncounterJournal") then
      C_AddOns.LoadAddOn("Blizzard_EncounterJournal")
    end
  else
    if not IsAddOnLoaded("Blizzard_EncounterJournal") then
      LoadAddOn("Blizzard_EncounterJournal")
    end
  end
end

local function GetEncounterName(encounterID)
  if not encounterID then
    return "Encounter Journal"
  end

  local name = EJ_GetEncounterInfo and EJ_GetEncounterInfo(encounterID) or nil
  return name or "Encounter Journal"
end

local function GetLootInfo(index)
  if C_EncounterJournal and C_EncounterJournal.GetLootInfoByIndex then
    local info = C_EncounterJournal.GetLootInfoByIndex(index)
    if info then
      return info
    end
  end

  if EJ_GetLootInfoByIndex then
    local itemID, encounterID, name = EJ_GetLootInfoByIndex(index)
    if itemID then
      return {
        itemID = itemID,
        encounterID = encounterID,
        name = name,
      }
    end
  end

  return nil
end

local function GetDecorCatalogInfo(itemID)
  if not itemID then
    return nil
  end

  if not C_HousingCatalog or not C_HousingCatalog.GetCatalogEntryInfoByItem then
    return nil
  end

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

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

  return nil
end

local function CanUseHousingCatalog()
  return C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem
end

local function IsDecorItem(itemID)
  if not itemID then
    return false
  end
  if not CanUseHousingCatalog() then
    -- Fallback for client builds where housing catalog API is unavailable.
    return true
  end
  if GetDecorCatalogInfo(itemID) ~= nil then
    return true
  end
  if type(ns.staticDecorCatalog) == "table"
    and type(ns.staticDecorCatalog.byItemID) == "table"
    and ns.staticDecorCatalog.byItemID[tonumber(itemID)] then
    return true
  end
  return false
end

local function ParsePercentFromText(text)
  if type(text) ~= "string" or text == "" then
    return nil
  end

  local raw = string.match(text, "(%d+[%.,]?%d*)%%")
  if not raw then
    return nil
  end

  raw = string.gsub(raw, ",", ".")
  local num = tonumber(raw)
  if not num then
    return nil
  end

  if num < 0 then
    return nil
  end

  if num > 100 then
    return 100.0
  end

  return num
end

local EXPANSION_BY_KEYWORD = {
  { "midnight", "Midnight" },
  { "khaz algar", "The War Within" },
  { "war within", "The War Within" },
  { "dragon isles", "Dragonflight" },
  { "dragonflight", "Dragonflight" },
  { "shadowlands", "Shadowlands" },
  { "zandalari", "Battle for Azeroth" },
  { "kul tiran", "Battle for Azeroth" },
  { "battle for azeroth", "Battle for Azeroth" },
  { "legion", "Legion" },
  { "draenor", "Warlords of Draenor" },
  { "pandaria", "Mists of Pandaria" },
  { "cataclysm", "Cataclysm" },
  { "northrend", "Wrath of the Lich King" },
  { "outland", "The Burning Crusade" },
  { "classic", "Classic" },
}

local BASE_PROFESSION_SKILL_LINES = {
  164, -- Blacksmithing
  165, -- Leatherworking
  171, -- Alchemy
  185, -- Cooking
  186, -- Mining
  197, -- Tailoring
  202, -- Engineering
  333, -- Enchanting
  393, -- Skinning
  755, -- Jewelcrafting
  773, -- Inscription
  182, -- Herbalism
  356, -- Fishing
}

local KNOWN_PROFESSION_NAMES = {
  ["Alchemy"] = true,
  ["Blacksmithing"] = true,
  ["Cooking"] = true,
  ["Enchanting"] = true,
  ["Engineering"] = true,
  ["Fishing"] = true,
  ["Herbalism"] = true,
  ["Inscription"] = true,
  ["Jewelcrafting"] = true,
  ["Leatherworking"] = true,
  ["Mining"] = true,
  ["Skinning"] = true,
  ["Tailoring"] = true,
}

local PROFESSION_ICON_BY_NAME = {
  ["Alchemy"] = "Interface\\Icons\\Trade_Alchemy",
  ["Blacksmithing"] = "Interface\\Icons\\Trade_BlackSmithing",
  ["Cooking"] = "Interface\\Icons\\INV_Misc_Food_15",
  ["Enchanting"] = "Interface\\Icons\\Trade_Engraving",
  ["Engineering"] = "Interface\\Icons\\Trade_Engineering",
  ["Fishing"] = "Interface\\Icons\\Trade_Fishing",
  ["Herbalism"] = "Interface\\Icons\\Trade_Herbalism",
  ["Inscription"] = "Interface\\Icons\\INV_Inscription_Tradeskill01",
  ["Jewelcrafting"] = "Interface\\Icons\\INV_Misc_Gem_01",
  ["Leatherworking"] = "Interface\\Icons\\Trade_LeatherWorking",
  ["Mining"] = "Interface\\Icons\\Trade_Mining",
  ["Skinning"] = "Interface\\Icons\\INV_Misc_Pelt_Wolf_01",
  ["Tailoring"] = "Interface\\Icons\\Trade_Tailoring",
}

local function ResolveProfessionIcon(professionName, fallbackIcon)
  local mapped = PROFESSION_ICON_BY_NAME[professionName]
  if mapped and mapped ~= "" then
    return mapped
  end
  if fallbackIcon and fallbackIcon ~= "" then
    return fallbackIcon
  end
  return "Interface\\Icons\\INV_Inscription_Tradeskill01"
end

local function CanonicalProfessionName(rawProfession, parentProfession, fallbackID)
  if parentProfession and parentProfession ~= "" and KNOWN_PROFESSION_NAMES[parentProfession] then
    return parentProfession
  end

  local text = tostring(rawProfession or "")
  for professionName in pairs(KNOWN_PROFESSION_NAMES) do
    if string.find(text, professionName, 1, true) then
      return professionName
    end
  end

  if text ~= "" then
    return text
  end

  return "Profession " .. tostring(fallbackID)
end

local function RecipeBelongsToProfession(recipeInfo, tradeSkillLineID, professionName)
  if type(recipeInfo) ~= "table" then
    return true
  end

  local recipeLineID = tonumber(
    recipeInfo.tradeSkillLineID
    or recipeInfo.skillLineID
    or recipeInfo.parentTradeSkillLineID
    or recipeInfo.professionID
  )
  if recipeLineID and tonumber(tradeSkillLineID) and recipeLineID == tonumber(tradeSkillLineID) then
    return true
  end

  local candidate = CanonicalProfessionName(
    recipeInfo.parentProfessionName or recipeInfo.professionName or recipeInfo.skillLineName or recipeInfo.name,
    recipeInfo.parentProfessionName or recipeInfo.parentTradeSkillLineName,
    tradeSkillLineID
  )

  if KNOWN_PROFESSION_NAMES[candidate] and candidate == professionName then
    return true
  end

  -- If API metadata is incomplete, do not hard-reject; rely on filtered recipe lists.
  return not KNOWN_PROFESSION_NAMES[candidate]
end

local function GuessExpansionFromText(text)
  local lower = string.lower(tostring(text or ""))
  for _, pair in ipairs(EXPANSION_BY_KEYWORD) do
    if string.find(lower, pair[1], 1, true) then
      return pair[2]
    end
  end
  return "Other"
end

local function ParseItemIDFromLink(link)
  if type(link) ~= "string" then
    return nil
  end
  return tonumber(string.match(link, "item:(%d+)"))
end

local function ParseItemIDFromCatalogInfo(info)
  if type(info) ~= "table" then
    return nil
  end
  local itemID = tonumber(info.itemID or info.catalogItemID)
  if itemID and itemID > 0 then
    return itemID
  end
  itemID = ParseItemIDFromLink(info.itemLink or info.link)
  if itemID and itemID > 0 then
    return itemID
  end
  return nil
end

local function ParseRecipeIDFromCatalogInfo(info, depth)
  depth = depth or 0
  if depth > 4 or type(info) ~= "table" then
    return nil
  end

  local direct = tonumber(
    info.recipeID or info.spellID or info.sourceSpellID or info.craftingSpellID or info.recipeSpellID
  )
  if direct and direct > 0 then
    return direct
  end

  local nestedKeys = { "source", "sourceInfo", "sources", "acquireInfo", "requirements", "recipe", "craftingInfo" }
  for _, key in ipairs(nestedKeys) do
    local value = info[key]
    if type(value) == "table" then
      if value[1] ~= nil then
        for _, child in ipairs(value) do
          local found = ParseRecipeIDFromCatalogInfo(child, depth + 1)
          if found then
            return found
          end
        end
      else
        local found = ParseRecipeIDFromCatalogInfo(value, depth + 1)
        if found then
          return found
        end
      end
    end
  end

  return nil
end

local function ParseResourcesFromCatalogInfo(info, out, depth)
  out = out or {}
  depth = depth or 0
  if depth > 5 or type(info) ~= "table" then
    return out
  end

  local itemID = tonumber(info.itemID or info.reagentItemID or info.resourceItemID or info.id)
  local quantity = tonumber(info.quantity or info.requiredQuantity or info.count or info.amount)
  if itemID and itemID > 0 and quantity and quantity > 0 then
    out[itemID] = (out[itemID] or 0) + math.max(1, quantity)
  end

  for _, value in pairs(info) do
    if type(value) == "table" then
      ParseResourcesFromCatalogInfo(value, out, depth + 1)
    end
  end

  return out
end

local function NormalizeCatalogEntryRef(value)
  if type(value) == "number" and value > 0 then
    return value
  end
  if type(value) ~= "table" then
    return nil
  end
  if value.entryID or value.recordID or value.entryType then
    return value
  end
  local numeric = tonumber(value.catalogEntryID or value.catalogID or value.id)
  if numeric and numeric > 0 then
    return numeric
  end
  return nil
end

local function DetectProfessionFromSourceText(sourceText)
  local text = tostring(sourceText or "")
  if text == "" then
    return nil
  end
  for professionName in pairs(KNOWN_PROFESSION_NAMES) do
    if string.find(text, professionName, 1, true) then
      return professionName
    end
  end
  return nil
end

local function DetectProfessionFromCatalogInfo(info)
  if type(info) ~= "table" then
    return nil
  end

  local directKeys = {
    "professionName",
    "parentProfessionName",
    "profession",
    "tradeSkillName",
    "tradeSkillLineName",
    "skillLineName",
    "requiredProfession",
    "professionReq",
    "sourceProfession",
  }

  for _, key in ipairs(directKeys) do
    local value = info[key]
    if type(value) == "string" and value ~= "" then
      local candidate = CanonicalProfessionName(value, nil, key)
      if KNOWN_PROFESSION_NAMES[candidate] then
        return candidate
      end
    end
  end

  local recipeID = tonumber(info.recipeID or info.spellID)
  if recipeID and C_TradeSkillUI and C_TradeSkillUI.GetRecipeInfo then
    local recipeInfo = C_TradeSkillUI.GetRecipeInfo(recipeID)
    if type(recipeInfo) == "table" then
      local candidate = CanonicalProfessionName(
        recipeInfo.parentProfessionName or recipeInfo.professionName or recipeInfo.skillLineName or recipeInfo.name,
        recipeInfo.parentProfessionName or recipeInfo.parentTradeSkillLineName,
        recipeID
      )
      if KNOWN_PROFESSION_NAMES[candidate] then
        return candidate
      end
    end
  end

  if type(info.sourceText) == "string" then
    local fromText = DetectProfessionFromSourceText(info.sourceText)
    if fromText then
      return fromText
    end
  end

  local nestedKeys = { "source", "sourceInfo", "sources", "acquireInfo", "requirements" }
  for _, key in ipairs(nestedKeys) do
    local value = info[key]
    if type(value) == "table" then
      if value[1] ~= nil then
        for _, child in ipairs(value) do
          local found = DetectProfessionFromCatalogInfo(child)
          if found then
            return found
          end
        end
      else
        local found = DetectProfessionFromCatalogInfo(value)
        if found then
          return found
        end
      end
    end
  end

  return nil
end

local function BuildCatalogProfessionQueue()
  if not C_HousingCatalog or not C_HousingCatalog.CreateCatalogSearcher then
    return {}
  end

  local searcher = C_HousingCatalog.CreateCatalogSearcher()
  if not searcher then
    return {}
  end

  if searcher.SetAutoUpdateOnParamChanges then
    pcall(searcher.SetAutoUpdateOnParamChanges, searcher, false)
  end
  if searcher.SetSearchText then
    pcall(searcher.SetSearchText, searcher, "")
  end
  if searcher.SetOwnedOnly then
    pcall(searcher.SetOwnedOnly, searcher, false)
  end
  if searcher.SetAllowedIndoors then
    pcall(searcher.SetAllowedIndoors, searcher, true)
  end
  if searcher.SetAllowedOutdoors then
    pcall(searcher.SetAllowedOutdoors, searcher, true)
  end
  if searcher.RunSearch then
    pcall(searcher.RunSearch, searcher)
  end

  local results = searcher.GetCatalogSearchResults and searcher:GetCatalogSearchResults() or {}
  if type(results) ~= "table" then
    return {}
  end

  local queue = {}
  local seen = {}
  for _, resultEntry in ipairs(results) do
    local entryRef = NormalizeCatalogEntryRef(resultEntry)
    if entryRef then
      local dedupeKey
      if type(entryRef) == "number" then
        dedupeKey = "n:" .. tostring(entryRef)
      else
        dedupeKey = "t:" .. tostring(entryRef.entryID or entryRef.recordID or entryRef.catalogEntryID or entryRef.id or "")
      end
      if dedupeKey ~= "" and not seen[dedupeKey] then
        seen[dedupeKey] = true
        queue[#queue + 1] = {
          mode = "catalogProfession",
          catalogEntryID = entryRef,
        }
      end
    end
  end
  return queue
end

local function BuildCatalogRecordQueue(maxRecordID)
  local queue = {}
  local cap = tonumber(maxRecordID) or 12000
  if cap < 500 then
    cap = 500
  elseif cap > 40000 then
    cap = 40000
  end

  for recordID = 1, cap do
    queue[#queue + 1] = {
      mode = "catalogRecord",
      recordID = recordID,
    }
  end
  return queue
end

local function BuildCatalogItemQueueFromExisting(existingProfessionData)
  local queue = {}
  local seen = {}
  for _, group in ipairs(existingProfessionData or {}) do
    for _, item in ipairs(group.decorations or {}) do
      local itemID = tonumber(item and item.itemID)
      if itemID and itemID > 0 and not seen[itemID] then
        seen[itemID] = true
        queue[#queue + 1] = {
          mode = "catalogItem",
          itemID = itemID,
        }
      end
    end
  end
  return queue
end

local KNOWN_VENDOR_ITEM_NAME_EXCLUSIONS = {
  ["khati scribe's records"] = true,
  ["zuldazar cook's griddle"] = true,
  ["redundant reclaimation rig"] = true,
  ["redundant reclamation rig"] = true,
  ["automated gnomeregan guardian"] = true,
  ["gnomish safety flamethrower"] = true,
  ["mecha-storage mecha-chest"] = true,
}

local function IsExcludedVendorItemName(name)
  local key = string.lower(tostring(name or ""))
  key = key:gsub("^%s+", ""):gsub("%s+$", "")
  if key == "" then
    return false
  end
  return KNOWN_VENDOR_ITEM_NAME_EXCLUSIONS[key] == true
end

local function HasCraftedCatalogSignal(info, itemID, recipeID)
  if type(info) ~= "table" then
    return false
  end

  local sourceText = string.lower(tostring(info.sourceText or ""))
  if string.find(sourceText, "crafted", 1, true) then
    return true
  end

  local area = string.lower(tostring(info.area or ""))
  if area == "profession" then
    return true
  end

  if tonumber(recipeID) and tonumber(recipeID) > 0 then
    return true
  end

  if ns.GetStaticRecipeData and tonumber(itemID) and tonumber(itemID) > 0 then
    local staticData = ns.GetStaticRecipeData(itemID, recipeID)
    if type(staticData) == "table" then
      if (tonumber(staticData.recipeID) or 0) > 0 then
        return true
      end
      if type(staticData.resources) == "table" and #staticData.resources > 0 then
        return true
      end
    end
  end

  return false
end

local function BuildProfessionEntryFromCatalogInfo(info, fallbackItemID)
  if type(info) ~= "table" then
    return nil
  end

  local itemID = ParseItemIDFromCatalogInfo(info) or tonumber(fallbackItemID)
  if not itemID then
    return nil
  end

  local itemName = info.name
  if (not itemName or itemName == "") and C_Item and C_Item.GetItemNameByID then
    itemName = C_Item.GetItemNameByID(itemID)
  end
  if IsExcludedVendorItemName(itemName) then
    return nil
  end

  local recipeID = ParseRecipeIDFromCatalogInfo(info)
  if not HasCraftedCatalogSignal(info, itemID, recipeID) then
    return nil
  end

  local professionName = DetectProfessionFromSourceText(info.sourceText)
  if not professionName then
    professionName = DetectProfessionFromCatalogInfo(info)
  end
  if not professionName then
    return nil
  end

  local expansionName = GuessExpansionFromText(info.sourceText or info.name or "")
  local catalogResourceMap = ParseResourcesFromCatalogInfo(info)
  local catalogResources = {}
  for reagentItemID, qty in pairs(catalogResourceMap) do
    if reagentItemID ~= itemID and qty and qty > 0 then
      catalogResources[#catalogResources + 1] = {
        itemID = reagentItemID,
        quantity = qty,
      }
    end
  end
  table.sort(catalogResources, function(a, b)
    return (a.itemID or 0) < (b.itemID or 0)
  end)
  local resolvedResources = catalogResources
  if recipeID then
    local schematicResources = BuildResourceListForRecipe and BuildResourceListForRecipe(recipeID) or {}
    if type(schematicResources) == "table" and #schematicResources > 0 then
      resolvedResources = schematicResources
    end
  end
  if (#resolvedResources == 0) and ns.GetStaticRecipeData then
    local staticData = ns.GetStaticRecipeData(itemID, recipeID)
    if staticData then
      if not recipeID and staticData.recipeID then
        recipeID = staticData.recipeID
      end
      if type(staticData.resources) == "table" and #staticData.resources > 0 then
        resolvedResources = staticData.resources
      end
    end
  end

  return {
    expansion = expansionName or "Other",
    profession = professionName,
    professionID = nil,
    icon = ResolveProfessionIcon(professionName, info.icon),
    decorations = {
      {
        itemID = itemID,
        name = itemName,
        source = professionName,
        skillRequired = nil,
        recipeID = recipeID,
        recipeKnown = false,
        resources = resolvedResources,
      },
    },
  }
end

local function RemoveKnownVendorMisclassifiedProfessionItems(entries)
  if type(entries) ~= "table" then
    return entries or {}
  end

  for gi = #entries, 1, -1 do
    local group = entries[gi]
    local items = type(group) == "table" and group.decorations or nil
    if type(items) == "table" then
      for ii = #items, 1, -1 do
        local item = items[ii]
        local itemName = type(item) == "table" and item.name or nil
        if IsExcludedVendorItemName(itemName) then
          table.remove(items, ii)
        end
      end
      if #items == 0 then
        table.remove(entries, gi)
      end
    end
  end

  return entries
end

local function BuildCatalogProfessionEntry(catalogEntryID)
  if not C_HousingCatalog then
    return nil
  end
  if not catalogEntryID then
    return nil
  end

  local info = nil
  local calls = {}
  if type(C_HousingCatalog.GetCatalogEntryInfo) == "function" then
    calls[#calls + 1] = { fn = C_HousingCatalog.GetCatalogEntryInfo, args = { catalogEntryID, true } }
    calls[#calls + 1] = { fn = C_HousingCatalog.GetCatalogEntryInfo, args = { catalogEntryID } }
  end
  local recordID = (type(catalogEntryID) == "table" and catalogEntryID.recordID) or (type(catalogEntryID) == "number" and catalogEntryID) or nil
  if recordID and type(C_HousingCatalog.GetCatalogEntryInfoByRecordID) == "function" then
    local decorEntryType = (Enum and Enum.HousingCatalogEntryType and Enum.HousingCatalogEntryType.Decor) or 1
    calls[#calls + 1] = { fn = C_HousingCatalog.GetCatalogEntryInfoByRecordID, args = { decorEntryType, recordID, true } }
    calls[#calls + 1] = { fn = C_HousingCatalog.GetCatalogEntryInfoByRecordID, args = { decorEntryType, recordID, false } }
  end

  for _, call in ipairs(calls) do
    local ok, result = pcall(call.fn, unpack(call.args))
    if ok and type(result) == "table" then
      info = result
      break
    end
  end
  if not info then
    return nil
  end
  return BuildProfessionEntryFromCatalogInfo(info)
end

local function BuildCatalogProfessionEntryByItem(itemID)
  local info = GetDecorCatalogInfo(itemID)
  if not info then
    return nil
  end
  return BuildProfessionEntryFromCatalogInfo(info, itemID)
end

local function ParsePercentFromValue(value, depth)
  depth = depth or 0
  if depth > 3 then
    return nil
  end

  local valueType = type(value)
  if valueType == "number" then
    if value >= 0 and value <= 1 then
      return value * 100.0
    end
    if value > 1 and value <= 100 then
      return value
    end
    return nil
  end

  if valueType == "string" then
    return ParsePercentFromText(value)
  end

  if valueType ~= "table" then
    return nil
  end

  local directKeys = {
    "dropRate",
    "dropChance",
    "chance",
    "percent",
    "percentage",
    "sourceText",
    "description",
  }

  for _, key in ipairs(directKeys) do
    local parsed = ParsePercentFromValue(value[key], depth + 1)
    if parsed then
      return parsed
    end
  end

  for _, nested in pairs(value) do
    local parsed = ParsePercentFromValue(nested, depth + 1)
    if parsed then
      return parsed
    end
  end

  return nil
end

local function ParsePercentFromTooltip(itemID)
  if not itemID or not C_TooltipInfo or not C_TooltipInfo.GetItemByID then
    return nil
  end

  local tipData = C_TooltipInfo.GetItemByID(itemID)
  if not tipData or not tipData.lines then
    return nil
  end

  for _, line in ipairs(tipData.lines) do
    local text = line.leftText or line.text
    local parsed = ParsePercentFromText(text)
    if parsed then
      return parsed
    end
  end

  return nil
end

local function ResolveDropRatePercent(lootInfo, catalogInfo, itemID)
  local parsed = ParsePercentFromValue(lootInfo)
  if parsed and parsed > 0 then
    return parsed
  end

  parsed = ParsePercentFromValue(catalogInfo)
  if parsed and parsed > 0 then
    return parsed
  end

  parsed = ParsePercentFromTooltip(itemID)
  if parsed and parsed > 0 then
    return parsed
  end

  return 100.0
end

local function FindBaseInstance(name, instanceType)
  for _, instance in ipairs(ns.baseData or {}) do
    if instance.name == name and instance.type == instanceType then
      return instance
    end
  end
  return nil
end

local function BuildInstanceEntry(instanceID, isRaid, tier)
  EJ_SelectInstance(instanceID)

  local name, _, _, _, _, _, _, _, dungeonMapID, mapID = EJ_GetInstanceInfo(instanceID)
  if not name then
    return nil
  end

  local instanceType = isRaid and "Raid" or "Dungeon"
  local expansion = nil
  if tier and EJ_GetTierInfo then
    local tierName = EJ_GetTierInfo(tier)
    if tierName and tierName ~= "" then
      expansion = tierName
    end
  end

  local base = FindBaseInstance(name, instanceType)
  if (not expansion or expansion == "") and base and base.expansion then
    expansion = base.expansion
  end
  if not expansion or expansion == "" then
    expansion = "Other"
  end
  local finalMapID = mapID or dungeonMapID or (base and base.mapID) or nil
  local entrance = nil
  if base and base.entrance and base.entrance.x and base.entrance.y then
    entrance = { x = base.entrance.x, y = base.entrance.y }
  end

  local entry = {
    instanceID = instanceID,
    name = name,
    type = instanceType,
    expansion = expansion,
    tier = tier,
    mapID = finalMapID,
    entrance = entrance,
    decorations = {},
  }

  local seenItems = {}
  local numLoot = EJ_GetNumLoot and EJ_GetNumLoot() or 0
  for lootIndex = 1, numLoot do
    local info = GetLootInfo(lootIndex)
    local catalogInfo = info and info.itemID and GetDecorCatalogInfo(info.itemID) or nil
    if info and info.itemID and not seenItems[info.itemID] and IsDecorItem(info.itemID) then
      entry.decorations[#entry.decorations + 1] = {
        itemID = info.itemID,
        name = info.name,
        source = GetEncounterName(info.encounterID),
        dropRate = ResolveDropRatePercent(info, catalogInfo, info.itemID),
      }
      seenItems[info.itemID] = true
    end
  end

  if #entry.decorations == 0 then
    return nil
  end

  return entry
end

local INSTANCE_TYPE_OVERRIDES = {
  ["blackrock depths"] = "Dungeon",
}

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

local function ResolveCanonicalScanType(name, existingType, incomingType)
  local key = NormalizeInstanceName(name)
  if INSTANCE_TYPE_OVERRIDES[key] then
    return INSTANCE_TYPE_OVERRIDES[key]
  end
  for _, base in ipairs(ns.baseData or {}) do
    if NormalizeInstanceName(base and base.name) == key and (base.type == "Dungeon" or base.type == "Raid") then
      return base.type
    end
  end
  if existingType == "Raid" or incomingType == "Raid" then
    return "Raid"
  end
  return "Dungeon"
end

local function MergeEntries(entries)
  local merged = {}
  local byKey = {}

  for _, entry in ipairs(entries) do
    local key = string.lower(
      (entry.expansion or "Other")
      .. "|" .. (entry.name or "")
      .. "|" .. tostring(entry.mapID or 0)
    )
    local existing = byKey[key]

    if not existing then
      existing = {
        instanceID = entry.instanceID,
        name = entry.name,
        type = entry.type,
        expansion = entry.expansion,
        tier = entry.tier,
        mapID = entry.mapID,
        entrance = (entry.entrance and entry.entrance.x and entry.entrance.y)
          and { x = entry.entrance.x, y = entry.entrance.y }
          or nil,
        decorations = {},
      }
      byKey[key] = existing
      merged[#merged + 1] = existing
    else
      existing.type = ResolveCanonicalScanType(entry.name, existing.type, entry.type)
    end

    local seen = {}
    for _, item in ipairs(existing.decorations) do
      seen[item.itemID] = true
    end

    for _, item in ipairs(entry.decorations or {}) do
      if item.itemID and not seen[item.itemID] then
        existing.decorations[#existing.decorations + 1] = item
        seen[item.itemID] = true
      end
    end
  end

  table.sort(merged, function(a, b)
    if a.type == b.type then
      return a.name < b.name
    end
    return a.type < b.type
  end)

  return merged
end

local function MergeProfessionEntries(entries, existing)
  local merged = {}
  local byKey = {}
  local byItemID = {}

  local function indexItem(group, item)
    local itemID = item and item.itemID
    if not itemID then
      return
    end
    byItemID[itemID] = byItemID[itemID] or {}
    byItemID[itemID][#byItemID[itemID] + 1] = {
      profession = group and group.profession or nil,
      item = item,
    }
  end

  local function mergeItemFields(target, incoming)
    if not target or not incoming then
      return
    end
    target.recipeKnown = incoming.recipeKnown and true or target.recipeKnown
    target.skillRequired = incoming.skillRequired or target.skillRequired
    if type(incoming.resources) == "table" and #incoming.resources > 0 then
      target.resources = incoming.resources
    end
    target.recipeID = incoming.recipeID or target.recipeID
    if (type(target.resources) ~= "table" or #target.resources == 0) and target.recipeID and BuildResourceListForRecipe then
      local schematicResources = BuildResourceListForRecipe(target.recipeID)
      if type(schematicResources) == "table" and #schematicResources > 0 then
        target.resources = schematicResources
      end
    end
    if (type(target.resources) ~= "table" or #target.resources == 0) and ns.GetStaticRecipeData then
      local staticData = ns.GetStaticRecipeData(target.itemID, target.recipeID)
      if staticData then
        if not target.recipeID and staticData.recipeID then
          target.recipeID = staticData.recipeID
        end
        if type(staticData.resources) == "table" and #staticData.resources > 0 then
          target.resources = staticData.resources
        end
      end
    end
  end

  local function findExistingByItemID(itemID, professionName)
    local candidates = byItemID[itemID]
    if type(candidates) ~= "table" then
      return nil
    end
    for _, candidate in ipairs(candidates) do
      if candidate and candidate.item and candidate.profession == professionName then
        return candidate.item
      end
    end
    return candidates[1] and candidates[1].item or nil
  end

  local function upsert(entry)
    local key = string.lower((entry.expansion or "Other") .. "|" .. (entry.profession or "Unknown Profession"))
    local current = byKey[key]
    if not current then
      current = {
        expansion = entry.expansion or "Other",
        profession = entry.profession or "Unknown Profession",
        professionID = entry.professionID,
        icon = ResolveProfessionIcon(entry.profession or "Unknown Profession", entry.icon),
        decorations = {},
      }
      byKey[key] = current
      merged[#merged + 1] = current
    end

    current.icon = ResolveProfessionIcon(current.profession, current.icon or entry.icon)
    if not current.professionID and entry.professionID then
      current.professionID = entry.professionID
    end

    local seen = {}
    for _, item in ipairs(current.decorations) do
      if item.itemID then
        seen[item.itemID] = item
        indexItem(current, item)
      end
    end

    for _, item in ipairs(entry.decorations or {}) do
      if item.itemID then
        local existingItem = seen[item.itemID]
        if existingItem then
          mergeItemFields(existingItem, item)
        else
          local crossGroupExisting = findExistingByItemID(item.itemID, current.profession)
          if crossGroupExisting then
            -- Preserve existing expansion grouping by updating the first matching item in place.
            mergeItemFields(crossGroupExisting, item)
          else
            current.decorations[#current.decorations + 1] = item
            seen[item.itemID] = item
            indexItem(current, item)
          end
        end
      end
    end
  end

  for _, e in ipairs(existing or {}) do
    upsert(e)
  end
  for _, e in ipairs(entries or {}) do
    upsert(e)
  end

  table.sort(merged, function(a, b)
    if a.expansion ~= b.expansion then
      return a.expansion < b.expansion
    end
    return a.profession < b.profession
  end)
  return merged
end

local function ReclassifyProfessionEntriesByCatalog(entries)
  if type(entries) ~= "table" or #entries == 0 then
    return entries or {}
  end

  local regrouped = {}
  local byKey = {}

  local function mergeItemFields(target, incoming)
    if not target or not incoming then
      return
    end
    target.recipeKnown = incoming.recipeKnown and true or target.recipeKnown
    target.skillRequired = incoming.skillRequired or target.skillRequired
    if type(incoming.resources) == "table" and #incoming.resources > 0 then
      target.resources = incoming.resources
    end
    target.recipeID = incoming.recipeID or target.recipeID
  end

  local function ensureGroup(expansionName, professionName, icon, professionID)
    local exp = expansionName or "Other"
    local prof = professionName or "Unknown Profession"
    local key = string.lower(exp .. "|" .. prof)
    local group = byKey[key]
    if not group then
      group = {
        expansion = exp,
        profession = prof,
        professionID = professionID,
        icon = ResolveProfessionIcon(prof, icon),
        decorations = {},
      }
      byKey[key] = group
      regrouped[#regrouped + 1] = group
    end
    if not group.professionID and professionID then
      group.professionID = professionID
    end
    group.icon = ResolveProfessionIcon(group.profession, group.icon or icon)
    return group
  end

  for _, group in ipairs(entries) do
    local baseExpansion = group.expansion or "Other"
    local baseProfession = group.profession or "Unknown Profession"
    local baseIcon = group.icon
    local baseProfessionID = group.professionID

    for _, item in ipairs(group.decorations or {}) do
      local expansionName = baseExpansion
      local professionName = baseProfession
      local professionIcon = baseIcon

      local itemID = tonumber(item and item.itemID)
      if itemID then
        local info = GetDecorCatalogInfo(itemID)
        if type(info) == "table" then
          local detectedProfession = DetectProfessionFromCatalogInfo(info) or DetectProfessionFromSourceText(info.sourceText)
          if detectedProfession then
            professionName = detectedProfession
          end
          local detectedExpansion = GuessExpansionFromText(info.sourceText or info.name or "")
          if detectedExpansion and detectedExpansion ~= "" then
            expansionName = detectedExpansion
          end
          professionIcon = ResolveProfessionIcon(professionName, info.icon or professionIcon)
        end
      end

      local targetGroup = ensureGroup(expansionName, professionName, professionIcon, baseProfessionID)
      local seen = nil
      for _, existingItem in ipairs(targetGroup.decorations) do
        if existingItem.itemID == item.itemID then
          seen = existingItem
          break
        end
      end
      if seen then
        mergeItemFields(seen, item)
      else
        targetGroup.decorations[#targetGroup.decorations + 1] = item
      end
    end
  end

  table.sort(regrouped, function(a, b)
    if a.expansion ~= b.expansion then
      return a.expansion < b.expansion
    end
    return a.profession < b.profession
  end)

  return regrouped
end

BuildResourceListForRecipe = function(recipeID)
  local resources = {}
  if not C_TradeSkillUI or not C_TradeSkillUI.GetRecipeSchematic then
    return resources
  end

  local ok, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
  if not ok or type(schematic) ~= "table" then
    return resources
  end

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

    if chosen then
      local reagentItemID = tonumber(chosen.itemID or chosen.reagentItemID)
      local finalQty = qty
      if finalQty <= 0 then
        finalQty = tonumber(chosen.quantity or chosen.quantityRequired or chosen.requiredQuantity) or 1
      end
      totals[reagentItemID] = (totals[reagentItemID] or 0) + math.max(1, finalQty)
    end
  end

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

  return resources
end

local function BuildProfessionEntry(tradeSkillLineID)
  if not C_TradeSkillUI then
    return nil
  end

  if C_TradeSkillUI.OpenTradeSkill then
    pcall(C_TradeSkillUI.OpenTradeSkill, tradeSkillLineID)
  end

  local infoBySkillLine = C_TradeSkillUI.GetProfessionInfoBySkillLineID and C_TradeSkillUI.GetProfessionInfoBySkillLineID(tradeSkillLineID) or nil
  local infoByLineID = C_TradeSkillUI.GetTradeSkillLineInfoByID and C_TradeSkillUI.GetTradeSkillLineInfoByID(tradeSkillLineID) or nil
  local info = infoBySkillLine or infoByLineID
  local rawProfession = info and (info.professionName or info.tradeSkillName or info.name or info.skillLineName) or nil
  local parentProfession = info and (info.parentProfessionName or info.parentTradeSkillLineName or info.parentSkillLineName) or nil
  local professionName = CanonicalProfessionName(rawProfession, parentProfession, tradeSkillLineID)
  if not KNOWN_PROFESSION_NAMES[professionName] then
    return nil
  end
  local expansionSeed = (info and (info.expansionName or info.expansion)) or nil
  if not expansionSeed or expansionSeed == "" then
    expansionSeed = rawProfession
  end
  local expansionName = GuessExpansionFromText(expansionSeed or "")
  local professionIcon = info and (info.iconID or info.icon) or nil

  local recipeIDs = {}
  if C_TradeSkillUI.GetFilteredRecipeIDs then
    local ok, recipes = pcall(C_TradeSkillUI.GetFilteredRecipeIDs)
    if ok and type(recipes) == "table" then
      recipeIDs = recipes
    end
  end
  if #recipeIDs == 0 and C_TradeSkillUI.GetAllRecipeIDs then
    local ok, recipes = pcall(C_TradeSkillUI.GetAllRecipeIDs)
    if ok and type(recipes) == "table" then
      recipeIDs = recipes
    end
  end

  local out = {
    expansion = expansionName or "Other",
    profession = professionName,
    professionID = tradeSkillLineID,
    icon = ResolveProfessionIcon(professionName, professionIcon),
    decorations = {},
  }
  local seen = {}

  for _, recipeID in ipairs(recipeIDs) do
    local recipeInfo = C_TradeSkillUI.GetRecipeInfo and C_TradeSkillUI.GetRecipeInfo(recipeID) or nil
    if RecipeBelongsToProfession(recipeInfo, tradeSkillLineID, professionName) then
      local recipeLink = C_TradeSkillUI.GetRecipeItemLink and C_TradeSkillUI.GetRecipeItemLink(recipeID) or nil
      local itemID = ParseItemIDFromLink(recipeLink)
      if itemID and not seen[itemID] then
        if IsDecorItem(itemID) then
          local recipeKnown = recipeInfo and (recipeInfo.learned or recipeInfo.isLearned)
          local skillRequired = recipeInfo and (recipeInfo.learnedAtSkillLineRank or recipeInfo.requiredSkillRank or recipeInfo.skillLineCurrentLevel) or nil
          out.decorations[#out.decorations + 1] = {
            itemID = itemID,
            name = recipeInfo and recipeInfo.name or nil,
            source = professionName,
            skillRequired = skillRequired,
            recipeID = recipeID,
            recipeKnown = recipeKnown and true or false,
            resources = BuildResourceListForRecipe(recipeID),
          }
          seen[itemID] = true
        end
      end
    end
  end

  if #out.decorations == 0 then
    return nil
  end
  return out
end

local function BuildInstanceQueue()
  local queue = {}

  if not EJ_GetNumTiers or not EJ_SelectTier then
    local i = 1
    while true do
      local dungeonID = EJ_GetInstanceByIndex(i, false)
      if not dungeonID then
        break
      end
      queue[#queue + 1] = { tier = nil, isRaid = false, instanceID = dungeonID }
      i = i + 1
    end

    i = 1
    while true do
      local raidID = EJ_GetInstanceByIndex(i, true)
      if not raidID then
        break
      end
      queue[#queue + 1] = { tier = nil, isRaid = true, instanceID = raidID }
      i = i + 1
    end

    return queue
  end

  local numTiers = EJ_GetNumTiers() or 0
  for tier = 1, numTiers do
    EJ_SelectTier(tier)

    local i = 1
    while true do
      local dungeonID = EJ_GetInstanceByIndex(i, false)
      if not dungeonID then
        break
      end
      queue[#queue + 1] = { tier = tier, isRaid = false, instanceID = dungeonID }
      i = i + 1
    end

    i = 1
    while true do
      local raidID = EJ_GetInstanceByIndex(i, true)
      if not raidID then
        break
      end
      queue[#queue + 1] = { tier = tier, isRaid = true, instanceID = raidID }
      i = i + 1
    end
  end

  return queue
end

local function FinishScan()
  if not scanState then
    return
  end

  local mode = scanState.mode or "instances"
  local mergedInstances = nil
  local mergedProfessions = nil

  if mode == "instances" or mode == "all" then
    local combined = {}
    for _, e in ipairs(DecorDropsDB.autoData or {}) do
      combined[#combined + 1] = e
    end
    for _, e in ipairs(scanState.instanceEntries or {}) do
      combined[#combined + 1] = e
    end
    mergedInstances = MergeEntries(combined)
    DecorDropsDB.autoData = mergedInstances
  end

  if mode == "professions" or mode == "all" then
    mergedProfessions = MergeProfessionEntries(scanState.professionEntries or {}, DecorDropsDB.autoProfessionData or {})
    mergedProfessions = ReclassifyProfessionEntriesByCatalog(mergedProfessions)
    mergedProfessions = RemoveKnownVendorMisclassifiedProfessionItems(mergedProfessions)
    DecorDropsDB.autoProfessionData = mergedProfessions
  end

  DecorDropsDB.autoScanAt = date("%Y-%m-%d %H:%M:%S")
  DecorDropsDB.scan.lastScanAt = time()
  DecorDropsDB.scan.lastScanAtByMode = DecorDropsDB.scan.lastScanAtByMode or {}
  local now = time()
  if mode == "professions" then
    DecorDropsDB.scan.lastScanAtByMode.professions = now
  elseif mode == "instances" then
    DecorDropsDB.scan.lastScanAtByMode.instances = now
  else
    DecorDropsDB.scan.lastScanAtByMode.instances = now
    DecorDropsDB.scan.lastScanAtByMode.professions = now
  end

  local instanceCount = 0
  local instanceItemCount = 0
  if mergedInstances then
    instanceCount = #mergedInstances
    for _, instance in ipairs(mergedInstances) do
      instanceItemCount = instanceItemCount + #instance.decorations
    end
  end

  local professionCount = 0
  local professionItemCount = 0
  if mergedProfessions then
    professionCount = #mergedProfessions
    for _, profession in ipairs(mergedProfessions) do
      professionItemCount = professionItemCount + #profession.decorations
    end
  end

  if scanState.originalTier and EJ_SelectTier then
    EJ_SelectTier(scanState.originalTier)
  end

  if mode == "instances" then
    PrintInfo(string.format("instance scan complete: %d instances, %d decor items.", instanceCount, instanceItemCount))
    ShowCompletionPrompt("DecorDrops: Dungeon/Raid scan complete.")
  elseif mode == "professions" then
    PrintInfo(string.format("profession scan complete: %d profession groups, %d decor items.", professionCount, professionItemCount))
    ShowCompletionPrompt("DecorDrops: Profession scan complete.")
  else
    PrintInfo(string.format("full scan complete: %d instances (%d items), %d profession groups (%d items).", instanceCount, instanceItemCount, professionCount, professionItemCount))
    ShowCompletionPrompt("DecorDrops: Full scan complete.")
  end

  scanState = nil
  if ns.SafeRefreshMainFrame then
    ns.SafeRefreshMainFrame()
  elseif ns.RefreshUI then
    ns.RefreshUI()
  end
end

scanFrame:SetScript("OnUpdate", function(_, elapsed)
  if not scanState then
    return
  end

  scanState.elapsed = scanState.elapsed + elapsed
  local interval = tonumber(DecorDropsDB.scan and DecorDropsDB.scan.interval) or 0.15
  local useFrameBudget = (scanState.mode == "professions") and (not scanState.usesTradeSkillProfessionJobs)
  if useFrameBudget then
    interval = 0
  end
  if scanState.mode == "professions" and scanState.usesTradeSkillProfessionJobs then
    interval = math.max(interval, TRADE_SKILL_SCAN_INTERVAL_SECONDS)
  end
  if scanState.elapsed < interval then
    return
  end

  scanState.elapsed = 0

  local startMS = NowMS()
  local processedThisTick = 0
  local frameBudgetMs = tonumber(scanState.frameBudgetMs) or 1.2
  local maxJobsPerTick = useFrameBudget and 50 or 1

  while processedThisTick < maxJobsPerTick do
    local nextJob = scanState.queue[scanState.index]
    if not nextJob then
      FinishScan()
      return
    end

    local ok, err = pcall(function()
      if nextJob.mode == "instance" then
        if nextJob.tier and EJ_SelectTier and scanState.currentTier ~= nextJob.tier then
          EJ_SelectTier(nextJob.tier)
          scanState.currentTier = nextJob.tier
        end

        local entry = BuildInstanceEntry(nextJob.instanceID, nextJob.isRaid, nextJob.tier)
        if entry then
          scanState.instanceEntries[#scanState.instanceEntries + 1] = entry
        end
      elseif nextJob.mode == "profession" then
        local entry = BuildProfessionEntry(nextJob.tradeSkillLineID)
        if entry then
          scanState.professionEntries[#scanState.professionEntries + 1] = entry
        end
      elseif nextJob.mode == "catalogProfession" then
        local entry = BuildCatalogProfessionEntry(nextJob.catalogEntryID)
        if entry then
          scanState.professionEntries[#scanState.professionEntries + 1] = entry
        end
      elseif nextJob.mode == "catalogRecord" then
        local entry = BuildCatalogProfessionEntry(nextJob.recordID)
        if entry then
          scanState.professionEntries[#scanState.professionEntries + 1] = entry
        end
      elseif nextJob.mode == "catalogItem" then
        local entry = BuildCatalogProfessionEntryByItem(nextJob.itemID)
        if entry then
          scanState.professionEntries[#scanState.professionEntries + 1] = entry
        end
      end
    end)
    if not ok then
      PrintInfo(string.format("scan job failed (%s): %s", tostring(nextJob.mode), tostring(err)))
    end

    scanState.index = scanState.index + 1
    processedThisTick = processedThisTick + 1

    if (scanState.index - 1) % 50 == 0 then
      local processed = scanState.index - 1
      PrintInfo(string.format("scan progress: %d/%d jobs", processed, scanState.total))
    end

    if useFrameBudget then
      local spent = NowMS() - startMS
      if spent >= frameBudgetMs then
        break
      end
    else
      break
    end
  end

  if useFrameBudget then
    local target = tonumber(DecorDropsDB.scan and DecorDropsDB.scan.frameBudgetMs) or 1.2
    target = math.max(0.5, math.min(3.0, target))
    if elapsed > 0.030 then
      scanState.frameBudgetMs = math.max(0.5, scanState.frameBudgetMs * 0.9)
    elseif elapsed < 0.017 then
      scanState.frameBudgetMs = math.min(target * 1.5, scanState.frameBudgetMs + 0.05)
    else
      scanState.frameBudgetMs = scanState.frameBudgetMs + (target - scanState.frameBudgetMs) * 0.10
    end
  end
end)

local function TryStartScan(scanMode, retriesRemaining)
  if scanState then
    PrintInfo("scan already in progress.")
    return
  end

  if (scanMode == "instances" or scanMode == "all") then
    EnsureJournalLoaded()
    if not EJ_GetInstanceByIndex then
      PrintInfo("instance scan unavailable in this client build.")
      return
    end
  end

  if scanMode == "all" then
    EnsureTradeSkillLoaded()
  end

  local queue = {}
  local instanceJobs = 0
  local professionJobs = 0
  if scanMode == "instances" or scanMode == "all" then
    for _, job in ipairs(BuildInstanceQueue()) do
      job.mode = "instance"
      queue[#queue + 1] = job
      instanceJobs = instanceJobs + 1
    end
  end

  if scanMode == "professions" or scanMode == "all" then
    local profQueue = {}
    local catalogQueue = BuildCatalogProfessionQueue()
    local usedRecordSweep = false
    local hasExistingProfessionData = type(DecorDropsDB.autoProfessionData) == "table" and #DecorDropsDB.autoProfessionData > 0
    if #catalogQueue == 0 and (scanMode == "professions" or scanMode == "all") then
      if hasExistingProfessionData then
        catalogQueue = BuildCatalogItemQueueFromExisting(DecorDropsDB.autoProfessionData)
        if retriesRemaining <= 0 then
          PrintInfo(string.format("catalog search returned no entries; refreshing %d existing profession items via catalog-by-item.", #catalogQueue))
        end
      else
        local maxRecordID = tonumber(DecorDropsDB.scan and DecorDropsDB.scan.catalogRecordMax) or 12000
        if scanMode == "all" then
          maxRecordID = math.min(maxRecordID, 3000)
        end
        catalogQueue = BuildCatalogRecordQueue(maxRecordID)
        usedRecordSweep = true
        if retriesRemaining <= 0 then
          PrintInfo(string.format("catalog search returned no entries; running recordID sweep compile (1-%d).", maxRecordID))
        end
      end
    end

    for _, job in ipairs(catalogQueue) do
      profQueue[#profQueue + 1] = job
    end

    for _, job in ipairs(profQueue) do
      queue[#queue + 1] = job
      professionJobs = professionJobs + 1
    end
    if usedRecordSweep and #catalogQueue == 0 and retriesRemaining <= 0 then
      PrintInfo("catalog recordID sweep could not be started.")
    end
  end

  if #queue == 0 then
    if retriesRemaining > 0 then
      C_Timer.After(0.30, function()
        TryStartScan(scanMode, retriesRemaining - 1)
      end)
      return
    end
    PrintInfo("no scan jobs found for current mode.")
    return
  end

  if not CanUseHousingCatalog() then
    PrintInfo("housing catalog API unavailable in this build; scanning without decor-only filtering.")
  end

  scanState = {
    mode = scanMode,
    queue = queue,
    index = 1,
    total = #queue,
    instanceEntries = {},
    professionEntries = {},
    elapsed = 0,
    currentTier = nil,
    originalTier = EJ_GetCurrentTier and EJ_GetCurrentTier() or nil,
    usesTradeSkillProfessionJobs = false,
    frameBudgetMs = math.max(0.5, math.min(3.0, tonumber(DecorDropsDB.scan and DecorDropsDB.scan.frameBudgetMs) or 1.2)),
  }

  for _, job in ipairs(queue) do
    if job.mode == "profession" then
      scanState.usesTradeSkillProfessionJobs = true
      break
    end
  end

  PrintInfo(string.format("%s scan started (%d jobs queued: %d instance, %d profession).", scanMode, #queue, instanceJobs, professionJobs))
end

function ns.RunAutoScan(mode)
  if ns.IsDeveloperToolsEnabled and not ns.IsDeveloperToolsEnabled() then
    PrintInfo("manual scan is developer-only in this build.")
    return
  end
  EnsureScanDefaults()
  local scanMode = mode or "all"

  if InCombatLockdown and InCombatLockdown() then
    PrintInfo("cannot start scan while in combat.")
    return
  end

  if scanState then
    PrintInfo("scan already in progress.")
    return
  end

  PrintInfo(string.format("starting %s scan...", scanMode))
  TryStartScan(scanMode, 10)
end

function ns.RequestScan(mode)
  if ns.IsDeveloperToolsEnabled and not ns.IsDeveloperToolsEnabled() then
    PrintInfo("manual scan is developer-only in this build.")
    return
  end
  EnsureScanDefaults()
  local scanMode = mode or "all"
  local scanLabel = ScanModeLabel(scanMode)

  if InCombatLockdown and InCombatLockdown() then
    PrintInfo("cannot start scan while in combat.")
    return
  end

  if scanState then
    PrintInfo("scan already in progress.")
    return
  end

  local now = time()
  local cooldownKey = CooldownKeyForMode(scanMode)
  local byMode = DecorDropsDB.scan.lastScanAtByMode or {}
  local last = tonumber(byMode[cooldownKey]) or 0
  if last > 0 and (now - last) < MANUAL_SCAN_COOLDOWN_SECONDS then
    local remaining = math.max(1, MANUAL_SCAN_COOLDOWN_SECONDS - (now - last))
    PrintInfo(string.format("manual scan cooldown active (%ds remaining). You can scan once every %d seconds.", remaining, MANUAL_SCAN_COOLDOWN_SECONDS))
    return
  end

  if not StaticPopupDialogs[POPUP_KEY] then
    StaticPopupDialogs[POPUP_KEY] = {
      text = string.format("DecorDrops scan can take time and may cause temporary lag.\nManual scans are limited to once every %d seconds.\nStart decor scan now?", MANUAL_SCAN_COOLDOWN_SECONDS),
      button1 = YES,
      button2 = NO,
      OnAccept = function(self, data)
        local modeToRun = data or (self and self.data) or "all"
        ns.RunAutoScan(modeToRun)
      end,
      timeout = 0,
      whileDead = true,
      hideOnEscape = true,
      preferredIndex = STATICPOPUP_NUMDIALOGS,
    }
  end

  local popup = StaticPopup_Show(POPUP_KEY, nil, nil, scanMode)
  if popup then
    if ns.PositionPopupOverMainFrame then
      ns.PositionPopupOverMainFrame(popup)
    end
    local popupText = popup.Text or popup.text
    if popupText and popupText.SetText then
      popupText:SetText(string.format("DecorDrops %s scan can take time and may cause temporary lag.\nManual scans are limited to once every %d seconds.\nStart scan now?", scanLabel, MANUAL_SCAN_COOLDOWN_SECONDS))
    end
    popup.data = scanMode
  else
    PrintInfo("scan confirmation dialog unavailable; starting scan directly.")
    ns.RunAutoScan(scanMode)
  end
end

function ns.ClearAutoData()
  if ns.IsDeveloperToolsEnabled and not ns.IsDeveloperToolsEnabled() then
    PrintInfo("clearauto is developer-only in this build.")
    return
  end
  scanState = nil
  DecorDropsDB.autoData = {}
  DecorDropsDB.autoProfessionData = {}
  DecorDropsDB.autoScanAt = nil
  PrintInfo("auto-scanned data cleared.")

  if ns.SafeRefreshMainFrame then
    ns.SafeRefreshMainFrame()
  elseif ns.RefreshUI then
    ns.RefreshUI()
  end
end
