---@class AddonPrivate
local Private = select(2, ...)

---@class Objectives
-- Shared data layer for the objective-driven features (Nearby HUD, Do-it-now
-- route, rare popups): the mapID->objective index, completion checks, distance,
-- and criterion type inference. Single source of truth so the UIs stay in sync.
local Objectives = {}
Private.Objectives = Objectives

-- Synthetic waypoint for objectives that have no map location (dungeons/raids),
-- so they share the same ignore-key scheme as mapped objectives.
local ZEROWP = { mapID = 0, x = 0, y = 0 }

-- ---------------------------------------------------------------------------
-- Criterion type inference (no explicit type field; classify by keywords).
-- ---------------------------------------------------------------------------
Objectives.TYPE_ICONS = {
    glyph      = "Interface\\Icons\\INV_Misc_Rune_01",
    race       = "Interface\\Icons\\INV_Misc_PocketWatch_01",
    treasure   = "Interface\\Icons\\INV_Misc_Coin_01",
    quest      = "Interface\\GossipFrame\\AvailableQuestIcon",
    rare       = "Interface\\Icons\\INV_Misc_Bone_HumanSkull_01",
    flightpath = "Interface\\Icons\\Ability_Mount_Gryphon_01",
    dungeon    = "Interface\\Icons\\INV_Misc_Key_03",
    explore    = "Interface\\Icons\\INV_Misc_Spyglass_02",
    lore       = "Interface\\Icons\\INV_Misc_Book_09",
    renown     = "Interface\\Icons\\Achievement_Reputation_01",
    alliedrace = "Interface\\GossipFrame\\AvailableQuestIcon",
    delve      = "Interface\\Icons\\INV_Torch_Lit",
    storyline  = "Interface\\Icons\\INV_Misc_Book_07",
}
-- Some types render better with a UI atlas than a plain icon texture. Rares use
-- the same skull the rare-alert panel / map vignette uses, for consistency.
Objectives.TYPE_ICON_ATLAS = {
    rare = "VignetteKill",
}
Objectives.TYPE_LABELS = {
    glyph = "Glyph", race = "Race", treasure = "Treasure", quest = "Quest", rare = "Rare",
    flightpath = "Flight Point", dungeon = "Dungeon", explore = "Explore", lore = "Lore",
    renown = "Renown", alliedrace = "Allied Race", delve = "Delve", storyline = "Storyline",
}

-- Apply a criterion type's icon to a texture object, using an atlas where one is
-- defined (rares) and a plain texture otherwise. Returns true if it set anything.
-- Resets texcoords so a pooled texture reused across atlas/texture rows is clean.
function Objectives:ApplyTypeIcon(tex, ctype)
    if not tex then return false end
    local atlas = ctype and self.TYPE_ICON_ATLAS[ctype]
    if atlas then
        tex:SetAtlas(atlas)
        return true
    end
    local path = ctype and self.TYPE_ICONS[ctype]
    if path then
        tex:SetTexCoord(0, 1, 0, 1)
        tex:SetTexture(path)
        return true
    end
    return false
end

-- Auto-generated "what is this / how to do it" line, used when a criterion has
-- no written guide so name-only criteria still tell the player what to do.
local CONTEXT_HINTS = {
    flightpath = "Flight Point — fly to this spot to discover the flight master.",
    lore       = "Lore Object — a findable, interactable object; click it once to collect it.",
    quest      = "Quest — pick up and complete the quest at this location.",
    treasure   = "Treasure — collect it at this spot (may be hidden/up high).",
    rare       = "Rare — find and defeat this creature; it may need to respawn.",
    dungeon    = "Dungeon — complete this inside the instance (try Find Group).",
    explore    = "Exploration — travel into this area to reveal it on the map.",
    glyph      = "Dragon Glyph — collect it, usually up high; use Skyriding.",
    race       = "Skyriding Race — start and finish the race course here.",
    renown     = "Renown — earn renown/reputation with the relevant faction.",
    alliedrace = "Allied Race — complete the recruitment questline to unlock this allied race.",
    delve      = "Delve — complete the quests/storylines inside this Delve.",
    storyline  = "Storyline — complete this zone's campaign story chapters.",
}

function Objectives:ContextHint(ctype)
    return ctype and CONTEXT_HINTS[ctype]
end

-- A "rare-kill" achievement: ALL its criteria are rare mobs, so every criterion should
-- get the rare (skull) icon even when an individual criterion's guide doesn't say
-- "kill/defeat" (e.g. "Multiple spawn points"). Detected by the achievement NAME, or
-- inferred when several of its criteria read as kills. Cached per achievementID.
local rareAchCache = {}
function Objectives:IsRareAchievement(ach)
    if not ach or not ach.achievementID then return false end
    local id = ach.achievementID
    local cached = rareAchCache[id]
    if cached ~= nil then return cached end
    local an = (ach.name or ""):lower()
    local result
    if an:find("adventurer") or an:find("%f[%a]rares?%f[%A]") or an:find("must die")
        or an:find("slayer") or an:find("murder") or an:find("menagerie")
        or an:find("big game") or an:find("%f[%a]bane%f[%A]") then
        result = true
    else
        -- infer: if a good share of the criteria read as kills, treat the whole set as rares
        local rareN, total = 0, 0
        for _, crit in ipairs(ach.criteria or {}) do
            total = total + 1
            local g = (crit.guide or ""):lower()
            local d = (crit.description or ""):lower()
            if g:find("%f[%a]kill") or g:find("%f[%a]defeat") or g:find("%f[%a]slay")
                or g:find("%f[%a]rares?%f[%A]") or d:find("%f[%a]kill") then
                rareN = rareN + 1
            end
        end
        result = total >= 2 and rareN >= 2 and (rareN / total) >= 0.34
    end
    rareAchCache[id] = result
    return result
end

-- Classify a criterion's type. The PARENT ACHIEVEMENT name is the strongest
-- signal (e.g. "Khaz Algar Flight Master" -> every criterion is a flight point,
-- "Khaz Algar Lore Hunter" -> quests), so it is checked first; then the
-- criterion's own description/guide keywords.
function Objectives:Classify(ach, crit)
    local d  = (crit.description or ""):lower()
    local g  = (crit.guide or ""):lower()
    local an = (ach.name or ""):lower()
    local ct  = d .. " " .. g       -- criterion text only
    local all = ct .. " " .. an     -- + achievement name
    -- Objectives gated by a world quest are OPEN-WORLD, never instance content --
    -- their guides often say "do it in a raid group", which must not read as a raid.
    local wqGated = ct:find("world quest") ~= nil

    -- Strong signals from the parent achievement name (most specific first).
    if an:find("flight master") or an:find("flight path") then return "flightpath" end
    if an:find("delve") then return "delve" end                                -- incl. "Delve Loremaster:"
    if an:find("diplomat") then return "renown" end                            -- Diplomat = renown/rep
    if an:find("allied race") then return "alliedrace" end                     -- "Allied Races:" unlock questlines
    if an:find("pathfinder") then return "storyline" end                       -- per-zone story chapters
    if an:find("lore hunter") or an:find("chronicler") then return "lore" end  -- clickable lore objects
    if an:find("loremaster") then return "quest" end                           -- quest completion
    if an:find("explor") then return "explore" end                             -- Explorer = uncover the map
    if an:find("keystone") or an:find("dungeon") then return "dungeon" end
    if an:find("glyph") then return "glyph" end
    if an:find("treasures") then return "treasure" end
    -- Any rare-kill achievement (Adventurer / "…Rare?" / murder / slayer / …): every
    -- criterion is a rare mob, so classify them ALL as rare (consistent skull icon),
    -- not just the ones whose guide happens to mention "kill". Checked after the more
    -- specific dungeon/glyph/treasure names so those still win.
    if self:IsRareAchievement(ach) then return "rare" end

    -- Criterion-level keywords.
    if all:find("glyph") then return "glyph" end
    if all:find("%f[%a]races?%f[%A]") or all:find("time trial") or all:find("skyriding race") then return "race" end
    if ct:find("flight path") or ct:find("flight point") or ct:find("flight master") then return "flightpath" end
    if ct:find("%f[%a]delve") then return "delve" end
    if ct:find("renown") or ct:find("reputation") then return "renown" end
    if not wqGated and (d:find("%f[%a]dungeons?%f[%A]") or d:find("%f[%a]raid%f[%A]")
        or ct:find("difficulty") or ct:find("defeat the bosses") or ct:find("mythic")) then return "dungeon" end
    if ct:find("treasure") or ct:find("%f[%a]chest") or ct:find("cache") or ct:find("coffer")
        or ct:find("lockbox") or ct:find("buried") or ct:find("dig site") then return "treasure" end
    if ct:find("%f[%a]lore%f[%A]") then return "lore" end
    if ct:find("%f[%a]quests?%f[%A]") or ct:find("questline") or ct:find("campaign")
        or ct:find("turn in") or ct:find("storyline") then return "quest" end
    if ct:find("explor") or ct:find("discover") then return "explore" end
    if ct:find("%f[%a]rares?%f[%A]") or g:find("%f[%a]kill%f[%A]") or g:find("%f[%a]defeat%f[%A]")
        or g:find("%f[%a]slay") or d:find("%f[%a]kill%f[%A]") then return "rare" end
    if wqGated then return "quest" end -- world-quest-gated open-world objective
    return nil
end

-- ---------------------------------------------------------------------------
-- Completion
-- ---------------------------------------------------------------------------
function Objectives:IsAchievementComplete(id)
    return select(4, GetAchievementInfo(id)) and true or false
end

function Objectives:IsCriterionComplete(ach, crit)
    if crit.criteriaIndex then
        local ok, _, _, done = pcall(GetAchievementCriteriaInfo, ach.achievementID, crit.criteriaIndex)
        if ok and done ~= nil then return done end
    end
    if crit.childAchievementID then
        return self:IsAchievementComplete(crit.childAchievementID)
    end
    return false
end

-- ---------------------------------------------------------------------------
-- Index: mapID -> { {ach, crit, wp}, ... } built once from the loaded data.
-- ---------------------------------------------------------------------------
local byMap = {}
local indexBuilt = false

