local _, ns = ...

-- Base instances are used for map/entrance metadata.
-- Decoration rows come from auto-scan data (SavedVariables) and/or your manual additions.
ns.baseData = {
  {
    name = "The Stonevault",
    type = "Dungeon",
    expansion = "The War Within",
    mapID = 2341,
    entrance = { x = 47.3, y = 25.2 },
    decorations = {},
  },
  {
    name = "City of Threads",
    type = "Dungeon",
    expansion = "The War Within",
    mapID = 2213,
    entrance = { x = 56.8, y = 33.0 },
    decorations = {},
  },
  {
    name = "Ara-Kara, City of Echoes",
    type = "Dungeon",
    expansion = "The War Within",
    mapID = 2359,
    entrance = { x = 54.0, y = 42.1 },
    decorations = {},
  },
  {
    name = "The Dawnbreaker",
    type = "Dungeon",
    expansion = "The War Within",
    mapID = 2357,
    entrance = { x = 27.6, y = 35.9 },
    decorations = {},
  },
  {
    name = "Priory of the Sacred Flame",
    type = "Dungeon",
    expansion = "The War Within",
    mapID = 2308,
    entrance = { x = 39.5, y = 50.1 },
    decorations = {},
  },
  {
    name = "Operation: Floodgate",
    type = "Dungeon",
    expansion = "The War Within",
    mapID = 2387,
    entrance = { x = 69.9, y = 37.4 },
    decorations = {},
  },
  {
    name = "Nerub-ar Palace",
    type = "Raid",
    expansion = "The War Within",
    mapID = 2291,
    entrance = { x = 54.1, y = 59.0 },
    decorations = {},
  },
  {
    name = "Liberation of Undermine",
    type = "Raid",
    expansion = "The War Within",
    mapID = 2406,
    entrance = { x = 41.9, y = 79.5 },
    decorations = {},
  },
}

ns.data = ns.baseData
ns.baseProfessionData = ns.baseProfessionData or {}
ns.staticDecorCatalog = ns.staticDecorCatalog or { byItemID = {}, meta = {} }
ns.staticProfessionResources = ns.staticProfessionResources or {
  byItemID = {},
  byRecipeID = {},
}
ns.acquireData = ns.acquireData or { groupsByMode = {} }
ns.staticAcquireData = ns.staticAcquireData or { groupsByMode = {} }
ns.staticQuestUnlocks = ns.staticQuestUnlocks or {}
ns.staticQuestRewards = ns.staticQuestRewards or {}
ns.staticScannedData = ns.staticScannedData or { instances = {}, professions = {} }

local staticResourceCache = nil

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

local function NormalizeInstanceType(value)
  local text = NormalizeInstanceName(value)
  if text == "raid" then
    return "Raid"
  end
  return "Dungeon"
end

local function BuildInstanceWaypointOverrideKey(instanceType, instanceName)
  local normalizedName = NormalizeInstanceName(instanceName)
  if normalizedName == "" then
    return nil
  end
  local normalizedType = NormalizeInstanceType(instanceType)
  return string.lower(normalizedType .. "|" .. normalizedName)
end
ns.BuildInstanceWaypointOverrideKey = BuildInstanceWaypointOverrideKey

local function ApplyWaypointOverride(instance)
  if type(instance) ~= "table" or not VamLaxPowerCrafterDB then
    return
  end
  local overrides = VamLaxPowerCrafterDB.instanceWaypointOverrides
  if type(overrides) ~= "table" then
    return
  end
  local key = BuildInstanceWaypointOverrideKey(instance.type, instance.name)
  if not key then
    return
  end
  local override = overrides[key]
  if type(override) ~= "table" then
    return
  end

  local mapID = tonumber(override.mapID)
  if mapID and mapID > 0 then
    instance.mapID = mapID
  end

  local ox = tonumber(override.x)
  local oy = tonumber(override.y)
  if ox and oy then
    instance.entrance = { x = ox, y = oy }
  end
  local zoneName = tostring(override.zoneName or "")
  if zoneName ~= "" then
    instance.zoneName = zoneName
  end
end

local function CloneResourceList(resources)
  local copy = {}
  for _, reagent in ipairs(resources or {}) do
    local itemID = tonumber(reagent and reagent.itemID)
    local quantity = tonumber(reagent and reagent.quantity) or 1
    if itemID and itemID > 0 then
      copy[#copy + 1] = {
        itemID = itemID,
        quantity = quantity > 0 and quantity or 1,
      }
    end
  end
  return copy
end

local function BuildStaticResourceCache()
  if staticResourceCache then
    return staticResourceCache
  end

  local byItemID = {}
  local byRecipeID = {}
  local staticData = ns.staticProfessionResources or {}

  for itemID, record in pairs(staticData.byItemID or {}) do
    local numericItemID = tonumber(itemID)
    if numericItemID and numericItemID > 0 and type(record) == "table" then
      byItemID[numericItemID] = {
        recipeID = tonumber(record.recipeID),
        resources = CloneResourceList(record.resources),
      }
    end
  end

  for recipeID, resources in pairs(staticData.byRecipeID or {}) do
    local numericRecipeID = tonumber(recipeID)
    if numericRecipeID and numericRecipeID > 0 then
      byRecipeID[numericRecipeID] = CloneResourceList(resources)
    end
  end

  staticResourceCache = {
    byItemID = byItemID,
    byRecipeID = byRecipeID,
  }
  return staticResourceCache
end

