local pluginName, pluginShared = ...
local Lockouts = pluginShared.Lockouts

local guarded = pluginShared.guarded
local reported = pluginShared.reported or guarded

-- 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
-- Favorites.lua defines this too: the same line for the player alone, never an emote
local tell = pluginShared.tell or print
-- Favorites.lua defines this first in the load order; the fallback keeps this file usable on its own.
local plain = pluginShared.plain or function(value)
    if type(issecretvalue) == "function" and issecretvalue(value) then
        return nil
    end
    return value
end
local L = LibStub("AceLocale-3.0"):GetLocale("AppearanceMatic")

function Lockouts:HasTomTom()
    return _G.TomTom and type(_G.TomTom.AddWaypoint) == "function" and true or false
end

-- Blizzard's own map pin + super-track arrow, so the in-game arrow follows the same target as TomTom.
function Lockouts:SetGameWaypoint(mapID, x, y)
    if not (mapID and x and y and type(C_Map) == "table" and type(C_Map.SetUserWaypoint) == "function"
        and type(UiMapPoint) == "table" and type(UiMapPoint.CreateFromCoordinates) == "function") then
        return false
    end
    if type(C_Map.CanSetUserWaypointOnMap) == "function" then
        local okCan, canSet = pcall(C_Map.CanSetUserWaypointOnMap, mapID)
        local hops = 0
        while okCan and not canSet and hops < 5 do
            local continentID, world
            local pos = (type(CreateVector2D) == "function") and CreateVector2D(x, y) or { x = x, y = y }
            local okWorld, cID, w = pcall(C_Map.GetWorldPosFromMapPos, mapID, pos)
            if not (okWorld and cID and w) then
                return false
            end
            continentID, world = cID, w
            local okInfo, info = pcall(C_Map.GetMapInfo, mapID)
            local parent = okInfo and type(info) == "table" and info.parentMapID
            if not parent or type(C_Map.GetMapPosFromWorldPos) ~= "function" then
                return false
            end
            local okBack, backMap, backPos = pcall(C_Map.GetMapPosFromWorldPos, continentID, world, parent)
            if not (okBack and backMap and type(backPos) == "table") then
                return false
            end
            local bx, by
            if type(backPos.GetXY) == "function" then bx, by = backPos:GetXY() else bx, by = backPos.x, backPos.y end
            mapID, x, y = backMap, bx, by
            okCan, canSet = pcall(C_Map.CanSetUserWaypointOnMap, mapID)
            hops = hops + 1
        end
        if not (okCan and canSet) then
            return false
        end
    end
    local okPoint, point = pcall(UiMapPoint.CreateFromCoordinates, mapID, x, y)
    if not (okPoint and point) then
        return false
    end
    local okSet = pcall(C_Map.SetUserWaypoint, point)
    if okSet and type(C_SuperTrack) == "table" and type(C_SuperTrack.SetSuperTrackedUserWaypoint) == "function" then
        pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, true)
    end
    return okSet and true or false
end

-- Re-arm the game's own arrow every tick: it is the primary guide, never hidden or replaced.
function Lockouts:EnsureGameArrow(mapID, x, y)
    if type(C_SuperTrack) ~= "table" then
        return
    end
    local trackingPin = type(C_SuperTrack.IsSuperTrackingUserWaypoint) == "function" and C_SuperTrack.IsSuperTrackingUserWaypoint()
    if trackingPin then
        return
    end
    local trackingContent = type(C_SuperTrack.IsSuperTrackingContent) == "function" and C_SuperTrack.IsSuperTrackingContent()
    if trackingContent and type(C_SuperTrack.GetNextWaypointForMap) == "function" then
        local okMap, playerMap = pcall(C_Map.GetBestMapForUnit, "player")
        local okHop, hx = okMap and pcall(C_SuperTrack.GetNextWaypointForMap, playerMap)
        if okHop and type(hx) == "number" then
            return
        end
    end
    if not self:SetGameWaypoint(mapID, x, y) then
        if AppearanceMaticDB then
            AppearanceMaticDB.diag = AppearanceMaticDB.diag or {}
            AppearanceMaticDB.diag.pinRefused = tostring(mapID)
        end
    end
end

function Lockouts:SetWaypoint(mapID, x, y, title)
    if not (mapID and x and y) then
        return
    end
    self:SetGameWaypoint(mapID, x, y)
    if self:HasTomTom() then
        guarded(_G.TomTom.AddWaypoint, _G.TomTom, mapID, x, y, { title = title, from = "AppearanceMatic" })
    end
end

local function roundCoord(value)
    return math.floor((value or 0) * 1000 + 0.5)
end

function Lockouts:WaypointLink(mapID, x, y, label)
    if not (mapID and x and y and label) then
        return label
    end
    return "|Hw2tswp:" .. mapID .. ":" .. roundCoord(x) .. ":" .. roundCoord(y) .. ":" .. label .. "|h[" .. label .. "]|h"
end

function Lockouts:HandleWaypointLink(link)
    if type(link) ~= "string" then
        return false
    end
    local mapID, x, y, refType, refID, title = link:match("^w2tswp:(%d+):(%d+):(%d+):(%d*):(%d*):(.*)$")
    if not mapID then
        mapID, x, y, title = link:match("^w2tswp:(%d+):(%d+):(%d+):(.*)$")
    end
    if not mapID then
        return false
    end
    local itemRef = (refType and refType ~= "" and refID and refID ~= "") and { type = tonumber(refType), id = tonumber(refID) } or nil
    local route = pluginShared.Route
    if route and route.target and route.target.title == title and route.Stop then
        -- second click on the same location link cancels the route
        route:Stop()
        self:StopGuide()
        self:ScheduleRebuild()
        return true
    end
    if route and route.Start then
        -- one driver at a time: a tracked guide left running would keep re-arming the arrow
        self:Mark("click")
        self:StopGuide()
        route:Start(tonumber(mapID), tonumber(x) / 1000, tonumber(y) / 1000, title, nil, itemRef)
        self:ScheduleRebuild()
    else
        self:SetWaypoint(tonumber(mapID), tonumber(x) / 1000, tonumber(y) / 1000, title)
    end
    return true
end

function Lockouts:HasSeasonDuplicate(raidName)
    local entry = raidName and self:BuildBossOrdinalIndex()[raidName:lower()]
    return entry and entry.currentSeason and entry.seasonInstanceID ~= nil
        and entry.originalInstanceID ~= nil and entry.seasonInstanceID ~= entry.originalInstanceID or false
end

-- Entrance from the shipped table, by journal instance ID or English raid name.
function Lockouts:ShippedEntrance(raidName, journalInstanceID)
    local shipped = pluginShared.RouteData and pluginShared.RouteData.ENTRANCES
    if type(shipped) ~= "table" then
        return nil
    end
    local row = journalInstanceID and shipped[journalInstanceID]
    if not row and raidName then
        local wanted = raidName:lower()
        for id, r in pairs(shipped) do
            if type(r.name) == "string" and r.name:lower() == wanted then
                row, journalInstanceID = r, id
                break
            end
        end
    end
    if not (row and row.mapID and row.x and row.y) then
        return nil
    end
    return { mapID = row.mapID, x = row.x, y = row.y, journalInstanceID = journalInstanceID, shipped = true, worldBoss = row.worldBoss and true or nil }
end

function Lockouts:LegacyEntrance(raidName)
    if not raidName then
        return nil
    end
    local function lookup()
        local bossEntry = self:BuildBossOrdinalIndex()[raidName:lower()]
        local shipped = self:ShippedEntrance(raidName, bossEntry and bossEntry.originalInstanceID)
        if shipped then
            return shipped
        end
        local entrances = self:BuildEntranceIndex()
        local byInstance = entrances.byInstance
        if bossEntry and bossEntry.originalInstanceID and byInstance and byInstance[bossEntry.originalInstanceID] then
            return byInstance[bossEntry.originalInstanceID]
        end
        return entrances[raidName:lower()]
    end
    local entry = lookup()
    if entry then
        return entry
    end
    -- the index is cached from its first build; if that happened before the map or journal data was
    -- ready it stays incomplete, so a miss rebuilds it once every 30 seconds until the raid shows up
    local now = type(GetTime) == "function" and GetTime() or 0
    if not self.entranceRetryAt or now - self.entranceRetryAt >= 30 then
        self.entranceRetryAt = now
        self.entranceIndexBuilt = nil
        self.bossOrdinalsBuilt = nil
        entry = lookup()
    end
    return entry
end

-- A raw w2tswp target the route follows for a row. itemRef = { type = trackType, id = id } lets a
-- collected alert end the guide.
local function waypointTarget(mapID, x, y, title, itemRef)
    local ref = itemRef and (itemRef.type .. ":" .. itemRef.id) or ":"
    return "w2tswp:" .. mapID .. ":" .. roundCoord(x) .. ":" .. roundCoord(y) .. ":" .. ref .. ":" .. title
end

function Lockouts:EntranceTarget(raidName, itemRef)
    local entry = self:LegacyEntrance(raidName)
    if not (entry and entry.mapID and entry.x and entry.y) then
        return ""
    end
    return waypointTarget(entry.mapID, entry.x, entry.y, raidName .. " entrance", itemRef)
end

-- Asked once per starred raid row on every rebuild. The Raid Finder difficulty names do not change
-- while the client runs, so the first answer that actually names something is kept.
local raidFinderNameCache

local function raidFinderNames()
    if raidFinderNameCache then
        return raidFinderNameCache
    end
    local names = {}
    local found = false
    if type(PLAYER_DIFFICULTY3) == "string" then names[PLAYER_DIFFICULTY3] = true found = true end
    if type(GetDifficultyInfo) == "function" then
        for _, id in ipairs({ 7, 17 }) do
            local ok, name = pcall(GetDifficultyInfo, id)
            if ok and type(name) == "string" then names[name] = true found = true end
        end
    end
    if found then
        raidFinderNameCache = names
    end
    return names
end

-- True when the drop's only difficulty is Looking For Raid.
function Lockouts:IsRaidFinderOnly(difficulties)
    if type(difficulties) ~= "table" or next(difficulties) == nil then return false end
    local names = raidFinderNames()
    for difficultyName in pairs(difficulties) do
        if not names[difficultyName] then return false end
    end
    return true
end

-- Walked per starred raid row on every rebuild; the expansion names are fixed for the session, so a
-- name that resolved once is not walked again.
local expansionLevels = {}

function Lockouts:ExpansionLevelByName(name)
    if type(name) ~= "string" then return nil end
    local known = expansionLevels[name]
    if known then return known end
    for level = 0, 20 do
        if _G["EXPANSION_NAME" .. level] == name then
            expansionLevels[name] = level
            return level
        end
    end
    return nil
end

-- The Raid Finder queue NPC for a legacy raid whose drop is LFR-only, plus the NPC's map when known.
-- Current-season raids queue from the group finder and return nil.
function Lockouts:RaidFinderNPC(raidName, difficulties)
    if not self:IsRaidFinderOnly(difficulties) or self:IsCurrentSeasonInstance(raidName) then
        return nil
    end
    -- past the difficulty test the answer turns only on the raid, and it is asked for hundreds of
    -- times per rebuild, so each raid's queue NPC is worked out once
    self.raidFinderPick = self.raidFinderPick or {}
    local held = self.raidFinderPick[raidName]
    if held then
        return held.npc, held.mapID
    end
    local entry = raidName and self:BuildBossOrdinalIndex()[raidName:lower()]
    local level = entry and self:ExpansionLevelByName(entry.expansion)
    local list = level and pluginShared.RaidFinderNPCs and pluginShared.RaidFinderNPCs[level]
    if not list then return nil end
    local faction = type(UnitFactionGroup) == "function" and UnitFactionGroup("player") or nil
    local pick
    for _, npc in ipairs(list) do
        if not npc.faction or npc.faction == faction then pick = npc break end
    end
    pick = pick or list[1]
    local route = pluginShared.Route
    local mapID = pick.map and route and route.ResolveMap and route:ResolveMap(pick.map)
    if not (mapID and pick.x and pick.y) then mapID = nil end
    self.raidFinderPick[raidName] = { npc = pick, mapID = mapID }
    return pick, mapID
