local pluginName, pluginShared = ...
local Lockouts = pluginShared.Lockouts
local Route = {}
pluginShared.Route = Route

local guarded = pluginShared.guarded
local R = pluginShared.RouteData or {}
local L = LibStub("AceLocale-3.0"):GetLocale("AppearanceMatic")

-- Favorites.lua defines this first in the load order: the star, the addon's name, then the message,
-- out as an emote or printed depending on the Emote box. The fallback keeps this file usable on its own.
local say = pluginShared.say or print
local tell = pluginShared.tell or print

local function worldFromMap(mapID, x, y)
    if not (mapID and x and y and type(C_Map) == "table" and type(C_Map.GetWorldPosFromMapPos) == "function") then
        return nil
    end
    local pos = (type(CreateVector2D) == "function") and CreateVector2D(x, y) or { x = x, y = y }
    local ok, continentID, world = pcall(C_Map.GetWorldPosFromMapPos, mapID, pos)
    if not (ok and continentID and type(world) == "table") then
        return nil
    end
    local wx, wy
    if type(world.GetXY) == "function" then
        wx, wy = world:GetXY()
    else
        wx, wy = world.x, world.y
    end
    if not (wx and wy) then
        return nil
    end
    return continentID, wx, wy
end
Route.worldFromMap = worldFromMap

local reported = pluginShared.reported or guarded

local function dist(ax, ay, bx, by)
    local dx, dy = ax - bx, ay - by
    return math.sqrt(dx * dx + dy * dy)
end

