-- AutoQuestLearner Map Module
-- Rebranded from pfQuest map.lua

local nodename = "AutoQuestMiniMapPin"

local validmaps = setmetatable({},{__mode="kv"})
local rgbcache = setmetatable({},{__mode="kv"})
local function GetMinimapSizes()
  return AutoQuestLearnerDB and AutoQuestLearnerDB.minimap or {}
end

local unifiedcache = {}
local similar_nodes = {}

local function IsEmpty(tabl)
  for k,v in pairs(tabl) do
    return false
  end
  return true
end

local layers = {
  ["Interface\\AddOns\\AutoQuestLearner\\img\\available"]          = 1,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\available_c"]        = 2,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\complete"]           = 3,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\complete_c"]         = 4,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\icon_vendor"]        = 5,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\fav"]                = 6,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\cluster_item"]       = 9,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\cluster_mob"]        = 9,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\cluster_misc"]       = 9,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\cluster_mob_mono"]   = 9,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\cluster_item_mono"]  = 9,
  ["Interface\\AddOns\\AutoQuestLearner\\img\\cluster_misc_mono"]  = 9,
}

local function GetLayerByTexture(tex)
  if layers[tex] then return layers[tex] else return 1 end
end

-- math.mod does not exist on this client (only math.fmod, and even
-- that isn't guaranteed across all client builds) -- use a manual
-- modulo instead.
local function mod(a, b)
  return a - math.floor(a / b) * b
end

-- A small, curated set of visually distinct colors instead of hashing
-- into the full 16.7-million-color RGB space -- with many quests
-- active at once, that produced clusters in near-arbitrary, often
-- similar-looking shades that were hard to tell apart at a glance.
-- Same title/spawn text always maps to the same color as before (the
-- hash below just picks a PALETTE INDEX now instead of a raw RGB
-- value), so a given NPC's color stays consistent across sessions.
local COLOR_PALETTE = {
  { 0.65, 0.13, 0.13 }, -- dark red
  { 0.13, 0.52, 0.13 }, -- dark green
  { 0.20, 0.33, 0.65 }, -- dark blue
  { 0.49, 0.36, 0.00 }, -- dark gold
  { 0.65, 0.36, 0.07 }, -- dark orange
  { 0.00, 0.36, 0.36 }, -- dark teal
  { 0.59, 0.16, 0.39 }, -- dark pink
}

local function str2rgb(text)
  if not text then return 1, 1, 1 end
  if AutoQuestLearnerDB.colors and AutoQuestLearnerDB.colors[text] then 
    return unpack(AutoQuestLearnerDB.colors[text]) 
  end
  if rgbcache[text] then return unpack(rgbcache[text]) end
  local counter = 1
  local l = string.len(text)
  for i = 1, l, 3 do
    counter = mod(counter*8161, 4294967279) +
        (string.byte(text,i)*16776193) +
        ((string.byte(text,i+1) or (l-i+256))*8372226) +
        ((string.byte(text,i+2) or (l-i+256))*3932164)
  end
  local hash = mod(mod(counter, 4294967291),16777216)
  local paletteIndex = mod(hash, table.getn(COLOR_PALETTE)) + 1
  rgbcache[text] = COLOR_PALETTE[paletteIndex]
  return unpack(rgbcache[text])
end

local fpsmod, step
local function NodeAnimate(self, zoom, alpha, fps)
  localcur_zoom = self:GetWidth()
  local cur_alpha = self:GetAlpha()
  local change = nil
  self:EnableMouse(true)
  fpsmod = math.min(2/fps, 2)
  step = fpsmod/10

  if math.abs(cur_zoom - zoom) < 3 then
    self:SetWidth(zoom)
    self:SetHeight(zoom)
  elseif cur_zoom < zoom then
    self:SetWidth(cur_zoom + fpsmod)
    self:SetHeight(cur_zoom + fpsmod)
    change = true
  elseif cur_zoom > zoom then
    self:SetWidth(cur_zoom - fpsmod)
    self:SetHeight(cur_zoom - fpsmod)
    change = true
  end

  if math.abs(cur_alpha - alpha) < step then
    self:SetAlpha(alpha)
    if alpha < .1 then
      self:EnableMouse(nil)
    end
  elseif cur_alpha < alpha then
    self:SetAlpha(cur_alpha + step)
    change = true
  elseif cur_alpha > alpha then
    self:SetAlpha(cur_alpha - step)
    change = true
  end

  return change
end

AutoQuestLearnerMap = CreateFrame("Frame", "AutoQuestLearnerMap", WorldFrame)
AutoQuestLearnerMap.str2rgb = str2rgb
AutoQuestLearnerMap.tooltips = {}
AutoQuestLearnerMap.nodes = {}
AutoQuestLearnerMap.pins = {}
AutoQuestLearnerMap.mpins = {}
AutoQuestLearnerMap.drawlayer = Minimap
AutoQuestLearnerMap.unifiedcache = unifiedcache

function AutoQuestLearnerMap:GetMinimapSizes()
  return GetMinimapSizes()
end

AutoQuestLearnerMap.tooltip = CreateFrame("Frame" , "AutoQuestLearnerMapTooltip", GameTooltip)
AutoQuestLearnerMap.tooltip:SetScript("OnShow", function()
  local focus = GetMouseFocus()
  if focus and focus.title then return end
  if AutoQuestLearnerDB.config.showtooltips == "0" then return end

  local name = getglobal("GameTooltipTextLeft1") and getglobal("GameTooltipTextLeft1"):GetText() or "__NONE__"
  local zone = AutoQuestLearnerMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())

  name = string.gsub(name, "|c%x%x%x%x%x%x%x%x", "")
  name = string.gsub(name, "|r", "")

  if AutoQuestLearnerMap.tooltips[name] and AutoQuestLearnerMap.tooltips[name] then
    for title, obj in pairs(AutoQuestLearnerMap.tooltips[name]) do
      if obj[zone] then
        AutoQuestLearnerMap:ShowTooltip(obj[zone], GameTooltip)
        GameTooltip:Show()
      end
    end
  end
end)

function AutoQuestLearnerMap:HasMinimap()
  return true
end

function AutoQuestLearnerMap.tooltip:GetColor(min, max)
  local max = max or 1
  local min = min or max or 1

  local perc = min / max
  local r1, g1, b1, r2, g2, b2
  if perc <= 0.5 then
    perc = perc * 2
    r1, g1, b1 = 1, 0, 0
    r2, g2, b2 = 1, 1, 0
  else
    perc = perc * 2 - 1
    r1, g1, b1 = 1, 1, 0
    r2, g2, b2 = 0, 1, 0
  end
  r = r1 + (r2 - r1)*perc
  g = g1 + (g2 - g1)*perc
  b = b1 + (b2 - b1)*perc

  return r, g, b
