--[[
    Navigation/NativeWaypoint.lua

    Dependency-free navigation for a selected spawn point. Blizzard owns the
    user waypoint and calculates any route between maps. Shiny Hunter presents
    that result in two deliberately small layers:

      * a clean in-world beacon when C_Navigation can project the next point;
      * an always-visible route card when no world projection exists yet.

    The latter is essential for continent changes: selecting a destination in
    Northrend while standing in Stormwind must never look like a dead click.
]]

local _, ns = ...

local NativeWaypoint = ns.NewModule("NativeWaypoint")
ns.Navigation = ns.Navigation or {}
ns.Navigation.NativeWaypoint = NativeWaypoint

local UPDATE_INTERVAL = 0.12
local ARRIVAL_DISTANCE_YARDS = 70
local ARRIVAL_DWELL_SECONDS = 3
local PATROL_CLUSTER_DISTANCE = 0.018
local WORLD_BEACON_MAX_DISTANCE = 140
local marker
local navigator
local target
local routeStep
local patrol
local UpdatePatrol

local function SameCoordinate(a, b)
    return a and b and a.mapID == b.mapID
        and math.abs(a.x - b.x) < 0.00001
        and math.abs(a.y - b.y) < 0.00001
end

local function DistanceSquared(a, b)
    local dx, dy = a.x - b.x, a.y - b.y
    return dx * dx + dy * dy
end

