---@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

-- 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

    -- 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("adventurer") then return "rare" end                            -- "Adventurer of X" = rare kills
    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

    -- 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 ct:find("%f[%a]dungeons?%f[%A]") or ct: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
    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
                            if wp.mapID and wp.x and wp.y 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 key = self:MakeKey(ach, crit, wp)
    -- priority: this player's own in-game note > shipped overlay note > baked wp.note
    local userNote = db and db.waypointNotes and db.waypointNotes[key]
    if userNote and userNote ~= "" then return userNote end
    local shipped = Private.ExpansionUtil and Private.ExpansionUtil:GetRegisteredNote(key)
    if shipped then return shipped 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

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

-- 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

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 }
                        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: a criterion in ExpansionData.lua may carry a list
-- of the specific WQ questIDs that count toward it, e.g.
--     worldQuests = { 79123, 79124 },
-- We build questID -> { ach, crit } so the live WQ list can flag/prioritise them.
local wqRelevant
local function BuildRelevantWQ()
    wqRelevant = {}
    for _, expansion in ipairs(Private.ExpansionUtil:GetLoadedExpansionData() or {}) do
        for _, ach in ipairs(expansion) do
            for _, crit in ipairs(ach.criteria or {}) do
                if crit.worldQuests then
                    for _, qid in ipairs(crit.worldQuests) do
                        wqRelevant[qid] = { ach = ach, crit = crit }
                    end
                end
            end
        end
    end
end

function Objectives:GetRelevantWorldQuest(questID)
    if not wqRelevant then BuildRelevantWQ() end
    return wqRelevant[questID]
end

function Objectives:HasRelevantWorldQuests()
    if not wqRelevant then BuildRelevantWQ() end
    return next(wqRelevant) ~= 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)
    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

    local out, seenQ = {}, {}
    for _, m in ipairs(maps) do
        local list = getMapTaskQuests(m)
        if list then
            local info = C_Map.GetMapInfo(m)
            local mapName = (info and info.name) or ""
            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
                    local title = C_QuestLog.GetTitleForQuestID and C_QuestLog.GetTitleForQuestID(qid)
                    if title and title ~= "" then
                        seenQ[qid] = true
                        local rel = self:GetRelevantWorldQuest(qid)
                        if rel and (self:IsAchievementComplete(rel.ach.achievementID)
                            or self:IsCriterionComplete(rel.ach, rel.crit)) then
                            rel = nil -- achievement already done; don't flag/prioritise
                        end
                        out[#out + 1] = {
                            questID = qid, title = title, x = q.x, y = q.y,
                            mapID = m, mapName = mapName, isCurrent = (m == mapID),
                            relevant = rel ~= nil, relAch = rel and rel.ach, relCrit = rel and rel.crit,
                        }
                    end
                end
            end
        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

-- ---------------------------------------------------------------------------
-- 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

function Objectives:FormatDistance(realYards)
    if not realYards then return "" end
    if realYards >= 1000 then
        return string.format("%.1fk", realYards / 1000)
    end
    return string.format("%dy", 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