end

function AutoQuestLearnerMap:ShowTooltip(meta, tooltip)
  local tooltip = tooltip or GameTooltip

  if meta["quest"] then
    for qid=1, GetNumQuestLogEntries() do
      local qtitle, _, _, _, _, complete = AutoQuestLearner_GetQuestLogTitle(qid)

      if meta["quest"] == qtitle then
        local objectives = GetNumQuestLeaderBoards(qid)
        local symbol = ( complete or objectives == 0 ) and "|cff555555[|cffffcc00?|cff555555]|r " or "|cff555555[|cffffcc00!|cff555555]|r "
        tooltip:AddLine(symbol .. meta["quest"], 1, 1, 0)

        if objectives then
          for i=1, objectives, 1 do
            local text, type, finished = GetQuestLogLeaderBoard(i, qid)
            tooltip:AddLine("|cffaaaaaa- |r" .. text, 1,1,1)
          end
        end
      end
    end

    tooltip:AddLine("|cff555555[|cffffcc00!|cff555555]|r " .. meta["quest"], 1, 1, .7)
  end

  tooltip:Show()
end

-- Reverse-lookup: given a zone NAME, find its numeric ID in pfDB's own
-- zone-ID scheme (the AutoQuestLearnerData.zones["loc"][id] = "Name"
-- table). This is the missing piece that makes GetMapID() actually
-- useful -- see the comment on GetMapID below.
local mapNameToID = {}
local mapNameToIDBuilt = false
local function GetMapIDByName(search)
  if not search then return nil end

  if not mapNameToIDBuilt and AutoQuestLearnerData and AutoQuestLearnerData.zones
      and AutoQuestLearnerData.zones["loc"] then
    for id, name in pairs(AutoQuestLearnerData.zones["loc"]) do
      mapNameToID[name] = id
    end
    -- Only mark as built once zone data actually exists -- if the
    -- database hasn't finished loading yet, we want to retry instead
    -- of permanently caching an empty table.
    if next(mapNameToID) then
      mapNameToIDBuilt = true
    end
  end

  return mapNameToID[search]
end

-- A handful of special-case zones (like Alterac Valley) that don't
-- resolve cleanly through the name lookup above, keyed by whatever
-- GetMapInfo() reports while standing in them.
local customMapIDs = {
  ["AlteracValley"] = 2597,
}

-- Starting-zone alias: translates a dedicated starting sub-zone's own
-- ID to the parent continent zone ID the bundled database actually
-- uses for NPCs physically located there. Every faction's starting
-- area has its own independently-zoomable world map (unlike
-- Northshire, which is rendered as part of Elwynn Forest's map and
-- needs no translation), but the bundled coordinate data tags NPCs in
-- these specific sub-zones with the PARENT zone instead -- verified
-- directly against real named NPCs: Durnan Furcutter (Coldridge
-- Valley) tagged zone 1/Dun Morogh instead of 132; Zureetha Fargaze
-- and Kaltunk (Valley of Trials) tagged 14/Durotar instead of 363;
-- Baine Bloodhoof and Grull Hawkwind (Camp Narache) tagged 215/Mulgore
-- instead of 221; Chief Hawkwind, Seer Graytongue, and Greatmother
-- Hawkwind (Red Cloud Mesa) tagged 215/Mulgore instead of 220.
-- Independently cross-checked against a third-party WoW Classic quest
-- dataset's own sub-area/zone field pairings, which confirmed every
-- one of these (plus caught Red Cloud Mesa, which the first pass
-- missed) and confirmed Northshire genuinely needs no alias. Without
-- this, every coordinate lookup for any of these zones -- the arrow,
-- map nodes, minimap, available quest givers -- silently matched
-- nothing the entire time a fresh character is in their starting zone.
local STARTING_ZONE_ALIASES = {
  [132] = 1,   -- Coldridge Valley -> Dun Morogh
  [363] = 14,  -- Valley of Trials -> Durotar
  [221] = 215, -- Camp Narache -> Mulgore
  [220] = 215, -- Red Cloud Mesa -> Mulgore
  [188] = 141, -- Shadowglen -> Teldrassil
  [154] = 85,  -- Deathknell -> Tirisfal Glades
}

function AutoQuestLearnerMap:GetMapID(cid, mid, skipAlias)
  cid = cid or GetCurrentMapContinent()
  mid = mid or GetCurrentMapZone()

  local list = { GetMapZones(cid) }
  local name = list[mid]

  -- IMPORTANT: this must return the SAME numeric ID space that pfDB's
  -- coordinate data uses (e.g. coord[3] in units/objects coords, and
  -- quests[id].start/end/obj entries once resolved to a spawn). A
  -- previous version of this function returned the raw zone-NAME
  -- string instead, which meant it could never match anything stored
  -- via the database-driven node paths (SearchQuestID, SearchQuests),
  -- and (after an earlier fix routed learned locations through this
  -- same function) the auto-learned nodes too -- nothing in either
  -- path was ever being looked up under the key it was stored under.
  local id = GetMapIDByName(name)
  if not id and type(GetMapInfo) == "function" then
    id = customMapIDs[GetMapInfo()]
  end

  -- Standalone fallback: without pfQuest, AutoQuestLearnerData.zones.loc
  -- is empty, so GetMapIDByName() can never resolve anything, and this
  -- would otherwise return nil for every zone -- which would silently
  -- reject every node the addon tries to draw via AddNode() (it
  -- requires meta["zone"] to be truthy), including everything from the
  -- addon's own independent learning system, which has nothing to do
  -- with pfDB at all. Rather than depend on pfDB's translation table,
  -- fall back to a simple key built directly from Blizzard's own
  -- continent/zone-index pair -- always available, no external data
  -- needed. This key space won't match pfDB's own numeric IDs, but
  -- that's fine: database-driven nodes only exist when pfDB is present
  -- anyway, in which case the real translated ID above is used
  -- instead, and storage/lookup stay consistent either way since both
  -- always go through this same function.
  if not id then
    id = "C" .. tostring(cid) .. "Z" .. tostring(mid)
  end

  -- Starting-zone alias: see the STARTING_ZONE_ALIASES table above.
  -- skipAlias returns the raw sub-zone ID instead -- needed for
  -- minimap calibration (see AutoQuestLearner_CalibrateMinimapZone),
  -- which needs to know it's SPECIFICALLY in e.g. Shadowglen (188) to
  -- calibrate that sub-area's own local scale, not "somewhere in the
  -- much larger parent zone Teldrassil (141)" that the alias collapses
  -- it to for every other purpose (quest/NPC coordinate lookup, which
  -- genuinely does need the parent ID since that's how the bundled
  -- data is tagged).
  if not skipAlias then
    id = STARTING_ZONE_ALIASES[id] or id
  end

  return id