-- A generated or game-reported gateway to the same destination within this many yards of a
-- hand-written hub portal is that portal seen from another map (the sweep marks a whole portal
-- room with one node, Blizzard's map links sit at the room too): it folds into the hub row.
local HUB_TWIN_YARDS = 150

function Route:MapIndex()
    if self.mapIndex then
        return self.mapIndex
    end
    local index = { byName = {}, info = {} }
    if not (type(C_Map) == "table" and type(C_Map.GetMapChildrenInfo) == "function") then
        return index
    end
    local ok, list = pcall(C_Map.GetMapChildrenInfo, 946, nil, true)
    if not (ok and type(list) == "table") then
        return index
    end
    for _, info in ipairs(list) do
        if info.mapID and type(info.name) == "string" then
            index.info[info.mapID] = info
            index.byName[info.name] = index.byName[info.name] or {}
            table.insert(index.byName[info.name], info.mapID)
        end
    end
    if next(index.info) then
        -- an empty answer (map data not ready yet) is never kept for good
        self.mapIndex = index
    end
    return index
end

function Route:ContinentNameOf(mapID)
    local index = self:MapIndex()
    local info = index.info[mapID]
    local hops = 0
    while info and hops < 10 do
        if info.mapType == (Enum and Enum.UIMapType and Enum.UIMapType.Continent or 2) then
            return info.name
        end
        info = info.parentMapID and index.info[info.parentMapID]
        hops = hops + 1
    end
    return nil
end

function Route:ResolveMap(spec)
    if type(spec) ~= "table" then
        return nil
    end
    if spec.mapID then
        return spec.mapID
    end
    local candidates = self:MapIndex().byName[spec.name]
    if not candidates then
        return nil
    end
    if spec.continent then
        for _, mapID in ipairs(candidates) do
            if self:ContinentNameOf(mapID) == spec.continent then
                return mapID
            end
        end
    end
    return candidates[1]
end

function Route:PlayerFaction()
    if type(UnitFactionGroup) == "function" then
        local ok, tag = pcall(UnitFactionGroup, "player")
        if ok and type(tag) == "string" then
            return tag
        end
    end
    return "Neutral"
end

local function factionOk(entryFaction, playerFaction)
    return entryFaction == nil or entryFaction == "Neutral" or entryFaction == playerFaction
end

-- True when this character has unlocked a gateway: the shipped `req` names the quest (character or
-- account-wide), level, spell, reputation standing or race behind it. A check the client cannot
-- answer counts as locked; the router must never send a player through a portal they cannot use.
function Route:GatewayUsable(req)
    if type(req) ~= "table" then
        return true
    end
    if req.quest then
        local done = false
        if type(C_QuestLog) == "table" and type(C_QuestLog.IsQuestFlaggedCompleted) == "function" then
            local ok, flagged = reported(C_QuestLog.IsQuestFlaggedCompleted, req.quest)
            done = ok and flagged == true
            if not done and req.accquest and type(C_QuestLog.IsQuestFlaggedCompletedOnAccount) == "function" then
                local okAccount, onAccount = reported(C_QuestLog.IsQuestFlaggedCompletedOnAccount, req.quest)
                done = okAccount and onAccount == true
            end
        end
        if not done then
            return false
        end
    end
    if req.level then
        local ok, level = reported(UnitLevel, "player")
        if not (ok and type(level) == "number" and level >= req.level) then
            return false
        end
    end
    if req.spell then
        if not (type(C_SpellBook) == "table" and type(C_SpellBook.IsSpellKnown) == "function") then
            return false
        end
        local ok, known = reported(C_SpellBook.IsSpellKnown, req.spell)
        if not (ok and known == true) then
            return false
        end
    end
    if req.reputation then
        if not (type(C_Reputation) == "table" and type(C_Reputation.GetFactionDataByID) == "function") then
            return false
        end
        local ok, data = reported(C_Reputation.GetFactionDataByID, req.reputation[1])
        if not (ok and type(data) == "table" and type(data.reaction) == "number" and data.reaction >= (req.reputation[2] or 0)) then
            return false
        end
    end
    if req.race then
        local ok, _, raceFile = reported(UnitRace, "player")
        if not (ok and raceFile == req.race) then
            return false
        end
    end
    return true
end

-- Quests turn in, levels go up, spells and standings change: re-check every requirement on the next use.
function Route:MarkLocksDirty()
    self.locksDirty = true
end

function Route:RefreshGatewayLocks()
    self.locksDirty = nil
    -- the locks decide which gateways a chain may use, so the walked chains are worked out again
    self.towardCache, self.towardCacheFor = nil, nil
    for _, g in ipairs(self.gateways or {}) do
        if g.req then
            g.locked = (not self:GatewayUsable(g.req)) or nil
        end
    end
end

function Route:DestContinent(mapID, x, y)
    local continentID = worldFromMap(mapID, x or 0.5, y or 0.5)
    return continentID
end

-- A short "where is it" for a hop that lands on a portal, dock or tower we know.
function Route:DescribeGateway(mapID, x, y)
    if not (mapID and x and y) then
        return nil
    end
    local continentID, wx, wy = worldFromMap(mapID, x, y)
    if not continentID then
        return nil
    end
    local best, bestD, hub, hubD
    for _, g in ipairs(self:Gateways()) do
        if g.continentID == continentID then
            local d = dist(wx, wy, g.wx, g.wy)
            if d <= 60 then
                if g.hub then
                    if not hubD or d < hubD then hub, hubD = g, d end
                elseif not bestD or d < bestD then
                    best, bestD = g, d
                end
            end
        end
    end
    -- a hub portal beside the spot names the room and its floor; any other row only names its map
    best = hub or best
    if not best then
        return nil
    end
    local parts = {}
    if best.hubName then parts[#parts + 1] = best.hubName end
    if best.floor then parts[#parts + 1] = best.floor end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, ", ")
end

-- The hub portal a gateway row stands beside, if any: a sweep row naming a destination no hub row
-- does (Caverns of Time by its own map, a portal newer than the hand table) still sits in the room.
function Route:HubNear(entry, yards)
    local best, bestD
    for _, g in ipairs(self:Gateways()) do
        if g.hub and g.continentID == entry.continentID then
            local d = dist(entry.wx, entry.wy, g.wx, g.wy)
            if d <= yards and (not bestD or d < bestD) then
                best, bestD = g, d
            end
        end
    end
    return best
end

function Route:Gateways()
    local faction = self:PlayerFaction()
    if self.gateways and self.gatewayFaction == faction then
        if self.locksDirty then
            self:RefreshGatewayLocks()
        end
        return self.gateways, self.gatewayFaction
    end
    self.locksDirty = nil
    local list = {}
    local hubs = {}
    local unresolved = {}
    self.unresolvedGateways = unresolved
    local function add(mapSpec, x, y, destSpec, label, kind, hubName, entryFaction, floor, req, isHub)
        if not factionOk(entryFaction, faction) then
            return
        end
        local mapID = self:ResolveMap(mapSpec)
        local destMap = self:ResolveMap(destSpec)
        if not (mapID and destMap) then
            -- a row whose map the client cannot name is invisible to the router; /am diag lists it
            unresolved[#unresolved + 1] = tostring(label) .. (mapID and " (dest)" or " (map)")
            return
        end
        local continentID, wx, wy = worldFromMap(mapID, x, y)
        local destContinent = self:DestContinent(destMap)
        if continentID and not isHub then
            -- the sweep and the game both report a hub's portals again, as one room node per map labelled
            -- "Portal to X" with the map's name and no floor. A row for the same destination within the
            -- room's reach of a hub portal is that portal seen from another map: the hub row keeps its
            -- label, hub name and floor (Pathfinder's Den hops always say top or bottom), and takes the
            -- row's requirement when it has none of its own
            for _, hub in ipairs(hubs) do
                if hub.destMap == destMap and hub.continentID == continentID and dist(wx, wy, hub.wx, hub.wy) <= HUB_TWIN_YARDS then
                    if req and not hub.req then
                        hub.req = req
                        hub.locked = (not self:GatewayUsable(req)) or nil
                    end
                    return nil
                end
            end
        end
        if continentID and destContinent then
            local entry = {
                mapID = mapID, x = x, y = y, wx = wx, wy = wy, continentID = continentID,
                destMap = destMap, destContinent = destContinent, label = label, kind = kind, hubName = hubName, floor = floor,
                req = req, locked = (req and not self:GatewayUsable(req)) or nil, hub = isHub or nil,
            }
            list[#list + 1] = entry
            if isHub then
                hubs[#hubs + 1] = entry
            end
            return entry
        end
    end
    for _, hub in ipairs(R.HUBS or {}) do
        if factionOk(hub.faction, faction) then
            for _, portal in ipairs(hub.portals or {}) do
                add(hub.map, portal.x, portal.y, portal.dest, portal.label, "portal", hub.name, portal.faction, portal.floor, portal.req, true)
            end
        end
    end
    for _, transit in ipairs(R.TRANSITS or {}) do
        add(transit.map, transit.x, transit.y, transit.dest, transit.label, transit.kind, transit.name, transit.faction, nil, transit.req)
    end
    -- the world-by-world sweep: every portal, boat, zeppelin and teleporter that changes worlds, keyed by mapID
    for _, g in ipairs(R.GATEWAYS or {}) do
        local entry = add(g.map, g.x, g.y, g.dest, g.label, g.kind, g.hubName, g.faction, g.floor, g.req)
        if entry then entry.shipped = true end
    end
    -- gateways the game reports itself (map links, area POIs) carry exact positions; a shipped row for
    -- the same map and destination is dropped in their favour, its requirement carried over
    local discovered = self:DiscoverGateways()
    local reported = {}
    for _, found in ipairs(discovered) do
        reported[found.mapID .. ":" .. found.destMapID] = true
    end
    local shippedReq = {}
    for i = #list, 1, -1 do
        local g = list[i]
        if g.shipped and reported[g.mapID .. ":" .. g.destMap] then
            if g.req then
                shippedReq[g.mapID .. ":" .. g.destMap] = g.req
            end
            table.remove(list, i)
        end
    end
    for _, found in ipairs(discovered) do
        add({ mapID = found.mapID }, found.x, found.y, { mapID = found.destMapID }, found.label, "portal", found.hubName, found.faction, nil,
            shippedReq[found.mapID .. ":" .. found.destMapID])
    end
    local learned = AppearanceMaticDB and AppearanceMaticDB.learnedGateways
    if type(learned) == "table" then
        for _, g in pairs(learned) do
            if type(g) == "table" and g.mapID and g.dest then
                add({ mapID = g.mapID }, g.x, g.y, { name = g.dest }, g.label, g.kind or "portal", nil, g.faction)
            end
        end
    end
    self.gateways, self.gatewayFaction = list, faction
    return list, faction
end

local FACTION_BY_ID = { [67] = "Horde", [469] = "Alliance" }

-- Portals the game itself draws on zone and city maps: map links and area POIs that carry a
-- linkedUiMapID. Scanned once per session so a zone the hand table never listed still routes.
function Route:DiscoverGateways()
    if self.discovered then
        return self.discovered
    end
    local found = {}
    if not (type(C_Map) == "table" and type(C_Map.GetMapChildrenInfo) == "function" and Enum and Enum.UIMapType) then
        return found
    end
    local seen = {}
    local function record(mapID, mapName, pos, name, destMapID, factionID)
        if not (pos and destMapID and destMapID ~= mapID) then
            return
        end
        local okXY, x, y = pcall(pos.GetXY, pos)
        if not (okXY and x and y and x > 0 and y > 0) then
            return
        end
        local key = mapID .. ":" .. destMapID .. ":" .. math.floor(x * 1000) .. ":" .. math.floor(y * 1000)
        if seen[key] then
            return
        end
        seen[key] = true
        found[#found + 1] = {
            mapID = mapID, x = x, y = y, destMapID = destMapID,
            label = name or ("map " .. destMapID), hubName = mapName, faction = factionID and FACTION_BY_ID[factionID] or nil,
        }
    end
    local maps = {}
    for _, mapType in ipairs({ Enum.UIMapType.Continent, Enum.UIMapType.Zone, Enum.UIMapType.Micro }) do
        local okList, list = pcall(C_Map.GetMapChildrenInfo, 946, mapType, true)
        if okList and type(list) == "table" then
            for _, info in ipairs(list) do
                if info.mapID then
                    maps[#maps + 1] = { mapID = info.mapID, name = info.name }
                end
            end
        end
    end
    for _, map in ipairs(maps) do
        if type(C_Map.GetMapLinksForMap) == "function" then
            local okLinks, links = pcall(C_Map.GetMapLinksForMap, map.mapID)
            if okLinks and type(links) == "table" then
                for _, link in ipairs(links) do
                    record(map.mapID, map.name, link.position, link.name, link.linkedUiMapID, nil)
                end
            end
        end
        if type(C_AreaPoiInfo) == "table" and type(C_AreaPoiInfo.GetAreaPOIForMap) == "function" then
            local okPois, pois = pcall(C_AreaPoiInfo.GetAreaPOIForMap, map.mapID)
            if okPois and type(pois) == "table" then
                for _, poiID in ipairs(pois) do
                    local okInfo, info = pcall(C_AreaPoiInfo.GetAreaPOIInfo, map.mapID, poiID)
                    if okInfo and type(info) == "table" and info.linkedUiMapID then
                        record(map.mapID, map.name, info.position, info.name, info.linkedUiMapID, info.factionID)
                    end
                end
            end
        end
    end
    if #found > 0 then
        -- a sweep that found nothing (links and POIs not ready yet) is never kept for good
        self.discovered = found
    end
    return found
end

-- Continent-hop counts to `destContinent`: how many world gateways a chain still needs from each
-- continent, walking gateways backwards. Kept beside the toward lists and cleared with them.
function Route:ContinentHops(gateways, destContinent, includeLocked)
    local key = "hops:" .. tostring(destContinent) .. ":" .. tostring(includeLocked)
    if self.towardCache and self.towardCacheFor == gateways then
        local known = self.towardCache[key]
        if known then
            return known
        end
    else
        self.towardCache, self.towardCacheFor = {}, gateways
    end
    local hopsToDest = { [destContinent] = 0 }
    local frontier = { destContinent }
    while #frontier > 0 do
        local nextFrontier = {}
        for _, continent in ipairs(frontier) do
            for _, g in ipairs(gateways) do
                if (includeLocked or not g.locked) and g.destContinent == continent and hopsToDest[g.continentID] == nil then
                    hopsToDest[g.continentID] = hopsToDest[continent] + 1
                    nextFrontier[#nextFrontier + 1] = g.continentID
                end
            end
        end
        frontier = nextFrontier
    end
    self.towardCache[key] = hopsToDest
    return hopsToDest
end

-- Gateways on `fromContinent` that begin a shortest gateway chain reaching `destContinent`. Gateways
-- this character has not unlocked are left out unless `includeLocked` asks for them.
function Route:GatewaysToward(gateways, fromContinent, destContinent, includeLocked)
    -- Walked on every route tick for the same pair of continents. The gateway table only changes when
    -- it is rebuilt or its locks are re-checked, and both clear what is kept here.
    local key = tostring(fromContinent) .. ":" .. tostring(destContinent) .. ":" .. tostring(includeLocked)
    if self.towardCache and self.towardCacheFor == gateways then
        local known = self.towardCache[key]
        if known then
            return known
        end
    end
    local hopsToDest = self:ContinentHops(gateways, destContinent, includeLocked)
    local out = {}
    self.towardCache[key] = out
    local want = hopsToDest[fromContinent]
    if not want or want == 0 then
        return out
    end
    for _, g in ipairs(gateways) do
        if (includeLocked or not g.locked) and g.continentID == fromContinent and hopsToDest[g.destContinent] == want - 1 then
            out[#out + 1] = g
        end
    end
    return out
end

-- The cheapest first gateway of any chain reaching `destContinent`: every unlocked gateway on
-- `fromContinent` whose destination still has a chain is scored by the real trip, the yards to reach
-- it plus a flat cost per continent hop the chain still needs. Fewest hops alone once flew a
-- Silvermoon player across the continent while the Orgrimmar portal stood beside them.
function Route:BestGatewayToward(gateways, fromContinent, destContinent, px, py)
    local hopsToDest = self:ContinentHops(gateways, destContinent)
    local hopYards = R.CHAIN_HOP_YARDS or 2500
    local best, bestScore
    for _, g in ipairs(gateways) do
        if not g.locked and g.continentID == fromContinent and g.destContinent ~= fromContinent then
            local remaining = hopsToDest[g.destContinent]
            if remaining then
                local score = dist(px, py, g.wx, g.wy) + hopYards * (1 + remaining)
                if not bestScore or score < bestScore then
                    best, bestScore = g, score
                end
            end
        end
    end
    return best, bestScore
end

-- The player's own stones are gateways too: the bound inn, the Dalaran stone and the garrison
-- stone. A stone that is ready and lands the chain closer than the best portal takes the hop; on
-- cooldown, unowned or turned off, the portals and flight masters stand alone.
local HEARTH_ITEM = 6948
local ASTRAL_RECALL_SPELL = 556 -- the shaman's own way home, same bind point as the stone
local DALARAN_HEARTH_ITEM, DALARAN_HEARTH_MAP = 140192, 627
local GARRISON_HEARTH_ITEM = 110560
local GARRISON_MAPS = { Alliance = 582, Horde = 590 }

function Route:HearthHop(gateways, destContinent, playerContinent, bestScore, destMapID, dx, dy)
    local profile = pluginShared.db and pluginShared.db.profile
    local cfg = profile and profile.guideArrow
    if not cfg or cfg.useHearth == false then
        return nil
    end
    if not (type(C_Container) == "table" and type(C_Container.GetItemCooldown) == "function") then
        return nil
    end
    local function owned(itemID)
        if type(GetItemCount) == "function" then
            local ok, n = pcall(GetItemCount, itemID)
            if ok and (n or 0) > 0 then
                return true
            end
        end
        if type(PlayerHasToy) == "function" then
            local ok, has = pcall(PlayerHasToy, itemID)
            if ok and has then
                return true
            end
        end
        return false
    end
    local function ready(itemID)
        local ok, start, duration = pcall(C_Container.GetItemCooldown, itemID)
        if not ok then
            return false
        end
        if (duration or 0) == 0 then
            return true
        end
        local now = type(GetTime) == "function" and GetTime() or 0
        return ((start or 0) + duration - now) <= 0
    end
    local function spellUsable(spellID)
        if not (type(C_SpellBook) == "table" and type(C_SpellBook.IsSpellKnown) == "function"
            and type(C_Spell) == "table" and type(C_Spell.GetSpellCooldown) == "function") then
            return false
        end
        local okK, known = pcall(C_SpellBook.IsSpellKnown, spellID)
        if not (okK and known) then
            return false
        end
        local okC, info = pcall(C_Spell.GetSpellCooldown, spellID)
        if not (okC and type(info) == "table") then
            return false
        end
        if (info.duration or 0) == 0 then
            return true
        end
        local now = type(GetTime) == "function" and GetTime() or 0
        return ((info.startTime or 0) + info.duration - now) <= 0
    end
    -- the line of succession: the stone in the bags, then the class recall, then the special
    -- stones; a lower rung wins whenever it is viable at all, score only breaks ties on a rung
    local diag = { chain = bestScore, cands = {} }
    self.lastHearthDiag = diag
    local candidates = {}
    if type(GetBindLocation) == "function" then
        local okB, bind = pcall(GetBindLocation)
        diag.bind = okB and bind or "?"
        local ids = okB and type(bind) == "string" and self:ResolveBindMaps(bind) or nil
        diag.bindMap = ids and ids[1] or nil
        local bindContinent = ids and ids[1] and worldFromMap(ids[1], 0.5, 0.5) or nil
        if bindContinent == nil and okB and type(bind) == "string" then
            bindContinent = self:BindAreaContinent(bind)
            diag.bindArea = bindContinent
        end
        if bindContinent ~= nil then
            candidates[#candidates + 1] = { prio = 0, itemID = HEARTH_ITEM, continent = bindContinent }
            candidates[#candidates + 1] = { prio = 1, spellID = ASTRAL_RECALL_SPELL, continent = bindContinent }
        end
    end
    candidates[#candidates + 1] = { prio = 2, itemID = DALARAN_HEARTH_ITEM, mapID = DALARAN_HEARTH_MAP }
    candidates[#candidates + 1] = { prio = 3, itemID = GARRISON_HEARTH_ITEM, mapID = GARRISON_MAPS[self:PlayerFaction()] }
    local hopsToDest = self:ContinentHops(gateways, destContinent)
    local hopYards = R.CHAIN_HOP_YARDS or 2500
    local hearthCost = R.HEARTH_COST_YARDS or 500
    local pick, pickScore
    for _, c in ipairs(candidates) do
        local usable = (c.itemID and owned(c.itemID) and ready(c.itemID)) or (c.spellID and spellUsable(c.spellID)) or false
        local verdict = "unusable"
        if (c.mapID or c.continent ~= nil) and usable then
            local hearthContinent = c.continent
            if hearthContinent == nil and c.mapID then
                hearthContinent = worldFromMap(c.mapID, 0.5, 0.5)
            end
            local remaining = hearthContinent and hearthContinent ~= playerContinent and hopsToDest[hearthContinent] or nil
            verdict = "cont=" .. tostring(hearthContinent) .. " rem=" .. tostring(remaining)
            if remaining then
                local score = hearthCost + hopYards * remaining
                verdict = verdict .. " sc=" .. score
                if (not bestScore or score < bestScore)
                    and (not pick or c.prio < pick.prio or (c.prio == pick.prio and score < pickScore)) then
                    pick, pickScore = c, score
                end
            end
        end
        diag.cands[#diag.cands + 1] = "p" .. tostring(c.prio) .. ":" .. tostring(c.itemID or c.spellID) .. " " .. verdict
    end
    diag.pick = pick and (pick.itemID or pick.spellID) or nil
    if not pick then
        return nil
    end
    local stoneName = "Hearthstone"
    if pick.spellID then
        if type(C_Spell) == "table" and type(C_Spell.GetSpellName) == "function" then
            local ok, name = pcall(C_Spell.GetSpellName, pick.spellID)
            if ok and type(name) == "string" and name ~= "" then
                stoneName = name
            end
        end
    elseif type(C_Item) == "table" and type(C_Item.GetItemInfo) == "function" then
        local ok, name = pcall(C_Item.GetItemInfo, pick.itemID)
        if ok and type(name) == "string" and name ~= "" then
            stoneName = name
        end
    end
    return { mapID = destMapID, x = dx, y = dy, text = string.format(L["route_hop_hearth_fmt"], stoneName), kind = "hearth", hearthItemID = pick.itemID, hearthSpellID = pick.spellID }
end

-- The bind name as the game reports it, matched to a map: the exact name first, then ignoring
-- case, since the reported home name does not always share the map's casing.
function Route:ResolveBindMaps(bind)
    local byName = self:MapIndex().byName
    local ids = byName[bind]
    if ids then
        return ids
    end
    local wanted = bind:lower()
    for name, list in pairs(byName) do
        if name:lower() == wanted then
            return list
        end
    end
    return nil
end

-- An inn's bind name is no map: Blizzard's area table, shipped as areaID lists per continent,
-- says which continent the name lives on. The scan runs once per bind name, answering through the
-- client's own localized area names; a name that lives on two continents stays unresolved rather
-- than guessed at.
function Route:BindAreaContinent(bind)
    local cache = self.bindAreaCache
    if cache and cache.name == bind then
        return cache.continent
    end
    local areas = R.BIND_AREAS
    if not (type(areas) == "table" and type(C_Map) == "table" and type(C_Map.GetAreaInfo) == "function") then
        return nil
    end
    local found
    for continent, list in pairs(areas) do
        for i = 1, #list do
            local ok, name = pcall(C_Map.GetAreaInfo, list[i])
            if ok and name == bind then
                if found ~= nil and found ~= continent then
                    self.bindAreaCache = { name = bind, continent = nil }
                    return nil
                end
                found = continent
            end
        end
    end
    self.bindAreaCache = { name = bind, continent = found }
    return found
end

-- One line for /am diag: where the stone is bound, whether that name resolves to a map, what
-- each stone reports right now, and what the last routing pass concluded about them.
function Route:HearthDiagText()
    local parts = {}
    local bind
    if type(GetBindLocation) == "function" then
        local ok, b = pcall(GetBindLocation)
        bind = ok and b or nil
    end
    parts[#parts + 1] = "bind=" .. tostring(bind)
    local ids = type(bind) == "string" and self:ResolveBindMaps(bind) or nil
    parts[#parts + 1] = "bindMap=" .. tostring(ids and ids[1])
        .. (ids and ids[1] and ("/cont=" .. tostring(worldFromMap(ids[1], 0.5, 0.5))) or "")
    if not (ids and ids[1]) and type(bind) == "string" then
        parts[#parts + 1] = "areaCont=" .. tostring(self:BindAreaContinent(bind))
    end
    for _, itemID in ipairs({ HEARTH_ITEM, DALARAN_HEARTH_ITEM, GARRISON_HEARTH_ITEM }) do
        local n = 0
        if type(GetItemCount) == "function" then
            local ok, c = pcall(GetItemCount, itemID)
            n = ok and c or 0
        end
        local toy = false
        if type(PlayerHasToy) == "function" then
            local ok, has = pcall(PlayerHasToy, itemID)
            toy = ok and has or false
        end
        local cd = "?"
        if type(C_Container) == "table" and type(C_Container.GetItemCooldown) == "function" then
            local ok, start, duration = pcall(C_Container.GetItemCooldown, itemID)
            if ok then
                local now = type(GetTime) == "function" and GetTime() or 0
                local left = (duration or 0) > 0 and math.max(0, (start or 0) + duration - now) or 0
                cd = string.format("%d", left)
            end
        end
        parts[#parts + 1] = tostring(itemID) .. "(n=" .. tostring(n) .. (toy and ",toy" or "") .. ",cd=" .. cd .. ")"
    end
    local d = self.lastHearthDiag
    if d then
        parts[#parts + 1] = "last[bind=" .. tostring(d.bind) .. " map=" .. tostring(d.bindMap)
            .. " chain=" .. tostring(d.chain) .. " pick=" .. tostring(d.pick)
            .. " " .. table.concat(d.cands, "; ") .. "]"
    else
        parts[#parts + 1] = "last=none"
    end
    return table.concat(parts, " ")
end

-- Full rebuild, including a fresh scan of the portals the game draws on its maps.
function Route:InvalidateGateways()
    self.gateways = nil
    self.mapIndex = nil
    self.discovered = nil
    self.taxiCache = nil
    self.towardCache, self.towardCacheFor = nil, nil
    self.zoneMapCache = nil
end

-- Ticks (0.3s each) to wait for Blizzard's own routing before the table router answers.
local GAME_QUERY_TICKS = 3

local HOP_VERB_KEYS = { portal = "route_hop_portal_fmt", boat = "route_hop_boat_fmt", zeppelin = "route_hop_zeppelin_fmt" }

-- Generated rows name the gateway itself ("Portal to Zuldazar", "Boat to Zuldazar", "Deeprun Tram to
-- Ironforge"); the hop's verb already says what it is, so the label keeps only the place.
local GATEWAY_WORDS = {
    portal = true, ["revealed portal"] = true, boat = true, ship = true, ferry = true, zeppelin = true, ["return"] = true,
    ["deeprun tram"] = true, waygate = true, waystone = true, teleporter = true, teleport = true, ["rocket drill"] = true,
    rootway = true, ["mole machine"] = true, submarine = true, rift = true,
}
local function placeName(label)
    if type(label) ~= "string" then
        return label
    end
    local gateway, place = label:match("^(.-) to (.+)$")
    if gateway and place and place ~= "" and GATEWAY_WORDS[gateway:lower()] then
        return place
    end
    return label
end

-- Flight points on a map the character can use; with `includeUndiscovered` also the ones not yet
-- learned, flagged, so a hop can say which flight point would shorten the trip.
-- How long a map's flight-point list is reused before it is read again.
local TAXI_CACHE_SECONDS = 3

function Route:TaxiNodes(mapID, faction, includeUndiscovered)
    if not (mapID and type(C_TaxiMap) == "table" and type(C_TaxiMap.GetTaxiNodesForMap) == "function") then
        return {}
    end
    -- The route asks two maps for their flight points several times a second and the answer barely ever
    -- changes, so it is kept per map for a few seconds. Learning a flight point clears it at once; the
    -- short life means a change the addon is never told about still comes through on its own. With no
    -- clock to age it by, nothing is kept.
    local key = mapID .. ":" .. tostring(faction) .. ":" .. tostring(includeUndiscovered)
    local now = type(GetTime) == "function" and GetTime() or nil
    if now then
        local cached = self.taxiCache and self.taxiCache[key]
        if cached and (now - cached.at) < TAXI_CACHE_SECONDS then
            return cached.list
        end
    end
    local ok, nodes = pcall(C_TaxiMap.GetTaxiNodesForMap, mapID)
    if not (ok and type(nodes) == "table") then
        return {}
    end
    local out = {}
    local F = Enum and Enum.FlightPathFaction
    for _, node in ipairs(nodes) do
        local nodeFaction = "Neutral"
        if F then
            if node.faction == F.Alliance then nodeFaction = "Alliance" elseif node.faction == F.Horde then nodeFaction = "Horde" end
        end
        if (includeUndiscovered or not node.isUndiscovered) and factionOk(nodeFaction, faction) and node.position then
            local x, y
            if type(node.position.GetXY) == "function" then
                x, y = node.position:GetXY()
            else
                x, y = node.position.x, node.position.y
            end
            -- the client hands the whole network in this map's coordinate space, off-map nodes
            -- past 0..1 included, and the world transform extrapolated off the map is garbage (a
            -- live diag showed the "nearest" flight master at -0.303,1.332 of the player's map,
            -- flying a Dragon Isles route toward Bel'ameth); only nodes on the map are kept
            if x and y and x >= 0 and x <= 1 and y >= 0 and y <= 1 then
                local continentID, wx, wy = worldFromMap(mapID, x, y)
                if continentID then
                    out[#out + 1] = { name = node.name, mapID = mapID, x = x, y = y, wx = wx, wy = wy, isUndiscovered = node.isUndiscovered or nil }
                end
            end
        end
    end
    if now then
        self.taxiCache = self.taxiCache or {}
        self.taxiCache[key] = { at = now, list = out }
    end
    return out
end

-- A learned flight point, or a fresh world, changes what the maps carry
function Route:InvalidateTaxiNodes()
    self.taxiCache = nil
end

local function nearest(list, wx, wy)
    local best, bestD
    for _, e in ipairs(list) do
        local d = dist(wx, wy, e.wx, e.wy)
        if not bestD or d < bestD then
            best, bestD = e, d
        end
    end
    return best, bestD
end

-- Yards an unlearned flight point must save before a hop mentions it.
local DISCOVER_HINT_YARDS = 500

-- " (discover X for a shorter route)" when an unlearned flight point sits much nearer the target
-- than the one the hop can use; nothing when the character has no flight point to compare.
function Route:DiscoverHint(destMapID, faction, chosen, chosenD, dwx, dwy)
    if not (chosen and chosenD) then
        return ""
    end
    local all = self:TaxiNodes(destMapID, faction, true)
    local closest, closestD = nearest(all, dwx, dwy)
    if closest and closest.isUndiscovered and closest.name ~= chosen.name
        and closestD < chosenD * 0.75 and (chosenD - closestD) >= DISCOVER_HINT_YARDS then
        return string.format(L["route_discover_hint_fmt"], closest.name)
    end
    return ""
end

-- Riding anyway, an unlearned flight point that lies on the way becomes a stop: reaching it costs
-- at most a sliver extra, and the learned point makes the next trip fly. A node off the path never
-- bends the route.
local LEARN_DETOUR_RATIO = 1.15
function Route:LearnDetour(playerMap, destMapID, faction, px, py, dwx, dwy, straight)
    local all = self:TaxiNodes(destMapID, faction, true)
    if playerMap ~= destMapID then
        local near = self:TaxiNodes(playerMap, faction, true)
        for _, node in ipairs(near) do
            all[#all + 1] = node
        end
    end
    local best, bestThrough
    for _, node in ipairs(all) do
        if node.isUndiscovered then
            local through = dist(px, py, node.wx, node.wy) + dist(node.wx, node.wy, dwx, dwy)
            if through <= straight * LEARN_DETOUR_RATIO and (not bestThrough or through < bestThrough) then
                best, bestThrough = node, through
            end
        end
    end
    if not best then
        return nil
    end
    return { mapID = best.mapID, x = best.x, y = best.y, text = string.format(L["route_hop_learn_fp_fmt"], best.name),
        kind = "taxi-learn", arriveYards = (R.ARRIVE_DISTANCE or 40) }
end

function Route:SameContinentHop(playerMap, px, py, destMapID, dx, dy, dwx, dwy, faction, finalTitle)
    local straight = dist(px, py, dwx, dwy)
    local minTaxi = R.TAXI_MIN_DISTANCE or 1500
    local playerNodes = self:TaxiNodes(playerMap, faction)
    local playerNode, playerNodeD = nearest(playerNodes, px, py)
    local nearFlightMaster = playerNode and playerNodeD <= (R.TAXI_NEAR_RADIUS or 300)
    if straight <= minTaxi and not nearFlightMaster then
        return { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "final" }
    end
    local destNodes = self:TaxiNodes(destMapID, faction)
    local destNode, destNodeD = nearest(destNodes, dwx, dwy)
    -- a flight must land meaningfully closer than riding straight from here: landing beside a
    -- flight master used to chain a second flight to save a few hundred yards
    if not destNode or destNodeD > straight - (R.TAXI_GAIN_YARDS or 1000) then
        return self:LearnDetour(playerMap, destMapID, faction, px, py, dwx, dwy, straight)
            or { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "final" }
    end
    if not playerNode or playerNodeD >= straight then
        -- no flight master closer than the target itself: just go, grabbing a flight point on the way
        return self:LearnDetour(playerMap, destMapID, faction, px, py, dwx, dwy, straight)
            or { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "final" }
    end
    if playerNode.name == destNode.name then
        -- standing near the last flight master on the way: point at the target
        return { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "final" }
    end
    local hint = self:DiscoverHint(destMapID, faction, destNode, destNodeD, dwx, dwy)
    -- the arrow stays on the flight master beside you until you take the flight; the flight map picks the node
    if nearFlightMaster then
        return { mapID = playerNode.mapID, x = playerNode.x, y = playerNode.y, text = string.format(L["route_fly_to_flightmaster_fmt"], destNode.name, playerNode.name) .. hint, kind = "taxi-dest", taxiDest = destNode.name, arriveYards = (R.ARRIVE_DISTANCE or 40) }
    end
    -- always use the nearest flight master rather than a long ride under your own power
    return { mapID = playerNode.mapID, x = playerNode.x, y = playerNode.y, text = string.format(L["route_flightmaster_then_fly_fmt"], playerNode.name, destNode.name) .. hint, kind = "taxi-start", taxiDest = destNode.name }
end

-- True when a hop description means a portal, boat or zeppelin off the current world.
function Route:LeavesWorld(text)
    local lowered = type(text) == "string" and text:lower() or ""
    return (lowered:find("portal", 1, true) or lowered:find("boat", 1, true) or lowered:find("zeppelin", 1, true)) and true or false
end

-- Blizzard's own routing for whatever is super-tracked: portals, flight paths, boats as the game sees them.
function Route:GameNextHop(finalTitle)
    if not (type(C_SuperTrack) == "table" and type(C_SuperTrack.GetNextWaypointForMap) == "function") then
        return nil
    end
    local okMap, playerMap = pcall(C_Map.GetBestMapForUnit, "player")
    if not (okMap and playerMap) then
        return nil
    end
    local ok, x, y, description = pcall(C_SuperTrack.GetNextWaypointForMap, playerMap)
    if not (ok and type(x) == "number") then
        local zoneMap = self:ZoneMap(playerMap)
        if zoneMap ~= playerMap then
            ok, x, y, description = pcall(C_SuperTrack.GetNextWaypointForMap, zoneMap)
            playerMap = zoneMap
        end
    end
    if ok and type(x) == "number" and type(y) == "number" then
        if not (type(description) == "string" and description ~= "") then
            -- a bare point with no words is the destination itself (or a straight line at it), not a
            -- route hop: the table's answer guides, and the point coming and going cannot flap the hop
            return nil
        end
        local text = description
        local taxiDest = description:match("^[Ff]ly to ([^,]+)")
        local where = self:DescribeGateway(playerMap, x, y)
        if where then
            text = text .. " (" .. where .. ")"
        end
        return { mapID = playerMap, x = x, y = y, text = text, kind = "game", arriveYards = (where or taxiDest) and 40 or nil, taxiDest = taxiDest }
    end
    return nil
end

-- Seconds a tracked item's game answer is reused before Blizzard is asked again.
local TRACKED_ASK_SECONDS = 1

-- Blizzard's routing for the tracked item this route carries: C_ContentTracking answers with the next
-- portal, boat or flight no matter what is super-tracked, so the game's own route to the very item
-- being guided is heard even while the addon drives its own pin.
function Route:TrackedNextHop()
    local item = self.target and self.target.item
    local tracking = pluginShared.Tracking
    if not (item and item.id and tracking and tracking.NextWaypoint) then
        return nil
    end
    -- No settle wait here: C_ContentTracking is asked by the player's current map and answers
    -- nothing while its data is pending, so the 3-5 s settle window after every zone change only
    -- parked the pin on the far destination. The junk guards below (a wordless point is no hop, an
    -- off-world hop for a same-continent target is refused) cover the loading-screen edge instead.
    local now = type(GetTime) == "function" and GetTime() or nil
    if now and self.trackedAskedAt and (now - self.trackedAskedAt) < TRACKED_ASK_SECONDS and self.trackedHop then
        -- only a standing answer is reused; nothing (Blizzard still pending) is asked again at once
        return self.trackedHop
    end
    self.trackedAskedAt = now
    self.trackedHop = nil
    local ok, mapID, x, y, text = pcall(tracking.NextWaypoint, tracking, item.type, item.id)
    if not (ok and mapID and type(x) == "number" and type(y) == "number" and type(text) == "string" and text ~= "") then
        -- a bare point with no words is the destination itself, not a route hop: the table guides
        return nil
    end
    local target = self.target
    if self:LeavesWorld(text) and self:SameWorldAsPlayer(target.mapID, target.x, target.y) then
        -- Blizzard wants to portal off this continent although the destination is on it; self-route
        return nil
    end
    -- the stone competes with the game's answer too: a ready hearth that beats the whole portal
    -- chain toward the final target outranks whatever single hop the tracker names
    local targetContinent = worldFromMap(target.mapID, target.x, target.y)
    local playerContinent, ppx, ppy = Lockouts:PlayerWorld()
    if targetContinent and playerContinent and targetContinent ~= playerContinent and ppx then
        local gateways = self:Gateways()
        local _, chainScore = self:BestGatewayToward(gateways, playerContinent, targetContinent, ppx, ppy)
        local hearth = self:HearthHop(gateways, targetContinent, playerContinent, chainScore, target.mapID, target.x, target.y)
        if hearth then
            self.trackedHop = hearth
            return self.trackedHop
        end
    end
    local taxiDest = text:match("^[Ff]ly to ([^,]+)")
    local where = self:DescribeGateway(mapID, x, y)
    if where and not text:find(where, 1, true) then
        text = text .. " (" .. where .. ")"
    end
    local hop = { mapID = mapID, x = x, y = y, text = text, kind = "game", arriveYards = (where or taxiDest) and 40 or nil, taxiDest = taxiDest }
    if not taxiDest then
        -- the game's answer rides the same flight-master assist as the table's hops: a portal
        -- thousands of yards away on this continent means fly there, not walk
        local playerContinent, px, py = Lockouts:PlayerWorld()
        local hopContinent, hwx, hwy = worldFromMap(mapID, x, y)
        local okMap, playerMap = pcall(C_Map.GetBestMapForUnit, "player")
        if okMap and playerMap and px and hopContinent and hopContinent == playerContinent then
            local assist = self:SameContinentHop(playerMap, px, py, mapID, x, y, hwx, hwy, self:PlayerFaction(), text)
            if assist and (assist.kind == "taxi-start" or assist.kind == "taxi-dest") then
                hop = assist
            end
        end
    end
    self.trackedHop = hop
    return self.trackedHop
end

-- On a flyable continent the shortest guide is your own mount: skip flight masters and Blizzard's "Fly to" hops.
-- True when the destination sits on the same world/continent the player is standing on.
function Route:SameWorldAsPlayer(destMapID, dx, dy)
    local destContinent = worldFromMap(destMapID, dx, dy)
    local playerContinent = Lockouts:PlayerWorld()
    return destContinent ~= nil and destContinent == playerContinent
end

function Route:CanFlyToTarget(destMapID, dx, dy)
    -- Only the Prefer my own mount option chooses a straight line. Being airborne at the moment must
    -- not: hovering beside a flight master and landing again flipped the hop between the flight
    -- master and a target thousands of yards away at the very same spot.
    local profile = pluginShared.db and pluginShared.db.profile
    local cfg = profile and profile.guideArrow
    if not (cfg and cfg.preferOwnMount) then
        return false
    end
    if not (type(IsFlyableArea) == "function" and IsFlyableArea()) then
        return false
    end
    local destContinent = worldFromMap(destMapID, dx, dy)
    local playerContinent = Lockouts:PlayerWorld()
    return destContinent ~= nil and destContinent == playerContinent
end

-- Sub-maps (caves, buildings, portal rooms) flicker with the zone map at their edges; route off the zone.
function Route:ZoneMap(mapID)
    -- Asked on every route tick for the same map. The map hierarchy is fixed, so each answer is kept.
    self.zoneMapCache = self.zoneMapCache or {}
    local known = self.zoneMapCache[mapID]
    if known then
        return known
    end
    local function keep(answer)
        self.zoneMapCache[mapID] = answer
        return answer
    end
    local hops = 0
    local current = mapID
    while current and hops < 6 do
        local ok, info = pcall(C_Map.GetMapInfo, current)
        if not (ok and type(info) == "table") then
            return mapID
        end
        local zoneType = Enum and Enum.UIMapType and Enum.UIMapType.Zone or 3
        local continentType = Enum and Enum.UIMapType and Enum.UIMapType.Continent or 2
        if info.mapType == zoneType or info.mapType == continentType then
            return keep(current)
        end
        if not info.parentMapID then
            return keep(current)
        end
        current = info.parentMapID
        hops = hops + 1
    end
    return mapID
end

-- True once the player is standing within the hop's arrival radius and not mid-flight.
function Route:ReachedHop(hop)
    if not (hop and hop.arriveYards and hop.mapID and hop.x and hop.y) then
        return false
    end
    if type(UnitOnTaxi) == "function" and UnitOnTaxi("player") then
        return false
    end
    local _, px, py = Lockouts:PlayerWorld()
    local _, hx, hy = worldFromMap(hop.mapID, hop.x, hop.y)
    if not (px and hx) then
        return false
    end
    return dist(px, py, hx, hy) <= hop.arriveYards
end

-- The gateway that actually blocks the chain to `destContinent`: walk the shortest chain over every
-- gateway, unlocked ones included, and answer the first one this character cannot use. A locked
-- candidate wins at each step; an unlocked one only moves the walk to its continent.
function Route:BlockingGateway(gateways, fromContinent, destContinent, px, py)
    local from, visited = fromContinent, {}
    for _ = 1, 12 do
        if visited[from] then
            return nil
        end
        visited[from] = true
        local locked, lockedD, open, openD
        for _, g in ipairs(self:GatewaysToward(gateways, from, destContinent, true)) do
            local d = (from == fromContinent and px and py) and dist(px, py, g.wx, g.wy) or 0
            if g.locked then
                if not lockedD or d < lockedD then locked, lockedD = g, d end
            elseif not openD or d < openD then
                open, openD = g, d
            end
        end
        if locked then
            return locked
        end
        if not open then
            return nil
        end
        from = open.destContinent
    end
    return nil
end

-- The game's pin: where the route last put Blizzard's user waypoint. The pin is the primary guide and is
-- always driven; it waits on the destination while the game is asked for its own route and sits on the
-- hop otherwise.
function Route:PlacePin(mapID, x, y)
    self.pinAt = { mapID = mapID, x = x, y = y }
    return Lockouts:SetGameWaypoint(mapID, x, y)
end

function Route:PinIsAt(spot)
    local pin = self.pinAt
    return pin ~= nil and spot ~= nil and pin.mapID == spot.mapID and math.abs(pin.x - spot.x) < 0.002 and math.abs(pin.y - spot.y) < 0.002
end

-- A hop the table gave: a portal, boat, zeppelin or flight master rather than the game's own answer or
-- the destination itself. The game would answer the same gateway, so it is not asked again for it.
local function tableGatewayHop(hop)
    return hop ~= nil and hop.kind ~= "game" and hop.kind ~= "final" and hop.kind ~= "pending"
end

function Route:NextHop(destMapID, dx, dy, finalTitle)
    if self:CanFlyToTarget(destMapID, dx, dy) then
        self.cachedGameHop = nil
        self.queryTicks = nil
        return { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "final" }
    end
    local okMap, playerMap = pcall(C_Map.GetBestMapForUnit, "player")
    if okMap and playerMap then
        playerMap = self:ZoneMap(playerMap)
    end
    local playerContinent, px, py = Lockouts:PlayerWorld()
    if not (okMap and playerMap and playerContinent and px and py) then
        -- through a portal or a loading screen the client does not know where the player stands yet:
        -- nothing to route from and nothing to ask the game; the arrow keeps what it has
        return { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "pending" }
    end
    local now = type(GetTime) == "function" and GetTime() or 0
    local mapChanged = playerMap ~= self.queriedMap
    local stale = (not self.queriedAt or (now - self.queriedAt) > 60) and not tableGatewayHop(self.lastHop)
    if mapChanged or stale then
        self:PlacePin(destMapID, dx, dy)
        self.queriedMap = playerMap
        self.queriedAt = now
        self.queryTicks = 0
        self.cachedGameHop = nil
        self.trackedAskedAt, self.trackedHop = nil, nil
    end
    -- Tick lets a hop change skip the two-tick flap gate when the player's map just changed: through
    -- a portal the new hop is genuine and must not wait
    self.hopMapChanged = mapChanged
    local tracked = self:TrackedNextHop()
    if tracked then
        -- the game's own route to the tracked item: never blinded by the addon's own super-tracked
        -- pin, so it outranks both the pin query and whatever the table would say
        self.cachedGameHop = tracked
        self.queryTicks = nil
    end
    local item = self.target and self.target.item
    if item and item.id and type(C_ContentTracking) == "table" and type(C_ContentTracking.GetNextWaypointForTrackable) == "function" then
        -- the content query replaces the pin dance: it answers for the tracked item no matter where
        -- the pin sits, so parking the pin on the far destination for a query window bought nothing
        -- but the ~3 s Blizzard's router needs after a loading screen. The table's hop goes up for
        -- good at once, pin included; the game's answer refines it silently when it is ready
        self.queryTicks = nil
    end
    local canAskGame = type(C_SuperTrack) == "table" and type(C_SuperTrack.GetNextWaypointForMap) == "function"
    if canAskGame and self.queryTicks and self.queryTicks < GAME_QUERY_TICKS and not self.cachedGameHop then
        if not (Lockouts.IsSettling and Lockouts:IsSettling()) then
            -- for a few seconds after a loading screen the game's router answers junk; the table guides
            -- meanwhile and the game is asked once the client has settled
            self.queryTicks = self.queryTicks + 1
            local gameHop = self:GameNextHop(finalTitle)
            if gameHop and self:LeavesWorld(gameHop.text) and self:SameWorldAsPlayer(destMapID, dx, dy) then
                -- Blizzard wants to portal off this continent although the destination is on it (its
                -- router loops between Orgrimmar and Ashran); route it ourselves from here on
                gameHop = nil
                self.queryTicks = nil
            end
            if gameHop then
                self.cachedGameHop = gameHop
                self.queryTicks = nil
                return gameHop
            end
        end
        if self.queryTicks and self.queryTicks < GAME_QUERY_TICKS then
            -- the game is still working its route out: the table's answer guides the arrow meanwhile, and
            -- the game's pin stays on the destination so the game can route from it
            local hop = self:TableHop(playerMap, playerContinent, px, py, destMapID, dx, dy, finalTitle)
            hop.provisional = true
            return hop
        end
    end
    if self.cachedGameHop and not self:LeavesWorld(self.cachedGameHop.text) and self:ReachedHop(self.cachedGameHop) then
        -- Landed at (or walked onto) the cached flight point, possibly inside the same zone we queried
        -- from, so the zone-keyed cache would otherwise keep saying "Fly to" the node we stand on. A
        -- portal, boat or zeppelin is different: standing at it is not being through it, the map
        -- change on the other side moves the route on, and dropping the hop here announced it twice.
        self.cachedGameHop = nil
        self.trackedAskedAt, self.trackedHop = nil, nil
        self.lastHop = nil
        self.queriedAt = nil
        self.queryTicks = 0
        self:PlacePin(destMapID, dx, dy)
        return { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "pending" }
    end
    if self.cachedGameHop then
        return self.cachedGameHop
    end
    return self:TableHop(playerMap, playerContinent, px, py, destMapID, dx, dy, finalTitle)
end

-- The route the shipped tables give from where the player stands: the destination itself on the same
-- continent (by flight master when that is shorter), else the first gateway of the shortest chain of
-- portals, boats and zeppelins that ends on the destination's continent.
function Route:TableHop(playerMap, playerContinent, px, py, destMapID, dx, dy, finalTitle)
    local destContinent, dwx, dwy = worldFromMap(destMapID, dx, dy)
    if not destContinent then
        return { mapID = destMapID, x = dx, y = dy, text = finalTitle, kind = "final" }
    end
    if playerContinent == destContinent then
        return self:SameContinentHop(playerMap, px, py, destMapID, dx, dy, dwx, dwy, self:PlayerFaction(), finalTitle)
    end
    local gateways, faction = self:Gateways()
    -- The hop is the first gateway of the cheapest chain of portals, boats and zeppelins that ends on
    -- the target's continent, so Silvermoon -> Orgrimmar -> Jade Forest -> Isle of Thunder resolves in
    -- one go, and a hub portal beside the player beats a fewer-hop gateway a continent away.
    local best, bestScore = self:BestGatewayToward(gateways, playerContinent, destContinent, px, py)
    local hearth = self:HearthHop(gateways, destContinent, playerContinent, bestScore, destMapID, dx, dy)
    if hearth then
        return hearth
    end
    if not best then
        -- a chain may exist through a gateway this character has not unlocked: say which one, the
        -- locked hop itself, not an open portal that merely starts the chain
        local lockedGate = self:BlockingGateway(gateways, playerContinent, destContinent, px, py)
        if lockedGate then
            return { mapID = destMapID, x = dx, y = dy, text = string.format(L["route_gateway_locked_fmt"], finalTitle, lockedGate.label), kind = "final" }
        end
        return { mapID = destMapID, x = dx, y = dy, text = string.format(L["route_no_known_gateway_fmt"], finalTitle), kind = "final" }
    end
    local bestD = dist(px, py, best.wx, best.wy)
    if bestD > (R.ARRIVE_DISTANCE or 40) * 4 then
        local hop = self:SameContinentHop(playerMap, px, py, best.mapID, best.x, best.y, best.wx, best.wy, faction, best.hubName)
        if hop.kind ~= "final" then
            return hop
        end
    end
    local hubName, floor = best.hubName, best.floor
    if not best.hub then
        -- a row standing in a hub's room is placed by the room's name rather than its map's
        local hub = self:HubNear(best, HUB_TWIN_YARDS)
        if hub then
            hubName, floor = hub.hubName, nil
        end
    end
    local whereParts = {}
    if hubName then whereParts[#whereParts + 1] = hubName end
    if floor then whereParts[#whereParts + 1] = floor end
    local where = #whereParts > 0 and (" (" .. table.concat(whereParts, ", ") .. ")") or ""
    local verbFmt = (HOP_VERB_KEYS[best.kind] and L[HOP_VERB_KEYS[best.kind]]) or L["route_hop_go_fmt"]
    return { mapID = best.mapID, x = best.x, y = best.y, text = string.format(verbFmt, placeName(best.label)) .. where, kind = best.kind }
end

-- At a flight master: pick the reachable node closest to the target and make it the hop.
function Route:OnTaxiMapOpened()
    local target = self.target
    if not (target and type(C_TaxiMap) == "table" and type(C_TaxiMap.GetAllTaxiNodes) == "function") then
        return nil
    end
    local okMap, playerMap = pcall(C_Map.GetBestMapForUnit, "player")
    if not (okMap and playerMap) then
        return nil
    end
    local destContinent, twx, twy = worldFromMap(target.mapID, target.x, target.y)
    if not twx then
        return nil
    end
    local playerContinent, px, py = Lockouts:PlayerWorld()
    if destContinent and playerContinent and destContinent ~= playerContinent then
        -- the target's yards live on another continent's grid; aim the flight at the first gateway of
        -- the chain instead of comparing distances across worlds (that math once flew a Silvermoon
        -- player toward Gilneas for a Black Temple route)
        local g = px and self:BestGatewayToward(self:Gateways(), playerContinent, destContinent, px, py) or nil
        if not g then
            return nil
        end
        twx, twy = g.wx, g.wy
    end
    local ok, nodes = pcall(C_TaxiMap.GetAllTaxiNodes, playerMap)
    if not (ok and type(nodes) == "table") then
        return nil
    end
    local reachable = Enum and Enum.FlightPathState and Enum.FlightPathState.Reachable or 1
    local best, bestD
    for _, node in ipairs(nodes) do
        if node.state == reachable and node.position then
            local nx, ny
            if type(node.position.GetXY) == "function" then nx, ny = node.position:GetXY() else nx, ny = node.position.x, node.position.y end
            local continentID, wx, wy = worldFromMap(playerMap, nx, ny)
            if continentID then
                local d = dist(wx, wy, twx, twy)
                if not bestD or d < bestD then
                    best, bestD = { name = node.name, x = nx, y = ny }, d
                end
            end
        end
    end
    if not best then
        return nil
    end
    local hop = { mapID = playerMap, x = best.x, y = best.y, text = string.format(L["route_fly_to_fmt"], best.name), kind = "taxi-dest", taxiDest = best.name, arriveYards = R.PORTAL_REACH or 40 }
    self.lastHop = hop
    self.candidateHop = nil
    self.cachedGameHop = hop
    if pluginShared.GuideArrow then
        pluginShared.GuideArrow:SetHop(hop)
    end
    say(string.format(L["route_print_next_fmt"], hop.text))
    return hop
end

function Route:Start(mapID, x, y, title, raidName, item)
    self:Stop()
    self.target = { mapID = mapID, x = x, y = y, title = title, raidName = raidName, item = item }
    if AppearanceMaticDB then
        AppearanceMaticDB.routeTarget = { mapID = mapID, x = x, y = y, title = title, raidName = raidName, item = item }
    end
    if not self:PlacePin(mapID, x, y) then
        tell(string.format(L["route_print_pin_rejected_fmt"], tostring(title)))
    end
    -- one private line for a start; the first hop rides on the arrow and the pin
    tell(string.format(L["route_print_routing_fmt"], tostring(title)))
    self:Tick()
    if type(C_Timer) == "table" and type(C_Timer.NewTicker) == "function" then
        self.ticker = C_Timer.NewTicker(0.3, function() Route:Tick() end)
    end
end

function Route:Resume()
    local saved = AppearanceMaticDB and AppearanceMaticDB.routeTarget
    if self.target or type(saved) ~= "table" or not saved.mapID then
        return false
    end
    self:Start(saved.mapID, saved.x, saved.y, saved.title, saved.raidName, saved.item)
    return true
end

function Route:Stop()
    if self.ticker then
        pcall(self.ticker.Cancel, self.ticker)
        self.ticker = nil
    end
    self.target = nil
    self.arrived = nil
    self.arrivedAnnounced = nil
    self.queriedMap = nil
    self.queriedAt = nil
    self.queryTicks = nil
    self.cachedGameHop = nil
    self.trackedAskedAt = nil
    self.trackedHop = nil
    self.candidateHop = nil
    self.pinAt = nil
    self.hopSeen = nil
    if AppearanceMaticDB then
        AppearanceMaticDB.routeTarget = nil
    end
    if Lockouts.ScheduleRebuild then
        Lockouts:ScheduleRebuild()
    end
    self.lastHop = nil
    if self.tomtomUID and Lockouts:HasTomTom() and type(_G.TomTom.RemoveWaypoint) == "function" then
        pcall(_G.TomTom.RemoveWaypoint, _G.TomTom, self.tomtomUID)
    end
    self.tomtomUID = nil
    if pluginShared.GuideArrow then
        pluginShared.GuideArrow:Clear()
    end
end

-- Only a portal or flight hop is worth the people around you hearing; every other route line is the
-- player's own bookkeeping and is told privately.
local function hopSpeaksAloud(hop)
    if not hop then
        return false
    end
    if hop.kind == "portal" or hop.kind == "taxi-start" or hop.kind == "taxi-dest" or hop.taxiDest then
        return true
    end
    if hop.kind == "game" then
        local lowered = type(hop.text) == "string" and hop.text:lower() or ""
        return (lowered:find("portal", 1, true) or lowered:find("fly to", 1, true)) and true or false
    end
    return false
end

-- The game names a gateway at the portal object itself, the table a few yards off: the same hop.
local SAME_HOP_YARDS = 15

local function sameHop(a, b)
    if not (a and b and a.mapID == b.mapID) then
        return false
    end
    if math.abs(a.x - b.x) < 0.002 and math.abs(a.y - b.y) < 0.002 then
        return true
    end
    local _, ax, ay = worldFromMap(a.mapID, a.x, a.y)
    local _, bx, by = worldFromMap(b.mapID, b.x, b.y)
    return ax ~= nil and bx ~= nil and dist(ax, ay, bx, by) <= SAME_HOP_YARDS
end

local function sameSpot(a, b)
    return a and b and math.abs(a.x - b.x) < 0.002 and math.abs(a.y - b.y) < 0.002
end

-- True while standing in the raid or dungeon the route leads to.
function Route:InsideTargetInstance()
    local target = self.target
    if not (target and type(GetInstanceInfo) == "function") then return false end
    local wanted = target.raidName or (type(target.title) == "string" and target.title:gsub(" entrance$", "")) or nil
    if type(wanted) ~= "string" or wanted == "" then return false end
    local ok, name, instanceType = pcall(GetInstanceInfo)
    if not (ok and type(name) == "string" and (instanceType == "raid" or instanceType == "party")) then return false end
    return name:lower() == wanted:lower()
end

function Route:Tick()
    local target = self.target
    if not target then
        self:Stop()
        return
    end
    if type(UnitOnTaxi) == "function" and UnitOnTaxi("player") then
        -- aboard a flight the route holds still: the flight is the hop being executed, so nothing
        -- recomputes, re-pins, announces or pings while zones pass underneath; landing recomputes
        return
    end
    if self.lastHop and self.lastHop.kind == "hearth" and type(UnitCastingInfo) == "function" then
        local casting = pluginShared.guarded and select(2, pluginShared.guarded(UnitCastingInfo, "player")) or UnitCastingInfo("player")
        if casting then
            -- mid-hearth the route holds still too: the stone is the hop being executed, and its own
            -- cooldown starts with the cast, which would flip the answer to the portals for ten
            -- seconds; the landing recomputes from the bind side
            return
        end
    end
    local _, px, py = Lockouts:PlayerWorld()
    local _, twx, twy = worldFromMap(target.mapID, target.x, target.y)
    local arriveYards = R.ARRIVE_DISTANCE or 40
    if self:InsideTargetInstance() then
        -- inside the instance the guide's job is done: end the route, clear the arrow
        tell(string.format(L["route_print_inside_fmt"], tostring(target.raidName or target.title)))
        self:Stop()
        if pluginShared.GuideArrow then
            pluginShared.GuideArrow:Clear()
        end
        return
    end
    if px and twx and dist(px, py, twx, twy) <= arriveYards then
        -- Stay "Guiding" while standing at the target; the guide only ends by hand or when the item is collected.
        if not self.arrived then
            self.arrived = true
            self.lastHop = nil
            self.candidateHop = nil
            if not self.arrivedAnnounced then
                self.arrivedAnnounced = true
                tell(string.format(L["route_print_arrived_fmt"], tostring(target.title)))
            end
            self:PlacePin(target.mapID, target.x, target.y)
            if pluginShared.GuideArrow then
                pluginShared.GuideArrow:SetHop({ mapID = target.mapID, x = target.x, y = target.y, text = target.title, arriveYards = arriveYards })
            end
        end
        return
    elseif self.arrived and px and twx and dist(px, py, twx, twy) > arriveYards * 2 then
        self.arrived = false
    elseif self.arrived then
        return
    end
    local hop = self:NextHop(target.mapID, target.x, target.y, target.title)
    if not hop then
        return
    end
    local gatewayHop = hop.kind == "portal" or hop.kind == "boat" or hop.kind == "zeppelin"
        or hop.kind == "taxi-start" or hop.kind == "taxi-dest" or (hop.kind == "game" and hop.arriveYards ~= nil)
    if gatewayHop and not hop.arriveYards then
        hop.arriveYards = R.PORTAL_REACH or 30
    end
    if hop.kind == "pending" then
        Lockouts:Mark("route pending")
        Lockouts:EnsureGameArrow(target.mapID, target.x, target.y)
        return
    end
    local pinAt = gatewayHop and hop or target
    local last = self.lastHop
    if sameHop(last, hop) then
        if not hop.provisional and (last.provisional or not sameSpot(last, hop)) then
            -- the same gateway, now for good: the table's answer stood after the game had its chance,
            -- or the game named the very portal a few yards from the table's spot. Taken over without
            -- a second chat line; the arrow follows the game's exact spot
            self.lastHop = hop
            if not sameSpot(last, hop) and pluginShared.GuideArrow then
                pluginShared.GuideArrow:SetHop(hop)
            end
        end
        if hop.provisional then
            Lockouts:EnsureGameArrow(target.mapID, target.x, target.y)
        elseif self:PinIsAt(pinAt) then
            Lockouts:EnsureGameArrow(pinAt.mapID, pinAt.x, pinAt.y)
        else
            -- the pin waited on the destination while the game was asked; the hop is unchanged, so
            -- it comes back onto the hop instead of staying on the far destination
            self:PlacePin(pinAt.mapID, pinAt.x, pinAt.y)
        end
        return
    end
    -- a change of hop the game gave, or the table's after the game was heard, must hold for two ticks
    -- so the arrow never flaps; a route's first hop and the table's provisional answer go up at once
    if last and not hop.provisional and not self.hopMapChanged and not sameHop(self.candidateHop, hop) then
        self.candidateHop = hop
        return
    end
    self.candidateHop = nil
    Lockouts:Mark("route hop")
    if self.hopSeen then
        -- the first hop rides with the "routing to" line, on the arrow and the pin; a change is
        -- announced, out loud only when it is a portal or a flight
        -- an aloud-worthy hop speaks as an emote at most once each 30 seconds; inside the window
        -- the change is still told privately, so a flapping pair of portals never spams the room
        local speak = tell
        if hopSpeaksAloud(hop) then
            local now = type(GetTime) == "function" and GetTime() or 0
            if not self.lastSpokeAt or (now - self.lastSpokeAt) >= 30 then
                self.lastSpokeAt = now
                speak = say
            end
        end
        speak(string.format(L["route_print_next_fmt"], hop.text))
    end
    self.hopSeen = true
    self.lastHop = hop
    if hop.provisional then
        Lockouts:EnsureGameArrow(target.mapID, target.x, target.y)
    else
        self:PlacePin(pinAt.mapID, pinAt.x, pinAt.y)
    end
    if pluginShared.GuideArrow then
        pluginShared.GuideArrow:SetHop(hop)
    end
end