function ns.GetStaticRecipeData(itemID, recipeID)
  local cache = BuildStaticResourceCache()
  local numericItemID = tonumber(itemID)
  local numericRecipeID = tonumber(recipeID)

  if numericRecipeID and numericRecipeID > 0 then
    local fromRecipe = cache.byRecipeID[numericRecipeID]
    if type(fromRecipe) == "table" and #fromRecipe > 0 then
      return {
        recipeID = numericRecipeID,
        resources = CloneResourceList(fromRecipe),
      }
    end
  end

  if numericItemID and numericItemID > 0 then
    local fromItem = cache.byItemID[numericItemID]
    if type(fromItem) == "table" then
      return {
        recipeID = tonumber(fromItem.recipeID) or numericRecipeID,
        resources = CloneResourceList(fromItem.resources or {}),
      }
    end
  end

  local byCatalogItem = ns.staticDecorCatalog and ns.staticDecorCatalog.byItemID or nil
  if numericItemID and numericItemID > 0 and type(byCatalogItem) == "table" then
    local record = byCatalogItem[numericItemID]
    if type(record) == "table" and (record.recipeID or (type(record.resources) == "table" and #record.resources > 0)) then
      return {
        recipeID = tonumber(record.recipeID) or numericRecipeID,
        resources = CloneResourceList(record.resources or {}),
      }
    end
  end

  return nil
end

function ns.GetStaticDecorInfo(itemID)
  local numericItemID = tonumber(itemID)
  if not numericItemID or numericItemID <= 0 then
    return nil
  end
  local byItem = ns.staticDecorCatalog and ns.staticDecorCatalog.byItemID or nil
  if type(byItem) ~= "table" then
    return nil
  end
  return byItem[numericItemID]
end

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

  local resources = item.resources
  if type(resources) == "table" and #resources > 0 then
    return
  end

  local staticData = ns.GetStaticRecipeData and ns.GetStaticRecipeData(item.itemID, item.recipeID) or nil
  if not staticData then
    return
  end

  if not item.recipeID and staticData.recipeID then
    item.recipeID = staticData.recipeID
  end
  if type(staticData.resources) == "table" and #staticData.resources > 0 then
    item.resources = staticData.resources
  end
end

local function CloneInstance(instance)
  local entrance = nil
  if instance.entrance and instance.entrance.x and instance.entrance.y then
    entrance = { x = instance.entrance.x, y = instance.entrance.y }
  end

  local copy = {
    instanceID = instance.instanceID,
    name = instance.name,
    type = instance.type,
    expansion = (instance.expansion and instance.expansion ~= "Unknown Expansion" and instance.expansion) or "Other",
    tier = instance.tier,
    mapID = instance.mapID,
    zoneName = instance.zoneName,
    entrance = entrance,
    decorations = {},
  }
  ApplyWaypointOverride(copy)

  for _, item in ipairs(instance.decorations or {}) do
    copy.decorations[#copy.decorations + 1] = {
      itemID = item.itemID,
      name = item.name,
      source = item.source,
      dropRate = item.dropRate,
    }
  end

  return copy
end

local function MergeInstance(target, incoming)
  if (not target.expansion or target.expansion == "Unknown Expansion" or target.expansion == "Other") and incoming.expansion then
    target.expansion = incoming.expansion
  end
  if not target.tier and incoming.tier then
    target.tier = incoming.tier
  end
  if not target.instanceID and incoming.instanceID then
    target.instanceID = incoming.instanceID
  end
  if (not target.mapID or target.mapID == 0) and incoming.mapID then
    target.mapID = incoming.mapID
  end
  if (not target.zoneName or target.zoneName == "") and incoming.zoneName then
    target.zoneName = incoming.zoneName
  end
  if not target.entrance and incoming.entrance and incoming.entrance.x and incoming.entrance.y then
    target.entrance = { x = incoming.entrance.x, y = incoming.entrance.y }
  end

  local seen = {}
  for _, item in ipairs(target.decorations) do
    local key = tostring(item.itemID or "") .. ":" .. tostring(item.name or "")
    seen[key] = true
  end

  for _, item in ipairs(incoming.decorations or {}) do
    local key = tostring(item.itemID or "") .. ":" .. tostring(item.name or "")
    if not seen[key] then
      target.decorations[#target.decorations + 1] = {
        itemID = item.itemID,
        name = item.name,
        source = item.source,
        dropRate = item.dropRate,
      }
      seen[key] = true
    end
  end
end

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

local function BuildBaseInstanceTypeIndex()
  local out = {}
  for _, instance in ipairs(ns.baseData or {}) do
    local key = NormalizeInstanceName(instance and instance.name)
    if key ~= "" and (instance.type == "Raid" or instance.type == "Dungeon") and not out[key] then
      out[key] = instance.type
    end
  end
  return out
end

local function BuildStaticTypeSignalByName()
  local signalByName = {}
  local byItem = ns.staticDecorCatalog and ns.staticDecorCatalog.byItemID or nil
  if type(byItem) ~= "table" then
    return signalByName
  end

  local function record(name, typeLabel)
    local key = NormalizeInstanceName(name)
    if key == "" then
      return
    end
    signalByName[key] = signalByName[key] or { raid = 0, dungeon = 0 }
    if typeLabel == "Raid" then
      signalByName[key].raid = signalByName[key].raid + 1
    else
      signalByName[key].dungeon = signalByName[key].dungeon + 1
    end
  end

  for _, row in pairs(byItem) do
    local sourceText = string.lower(tostring(type(row) == "table" and row.sourceText or ""))
    if sourceText ~= "" then
      local name, suffix = string.match(sourceText, "%((.-)%s+(raid)%)")
      if name and suffix == "raid" then
        record(name, "Raid")
      end
      name, suffix = string.match(sourceText, "%((.-)%s+(dungeon)%)")
      if name and suffix == "dungeon" then
        record(name, "Dungeon")
      end
    end
  end

  return signalByName
end

local function ResolveCanonicalInstanceType(name, candidates, baseTypeByName, staticSignalByName)
  local nameKey = NormalizeInstanceName(name)
  if nameKey == "" then
    return "Dungeon"
  end

  if INSTANCE_TYPE_OVERRIDES[nameKey] then
    return INSTANCE_TYPE_OVERRIDES[nameKey]
  end

  if baseTypeByName and baseTypeByName[nameKey] then
    return baseTypeByName[nameKey]
  end

  local signal = staticSignalByName and staticSignalByName[nameKey] or nil
  if signal then
    if signal.raid > signal.dungeon then
      return "Raid"
    end
    if signal.dungeon > signal.raid then
      return "Dungeon"
    end
  end

  local raidScore = 0
  local dungeonScore = 0
  for _, candidate in ipairs(candidates or {}) do
    if candidate.type == "Raid" then
      raidScore = raidScore + #(candidate.decorations or {})
    else
      dungeonScore = dungeonScore + #(candidate.decorations or {})
    end
  end
  if raidScore > dungeonScore then
    return "Raid"
  end
  if dungeonScore > raidScore then
    return "Dungeon"
  end

  return "Dungeon"
end

local function MergeCrossTypeDuplicates(instances)
  local grouped = {}
  local order = {}
  for _, instance in ipairs(instances or {}) do
    local key = string.lower(table.concat({
      tostring(instance.expansion or "Other"),
      tostring(instance.name or ""),
      tostring(instance.mapID or 0),
    }, "|"))
    if not grouped[key] then
      grouped[key] = {}
      order[#order + 1] = key
    end
    grouped[key][#grouped[key] + 1] = instance
  end

  local baseTypeByName = BuildBaseInstanceTypeIndex()
  local staticSignalByName = BuildStaticTypeSignalByName()
  local mergedOut = {}

  for _, key in ipairs(order) do
    local candidates = grouped[key]
    if #candidates <= 1 then
      mergedOut[#mergedOut + 1] = candidates[1]
    else
      local canonicalType = ResolveCanonicalInstanceType(candidates[1].name, candidates, baseTypeByName, staticSignalByName)
      local chosen = nil
      for _, candidate in ipairs(candidates) do
        if candidate.type == canonicalType then
          chosen = candidate
          break
        end
      end
      chosen = chosen or candidates[1]
      local merged = {
        instanceID = chosen.instanceID,
        name = chosen.name,
        type = canonicalType,
        expansion = chosen.expansion,
        tier = chosen.tier,
        mapID = chosen.mapID,
        zoneName = chosen.zoneName,
        entrance = (chosen.entrance and chosen.entrance.x and chosen.entrance.y)
          and { x = chosen.entrance.x, y = chosen.entrance.y }
          or nil,
        decorations = {},
      }
      for _, candidate in ipairs(candidates) do
        MergeInstance(merged, candidate)
      end
      merged.type = canonicalType
      mergedOut[#mergedOut + 1] = merged
    end
  end

  return mergedOut
end

function ns.GetData()
  local merged = {}
  local indexByKey = {}

  for _, instance in ipairs(ns.baseData or {}) do
    local cloned = CloneInstance(instance)
    local key = string.lower(
      (cloned.expansion or "Other")
      .. "|" .. cloned.type
      .. "|" .. cloned.name
      .. "|" .. tostring(cloned.mapID or 0)
    )
    indexByKey[key] = #merged + 1
    merged[#merged + 1] = cloned
  end

  for _, instance in ipairs((ns.staticScannedData and ns.staticScannedData.instances) or {}) do
    local key = string.lower(
      ((instance.expansion and instance.expansion ~= "Unknown Expansion" and instance.expansion) or "Other")
      .. "|" .. (instance.type or "Dungeon")
      .. "|" .. (instance.name or "")
      .. "|" .. tostring(instance.mapID or 0)
    )
    local existingIndex = indexByKey[key]
    if existingIndex then
      MergeInstance(merged[existingIndex], instance)
    else
      local cloned = CloneInstance(instance)
      indexByKey[key] = #merged + 1
      merged[#merged + 1] = cloned
    end
  end

  local auto = (VamLaxPowerCrafterDB and VamLaxPowerCrafterDB.autoData) or {}
  for _, instance in ipairs(auto) do
    local key = string.lower(
      ((instance.expansion and instance.expansion ~= "Unknown Expansion" and instance.expansion) or "Other")
      .. "|" .. (instance.type or "Dungeon")
      .. "|" .. (instance.name or "")
      .. "|" .. tostring(instance.mapID or 0)
    )
    local existingIndex = indexByKey[key]
    if existingIndex then
      MergeInstance(merged[existingIndex], instance)
    else
      local cloned = CloneInstance(instance)
      indexByKey[key] = #merged + 1
      merged[#merged + 1] = cloned
    end
  end

  for _, instance in ipairs(merged) do
    ApplyWaypointOverride(instance)
  end

  merged = MergeCrossTypeDuplicates(merged)

  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 CloneProfessionEntry(entry)
  local blockedNames = {
    ["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 IsBlockedProfessionItemName(name)
    local key = string.lower(tostring(name or ""))
    key = key:gsub("^%s+", ""):gsub("%s+$", "")
    if key == "" then
      return false
    end
    return blockedNames[key] == true
  end

  local copy = {
    expansion = entry.expansion or "Other",
    professionID = entry.professionID,
    profession = entry.profession or "Unknown Profession",
    icon = entry.icon,
    decorations = {},
  }

  for _, item in ipairs(entry.decorations or {}) do
    if not IsBlockedProfessionItemName(item and item.name) then
      local resources = {}
      for _, reagent in ipairs(item.resources or {}) do
        resources[#resources + 1] = {
          itemID = reagent.itemID,
          name = reagent.name,
          quantity = reagent.quantity,
          icon = reagent.icon,
        }
      end
      copy.decorations[#copy.decorations + 1] = {
        itemID = item.itemID,
        name = item.name,
        source = item.source or item.profession or copy.profession,
        skillRequired = item.skillRequired,
        recipeID = item.recipeID,
        recipeKnown = item.recipeKnown and true or false,
        resources = resources,
      }
      ApplyStaticProfessionFallback(copy.decorations[#copy.decorations])
    end
  end

  return copy
end

local function MergeProfessionEntry(target, incoming)
  local blockedNames = {
    ["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 IsBlockedProfessionItemName(name)
    local key = string.lower(tostring(name or ""))
    key = key:gsub("^%s+", ""):gsub("%s+$", "")
    if key == "" then
      return false
    end
    return blockedNames[key] == true
  end

  if not target.icon and incoming.icon then
    target.icon = incoming.icon
  end
  if not target.professionID and incoming.professionID then
    target.professionID = incoming.professionID
  end

  local seen = {}
  for _, item in ipairs(target.decorations or {}) do
    if item.itemID and not IsBlockedProfessionItemName(item.name) then
      seen[item.itemID] = item
    end
  end

  for _, item in ipairs(incoming.decorations or {}) do
    if item.itemID and not IsBlockedProfessionItemName(item.name) then
      local existing = seen[item.itemID]
      if existing then
        existing.name = item.name or existing.name
        existing.skillRequired = item.skillRequired or existing.skillRequired
        existing.recipeID = item.recipeID or existing.recipeID
        existing.recipeKnown = item.recipeKnown and true or false
        existing.resources = item.resources or existing.resources
        ApplyStaticProfessionFallback(existing)
      else
        target.decorations[#target.decorations + 1] = {
          itemID = item.itemID,
          name = item.name,
          source = item.source or item.profession or target.profession,
          skillRequired = item.skillRequired,
          recipeID = item.recipeID,
          recipeKnown = item.recipeKnown and true or false,
          resources = item.resources or {},
        }
        ApplyStaticProfessionFallback(target.decorations[#target.decorations])
      end
    end
  end
end

function ns.GetProfessionData()
  if type(VamLaxPowerCrafterDB) == "table"
    and tonumber(VamLaxPowerCrafterDB.professionCategoryMigrationVersion)
    and tonumber(VamLaxPowerCrafterDB.professionCategoryMigrationVersion) >= 2 then
    VamLaxPowerCrafterDB.autoProfessionData = {}
    VamLaxPowerCrafterDB.professionCategoryMigrationVersion = nil
  end

  local merged = {}
  local indexByKey = {}

  local function addEntry(entry)
    local key = string.lower((entry.expansion or "Other") .. "|" .. (entry.profession or "Unknown Profession"))
    local idx = indexByKey[key]
    if idx then
      MergeProfessionEntry(merged[idx], entry)
    else
      local cloned = CloneProfessionEntry(entry)
      indexByKey[key] = #merged + 1
      merged[#merged + 1] = cloned
    end
  end

  for _, entry in ipairs(ns.baseProfessionData or {}) do
    addEntry(entry)
  end

  for _, entry in ipairs((ns.staticScannedData and ns.staticScannedData.professions) or {}) do
    addEntry(entry)
  end

  for _, entry in ipairs((VamLaxPowerCrafterDB and VamLaxPowerCrafterDB.autoProfessionData) or {}) do
    addEntry(entry)
  end

  for _, entry in ipairs(merged) do
    for _, item in ipairs(entry.decorations or {}) do
      ApplyStaticProfessionFallback(item)
    end
  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 CloneAcquireItem(item)
  if type(item) ~= "table" then
    return nil
  end

  local purchaseCosts = {}
  for _, cost in ipairs(item.purchaseCosts or {}) do
    purchaseCosts[#purchaseCosts + 1] = {
      costType = cost.costType,
      copper = tonumber(cost.copper),
      quantity = tonumber(cost.quantity),
      currencyID = tonumber(cost.currencyID),
      itemID = tonumber(cost.itemID),
    }
  end

  return {
    itemID = tonumber(item.itemID),
    name = item.name,
    source = item.source,
    sourceType = tonumber(item.sourceType) or 0,
    sourceName = item.sourceName,
    sourceDetail = item.sourceDetail,
    displaySource = item.displaySource,
    acquireStatus = item.acquireStatus,
    category = item.category,
    expansion = item.expansion,
    mapID = tonumber(item.mapID),
    x = tonumber(item.x),
    y = tonumber(item.y),
    npcID = tonumber(item.npcID),
    npcName = item.npcName,
    zone = item.zone,
    faction = item.faction,
    factionName = item.factionName,
    factionID = tonumber(item.factionID),
    minRep = tonumber(item.minRep),
    questID = tonumber(item.questID),
    achievementID = tonumber(item.achievementID),
    repStandingText = item.repStandingText,
    purchaseCosts = purchaseCosts,
  }
end

local function CloneAcquireGroup(group)
  if type(group) ~= "table" then
    return nil
  end

  local copy = {
    key = group.key,
    mode = group.mode,
    category = group.category,
    expansion = group.expansion or "Other",
    label = group.label or "Source",
    detail = group.detail or "",
    waypoint = nil,
    rows = {},
  }

  if type(group.waypoint) == "table" then
    copy.waypoint = {
      mapID = tonumber(group.waypoint.mapID),
      x = tonumber(group.waypoint.x),
      y = tonumber(group.waypoint.y),
      zone = group.waypoint.zone,
      npcID = tonumber(group.waypoint.npcID),
      npcName = group.waypoint.npcName,
      title = group.waypoint.title,
    }
  end

  for _, item in ipairs(group.items or {}) do
    local cloned = CloneAcquireItem(item)
    if cloned then
      copy.rows[#copy.rows + 1] = cloned
    end
  end

  return copy
end

function ns.IsQuestGroupVendorUnlockLike(group)
  if type(group) ~= "table" then
    return false
  end

  local labelKey = string.lower(tostring(group.label or ""))
  if labelKey ~= "decor treasure hunt" then
    local foundByRowSource = false
    for _, row in ipairs(group.items or group.rows or {}) do
      if type(row) == "table" then
        local sourceKey = string.lower(tostring(row.sourceName or row.source or ""))
        if sourceKey == "decor treasure hunt" then
          foundByRowSource = true
          break
        end
      end
    end
    if not foundByRowSource then
      return false
    end
  end

  local rows = group.items or group.rows or {}
  if type(rows) ~= "table" or #rows == 0 then
    return false
  end

  local hasCost = false
  for _, row in ipairs(rows) do
    if type(row) == "table" then
      if (tonumber(row.questID) or 0) > 0 then
        return false
      end
      if (type(row.purchaseCosts) == "table" and #row.purchaseCosts > 0)
        or ((tonumber(row.price) or 0) > 0)
        or ((tonumber(row.cost) or 0) > 0) then
        hasCost = true
      end
    end
  end
  return hasCost
end

local NEIGHBOURHOOD_ZONE_KEYS = {
  ["founder's point"] = true,
  ["razorwind shores"] = true,
}

local function IsNeighbourhoodToken(text)
  local key = string.lower(tostring(text or ""))
  if key == "neighbourhoods" then
    return true
  end
  return NEIGHBOURHOOD_ZONE_KEYS[key] == true
end

local function IsNeighbourhoodQuestGroupOrRow(group, row)
  if type(group) == "table" then
    if IsNeighbourhoodToken(group.expansion) or IsNeighbourhoodToken(group.label) or IsNeighbourhoodToken(group.detail) then
      return true
    end
    local wp = group.waypoint
    if type(wp) == "table" and (IsNeighbourhoodToken(wp.zone) or IsNeighbourhoodToken(wp.title)) then
      return true
    end
  end
  if type(row) == "table" then
    if IsNeighbourhoodToken(row.expansion)
      or IsNeighbourhoodToken(row.zone)
      or IsNeighbourhoodToken(row.sourceDetail)
      or IsNeighbourhoodToken(row.sourceName) then
      return true
    end
  end
  return false
end

local function IsNeighbourhoodAcquireGroup(group)
  if type(group) ~= "table" then
    return false
  end
  if IsNeighbourhoodToken(group.expansion) or IsNeighbourhoodToken(group.label) or IsNeighbourhoodToken(group.detail) then
    return true
  end
  local wp = group.waypoint
  if type(wp) == "table" and (IsNeighbourhoodToken(wp.zone) or IsNeighbourhoodToken(wp.title)) then
    return true
  end
  for _, row in ipairs(group.rows or {}) do
    if IsNeighbourhoodToken(type(row) == "table" and row.zone)
      or IsNeighbourhoodToken(type(row) == "table" and row.sourceDetail)
      or IsNeighbourhoodToken(type(row) == "table" and row.sourceName) then
      return true
    end
  end
  return false
end

local function NormalizeVendorNeighbourhoodGroup(group)
  if type(group) ~= "table" or not IsNeighbourhoodAcquireGroup(group) then
    return
  end

  group.expansion = "Neighbourhoods"
  if type(group.key) == "string" and group.key ~= "" then
    local lowerKey = string.lower(group.key)
    if string.sub(lowerKey, 1, 6) == "other|" then
      group.key = "neighbourhoods|" .. string.sub(group.key, 7)
    elseif string.sub(lowerKey, 1, 9) == "midnight|" then
      group.key = "neighbourhoods|" .. string.sub(group.key, 10)
    end
  end

  for _, row in ipairs(group.rows or {}) do
    if type(row) == "table" and (not row.expansion or row.expansion == "" or row.expansion == "Other" or row.expansion == "Midnight") then
      row.expansion = "Neighbourhoods"
    end
  end
end

local TUURAN_VENDOR_ITEM_RULES = {
  [260785] = { copper = 14250000 }, -- Dark Portal
  [264396] = { copper = 5000000 },  -- Naturally Elegant Doormat
}

local function IsTuuranVendorGroup(group)
  if type(group) ~= "table" then
    return false
  end
  local labelKey = string.lower(tostring(group.label or ""))
  if labelKey == "tuuran" then
    return true
  end
  local wp = type(group.waypoint) == "table" and group.waypoint or nil
  local wpNameKey = string.lower(tostring(wp and wp.npcName or ""))
  return wpNameKey == "tuuran"
end

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

  local wp = type(group.waypoint) == "table" and group.waypoint or {}
  local added = 0
  for itemID, rule in pairs(TUURAN_VENDOR_ITEM_RULES) do
    if not itemSeen[itemID] then
      group.items[#group.items + 1] = {
        itemID = itemID,
        sourceType = 5,
        source = "Tuuran",
        sourceName = "Tuuran",
        sourceDetail = tostring(group.detail or wp.zone or "Stormwind City"),
        category = "Vendor",
        expansion = tostring(group.expansion or "Classic"),
        mapID = tonumber(wp.mapID),
        x = tonumber(wp.x),
        y = tonumber(wp.y),
        npcID = tonumber(wp.npcID),
        npcName = tostring(wp.npcName or "Tuuran"),
        zone = tostring(wp.zone or group.detail or "Stormwind City"),
        faction = "A",
        factionName = "",
        factionID = 0,
        minRep = 0,
        questID = 0,
        achievementID = 0,
        repStandingText = "",
        acquireStatus = "Vendor",
        purchaseCosts = {
          { costType = "gold", copper = tonumber(rule.copper) or 0 },
        },
      }
      itemSeen[itemID] = true
      added = added + 1
    end
  end
  if type(group.itemSeen) == "table" then
    for itemID, seen in pairs(itemSeen) do
      if seen then
        group.itemSeen[itemID] = true
      end
    end
  end
  return added
end

local SILVERMOON_VENDOR_EXPANSION_BY_NPC_ID = {
  [256828] = "Midnight", -- Dennia Silvertongue (Midnight-era vendor set)
}

local SILVERMOON_VENDOR_EXPANSION_BY_NPC_NAME = {
  ["dennia silvertongue"] = "Midnight",
}

local function GetSilvermoonVendorExpansionOverride(group)
  if type(group) ~= "table" then
    return nil
  end
  local zoneKey = string.lower(tostring(group.detail or (type(group.waypoint) == "table" and group.waypoint.zone) or ""))
  if zoneKey ~= "silvermoon city" then
    return nil
  end

  local wp = type(group.waypoint) == "table" and group.waypoint or nil
  local npcID = tonumber(wp and wp.npcID) or 0
  if npcID <= 0 and type(group.rows) == "table" and #group.rows > 0 then
    npcID = tonumber(type(group.rows[1]) == "table" and group.rows[1].npcID) or 0
  end
  local mappedByID = SILVERMOON_VENDOR_EXPANSION_BY_NPC_ID[npcID]
  if mappedByID then
    return mappedByID
  end

  local npcName = string.lower(tostring(group.label or (wp and wp.npcName) or ""))
  if npcName == "" and type(group.rows) == "table" and #group.rows > 0 then
    npcName = string.lower(tostring(type(group.rows[1]) == "table" and group.rows[1].npcName or ""))
  end
  return SILVERMOON_VENDOR_EXPANSION_BY_NPC_NAME[npcName]
end

local function NormalizeSilvermoonVendorExpansion(group)
  local targetExpansion = GetSilvermoonVendorExpansionOverride(group)
  if type(targetExpansion) ~= "string" or targetExpansion == "" then
    return
  end

  group.expansion = targetExpansion
  if type(group.key) == "string" and group.key ~= "" then
    local lower = string.lower(group.key)
    if lower:find("^the burning crusade|", 1, true) then
      group.key = targetExpansion:lower() .. "|" .. string.sub(group.key, #"the burning crusade|" + 1)
    end
  end

  for _, row in ipairs(group.rows or {}) do
    if type(row) == "table" then
      row.expansion = targetExpansion
    end
  end
end

local function BuildPromotionGroups(db, existingPromotionGroups)
  local byKey = {}
  local out = {}
  local seenItemIDs = {}
  local staticPromotionItemIDs = {}
  local PROMOTION_ITEM_OVERRIDES = {
    [264396] = {
      expansion = "Brand Promotions",
      sourceName = "Promotional",
      sourceDetail = "Zillow",
      displaySource = "Zillow Website",
    },
    [259055] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259056] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259057] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259058] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259059] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259060] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259061] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259062] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259063] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259064] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259065] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259066] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259067] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259068] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259069] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [259070] = { expansion = "Preorder Bonuses", sourceName = "Promotional", sourceDetail = "Diablo IV: Lord of Hatred", displaySource = "Preorder Bonus" },
    [260785] = { expansion = "Login Bonuses", sourceName = "Dark Portal", sourceDetail = "Midnight Prelaunch", displaySource = "Login Bonus" },
    [263298] = { expansion = "Twitch Drops", sourceName = "Plushies", sourceDetail = "Warcraft", displaySource = "Twitch Drops" },
    [263299] = { expansion = "Twitch Drops", sourceName = "Plushies", sourceDetail = "Warcraft", displaySource = "Twitch Drops" },
    [263301] = { expansion = "Twitch Drops", sourceName = "Plushies", sourceDetail = "Warcraft", displaySource = "Twitch Drops" },
  }

  local MIDNIGHT_PREORDER_ITEM_NAME_KEYS = {
    ["light infused fountain"] = true,
    ["void corrupted fountain"] = true,
  }

  local function normalizePromoItemName(value)
    local text = string.lower(tostring(value or ""))
    text = text:gsub("[’']", "")
    text = text:gsub("[^%w%s]", " ")
    text = text:gsub("%s+", " ")
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    return text
  end

  local function resolvePromotionItemName(rowLike)
    if type(rowLike) ~= "table" then
      return ""
    end
    local direct = tostring(rowLike.name or "")
    if direct ~= "" then
      return direct
    end
    local itemID = tonumber(rowLike.itemID) or 0
    if itemID > 0 and C_Item and C_Item.GetItemNameByID then
      local n = C_Item.GetItemNameByID(itemID)
      if type(n) == "string" and n ~= "" then
        return n
      end
    end
    if itemID > 0 and GetItemInfo then
      local n = GetItemInfo(itemID)
      if type(n) == "string" and n ~= "" then
        return n
      end
    end
    return ""
  end

  local function buildDiabloIICubeOverride()
    return {
      expansion = "Preorder Bonuses",
      sourceName = "Promotional",
      sourceDetail = "Diablo II: Rein of the Warlock",
      displaySource = "Preorder Bonus",
    }
  end

  local function buildDiabloIVOverride()
    return {
      expansion = "Preorder Bonuses",
      sourceName = "Promotional",
      sourceDetail = "Diablo IV: Lord of Hatred",
      displaySource = "Preorder Bonus",
    }
  end

  local function buildWoWMidnightOverride()
    return {
      expansion = "Preorder Bonuses",
      sourceName = "Promotional",
      sourceDetail = "WoW: Midnight",
      displaySource = "Preorder Bonus",
    }
  end

  local function buildCashShopPlushieOverride()
    return {
      expansion = "Cash Shop",
      sourceName = "Shop",
      sourceDetail = "Plushies",
    }
  end

  local function IsWoWMidnightPreorderName(nameKey)
    local has = function(token)
      return string.find(nameKey, token, 1, true) ~= nil
    end
    if MIDNIGHT_PREORDER_ITEM_NAME_KEYS[nameKey] then
      return true
    end
    if has("light infused rotunda") then
      return true
    end
    if has("void corrupted rotunda") then
      return true
    end
    if has("alleria") and has("ranger general") then
      return true
    end
    if has("alleria") and has("portrait") then
      return true
    end
    if has("turalyon") and has("high exarch") then
      return true
    end
    if has("high exarch") and has("painting") then
      return true
    end
    if has("turalyon") and has("portrait") then
      return true
    end
    if has("arator") and has("redeemer") then
      return true
    end
    if has("redeemer") and has("painting") then
      return true
    end
    if has("arator") and has("portrait") then
      return true
    end
    if (has("xalatath") or (has("xal") and has("atath"))) and has("harbinger") then
      return true
    end
    if has("harbinger") and has("painting") then
      return true
    end
    if (has("xalatath") or has("xal")) and has("portrait") then
      return true
    end
    if has("ranger of the void") and has("painting") then
      return true
    end
    return false
  end

  local function IsCashShopPlushieName(nameKey)
    if string.find(nameKey, "beloved lion plushie", 1, true) then
      return true
    end
    if string.find(nameKey, "beloved wolf plushie", 1, true) then
      return true
    end
    return false
  end

  local function isDiabloIICubeNameKey(nameKey)
    return nameKey == "sanctuarys horadric cube" or nameKey == "sanctuarys hoardic cube"
  end

  local function applyPromotionItemOverride(rowLike)
    if type(rowLike) ~= "table" then
      return
    end
    local itemID = tonumber(rowLike.itemID) or 0
    local override = PROMOTION_ITEM_OVERRIDES[itemID]
    local resolvedName = resolvePromotionItemName(rowLike)
    local nameKey = normalizePromoItemName(resolvedName)
    if IsCashShopPlushieName(nameKey) then
      override = buildCashShopPlushieOverride()
    elseif IsWoWMidnightPreorderName(nameKey) then
      override = buildWoWMidnightOverride()
    elseif type(override) ~= "table" then
      if isDiabloIICubeNameKey(nameKey) then
        override = buildDiabloIICubeOverride()
      elseif nameKey == "cuddly green grrgle" then
        override = {
          expansion = "Twitch Drops",
          sourceName = "Plushies",
          sourceDetail = "Warcraft",
          displaySource = "Twitch Drops",
        }
      end
    elseif nameKey == "cuddly green grrgle" then
      -- Force this plushie into Twitch Drops instead of stale Diablo mapping.
      override = {
        expansion = "Twitch Drops",
        sourceName = "Plushies",
        sourceDetail = "Warcraft",
        displaySource = "Twitch Drops",
      }
    end
    if type(override) ~= "table" then
      return
    end
    if type(override.sourceName) == "string" and override.sourceName ~= "" then
      rowLike.sourceName = override.sourceName
      rowLike.source = override.sourceName
    end
    if type(override.sourceDetail) == "string" then
      rowLike.sourceDetail = override.sourceDetail
      if rowLike.detail == nil or rowLike.detail == "" then
        rowLike.detail = override.sourceDetail
      end
    end
    if type(override.expansion) == "string" and override.expansion ~= "" then
      rowLike.expansion = override.expansion
    end
    if type(override.displaySource) == "string" and override.displaySource ~= "" then
      rowLike.displaySource = override.displaySource
    end
  end

  local function normalizePromotionSourceName(value)
    local label = tostring(value or "")
    label = label:gsub("^%s+", ""):gsub("%s+$", "")
    local lowered = string.lower(label)
    if lowered == "" then
      return "Promotional"
    end
    if lowered == "shop" or lowered == "in-game shop" then
      return "Shop"
    end
    if lowered == "promotional" then
      return "Promotional"
    end
    return label
  end

  local function normalizePromotionDetail(value)
    local detail = tostring(value or "")
    detail = detail:gsub("^%s+", ""):gsub("%s+$", "")
    local lowered = string.lower(detail)
    if lowered == "unknown" or lowered == "unknown zone" then
      return ""
    end
    return detail
  end

  local function ensureGroup(expansion, sourceName, sourceDetail)
    local exp = tostring(expansion or "")
    if exp == "" then
      exp = "Other"
    end
    local label = normalizePromotionSourceName(sourceName)
    local detail = normalizePromotionDetail(sourceDetail)
    if string.lower(tostring(label or "")) == string.lower(tostring(detail or "")) then
      detail = ""
    end
    local key = string.lower(exp .. "|" .. label .. "|" .. detail .. "||0|promotion")
    local group = byKey[key]
    if group then
      return group
    end
    group = {
      key = key,
      mode = "promotion",
      category = "Promotional",
      expansion = exp,
      label = label,
      detail = detail,
      items = {},
    }
    byKey[key] = group
    out[#out + 1] = group
    return group
  end

  local function addRow(rowLike)
    if type(rowLike) ~= "table" then
      return
    end
    applyPromotionItemOverride(rowLike)
    local itemID = tonumber(rowLike.itemID) or 0
    if itemID <= 0 or seenItemIDs[itemID] then
      return
    end
    local sourceName = normalizePromotionSourceName(rowLike.sourceName or rowLike.source)
    local group = ensureGroup(rowLike.expansion, sourceName, rowLike.sourceDetail or rowLike.detail)
    group.items[#group.items + 1] = {
      itemID = itemID,
      name = rowLike.name,
      sourceType = 10,
      sourceName = sourceName,
      sourceDetail = normalizePromotionDetail(rowLike.sourceDetail or rowLike.detail),
      source = sourceName,
      displaySource = rowLike.displaySource,
      category = "Promotional",
      expansion = rowLike.expansion or "Other",
      mapID = tonumber(rowLike.mapID),
      x = tonumber(rowLike.x),
      y = tonumber(rowLike.y),
      npcID = tonumber(rowLike.npcID),
      npcName = rowLike.npcName,
      zone = rowLike.zone,
      faction = rowLike.faction,
      factionName = rowLike.factionName,
      factionID = tonumber(rowLike.factionID),
      minRep = tonumber(rowLike.minRep),
      questID = tonumber(rowLike.questID),
      achievementID = tonumber(rowLike.achievementID),
      repStandingText = rowLike.repStandingText,
      purchaseCosts = type(rowLike.purchaseCosts) == "table" and rowLike.purchaseCosts or {},
      acquireStatus = rowLike.acquireStatus or "Promotion",
    }
    seenItemIDs[itemID] = true
  end

  local function AddTuuranVendorRowsAsPromotions(vendorGroups)
    if type(vendorGroups) ~= "table" then
      return
    end
    for _, group in ipairs(vendorGroups) do
      if IsTuuranVendorGroup(group) then
        EnsureTuuranVendorRows(group)
        for _, row in ipairs((type(group) == "table" and group.items) or {}) do
          if type(row) == "table" then
            local clone = {
              itemID = tonumber(row.itemID) or 0,
              name = row.name,
              sourceType = 10,
              sourceName = "Promotional",
              sourceDetail = "Midnight Prelaunch",
              source = "Promotional",
              category = "Promotional",
              expansion = "Login Bonuses",
              mapID = tonumber(row.mapID),
              x = tonumber(row.x),
              y = tonumber(row.y),
              npcID = tonumber(row.npcID),
              npcName = row.npcName,
              zone = row.zone,
              faction = row.faction,
              factionName = row.factionName,
              factionID = tonumber(row.factionID),
              minRep = tonumber(row.minRep),
              questID = tonumber(row.questID),
              achievementID = tonumber(row.achievementID),
              repStandingText = row.repStandingText,
              purchaseCosts = type(row.purchaseCosts) == "table" and row.purchaseCosts or {},
              acquireStatus = "Promotion",
              displaySource = "Vendor Purchase",
            }
            addRow(clone)
          end
        end
      end
    end
  end

  for _, group in ipairs(existingPromotionGroups or {}) do
    local groupExpansion = type(group) == "table" and group.expansion or nil
    local groupLabel = type(group) == "table" and group.label or nil
    local groupDetail = type(group) == "table" and group.detail or nil
    for _, row in ipairs((type(group) == "table" and group.items) or {}) do
      if type(row) == "table" then
        if (row.sourceName == nil or row.sourceName == "") and type(groupLabel) == "string" and groupLabel ~= "" then
          row.sourceName = groupLabel
        end
        if (row.source == nil or row.source == "") and type(row.sourceName) == "string" and row.sourceName ~= "" then
          row.source = row.sourceName
        end
        if (row.sourceDetail == nil or row.sourceDetail == "") and type(groupDetail) == "string" then
          row.sourceDetail = groupDetail
        end
        if (row.expansion == nil or row.expansion == "") and type(groupExpansion) == "string" and groupExpansion ~= "" then
          row.expansion = groupExpansion
        end
        addRow(row)
      end
    end
  end

  for _, row in ipairs(ns.staticPromotionItems or {}) do
    local itemID = tonumber(type(row) == "table" and row.itemID) or 0
    if itemID > 0 then
      staticPromotionItemIDs[itemID] = true
    end
    addRow(row)
  end

  local byMode = type(db) == "table" and db.groupsByMode or nil
  for modeKey, groups in pairs(byMode or {}) do
    if modeKey ~= "promotion" and type(groups) == "table" then
      if modeKey == "vendor" then
        AddTuuranVendorRowsAsPromotions(groups)
      end
      for _, group in ipairs(groups) do
        for _, row in ipairs((type(group) == "table" and group.items) or {}) do
          local sourceType = tonumber(type(row) == "table" and row.sourceType) or 0
          local rowNameKey = normalizePromoItemName(resolvePromotionItemName(row))
          local rowItemID = tonumber(type(row) == "table" and row.itemID) or 0
          if sourceType == 10
            or (rowItemID > 0 and staticPromotionItemIDs[rowItemID] == true)
            or (rowItemID > 0 and type(PROMOTION_ITEM_OVERRIDES[rowItemID]) == "table")
            or isDiabloIICubeNameKey(rowNameKey)
            or IsWoWMidnightPreorderName(rowNameKey)
            or IsCashShopPlushieName(rowNameKey) then
            addRow(row)
          end
        end
      end
    end
  end

  return out
end

function ns.GetAcquireData(mode)
  local normalizedMode = string.lower(tostring(mode or ""))
  if normalizedMode == "" then
    return {}
  end

  local db = VamLaxPowerCrafterDB and VamLaxPowerCrafterDB.acquireData or nil
  local staticData = ns.staticAcquireData
  if type(staticData) ~= "table" then
    staticData = nil
  end
  if type(db) ~= "table" and type(staticData) == "table" then
    db = staticData
  elseif type(db) == "table" and type(staticData) == "table" then
    local dbVersion = tonumber(db.version) or 0
    local staticVersion = tonumber(staticData.version) or 0
    if staticVersion > dbVersion then
      db = staticData
    end
  end
  if type(db) ~= "table" then
    return {}
  end

  local byMode = db.groupsByMode or {}
  local groups = byMode[normalizedMode]
  if normalizedMode == "promotion" then
    groups = BuildPromotionGroups(db, type(groups) == "table" and groups or {})
  end
  if type(groups) ~= "table" then
    return {}
  end

  local out = {}
  local outByKey = {}
  local function RowSignature(row)
    if type(row) ~= "table" then
      return ""
    end
    return table.concat({
      tostring(tonumber(row.itemID) or 0),
      tostring(row.sourceName or row.source or ""),
      tostring(row.sourceDetail or row.zone or ""),
      tostring(tonumber(row.questID) or 0),
      tostring(tonumber(row.achievementID) or 0),
      tostring(tonumber(row.npcID) or 0),
    }, "|")
  end
  local function AppendUniqueRows(targetRows, incomingRows)
    if type(targetRows) ~= "table" or type(incomingRows) ~= "table" then
      return
    end
    local seen = {}
    for _, existing in ipairs(targetRows) do
      seen[RowSignature(existing)] = true
    end
    for _, candidate in ipairs(incomingRows) do
      local sig = RowSignature(candidate)
      if sig ~= "" and not seen[sig] then
        targetRows[#targetRows + 1] = candidate
        seen[sig] = true
      end
    end
  end
  local nonQuestItemIDs = nil
  if normalizedMode == "quest" then
    nonQuestItemIDs = {}
    for modeKey, modeGroups in pairs(byMode) do
      if modeKey ~= "quest" and type(modeGroups) == "table" then
        for _, modeGroup in ipairs(modeGroups) do
          for _, modeRow in ipairs(type(modeGroup) == "table" and modeGroup.items or {}) do
            local itemID = tonumber(type(modeRow) == "table" and modeRow.itemID) or 0
            if itemID > 0 then
              nonQuestItemIDs[itemID] = true
            end
          end
        end
      end
    end
  end
  for _, group in ipairs(groups) do
    local skipGroup = false
    if normalizedMode == "quest" and ns.IsQuestGroupVendorUnlockLike and ns.IsQuestGroupVendorUnlockLike(group) then
      skipGroup = true
    end
    if not skipGroup then
      local cloned = CloneAcquireGroup(group)
      if cloned and cloned.key then
        if normalizedMode == "vendor" then
          NormalizeVendorNeighbourhoodGroup(cloned)
          NormalizeSilvermoonVendorExpansion(cloned)
          EnsureTuuranVendorRows(cloned)
        end
        if normalizedMode == "quest" and type(nonQuestItemIDs) == "table" and type(cloned.rows) == "table" and #cloned.rows > 0 then
          for i = #cloned.rows, 1, -1 do
            local row = cloned.rows[i]
            local itemID = tonumber(type(row) == "table" and row.itemID) or 0
            if itemID > 0
              and nonQuestItemIDs[itemID]
              and IsNeighbourhoodQuestGroupOrRow(group, row) then
              table.remove(cloned.rows, i)
            end
          end
        end
        if type(cloned.rows) ~= "table" or #cloned.rows == 0 then
          skipGroup = true
        end
      else
        skipGroup = true
      end
      if not skipGroup then
        local existing = outByKey[cloned.key]
        if existing then
          AppendUniqueRows(existing.rows, cloned.rows)
        else
          outByKey[cloned.key] = cloned
          out[#out + 1] = cloned
        end
      end
    end
  end

  table.sort(out, function(a, b)
    if (a.expansion or "Other") ~= (b.expansion or "Other") then
      return tostring(a.expansion or "Other") < tostring(b.expansion or "Other")
    end
    return tostring(a.label or "") < tostring(b.label or "")
  end)

  return out
end