end

function AutoQuestLearnerMap:AddNode(meta)
  if not meta then return end
  if not meta["zone"] then return end
  if not meta["title"] then return end

  local addon = meta["addon"] or "AUTOQLDB"
  local map = meta["zone"]
  local coords = meta["x"] .. "|" .. meta["y"]
  local title = meta["title"]
  local layer = GetLayerByTexture(meta["texture"])
  local spawn = meta["spawn"]
  local item = meta["item"]

  local sindex = string.format("%s:%s:%s:%s:%s:%s",
    (addon or ""), (map or ""), (coords or ""), (title or ""), (layer or ""), (spawn or ""), (item or ""))

  if layer >= 9 and meta["priority"] then
    layer = layer + (10 - min(meta["priority"], 10))
  end

  if not AutoQuestLearnerMap.nodes[addon] then AutoQuestLearnerMap.nodes[addon] = {} end
  if not AutoQuestLearnerMap.nodes[addon][map] then AutoQuestLearnerMap.nodes[addon][map] = {} end
  if not AutoQuestLearnerMap.nodes[addon][map][coords] then AutoQuestLearnerMap.nodes[addon][map][coords] = {} end

  if AutoQuestLearnerMap.nodes[addon][map][coords][title] then
    if AutoQuestLearnerMap.nodes[addon][map][coords][title].layer and layer and
     AutoQuestLearnerMap.nodes[addon][map][coords][title].layer >= layer then
      return
    end
  end

  if not similar_nodes[sindex] then
    similar_nodes[sindex] = {}
    for key, val in pairs(meta) do similar_nodes[sindex][key] = val end
    similar_nodes[sindex].item = { [1] = item }
  end

  AutoQuestLearnerMap.nodes[addon][map][coords][title] = similar_nodes[sindex]

  if spawn and title then
    AutoQuestLearnerMap.tooltips[spawn] = AutoQuestLearnerMap.tooltips[spawn] or {}
    AutoQuestLearnerMap.tooltips[spawn][title] = AutoQuestLearnerMap.tooltips[spawn][title] or {}
    AutoQuestLearnerMap.tooltips[spawn][title][map] = AutoQuestLearnerMap.tooltips[spawn][title][map] or similar_nodes[sindex]
  end
end

function AutoQuestLearnerMap:DeleteNode(addon, title)
  if not addon then
    AutoQuestLearnerMap.tooltips = {}
  else
    for mk, mv in pairs(AutoQuestLearnerMap.tooltips) do
      for tk, tv in pairs(mv) do
        if ( title and tk == title ) or ( not title and tv.addon == addon ) then
          AutoQuestLearnerMap.tooltips[mk][tk] = nil
        end
      end
    end
  end

  if not addon then
    AutoQuestLearnerMap.nodes = {}
  elseif not title then
    AutoQuestLearnerMap.nodes[addon] = {}
  elseif AutoQuestLearnerMap.nodes[addon] then
    for map, foo in pairs(AutoQuestLearnerMap.nodes[addon]) do
      for coords, node in pairs(AutoQuestLearnerMap.nodes[addon][map]) do
        if AutoQuestLearnerMap.nodes[addon][map][coords][title] then
          AutoQuestLearnerMap.nodes[addon][map][coords][title] = nil
          if IsEmpty(AutoQuestLearnerMap.nodes[addon][map][coords]) then
            AutoQuestLearnerMap.nodes[addon][map][coords] = nil
          end
        end
      end
    end
  end
end

-- Removes every node under `addon` whose stored questid matches,
-- regardless of the title string it was drawn under. Title-string
-- matching (the plain DeleteNode above) can miss nodes when the same
-- quest got drawn under more than one title spelling -- e.g. the
-- bundled database's title, the live quest-log title, and a
-- gossip/quest-dialog title aren't always byte-identical -- which is
-- how a quest's nodes could survive being removed from the log and
-- linger on the map/minimap indefinitely. Call this alongside (not
-- instead of) DeleteNode() for a belt-and-suspenders cleanup whenever
-- a quest is fully done with (turned in or abandoned).
-- BUGFIX: previously matched ONLY by tab.questid == questID, the same
-- fragility already found and fixed in DeleteObjectiveNodes below (see
-- its comment) -- the questID resolved at deletion time doesn't always
-- match the form it was tagged with when the node was created (numeric
-- vs. title-string, depending on GetQuestIDs/GetQuestLink timing). This
-- is the function that actually runs when a quest is turned in or
-- abandoned, so that mismatch is exactly what let completed quests'
-- clusters -- objective clouds included, this function doesn't
-- distinguish node types -- linger on the map indefinitely instead of
-- clearing. Now also accepts an optional questTitle and matches on
-- that as a fallback, same as DeleteObjectiveNodes already does.
function AutoQuestLearnerMap:DeleteNodeByQuestID(addon, questID, questTitle)
  if not addon or not questID then return end
  if not AutoQuestLearnerMap.nodes[addon] then return end

  for map, _ in pairs(AutoQuestLearnerMap.nodes[addon]) do
    for coords, node in pairs(AutoQuestLearnerMap.nodes[addon][map]) do
      for title, tab in pairs(node) do
        if tab.questid == questID or (questTitle and title == questTitle) then
          node[title] = nil
        end
      end
      if IsEmpty(AutoQuestLearnerMap.nodes[addon][map][coords]) then
        AutoQuestLearnerMap.nodes[addon][map][coords] = nil
      end
    end
  end

  for spawn, titles in pairs(AutoQuestLearnerMap.tooltips) do
    for title, tv in pairs(titles) do
      if tv.questid == questID or (questTitle and title == questTitle) then
        AutoQuestLearnerMap.tooltips[spawn][title] = nil
      end
    end
  end
end