local function BuildIndex()
    wipe(byMap)
    local allData = Private.ExpansionUtil:GetLoadedExpansionData()
    for _, expansion in ipairs(allData or {}) do
        for _, ach in ipairs(expansion) do
            if ach.criteria then
                for _, crit in ipairs(ach.criteria) do
                    if crit.waypoints then
                        for _, wp in ipairs(crit.waypoints) do
                            -- require NUMERIC coords: a stray string placeholder (e.g. "N/A")
                            -- would otherwise crash MakeKey's %.1f format on every refresh.
                            if wp.mapID and type(wp.x) == "number" and type(wp.y) == "number" then
                                byMap[wp.mapID] = byMap[wp.mapID] or {}
                                tinsert(byMap[wp.mapID], { ach = ach, crit = crit, wp = wp })
                            end
                        end
                    end
                end
            end
        end
    end
    indexBuilt = true
end

function Objectives:InvalidateIndex()
    indexBuilt = false
end

function Objectives:GetCandidates(mapID)
    if not indexBuilt then BuildIndex() end
    return mapID and byMap[mapID]
end

-- True if this map has any tracked objectives (i.e. it's part of a meta the addon
-- tracks) -- used to decide whether to show the "0 WQs needed" note.
function Objectives:IsMetaZone(mapID)
    local c = self:GetCandidates(mapID)
    return c ~= nil and #c > 0
end

-- ---------------------------------------------------------------------------
-- Ignore list (persisted in SavedVariables via Settings). A stable key per
-- objective waypoint so the player can skip individual "too hard for now" stops.
-- ---------------------------------------------------------------------------
function Objectives:MakeKey(ach, crit, wp)
    return string.format("%d:%d:%d:%.1f:%.1f",
        ach.achievementID, crit.criteriaIndex or 0, wp.mapID or 0, wp.x or 0, wp.y or 0)
end

function Objectives:IsIgnored(ach, crit, wp)
    local db = Private.Settings and Private.Settings.db
    return (db and db.ignored and db.ignored[self:MakeKey(ach, crit, wp)]) ~= nil
end

function Objectives:SetIgnored(ach, crit, wp, ignored)
    local db = Private.Settings and Private.Settings.db
    if not db then return end
    db.ignored = db.ignored or {}
    local key = self:MakeKey(ach, crit, wp)
    if ignored then
        local ctype = self:Classify(ach, crit)
        local label = self.TYPE_LABELS[ctype]
        db.ignored[key] = (label and ("[" .. label .. "] ") or "") .. (crit.description or ach.name or "Objective")
    else
        db.ignored[key] = nil
    end
    self:InvalidateSummaries()
end

function Objectives:UnignoreKey(key)
    local db = Private.Settings and Private.Settings.db
    if db and db.ignored then
        db.ignored[key] = nil
        self:InvalidateSummaries()
    end
end

-- key -> display name of every ignored objective.
function Objectives:GetIgnored()
    local db = Private.Settings and Private.Settings.db
    return (db and db.ignored) or {}
end

function Objectives:IgnoredCount()
    local n = 0
    for _ in pairs(self:GetIgnored()) do n = n + 1 end
    return n
end

-- ---------------------------------------------------------------------------
-- Waypoint notes. Author a short title/context line per waypoint in-game
-- (Ctrl-click a route sub-waypoint). Stored in SavedVariables keyed the same
-- way as the ignore list, so it survives reloads. GetNote falls back to a
-- baked-in wp.note shipped in the ExpansionData, so authored notes overlay data.
-- ---------------------------------------------------------------------------
function Objectives:GetNote(ach, crit, wp)
    local db = Private.Settings and Private.Settings.db
    local userNotes = db and db.waypointNotes
    local expUtil = Private.ExpansionUtil
    -- Priority: player's own in-game note > shipped overlay note > baked wp.note.
    -- Keys embed coords at %.1f; a coord ending in .x5 can round either way in Lua
    -- (float-repr dependent), so we also probe keys with x/y nudged by <0.05. This
    -- lets bulk-authored Notes.lua match reliably. The exact key (dx=dy=0) is tried
    -- first, so a normal note returns immediately; neighbours only cover the .x5 edge.
    local haveUser = userNotes and next(userNotes) ~= nil
    local haveReg = expUtil and expUtil.waypointNotes and next(expUtil.waypointNotes) ~= nil
    if haveUser or haveReg then
        local x, y = wp.x or 0, wp.y or 0
        local aid, cidx, mid = ach.achievementID, crit.criteriaIndex or 0, wp.mapID or 0
        for _, dx in ipairs({ 0, 0.049, -0.049 }) do
            for _, dy in ipairs({ 0, 0.049, -0.049 }) do
                local key = string.format("%d:%d:%d:%.1f:%.1f", aid, cidx, mid, x + dx, y + dy)
                local u = haveUser and userNotes[key]
                if u and u ~= "" then return u end
                local s = haveReg and expUtil:GetRegisteredNote(key)
                if s and s ~= "" then return s end
            end
        end
    end
    return wp.note
end

function Objectives:SetNote(ach, crit, wp, text)
    local db = Private.Settings and Private.Settings.db
    if not db then return end
    db.waypointNotes = db.waypointNotes or {}
    local key = self:MakeKey(ach, crit, wp)
    if text and text ~= "" then
        -- Record enough to bake this back into ExpansionData later (see /drustexportnotes).
        db.waypointNotes[key] = text
        db.waypointNoteMeta = db.waypointNoteMeta or {}
        db.waypointNoteMeta[key] = {
            achievementID = ach.achievementID,
            achName = ach.name,
            criteriaIndex = crit.criteriaIndex,
            description = crit.description,
            x = wp.x, y = wp.y, mapID = wp.mapID,
            note = text,
            expansion = Private.ExpansionUtil
                and Private.ExpansionUtil:GetExpansionNameForAchievement(ach.achievementID),
        }
    else
        db.waypointNotes[key] = nil
        if db.waypointNoteMeta then db.waypointNoteMeta[key] = nil end
    end
end