end

function Lockouts:RaidFinderTarget(raidName, difficulties, itemRef)
    local npc, mapID = self:RaidFinderNPC(raidName, difficulties)
    if not (npc and mapID) then return nil end
    return waypointTarget(mapID, npc.x, npc.y, string.format(L["lock_raid_finder_label"], npc.npc), itemRef)
end

-- Boss name (lowercased) -> Raid Finder wing name, scanned once from the game's LFG dungeon list so the
-- NPC's story option ("Tell me the one about <wing>") can be named without a hand-kept table.
function Lockouts:RaidFinderWingIndex()
    if self.finderWings then return self.finderWings end
    local index = {}
    self.finderWings = index
    if not (type(GetLFGDungeonInfo) == "function" and type(GetLFGDungeonNumEncounters) == "function"
        and type(GetLFGDungeonEncounterInfo) == "function") then
        return index
    end
    local raidSubtype = LFG_SUBTYPEID_RAID or 3
    for dungeonID = 1, 3000 do
        local ok, name, _, subtypeID, _, _, _, _, _, _, _, _, difficulty = pcall(GetLFGDungeonInfo, dungeonID)
        if ok and type(name) == "string" and name ~= "" and subtypeID == raidSubtype and (difficulty == 17 or difficulty == 7) then
            local okN, count = pcall(GetLFGDungeonNumEncounters, dungeonID)
            for i = 1, (okN and tonumber(count)) or 0 do
                local okE, bossName = pcall(GetLFGDungeonEncounterInfo, dungeonID, i)
                if okE and type(bossName) == "string" and bossName ~= "" then
                    index[bossName:lower()] = index[bossName:lower()] or name
                end
            end
        end
    end
    return index
end

function Lockouts:RaidFinderWing(bossName)
    return type(bossName) == "string" and self:RaidFinderWingIndex()[bossName:lower()] or nil
end

-- The Group Finder, where this season's Raid Finder is queued. w2tsgroupfinder
function Lockouts:GroupFinderLink()
    -- the game's own name for it in the player's language, so the row matches the button they click
    local label = type(_G.DUNGEONS_BUTTON) == "string" and _G.DUNGEONS_BUTTON ~= "" and _G.DUNGEONS_BUTTON
        or L["lock_group_finder"]
    return "|Hw2tsgroupfinder|h|cff40c0ff[" .. label .. "]|r|h"
end

-- Opens Blizzard's Group Finder on its Raid Finder side, the same call its own buttons make.
function Lockouts:OpenRaidFinder()
    local loader = C_AddOns and C_AddOns.LoadAddOn or _G.LoadAddOn
    if type(loader) == "function" then
        pcall(loader, "Blizzard_GroupFinder")
    end
    if type(_G.PVEFrame_ShowFrame) ~= "function" then
        return false
    end
    local ok = pcall(_G.PVEFrame_ShowFrame, "GroupFinderFrame", _G.RaidFinderFrame)
    return ok and true or false
end

function Lockouts:RaidFinderHint(raidName, difficulties, bossName)
    -- This season's raids have no queue NPC: they are queued from Blizzard's own Group Finder. Saying
    -- nothing at all there reads as "no way in", so the row names where the queue actually is.
    if self:IsRaidFinderOnly(difficulties) and self:IsCurrentSeasonInstance(raidName) then
        local text = "|cff80c0ff" .. string.format(L["lock_raid_finder_current"], self:GroupFinderLink())
        local season = self:RaidFinderWing(bossName)
        if season then
            text = text .. string.format(L["lock_raid_finder_wing"], season)
        end
        return text .. "|r"
    end
    local npc = self:RaidFinderNPC(raidName, difficulties)
    if not npc then return "" end
    local wing = self:RaidFinderWing(bossName)
    local text = "|cff80c0ff" .. string.format(L["lock_raid_finder_hint"], npc.npc, npc.where)
    if wing then
        text = text .. string.format(L["lock_raid_finder_wing"], wing)
    end
    return text .. "|r"
end

-- fallback = { mapID, x, y, title }: where to guide when Blizzard cannot track the source and there is
-- no raid entrance (vendor rows, world mounts)
-- Blizzard tracks one sourceID; the panel lists one source per look. Same look = same guide.
function Lockouts:VisualOfSource(sourceID)
    if type(sourceID) ~= "number" or not (C_TransmogCollection and type(C_TransmogCollection.GetSourceInfo) == "function") then
        return nil
    end
    self.visualBySource = self.visualBySource or {}
    local cached = self.visualBySource[sourceID]
    if cached ~= nil then
        return cached or nil
    end
    local ok, info = guarded(C_TransmogCollection.GetSourceInfo, sourceID)
    local raw = ok and type(info) == "table" and info.visualID or nil
    -- the look's id is compared and kept as a table key, so a withheld one is dropped rather than
    -- stored, and not remembered as "none" either: the game answers once the fight is over, and this
    -- is asked again then rather than pointing the guide at nothing for the rest of the session
    local visualID = self.Plain and self:Plain(raw) or plain(raw)
    if visualID == nil and raw ~= nil then
        return nil
    end
    self.visualBySource[sourceID] = visualID or false
    return visualID or nil
end

-- The item being guided right now: the route's item, else Blizzard's tracked item.
function Lockouts:GuidedRef()
    local route = pluginShared.Route
    local ref = (route and route.target and route.target.item) or self.guideTarget
    if not ref then
        return nil
    end
    local appearanceType = pluginShared.Tracking and pluginShared.Tracking.APPEARANCE or 0
    local visualID = ref.type == appearanceType and self:VisualOfSource(ref.id) or nil
    return { type = ref.type, id = ref.id, visualID = visualID, raidName = ref.raidName }
end

-- raidName: the raid whose row is asking. Another source of the same appearance only counts as guided
-- when it drops in the raid being guided, so a shared model does not light up every raid that has it.
function Lockouts:IsGuided(trackType, id, raidName)
    local ref = self:GuidedRef()
    if not ref or ref.type ~= trackType then
        return false
    end
    if ref.id == id then
        return true
    end
    if ref.raidName and raidName and ref.raidName ~= raidName then
        return false
    end
    return ref.visualID ~= nil and ref.visualID == self:VisualOfSource(id)
end

-- The raw target the route follows for a starred row: Blizzard's tracker for the item, the raid's
-- entrance, the Raid Finder NPC or the shipped location. "" when there is nothing to guide to.
function Lockouts:TrackTarget(trackType, id, raidName, difficulties, fallback)
    -- LFR-only drops from legacy raids are queued at the Raid Finder NPC, not at the entrance
    local itemRef = { type = trackType, id = id }
    local finderTarget = self:RaidFinderTarget(raidName, difficulties, itemRef)
    if finderTarget then
        return finderTarget
    end
    local tracking = pluginShared.Tracking
    if not (tracking and tracking:CanTrack(trackType, id)) or self:HasSeasonDuplicate(raidName) then
        -- Blizzard cannot track this source, or its tracker would route to the current-season copy of a
        -- legacy dungeon; use the original version's entrance instead
        local entrance = self:EntranceTarget(raidName, itemRef)
        if entrance == "" and fallback and fallback.mapID and fallback.x and fallback.y then
            return waypointTarget(fallback.mapID, fallback.x, fallback.y, fallback.title or L["lock_location_lower"], itemRef)
        end
        return entrance
    end
    local where = ""
    if fallback and fallback.mapID and fallback.x and fallback.y then
        -- the shipped location rides along: the guide routes there itself for single items, and a
        -- raid drop keeps it as the last resort for when its raid has no entrance and the game
        -- refuses the track (raid drops with an entrance still route to the entrance)
        where = ":" .. fallback.mapID .. ":" .. roundCoord(fallback.x) .. ":" .. roundCoord(fallback.y) .. ":" .. (fallback.zoneOnly and 1 or 0)
    end
    return "w2tstrack:" .. trackType .. ":" .. id .. ":" .. (raidName or "") .. where
end

function Lockouts:ClearGuideWaypoint()
    if self.guideWaypoint and self:HasTomTom() and type(_G.TomTom.RemoveWaypoint) == "function" then
        pcall(_G.TomTom.RemoveWaypoint, _G.TomTom, self.guideWaypoint)
    end
    self.guideWaypoint = nil
end

local function sameHop(a, mapID, x, y)
    if not (a and a.mapID == mapID and a.x and a.y) then
        return false
    end
    return math.abs(a.x - x) < 0.002 and math.abs(a.y - y) < 0.002
end