-- Removes only the objective ("cloud of dots") nodes for a quest,
-- leaving its start/turn-in markers alone. Nodes are matched via the
-- isObjective flag set when they were created (see DrawObjectiveNode /
-- SearchQuestID in the other files). Intended to be called once a
-- quest's objectives are fully done (ready for turn-in) or once the
-- quest is removed from the log entirely -- objective spawn clouds are
-- only useful while you're actively working on that objective.
--
-- BUGFIX: matched ONLY by tab.questid == questID, which silently failed
-- whenever the questID resolved at deletion time (via GetQuestIDs, which
-- can return either a numeric ID or a raw title string depending on
-- GetQuestLink's timing-dependent availability -- see the alias-lookup
-- notes in AutoQuestLearner.lua) didn't match whichever form was
-- resolved when the node was originally created. A 100%-complete quest
-- could sit there with its objective clusters never clearing, because
-- the delete call was silently searching for the wrong key with no
-- error. Now also accepts an optional questTitle and matches on that as
-- a fallback -- the node's OUTER key already IS the quest's title
-- string, which is far more stable than the resolved ID.
function AutoQuestLearnerMap:DeleteObjectiveNodes(addon, questID, questTitle)
  if not addon or not questID then return end
  if not AutoQuestLearnerMap.nodes[addon] then return end

  for map, _ in pairs(AutoQuestLearnerMap.nodes[addon]) do
    for coords, node in pairs(AutoQuestLearnerMap.nodes[addon][map]) do
      for title, tab in pairs(node) do
        if tab.isObjective and (tab.questid == questID or (questTitle and title == questTitle)) then
          node[title] = nil
        end
      end
      if IsEmpty(AutoQuestLearnerMap.nodes[addon][map][coords]) then
        AutoQuestLearnerMap.nodes[addon][map][coords] = nil
      end
    end
  end
end

-- Removes only the objective nodes for ONE specific sub-objective within
-- a quest (matched by name, since the bundled/live objective data
-- doesn't preserve a direct link from a specific leaderboard line to
-- the exact NPC/object/item-source ID it corresponds to) -- leaving any
-- OTHER still-incomplete sub-objectives' nodes untouched. Matching is a
-- case-insensitive substring check in both directions between the
-- node's resolved name (e.g. "Prairie Wolf") and the leaderboard's
-- objective text (e.g. "Prairie Wolf Heart: 1/1", already stripped to
-- "Prairie Wolf Heart" by the caller) -- covers the common case where a
-- quest item's name is built from the creature's name. Deliberately
-- does nothing (no false cull) when no match is found, rather than
-- guessing and risking removing a node a different, still-incomplete
-- objective still needs.
function AutoQuestLearnerMap:DeleteObjectiveNodesByName(addon, questID, nameHint, questTitle)
  if not addon or not questID or not nameHint or nameHint == "" then return end
  if not AutoQuestLearnerMap.nodes[addon] then return end

  local needle = strlower(nameHint)

  for map, _ in pairs(AutoQuestLearnerMap.nodes[addon]) do
    for coords, node in pairs(AutoQuestLearnerMap.nodes[addon][map]) do
      for title, tab in pairs(node) do
        if tab.isObjective and tab.spawn and (tab.questid == questID or (questTitle and title == questTitle)) then
          local spawnLower = strlower(tab.spawn)
          if strfind(needle, spawnLower, 1, true) or strfind(spawnLower, needle, 1, true) then
            node[title] = nil
          end
        end
      end
      if IsEmpty(AutoQuestLearnerMap.nodes[addon][map][coords]) then
        AutoQuestLearnerMap.nodes[addon][map][coords] = nil
      end
    end
  end
end

function AutoQuestLearnerMap:NodeClick()
  if IsShiftKeyDown() then
    if this.questid and this.texture and this.layer < 5 then
      AutoQuestLearnerDB.history = AutoQuestLearnerDB.history or {}
      AutoQuestLearnerDB.history[this.questid] = { time(), UnitLevel("player") }
    end

    if this.node and this.title and this.node[this.title] then
      AutoQuestLearnerMap:DeleteNode(this.node[this.title].addon, this.title)
    end
  else
    if AutoQuestLearnerDB.colors then
      AutoQuestLearnerDB.colors[this.color] = { str2rgb(this.color .. GetTime()) }
    end
  end
end

function AutoQuestLearnerMap:NodeEnter()
  -- Defensive guard: 'this' should always be one of our own pin frames
  -- here (this only fires as a genuine mouseover OnEnter on one), but a
  -- known class of WoW addon interaction can occasionally invoke frame
  -- scripts with the shared global 'this' pointing at something else
  -- entirely -- observed in practice as 'this' being GameTooltip itself,
  -- which crashes GameTooltip:SetOwner(GameTooltip, ...) with "Can't set
  -- owner to self". this.node is only ever set on our own pin frames
  -- (see BuildNode/UpdateNode), so its absence is a reliable signal this
  -- wasn't actually a real pin mouseover.
  if not this or not this.node or this == GameTooltip or this == WorldMapTooltip then
    return
  end

  local tooltip = this:GetParent() == WorldMapButton and WorldMapTooltip or GameTooltip
  tooltip:SetOwner(this, "ANCHOR_LEFT")
  this.spawn = this.spawn or "Unknown"
  tooltip:SetText(this.spawn, .3, 1, .8)

  for title, meta in pairs(this.node) do
    AutoQuestLearnerMap:ShowTooltip(meta, tooltip)
  end

  if AutoQuestLearnerDB.config.mouseover == "1" then
    tooltip:AddLine("Use <Shift>-Click To Remove Nodes", .6, .6, .6)
    tooltip:Show()
  end

  AutoQuestLearnerMap.highlight = AutoQuestLearnerDB.config.mouseover == "1" and this.title
end

function AutoQuestLearnerMap:NodeLeave()
  if not this or not this.GetParent then
    GameTooltip:Hide()
    return
  end
  local tooltip = this:GetParent() == WorldMapButton and WorldMapTooltip or GameTooltip
  tooltip:Hide()
  AutoQuestLearnerMap.highlight = nil
end