-- Every authored note, richest form, for export. Returns array of meta tables.
function Objectives:GetAllNoteMeta()
    local db = Private.Settings and Private.Settings.db
    local out = {}
    if db and db.waypointNoteMeta then
        for _, m in pairs(db.waypointNoteMeta) do out[#out + 1] = m end
    end
    table.sort(out, function(a, b)
        if a.achievementID ~= b.achievementID then return a.achievementID < b.achievementID end
        return (a.criteriaIndex or 0) < (b.criteriaIndex or 0)
    end)
    return out
end

-- ---------------------------------------------------------------------------
-- Waypoint coordinate fixes. A live-debugging aid: when a shipped waypoint is at
-- the wrong spot (e.g. a map re-scaled in a patch, like the Ringing Deeps -4 X
-- shift), the player Alt-left-clicks a route waypoint to TEST candidate offsets
-- in-game, then Alt-right-clicks to RECORD the correct one. Stored richly (orig +
-- new coords) so /drustexportfixes can dump them for baking back into ExpansionData.
-- Look-up tolerates the %.1f key rounding, same as notes.
-- ---------------------------------------------------------------------------
function Objectives:GetWaypointFix(ach, crit, wp)
    local db = Private.Settings and Private.Settings.db
    local fixes = db and db.waypointFixes
    if not fixes or next(fixes) == nil then return nil end
    local x, y = wp.x or 0, wp.y or 0
    local aid, cidx, mid = ach.achievementID, crit.criteriaIndex or 0, wp.mapID or 0
    for _, dx in ipairs({ 0, 0.049, -0.049 }) do
        for _, dy in ipairs({ 0, 0.049, -0.049 }) do
            local key = string.format("%d:%d:%d:%.1f:%.1f", aid, cidx, mid, x + dx, y + dy)
            if fixes[key] then return fixes[key], key end
        end
    end
    return nil
end

function Objectives:SetWaypointFix(ach, crit, wp, newX, newY, label)
    local db = Private.Settings and Private.Settings.db
    if not db then return end
    db.waypointFixes = db.waypointFixes or {}
    local key = self:MakeKey(ach, crit, wp)
    -- Choosing "On file" (coords unchanged) clears any saved fix.
    if not newX or not newY
        or (math.abs(newX - (wp.x or 0)) < 0.001 and math.abs(newY - (wp.y or 0)) < 0.001) then
        db.waypointFixes[key] = nil
        return
    end
    db.waypointFixes[key] = {
        achievementID = ach.achievementID,
        achName = ach.name,
        criteriaIndex = crit.criteriaIndex,
        description = crit.description,
        mapID = wp.mapID,
        origX = wp.x, origY = wp.y,
        x = newX, y = newY,
        label = label,
        expansion = Private.ExpansionUtil
            and Private.ExpansionUtil:GetExpansionNameForAchievement(ach.achievementID),
    }
end

function Objectives:ClearWaypointFix(ach, crit, wp)
    local db = Private.Settings and Private.Settings.db
    if db and db.waypointFixes then
        db.waypointFixes[self:MakeKey(ach, crit, wp)] = nil
    end
end

-- Every recorded fix, for export. Returns array sorted by achievement/criterion.
function Objectives:GetAllWaypointFixes()
    local db = Private.Settings and Private.Settings.db
    local out = {}
    if db and db.waypointFixes then
        for _, m in pairs(db.waypointFixes) do out[#out + 1] = m end
    end
    table.sort(out, function(a, b)
        if a.achievementID ~= b.achievementID then return a.achievementID < b.achievementID end
        return (a.criteriaIndex or 0) < (b.criteriaIndex or 0)
    end)
    return out
end

-- ---------------------------------------------------------------------------
-- Route steps. Author the ORDER a criterion's waypoints should be visited, so the
-- planner routes you the reachable way (e.g. a cave ENTRANCE before the treasure
-- that sits underground directly below you). Each waypoint is tagged with:
--   object = which physical collectible/target it belongs to (1, 2, 3 ...)
--   step   = order within that object (1 = approach/entrance, higher = deeper, last = item)
-- A criterion covering many collectibles (e.g. "Feline Figurines Found", 10 figures)
-- is split into objects 1..10, each with its own entrance/step chain. Waypoints with
-- no assignment behave exactly as before (each its own single-step target, nearest first).
-- Player assignments (SavedVariables) overlay a shipped Steps overlay, which overlays
-- baked wp.object/wp.step. Look-up tolerates the %.1f key rounding, same as notes/fixes.
-- ---------------------------------------------------------------------------
function Objectives:GetWaypointStep(ach, crit, wp)
    -- This runs for EVERY incomplete waypoint on the map, every planner refresh, so
    -- it must be cheap in the common case. The 9-key tolerant scan only matters when
    -- steps have actually been authored (player SavedVariables or a shipped overlay);
    -- otherwise skip straight to the baked wp.object/wp.step (usually nil too).
    local db = Private.Settings and Private.Settings.db
    local userSteps = db and db.waypointSteps
    local expUtil = Private.ExpansionUtil
    local haveUser = userSteps and next(userSteps) ~= nil
    local haveReg = expUtil and expUtil.waypointSteps and next(expUtil.waypointSteps) ~= nil
    if haveUser or haveReg then
        local x, y = wp.x or 0, wp.y or 0
        local aid, cidx, mid = ach.achievementID, crit.criteriaIndex or 0, wp.mapID or 0
        for _, dx in ipairs({ 0, 0.049, -0.049 }) do
            for _, dy in ipairs({ 0, 0.049, -0.049 }) do
                local key = string.format("%d:%d:%d:%.1f:%.1f", aid, cidx, mid, x + dx, y + dy)
                local u = haveUser and userSteps[key]
                if u then return u, key end
                local s = haveReg and expUtil:GetRegisteredStep(key)
                if s then return s, key end
            end
        end
    end
    -- baked fallback: a shipped waypoint may carry object/step directly
    if wp.object or wp.step then
        return { object = wp.object, step = wp.step }
    end
    return nil
end

function Objectives:SetWaypointStep(ach, crit, wp, object, step)
    local db = Private.Settings and Private.Settings.db
    if not db then return end
    db.waypointSteps = db.waypointSteps or {}
    local key = self:MakeKey(ach, crit, wp)
    -- Clearing both dimensions removes the assignment entirely.
    if not object and not step then
        db.waypointSteps[key] = nil
        if db.waypointStepMeta then db.waypointStepMeta[key] = nil end
        return
    end
    -- Assigning one dimension defaults the other to 1, so a single click makes a
    -- valid object 1 / step 1 entry the player can refine.
    local existing = db.waypointSteps[key] or {}
    object = object or existing.object or 1
    step = step or existing.step or 1
    db.waypointSteps[key] = { object = object, step = step }
    db.waypointStepMeta = db.waypointStepMeta or {}
    db.waypointStepMeta[key] = {
        achievementID = ach.achievementID,
        achName = ach.name,
        criteriaIndex = crit.criteriaIndex,
        description = crit.description,
        x = wp.x, y = wp.y, mapID = wp.mapID,
        object = object, step = step,
        expansion = Private.ExpansionUtil
            and Private.ExpansionUtil:GetExpansionNameForAchievement(ach.achievementID),
    }
end

function Objectives:ClearWaypointStep(ach, crit, wp)
    self:SetWaypointStep(ach, crit, wp, nil, nil)
end

-- Every authored step assignment, richest form, for export. Sorted by
-- achievement, then criterion, then object, then step, so the export reads in route order.
function Objectives:GetAllWaypointStepMeta()
    local db = Private.Settings and Private.Settings.db
    local out = {}
    if db and db.waypointStepMeta then
        for _, m in pairs(db.waypointStepMeta) do out[#out + 1] = m end
    end
    table.sort(out, function(a, b)
        if a.achievementID ~= b.achievementID then return a.achievementID < b.achievementID end
        if (a.criteriaIndex or 0) ~= (b.criteriaIndex or 0) then return (a.criteriaIndex or 0) < (b.criteriaIndex or 0) end
        if (a.object or 0) ~= (b.object or 0) then return (a.object or 0) < (b.object or 0) end
        return (a.step or 0) < (b.step or 0)
    end)
    return out
end

local function RefreshHuds()
    if Private.NearbyHud then Private.NearbyHud:Refresh() end
    if Private.RouteHud then Private.RouteHud:Refresh() end
end

-- Populate a menu root/submenu with a "restore" entry per ignored objective.
function Objectives:BuildIgnoredMenu(root)
    local list = {}
    for key, name in pairs(self:GetIgnored()) do list[#list + 1] = { key = key, name = name } end
    table.sort(list, function(a, b) return a.name < b.name end)
    if #list == 0 then
        root:CreateTitle("No ignored objectives")
        return
    end
    root:CreateTitle("Click to restore:")
    for _, item in ipairs(list) do
        root:CreateButton(item.name, function()
            self:UnignoreKey(item.key)
            RefreshHuds()
        end)
    end
end

function Objectives:ShowIgnoredMenu(anchor)
    MenuUtil.CreateContextMenu(anchor, function(_, root)
        self:BuildIgnoredMenu(root)
    end)
end

-- Debug: a fake dungeon objective injected at the player's position so the
-- dungeon row / per-row LFG button can be tested. Toggled by /drustdebugdungeon.
local debugDungeon = nil
function Objectives:ToggleDebugDungeon()
    if debugDungeon then debugDungeon = nil; return false end
    local mapID = C_Map.GetBestMapForUnit("player")
    local pos = mapID and C_Map.GetPlayerMapPosition(mapID, "player")
    if not pos then return false end
    local x, y = pos:GetXY()
    debugDungeon = {
        ach  = { achievementID = -1, name = "DEBUG Dungeon Meta" },
        crit = { description = "Algeth'ar Academy", criteriaIndex = -1,
                 guide = "Debug row to test the per-row Find Group button." },
        wp   = { mapID = mapID, x = x * 100, y = y * 100 },
    }
    return true
end

-- Returns an array of incomplete { ach, crit, wp } entries on the given map.
function Objectives:GetIncompleteOnMap(mapID)
    local out = {}
    local candidates = self:GetCandidates(mapID)
    if candidates then
        local achDone = {}
        for _, e in ipairs(candidates) do
            local id = e.ach.achievementID
            local done = achDone[id]
            if done == nil then
                done = self:IsAchievementComplete(id)
                achDone[id] = done
            end
            if not done and not self:IsCriterionComplete(e.ach, e.crit) and not self:IsIgnored(e.ach, e.crit, e.wp) then
                tinsert(out, e)
            end
        end
    end
    if debugDungeon and debugDungeon.wp.mapID == mapID then
        tinsert(out, debugDungeon)
    end
    return out
end

-- Does a nearby rare (by vignette name) correspond to an incomplete tracked
-- objective on this map? Returns the matching {ach, crit, wp} entry or nil, so a
-- rare-spawn popup can flag "needed for your meta" and link to the achievement.
function Objectives:FindRareMatch(name, mapID)
    if not name or name == "" then return nil end
    local lname = name:lower()
    for _, e in ipairs(self:GetIncompleteOnMap(mapID)) do
        local desc = (e.crit.description or ""):lower()
        if desc ~= "" and (desc:find(lname, 1, true) or lname:find(desc, 1, true)) then
            return e
        end
    end
    return nil
end

-- Walk up the map hierarchy to the continent this map belongs to.
function Objectives:GetContinentID(mapID)
    local info = mapID and C_Map.GetMapInfo(mapID)
    -- Enum.UIMapType.Continent == 2; zones/dungeons are > 2.
    while info and info.mapType and info.mapType > 2 and info.parentMapID and info.parentMapID ~= 0 do
        info = C_Map.GetMapInfo(info.parentMapID)
    end
    return info and info.mapID
end

-- Continent map ID -> which expansion's reputations to show, and which SYSTEM it
-- uses. "renown" = modern C_MajorFactions (DF/WW); "classic" = old Neutral..Exalted
-- C_Reputation (BfA/SL). expID is only needed for renown (major faction filter);
-- expName scopes the data scan for meta-relevant factions.
local SL = { name = "Back from the Beyond", kind = "classic" }
local BFA = { name = "Farewell to Arms", kind = "classic" }
local CONTINENT_EXPANSION = {
    [2274] = { name = "Worldsoul-Searching", kind = "renown", expID = 10 }, -- Khaz Algar / TWW
    [1978] = { name = "A World Awoken",       kind = "renown", expID = 9  }, -- Dragon Isles / DF
    -- Shadowlands: 1550 is the continent, but The Maw / Zereth Mortis / Oribos can
    -- resolve as their own continent-level maps, so map them all to SL.
    [1550] = SL, [1543] = SL, [1970] = SL, [1670] = SL,
    [875]  = BFA, [876] = BFA,  -- Zandalar / Kul Tiras (BfA)
    -- Nazjatar (8.2) and Mechagon resolve as their OWN continent-level maps rather
    -- than under Zandalar/Kul Tiras, so map them to BfA explicitly (same reason as
    -- the SL Maw/Zereth Mortis entries) -- otherwise auto open/close wrongly treated
    -- Nazjatar as non-meta and closed the HUDs on reload.
    [1355] = BFA, [1462] = BFA,
}

-- True if the CURRENT CONTINENT hosts a tracked meta expansion at all. Used by the
-- auto open/close setting so the HUDs only close when the WHOLE continent is unrelated
-- (not merely the current zone).
function Objectives:IsMetaContinent(mapID)
    mapID = mapID or (C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player"))
    if CONTINENT_EXPANSION[self:GetContinentID(mapID) or 0] ~= nil then return true end
    -- Dynamic safety net: if the current zone actually has tracked meta objectives,
    -- it's meta regardless of how its continent resolves. Prevents auto open/close
    -- from closing the HUDs in odd continent-level maps (Nazjatar, Mechagon, etc.).
    return self:IsMetaZone(mapID)
end
local REP_KEYWORDS = { "renown", "reputation", "exalted", "revered", "honored",
    "friendly", "paragon", "appreciated", "status with" }

-- The standing/level a criterion asks for, so we can show "needs Revered" (not max)
-- in the tooltip. Renown levels + the classic + SL custom standing names.
local STANDINGS = { "Exalted", "Revered", "Honored", "Friendly",
    "Appreciated", "Cordial", "Ambivalent", "Tentative" }
local function parseRequired(desc)
    if not desc then return nil end
    local lvl = desc:match("[Rr]enown level (%d+)") or desc:match("[Rr]enown rank (%d+)")
        or desc:match("[Rr]ank (%d+) renown") or desc:match("[Rr]enown (%d+)")
    if lvl then return "Renown " .. lvl end
    for _, s in ipairs(STANDINGS) do if desc:find(s) then return s end end
    return nil
end

-- Find the tracked achievement that gates a faction's reputation/renown: a
-- criterion naming the faction next to a rep keyword (or under a "...Diplomat"
-- achievement). Returns { achID, expIndex, achName, guide } for shift-click, or
-- nil. Cached (data is static after load). Works for renown AND classic reps.
local repAchCache = {}
function Objectives:GetReputationAchievement(factionName)
    if not factionName or factionName == "" then return nil end
    local key = factionName:lower()
    local cached = repAchCache[key]
    if cached ~= nil then return cached or nil end
    local found = false
    for expIndex, expansion in ipairs(Private.ExpansionUtil:GetLoadedExpansionData() or {}) do
        for _, ach in ipairs(expansion) do
            local an = (ach.name or ""):lower()
            local diplomat = an:find("diplomat", 1, true) ~= nil
            if ach.criteria then
                for _, crit in ipairs(ach.criteria) do
                    -- Some criteria keep the faction/standing in the GUIDE, not the
                    -- description (desc is just the child achievement's name), so
                    -- search both.
                    local text = (crit.description or "") .. " " .. (crit.guide or "")
                    local d = text:lower()
                    if d:find(key, 1, true) then
                        local repish = diplomat
                        if not repish then
                            for _, kw in ipairs(REP_KEYWORDS) do
                                if d:find(kw, 1, true) then repish = true; break end
                            end
                        end
                        if repish then
                            found = { achID = ach.achievementID, expIndex = expIndex, achName = ach.name,
                                guide = (crit.guide and crit.guide ~= "" and crit.guide) or nil,
                                ach = ach, crit = crit, required = parseRequired(text) }
                            break
                        end
                    end
                end
            end
            if found then break end
        end
        if found then break end
    end
    repAchCache[key] = found or false
    return found or nil
end

-- Lowercased blob of an expansion's reputation-criterion text, used to test
-- whether a live faction is one the meta actually needs. Cached per expansion.
local repBlobCache = {}
local function reputationBlob(expName)
    if repBlobCache[expName] then return repBlobCache[expName] end
    local parts = {}
    local data = Private.ExpansionUtil:GetExpansionByName(expName)
    if data then
        for _, ach in ipairs(data) do
            local diplomat = (ach.name or ""):lower():find("diplomat", 1, true) ~= nil
            if ach.criteria then
                for _, crit in ipairs(ach.criteria) do
                    -- include the guide too: faction names often live there, not in the description
                    local dl = ((crit.description or "") .. " " .. (crit.guide or "")):lower()
                    local repish = diplomat
                    if not repish then
                        for _, kw in ipairs(REP_KEYWORDS) do if dl:find(kw, 1, true) then repish = true; break end end
                    end
                    if repish then parts[#parts + 1] = dl end
                end
            end
        end
    end
    -- Strip apostrophes (straight ' and curly U+2019) so names like Ve'nari match
    -- regardless of which apostrophe the data vs the API uses.
    local blob = table.concat(parts, " | "):gsub("'", ""):gsub("\226\128\153", "")
    repBlobCache[expName] = blob
    return blob
end

local function normName(s) return ((s or ""):lower():gsub("'", "")):gsub("\226\128\153", "") end

-- Modern renown (C_MajorFactions). Only factions a tracked achievement needs, so
-- seasonal Delver's Journey ("Delves: Season N") tracks (no gating achievement)
-- are excluded.
function Objectives:_renownFactions(exp)
    if not (C_MajorFactions and C_MajorFactions.GetMajorFactionIDs) then return {} end
    local ids = C_MajorFactions.GetMajorFactionIDs(exp.expID) or {}
    local out = {}
    for _, id in ipairs(ids) do
        local d = C_MajorFactions.GetMajorFactionData(id)
        if d and d.isUnlocked then
            local linkedAch = self:GetReputationAchievement(d.name)
            if linkedAch then
                out[#out + 1] = {
                    isRenown = true, factionID = id, name = d.name,
                    renownLevel = d.renownLevel or 0,
                    current = d.renownReputationEarned or 0,
                    threshold = d.renownLevelThreshold or 0,
                    maxed = (C_MajorFactions.HasMaximumRenown and C_MajorFactions.HasMaximumRenown(id)) or false,
                    uiPriority = d.uiPriority or 0, linkedAch = linkedAch,
                    required = linkedAch.required,
                    met = self:IsCriterionComplete(linkedAch.ach, linkedAch.crit),
                }
            end
        end
    end
    table.sort(out, function(a, b)
        if a.uiPriority ~= b.uiPriority then return a.uiPriority > b.uiPriority end
        return (a.name or "") < (b.name or "")
    end)
    return out
end

-- Enumerate ALL of the player's factions. The C_Reputation index API only returns
-- rows currently visible in the UI, so children of COLLAPSED headers are hidden
-- (this is why the section was empty). We expand collapsed headers as we walk, then
-- restore them so the player's reputation pane looks unchanged.
local function walkAllFactions()
    local Rep = C_Reputation
    if not (Rep and Rep.GetNumFactions and Rep.GetFactionDataByIndex) then return {} end
    local expand = Rep.ExpandFactionHeader or _G.ExpandFactionHeader
    local collapse = Rep.CollapseFactionHeader or _G.CollapseFactionHeader
    local reExpanded = {}
    if expand then
        local i = 1
        while i <= Rep.GetNumFactions() do
            local d = Rep.GetFactionDataByIndex(i)
            if d and d.isHeader and d.isCollapsed then
                if d.factionID then reExpanded[#reExpanded + 1] = d.factionID end
                pcall(expand, i)
            end
            i = i + 1
        end
    end
    local all = {}
    for j = 1, Rep.GetNumFactions() do
        local d = Rep.GetFactionDataByIndex(j)
        if d then all[#all + 1] = d end
    end
    if collapse then -- restore: re-collapse headers we expanded (find by factionID)
        for _, fid in ipairs(reExpanded) do
            for k = 1, Rep.GetNumFactions() do
                local d = Rep.GetFactionDataByIndex(k)
                if d and d.factionID == fid then pcall(collapse, k); break end
            end
        end
    end
    return all
end

-- Classic reputations (C_Reputation, Neutral..Exalted). A live faction is shown
-- only if its name appears in the expansion's rep criteria (so it's meta-relevant,
-- and Alliance/Horde-specific factions resolve naturally since the player only has
-- their own side's factions in the list). Cached ~15s to limit the header churn.
function Objectives:_classicReputations(exp)
    if not (C_Reputation and C_Reputation.GetNumFactions and C_Reputation.GetFactionDataByIndex) then return {} end
    local now = GetTime()
    if self._crCache and self._crCacheExp == exp.name and (now - (self._crCacheTime or 0)) < 15 then
        return self._crCache
    end
    local blob = reputationBlob(exp.name)
    local out = {}
    for _, d in ipairs(walkAllFactions()) do
        if d and not d.isHeader and d.name and d.name ~= "" and blob ~= "" and blob:find(normName(d.name), 1, true) then
            local reaction = d.reaction or 4
            local cur = (d.currentStanding or 0) - (d.currentReactionThreshold or 0)
            local mx  = (d.nextReactionThreshold or 0) - (d.currentReactionThreshold or 0)
            local label = (GetFactionStandingLabel and GetFactionStandingLabel(reaction))
                or _G["FACTION_STANDING_LABEL" .. reaction] or "?"
            local maxed = reaction >= 8   -- Exalted (standard scale)
            -- Friendship reputations (Ve'nari, etc.) use custom rank names + scale.
            local fr = C_GossipInfo and C_GossipInfo.GetFriendshipReputation and C_GossipInfo.GetFriendshipReputation(d.factionID)
            if fr and fr.friendshipFactionID and fr.friendshipFactionID > 0 then
                label = fr.reaction or label
                local rt, nt = fr.reactionThreshold or 0, fr.nextThreshold
                cur = (fr.standing or 0) - rt
                mx  = (nt and (nt - rt)) or 0
                maxed = (not nt) or nt == 0 or (fr.maxRep and fr.standing and fr.standing >= fr.maxRep) or false
            end
            local linkedAch = self:GetReputationAchievement(d.name)
            out[#out + 1] = {
                isRenown = false, factionID = d.factionID, name = d.name,
                standing = reaction, standingLabel = label,
                current = cur, threshold = mx,
                maxed = maxed,
                linkedAch = linkedAch,
                required = linkedAch and linkedAch.required,
                met = linkedAch and self:IsCriterionComplete(linkedAch.ach, linkedAch.crit),
            }
        end
    end
    -- Shadowlands Covenant renown (separate C_CovenantSanctumUI system). Only shown
    -- if a tracked criterion needs it. GetRenownLevel() is active-covenant only, so
    -- for ALL FOUR we read the renown achievement's per-covenant criteria (each
    -- criterion is one covenant, quantity = its renown, reqQuantity = 80).
    if exp.name == "Back from the Beyond" then
        local link = self:GetReputationAchievement("Covenant")
        local achID = link and link.crit and link.crit.childAchievementID
        local added = false
        if achID and GetAchievementNumCriteria then
            for ci = 1, (GetAchievementNumCriteria(achID) or 0) do
                local cname, _, ccompleted, cquantity, creq = GetAchievementCriteriaInfo(achID, ci)
                if cname and cname ~= "" then
                    local hasNum = creq and creq > 5
                    out[#out + 1] = {
                        isRenown = true, isCovenant = true, factionID = -100 - ci,
                        name = "Covenant: " .. cname,
                        -- a completed criterion reports quantity=1 (boolean), so show
                        -- the target (80) when done; the covenant renown cap is 80.
                        renownLevel = ccompleted and 80 or (hasNum and (cquantity or 0) or 0),
                        current = ccompleted and 80 or (hasNum and (cquantity or 0) or 0),
                        threshold = 80,
                        maxed = ccompleted and true or false,
                        required = "Renown " .. (hasNum and creq or 80),
                        met = ccompleted and true or false,
                        linkedAch = link,
                    }
                    added = true
                end
            end
        end
        -- Fallback: if the achievement had no readable criteria, show just the active covenant.
        if not added and link and C_CovenantSanctumUI and C_CovenantSanctumUI.GetRenownLevel then
            local level = C_CovenantSanctumUI.GetRenownLevel() or 0
            local covName
            local cid = C_Covenants and C_Covenants.GetActiveCovenantID and C_Covenants.GetActiveCovenantID()
            if cid and cid > 0 and C_Covenants.GetCovenantData then
                local cd = C_Covenants.GetCovenantData(cid); covName = cd and cd.name
            end
            out[#out + 1] = {
                isRenown = true, isCovenant = true, factionID = -1,
                name = "Covenant Renown" .. (covName and (" (" .. covName .. ")") or ""),
                renownLevel = level, current = level, threshold = 80, maxed = level >= 80,
                required = "Renown 80 with all four Covenants",
                met = self:IsCriterionComplete(link.ach, link.crit), linkedAch = link,
            }
        end
    end
    table.sort(out, function(a, b) return (a.name or "") < (b.name or "") end)
    self._crCache, self._crCacheExp, self._crCacheTime = out, exp.name, now
    return out
end

-- Meta-relevant reputations for the current continent's expansion, renown OR
-- classic. Empty on continents the addon doesn't cover, or before the API exists.
function Objectives:GetContinentReputations(mapID)
    mapID = mapID or (C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player"))
    local exp = CONTINENT_EXPANSION[self:GetContinentID(mapID) or 0]
    if not exp then return {} end
    if exp.kind == "renown" then return self:_renownFactions(exp) end
    return self:_classicReputations(exp)
end

-- ---------------------------------------------------------------------------
-- Delves. Delve criteria (per-delve "Stories" bonus objectives + "Discoveries"
-- chests, gated by Delve Loremaster / Glory) carry NO waypoints in the data, so
-- entrance coords live here (scraped from warcraft.wiki.gg). Grouped one-per-delve
-- for the planner's expandable Delves section, and routeable to the entrance.
-- Only delves that actually have tracked achievement criteria are listed here.
-- (Midnight delves aren't included yet -- no Midnight module/criteria to attach to.)
-- ---------------------------------------------------------------------------
local DELVE_LOCATIONS = {
    ["Earthcrawl Mines"]  = { mapID = 2248, x = 35.5, y = 79.2, zone = "Isle of Dorn" },
    ["Fungal Folly"]      = { mapID = 2248, x = 51.7, y = 70.4, zone = "Isle of Dorn" },
    ["Kriegval's Rest"]   = { mapID = 2248, x = 63.0, y = 42.5, zone = "Isle of Dorn" },
    ["The Dread Pit"]     = { mapID = 2214, x = 68.0, y = 39.0, zone = "The Ringing Deeps" },
    ["Excavation Site 9"] = { mapID = 2214, x = 81.0, y = 98.0, zone = "The Ringing Deeps" },
    ["The Waterworks"]    = { mapID = 2214, x = 37.0, y = 50.0, zone = "The Ringing Deeps" },
    ["Mycomancer Cavern"] = { mapID = 2215, x = 70.0, y = 30.0, zone = "Hallowfall" },
    ["Nightfall Sanctum"] = { mapID = 2215, x = 34.0, y = 47.0, zone = "Hallowfall" },
    ["The Sinkhole"]      = { mapID = 2215, x = 50.0, y = 52.0, zone = "Hallowfall" },
    ["Skittering Breach"] = { mapID = 2215, x = 65.0, y = 61.0, zone = "Hallowfall" },
    ["The Spiral Weave"]  = { mapID = 2255, x = 46.5, y = 25.4, zone = "Azj-Kahet" },
    ["Tak-Rethan Abyss"]  = { mapID = 2255, x = 55.6, y = 75.7, zone = "Azj-Kahet" },
    ["The Underkeep"]     = { mapID = 2255, x = 52.6, y = 88.9, zone = "Azj-Kahet" },
    ["Sidestreet Sluice"] = { mapID = 2346, x = 35.0, y = 53.0, zone = "Undermine" },
    ["Archival Assault"]  = { mapID = 2371, x = 55.0, y = 48.0, zone = "K'aresh" },
}

-- Read all readable text out of a UI widget set (delve map-pin tooltips render
-- the current tier / bountiful / active-story line as text widgets). Defensive:
-- returns a single concatenated string or nil, never errors.
local function readWidgetSetText(setID)
    if not (setID and setID ~= 0 and C_UIWidgetManager and C_UIWidgetManager.GetAllWidgetsBySetID) then return nil end
    local ok, widgets = pcall(C_UIWidgetManager.GetAllWidgetsBySetID, setID)
    if not ok or type(widgets) ~= "table" then return nil end
    local parts = {}
    for _, w in ipairs(widgets) do
        local getter = C_UIWidgetManager.GetTextWithStateWidgetVisualizationInfo
        if getter then
            local ok2, info = pcall(getter, w.widgetID)
            if ok2 and info and info.text and info.text ~= "" then
                parts[#parts + 1] = info.text
            end
        end
    end
    if #parts == 0 then return nil end
    return (table.concat(parts, " \226\128\148 "):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""))
end

-- Best-effort: the story/objective currently shown on a delve's map pin. Delve
-- Loremaster wants "each story variant", and the live pin tells you which one is
-- active right now -- so surfacing it lets the planner flag "the active story is
-- one you still need". Matches the delve's area POI by name; returns a string or nil.
function Objectives:GetActiveDelveStory(mapID, delveName)
    if not (mapID and C_AreaPoiInfo and C_AreaPoiInfo.GetAreaPOIInfo) then return nil end
    local want = normName(delveName)
    -- Delve entrances are NOT in GetAreaPOIForMap; The War Within added a separate
    -- GetDelvesForMap list for them. Try that first, then fall back to area POIs.
    local lists = {}
    if C_AreaPoiInfo.GetDelvesForMap then
        local ok, d = pcall(C_AreaPoiInfo.GetDelvesForMap, mapID)
        if ok and type(d) == "table" then lists[#lists + 1] = d end
    end
    if C_AreaPoiInfo.GetAreaPOIForMap then
        local ok, p = pcall(C_AreaPoiInfo.GetAreaPOIForMap, mapID)
        if ok and type(p) == "table" then lists[#lists + 1] = p end
    end
    for _, pois in ipairs(lists) do
        for _, poiID in ipairs(pois) do
            local info = C_AreaPoiInfo.GetAreaPOIInfo(mapID, poiID)
            if info and info.name and normName(info.name):find(want, 1, true) then
                -- the dynamic active-story line lives in the tooltip widget set; the
                -- description is usually a static blurb, so prefer the widgets.
                local txt = readWidgetSetText(info.tooltipWidgetSet)
                if not txt or txt == "" then txt = info.description end
                if txt and txt ~= "" then return txt end
                return nil
            end
        end
    end
    return nil
end

-- Incomplete delves on the current continent, one entry per delve, each with its
-- entrance + a list of that delve's objectives (Stories bonus objectives +
-- Discoveries chests). Cached briefly. Empty off Khaz Algar.
local delveCache, delveCacheTime
function Objectives:InvalidateDelves() delveCache = nil end
function Objectives:GetDelveObjectives(mapID)
    local continent = self:GetContinentID(mapID or (C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")))
    if continent ~= 2274 then return {} end -- Khaz Algar (War Within) only for now
    local now = GetTime()
    if delveCache and (now - (delveCacheTime or 0)) < 5 then return delveCache end
    local groups = {}
    for _, expansion in ipairs(Private.ExpansionUtil:GetLoadedExpansionData() or {}) do
        for _, ach in ipairs(expansion) do
            -- match "<Delve> Stories" / "<Delve> Discoveries" achievements only
            local dn = ach.name and (ach.name:match("^(.*) Stories$") or ach.name:match("^(.*) Discoveries$"))
            if dn and DELVE_LOCATIONS[dn] and ach.criteria then
                local g = groups[dn]
                if not g then g = { objectives = {}, done = 0, total = 0 }; groups[dn] = g end
                local isChest = ach.name:find("Discoveries") ~= nil
                for _, crit in ipairs(ach.criteria) do
                    local met = self:IsCriterionComplete(ach, crit)
                    g.total = g.total + 1
                    if met then g.done = g.done + 1 end
                    g.objectives[#g.objectives + 1] = { ach = ach, crit = crit, met = met,
                        label = crit.description or "", isChest = isChest }
                end
            end
        end
    end
    local out = {}
    for dn, g in pairs(groups) do
        if g.done < g.total then
            local loc = DELVE_LOCATIONS[dn]
            table.sort(g.objectives, function(a, b)
                if (a.met and true) ~= (b.met and true) then return not a.met end -- incomplete first
                return (a.label or "") < (b.label or "")
            end)
            -- pull the live active-story line off the map pin and flag any still-needed
            -- objective that's currently active (so it can be highlighted "do it now").
            local activeStory = self:GetActiveDelveStory(loc.mapID, dn)
            local activeNeeded, activeStoryName = false, nil
            if activeStory and activeStory ~= "" then
                local blob = normName(activeStory)
                for _, o in ipairs(g.objectives) do
                    if not o.met and o.label and o.label ~= "" and blob:find(normName(o.label), 1, true) then
                        o.activeNow = true
                        activeNeeded = true
                        activeStoryName = activeStoryName or o.label -- the still-needed story that's live now
                    end
                end
            end
            out[#out + 1] = { name = dn, mapID = loc.mapID, x = loc.x, y = loc.y, zone = loc.zone,
                objectives = g.objectives, doneCount = g.done, totalCount = g.total,
                activeStory = activeStory, activeNeeded = activeNeeded, activeStoryName = activeStoryName }
        end
    end
    table.sort(out, function(a, b)
        if a.zone ~= b.zone then return a.zone < b.zone end
        return a.name < b.name
    end)
    delveCache, delveCacheTime = out, now
    return out
end

-- Per-zone summary of remaining objectives: { {mapID, name, count}, ... } sorted
-- by count desc. Optionally filtered to a continent. Cached briefly since it
-- scans every mapped zone.
local zoneCache, zoneCacheTime, zoneCacheContinent
function Objectives:InvalidateSummaries()
    zoneCache = nil
end

function Objectives:GetZoneSummaries(continentID)
    local now = GetTime()
    if zoneCache and zoneCacheContinent == continentID and (now - (zoneCacheTime or 0)) < 5 then
        return zoneCache
    end
    if not indexBuilt then BuildIndex() end
    local out = {}
    for mapID in pairs(byMap) do
        if not continentID or self:GetContinentID(mapID) == continentID then
            local items = self:GetIncompleteOnMap(mapID)
            if #items > 0 then
                local info = C_Map.GetMapInfo(mapID)
                out[#out + 1] = { mapID = mapID, name = (info and info.name) or ("Map " .. mapID), count = #items, first = items[1] }
            end
        end
    end
    table.sort(out, function(a, b) return a.count > b.count end)
    zoneCache, zoneCacheTime, zoneCacheContinent = out, now, continentID
    return out
end

-- ---------------------------------------------------------------------------
-- Dungeon & raid instance objectives (no map waypoints). We identify them by
-- matching the criterion name against the Encounter Journal's instance list,
-- which also tells us dungeon vs raid.
-- ---------------------------------------------------------------------------
local instanceInfo -- lowercased instance name -> { name, isRaid }

local function BuildInstanceNames()
    instanceInfo = {}
    if C_AddOns and C_AddOns.LoadAddOn then pcall(C_AddOns.LoadAddOn, "Blizzard_EncounterJournal") end
    if not (EJ_GetNumTiers and EJ_SelectTier and EJ_GetInstanceByIndex) then return end
    local prevTier = EJ_GetCurrentTier and EJ_GetCurrentTier() or 1
    pcall(function()
        for tier = 1, EJ_GetNumTiers() do
            EJ_SelectTier(tier)
            for _, isRaid in ipairs({ false, true }) do
                local i = 1
                while true do
                    local id, name = EJ_GetInstanceByIndex(i, isRaid)
                    if not id then break end
                    if name then instanceInfo[name:lower()] = { name = name, isRaid = isRaid } end
                    i = i + 1
                end
            end
        end
    end)
    if EJ_SelectTier then pcall(EJ_SelectTier, prevTier) end
end

-- Strip "(...)" and any "Mythic:/Heroic:/Normal:" prefix, then trim.
function Objectives:CleanInstanceName(s)
    s = (s or ""):gsub("%s*%b()", ""):gsub("^%s*[MmHhNn]%a+:%s*", "")
    return (s:gsub("^%s*(.-)%s*$", "%1"))
end

-- Difficulty requirement parsed from a criterion/achievement string like
-- "Heroic: Underrot" or "...on Heroic difficulty or higher." Because a higher
-- difficulty always credits a lower-tier instance achievement, Normal/Heroic/LFR
-- are reported as "or higher"; Mythic is the cap. Returns { tag, label, color }
-- (tag = compact "Heroic+", label = "Heroic difficulty or higher") or nil when
-- no difficulty is named (any difficulty counts).
local DIFF_COLORS = { LFR = "1eff00", Normal = "1eff00", Heroic = "0070dd", Mythic = "a335ee" }
function Objectives:GetInstanceDifficulty(...)
    for idx = 1, select("#", ...) do
        local desc = select(idx, ...)
        local d = (desc or ""):lower()
        local base
        if d:find("mythic") then base = "Mythic"
        elseif d:find("heroic") then base = "Heroic"
        elseif d:find("raid finder") or d:find("looking for raid") or d:find("lfr") then base = "LFR"
        elseif d:find("normal") then base = "Normal" end
        if base then
            local orHigher = d:find("or higher") ~= nil
            local tag, label
            if base == "Mythic" then
                tag = orHigher and "Mythic+" or "Mythic"
                label = orHigher and "Mythic difficulty or higher" or "Mythic difficulty"
            else
                tag = base .. "+"
                label = base .. " difficulty or higher"
            end
            return { tag = tag, label = label, color = DIFF_COLORS[base] or "ffd200" }
        end
    end
    return nil
end

local instCache, instCacheTime
function Objectives:InvalidateInstances()
    instCache = nil
end

-- Incomplete dungeon/raid objectives across all loaded data, each RESOLVED to the
-- instance it lives in via the Encounter Journal (exact instance-name or boss-name
-- match only -- no loose keyword guessing). Returns:
--   { {ach, crit, name, instance, isRaid, isBoss}, ... }
function Objectives:GetInstanceObjectives()
    local now = GetTime()
    if instCache and (now - (instCacheTime or 0)) < 5 then return instCache end
    if not instanceInfo then BuildInstanceNames() end

    local out, seen = {}, {}
    for expIndex, expansion in ipairs(Private.ExpansionUtil:GetLoadedExpansionData() or {}) do
        for _, ach in ipairs(expansion) do
            if ach.criteria and not self:IsAchievementComplete(ach.achievementID) then
                for _, crit in ipairs(ach.criteria) do
                    local nm = self:CleanInstanceName(crit.description)
                    local inst = instanceInfo[nm:lower()] -- exact instance-name match only
                    if inst and not self:IsCriterionComplete(ach, crit)
                        and not self:IsIgnored(ach, crit, ZEROWP) then
                        local key = ach.achievementID .. ":" .. (crit.criteriaIndex or 0) .. ":" .. (crit.description or "")
                        if not seen[key] then
                            seen[key] = true
                            out[#out + 1] = { ach = ach, crit = crit, name = nm,
                                instance = inst.name, isRaid = inst.isRaid, expansionIndex = expIndex,
                                difficulty = self:GetInstanceDifficulty(crit.description, ach.name, ach.description) }
                        end
                    end
                end
            end
        end
    end
    table.sort(out, function(a, b)
        if a.instance == b.instance then return a.name < b.name end
        return a.instance < b.instance
    end)
    instCache, instCacheTime = out, now
    return out
end

-- Zero-waypoint accessor so the windows can ignore/track instance objectives.
function Objectives:ZeroWaypoint()
    return ZEROWP
end

-- ---------------------------------------------------------------------------
-- World Quests currently active in the player's zone + nearby (same-continent)
-- zones, so the player can prioritise the ones that feed achievement criteria.
-- ---------------------------------------------------------------------------
function Objectives:FormatTimeLeft(minutes)
    if not minutes or minutes <= 0 then return "" end
    if minutes >= 1440 then return string.format("%dd %dh", math.floor(minutes / 1440), math.floor((minutes % 1440) / 60)) end
    if minutes >= 60 then return string.format("%dh %dm", math.floor(minutes / 60), minutes % 60) end
    return string.format("%dm", minutes)
end

function Objectives:GetQuestTimeLeft(questID)
    if C_TaskQuest and C_TaskQuest.GetQuestTimeLeftMinutes then
        return C_TaskQuest.GetQuestTimeLeftMinutes(questID)
    end
end

local wqCache, wqCacheTime, wqCacheMap
function Objectives:InvalidateWorldQuests()
    wqCache = nil
end

-- Meta-relevant World Quests. Ways a criterion in ExpansionData.lua counts:
--   1. Explicit questIDs (most precise):   worldQuests = { 79123, 79124 },
--   2. By title -- the criterion description IS the WQ name (e.g. "Grub Run"),
--      indexed only when the guide says "world quest" and the description looks
--      like a title (short, not a sentence), so prose criteria don't pollute it.
--   3. Quoted names harvested from prose -- e.g. a description that reads
--      "...complete the Nazmir world quest 'Getting out of hand'..." indexes
--      "Getting out of hand". Handles achievements that only NAME the WQ in text.
--   4. wqTitle = "..." override, when the live WQ is named differently.
--   5. Broad fallback -- ANY title-like criterion name is a candidate WQ title.
--      This is SAFE because matching only ever runs against the live world-quest
--      list, so a normal quest/treasure/rare name never matches an active WQ and
--      self-filters. It removes reliance on the guide text (e.g. War Within's
--      "Worm Theory" whose description never says "world quest").
-- We build questID -> {ach,crit}, a CURATED normalized-title -> {ach,crit} (shown
-- by /drustwqlist), and a BROAD normalized-title -> {ach,crit} used as a fallback.
local wqRelevant, wqRelevantTitle, wqTitleBroad

local function titleLike(desc)
    -- looks like a quest/objective/storyline name, not prose. Allow trailing
    -- punctuation ("All Good Things...") but reject multi-sentence blobs and
    -- newlines. Over-inclusion is harmless -- it self-filters against live data.
    if not desc or #desc < 3 or #desc > 60 then return false end
    if desc:find("\n") then return false end
    if desc:find("[.!?]%s+%u") then return false end -- a "Sentence. Next" boundary
    return true
end

-- Pull WQ names quoted inside prose (straight or curly quotes), in either order:
-- world quest 'X'  /  'X' world quest. Names with internal apostrophes (e.g.
-- Krag'wa) can't be captured cleanly and are left to the questID/wqTitle paths.
local function harvestQuotedWQ(text, sink, entry)
    if not text or text == "" then return end
    text = text:gsub("\226\128\152", "'"):gsub("\226\128\153", "'")
               :gsub("\226\128\156", '"'):gsub("\226\128\157", '"')
    local function add(n)
        n = (n or ""):gsub("^%s+", ""):gsub("%s+$", "")
        if #n >= 3 then sink[normName(n)] = entry end
    end
    for n in text:gmatch("[Ww]orld [Qq]uest%s+'([^']+)'") do add(n) end
    for n in text:gmatch('[Ww]orld [Qq]uest%s+"([^"]+)"') do add(n) end
    for n in text:gmatch("'([^']+)'%s*[Ww]orld [Qq]uest") do add(n) end
    for n in text:gmatch('"([^"]+)"%s*[Ww]orld [Qq]uest') do add(n) end
    -- speed-challenge phrasing: "...world quest NAME in 30 seconds..." -- the name
    -- may be unquoted or contain an apostrophe (e.g. Revenge of Krag'wa). Over-capture
    -- is harmless: like the broad net, a wrong name simply never matches a live WQ.
    for n in text:gmatch("[Ww]orld [Qq]uest%s+'?([%u][%w%s,'!:-]-)'?%s*in%s+%d") do add(n) end
    -- "...World Quest NAME" ending the clause (e.g. Vulpera for a Day)
    for n in text:gmatch("[Ww]orld [Qq]uest%s+([%u][%w%s,'!:-]-)%.?%s*$") do
        local l = n:lower()
        if not l:find(" in ") and not l:find("second") then add(n) end
    end
end

local function BuildRelevantWQ()
    wqRelevant, wqRelevantTitle, wqTitleBroad = {}, {}, {}
    for _, expansion in ipairs(Private.ExpansionUtil:GetLoadedExpansionData() or {}) do
        for _, ach in ipairs(expansion) do
            for _, crit in ipairs(ach.criteria or {}) do
                local entry = { ach = ach, crit = crit }
                if crit.worldQuests then
                    for _, qid in ipairs(crit.worldQuests) do wqRelevant[qid] = entry end
                end
                local g = (crit.guide or ""):lower()
                local desc = crit.description or ""
                -- (5) broad fallback: any title-like criterion name is a candidate
                if titleLike(desc) then
                    local n = normName(desc)
                    if not wqTitleBroad[n] then wqTitleBroad[n] = entry end
                end
                -- (2) curated description-as-title: guide says WQ + title-like
                if g:find("world quest", 1, true) and titleLike(desc) then
                    wqRelevantTitle[normName(desc)] = entry
                end
                -- (3) quoted names inside the criterion's own text
                harvestQuotedWQ(desc, wqRelevantTitle, entry)
                harvestQuotedWQ(crit.guide, wqRelevantTitle, entry)
                -- (4) explicit override
                if crit.wqTitle and crit.wqTitle ~= "" then
                    wqRelevantTitle[normName(crit.wqTitle)] = entry
                end
            end
            -- also harvest quoted WQ names from the achievement description, tied to
            -- its first criterion (single-criterion "complete WQ X" achievements).
            if ach.description and ach.criteria and ach.criteria[1] then
                harvestQuotedWQ(ach.description, wqRelevantTitle, { ach = ach, crit = ach.criteria[1] })
            end
        end
    end
end

function Objectives:GetRelevantWorldQuest(questID, title)
    if not wqRelevant then BuildRelevantWQ() end
    local r = wqRelevant[questID]
    if r then return r end
    if title and title ~= "" then
        local n = normName(title)
        return wqRelevantTitle[n] or wqTitleBroad[n] -- curated first, then broad net
    end
    return nil
end

-- Sorted list of the CURATED WQ names indexed for meta-relevance (for /drustwqlist).
-- Second return is the size of the broad fallback net (all title-like criteria).
function Objectives:GetTrackedWQTitles()
    if not wqRelevant then BuildRelevantWQ() end
    local t = {}
    for k, e in pairs(wqRelevantTitle) do
        t[#t + 1] = { title = k, ach = (e.ach and e.ach.name) or "?" }
    end
    table.sort(t, function(a, b) return a.title < b.title end)
    local b, broad = {}, 0
    for k, e in pairs(wqTitleBroad) do
        broad = broad + 1
        b[#b + 1] = { title = k, ach = (e.ach and e.ach.name) or "?" }
    end
    table.sort(b, function(x, y) return x.title < y.title end)
    return t, broad, b
end

function Objectives:HasRelevantWorldQuests()
    if not wqRelevant then BuildRelevantWQ() end
    return next(wqRelevant) ~= nil or next(wqRelevantTitle) ~= nil
end

-- The task-quest list API was renamed in 11.x (GetQuestsForPlayerByMapID ->
-- GetQuestsOnMap); support whichever the client has.
local function getMapTaskQuests(m)
    if not C_TaskQuest then return nil end
    if C_TaskQuest.GetQuestsOnMap then return C_TaskQuest.GetQuestsOnMap(m) end
    if C_TaskQuest.GetQuestsForPlayerByMapID then return C_TaskQuest.GetQuestsForPlayerByMapID(m) end
    return nil
end

function Objectives:HasWorldQuestAPI()
    return C_TaskQuest and (C_TaskQuest.GetQuestsOnMap or C_TaskQuest.GetQuestsForPlayerByMapID) and true or false
end

function Objectives:GetWorldQuests(mapID)
    if not mapID or not self:HasWorldQuestAPI() then return {} end
    local now = GetTime()
    if wqCache and wqCacheMap == mapID and (now - (wqCacheTime or 0)) < 10 then return wqCache end

    -- Build the map set: current zone + every zone on the same continent.
    local maps, seenMap = {}, {}
    local function addMap(m) if m and not seenMap[m] then seenMap[m] = true; maps[#maps + 1] = m end end
    addMap(mapID)
    -- Continent-wide WQ scan (RE-ENABLED for perf testing). The freeze that this
    -- seemed to cause was really the QUESTLINE_UPDATE storm, now debounced (Events.lua),
    -- and this result is 10s-cached. If lag returns, revert to a current-zone scan:
    --   local kids = C_Map.GetMapChildrenInfo(mapID, Enum.UIMapType.Zone, true)
    local continent = self:GetContinentID(mapID)
    if continent and C_Map.GetMapChildrenInfo then
        local kids = C_Map.GetMapChildrenInfo(continent, Enum.UIMapType.Zone, true)
        if kids then for _, k in ipairs(kids) do addMap(k.mapID) end end
    end

    -- Gather unique candidate quests. C_TaskQuest.GetQuestsOnMap(zone) OVER-RETURNS
    -- (a zone query can hand back quests from the whole continent), so we do NOT
    -- trust the queried map as the quest's zone -- we resolve that separately below.
    local seenQ, cands = {}, {}
    for _, m in ipairs(maps) do
        local list = getMapTaskQuests(m)
        if list then
            for _, q in ipairs(list) do
                local qid = q.questID or q.questId
                if qid and not seenQ[qid]
                    and (not C_QuestLog.IsWorldQuest or C_QuestLog.IsWorldQuest(qid))
                    and not C_QuestLog.IsQuestFlaggedCompleted(qid) then
                    seenQ[qid] = true
                    cands[#cands + 1] = { qid = qid, foundMap = m, x = q.x, y = q.y }
                end
            end
        end
    end

    -- A quest's TRUE zone. NOTE: GetQuestLocation is useless for this -- it clamps
    -- the projected position into 0-1 for ANY map, so it can't test membership.
    -- GetQuestZoneID / GetQuestUiMapID return the quest's real home zone; use
    -- whichever the client has, then read the coords on that zone.
    local function resolveZone(c)
        local z
        if C_TaskQuest and C_TaskQuest.GetQuestZoneID then z = C_TaskQuest.GetQuestZoneID(c.qid) end
        if (not z or z == 0) and GetQuestUiMapID then z = GetQuestUiMapID(c.qid) end
        if z and z > 0 then
            local x, y
            if C_TaskQuest and C_TaskQuest.GetQuestLocation then x, y = C_TaskQuest.GetQuestLocation(c.qid, z) end
            return z, x, y
        end
        return c.foundMap, c.x, c.y
    end

    local out = {}
    for _, c in ipairs(cands) do
        local title = C_QuestLog.GetTitleForQuestID and C_QuestLog.GetTitleForQuestID(c.qid)
        if title and title ~= "" then
            local tmap, tx, ty = resolveZone(c)
            local info = C_Map.GetMapInfo(tmap)
            local rel = self:GetRelevantWorldQuest(c.qid, title)
            if rel then
                -- For a MULTI-criterion achievement (e.g. the orphan games) the WQ can
                -- feed several parts, so keep it relevant until the whole achievement is
                -- done -- not just the one title-matched criterion. Single-criterion
                -- achievements still drop as soon as that criterion completes.
                local multi = rel.ach.criteria and #rel.ach.criteria > 1
                if self:IsAchievementComplete(rel.ach.achievementID)
                    or (not multi and self:IsCriterionComplete(rel.ach, rel.crit)) then
                    rel = nil
                end
            end
            out[#out + 1] = {
                questID = c.qid, title = title, x = tx, y = ty,
                mapID = tmap, mapName = (info and info.name) or "", isCurrent = (tmap == mapID),
                relevant = rel ~= nil, relAch = rel and rel.ach, relCrit = rel and rel.crit,
            }
        end
    end

    -- Meta-relevant first, then current zone, then soonest-to-expire.
    table.sort(out, function(a, b)
        if a.relevant ~= b.relevant then return a.relevant end
        if a.isCurrent ~= b.isCurrent then return a.isCurrent end
        return (self:GetQuestTimeLeft(a.questID) or 1e9) < (self:GetQuestTimeLeft(b.questID) or 1e9)
    end)
    wqCache, wqCacheTime, wqCacheMap = out, now, mapID
    return out
end

-- The world quest a criterion is GATED behind, if its guide names one (e.g. the
-- Freehold "Bless the Rains Down in Freehold" achievements need the "Swab This!"
-- WQ to be up). Returns the WQ name or nil -- used to warn/highlight on the row.
function Objectives:GetRequiredWQ(crit)
    if not crit then return nil end
    local text = (crit.guide or "") .. " " .. (crit.description or "")
    text = text:gsub("\226\128\152", "'"):gsub("\226\128\153", "'")
               :gsub("\226\128\156", '"'):gsub("\226\128\157", '"')
    return text:match("[Ww]orld [Qq]uest%s+'([^']+)'") or text:match('[Ww]orld [Qq]uest%s+"([^"]+)"')
end

-- A criterion whose target only EXISTS while its World Quest is active (e.g. the
-- Nazjatar assault elites: "These will only spawn while their World Quest is active").
-- These belong in the World Quests section when their WQ is up -- not cluttering
-- "This zone" with static, unreachable spawn points the rest of the time.
function Objectives:IsWorldQuestOnly(crit)
    if not crit or not crit.guide then return false end
    local g = crit.guide:lower()
    return g:find("world quest is active", 1, true) ~= nil
        or g:find("while their world quest", 1, true) ~= nil
        or (g:find("only spawn", 1, true) ~= nil and g:find("world quest", 1, true) ~= nil)
end

-- Does this achievement DECLARE itself a storyline achievement? If its description
-- (authored, or the game's own) mentions "storyline", then EVERY one of its criteria
-- is a required storyline name -- letting us list the locked ones precisely instead of
-- guessing by waypoint presence (which would wrongly catch reputation/other criteria).
function Objectives:IsStorylineAchievement(ach)
    if not ach then return false end
    if (ach.description or ""):lower():find("storyline", 1, true) then return true end
    local gd = select(8, GetAchievementInfo(ach.achievementID)) -- 8th return = description
    return type(gd) == "string" and gd:lower():find("storyline", 1, true) ~= nil
end

-- Is a world quest with this name currently active on the continent? (cached list)
function Objectives:IsWQActive(name, mapID)
    if not name or name == "" then return false end
    local want = normName(name)
    for _, q in ipairs(self:GetWorldQuests(mapID) or {}) do
        if normName(q.title) == want then return true end
    end
    return false
end

-- ---------------------------------------------------------------------------
-- Meta-required storylines & quests. Same idea as the WQ matcher, different live
-- source: match the game's available questlines (C_QuestLine) and your quest log
-- against the SAME criterion-title index. A storyline/quest whose name isn't a
-- meta criterion self-filters, so this is meta-only. Shown when incomplete +
-- available in the zone/continent or already in your log (start / continue).
-- ---------------------------------------------------------------------------
local questCache, questCacheTime, questCacheMap
function Objectives:InvalidateQuests() questCache = nil end

-- Ask the game to load questline data for the continent (async -> QUESTLINE_UPDATE).
function Objectives:RequestQuestLines(mapID)
    if not (C_QuestLine and C_QuestLine.RequestQuestLinesForMap) then return end
    mapID = mapID or (C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player"))
    if not mapID then return end
    pcall(C_QuestLine.RequestQuestLinesForMap, mapID)
    -- current zone + sub-zones only (see GetWorldQuests) -- avoids requesting questlines
    -- for every zone on the continent, which also fed the QUESTLINE_UPDATE storm.
    if C_Map.GetMapChildrenInfo then
        local kids = C_Map.GetMapChildrenInfo(mapID, Enum.UIMapType.Zone, true)
        if kids then for _, k in ipairs(kids) do pcall(C_QuestLine.RequestQuestLinesForMap, k.mapID) end end
    end
end

function Objectives:GetNeededQuests(mapID)
    mapID = mapID or (C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player"))
    if not mapID then return {} end
    local now = GetTime()
    if questCache and questCacheMap == mapID and (now - (questCacheTime or 0)) < 10 then return questCache end
    if not wqRelevant then BuildRelevantWQ() end

    -- match a name against the criterion-title index, but only if still needed
    local function matchCrit(name)
        if not name or name == "" then return nil end
        local n = normName(name)
        local e = wqRelevantTitle[n] or wqTitleBroad[n]
        if not e then return nil end
        if self:IsAchievementComplete(e.ach.achievementID) then return nil end
        if e.crit and self:IsCriterionComplete(e.ach, e.crit) then return nil end
        return e
    end

    local out, seen = {}, {}
    -- Dedup across BOTH sources by the META CRITERION they satisfy: the same objective
    -- can surface as an available questline (source 1) AND as a quest in your log
    -- (source 2) with different questIDs, which showed the same story twice.
    local seenCrit = {}
    local function critKey(e) return e.ach.achievementID .. ":" .. (e.crit.criteriaIndex or e.crit.description or "") end

    -- 1) available storylines (questlines) in the current zone + continent zones
    if C_QuestLine and C_QuestLine.GetAvailableQuestLines then
        local maps, seenMap = {}, {}
        local function addMap(m) if m and not seenMap[m] then seenMap[m] = true; maps[#maps + 1] = m end end
        addMap(mapID)
        -- current zone + sub-zones only (see GetWorldQuests) -- keeps the storyline
        -- list to THIS zone instead of the whole continent (was 40+ entries in BfA).
        if C_Map.GetMapChildrenInfo then
            local kids = C_Map.GetMapChildrenInfo(mapID, Enum.UIMapType.Zone, true)
            if kids then for _, k in ipairs(kids) do addMap(k.mapID) end end
        end
        -- A questline can return several entries (one per available quest POI) and
        -- can appear on multiple zones -- collapse to ONE row per questline, keeping
        -- the current-zone copy when there is one.
        local byLine = {}
        for _, m in ipairs(maps) do
            local ok, lines = pcall(C_QuestLine.GetAvailableQuestLines, m)
            if ok and lines then
                local info = C_Map.GetMapInfo(m)
                for _, ql in ipairs(lines) do
                    local qid = ql.questID
                    local lineKey = ql.questLineID or ql.questLineName
                    if qid and lineKey
                        and not (C_QuestLog.IsQuestFlaggedCompleted and C_QuestLog.IsQuestFlaggedCompleted(qid)) then
                        local e = matchCrit(ql.questLineName)
                        if e then
                            local isCur = (m == mapID)
                            local prev = byLine[lineKey]
                            if not prev or (isCur and not prev.isCurrent) then
                                local qName = C_QuestLog.GetTitleForQuestID and C_QuestLog.GetTitleForQuestID(qid)
                                byLine[lineKey] = {
                                    questID = qid, questName = qName, storyName = ql.questLineName,
                                    questLineID = ql.questLineID,
                                    title = qName or ql.questLineName, kind = "storyline",
                                    mapID = m, x = ql.x, y = ql.y, isCurrent = isCur,
                                    mapName = (info and info.name) or "",
                                    onQuest = C_QuestLog.IsOnQuest and C_QuestLog.IsOnQuest(qid),
                                    ach = e.ach, crit = e.crit,
                                }
                            end
                        end
                    end
                end
            end
        end
        for _, e in pairs(byLine) do
            local ck = critKey(e)
            if not seenCrit[ck] then
                seenCrit[ck] = true
                out[#out + 1] = e
                seen[e.questID] = true
            end
        end
    end

    -- 2) quests already in your log whose name matches a meta criterion (continue)
    local n = C_QuestLog.GetNumQuestLogEntries and C_QuestLog.GetNumQuestLogEntries() or 0
    for i = 1, n do
        local qi = C_QuestLog.GetInfo and C_QuestLog.GetInfo(i)
        if qi and not qi.isHeader and qi.questID and not seen[qi.questID]
            and not (C_QuestLog.IsWorldQuest and C_QuestLog.IsWorldQuest(qi.questID)) then -- WQs live in their own section
            local e = matchCrit(qi.title)
            if e and not seenCrit[critKey(e)] then
                seenCrit[critKey(e)] = true
                seen[qi.questID] = true
                local storyName, storyLineID
                if C_QuestLine and C_QuestLine.GetQuestLineInfo then
                    local ok, ql = pcall(C_QuestLine.GetQuestLineInfo, qi.questID, mapID)
                    if ok and ql then storyName = ql.questLineName; storyLineID = ql.questLineID end
                end
                out[#out + 1] = {
                    questID = qi.questID, questName = qi.title, storyName = storyName,
                    questLineID = storyLineID,
                    title = qi.title, kind = "quest",
                    mapID = qi.mapID, isCurrent = true, onQuest = true,
                    isComplete = C_QuestLog.IsComplete and C_QuestLog.IsComplete(qi.questID),
                    ach = e.ach, crit = e.crit,
                }
            end
        end
    end

    -- 3) LOCKED siblings: for achievements whose DESCRIPTION declares them storyline
    -- achievements (so EVERY criterion is a required storyline name), list the other
    -- incomplete criteria that aren't currently available -- greyed "(pre-req not met)".
    -- Lets the player see the FULL set of storylines an achievement needs, not just the
    -- one available now (e.g. Nazjatar 13709 = 3 chapters; you only see the doable one).
    local achsShown = {}
    for _, e in ipairs(out) do if e.ach then achsShown[e.ach.achievementID] = e.ach end end
    for _, ach in pairs(achsShown) do
        if self:IsStorylineAchievement(ach) and not self:IsAchievementComplete(ach.achievementID) then
            for _, c in ipairs(ach.criteria or {}) do
                local ck = ach.achievementID .. ":" .. (c.criteriaIndex or c.description or "")
                if not seenCrit[ck] and not self:IsCriterionComplete(ach, c) then
                    seenCrit[ck] = true
                    out[#out + 1] = {
                        title = c.description, questName = c.description, kind = "storyline",
                        unavailable = true, isCurrent = false, onQuest = false,
                        ach = ach, crit = c,
                    }
                end
            end
        end
    end

    table.sort(out, function(a, b)
        -- locked/unavailable always last, then current zone, then in-progress, then name
        if (a.unavailable and true) ~= (b.unavailable and true) then return not a.unavailable end
        if a.isCurrent ~= b.isCurrent then return a.isCurrent end
        if (a.onQuest and true) ~= (b.onQuest and true) then return a.onQuest end -- in-progress first
        return (a.title or "") < (b.title or "")
    end)
    questCache, questCacheTime, questCacheMap = out, now, mapID
    return out
end

-- ---------------------------------------------------------------------------
-- Distance (coordinates normalized 0-1). Returns (sortKey, realYards|nil).
-- ---------------------------------------------------------------------------
function Objectives:Distance(mapID, px, py, nx, ny)
    local dx, dy = nx - px, ny - py
    local norm = math.sqrt(dx * dx + dy * dy)
    if C_Map.GetMapWorldSize then
        local ok, w, h = pcall(C_Map.GetMapWorldSize, mapID)
        if ok and w and w > 0 then
            local yards = math.sqrt((dx * w) ^ 2 + (dy * h) ^ 2)
            return yards, yards
        end
    end
    return norm, nil
end

-- WoW's world distance unit; shown as metres to match TomTom's readout.
function Objectives:FormatDistance(realYards)
    if not realYards then return "" end
    if realYards >= 1000 then
        return string.format("%.1fkm", realYards / 1000)
    end
    return string.format("%dm", realYards)
end

-- Map world size in yards (width, height) or nil. Fetch once and reuse for the
-- route's nearest-neighbour math instead of calling the API per comparison.
function Objectives:GetWorldSize(mapID)
    if C_Map.GetMapWorldSize then
        local ok, w, h = pcall(C_Map.GetMapWorldSize, mapID)
        if ok and w and w > 0 then return w, h end
    end
    return nil
end