-- Timeline of guide milestones for /am diag ("click +0.0s, fallback +3.1s, hop +6.4s").
function Lockouts:Mark(event)
    local t = type(GetTime) == "function" and GetTime() or 0
    if event == "click" or not self.timeline then
        self.timeline = { t0 = t, events = {} }
    end
    local evs = self.timeline.events
    local last = evs[#evs]
    if last and last.event == event then return end
    evs[#evs + 1] = { event = event, at = t - self.timeline.t0 }
    if #evs > 16 then table.remove(evs, 1) end
end

function Lockouts:Timeline()
    if not self.timeline then return "none" end
    local parts = {}
    for _, e in ipairs(self.timeline.events) do
        parts[#parts + 1] = string.format("%s +%.1fs", e.event, e.at)
    end
    return table.concat(parts, ", ")
end

-- Blizzard's router can loop (Orgrimmar says "take the Ashran portal", Ashran says "take the Orgrimmar
-- portal") when the destination is on the continent the player already stands on. When its next hop
-- would leave this world by portal, boat or zeppelin, guide with the addon's own destination instead:
-- the shipped vendor coordinates, or the raid entrance.
-- Where the guide routes on its own for a vendor or world item: the shipped location, or the zone's
-- centre when only the zone is known.
function Lockouts:ShippedTarget(target)
    local fb = target and target.fallback
    if not (fb and fb.mapID and fb.x and fb.y) then
        return nil
    end
    local acq = pluginShared.Acquisition
    local place = acq and acq.PlaceTitle and acq:PlaceTitle(fb.title or L["lock_location_title"], fb.mapID) or fb.title or L["lock_location_title"]
    local title = fb.title or place
    if fb.zoneOnly and not fb.title then
        title = string.format(L["lock_zone_label"], acq and acq.PlaceTitle and acq:PlaceTitle(nil, fb.mapID) or place)
    end
    return { mapID = fb.mapID, x = fb.x, y = fb.y, title = title, zoneOnly = fb.zoneOnly or nil }
end

-- True while standing in the zone a zone-only location belongs to.
function Lockouts:InShippedZone(mapID)
    local route = pluginShared.Route
    if not (mapID and route and route.ZoneMap and type(C_Map) == "table" and type(C_Map.GetBestMapForUnit) == "function") then
        return false
    end
    local ok, playerMap = pcall(C_Map.GetBestMapForUnit, "player")
    if not (ok and playerMap) then
        return false
    end
    return route:ZoneMap(playerMap) == route:ZoneMap(mapID)
end

function Lockouts:StartGuideTicker()
    if self.guideTicker or not (type(C_Timer) == "table" and type(C_Timer.NewTicker) == "function") then
        return
    end
    self.guideTicker = C_Timer.NewTicker(0.3, function()
        if not Lockouts.guideTarget then
            Lockouts:StopGuideTicker()
            return
        end
        Lockouts:RouteGuideToTomTom(0)
    end)
end

function Lockouts:StopGuideTicker()
    if self.guideTicker then
        pcall(self.guideTicker.Cancel, self.guideTicker)
        self.guideTicker = nil
    end
end

function Lockouts:MarkSettling(seconds)
    self.settleUntil = (type(GetTime) == "function" and GetTime() or 0) + (seconds or 4)
end

function Lockouts:IsSettling()
    return self.settleUntil ~= nil and type(GetTime) == "function" and GetTime() < self.settleUntil
end

function Lockouts:SaveGuideTarget(target)
    if not AppearanceMaticDB then
        return
    end
    if target then
        local fb = target.fallback
        AppearanceMaticDB.guide = { type = target.type, id = target.id, raidName = target.raidName,
            fallbackMap = fb and fb.mapID, fallbackX = fb and fb.x, fallbackY = fb and fb.y, fallbackZoneOnly = fb and fb.zoneOnly or nil }
    else
        AppearanceMaticDB.guide = nil
    end
end

-- A starred item was collected: end whichever guide or route was leading to it.
-- Stars on things already owned are leftovers; clear them so the panel and the studio filter only hold work left to do.
function Lockouts:PruneCollectedFavorites()
    local favorites = pluginShared.Favorites
    if not favorites then
        return 0
    end
    local removed = 0
    if C_TransmogCollection and type(C_TransmogCollection.GetAppearanceSources) == "function" then
        for _, visualID in ipairs(favorites:All()) do
            local ok, sources = guarded(C_TransmogCollection.GetAppearanceSources, visualID)
            if ok and type(sources) == "table" then
                for _, src in ipairs(sources) do
                    if type(src) == "table" and src.isCollected then
                        favorites:Set(visualID, false)
                        removed = removed + 1
                        break
                    end
                end
            end
        end
    end
    if C_MountJournal and type(C_MountJournal.GetMountIDs) == "function" then
        local ok, ids = guarded(C_MountJournal.GetMountIDs)
        if ok and type(ids) == "table" then
            for _, mountID in ipairs(ids) do
                if favorites:MountIsFavorite(mountID) then
                    local info = { guarded(C_MountJournal.GetMountInfoByID, mountID) }
                    if info[1] and info[12] then
                        favorites:MountSet(mountID, false)
                        removed = removed + 1
                    end
                end
            end
        end
    end
    if removed > 0 then
        tell(string.format(L["lock_stars_removed"], removed, removed == 1 and "" or "s"))
        self:ScheduleRebuild()
    end
    -- the collection has settled: from here a routed raid with nothing starred left may leave the route
    self.routeSettled = true
    return removed
end

function Lockouts:OnTrackedCollected(trackType, id)
    local stopped = false
    if self.guideTarget and self.guideTarget.type == trackType and self.guideTarget.id == id then
        local tracking = pluginShared.Tracking
        if tracking and tracking.IsTracking and tracking:IsTracking(trackType, id) and tracking.Toggle then
            pcall(tracking.Toggle, tracking, trackType, id)
        end
        self:StopGuide()
        stopped = true
    end
    local route = pluginShared.Route
    local ref = route and route.target and route.target.item
    if ref and ref.type == trackType and ref.id == id then
        route:Stop()
        stopped = true
    end
    if stopped then
        tell(L["lock_collected_guide_ended"])
    end
    -- collected means done: drop the star so the item leaves the panel and the studio filter
    local favorites = pluginShared.Favorites
    local tracking = pluginShared.Tracking
    if favorites then
        if trackType == (tracking and tracking.MOUNT or 1) then
            if favorites.MountIsFavorite and favorites:MountIsFavorite(id) and favorites.MountSet then
                favorites:MountSet(id, false)
                tell(L["lock_mount_collected_star_removed"])
            end
        else
            local visualID = self:VisualOfSource(id)
            if visualID and favorites:IsFavorite(visualID) then
                favorites:Set(visualID, false)
                tell(L["lock_appearance_collected_star_removed"])
            end
        end
    end
    -- a route item collected: off the route, and the guide moves on to the next route entry (a raid
    -- with nothing starred left drops off the route in the next build)
    local routeList = pluginShared.RouteList
    if routeList then
        local isMount = trackType == (tracking and tracking.MOUNT or 1)
        local routeID = isMount and id or self:VisualOfSource(id)
        local onRoute = (routeID ~= nil) and routeList:Remove(isMount, routeID) or false
        -- only a route item (or the item the route was guiding) moves the guide on; /am stop holds it
        if (onRoute or stopped) and not self.routePaused and (stopped or not self:GuidedRef()) and routeList:Count() > 0 then
            local function advance() Lockouts:GuideRouteNext(true) end
            if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
                C_Timer.After(1, advance)
            else
                advance()
            end
        end
    end
    self:ScheduleRebuild()
    return stopped
end

function Lockouts:CurrentCharKey()
    local charName = type(UnitName) == "function" and UnitName("player") or nil
    local realmName = type(GetRealmName) == "function" and GetRealmName() or nil
    if not (charName and realmName) then return nil end
    return charName .. "-" .. realmName
end

-- Raid difficulty IDs (DifficultyUtil.ID): 3 10N, 4 25N, 5 10H, 6 25H, 7 legacy LFR, 14 Normal, 15 Heroic, 16 Mythic, 17 LFR.
local RAID_DIFFICULTY_IDS = { 3, 4, 5, 6, 7, 14, 15, 16, 17 }

function Lockouts:DifficultyIdByName(name)
    if not self.difficultyIds then
        self.difficultyIds = {}
        if type(GetDifficultyInfo) == "function" then
            for _, id in ipairs(RAID_DIFFICULTY_IDS) do
                local ok, diffName = pcall(GetDifficultyInfo, id)
                if ok and type(diffName) == "string" and not self.difficultyIds[diffName] then
                    self.difficultyIds[diffName] = id
                end
            end
        end
    end
    return name and self.difficultyIds[name] or nil
end

-- Two difficulties share a weekly lockout when they map to the same group:
--   Cataclysm and Mists raids before Siege of Orgrimmar: 10/25 Normal/Heroic are one lock
--   Wrath and earlier: 10-player sizes share (10N/10H), 25-player sizes share (25N/25H), 10 and 25 are separate
--   LFR is its own lock; Normal/Heroic/Mythic (Siege of Orgrimmar onward) are each their own lock
function Lockouts:LockoutGroup(raidName, difficultyID)
    if not difficultyID then return nil end
    if difficultyID == 7 or difficultyID == 17 then return "lfr" end
    if difficultyID >= 3 and difficultyID <= 6 then
        local entry = raidName and self:BuildBossOrdinalIndex()[raidName:lower()]
        local level = entry and self:ExpansionLevelByName(entry.expansion)
        if level == 3 or level == 4 then return "shared" end
        return (difficultyID == 3 or difficultyID == 5) and "10" or "25"
    end
    return tostring(difficultyID)
end

-- Is this boss already down on the character for the lock that the given item difficulty would use?
-- Returns down, the lockout entry that holds the lock (or the first live entry in the group), and whether any live entry exists.
function Lockouts:BossLockState(snapshot, raidName, bossName, difficultyName, now)
    local wantID = self:DifficultyIdByName(difficultyName)
    local wantGroup = self:LockoutGroup(raidName, wantID)
    local holder, live
    for _, entry in ipairs(snapshot and snapshot.raids or {}) do
        if entry.name == raidName and entry.resetAt and entry.resetAt > now then
            local entryID = entry.difficultyID or self:DifficultyIdByName(entry.difficulty)
            local group = self:LockoutGroup(raidName, entryID)
            local sameLock = (wantGroup == nil) or (group ~= nil and group == wantGroup)
            if sameLock then
                live = live or entry
                if entry.bosses and entry.bosses[bossName] then
                    holder = entry
                    break
                end
            end
        end
    end
    return holder ~= nil, holder or live, live ~= nil
end

-- True when every starred boss in this raid is already down on this character for every difficulty it drops on.
function Lockouts:RaidDoneForCurrentChar(raidName, raid)
    local store = AppearanceMaticDB and AppearanceMaticDB.lockouts
    local charKey = self:CurrentCharKey()
    local snapshot = store and charKey and store[charKey]
    if not (snapshot and type(snapshot.raids) == "table" and raid and raid.bossOrder and #raid.bossOrder > 0) then
        return false
    end
    local now = time()
    for _, bossName in ipairs(raid.bossOrder) do
        local boss = raid.bosses[bossName]
        local difficulties = boss and boss.difficulties or {}
        if next(difficulties) == nil then
            if not self:BossLockState(snapshot, raidName, bossName, nil, now) then return false end
        else
            for difficultyName in pairs(difficulties) do
                if not self:BossLockState(snapshot, raidName, bossName, difficultyName, now) then return false end
            end
        end
    end
    return true
end

function Lockouts:StopGuide()
    self.guideTarget = nil
    self.noRouteWarned = nil
    self:SaveGuideTarget(nil)
    self.guideHop = nil
    self.trackMisses = 0
    if self.guideRouted and pluginShared.Route then
        pluginShared.Route:Stop()
    end
    self.guideRouted = nil
    self.guideGateKey = nil
    self:StopGuideTicker()
    self:ClearGuideWaypoint()
    if pluginShared.GuideArrow then
        pluginShared.GuideArrow:Clear()
    end
end

-- Blizzard's tracker names the portal, boat or zeppelin it sends the player through. Keep every one
-- as a gateway so the route graph grows from the game's own data.
function Lockouts:LearnGateway(mapID, x, y, text)
    if not (AppearanceMaticDB and mapID and x and y and type(text) == "string") then
        return
    end
    local lowered = text:lower()
    local kind = (lowered:find("portal", 1, true) and "portal") or (lowered:find("zeppelin", 1, true) and "zeppelin")
        or (lowered:find("boat", 1, true) or lowered:find("ship", 1, true)) and "boat" or nil
    if not kind then
        return
    end
    local destName = text:match(" to (.+)$")
    if not destName then
        return
    end
    destName = destName:gsub("%s*%(.-%)%s*$", ""):gsub("%.$", "")
    local learned = AppearanceMaticDB.learnedGateways
    if type(learned) ~= "table" then
        learned = {}
        AppearanceMaticDB.learnedGateways = learned
    end
    local key = mapID .. ":" .. math.floor(x * 1000) .. ":" .. math.floor(y * 1000) .. ":" .. destName:lower()
    if learned[key] then
        return
    end
    learned[key] = { mapID = mapID, x = x, y = y, dest = destName, label = text, kind = kind, faction = pluginShared.Route and pluginShared.Route:PlayerFaction() or nil }
    if pluginShared.Route then
        pluginShared.Route.gateways = nil
    end
end

function Lockouts:RouteGuideToTomTom(attempt)
    local target = self.guideTarget
    local tracking = pluginShared.Tracking
    if not (target and tracking) then
        return
    end
    if self:IsSettling() then
        return
    end
    if not tracking:IsTracking(target.type, target.id) then
        -- Blizzard answers "not tracked" briefly around loading screens; only a sustained no ends the guide
        self.trackMisses = (self.trackMisses or 0) + 1
        if self.trackMisses >= 3 then
            -- the game dropped the tracking for good; the trip continues to the entrance or the
            -- shipped spot without it rather than dying with no word
            local dead = self.guideTarget
            self:StopGuide()
            self:RouteUntracked(dead and dead.raidName, dead and dead.fallback)
        end
        return
    end
    self.trackMisses = 0
    if target.raidName and type(GetInstanceInfo) == "function" then
        local okI, instName, instType = pcall(GetInstanceInfo)
        if okI and type(instName) == "string" and (instType == "raid" or instType == "party") and instName:lower() == target.raidName:lower() then
            -- standing inside the raid: the guide has done its job, no arrow; tracking stays on for the collected alert
            if self.guideRouted and pluginShared.Route then
                pluginShared.Route:Stop()
                self.guideRouted = nil
            end
            self.guideHop = nil
            self:ClearGuideWaypoint()
            if pluginShared.GuideArrow then
                pluginShared.GuideArrow:Clear()
            end
            return
        end
    end
    local route = pluginShared.Route
    if target.raidName and route and route.Start then
        -- raid drops: the guide is the addon's own route to the raid entrance; Blizzard's tracker
        -- stays on only for the objective tracker, the pin and the collected alert
        local entrance = self:LegacyEntrance(target.raidName)
        if entrance and entrance.mapID then
            -- a gate that is closed right now (faction control, campaign travel) rides in the title so
            -- the arrow and the chat line say what to do first; a gate flipping restarts with fresh text
            local gate = self.GateState and self:GateState(target.raidName)
            local gateKey = gate and gate.state or "none"
            local title = target.raidName .. " entrance"
            if gate and gate.state == "blocked" then
                title = title .. " (" .. gate.text .. ")"
            end
            local flipped = self.guideGateKey ~= nil and self.guideGateKey ~= gateKey
            self.guideGateKey = gateKey
            if not self.guideRouted or not route.target or flipped then
                self.guideRouted = true
                self.guideHop = nil
                self:ClearGuideWaypoint()
                self:Mark("entrance route")
                route:Start(entrance.mapID, entrance.x, entrance.y, title, target.raidName, { type = target.type, id = target.id })
            end
            return
        end
    end
    local shipped = self:ShippedTarget(target)
    local inShippedZone = shipped and shipped.zoneOnly and self:InShippedZone(shipped.mapID)
    if shipped and route and route.Start and not inShippedZone then
        -- vendor and world items: the guide routes itself to the shipped location, Blizzard's tracker
        -- stays on for the objective tracker, the pin and the collected alert. A zone-only location is
        -- routed to the zone; inside it Blizzard's tracker knows the exact spot and takes over below.
        if not self.guideRouted or not route.target then
            self.guideRouted = true
            self.guideHop = nil
            self:ClearGuideWaypoint()
            self:Mark("shipped route")
            route:Start(shipped.mapID, shipped.x, shipped.y, shipped.title, nil, { type = target.type, id = target.id })
        end
        return
    end
    local mapID, x, y, text, result = tracking:NextWaypoint(target.type, target.id)
    if inShippedZone and mapID and route and route.LeavesWorld and route:LeavesWorld(text) then
        -- inside the item's own zone a portal off it is Blizzard's router looping; keep the zone route
        mapID = nil
    end
    if mapID and route and route.CanFlyToTarget then
        local entrance = self:LegacyEntrance(target.raidName)
        local lowered = type(text) == "string" and text:lower() or ""
        if entrance and entrance.mapID and lowered:find("fly to", 1, true) and route:CanFlyToTarget(entrance.mapID, entrance.x, entrance.y) then
            mapID, x, y, text = entrance.mapID, entrance.x, entrance.y, (target.raidName or "instance") .. " entrance"
        end
    end
    if self.guideRouted and not mapID then
        -- the entrance route owns the pin and the arrow while Blizzard has no route for this item
        return
    end
    if mapID and x and y then
        if self.guideRouted then
            self.guideRouted = nil
            if pluginShared.Route then
                pluginShared.Route:Stop()
            end
            self.guideHop = nil
        end
        if pluginShared.GuideArrow and not pluginShared.GuideArrow:IsActive() then
            self.guideHop = nil
        end
        if sameHop(self.guideHop, mapID, x, y) then
            self:EnsureGameArrow(mapID, x, y)
            return
        end
        self.guideHop = { mapID = mapID, x = x, y = y }
        self:Mark("blizzard hop")
        self:LearnGateway(mapID, x, y, text)
        self:SetGameWaypoint(mapID, x, y)
        if pluginShared.GuideArrow then
            local lowered = type(text) == "string" and text:lower() or ""
            local isHop = lowered:find("portal", 1, true) or lowered:find("fly to", 1, true) or lowered:find("boat", 1, true) or lowered:find("zeppelin", 1, true)
            local where = pluginShared.Route and pluginShared.Route.DescribeGateway and pluginShared.Route:DescribeGateway(mapID, x, y)
            local label = text or L["lock_guide_target_fallback"]
            if where and not label:find(where, 1, true) then
                label = label .. " (" .. where .. ")"
            end
            local taxiDest = type(text) == "string" and text:match("^[Ff]ly to ([^,]+)") or nil
            pluginShared.GuideArrow:SetHop({ mapID = mapID, x = x, y = y, text = label, arriveYards = (isHop or where) and 40 or nil, taxiDest = taxiDest })
        end
        return
    end
    attempt = (attempt or 0) + 1
    -- Blizzard's routing is briefly empty after login and loading screens; keep asking while it says
    -- DataPending, but a flat Failure only gets a short grace before the table router takes over.
    local pendingResult = Enum and Enum.ContentTrackingResult and Enum.ContentTrackingResult.DataPending or 1
    local maxAttempts = (result == pendingResult) and 6 or 1
    self:Mark("blizzard " .. tostring(result))
    if attempt <= maxAttempts and type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        C_Timer.After(0.5, function()
            Lockouts:RouteGuideToTomTom(attempt)
        end)
        return
    end
    if not self.guideRouted then
        local entry = self:LegacyEntrance(target.raidName)
        local route = pluginShared.Route
        if entry and entry.mapID and route and route.Start then
            self.guideRouted = true
            self:Mark("fallback route")
            route:Start(entry.mapID, entry.x, entry.y, (target.raidName or "instance") .. " entrance", target.raidName)
        elseif not self.noRouteWarned then
            self.noRouteWarned = true
            tell(L["lock_no_route_no_entrance"])
        end
    end
end

-- Blizzard keeps tracking across /reload; pick the guide back up for a starred item without another click.
function Lockouts:ResumeGuide()
    if self.guideTarget then
        return false
    end
    local saved = AppearanceMaticDB and AppearanceMaticDB.guide
    if type(saved) == "table" and type(saved.id) == "number" then
        local fallback = saved.fallbackMap and { mapID = saved.fallbackMap, x = saved.fallbackX, y = saved.fallbackY, zoneOnly = saved.fallbackZoneOnly or nil } or nil
        self.guideTarget = { type = saved.type, id = saved.id, raidName = saved.raidName, fallback = fallback }
        local route = pluginShared.Route
        if route and route.target then
            -- the saved route already resumed for this guide: it owns the arrow, do not start it twice
            self.guideRouted = true
        end
        local tracking = pluginShared.Tracking
        if tracking and not tracking:IsTracking(saved.type, saved.id) and tracking:CanTrack(saved.type, saved.id) then
            tracking:Toggle(saved.type, saved.id)
        end
        if type(C_SuperTrack) == "table" and type(C_SuperTrack.SetSuperTrackedContent) == "function" then
            pcall(C_SuperTrack.SetSuperTrackedContent, saved.type, saved.id)
        end
        self:RouteGuideToTomTom(0)
        self:StartGuideTicker()
        self:ScheduleRebuild()
        return true
    end
    -- only the route starts a guide: an item tracked from Blizzard's own collections UI is never
    -- adopted, so the panel does not reorder itself around something the player did not route
    return false
end

-- The route controls: Add to route in the panel's cream, Remove from route in red
local LINK_ADD, LINK_REMOVE = "|cfffff8dc", "|cffff6060"

-- A single item's route control after its row: Add to route, or Remove from route while it is on.
-- w2tsroute:<a|m>:<visualID|mountID>:<sourceID or 0>
function Lockouts:RouteItemLink(isMount, id, sourceID)
    local routeList = pluginShared.RouteList
    if not (routeList and id) then
        return ""
    end
    local onRoute = routeList:Has(isMount, id)
    local label = onRoute and L["route_tooltip_remove"] or L["route_tooltip_add"]
    return " |Hw2tsroute:" .. (isMount and "m" or "a") .. ":" .. id .. ":" .. (sourceID or 0) .. "|h" .. (onRoute and LINK_REMOVE or LINK_ADD) .. "[" .. label .. "]|r|h"
end

-- The raid's route control after its header line: Add to route, or Remove from route while it is on.
-- w2tsrouteraid:<raid name>
function Lockouts:RouteRaidLink(raidName)
    local routeList = pluginShared.RouteList
    if not (routeList and type(raidName) == "string" and raidName ~= "") then
        return ""
    end
    local onRoute = routeList:HasRaid(raidName)
    local label = onRoute and L["route_tooltip_remove"] or L["route_tooltip_add"]
    return "  |Hw2tsrouteraid:" .. raidName .. "|h" .. (onRoute and LINK_REMOVE or LINK_ADD) .. "[" .. label .. "]|r|h"
end

-- The switch beside the Routing header. Off rests the route's guide (arrow, pin, TomTom mute) while
-- the route keeps its entries; on brings it back. A route change also turns it on. The choice is
-- saved, so a reload or the next login finds the switch where it was left.
-- The last few times the switch moved, for /am diag: when, which way, and who moved it
local SWITCH_LOG_ENTRIES = 8
function Lockouts:NoteSwitch(paused, why)
    local log = self.switchLog or {}
    self.switchLog = log
    local now = type(GetTime) == "function" and GetTime() or 0
    log[#log + 1] = string.format("%.1f %s %s", now, paused and "off" or "on", tostring(why))
    while #log > SWITCH_LOG_ENTRIES do
        table.remove(log, 1)
    end
end

function Lockouts:SetRoutePaused(paused)
    self.routePaused = paused and true or nil
    if AppearanceMaticDB then
        AppearanceMaticDB.routePaused = self.routePaused
    end
    self:NoteSwitch(self.routePaused, self.Asker and self:Asker(3) or "?")
end

-- At login: the switch as it was left
function Lockouts:LoadRoutePause()
    self.routePaused = (AppearanceMaticDB and AppearanceMaticDB.routePaused) and true or nil
    self:NoteSwitch(self.routePaused, "login")
    return self.routePaused
end

function Lockouts:PauseRoute()
    self:EndGuide()
    self:SetRoutePaused(true)
    self:ScheduleRebuild()
end

function Lockouts:ResumeRoute()
    self:SetRoutePaused(nil)
    if self:GuideIdle() then
        self:GuideRouteNext(false)
    end
    self:ScheduleRebuild()
end

function Lockouts:ToggleRoutePause()
    if self.routePaused then
        self:ResumeRoute()
        tell(L["lock_routing_resumed"])
    else
        self:PauseRoute()
        tell(L["lock_routing_stopped"])
    end
    return self.routePaused and true or false
end

-- The raid name on its header line, a link so hovering it tells where the raid is. w2tsraid:<name>
function Lockouts:RaidTitleLink(raidName)
    return "|Hw2tsraid:" .. raidName .. "|h" .. raidName .. "|h"
end

function Lockouts:IsRaidTitleLink(link)
    return type(link) == "string" and link:sub(1, 9) == "w2tsraid:"
end

-- The header tooltip: the raid name, its zone (where the entrance is), its expansion
function Lockouts:FillRaidTooltip(tooltip, raidName)
    tooltip:SetText(raidName, 1, 0.82, 0)
    local zone = self:RaidZoneName(raidName)
    if zone then
        tooltip:AddLine(zone, 1, 1, 1)
    end
    local expansion = self:RaidExpansionName(raidName)
    if expansion then
        tooltip:AddLine(expansion, 0.7, 0.7, 0.7)
    end
    local gateText, gr, gg, gb
    if self.GateLine then
        gateText, gr, gg, gb = self:GateLine(raidName)
    end
    if gateText then
        tooltip:AddLine(gateText, gr, gg, gb, true)
    end
end

-- The line under the Routing section that empties the route. w2tsrouteclear
function Lockouts:RouteClearLink()
    return "|Hw2tsrouteclear|h|cffff6060[" .. L["lock_route_clear"] .. "]|r|h"
end

function Lockouts:HandleRouteLink(link)
    if type(link) ~= "string" then
        return false
    end
    if link == "w2tsrouteclear" then
        self:ConfirmClearRoute()
        return true
    end
    if link == "w2tsgroupfinder" then
        self:OpenRaidFinder()
        return true
    end
    local raidName = link:match("^w2tsrouteraid:(.+)$")
    if raidName then
        self:ToggleRouteRaid(raidName)
        if self.ClearSearch then
            self:ClearSearch()
        end
        return true
    end
    local kind, id, sourceID = link:match("^w2tsroute:(%a):(%d+):(%d+)$")
    if not kind then
        return false
    end
    self:ToggleRouteItem(kind == "m", tonumber(id), self:RouteItemLabel(kind == "m", tonumber(id), tonumber(sourceID)))
    if self.ClearSearch then
        self:ClearSearch()
    end
    return true
end

-- What a chat line calls a route item: the mount's name, the appearance's item link, else "item"
function Lockouts:RouteItemLabel(isMount, id, sourceID)
    if isMount and id and type(C_MountJournal) == "table" and type(C_MountJournal.GetMountInfoByID) == "function" then
        local ok, name = pcall(C_MountJournal.GetMountInfoByID, id)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    elseif not isMount and sourceID and sourceID > 0 and self.ResolveItemDisplay then
        local ok, link = pcall(self.ResolveItemDisplay, self, sourceID)
        if ok and type(link) == "string" and link ~= "" then
            return link
        end
    end
    return L["lock_route_item_fallback"]
end

-- Chat feedback for every route change, so a click never goes unanswered. Route bookkeeping is the
-- player's own: told to them alone, never emoted; the Social voices are for collected items.
function Lockouts:AnnounceRoute(added, label)
    if added then
        tell(string.format(L["lock_route_added_fmt"], tostring(label)))
    else
        tell(string.format(L["lock_route_removed_fmt"], tostring(label)))
    end
end

-- Nothing is being guided: no tracked guide, no route arrow
function Lockouts:GuideIdle()
    return not self:GuidedRef() and not (pluginShared.Route and pluginShared.Route.target)
end

-- True while the guide leads to a route item: the mount itself, or any source of the appearance
function Lockouts:IsRouteItemGuided(isMount, id)
    local ref = self:GuidedRef()
    if not (ref and id) then
        return false
    end
    local tracking = pluginShared.Tracking
    local mountType = tracking and tracking.MOUNT or 1
    if isMount then
        return ref.type == mountType and ref.id == id
    end
    return ref.type ~= mountType and ref.visualID == id
end

-- True while the guide leads into this raid: a drop from it, or the entrance route
function Lockouts:IsGuidingRaid(raidName)
    if type(raidName) ~= "string" then
        return false
    end
    local wanted = raidName:lower()
    local target = self.guideTarget
    if target and type(target.raidName) == "string" and target.raidName:lower() == wanted then
        return true
    end
    local route = pluginShared.Route
    local rt = route and route.target
    if rt then
        local name = rt.raidName or (type(rt.title) == "string" and rt.title:gsub(" entrance$", "")) or nil
        if type(name) == "string" and name:lower() == wanted then
            return true
        end
    end
    return false
end

-- Ends whatever is guiding: Blizzard's tracking of the item, the saved guide and the route arrow
function Lockouts:EndGuide()
    local target = self.guideTarget
    local tracking = pluginShared.Tracking
    if target and tracking and tracking.IsTracking and tracking.Toggle and tracking:IsTracking(target.type, target.id) then
        pcall(tracking.Toggle, tracking, target.type, target.id)
    end
    if target and type(C_SuperTrack) == "table" and type(C_SuperTrack.ClearSuperTrackedContent) == "function" then
        pcall(C_SuperTrack.ClearSuperTrackedContent)
    end
    self:StopGuide()
    if pluginShared.Route and pluginShared.Route.Stop then
        pluginShared.Route:Stop()
    end
end

-- Adds or removes an item. Adding while nothing is being guided starts the guide on the nearest
-- route entry; taking the guided item off the route moves the guide to the next entry.
function Lockouts:ToggleRouteItem(isMount, id, label)
    local routeList = pluginShared.RouteList
    if not (routeList and id) then
        return false
    end
    local wasGuided = self:IsRouteItemGuided(isMount, id)
    -- Toggle answers false both for "taken off" and for "refused, you already have it"; only a real
    -- removal is announced, the refusal already said its piece
    local wasOnRoute = routeList:Has(isMount, id)
    local added = routeList:Toggle(isMount, id)
    self:SetRoutePaused(nil)
    if added then
        self:AnnounceRoute(true, label or self:RouteItemLabel(isMount, id))
        if self:GuideIdle() then
            self:GuideRouteNext(false)
        end
    elseif wasOnRoute then
        self:AnnounceRoute(false, label or self:RouteItemLabel(isMount, id))
        if wasGuided then
            self:EndGuide()
            self:GuideRouteNext(false)
        end
    end
    return added
end

-- Adds or removes a whole raid or dungeon. Adding while nothing is being guided starts the guide on
-- the nearest route entry; taking the guided raid off moves the guide to the next entry.
function Lockouts:ToggleRouteRaid(raidName)
    local routeList = pluginShared.RouteList
    if not (routeList and type(raidName) == "string" and raidName ~= "") then
        return false
    end
    local wasGuided = self:IsGuidingRaid(raidName)
    local added = routeList:ToggleRaid(raidName)
    self:SetRoutePaused(nil)
    self:AnnounceRoute(added, raidName)
    if added then
        if self:GuideIdle() then
            self:GuideRouteNext(false)
        end
    elseif wasGuided then
        self:EndGuide()
        self:GuideRouteNext(false)
    end
    self:ScheduleRebuild()
    return added
end

-- Empties the route and ends the guide
function Lockouts:ClearRoute()
    local routeList = pluginShared.RouteList
    if routeList then
        routeList:Clear()
    end
    self:EndGuide()
    self:ScheduleRebuild()
    return true
end

local CLEAR_ROUTE_POPUP = "APPEARANCEMATIC_ROUTE_CLEAR"
if type(StaticPopupDialogs) == "table" and not StaticPopupDialogs[CLEAR_ROUTE_POPUP] then
    StaticPopupDialogs[CLEAR_ROUTE_POPUP] = {
        text = L["lock_route_clear_confirm"],
        button1 = YES,
        button2 = NO,
        OnAccept = function() Lockouts:ClearRoute() end,
        timeout = 0,
        whileDead = true,
        hideOnEscape = true,
        preferredIndex = 3,
    }
end

function Lockouts:ConfirmClearRoute()
    if type(StaticPopup_Show) == "function" and type(StaticPopupDialogs) == "table" and StaticPopupDialogs[CLEAR_ROUTE_POPUP] then
        StaticPopup_Show(CLEAR_ROUTE_POPUP)
        return true
    end
    return self:ClearRoute()
end

-- The panel row's own target, remembered while the panel builds, is what the route follows. A raid
-- row's key carries its raid: the same model can drop in several raids (one visualID, a source per
-- raid), and an unscoped key let whichever raid rendered last overwrite the routed raid's target, so
-- routing Atal'Dazar guided to The Underrot's entrance.
local function routeKey(isMount, id, raidName)
    return (isMount and "m" or "a") .. ":" .. tostring(id) .. (raidName and (":" .. raidName) or "")
end

function Lockouts:NoteRouteLink(isMount, id, target, raidName)
    if not (id and type(target) == "string" and target ~= "") then
        return
    end
    local raw = target:match("|H(.-)|h") or target
    if raw:sub(1, 4) == "w2ts" then
        self.routeLinks = self.routeLinks or {}
        self.routeLinks[routeKey(isMount, id, raidName)] = raw
    end
end

-- A dry build of the panel: fills routeLinks and routeOrder without touching the shown lines
function Lockouts:DryBuild()
    if not self.BuildContent then
        return false
    end
    if not self.panel then
        self:EnsurePanel()
    end
    if not self.content then
        return false
    end
    local savedCapture, savedHeight, savedCount = self.captureLines, self.contentHeight, self.lineCount
    self.captureLines = {}
    local ok = reported(self.BuildContent, self)
    self.captureLines, self.contentHeight, self.lineCount = savedCapture, savedHeight, savedCount
    return ok
end

-- Follows a raw row target the way a click on the row would
function Lockouts:FollowTarget(raw)
    if type(raw) ~= "string" or raw == "" then
        return false
    end
    if self:HandleTrackLink(raw) or self:HandleWaypointLink(raw) then
        return true
    end
    return false
end

-- Guides to a route item exactly the way its panel row would: a dry build of the panel collects the
-- row targets, then the item's target is followed.
function Lockouts:GuideRouteItem(isMount, id)
    if not (id and self:DryBuild()) then
        return false
    end
    return self:FollowTarget(self.routeLinks and self.routeLinks[routeKey(isMount, id)])
end

-- Where the Encounter Journal pins its encounters on zone maps: world bosses have no entrance, but
-- the game draws each one's skull on its zone. Built once, keyed by journal encounter id.
function Lockouts:EncounterPinIndex()
    -- no answer is ever final here: right after login the journal answers empty, and even a good
    -- answer is partial while old-expansion pins are still streaming in (a session cached 22
    -- current-content pins for good and never saw Calamir's Azsuna skull). Empty asks retry every
    -- half minute, a built index rescans every minute, and a rescan that answers empty keeps what
    -- it already had.
    local now = type(GetTime) == "function" and GetTime() or 0
    local ttl = self.encounterPinsBuilt and 60 or 30
    if self.encounterPins and self.encounterPinsAskedAt and now - self.encounterPinsAskedAt < ttl then
        return self.encounterPins
    end
    local pins = {}
    if not (type(C_EncounterJournal) == "table" and type(C_EncounterJournal.GetEncountersOnMap) == "function"
        and type(C_Map) == "table" and type(C_Map.GetMapChildrenInfo) == "function" and Enum and Enum.UIMapType) then
        self.encounterPins = self.encounterPins or pins
        return self.encounterPins
    end
    self.encounterPinsAskedAt = now
    local live = {}
    local function sweep(mapType, markLive)
        local okList, list = guarded(C_Map.GetMapChildrenInfo, 946, mapType, true)
        for _, info in ipairs(okList and type(list) == "table" and list or {}) do
            if info.mapID then
                local okEnc, encounters = guarded(C_EncounterJournal.GetEncountersOnMap, info.mapID)
                for _, pin in ipairs(okEnc and type(encounters) == "table" and encounters or {}) do
                    local x, y = pin.mapX, pin.mapY
                    if not (x and y) and type(pin.position) == "table" and type(pin.position.GetXY) == "function" then
                        local okXY, px, py = pcall(pin.position.GetXY, pin.position)
                        if okXY then x, y = px, py end
                    end
                    if pin.encounterID and x and y then
                        if markLive then
                            live[pin.encounterID] = true
                        end
                        if not pins[pin.encounterID] then
                            pins[pin.encounterID] = { mapID = info.mapID, x = x, y = y }
                        end
                    end
                end
            end
        end
    end
    sweep(Enum.UIMapType.Zone)
    -- a continent map carries a skull only for the world boss that is live right now (that is how
    -- the game itself answers "which boss is up this week"), so a continent pin both places the
    -- boss and marks it the active one
    sweep(Enum.UIMapType.Continent, true)
    if next(pins) then
        self.encounterPins = pins
        self.encounterPinsBuilt = true
        self.liveContinentBosses = live
    else
        self.encounterPins = self.encounterPins or pins
    end
    return self.encounterPins
end

-- The encounter ids whose pin carries a live world-boss world quest (Enum.QuestTagType.WorldBoss):
-- that is the boss actually up this week. The scan is re-asked every 30 seconds, so an empty early
-- answer is never kept and the week turning mid-session is seen.
local ACTIVE_BOSS_ASK_SECONDS = 30

function Lockouts:ActiveWorldBossSet()
    local now = type(GetTime) == "function" and GetTime() or 0
    if self.activeBossSet and self.activeBossAskedAt and now - self.activeBossAskedAt < ACTIVE_BOSS_ASK_SECONDS then
        return self.activeBossSet
    end
    self.activeBossAskedAt = now
    local active = {}
    local found = {}
    self.activeBossSet = active
    self.activeBossQuests = found
    -- bosses the player network heard about this week join the answer before any local scan
    local network = pluginShared.Network
    local netStore = pluginShared.db and pluginShared.db.global
    if network and network.IsFresh and netStore and type(netStore.bossNetwork) == "table" then
        for encounterID, seenAt in pairs(netStore.bossNetwork) do
            if network.IsFresh(seenAt) then
                active[encounterID] = true
            end
        end
    end
    if not (type(C_TaskQuest) == "table" and type(C_TaskQuest.GetQuestsOnMap) == "function"
        and type(C_Map) == "table" and type(C_Map.GetMapChildrenInfo) == "function") then
        return active
    end
    local bossTag = (type(Enum) == "table" and Enum.QuestTagType and Enum.QuestTagType.WorldBoss) or 18
    local zoneType = (type(Enum) == "table" and Enum.UIMapType and Enum.UIMapType.Zone) or 3
    local pins = self:EncounterPinIndex()
    -- the task POI's own questTagType rides along nil on the live client; the boss mark lives in
    -- C_QuestLog.GetQuestTagInfo(questID).worldQuestType, the field Blizzard's own map pins read
    local tagOf = type(C_QuestLog) == "table" and C_QuestLog.GetQuestTagInfo or nil
    local total = 0
    local seen = {}
    local function sweepQuests(mapType)
        local okList, zones = guarded(C_Map.GetMapChildrenInfo, 946, mapType, true)
        for _, info in ipairs(okList and type(zones) == "table" and zones or {}) do
            local okQ, quests = guarded(C_TaskQuest.GetQuestsOnMap, info.mapID)
            for _, quest in ipairs(okQ and type(quests) == "table" and quests or {}) do
                if not (quest.questID and seen[quest.questID]) then
                    if quest.questID then
                        seen[quest.questID] = true
                    end
                    total = total + 1
                    local wq = quest.questTagType
                    if wq == nil and tagOf and quest.questID then
                        local okTag, tagInfo = guarded(tagOf, quest.questID)
                        wq = okTag and type(tagInfo) == "table" and tagInfo.worldQuestType or nil
                    end
                    if quest.x and quest.y then
                        local title
                        if type(C_TaskQuest.GetQuestInfoByQuestID) == "function" then
                            local okT, name = guarded(C_TaskQuest.GetQuestInfoByQuestID, quest.questID)
                            title = okT and type(name) == "string" and name or nil
                        end
                        -- old boss quests answer no tag at all ("DEADLY: Withered J'im" came back
                        -- nil), so every titled task quest is a candidate; the boss-name match
                        -- downstream decides, and the tag keeps its pin-proximity fast path
                        if title or wq == bossTag then
                            found[#found + 1] = { questID = quest.questID, mapID = info.mapID, x = quest.x, y = quest.y, title = title, boss = wq == bossTag or nil }
                        end
                        if wq == bossTag then
                            for encounterID, pin in pairs(pins) do
                                if pin.mapID == info.mapID and math.abs(pin.x - quest.x) < 0.05 and math.abs(pin.y - quest.y) < 0.05 then
                                    active[encounterID] = true
                                end
                            end
                        end
                    end
                end
            end
        end
    end
    sweepQuests(zoneType)
    -- the continent map aggregates its zones' world quests, and for some old groups it is the only
    -- place the boss quest surfaces
    sweepQuests((type(Enum) == "table" and Enum.UIMapType and Enum.UIMapType.Continent) or 2)
    -- rotating bosses never get journal pins; the skull the zone map draws for them is a vignette,
    -- so every world-map vignette is kept by name for the group matchers below
    local vignettes = {}
    self.activeBossVignettes = vignettes
    if type(C_VignetteInfo) == "table" and type(C_VignetteInfo.GetVignettes) == "function"
        and type(C_VignetteInfo.GetVignetteInfo) == "function" then
        local okV, guids = guarded(C_VignetteInfo.GetVignettes)
        for _, guid in ipairs(okV and type(guids) == "table" and guids or {}) do
            local okI, info = guarded(C_VignetteInfo.GetVignetteInfo, guid)
            if okI and type(info) == "table" and info.onWorldMap and not info.isDead
                and type(info.name) == "string" and info.name ~= "" then
                vignettes[#vignettes + 1] = { guid = guid, name = info.name }
            end
        end
    end
    for encounterID in pairs(self.liveContinentBosses or {}) do
        active[encounterID] = true
    end
    self.activeBossQuestTotal = total
    return active
end

-- Is this boss of this group the one carrying the week's world quest? Either its pin holds the
-- quest, or the quest is titled with the boss's own name (old groups get no pin at all).
function Lockouts:IsActiveWorldBoss(raidName, bossName)
    if not (raidName and bossName) then
        return false
    end
    local entry = self:BuildBossOrdinalIndex()[raidName:lower()]
    local encounterID = entry and entry.bossEncounters and entry.bossEncounters[bossName:lower()]
    if not encounterID then
        return false
    end
    if self:ActiveWorldBossSet()[encounterID] then
        return true
    end
    local wanted = bossName:lower()
    for _, quest in ipairs(self.activeBossQuests or {}) do
        if quest.title and quest.title:lower():find(wanted, 1, true) then
            return true
        end
    end
    for _, vignette in ipairs(self.activeBossVignettes or {}) do
        if vignette.name:lower():find(wanted, 1, true) then
            return true
        end
    end
    -- no quest signal at all: the game pins only the live boss of an old rotating group, so a
    -- group with exactly one pinned boss has its answer right there
    local pins = self:EncounterPinIndex()
    if pins[encounterID] then
        local pinned = 0
        for _, id in pairs(entry.bossEncounters) do
            if pins[id] then
                pinned = pinned + 1
            end
        end
        if pinned == 1 then
            return true
        end
    end
    return false
end

-- A world-boss group's routable spot: the world quest of the boss that is up this week (its own
-- position, matched to the group by the quest bearing a boss's name or standing on a boss's pin),
-- else the first pinned boss. Also names the boss the spot belongs to.
function Lockouts:WorldBossSpot(raidName)
    if not raidName then
        return nil
    end
    local entry = self:BuildBossOrdinalIndex()[raidName:lower()]
    local encounters = entry and entry.bossEncounters
    if not encounters then
        return nil
    end
    local pins = self:EncounterPinIndex()
    local active = self:ActiveWorldBossSet()
    for _, quest in ipairs(self.activeBossQuests or {}) do
        local title = quest.title and quest.title:lower()
        if title then
            -- the quest is usually titled with the boss's bare name, sometimes prefixed ("Lair: ...")
            if encounters[title] then
                return quest.mapID, quest.x, quest.y, quest.title
            end
            for bossName in pairs(encounters) do
                if #bossName > 3 and title:find(bossName, 1, true) then
                    return quest.mapID, quest.x, quest.y, quest.title
                end
            end
        end
    end
    for _, vignette in ipairs(self.activeBossVignettes or {}) do
        local vName = vignette.name:lower()
        local matched = encounters[vName] ~= nil
        if not matched then
            for bossName in pairs(encounters) do
                if #bossName > 3 and vName:find(bossName, 1, true) then
                    matched = true
                    break
                end
            end
        end
        if matched then
            if not vignette.mapID and type(C_VignetteInfo) == "table"
                and type(C_VignetteInfo.GetVignettePosition) == "function"
                and type(C_Map) == "table" and type(C_Map.GetMapChildrenInfo) == "function" then
                local zoneType = (type(Enum) == "table" and Enum.UIMapType and Enum.UIMapType.Zone) or 3
                local okList, zones = guarded(C_Map.GetMapChildrenInfo, 946, zoneType, true)
                for _, info in ipairs(okList and type(zones) == "table" and zones or {}) do
                    local okP, pos = guarded(C_VignetteInfo.GetVignettePosition, vignette.guid, info.mapID)
                    if okP and type(pos) == "table" then
                        local px, py = pos.x, pos.y
                        if not (px and py) and type(pos.GetXY) == "function" then
                            local okXY, gx, gy = pcall(pos.GetXY, pos)
                            if okXY then px, py = gx, gy end
                        end
                        if px and py and px > 0 and py > 0 then
                            vignette.mapID, vignette.x, vignette.y = info.mapID, px, py
                            break
                        end
                    end
                end
            end
            if vignette.mapID then
                return vignette.mapID, vignette.x, vignette.y, vignette.name
            end
        end
    end
    local chosen
    for _, encounterID in pairs(encounters) do
        local pin = pins[encounterID]
        if pin and active[encounterID] then
            chosen = encounterID
            break
        end
        if pin and not chosen then
            chosen = encounterID
        end
    end
    if not chosen then
        return nil
    end
    local pin = pins[chosen]
    local okName, name = guarded(EJ_GetEncounterInfo, chosen)
    return pin.mapID, pin.x, pin.y, okName and type(name) == "string" and name or nil
end

-- Nothing starred from the week's active boss means nothing can drop this week: the group acts
-- like a done raid until the rotation turns. Only a positively known active boss writes a group
-- off; an unknown week never does.
function Lockouts:WorldBossNoChance(raidName, raid)
    if not (raidName and raid and raid.bosses) then
        return false
    end
    local entry = self:BuildBossOrdinalIndex()[raidName:lower()]
    if not (entry and entry.bossEncounters) then
        return false
    end
    local activeKey
    for bossKey in pairs(entry.bossEncounters) do
        if self:IsActiveWorldBoss(raidName, bossKey) then
            activeKey = bossKey
            break
        end
    end
    if not activeKey then
        return false
    end
    for starredBoss in pairs(raid.bosses) do
        if starredBoss:lower() == activeKey then
            return false
        end
    end
    return true
end

-- The game refusing (or dropping) an item's tracking must not kill the trip: the entry's entrance
-- or its shipped spot still routes, plain, with no tracked item aboard. Says where it is heading.
function Lockouts:RouteUntracked(raidName, fallback)
    local route = pluginShared.Route
    if not (route and route.Start) then
        return false
    end
    -- a route action asks fresh: the pin and quest scans drop their throttles so the click sees
    -- the live boss now, not after a timer
    self.encounterPinsAskedAt = nil
    self.activeBossAskedAt = nil
    local entrance = raidName and self:LegacyEntrance(raidName) or nil
    if entrance and entrance.worldBoss then
        -- the anchor names one fixed spot while the actual boss rotates: the skull the game pins
        -- this week outranks it, and the anchor holds the floor when no pin is up yet
        local bossMap, bossX, bossY, bossName = self:WorldBossSpot(raidName)
        if bossMap then
            tell(string.format(L["lock_guide_untracked_fmt"], bossName or raidName))
            self:Mark("untracked route")
            route:Start(bossMap, bossX, bossY, bossName or raidName, raidName)
            return true
        end
        -- no live boss found: the anchor goes out, and the route tick keeps asking until the live
        -- boss streams in, then moves the trip onto it
        self.anchorRouteRaid = raidName
        -- if a gate explains why (the unlock quest is missing), say so before the anchor takes
        -- over, so the player knows what to fix
        local gate = self.GateState and self:GateState(raidName) or nil
        if gate and gate.state == "blocked" and gate.text then
            tell(gate.text)
        else
            -- nothing pinned, no quest visible, no gate to blame: say so instead of a wordless
            -- trip to an anchor the player did not choose
            tell(string.format(L["lock_worldboss_none_fmt"], raidName))
        end
    end
    if entrance and entrance.mapID then
        tell(string.format(L["lock_guide_untracked_fmt"], raidName))
        self:Mark("untracked route")
        route:Start(entrance.mapID, entrance.x, entrance.y, raidName .. " entrance", raidName)
        return true
    end
    local bossMap, bossX, bossY, bossName = self:WorldBossSpot(raidName)
    if bossMap then
        tell(string.format(L["lock_guide_untracked_fmt"], bossName or raidName))
        self:Mark("untracked route")
        route:Start(bossMap, bossX, bossY, bossName or raidName, raidName)
        return true
    end
    if fallback and fallback.mapID and fallback.x and fallback.y then
        local title = fallback.title or L["lock_location_lower"]
        tell(string.format(L["lock_guide_untracked_fmt"], title))
        self:Mark("untracked route")
        route:Start(fallback.mapID, fallback.x, fallback.y, title)
        return true
    end
    return false
end

-- Guides the next route entry in travel order: the nearest raid or item that is not already done
-- this lockout on this character. announce: say so in chat (after a collection).
function Lockouts:GuideRouteNext(announce)
    local routeList = pluginShared.RouteList
    if not (routeList and routeList:Count() > 0) or not self:DryBuild() then
        return false
    end
    local hadOpenEntry = false
    for _, entry in ipairs(self.routeOrder or {}) do
        if not (entry.done or entry.noChance) then
            local raw
            if entry.raid then
                for _, item in ipairs(entry.items or {}) do
                    raw = self.routeLinks and self.routeLinks[routeKey(item.isMount, item.id, entry.raid)]
                    if raw then break end
                end
            else
                raw = self.routeLinks and self.routeLinks[routeKey(entry.isMount, entry.id, entry.raidName)]
            end
            if raw and self:FollowTarget(raw) then
                if announce then
                    tell(L["lock_route_next"])
                end
                return true
            end
            -- the game refused the track, or no link was stored for this entry: the trip starts anyway
            local fMap, fx, fy
            if raw then
                fMap, fx, fy = raw:match("^w2tstrack:%d+:%d+:[^:]*:(%d+):(%d+):(%d+)")
            end
            local fallback = fMap and { mapID = tonumber(fMap), x = tonumber(fx) / 1000, y = tonumber(fy) / 1000 } or nil
            if self:RouteUntracked(entry.raid or entry.raidName, fallback) then
                if announce then
                    tell(L["lock_route_next"])
                end
                return true
            end
            hadOpenEntry = true
        end
    end
    if hadOpenEntry and not self.noRouteWarned then
        -- an open entry with nowhere to route must never fail in silence
        self.noRouteWarned = true
        tell(L["lock_no_route_no_entrance"])
    end
    return false
end

-- On login, after a zone change and when the lockouts update: a route with nothing guided starts
-- guiding, and a guide standing on a raid that is done this lockout moves to the next entry. Never
-- inside an instance, never while /am stop holds the route, at most once a minute when nothing starts.
function Lockouts:RouteAdvanceIfDone()
    local routeList = pluginShared.RouteList
    if not (routeList and routeList:Count() > 0) or self.routePaused or self:IsSettling() then
        return false
    end
    if type(GetInstanceInfo) == "function" then
        local ok, _, instanceType = pcall(GetInstanceInfo)
        if ok and (instanceType == "raid" or instanceType == "party") then
            return false
        end
    end
    -- a world-boss trip follows the rotation: whether the route stands on the shipped anchor
    -- (a click before the scan warmed, or the anchor flag lost to a reload) or on last week's
    -- boss spot, the live boss is where the trip belongs; a route already on the live boss is
    -- left alone
    local anchorRaid = self.anchorRouteRaid
    if not anchorRaid then
        local route = pluginShared.Route
        local target = route and route.target
        if target and target.mapID then
            for _, entry in ipairs(self.routeOrder or {}) do
                if not (entry.done or entry.noChance) then
                    local raidName = entry.raid or entry.raidName
                    local entrance = raidName and self:LegacyEntrance(raidName) or nil
                    if entrance and entrance.worldBoss
                        and (target.raidName == raidName or (entrance.mapID == target.mapID
                            and math.abs((entrance.x or 0) - (target.x or -1)) < 0.01
                            and math.abs((entrance.y or 0) - (target.y or -1)) < 0.01)) then
                        anchorRaid = raidName
                    end
                    break
                end
            end
        end
    end
    if anchorRaid then
        local route = pluginShared.Route
        if route and route.target and route.Start then
            local bossMap, bossX, bossY, bossName = self:WorldBossSpot(anchorRaid)
            local target = route.target
            local alreadyThere = bossMap and target.mapID == bossMap
                and math.abs((target.x or -1) - bossX) < 0.01 and math.abs((target.y or -1) - bossY) < 0.01
            if bossMap and not alreadyThere then
                local raidName = anchorRaid
                self.anchorRouteRaid = nil
                tell(string.format(L["lock_guide_untracked_fmt"], bossName or raidName))
                self:Mark("untracked route")
                route:Start(bossMap, bossX, bossY, bossName or raidName, raidName)
                return true
            end
            if alreadyThere then
                self.anchorRouteRaid = nil
            end
        elseif self.anchorRouteRaid and not (route and route.target) then
            self.anchorRouteRaid = nil
        end
    end
    local now = type(GetTime) == "function" and GetTime() or 0
    if self:GuideIdle() then
        if self.routeAutoAt and now - self.routeAutoAt < 60 then
            return false
        end
        self.routeAutoAt = now
        return self:GuideRouteNext(false)
    end
    -- the guided entry leads the travel order (tracked drop, entrance route or Raid Finder NPC alike);
    -- done this lockout means the guide moves on. Lockout updates come in bursts, so at most every 5 s.
    if self.routeDoneCheckAt and now - self.routeDoneCheckAt < 5 then
        return false
    end
    self.routeDoneCheckAt = now
    if not self:DryBuild() then
        return false
    end
    local first = self.routeOrder and self.routeOrder[1]
    if first and first.guided and first.done then
        self:EndGuide()
        self.routeAutoAt = now
        return self:GuideRouteNext(false)
    end
    return false
end

-- The star itself after a starred row, the same icon the wardrobe and mount stars use; clicking it
-- removes the star. w2tsunstar:<a|m>:<visualID|mountID>:<sourceID or 0>
function Lockouts:UnstarLink(isMount, id, sourceID)
    if not id then
        return ""
    end
    return " |Hw2tsunstar:" .. (isMount and "m" or "a") .. ":" .. id .. ":" .. (sourceID or 0) .. "|h|A:PetJournal-FavoritesIcon:21:21|a|h"
end

-- Removes the star (after the player confirmed in the panel); the favorites listeners rebuild the
-- panel. The item leaves the route too, and a guide leading to it moves on to the next route entry.
function Lockouts:Unstar(isMount, id, sourceID)
    local favorites = pluginShared.Favorites
    local tracking = pluginShared.Tracking
    local routeList = pluginShared.RouteList
    local wasGuided
    if isMount then
        wasGuided = self:IsGuided(tracking and tracking.MOUNT or 1, id)
    else
        wasGuided = (sourceID or 0) > 0 and self:IsGuided(tracking and tracking.APPEARANCE or 0, sourceID) or false
    end
    if wasGuided then
        self:EndGuide()
    end
    if routeList then
        routeList:Remove(isMount, id)
    end
    if isMount then
        if favorites and favorites.MountSet then
            favorites:MountSet(id, false)
        end
    elseif favorites and favorites.Set then
        favorites:Set(id, false)
    end
    if wasGuided then
        self:GuideRouteNext(false)
    end
    return true
end

local UNSTAR_POPUP = "APPEARANCEMATIC_UNSTAR"
if type(StaticPopupDialogs) == "table" and not StaticPopupDialogs[UNSTAR_POPUP] then
    StaticPopupDialogs[UNSTAR_POPUP] = {
        text = L["lock_unstar_confirm"],
        button1 = YES,
        button2 = NO,
        OnAccept = function(dialog, data)
            data = data or (type(dialog) == "table" and dialog.data) or nil
            if type(data) == "table" then
                Lockouts:Unstar(data.isMount, data.id, data.sourceID)
            end
        end,
        timeout = 0,
        whileDead = true,
        hideOnEscape = true,
        preferredIndex = 3,
    }
end

-- The star on a panel row: asks first, then removes the star
function Lockouts:HandleUnstarLink(link)
    if type(link) ~= "string" then
        return false
    end
    local kind, id, sourceID = link:match("^w2tsunstar:(%a):(%d+):(%d+)$")
    if not kind then
        return false
    end
    local data = { isMount = kind == "m", id = tonumber(id), sourceID = tonumber(sourceID) }
    if type(StaticPopup_Show) == "function" and type(StaticPopupDialogs) == "table" and StaticPopupDialogs[UNSTAR_POPUP] then
        StaticPopup_Show(UNSTAR_POPUP, nil, nil, data)
    else
        self:Unstar(data.isMount, data.id, data.sourceID)
    end
    return true
end

function Lockouts:HandleTrackLink(link)
    if type(link) ~= "string" then
        return false
    end
    local trackType, id, raidName, fMap, fx, fy, fZone = link:match("^w2tstrack:(%d+):(%d+):([^:]*):(%d+):(%d+):(%d+):(%d)$")
    if not trackType then
        trackType, id, raidName, fMap, fx, fy = link:match("^w2tstrack:(%d+):(%d+):([^:]*):(%d+):(%d+):(%d+)$")
    end
    if not trackType then
        trackType, id, raidName = link:match("^w2tstrack:(%d+):(%d+):?(.*)$")
    end
    if not trackType then
        return false
    end
    trackType, id = tonumber(trackType), tonumber(id)
    if raidName == "" then raidName = nil end
    local fallback = fMap and { mapID = tonumber(fMap), x = tonumber(fx) / 1000, y = tonumber(fy) / 1000, zoneOnly = (fZone == "1") or nil } or nil
    local tracking = pluginShared.Tracking
    if tracking then
        local wasTracking = tracking:IsTracking(trackType, id)
        local guidingThis = self.guideTarget and self.guideTarget.type == trackType and self.guideTarget.id == id
        local toggled
        if wasTracking and guidingThis then
            tracking:Toggle(trackType, id)
            if type(C_SuperTrack) == "table" and type(C_SuperTrack.ClearSuperTrackedContent) == "function" then
                pcall(C_SuperTrack.ClearSuperTrackedContent)
            end
            self:StopGuide()
            -- remember the dismissal so a login does not re-adopt this item from Blizzard's tracker
            if AppearanceMaticDB then
                AppearanceMaticDB.guideDismissed = { type = trackType, id = id }
            end
        elseif wasTracking then
            toggled = true
        else
            toggled = tracking:Toggle(trackType, id)
        end
        if toggled then
            self:Mark("click")
            self:StopGuide()
            if pluginShared.Route and pluginShared.Route.Stop then
                pluginShared.Route:Stop()
            end
            self.guideTarget = { type = trackType, id = id, raidName = raidName, fallback = fallback }
            self:SaveGuideTarget(self.guideTarget)
            if AppearanceMaticDB then AppearanceMaticDB.guideDismissed = nil end
            if type(C_SuperTrack) == "table" and type(C_SuperTrack.SetSuperTrackedContent) == "function" then
                pcall(C_SuperTrack.SetSuperTrackedContent, trackType, id)
            end
            self:RouteGuideToTomTom(0)
            self:StartGuideTicker()
        else
            -- the game refused the track: saying a guide started when nothing did left routes dead
            -- with no word, so the failure is reported and the caller finds another way
            tell(L["lock_track_limit_reached"])
            self:ScheduleRebuild()
            return false
        end
    end
    self:ScheduleRebuild()
    return true
end

function Lockouts:InvalidateIndexes()
    self.entranceIndexBuilt = nil
    self.bossOrdinalsBuilt = nil
    self.encounterPinsBuilt = nil
    self.activeBossSet, self.activeBossQuests, self.activeBossAskedAt = nil, nil, nil
end

function Lockouts:BuildEntranceIndex()
    if self.entranceIndexBuilt then
        return self.entranceIndex
    end
    local index = {}
    self.entranceIndex = index
    if not (C_Map and C_Map.GetMapChildrenInfo and C_EncounterJournal
        and C_EncounterJournal.GetDungeonEntrancesForMap and Enum and Enum.UIMapType) then
        return index
    end
    self.entranceIndexBuilt = true
    local mapIDs = {}
    local function collectMaps(mapType)
        local okList, list = guarded(C_Map.GetMapChildrenInfo, 946, mapType, true)
        if okList and type(list) == "table" then
            for _, info in ipairs(list) do
                if info.mapID then
                    mapIDs[#mapIDs + 1] = info.mapID
                end
            end
        end
    end
    collectMaps(Enum.UIMapType.Continent)
    collectMaps(Enum.UIMapType.Zone)
    for _, mapID in ipairs(mapIDs) do
        local okEntrances, entrances = guarded(C_EncounterJournal.GetDungeonEntrancesForMap, mapID)
        if okEntrances and type(entrances) == "table" then
            for _, entrance in ipairs(entrances) do
                if entrance.name and entrance.position and entrance.position.GetXY then
                    local okXY, x, y = pcall(entrance.position.GetXY, entrance.position)
                    if okXY and x and y then
                        local entry = { mapID = mapID, x = x, y = y, journalInstanceID = entrance.journalInstanceID }
                        if not index[entrance.name:lower()] then
                            index[entrance.name:lower()] = entry
                        end
                        if entrance.journalInstanceID then
                            index.byInstance = index.byInstance or {}
                            index.byInstance[entrance.journalInstanceID] = entry
                        end
                    end
                end
            end
        end
    end
    return index
end

function Lockouts:RaidZoneName(raidName)
    local entry = self:LegacyEntrance(raidName)
    if not (entry and entry.mapID and type(C_Map) == "table" and type(C_Map.GetMapInfo) == "function") then
        return nil
    end
    local ok, info = pcall(C_Map.GetMapInfo, entry.mapID)
    if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then
        return info.name
    end
    return nil
end

function Lockouts:EntranceWorld(raidName)
    local entry = raidName and self:BuildEntranceIndex()[raidName:lower()]
    if not entry then
        return nil
    end
    if not entry.continentID and type(C_Map) == "table" and type(C_Map.GetWorldPosFromMapPos) == "function" then
        local mapPos = (type(CreateVector2D) == "function") and CreateVector2D(entry.x, entry.y) or { x = entry.x, y = entry.y }
        local ok, continentID, world = pcall(C_Map.GetWorldPosFromMapPos, entry.mapID, mapPos)
        if ok and continentID and type(world) == "table" then
            local wx, wy
            if type(world.GetXY) == "function" then
                wx, wy = world:GetXY()
            else
                wx, wy = world.x, world.y
            end
            if wx and wy then
                entry.continentID = continentID
                entry.worldX = wx
                entry.worldY = wy
            end
        end
    end
    if entry.continentID then
        return entry.continentID, entry.worldX, entry.worldY
    end
    return nil
end

function Lockouts:PlayerWorld()
    if not (type(C_Map) == "table" and type(C_Map.GetBestMapForUnit) == "function"
        and type(C_Map.GetPlayerMapPosition) == "function" and type(C_Map.GetWorldPosFromMapPos) == "function") then
        return nil
    end
    local okMap, uiMapID = pcall(C_Map.GetBestMapForUnit, "player")
    if not (okMap and uiMapID) then
        return nil
    end
    local okPos, mapPos = pcall(C_Map.GetPlayerMapPosition, uiMapID, "player")
    if not (okPos and type(mapPos) == "table") then
        return nil
    end
    local ok, continentID, world = pcall(C_Map.GetWorldPosFromMapPos, uiMapID, mapPos)
    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

-- World position of a map spot: continentID, worldX, worldY (nil when the client cannot place it)
function Lockouts:MapWorld(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 mapPos = (type(CreateVector2D) == "function") and CreateVector2D(x, y) or { x = x, y = y }
    local ok, continentID, world = pcall(C_Map.GetWorldPosFromMapPos, mapID, mapPos)
    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

-- Travel order for the route: nearest first, each next stop measured from the last, starting at the
-- player (or at the anchor: the entry being guided right now, which stays first). The player's
-- continent comes first, then the others; entries with no known position follow in the order given.
-- entries: { { continentID = n, x = wx, y = wy, ... } | { ... } }
function Lockouts:TravelOrder(entries, anchor)
    local groups, groupOrder, tail = {}, {}, {}
    for _, entry in ipairs(entries or {}) do
        if entry.continentID and entry.x and entry.y then
            local group = groups[entry.continentID]
            if not group then
                group = {}
                groups[entry.continentID] = group
                groupOrder[#groupOrder + 1] = entry.continentID
            end
            group[#group + 1] = entry
        else
            tail[#tail + 1] = entry
        end
    end
    local startContinent, sx, sy
    if anchor and anchor.continentID and anchor.x and anchor.y then
        startContinent, sx, sy = anchor.continentID, anchor.x, anchor.y
    else
        startContinent, sx, sy = self:PlayerWorld()
    end
    table.sort(groupOrder, function(a, b)
        if a == startContinent then
            return true
        end
        if b == startContinent then
            return false
        end
        return a < b
    end)
    local function dist2(ax, ay, bx, by)
        local dx, dy = ax - bx, ay - by
        return dx * dx + dy * dy
    end
    local ordered = {}
    for _, continentID in ipairs(groupOrder) do
        local group = groups[continentID]
        local cx, cy
        if continentID == startContinent and sx then
            cx, cy = sx, sy
        else
            local first = group[1]
            for _, e in ipairs(group) do
                if (e.x + e.y) < (first.x + first.y) then
                    first = e
                end
            end
            cx, cy = first.x, first.y
        end
        local remaining = {}
        for _, e in ipairs(group) do
            remaining[#remaining + 1] = e
        end
        while #remaining > 0 do
            local bestIdx = 1
            local bestD = dist2(cx, cy, remaining[1].x, remaining[1].y)
            for idx = 2, #remaining do
                local d = dist2(cx, cy, remaining[idx].x, remaining[idx].y)
                if d < bestD then
                    bestD = d
                    bestIdx = idx
                end
            end
            local chosen = table.remove(remaining, bestIdx)
            ordered[#ordered + 1] = chosen
            cx, cy = chosen.x, chosen.y
        end
    end
    for _, e in ipairs(tail) do
        ordered[#ordered + 1] = e
    end
    return ordered
end

function Lockouts:BuildBossOrdinalIndex()
    if self.bossOrdinalsBuilt then
        return self.bossOrdinals
    end
    local index = {}
    self.bossOrdinals = index
    if not (type(EJ_GetNumTiers) == "function" and type(EJ_SelectTier) == "function"
        and type(EJ_GetInstanceByIndex) == "function" and type(EJ_SelectInstance) == "function"
        and type(EJ_GetEncounterInfoByIndex) == "function") then
        return index
    end
    local savedTier = type(EJ_GetCurrentTier) == "function" and EJ_GetCurrentTier() or nil
    local savedInstance = type(EncounterJournal) == "table" and EncounterJournal.instanceID or nil
    local knownExpansions = self:KnownExpansionNames()
    local okTiers, numTiers = pcall(EJ_GetNumTiers)
    if okTiers and type(numTiers) == "number" then
        for tier = 1, numTiers do
            pcall(EJ_SelectTier, tier)
            local tierName
            if type(EJ_GetTierInfo) == "function" then
                local okTier, name = pcall(EJ_GetTierInfo, tier)
                if okTier and type(name) == "string" and name ~= "" then
                    tierName = name
                end
            end
            local isSeasonTier = tierName ~= nil and not knownExpansions[tierName]
            local expansionLabel = (tierName and knownExpansions[tierName]) and tierName or nil
            for _, isRaid in ipairs({ true, false }) do
                local i = 1
                while true do
                    local okInst, instanceID, name = pcall(EJ_GetInstanceByIndex, i, isRaid)
                    if not (okInst and instanceID and type(name) == "string") then
                        break
                    end
                    pcall(EJ_SelectInstance, instanceID)
                    local bosses, encounters = {}, {}
                    local j = 1
                    while true do
                        local okEnc, bossName, _, journalEncounterID = pcall(EJ_GetEncounterInfoByIndex, j, instanceID)
                        if not (okEnc and type(bossName) == "string" and bossName ~= "") then
                            break
                        end
                        bosses[bossName:lower()] = j
                        if type(journalEncounterID) == "number" then
                            -- world-boss groups have no entrance; the encounter id reaches the boss's own map pin
                            encounters[bossName:lower()] = journalEncounterID
                        end
                        j = j + 1
                    end
                    local key = name:lower()
                    if next(bosses) and not index[key] then
                        index[key] = { bosses = bosses, bossEncounters = encounters, total = j - 1, expansion = expansionLabel, isRaid = isRaid,
                            originalInstanceID = instanceID }
                    end
                    if index[key] and isSeasonTier then
                        index[key].currentSeason = true
                        index[key].seasonInstanceID = instanceID
                    end
                    i = i + 1
                end
            end
        end
    end
    if savedTier then
        pcall(EJ_SelectTier, savedTier)
    end
    if savedInstance then
        pcall(EJ_SelectInstance, savedInstance)
    end
    if next(index) then
        -- an empty journal (its data not loaded yet) is never kept as the index for good
        self.bossOrdinalsBuilt = true
    end
    return index
end

-- Name of the current expansion as the Encounter Journal spells it (last tier), so it matches RaidExpansionName.
function Lockouts:CurrentExpansionName()
    if self.currentExpansionName then
        return self.currentExpansionName
    end
    local known = self:KnownExpansionNames()
    local name
    if type(EJ_GetNumTiers) == "function" and type(EJ_GetTierInfo) == "function" then
        local okN, n = pcall(EJ_GetNumTiers)
        if okN and type(n) == "number" then
            for tier = n, 1, -1 do
                local okT, tierName = pcall(EJ_GetTierInfo, tier)
                if okT and type(tierName) == "string" and known[tierName] then
                    name = tierName
                    break
                end
            end
        end
    end
    if not name and type(LE_EXPANSION_LEVEL_CURRENT) == "number" then
        name = _G["EXPANSION_NAME" .. LE_EXPANSION_LEVEL_CURRENT]
    end
    self.currentExpansionName = name
    return name
end

function Lockouts:KnownExpansionNames()
    if self.knownExpansionNames then
        return self.knownExpansionNames
    end
    local known = {}
    local top = type(LE_EXPANSION_LEVEL_CURRENT) == "number" and LE_EXPANSION_LEVEL_CURRENT or 20
    for n = 0, top do
        local expName = _G["EXPANSION_NAME" .. n]
        if type(expName) == "string" and expName ~= "" then
            known[expName] = true
        end
    end
    self.knownExpansionNames = known
    return known
end

function Lockouts:IsCurrentSeasonInstance(raidName)
    local entry = raidName and self:BuildBossOrdinalIndex()[raidName:lower()]
    return entry and entry.currentSeason or false
end

-- The raid to pin at the top of the list: the instance you are standing in. Nothing else moves the
-- list; the route has its own section above it.
function Lockouts:PriorityRaid(raidOrder)
    if type(GetInstanceInfo) ~= "function" then
        return nil
    end
    local ok, name, instanceType = pcall(GetInstanceInfo)
    if not (ok and type(name) == "string" and name ~= "" and (instanceType == "raid" or instanceType == "party")) then
        return nil
    end
    local lowered = name:lower()
    for _, raidName in ipairs(raidOrder) do
        if raidName:lower() == lowered then return raidName, "inside" end
    end
    return nil
end

-- A raid passes when its expansion's box is ticked: the current expansion (or this season's
-- instances) under "current", everything else under "legacy". expansionName may be nil (unknown);
-- unknown counts as "not current".
function Lockouts:PassesExpansionFilter(expansionName, raidName)
    local show = self:ShowFilter()
    if show.current and show.legacy then
        return true
    end
    local isCurrent = (expansionName ~= nil and expansionName == self:CurrentExpansionName())
        or (raidName ~= nil and self:IsCurrentSeasonInstance(raidName))
    if isCurrent then
        return show.current
    end
    return show.legacy
end

function Lockouts:ItemExpansionName(itemID)
    if not (itemID and C_Item and type(C_Item.GetItemInfo) == "function") then
        return nil
    end
    local ok, expansionID = pcall(function(id) return select(15, C_Item.GetItemInfo(id)) end, itemID)
    if not (ok and type(expansionID) == "number") then
        return nil
    end
    if type(GetExpansionName) == "function" then
        local okName, name = pcall(GetExpansionName, expansionID)
        if okName and type(name) == "string" and name ~= "" then
            return name
        end
    end
    return _G["EXPANSION_NAME" .. expansionID]
end

function Lockouts:RaidExpansionName(raidName)
    local entry = raidName and self:BuildBossOrdinalIndex()[raidName:lower()]
    if not entry then
        return nil
    end
    if entry.currentSeason then
        local current = self:CurrentExpansionName()
        if entry.expansion and current and entry.expansion ~= current then
            return string.format(L["lock_expansion_season"], entry.expansion, current)
        end
        return current or entry.expansion
    end
    return entry.expansion
end

function Lockouts:BossOrdinal(raidName, bossName)
    if not (raidName and bossName) then
        return nil
    end
    local raidEntry = self:BuildBossOrdinalIndex()[raidName:lower()]
    if not raidEntry then
        return nil
    end
    local pos = raidEntry.bosses[bossName:lower()]
    if not pos then
        return nil
    end
    return pos, raidEntry.total
end

function Lockouts:SortBossOrder(raidName, bossOrder)
    local raidEntry = raidName and self:BuildBossOrdinalIndex()[raidName:lower()]
    local bosses = raidEntry and raidEntry.bosses
    local active = {}
    if raidEntry and raidEntry.bossEncounters then
        for _, name in ipairs(bossOrder) do
            active[name] = self:IsActiveWorldBoss(raidName, name)
        end
    end
    table.sort(bossOrder, function(a, b)
        if active[a] ~= active[b] then
            return active[a] == true
        end
        local pa = bosses and bosses[a:lower()]
        local pb = bosses and bosses[b:lower()]
        if pa and pb then
            return pa < pb
        end
        if pa then
            return true
        end
        if pb then
            return false
        end
        return a < b
    end)
end