function AutoQuestLearnerMap:BuildNode(name, parent)
  local f = CreateFrame("Button", name, parent)

  if parent == WorldMapButton then
    f.defalpha = tonumber(AutoQuestLearnerDB.config.worldmaptransp) or 1
    f.defsize = 16
  else
    f.defalpha = tonumber(AutoQuestLearnerDB.config.minimaptransp) or 1
    f.defsize = 16
    f.minimap = true
  end

  f:SetWidth(f.defsize)
  f:SetHeight(f.defsize)

  f.Animate = NodeAnimate
  f:SetScript("OnEnter", AutoQuestLearnerMap.NodeEnter)
  f:SetScript("OnLeave", AutoQuestLearnerMap.NodeLeave)

  f.tex = f:CreateTexture(nil, "BACKGROUND")
  f.tex:SetAllPoints(f)

  f.hl = f:CreateTexture(nil, "BORDER")
  f.hl:SetTexture("Interface\\AddOns\\AutoQuestLearner\\img\\track")
  f.hl:SetPoint("TOPLEFT", f, "TOPLEFT", -5, 5)
  f.hl:SetWidth(12)
  f.hl:SetHeight(12)
  -- BUGFIX: this was never hidden, so every single node on the map got
  -- a permanent white pushpin badge in its top-left corner, regardless
  -- of mouseover state -- it's supposed to be a highlight indicator
  -- shown only for the currently-moused-over node (see the `highlight`
  -- check in UpdateNode below, which previously computed the flag but
  -- never actually used it to show/hide this texture).
  f.hl:Hide()

  -- Count badge for merged/clustered spawn points -- see
  -- AutoQuestLearnerMap:ClusterNodePositions. Only shown when several
  -- nearby spawns get merged into one marker (SetText/:Show() called
  -- from the drawing loop, never automatically).
  f.countBadge = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  f.countBadge:SetPoint("CENTER", f, "CENTER", 0, 0)
  f.countBadge:SetTextColor(1, 1, 1, 1)
  f.countBadge:Hide()

  return f
end

AutoQuestLearnerMap.highlightdb = {}
function AutoQuestLearnerMap:UpdateNode(frame, node, color, obj)
  if AutoQuestLearnerMap.highlightdb[frame] then
    for k,v in pairs(AutoQuestLearnerMap.highlightdb[frame]) do
      AutoQuestLearnerMap.highlightdb[frame][k] = nil
    end
  else
    AutoQuestLearnerMap.highlightdb[frame] = {}
  end

  frame.layer = 0

  for title, tab in pairs(node) do
    AutoQuestLearnerMap.highlightdb[frame][title] = true

    tab.layer = GetLayerByTexture(tab.texture)

    if tab.cluster and tab.priority then
      tab.layer = tab.layer + (10 - min(tab.priority , 10))
    end

    if tab.spawn and ( tab.layer > frame.layer or not frame.spawn ) then
      frame.updateTexture = (frame.texture ~= tab.texture)
      frame.updateVertex = (frame.vertex ~= tab.vertex )
      frame.updateColor = (frame.color ~= tab.color)
      frame.updateLayer = (frame.layer ~= tab.layer)

      frame.layer       = tab.layer
      frame.spawn       = tab.spawn
      frame.spawnid     = tab.spawnid
      frame.spawntype   = tab.spawntype
      frame.respawn     = tab.respawn
      frame.level       = tab.level
      frame.questid     = tab.questid
      frame.texture     = tab.texture
      frame.vertex      = tab.vertex
      frame.title       = title
      frame.func        = tab.func
      frame.cluster     = tab.cluster
      frame.description = tab.description
      frame.priority    = tab.priority
      frame.quest       = tab.quest
      frame.qlvl        = tab.qlvl
      frame.itemreq     = tab.itemreq
      frame.arrow       = tab.arrow

      if AutoQuestLearnerDB.config.spawncolors == "1" then
        frame.color = tab.spawn or tab.title
      else
        frame.color = tab.title
      end
    end
  end

  if ( frame.updateTexture or frame.updateVertex or not frame.tex:GetTexture() ) and frame.texture then
    frame.tex:SetTexture(frame.texture)
    frame.tex:SetVertexColor(1,1,1)

    if frame.updateVertex and frame.vertex then
      local r, g, b = unpack(frame.vertex)
      if r > 0 or g > 0 or b > 0 then
        frame.tex:SetVertexColor(r, g, b, 1)
      end
    end
  end

  if ( frame.updateColor or frame.updateTexture or not frame.tex:GetTexture() ) and not frame.texture then
    local r,g,b = str2rgb(frame.color)
    frame.tex:SetTexture("Interface\\AddOns\\AutoQuestLearner\\img\\node_ring")
    frame.tex:SetVertexColor(r,g,b,1)
  end

  if frame.updateLayer then
    frame:SetFrameLevel((obj == "minimap" and 4 or 112) + frame.layer)
  end

  if frame.updateTexture or frame.updateVertex or frame.updateColor or frame.updateLayer then
    frame:SetScript("OnClick", (frame.func or AutoQuestLearnerMap.NodeClick))
  end

  local highlight = frame.texture and AutoQuestLearnerMap.highlightdb[frame][AutoQuestLearnerMap.highlight] and true or nil

  -- Reduced from 22 -- with rings (as opposed to the old solid dots),
  -- a dense cluster of many overlapping nodes got visually crowded at
  -- the larger size.
  -- Minimap gets its own, smaller cluster size -- the same absolute
  -- pixel size takes up proportionally more space on the much smaller
  -- minimap circle than it does on the full world map.
  if frame.cluster or frame.layer == 4 then
    frame.defsize = frame.minimap and 3 or 3
  else
    frame.defsize = 16
  end

  if not highlight then
    frame:SetWidth(frame.defsize)
    frame:SetHeight(frame.defsize)
  end

  if highlight then
    frame.hl:Show()
  else
    frame.hl:Hide()
  end

  frame.node = node
end

-- Groups a flat list of {x=, y=, ...} items into clusters by screen
-- proximity (simple greedy clustering: each item joins the nearest
-- existing group within mergeRadius pixels, or starts a new one).
-- Returns a list of { x=, y=, items={...} } groups, where x/y is the
-- group's running-average centroid. Used to merge many overlapping
-- spawn points into a single marker with a count badge, rather than
-- drawing dozens of individually-tiny, fully-overlapping rings.
function AutoQuestLearnerMap:ClusterNodePositions(items, mergeRadius)
    local groups = {}
    for _, item in ipairs(items) do
        local matched = nil
        local bestDist = nil
        for _, g in ipairs(groups) do
            -- Only ever merge items that represent the same NPC/target
            -- (same groupKey) -- two different creature types sitting
            -- close together should stay as two distinct, differently
            -- colored markers, not get blended into one that only
            -- shows whichever color happened to be first.
            if g.groupKey == item.groupKey then
                local dx = item.x - g.x
                local dy = item.y - g.y
                local dist = math.sqrt(dx * dx + dy * dy)
                if dist <= mergeRadius and (not bestDist or dist < bestDist) then
                    matched = g
                    bestDist = dist
                end
            end
        end
        if matched then
            table.insert(matched.items, item)
            local n = table.getn(matched.items)
            matched.x = matched.x + (item.x - matched.x) / n
            matched.y = matched.y + (item.y - matched.y) / n
        else
            table.insert(groups, { x = item.x, y = item.y, groupKey = item.groupKey, items = { item } })
        end
    end
    return groups
end