--- Collapses dense coordinate samples into search zones, then orders those
-- zones by nearest neighbour from the point the player selected. The map still
-- draws every raw coordinate; only navigation avoids asking the hunter to stop
-- at several pixels that describe the same patrol area.
local function BuildPatrol(appearanceID, mapID, startX, startY, sourcePoints)
    local raw, seen = {}, {}
    if sourcePoints then
        for _, location in ipairs(sourcePoints) do
            if location.mapID == mapID and location.x and location.y then
                local key = ("%.5f:%.5f"):format(location.x, location.y)
                if not seen[key] then
                    seen[key] = true
                    raw[#raw + 1] = {
                        mapID = mapID,
                        x = location.x,
                        y = location.y,
                    }
                end
            end
        end
    else
        for _, creatureID in ipairs(
            ns.Data.API:GetCreatureIDsByAppearance(appearanceID)) do
            for _, location in ipairs(ns.Data.API:GetLocationsByCreature(creatureID)) do
                if location.mapID == mapID and location.x and location.y then
                    local key = ("%.5f:%.5f"):format(location.x, location.y)
                    if not seen[key] then
                        seen[key] = true
                        raw[#raw + 1] = {
                            mapID = mapID,
                            x = location.x,
                            y = location.y,
                        }
                    end
                end
            end
        end
    end

    local start = { mapID = mapID, x = startX, y = startY }
    if #raw == 0 then
        return { start }
    end

    -- Greedy centroid clusters turn dense samples into search zones without
    -- allowing a long chain of individually-close points to collapse an
    -- entire patrol path into one stop.
    local clusters = {}
    local thresholdSquared = PATROL_CLUSTER_DISTANCE * PATROL_CLUSTER_DISTANCE
    for _, point in ipairs(raw) do
        local bestCluster, bestDistance
        for _, cluster in ipairs(clusters) do
            local center = { x = cluster.sumX / #cluster,
                y = cluster.sumY / #cluster }
            local distance = DistanceSquared(point, center)
            if distance <= thresholdSquared
                and (not bestDistance or distance < bestDistance) then
                bestCluster, bestDistance = cluster, distance
            end
        end

        if bestCluster then
            bestCluster[#bestCluster + 1] = point
            bestCluster.sumX = bestCluster.sumX + point.x
            bestCluster.sumY = bestCluster.sumY + point.y
        else
            clusters[#clusters + 1] = {
                point,
                sumX = point.x,
                sumY = point.y,
            }
        end
    end

    local representatives = {}
    local startCluster
    for _, cluster in ipairs(clusters) do
        local cx, cy = cluster.sumX / #cluster, cluster.sumY / #cluster
        for _, point in ipairs(cluster) do
            if SameCoordinate(start, point) then
                startCluster = cluster
            end
        end

        local representative, bestDistance
        for _, point in ipairs(cluster) do
            local distance = (point.x - cx) ^ 2 + (point.y - cy) ^ 2
            if not bestDistance or distance < bestDistance then
                representative, bestDistance = point, distance
            end
        end
        representatives[#representatives + 1] = {
            cluster = cluster,
            point = cluster == startCluster and start or representative,
        }
    end

    -- The selected coordinate starts the route even if it was rounded slightly
    -- differently from the generated record.
    local ordered, remaining = { start }, {}
    for _, entry in ipairs(representatives) do
        if entry.cluster ~= startCluster then
            remaining[#remaining + 1] = entry.point
        end
    end

    local current = start
    while #remaining > 0 do
        local bestIndex, bestDistance
        for index, point in ipairs(remaining) do
            local distance = DistanceSquared(current, point)
            if not bestDistance or distance < bestDistance then
                bestIndex, bestDistance = index, distance
            end
        end
        current = table.remove(remaining, bestIndex)
        ordered[#ordered + 1] = current
    end

    return ordered
end

local function MatchesBlizzardWaypoint(candidate)
    if not candidate or not C_Map or not C_Map.GetUserWaypoint then
        return false
    end
    local waypoint = C_Map.GetUserWaypoint()
    return waypoint and waypoint.position
        and waypoint.uiMapID == candidate.mapID
        and math.abs(waypoint.position.x - candidate.x) < 0.0005
        and math.abs(waypoint.position.y - candidate.y) < 0.0005
end

local function BuildNavigator()
    if navigator then
        return navigator
    end

    local frame = CreateFrame("Frame", nil, UIParent)
    frame:SetSize(420, 104)
    frame:SetPoint("TOP", UIParent, "TOP", 0, -52)
    frame:SetFrameStrata("MEDIUM")
    frame:SetFrameLevel(40)
    frame:EnableMouse(false)
    frame:Hide()

    ns.UI.Theme:ApplyTrackerArtwork(frame, 116, 62)

    frame.Eyebrow = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    frame.Eyebrow:SetPoint("TOPLEFT", frame, "TOPLEFT", 118, -24)
    frame.Eyebrow:SetPoint("RIGHT", frame, "RIGHT", -56, 0)
    frame.Eyebrow:SetJustifyH("LEFT")
    frame.Eyebrow:SetText(ns.L["NAV_HUNT_LABEL"])
    frame.Eyebrow:SetTextColor(0.27, 0.72, 0.68)

    frame.Title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.Title:SetPoint("TOPLEFT", frame.Eyebrow, "BOTTOMLEFT", 0, -2)
    frame.Title:SetPoint("RIGHT", frame, "RIGHT", -56, 0)
    frame.Title:SetJustifyH("LEFT")
    frame.Title:SetTextColor(0.94, 0.82, 0.48)

    frame.Step = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    frame.Step:SetPoint("TOPLEFT", frame.Title, "BOTTOMLEFT", 0, -3)
    frame.Step:SetPoint("RIGHT", frame, "RIGHT", -56, 0)
    frame.Step:SetHeight(22)
    frame.Step:SetJustifyH("LEFT")
    frame.Step:SetJustifyV("TOP")
    frame.Step:SetWordWrap(true)
    frame.Step:SetTextColor(0.78, 0.76, 0.69)

    frame.ProgressGlow = frame:CreateTexture(nil, "ARTWORK")
    frame.ProgressGlow:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.ProgressGlow:SetBlendMode("ADD")
    frame.ProgressGlow:SetColorTexture(0.20, 0.95, 0.86, 0.20)
    frame.ProgressGlow:SetSize(16, 16)
    frame.ProgressGlow:SetRotation(math.pi / 4)

    frame.Progress = frame:CreateTexture(nil, "OVERLAY")
    frame.Progress:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.Progress:SetColorTexture(0.38, 0.93, 0.84, 1)
    frame.Progress:SetSize(7, 7)
    frame.Progress:SetRotation(math.pi / 4)
    frame.Progress:SetPoint("CENTER", frame.ProgressGlow, "CENTER")

    navigator = frame
    return frame
end

local function UpdateNavigatorText()
    if not target then
        if navigator then
            navigator:Hide()
        end
        return
    end

    local frame = BuildNavigator()
    frame.Title:SetText(target.title or ns.L["NAV_DEFAULT_TITLE"])

    if patrol and #patrol.points > 1 then
        local fraction = (patrol.index - 1) / (#patrol.points - 1)
        local railX = ns.UI.Theme:GetTrackerRailStop(
            frame:GetWidth(), 116, 62, fraction)
        frame.ProgressGlow:ClearAllPoints()
        frame.ProgressGlow:SetPoint("CENTER", frame, "BOTTOMLEFT", railX, 21)
        frame.ProgressGlow:Show()
        frame.Progress:Show()
    else
        frame.ProgressGlow:Hide()
        frame.Progress:Hide()
    end

    if routeStep and routeStep.description and routeStep.description ~= "" then
        frame.Step:SetText(ns.L["NAV_ROUTE_STEP"]:format(routeStep.description))
    elseif patrol and #patrol.points > 1 then
        local distance = C_Navigation and C_Navigation.GetDistance
            and C_Navigation.GetDistance() or nil
        if type(distance) == "number" then
            frame.Step:SetText(ns.L["NAV_PATROL_PROGRESS_DISTANCE"]:format(
                patrol.index, #patrol.points, math.floor(distance + 0.5)))
        else
            frame.Step:SetText(ns.L["NAV_PATROL_PROGRESS"]:format(
                patrol.index, #patrol.points))
        end
    elseif target.zone and target.zone ~= "" then
        frame.Step:SetText(ns.L["NAV_ROUTE_DESTINATION"]:format(target.zone))
    else
        frame.Step:SetText(ns.L["NAV_ROUTE_CALCULATING"])
    end
end

local function RefreshRouteStep()
    routeStep = nil
    if target and C_Map and C_Map.GetBestMapForUnit
        and C_SuperTrack and C_SuperTrack.GetNextWaypointForMap then
        local currentMapID = C_Map.GetBestMapForUnit("player")
        if currentMapID then
            local x, y, description = C_SuperTrack.GetNextWaypointForMap(currentMapID)
            if x and y then
                routeStep = {
                    mapID = currentMapID,
                    x = x,
                    y = y,
                    description = description,
                }
            end
        end
    end
    UpdateNavigatorText()
end

local function BuildMarker()
    if marker then
        return marker
    end

    local frame = CreateFrame("Frame", nil, UIParent)
    frame:SetSize(220, 278)
    frame:SetFrameStrata("LOW")
    frame:SetFrameLevel(20)
    frame:EnableMouse(false)
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame:Hide()

    -- Quest-like field beacon: a warm halo, pale shaft and tight core. The
    -- layers are deliberately quiet at the edge, so it reads as a destination
    -- rather than a column pasted in front of the terrain.
    local aura = frame:CreateTexture(nil, "BACKGROUND")
    aura:SetTexture("Interface\\Buttons\\WHITE8X8")
    aura:SetBlendMode("ADD")
    aura:SetColorTexture(1.00, 0.80, 0.25, 0.10)
    aura:SetSize(56, 224)
    aura:SetPoint("BOTTOM", frame, "CENTER", 0, 28)

    local glow = frame:CreateTexture(nil, "BORDER")
    glow:SetTexture("Interface\\Buttons\\WHITE8X8")
    glow:SetBlendMode("ADD")
    glow:SetColorTexture(0.94, 0.95, 0.50, 0.28)
    glow:SetSize(16, 224)
    glow:SetPoint("BOTTOM", frame, "CENTER", 0, 28)

    local beam = frame:CreateTexture(nil, "ARTWORK")
    beam:SetTexture("Interface\\Buttons\\WHITE8X8")
    beam:SetBlendMode("ADD")
    beam:SetColorTexture(1.00, 0.95, 0.60, 0.88)
    beam:SetSize(4, 224)
    beam:SetPoint("BOTTOM", frame, "CENTER", 0, 28)

    local diamondGlow = frame:CreateTexture(nil, "ARTWORK")
    diamondGlow:SetTexture("Interface\\Buttons\\WHITE8X8")
    diamondGlow:SetBlendMode("ADD")
    diamondGlow:SetColorTexture(1.00, 0.80, 0.18, 0.48)
    diamondGlow:SetSize(58, 58)
    diamondGlow:SetPoint("BOTTOM", frame, "BOTTOM", 0, 30)
    diamondGlow:SetRotation(math.pi / 4)

    local diamond = frame:CreateTexture(nil, "OVERLAY")
    diamond:SetTexture("Interface\\Buttons\\WHITE8X8")
    diamond:SetColorTexture(0.96, 0.78, 0.20, 1.00)
    diamond:SetSize(48, 48)
    diamond:SetPoint("CENTER", diamondGlow, "CENTER")
    diamond:SetRotation(math.pi / 4)

    local rim = frame:CreateTexture(nil, "OVERLAY", nil, 1)
    rim:SetTexture("Interface\\Buttons\\WHITE8X8")
    rim:SetColorTexture(0.08, 0.06, 0.025, 0.98)
    rim:SetSize(42, 42)
    rim:SetPoint("CENTER", diamond, "CENTER")
    rim:SetRotation(math.pi / 4)

    local portraitRing = frame:CreateTexture(nil, "OVERLAY", nil, 2)
    portraitRing:SetTexture("Interface\\Buttons\\WHITE8X8")
    portraitRing:SetColorTexture(0.055, 0.040, 0.018, 1.00)
    portraitRing:SetSize(31, 31)
    portraitRing:SetPoint("CENTER", diamond, "CENTER")

    local portrait = frame:CreateTexture(nil, "OVERLAY", nil, 3)
    portrait:SetSize(27, 27)
    portrait:SetPoint("CENTER", portraitRing, "CENTER")
    portrait:SetTexture("Interface\\Icons\\Ability_Hunter_BeastCall")
    portrait:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    frame.Portrait = portrait

    frame.Title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.Title:SetPoint("TOP", diamondGlow, "BOTTOM", 0, -8)
    frame.Title:SetWidth(210)
    frame.Title:SetJustifyH("CENTER")
    frame.Title:SetTextColor(1.00, 0.84, 0.24)
    frame.Title:SetShadowColor(0, 0, 0, 1)
    frame.Title:SetShadowOffset(1, -1)

    frame.elapsed = 0
    frame.navigationFrame = nil

    local function AnchorToNavigationFrame(self)
        if not C_Navigation or not C_Navigation.GetFrame then
            return false
        end
        local navigationFrame = C_Navigation.GetFrame()
        if not navigationFrame then
            return false
        end
        if self.navigationFrame ~= navigationFrame then
            self.navigationFrame = navigationFrame
            self:ClearAllPoints()
            self:SetPoint("CENTER", navigationFrame, "CENTER", 0, 0)
        end
        return true
    end

    frame:SetScript("OnUpdate", function(self, elapsed)
        self.elapsed = self.elapsed + elapsed
        if self.elapsed < UPDATE_INTERVAL then
            return
        end
        self.elapsed = 0

        if not target or not C_SuperTrack.IsSuperTrackingUserWaypoint() then
            self:Hide()
            if navigator then
                navigator:Hide()
            end
            return
        end


        if UpdatePatrol then
            UpdatePatrol()
        end

        local distance = C_Navigation and C_Navigation.GetDistance
            and C_Navigation.GetDistance() or nil
        local closeEnough = type(distance) == "number"
            and distance <= WORLD_BEACON_MAX_DISTANCE

        -- A map waypoint has X/Y but no terrain height. From far away the
        -- client's projected frame can land on an intervening cliff, making a
        -- decorative vertical beam look like an exact ground marker. Only use
        -- that beacon in the final approach; at range the route card and
        -- Blizzard's own navigation remain the honest directional guidance.
        if closeEnough and AnchorToNavigationFrame(self) then
            self:SetAlpha(1)
            if routeStep and routeStep.mapID ~= target.mapID then
                UpdateNavigatorText()
                navigator:Show()
            elseif navigator then
                navigator:Hide()
            end
        else
            self:SetAlpha(0)
            UpdateNavigatorText()
            navigator:Show()
        end
    end)

    marker = frame
    return frame
end

local function UpdateMarkerPortrait(frame, candidate)
    local displayID
    if candidate.creatureID then
        local record = ns.Data.RareCatalog:Get(candidate.creatureID)
        displayID = record and record.displayIDs and record.displayIDs[1]
    end
    if not displayID and candidate.appearanceID then
        local appearance = ns.Data.API:GetAppearance(candidate.appearanceID)
        displayID = appearance and appearance.displayIDs and appearance.displayIDs[1]
    end

    local applied = displayID and SetPortraitTextureFromCreatureDisplayID
        and pcall(SetPortraitTextureFromCreatureDisplayID, frame.Portrait, displayID)
    if not applied then
        frame.Portrait:SetTexture("Interface\\Icons\\Ability_Hunter_BeastCall")
        frame.Portrait:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    else
        frame.Portrait:SetTexCoord(0, 1, 0, 1)
    end
end

function NativeWaypoint:IsTarget(mapID, x, y)
    local candidate = { mapID = mapID, x = x, y = y }
    return SameCoordinate(target, candidate) and MatchesBlizzardWaypoint(candidate)
        and C_SuperTrack and C_SuperTrack.IsSuperTrackingUserWaypoint
        and C_SuperTrack.IsSuperTrackingUserWaypoint()
end

function NativeWaypoint:IsAvailable()
    return C_Map and C_Map.CanSetUserWaypointOnMap and C_Map.SetUserWaypoint
        and UiMapPoint and UiMapPoint.CreateFromCoordinates
        and C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint
        and C_SuperTrack.IsSuperTrackingUserWaypoint
end

--- Returns whether a creature belongs to the appearance currently being
--- hunted. Creature ids are locale independent, unlike visible NPC names.
function NativeWaypoint:IsTrackedCreature(creatureID)
    if not target or not creatureID then
        return false
    end
    if target.creatureID == creatureID then
        return true
    end
    if not target.appearanceID then return false end
    for _, candidateID in ipairs(
        ns.Data.API:GetCreatureIDsByAppearance(target.appearanceID)) do
        if candidateID == creatureID then
            return true
        end
    end
    return false
end

function NativeWaypoint:IsHuntingAppearance(appearanceID)
    return target ~= nil and target.appearanceID == appearanceID
        and patrol ~= nil
end

function NativeWaypoint:IsHuntingCreature(creatureID)
    return target ~= nil and target.creatureID == creatureID
        and patrol ~= nil
end

function NativeWaypoint:GetPatrolState(appearanceID)
    if not self:IsHuntingAppearance(appearanceID) then
        return nil
    end
    return {
        index = patrol.index,
        total = #patrol.points,
        mapID = target.mapID,
        x = target.x,
        y = target.y,
    }
end

local function ActivateCandidate(candidate, playSound)
    if not NativeWaypoint:IsAvailable()
        or not C_Map.CanSetUserWaypointOnMap
        or not C_Map.CanSetUserWaypointOnMap(candidate.mapID) then
        ns.Print(ns.L["NAV_UNAVAILABLE"])
        return nil
    end

    local point = UiMapPoint.CreateFromCoordinates(
        candidate.mapID, candidate.x, candidate.y)
    local previousTarget = target
    target = candidate
    local ok, err = pcall(C_Map.SetUserWaypoint, point)
    if not ok then
        target = previousTarget
        ns.Debug:ReportError("native waypoint", err)
        ns.Print(ns.L["NAV_UNAVAILABLE"])
        return nil
    end

    routeStep = nil
    if patrol then
        patrol.arrivedAt = nil
        patrol.ignoreUntil = GetTime() + 1.25
    end
    C_SuperTrack.SetSuperTrackedUserWaypoint(true)

    local frame = BuildMarker()
    frame.Title:SetText(candidate.title or ns.L["NAV_DEFAULT_TITLE"])
    UpdateMarkerPortrait(frame, candidate)
    frame.navigationFrame = nil
    frame:SetAlpha(0)
    frame:Show()

    UpdateNavigatorText()
    navigator:Show()
    RefreshRouteStep()

    if C_Timer and C_Timer.After then
        C_Timer.After(0.25, function()
            if SameCoordinate(target, candidate) then
                RefreshRouteStep()
            end
        end)
    end

    if playSound and SOUNDKIT and SOUNDKIT.UI_MAP_WAYPOINT_CHAT_SHARE then
        PlaySound(SOUNDKIT.UI_MAP_WAYPOINT_CHAT_SHARE)
    end

    ns.Events:Send("NAVIGATION_CHANGED", candidate)
    return true
end

local function AdvancePatrol()
    if not patrol or not target or #patrol.points <= 1 then
        return false
    end

    patrol.index = patrol.index % #patrol.points + 1
    local point = patrol.points[patrol.index]
    local candidate = {
        mapID = point.mapID,
        x = point.x,
        y = point.y,
        title = target.title,
        zone = target.zone,
        appearanceID = target.appearanceID,
        creatureID = target.creatureID,
    }

    ns.Debug:Log("NAV", "patrol advanced to %d/%d at %.1f, %.1f",
        patrol.index, #patrol.points, point.x * 100, point.y * 100)
    return ActivateCandidate(candidate, false)
end

UpdatePatrol = function()
    if not patrol or not target or #patrol.points <= 1
        or (patrol.ignoreUntil and GetTime() < patrol.ignoreUntil)
        or not C_Navigation or not C_Navigation.GetDistance then
        return
    end

    local playerPosition = C_Map.GetPlayerMapPosition(target.mapID, "player")
    if not playerPosition then
        patrol.arrivedAt = nil
        return
    end

    local distance = C_Navigation.GetDistance()
    if type(distance) ~= "number" or distance > ARRIVAL_DISTANCE_YARDS then
        patrol.arrivedAt = nil
        return
    end

    patrol.arrivedAt = patrol.arrivedAt or GetTime()
    if GetTime() - patrol.arrivedAt >= ARRIVAL_DWELL_SECONDS then
        AdvancePatrol()
    end
end

function NativeWaypoint:Clear()
    local ownedTarget = target
    target = nil
    routeStep = nil
    patrol = nil
    if marker then
        marker:Hide()
        marker.navigationFrame = nil
    end
    if navigator then
        navigator:Hide()
    end
    if MatchesBlizzardWaypoint(ownedTarget) and C_Map.ClearUserWaypoint then
        C_Map.ClearUserWaypoint()
    end
    ns.Events:Send("NAVIGATION_CHANGED")
end

--- Starts an automatic patrol for the appearance. The selected coordinate is
-- the first stop; nearby samples collapse into search zones and the remaining
-- zones are ordered to minimize backtracking. Selecting the active coordinate
-- again stops the complete hunt.
function NativeWaypoint:Select(mapID, x, y, title, zone, appearanceID,
    routePoints, creatureID)
    local candidate = {
        mapID = mapID,
        x = x,
        y = y,
        title = title,
        zone = zone,
        appearanceID = appearanceID,
        creatureID = creatureID,
    }
    if self:IsTarget(mapID, x, y) then
        self:Clear()
        return false
    end

    local previousPatrol = patrol
    local points = (appearanceID or routePoints)
        and BuildPatrol(appearanceID, mapID, x, y, routePoints)
        or { { mapID = mapID, x = x, y = y } }
    patrol = {
        appearanceID = appearanceID,
        creatureID = creatureID,
        points = points,
        index = 1,
    }

    local activated = ActivateCandidate(candidate, true)
    if not activated then
        patrol = previousPatrol
        return activated
    end

    ns.Debug:Log("NAV", "automatic patrol started with %d search zones",
        #points)
    return true
end

--- Starts the hunt from the best known location with one UI action. A point on
-- the player's current map wins; within that map the nearest normalized point
-- starts the ordered patrol. Calling it again for the active appearance stops.
function NativeWaypoint:StartAppearance(appearanceID, title)
    if self:IsHuntingAppearance(appearanceID) then
        self:Clear()
        return false
    end

    local locations = {}
    for _, creatureID in ipairs(
        ns.Data.API:GetCreatureIDsByAppearance(appearanceID)) do
        for _, location in ipairs(ns.Data.API:GetLocationsByCreature(creatureID)) do
            if location.x and location.y then
                locations[#locations + 1] = location
            end
        end
    end

    if #locations == 0 then
        ns.Print(ns.L["NAV_NO_EXACT_LOCATIONS"])
        return nil
    end

    local selected = locations[1]
    local bestDistance
    for _, location in ipairs(locations) do
        local playerPosition = C_Map.GetPlayerMapPosition(location.mapID, "player")
        if playerPosition then
            local distance = (playerPosition.x - location.x) ^ 2
                + (playerPosition.y - location.y) ^ 2
            if not bestDistance or distance < bestDistance then
                selected, bestDistance = location, distance
            end
        end
    end

    local mapInfo = C_Map.GetMapInfo(selected.mapID)
    return self:Select(selected.mapID, selected.x, selected.y, title,
        mapInfo and mapInfo.name, appearanceID)
end

function NativeWaypoint:OnEnable()
    if not self:IsAvailable() then
        ns.Debug:Warn("NAV", "native navigation overlay unavailable")
        return
    end

    ns.Events:RegisterEvent("USER_WAYPOINT_UPDATED", self, function()
        if target and not MatchesBlizzardWaypoint(target) then
            -- Blizzard may remove the native point as it announces arrival.
            -- The scheduled patrol advance owns that transition; treating it
            -- as a manual cancellation would stop the hunt at every zone.
            if patrol and patrol.arrivedAt then
                return
            end
            target = nil
            routeStep = nil
            patrol = nil
            if marker then
                marker:Hide()
            end
            if navigator then
                navigator:Hide()
            end
            ns.Events:Send("NAVIGATION_CHANGED")
        end
    end)

    local function OnRouteChanged()
        if target then
            RefreshRouteStep()
        end
    end
    ns.Events:RegisterEvent("SUPER_TRACKING_PATH_UPDATED", self, OnRouteChanged)
    ns.Events:RegisterEvent("ZONE_CHANGED_NEW_AREA", self, OnRouteChanged)

    ns.Events:RegisterEvent("NAVIGATION_DESTINATION_REACHED", self, function()
        if patrol and target then
            patrol.arrivedAt = patrol.arrivedAt or GetTime()
            local arrivedTarget = target
            C_Timer.After(ARRIVAL_DWELL_SECONDS, function()
                if patrol and SameCoordinate(target, arrivedTarget) then
                    AdvancePatrol()
                end
            end)
        end
    end)

    ns.Events:Subscribe("BEAST_DETECTED", self,
        function(_, _, creatureID)
            if not self:IsTrackedCreature(creatureID) then
                return
            end

            -- Let every BEAST_DETECTED subscriber render against the still
            -- active hunt before clearing it on the next frame.
            C_Timer.After(0, function()
                if self:IsTrackedCreature(creatureID) then
                    ns.Print(ns.L["NAV_TARGET_FOUND"])
                    self:Clear()
                end
            end)
        end)

    ns.Debug:Log("NAV", "native waypoint navigation available")
end