function AutoQuestLearnerMap:UpdateNodes()
  if AutoQuestLearner_FlickerDebug and AutoQuestLearner_FlickerDebug.active then
    local c = AutoQuestLearner_FlickerDebug.counts
    c["Map_UpdateNodes"] = (c["Map_UpdateNodes"] or 0) + 1
    -- Capture which specific call site triggered this -- there are 18
    -- different places in this addon that can call UpdateNodes(), and
    -- guessing among them one at a time hasn't worked, so let the
    -- debugger identify the actual source directly.
    local ok, stack = pcall(debugstack, 2, 3, 0)
    if ok and stack then
      local clean = string.gsub(stack, "Interface\\AddOns\\AutoQuestLearner\\", "")
      clean = string.gsub(clean, "\n", " <- ")
      clean = string.gsub(clean, "%s+", " ")
      if string.len(clean) > 150 then
        clean = string.sub(clean, 1, 150)
      end
      local key = "UpdateNodes_caller: " .. clean
      c[key] = (c[key] or 0) + 1
    else
      c["UpdateNodes_caller_UNAVAILABLE"] = (c["UpdateNodes_caller_UNAVAILABLE"] or 0) + 1
    end
  end
  local color = AutoQuestLearnerDB.config.spawncolors == "1" and "spawn" or "title"
  local map = AutoQuestLearnerMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
  local i = 1

  if AutoQuestLearner.tracker then
    AutoQuestLearner.tracker.Reset()
  end

  local focusQuestID = AutoQuestLearnerDB.config.focusmode == "1"
      and AutoQuestLearner_GetTrackedQuestID and AutoQuestLearner_GetTrackedQuestID()

  -- "Only Show Clusters in My Current Zone": skip populating this map
  -- view entirely if it's not the zone the player is actually in --
  -- see AutoQuestLearner_LastKnownPlayerZone's own comment for why this
  -- uses that cache instead of calling GetCurrentMap() directly (that
  -- function intentionally reports whatever's being BROWSED to while
  -- the map is open, not the player's real position).
  local hideAllForThisZone = AutoQuestLearnerDB.config.clustersCurrentZoneOnly == "1"
      and AutoQuestLearner_LastKnownPlayerZone
      and map ~= AutoQuestLearner_LastKnownPlayerZone

  -- Pass 1: collect every node that should be shown, with its real
  -- pixel position on the world map, before drawing anything.
  local items = {}
  for addon, _ in pairs(AutoQuestLearnerMap.nodes) do
    if AutoQuestLearnerMap.nodes[addon][map] and not hideAllForThisZone then
      for coords, node in pairs(AutoQuestLearnerMap.nodes[addon][map]) do
        local showThis = true
        if focusQuestID then
          showThis = false
          for _, entry in pairs(node) do
            if entry.questid == focusQuestID then
              showThis = true
              break
            end
          end
        end

        if showThis then
          local _, _, xPct, yPct = strfind(coords, "(.*)|(.*)")
          local x = xPct / 100 * WorldMapButton:GetWidth()
          local y = yPct / 100 * WorldMapButton:GetHeight()
          -- Same key UpdateNode uses to pick a color (tab.spawn, the
          -- NPC/target name, falling back to the title) -- clustering
          -- is scoped to this so two different creature types never
          -- get merged into one marker just because they're close,
          -- which would silently hide one of their colors.
          local groupKey = nil
          for title, tab in pairs(node) do
            groupKey = tab.spawn or title
            break
          end
          table.insert(items, { node = node, x = x, y = y, groupKey = groupKey })
        end
      end
    end
  end

  -- Pass 2: merge nodes that would visually overlap into a single
  -- marker (avoids dozens of fully-overlapping dots reading as one
  -- indistinguishable blob), then draw one pin per group.
  -- Merge radius increased from 8 to 18 -- live diagnostic data (two
  -- separate samples) showed multiple visually-overlapping groups
  -- sitting at ~9-10 units apart, just barely missing the old
  -- threshold and staying as separate, still-overlapping markers.
  -- Hold Ctrl to temporarily see every individual spawn point instead
  -- of the merged view -- matches pfQuest's own established
  -- convention for this exact interaction.
  local mergeRadius = (IsControlKeyDown and IsControlKeyDown()) and 0 or 18
  local groups = AutoQuestLearnerMap:ClusterNodePositions(items, mergeRadius)
  if AutoQuestLearner_ClusterDebug then
    print(string.format("|cff00ff00AutoQuestLearner:|r [worldmap cluster debug] items=%d groups=%d WorldMapButton size=%.0fx%.0f",
        table.getn(items), table.getn(groups), WorldMapButton:GetWidth(), WorldMapButton:GetHeight()))
    for gi, g in ipairs(groups) do
      if gi <= 5 then
        print(string.format("    group %d: (%.1f, %.1f) size=%d", gi, g.x, g.y, table.getn(g.items)))
      end
    end
  end

  for _, group in ipairs(groups) do
    if not AutoQuestLearnerMap.pins[i] then
      AutoQuestLearnerMap.pins[i] = AutoQuestLearnerMap:BuildNode("AutoQuestLearnerMapPin" .. i, WorldMapButton)
    end
    local pin = AutoQuestLearnerMap.pins[i]

    AutoQuestLearnerMap:UpdateNode(pin, group.items[1].node, color)

    if AutoQuestLearner.tracker then
      for _, item in ipairs(group.items) do
        for title, entryNode in pairs(item.node) do
          AutoQuestLearner.tracker.ButtonAdd(title, entryNode)
        end
      end
    end

    local groupSize = table.getn(group.items)
    pin.countBadge:Hide()

    pin:ClearAllPoints()
    pin:SetPoint("CENTER", WorldMapButton, "TOPLEFT", group.x, -group.y)
    pin:Show()

    i = i + 1
  end

  for j=i, table.getn(AutoQuestLearnerMap.pins) do
    if AutoQuestLearnerMap.pins[j] then AutoQuestLearnerMap.pins[j]:Hide() end
  end
end

-- UpdateNodes() is event-driven, not on a timer, so pressing/releasing
-- Ctrl while the world map is open wouldn't trigger a redraw on its
-- own -- this small ticker watches specifically for that state change
-- and forces one, so the un-merge preview feels instant.
local ctrlWatchFrame = CreateFrame("Frame")
local ctrlWatchElapsed = 0
local ctrlWatchLastState = nil
ctrlWatchFrame:SetScript("OnUpdate", function(self, elapsed)
    if not (WorldMapFrame and WorldMapFrame:IsVisible()) then
        ctrlWatchLastState = nil
        return
    end
    ctrlWatchElapsed = ctrlWatchElapsed + (elapsed or 0)
    if ctrlWatchElapsed < 0.1 then return end
    ctrlWatchElapsed = 0
    local down = IsControlKeyDown and IsControlKeyDown()
    if down ~= ctrlWatchLastState then
        ctrlWatchLastState = down
        AutoQuestLearnerMap:UpdateNodes()
    end
end)

local coord_cache = {}
local rotationWarningShown = false
function AutoQuestLearnerMap:UpdateMinimap()
  if AutoQuestLearnerDB.config.minimapnodes == "0" then
    return
  end

  if not rotationWarningShown and GetCVar and GetCVar("rotateMinimap") == "1" then
    rotationWarningShown = true
    print("|cff00ff00AutoQuestLearner:|r Your minimap is set to rotate with your facing. This addon's minimap pins assume a fixed north-up minimap and will drift/swing as you turn while rotation is on. For accurate pin positions, disable it: Interface Options > Minimap > uncheck 'Rotate Minimap' (or /console rotateMinimap 0). This doesn't affect the world map or the 3D arrow, which are unaffected by this setting.")
  end

  local xPlayer, yPlayer = GetPlayerMapPosition("player")
  if xPlayer == 0 and yPlayer == 0 then
    for pins, pin in pairs(AutoQuestLearnerMap.mpins) do pin:Hide() end
    return
  end

  local mZoom = Minimap:GetZoom()
  xPlayer, yPlayer = xPlayer * 100, yPlayer * 100
  local ctrlDown = IsControlKeyDown and IsControlKeyDown()
  local ctrlChanged = this.ctrlDown ~= ctrlDown
  this.ctrlDown = ctrlDown

  if not ctrlChanged and this.xPlayer == xPlayer and this.yPlayer == yPlayer and this.mZoom == mZoom then
    if ( this.stationaryTick or 1) > GetTime() then return else this.stationaryTick = GetTime() + 1 end
  end

  this.xPlayer, this.yPlayer, this.mZoom = xPlayer, yPlayer, mZoom
  local minimap_sizes = GetMinimapSizes()
  local color = AutoQuestLearnerDB.config.spawncolors == "1" and "spawn" or "title"
  local mapID = (AutoQuestLearner_GetCurrentMap and AutoQuestLearner_GetCurrentMap()) or AutoQuestLearnerMap:GetMapID()
  local mapZoom = Minimap:GetViewRadius() * 2

  -- Prefer a self-calibrated dimension (see
  -- AutoQuestLearner_UpdateMinimapCalibration in AutoQuestLearner.lua)
  -- over the static bundled data -- for the handful of starting-zone
  -- sub-areas this exists for, the bundled data only ever has the much
  -- larger PARENT zone's dimension (no external database has ever had
  -- the real value; it's unique to this client), so a calibrated value
  -- is always more accurate than the fallback once one exists. Looked
  -- up by the RAW sub-zone ID (skipAlias=true) since that's what
  -- calibration itself is keyed by -- the node LOOKUP below still uses
  -- the aliased mapID, since that's where nodes are actually stored.
  local rawZoneID = AutoQuestLearnerMap:GetMapID(nil, nil, true)
  local calibrated = AutoQuestLearnerDB.calibratedMinimap and AutoQuestLearnerDB.calibratedMinimap[rawZoneID]
  local mapWidth = calibrated and calibrated[1] or (minimap_sizes[mapID] and minimap_sizes[mapID][1] or 0)
  local mapHeight = calibrated and calibrated[2] or (minimap_sizes[mapID] and minimap_sizes[mapID][2] or 0)

  if mapWidth == 0 or mapHeight == 0 then
    return
  end

  local xScale = mapZoom / mapWidth
  local yScale = mapZoom / mapHeight

  local xDraw = Minimap:GetWidth() / xScale / 100
  local yDraw = Minimap:GetHeight() / yScale / 100

  local i = 1
  local focusQuestID = AutoQuestLearnerDB.config.focusmode == "1"
      and AutoQuestLearner_GetTrackedQuestID and AutoQuestLearner_GetTrackedQuestID()

  -- Pass 1: collect every node that's actually within the visible
  -- minimap circle, with its real pixel position, before drawing.
  local items = {}
  for addon, data in pairs(AutoQuestLearnerMap.nodes) do
    if data[mapID] and minimap_sizes[mapID] and AutoQuestLearnerMap:HasMinimap(mapID) then
      for coords, node in pairs(data[mapID]) do
        local showThis = true
        if focusQuestID then
          showThis = false
          for _, entry in pairs(node) do
            if entry.questid == focusQuestID then
              showThis = true
              break
            end
          end
        end

        if showThis then
          local x, y
          if coord_cache[coords] then
            x, y = coord_cache[coords][1], coord_cache[coords][2]
          else
            local _, _, strx, stry = strfind(coords, "(.*)|(.*)")
            x, y = strx + 0, stry + 0
            coord_cache[coords] = { x, y }
          end

          local xPos = ( x - xPlayer) * xDraw
          local yPos = -( y - yPlayer) * yDraw
          local distance = sqrt(xPos * xPos + yPos * yPos)

          if distance + 8 < Minimap:GetWidth() / 2 then
            local groupKey = nil
            for title, tab in pairs(node) do
              groupKey = tab.spawn or title
              break
            end
            table.insert(items, { node = node, x = xPos, y = yPos, groupKey = groupKey })
          end
        end
      end
    end
  end

  -- Pass 2: merge nodes that would visually overlap into a single
  -- marker, then draw one pin per group. Smaller merge radius than the
  -- world map's, since minimap pins are already much smaller.
  -- Hold Ctrl to temporarily see every individual spawn point instead
  -- of the merged view -- matches pfQuest's own established
  -- convention for this exact interaction (also matches pfQuest's
  -- separate "hold Ctrl over the minimap" convention for hiding nodes
  -- entirely, though this addon uses it for un-merging instead).
  local mergeRadius = (IsControlKeyDown and IsControlKeyDown()) and 0 or 5
  local groups = AutoQuestLearnerMap:ClusterNodePositions(items, mergeRadius)

  for _, group in ipairs(groups) do
    if not AutoQuestLearnerMap.mpins[i] then
      AutoQuestLearnerMap.mpins[i] = AutoQuestLearnerMap:BuildNode("AutoQuestMiniMapPin" .. i, Minimap)
    end
    local pin = AutoQuestLearnerMap.mpins[i]

    AutoQuestLearnerMap:UpdateNode(pin, group.items[1].node, color, "minimap")

    -- Resize based on current minimap zoom -- WoW's Minimap zoom
    -- level runs from 0 (fully zoomed out) up to
    -- GetZoomLevels()-1 (fully zoomed in), so this scales from
    -- 0.6x at minimum zoom up to 1.4x at maximum zoom, keeping
    -- cluster dots visually proportionate to everything else on
    -- the minimap instead of staying a fixed pixel size
    -- regardless of how zoomed in/out the view currently is.
    if pin.defsize and (pin.cluster or pin.layer == 4) then
      local zoomLevel = Minimap.GetZoom and Minimap:GetZoom() or 0
      local maxZoom = math.max((Minimap.GetZoomLevels and Minimap:GetZoomLevels() or 5) - 1, 1)
      local zoomRatio = zoomLevel / maxZoom
      -- Inverted from the initial assumption -- this client's
      -- zoom level convention runs opposite to the usual "0 =
      -- fully zoomed out" default, confirmed by it looking
      -- backwards in practice.
      local sizeMultiplier = 1.4 - zoomRatio * 0.8
      local scaledSize = pin.defsize * sizeMultiplier
      pin:SetWidth(scaledSize)
      pin:SetHeight(scaledSize)
    end

    local groupSize = table.getn(group.items)
    pin.countBadge:Hide()

    pin:ClearAllPoints()
    pin:SetPoint("CENTER", Minimap, "CENTER", group.x, group.y)
    pin:Show()

    i = i + 1
  end

  for j=i, table.getn(AutoQuestLearnerMap.mpins) do
    if AutoQuestLearnerMap.mpins[j] then AutoQuestLearnerMap.mpins[j]:Hide() end
  end
end

AutoQuestLearnerMap:SetScript("OnUpdate", function()
  if ( this.updateTick or 0.1) > GetTime() then return else this.updateTick = GetTime() + 0.1 end
  AutoQuestLearnerMap:UpdateMinimap()
end)

-- Register events for map updates
AutoQuestLearnerMap:RegisterEvent("WORLD_MAP_UPDATE")
AutoQuestLearnerMap:RegisterEvent("ZONE_CHANGED")
AutoQuestLearnerMap:RegisterEvent("ZONE_CHANGED_NEW_AREA")
AutoQuestLearnerMap:RegisterEvent("PLAYER_ENTERING_WORLD")
AutoQuestLearnerMap:RegisterEvent("QUEST_LOG_UPDATE")
AutoQuestLearnerMap:RegisterEvent("QUEST_WATCH_UPDATE")
AutoQuestLearnerMap:SetScript("OnEvent", function()
  if event == "WORLD_MAP_UPDATE" and not WorldMapFrame:IsShown() then
    -- BUGFIX: WORLD_MAP_UPDATE is specifically about the World Map UI's
    -- own display, and fires far more often than its name implies --
    -- including constantly while the map isn't even open, where there's
    -- nothing for it to usefully refresh. It was still triggering a
    -- full tracker rebuild every time regardless (UpdateNodes() draws
    -- both the map's pins AND resets the tracker as a side effect),
    -- which is what /aql flickerdebug's call-stack capture caught.
    -- Throttling this alone brought the rebuild rate way down but
    -- didn't eliminate the visible pulse entirely, since each
    -- individual unnecessary rebuild was still happening, just less
    -- often -- this addresses why it fires at all while the map is
    -- closed, not just how frequently.
    if AutoQuestLearner_FlickerDebug and AutoQuestLearner_FlickerDebug.active then
      local c = AutoQuestLearner_FlickerDebug.counts
      c["MapEvent_WORLD_MAP_UPDATE_skipped_closed"] = (c["MapEvent_WORLD_MAP_UPDATE_skipped_closed"] or 0) + 1
    end
    return
  end

  if event == "WORLD_MAP_UPDATE" or event == "ZONE_CHANGED"
      or event == "ZONE_CHANGED_NEW_AREA" or event == "QUEST_LOG_UPDATE"
      or event == "QUEST_WATCH_UPDATE" then
    if AutoQuestLearner_FlickerDebug and AutoQuestLearner_FlickerDebug.active then
      local c = AutoQuestLearner_FlickerDebug.counts
      local key = "MapEvent_" .. event
      c[key] = (c[key] or 0) + 1
    end
    if (this.lastQuestEventUpdate or 0) < GetTime() then
      AutoQuestLearnerMap:UpdateNodes()
      this.lastQuestEventUpdate = GetTime() + 3
    end
  elseif event == "PLAYER_ENTERING_WORLD" then
    -- Delay slightly on login/reload so the quest log and saved
    -- variables are fully populated before we build tracker buttons.
    local f = CreateFrame("Frame")
    local elapsedTotal = 0
    f:SetScript("OnUpdate", function(self, elapsed)
      elapsedTotal = elapsedTotal + elapsed
      if elapsedTotal >= 1 then
        self:SetScript("OnUpdate", nil)
        AutoQuestLearnerMap:UpdateNodes()
      end
    end)
  end
end)

-- make global available
AutoQuestLearner = AutoQuestLearner or CreateFrame("Frame")
AutoQuestLearner.map = AutoQuestLearnerMap

-- Real timing-based performance instrumentation, since scriptProfile is
-- confirmed to crash this client and can't be relied on. Wraps
-- functions from OUTSIDE rather than editing their internals -- safer
-- given some of these (UpdateMinimap especially) have several
-- early-return paths that would be easy to miss if timing code were
-- inserted at every exit point instead. debugprofilestop() is a
-- lightweight, standalone high-precision timer, unrelated to the full
-- CPU profiler that crashed -- it doesn't require scriptProfile to be
-- enabled at all.
AutoQuestLearner_PerfDebug = AutoQuestLearner_PerfDebug or { active = false, totals = {}, calls = {} }
local function WrapForTiming(tbl, methodName, label)
  local original = tbl[methodName]
  tbl[methodName] = function(self, ...)
    if not AutoQuestLearner_PerfDebug.active then
      return original(self, ...)
    end
    local ok, startTime = pcall(debugprofilestop)
    if not ok then
      return original(self, ...)
    end
    local a, b, c, d, e = original(self, ...)
    local elapsed = debugprofilestop() - startTime
    AutoQuestLearner_PerfDebug.totals[label] = (AutoQuestLearner_PerfDebug.totals[label] or 0) + elapsed
    AutoQuestLearner_PerfDebug.calls[label] = (AutoQuestLearner_PerfDebug.calls[label] or 0) + 1
    return a, b, c, d, e
  end
end
WrapForTiming(AutoQuestLearnerMap, "UpdateMinimap", "UpdateMinimap")
WrapForTiming(AutoQuestLearnerMap, "UpdateNodes", "UpdateNodes")
