--[[ Native, dependency-free rare-beast layer for Blizzard's world map. ]]

local _, ns = ...

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

local ROUTE_COLORS = {
    { 0.96, 0.25, 0.66 }, { 0.18, 0.86, 0.78 },
    { 1.00, 0.66, 0.16 }, { 0.58, 0.42, 1.00 },
    { 0.48, 0.92, 0.30 }, { 0.28, 0.62, 1.00 },
}
local ACTIVE_COLOR = { 1.00, 0.82, 0.16 }

-- One table for every way a pin reports its collection state, so the halo and
-- the tooltip cannot drift apart. The tooltip has room to say how the entry was
-- earned; the halo only has twenty pixels, so it answers the one question worth
-- asking at a glance -- do I still need this beast -- and leaves the rest to the
-- tooltip. Crimson and gold are far enough apart to read while panning a map.
local STATE = {
    missing  = { color = { 1.00, 0.35, 0.30 }, key = "DETAIL_UNDISCOVERED" },
    captured = { color = { 0.45, 0.95, 0.62 }, key = "RARE_CATALOG_CAPTURED" },
    killed   = { color = { 0.96, 0.38, 0.24 }, key = "RARE_CATALOG_KILLED" },
    both     = { color = { 1.00, 0.76, 0.24 },
                 key = "RARE_CATALOG_CAPTURED_AND_KILLED" },
}
-- Two emblems, same silhouette, generated from one another by
-- tools/make_paw_variants.py. Crimson is what the player still owes; turquoise
-- is the reward for having caught it. The distinction is hue, never brightness:
-- a dimmed paw would read as "disabled", and in the journal a faded card
-- already means the opposite -- not obtained.
local PAW_ART = "Interface\\AddOns\\ShinyHunter\\Assets\\UI\\shiny-hunter-map-paw"
local PAW_COLLECTED = PAW_ART .. ".png"
local PAW_WANTED = PAW_ART .. "-wanted.png"

-- The emblem is drawn inset so its glow does not touch the pin edge. Setting a
-- texture resets these, so they are re-applied with every swap.
local PAW_INSET = { 0.06, 0.94, 0.06, 0.94 }
local RARE_PIN_TEMPLATE = "ShinyHunterRareMapPinTemplate"
local SPOT_PIN_TEMPLATE = "ShinyHunterSearchSpotPinTemplate"
local dataProvider
local installed = false
local target
local selectedRoutes = {}
local nextColor = 1
local mapOpenRefreshQueued = false

local function RareRouteKey(npcID)
    return "npc:" .. tostring(npcID)
end

local function RareIDFromRouteKey(routeKey)
    return type(routeKey) == "string"
        and tonumber(routeKey:match("^npc:(%d+)$")) or nil
end

local function AppearanceName(appearanceID)
    local appearance = ns.Data.API:GetAppearance(appearanceID)
    return appearance and ns.L[appearance.nameKey] or ns.L["NAV_DEFAULT_TITLE"]
end

local function RouteName(routeKey)
    local npcID = RareIDFromRouteKey(routeKey)
    if npcID then
        local rare = ns.Data.RareCatalog:Get(npcID)
        return rare and rare.name or ns.L["NAV_DEFAULT_TITLE"]
    end
    return AppearanceName(routeKey)
end

local function FamilyName(record)
    local family = record and record.family
        and ns.Data.API:GetFamily(record.family)
    return family and ns.L[family.nameKey]
        or ns.L["RARE_CATALOG_FAMILY_UNKNOWN"]
end

local function GetAppearanceLocations(appearanceID, mapID)
    local result, seen = {}, {}
    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
                    result[#result + 1] = location
                end
            end
        end
    end
    return result
end

local function GetRouteLocations(routeKey, mapID)
    local npcID = RareIDFromRouteKey(routeKey)
    if npcID then
        return ns.Data.RareCatalog:GetLocations(npcID, mapID)
    end
    return GetAppearanceLocations(routeKey, mapID)
end

-- Returns the STATE entry for a route, and whether it counts as collected.
-- A rare answers from its own captured/defeated record; an appearance route
-- answers from the journal. Returns nil only when the route names neither, in
-- which case the pin keeps the collected emblem rather than accusing the player
-- of missing something the addon cannot actually check.
local function RouteState(routeKey)
    local npcID = RareIDFromRouteKey(routeKey)
    if npcID then
        if not ns.Data.RareCatalog:Get(npcID) then return nil end
        local outcome = ns.Collection.RareState:GetOutcome(npcID)
        return STATE[outcome or "missing"], outcome ~= nil
    end

    if ns.Data.API:GetAppearance(routeKey) then
        local collected = ns.Collection.State:IsCollected(routeKey)
        return STATE[collected and "captured" or "missing"], collected
    end
    return nil
end

local function ApplyPinState(pin, routeKey)
    local _, collected = RouteState(routeKey)
    pin.Icon:SetTexture(collected == false and PAW_WANTED or PAW_COLLECTED)
    pin.Icon:SetTexCoord(PAW_INSET[1], PAW_INSET[2], PAW_INSET[3], PAW_INSET[4])
end

local function Refresh()
    if not dataProvider or not dataProvider.GetMap or not dataProvider:GetMap() then
        return
    end

    -- Nothing is refreshed onto a map the player cannot see. Tracking a beast
    -- or recording a capture used to rebuild the pin layer on a closed canvas,
    -- which is both wasted work and where Blizzard's pin pool asserted:
    -- releasing and acquiring pins on a canvas that is not laid out is not a
    -- state the map is built for. The OnShow hook below refreshes on open, so
    -- the map is still correct the moment it appears.
    if WorldMapFrame and not WorldMapFrame:IsShown() then
        return
    end

    dataProvider:RefreshAllData()
end

-- A map provider installed before the first canvas layout is not always asked
-- for data on Retail. Refresh once the map has a live canvas, then once more
-- after its own provider pass. This makes all hunter pins appear on first open
-- rather than requiring the player to click an existing pin to wake the layer.
local function RefreshWhenMapOpens()
    if mapOpenRefreshQueued then return end
    mapOpenRefreshQueued = true
    for _, delay in ipairs({ 0, 0.16 }) do
        C_Timer.After(delay, function()
            if WorldMapFrame and WorldMapFrame:IsShown() then
                Refresh()
            end
        end)
    end
    C_Timer.After(0.20, function()
        mapOpenRefreshQueued = false
    end)
end

local function ToggleRoute(routeKey)
    if selectedRoutes[routeKey] then
        selectedRoutes[routeKey] = nil
    else
        local used = {}
        for _, colorIndex in pairs(selectedRoutes) do used[colorIndex] = true end
        local chosen
        for offset = 0, #ROUTE_COLORS - 1 do
            local candidate = (nextColor + offset - 1) % #ROUTE_COLORS + 1
            if not used[candidate] then
                chosen = candidate
                break
            end
        end
        chosen = chosen or nextColor
        selectedRoutes[routeKey] = chosen
        nextColor = chosen % #ROUTE_COLORS + 1
    end
    Refresh()
end

local function ShowRoute(routeKey)
    if not selectedRoutes[routeKey] then ToggleRoute(routeKey) else Refresh() end
end

local function StartHunt(routeKey)
    local npcID = RareIDFromRouteKey(routeKey)
    if not npcID then
        return ns.Navigation.NativeWaypoint:StartAppearance(
            routeKey, RouteName(routeKey))
    end

    local record = ns.Data.RareCatalog:Get(npcID)
    if not record then return false end
    local navigation = ns.Navigation.NativeWaypoint
    if navigation:IsHuntingCreature(npcID) then
        navigation:Clear()
        return false
    end

    local locations = ns.Data.RareCatalog:GetLocations(npcID, record.mapID)
    if #locations == 0 then
        ns.Print(ns.L["NAV_NO_EXACT_LOCATIONS"])
        return false
    end
    local selected, bestDistance = locations[1]
    local playerPosition = C_Map.GetPlayerMapPosition(record.mapID, "player")
    if playerPosition then
        for _, location in ipairs(locations) do
            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)
    ShowRoute(routeKey)
    return navigation:Select(selected.mapID, selected.x, selected.y,
        record.name, mapInfo and mapInfo.name, nil, locations, npcID)
end

-- Every map addon shares this band: RareScanner, HandyNotes, GatherMate2 and
-- Waypoint UI all ask for it through HereBeDragons. The canvas hands out a
-- higher frame level from the band on each request, so the pin drawn on top is
-- whichever one asked most recently.
--
-- Claiming it only in OnLoad -- once per pin, at pool creation -- meant our
-- pins kept their first level for the whole session while every other addon
-- climbed past on each of their refreshes. That is why the paw always ended up
-- underneath. HereBeDragons re-claims on acquire, and matching that is the
-- only way to stay level with the addons that use it.
local PIN_LEVEL_BAND = "PIN_FRAME_LEVEL_AREA_POI"

-- The XML templates need globally addressable mixins. State and data remain
-- private to this module; these globals only implement their visual behavior.
ShinyHunterRareMapPinMixin = CreateFromMixins(MapCanvasPinMixin)

function ShinyHunterRareMapPinMixin:OnLoad()
    self:UseFrameLevelType(PIN_LEVEL_BAND)
    self:SetScalingLimits(1, 1.0, 1.15)
    self:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end

function ShinyHunterRareMapPinMixin:SetPassThroughButtons() end

function ShinyHunterRareMapPinMixin:OnAcquired(routeKey, x, y, color)
    -- Re-claim the band on every acquire; the canvas applies the level it
    -- hands back once this returns.
    self:UseFrameLevelType(PIN_LEVEL_BAND)
    self.routeKey = routeKey
    self:SetPosition(x, y)
    ApplyPinState(self, routeKey)
    self.Ring:SetShown(color ~= nil)
    if color then self.Ring:SetVertexColor(color[1], color[2], color[3], 0.95) end
end

function ShinyHunterRareMapPinMixin:OnReleased()
    self.routeKey = nil
    self.Ring:Hide()
end

function ShinyHunterRareMapPinMixin:OnMouseEnter()
    local npcID = RareIDFromRouteKey(self.routeKey)
    local record = npcID and ns.Data.RareCatalog:Get(npcID)
    local locations = GetRouteLocations(self.routeKey, self:GetMap():GetMapID())
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText(RouteName(self.routeKey))
    if record then
        local state = RouteState(self.routeKey) or STATE.missing
        local r, g, b = state.color[1], state.color[2], state.color[3]
        GameTooltip:AddDoubleLine(FamilyName(record),
            ns.L[state.key], 0.25, 0.88, 0.80, r, g, b)
    else
        GameTooltip:AddLine(ns.L["MAP_RARE_AUTOMATIC"], 0.92, 0.30, 0.24)
    end
    GameTooltip:AddLine(ns.Plural("MAP_RARE_POINTS", #locations):format(
        #locations), 1.00, 0.82, 0.20)
    GameTooltip:AddLine(selectedRoutes[self.routeKey]
        and ns.L["MAP_ROUTE_REMOVE_HINT"] or ns.L["MAP_ROUTE_ADD_HINT"],
        0.78, 0.76, 0.69, true)
    GameTooltip:AddLine(ns.L["MAP_RARE_TRACK_HINT"], 0.35, 0.85, 0.78, true)
    GameTooltip:Show()
end

function ShinyHunterRareMapPinMixin:OnMouseLeave() GameTooltip:Hide() end
function ShinyHunterRareMapPinMixin:OnClick(button)
    if button == "RightButton" then
        StartHunt(self.routeKey)
    else
        ToggleRoute(self.routeKey)
    end
end

ShinyHunterSearchSpotPinMixin = CreateFromMixins(MapCanvasPinMixin)

local function EnsureRouteLine(pin)
    if pin.RouteLine then return pin.RouteLine end
    pin.RouteLine = pin:CreateLine(nil, "BACKGROUND")
    pin.RouteLine:SetThickness(10)
    pin.RouteLine:Hide()
    return pin.RouteLine
end

function ShinyHunterSearchSpotPinMixin:OnLoad()
    self:UseFrameLevelType(PIN_LEVEL_BAND)
    self:SetScalingLimits(1, 1.0, 1.15)
    self.Core:SetRotation(math.pi / 4)
    EnsureRouteLine(self)
end

function ShinyHunterSearchSpotPinMixin:SetPassThroughButtons() end

function ShinyHunterSearchSpotPinMixin:OnAcquired(routeKey, location, color,
    previousSpot)
    local routeLine = EnsureRouteLine(self)
    self:UseFrameLevelType(PIN_LEVEL_BAND)
    self.routeKey, self.location, self.color = routeKey, location, color
    self:SetPosition(location.x, location.y)
    self.Fill:SetVertexColor(color[1], color[2], color[3], 0.30)
    self.Core:SetVertexColor(color[1], color[2], color[3], 0.95)
    routeLine:ClearAllPoints()
    if previousSpot then
        routeLine:SetStartPoint("CENTER", previousSpot)
        routeLine:SetEndPoint("CENTER", self)
        routeLine:SetColorTexture(color[1], color[2], color[3], 0.28)
        routeLine:Show()
    else
        routeLine:Hide()
    end
end

function ShinyHunterSearchSpotPinMixin:OnReleased()
    self.routeKey, self.location, self.color = nil, nil, nil
    -- Blizzard's frame pool may reset a newly created pin before its first
    -- OnLoad/OnAcquired cycle. Dynamic regions therefore do not necessarily
    -- exist yet when OnReleased runs.
    if self.RouteLine then
        self.RouteLine:Hide()
        self.RouteLine:ClearAllPoints()
    end
end

function ShinyHunterSearchSpotPinMixin:OnMouseEnter()
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText(RouteName(self.routeKey))
    GameTooltip:AddLine(ns.L["MAP_SEARCH_AREA"],
        self.color[1], self.color[2], self.color[3])
    GameTooltip:AddLine(ns.L["MAP_SEARCH_COORDS"]:format(
        self.location.x * 100, self.location.y * 100), 1.00, 0.82, 0.20)
    GameTooltip:AddLine(ns.L["MAP_SEARCH_HINT"], 0.75, 0.75, 0.75, true)
    GameTooltip:Show()
end

function ShinyHunterSearchSpotPinMixin:OnMouseLeave() GameTooltip:Hide() end

function ShinyHunterSearchSpotPinMixin:OnClick()
    local mapInfo = C_Map.GetMapInfo(self.location.mapID)
    local npcID = RareIDFromRouteKey(self.routeKey)
    ns.Navigation.NativeWaypoint:Select(
        self.location.mapID, self.location.x, self.location.y,
        RouteName(self.routeKey), mapInfo and mapInfo.name,
        npcID and nil or self.routeKey,
        GetRouteLocations(self.routeKey, self.location.mapID), npcID)
end

local ProviderMixin = CreateFromMixins(MapCanvasDataProviderMixin)

-- Canvas calls that have already been reported, so a failing map does not
-- repeat the same line on every refresh.
local canvasFailures = {}

--- Calls a map canvas method, and names it if the canvas refuses.
--
-- Blizzard's map canvas asserts rather than returning an error, and its
-- message says only "Blizzard_MapCanvas.lua:280: assertion failed!" -- no
-- template, no map, no clue which of our calls it was. An assertion escaping
-- into our event handler also aborts the rest of the refresh, so a single
-- unlucky pin leaves the map half drawn.
--
-- This keeps the map working and turns an anonymous assertion into a line
-- that says which call, which template and which map.
local function ReportCanvasFailure(map, method, template, err)
    local key = method .. ":" .. tostring(template)
    if canvasFailures[key] then return end
    canvasFailures[key] = true

    ns.Debug:Warn("MAP", "canvas refused %s(%s) on map %s: %s",
        method, tostring(template),
        tostring(map.GetMapID and map:GetMapID()), tostring(err))
end

local function SafeCanvas(map, method, template, ...)
    local ok, result = pcall(map[method], map, template, ...)
    if ok then
        return true, result
    end

    ReportCanvasFailure(map, method, template, result)
    return false
end

function ProviderMixin:RemoveAllData()
    local map = self:GetMap()
    if not map then return end
    -- `RemoveAllPinsByTemplate` asserts when its template has never acquired a
    -- pin: the map has no pool for it yet. The provider is refreshed once as
    -- it is installed, before either Shiny Hunter pool exists, so only release
    -- templates the canvas has actually created.
    local pools = map.pinPools
    if pools and pools[RARE_PIN_TEMPLATE] then
        SafeCanvas(map, "RemoveAllPinsByTemplate", RARE_PIN_TEMPLATE)
    end
    if pools and pools[SPOT_PIN_TEMPLATE] then
        SafeCanvas(map, "RemoveAllPinsByTemplate", SPOT_PIN_TEMPLATE)
    end
end

local function AcquireRarePin(map, routeKey, locations)
    local colorIndex = selectedRoutes[routeKey]
    local color = colorIndex and ROUTE_COLORS[colorIndex]
    return SafeCanvas(map, "AcquirePin", RARE_PIN_TEMPLATE, routeKey,
        locations[1].x, locations[1].y, color)
end

local function AcquireTrail(map, routeKey, mapID, color)
    local previous
    for _, location in ipairs(GetRouteLocations(routeKey, mapID)) do
        -- The trail chains each spot to the one before it, so a refused pin
        -- must not become the anchor for the next: the line would be drawn to
        -- nowhere. Keep the previous good pin instead.
        local ok, pin = pcall(map.AcquirePin, map, SPOT_PIN_TEMPLATE,
            routeKey, location, color, previous)
        if not ok then
            ReportCanvasFailure(map, "AcquirePin", SPOT_PIN_TEMPLATE, pin)
            return
        end
        previous = pin
    end
end

function ProviderMixin:RefreshAllData()
    local map = self:GetMap()
    if not map then return end
    self:RemoveAllData()
    local mapID = map:GetMapID()
    if not mapID then return end
    local pinCount, trailCount = 0, 0

    for _, appearanceID in ipairs(ns.Data.API:GetRareAppearanceIDsByMap(mapID)) do
        local locations = GetAppearanceLocations(appearanceID, mapID)
        if #locations > 0 then
            AcquireRarePin(map, appearanceID, locations)
            pinCount = pinCount + 1
        end
    end
    for _, rare in ipairs(ns.Data.RareCatalog:GetByMap(mapID)) do
        if not ns.Data.API:GetCreature(rare.npcID) then
            local routeKey = RareRouteKey(rare.npcID)
            local locations = GetRouteLocations(routeKey, mapID)
            if #locations > 0 then
                AcquireRarePin(map, routeKey, locations)
                pinCount = pinCount + 1
            end
        end
    end
    for routeKey, colorIndex in pairs(selectedRoutes) do
        if #GetRouteLocations(routeKey, mapID) > 0 then
            AcquireTrail(map, routeKey, mapID, ROUTE_COLORS[colorIndex])
            trailCount = trailCount + 1
        end
    end
    local activeRouteKey = target and (target.creatureID
        and RareRouteKey(target.creatureID) or target.appearanceID)
    if activeRouteKey and not selectedRoutes[activeRouteKey]
        and #GetRouteLocations(activeRouteKey, mapID) > 0 then
        AcquireTrail(map, activeRouteKey, mapID, ACTIVE_COLOR)
        trailCount = trailCount + 1
    end
    ns.Debug:Log("MAP", "%d native rare pins, %d visible trails on map %d",
        pinCount, trailCount, mapID)
end

local function Install()
    if installed then return true end
    if not WorldMapFrame or not WorldMapFrame.AddDataProvider then return false end
    dataProvider = CreateFromMixins(ProviderMixin)
    WorldMapFrame:AddDataProvider(dataProvider)
    WorldMapFrame:HookScript("OnShow", RefreshWhenMapOpens)
    installed = true
    ns.Debug:Log("MAP", "native tameable-rare data provider installed")
    -- During login the map canvas has not completed its first refresh yet.
    -- Forcing one here trips Retail's internal pin-pool assertion. A visible
    -- map can safely refresh on the next frame; otherwise the canvas refreshes
    -- normally when the player opens the map.
    if WorldMapFrame:IsShown() then
        RefreshWhenMapOpens()
    end
    return true
end

function SearchAreaMap:GetSelectedRoutes() return selectedRoutes end
function SearchAreaMap:ToggleRoute(routeKey) ToggleRoute(routeKey) end
function SearchAreaMap:ShowRoute(routeKey) ShowRoute(routeKey) end

function SearchAreaMap:OnEnable()
    if not Install() then
        ns.Events:RegisterEvent("ADDON_LOADED", self, function()
            if Install() then ns.Events:UnregisterEvent("ADDON_LOADED", self) end
        end)
    end
    ns.Events:Subscribe("NAVIGATION_CHANGED", self, function(_, candidate)
        target = candidate
        -- Deferred by one frame on purpose. Starting a hunt opens the map and
        -- changes its zone, and this message arrives inside that call stack --
        -- so a straight refresh released and acquired pins while the canvas
        -- was still swapping pools underneath, which is where its assertion
        -- came from. Next frame the canvas is settled and the same work is
        -- ordinary.
        C_Timer.After(0, Refresh)
    end)
    ns.Events:Subscribe("COLLECTION_CHANGED", self, Refresh)
    -- Rares keep their own record, separate from appearances, and it is the one
    -- the halo reads. Without this the pin kept saying "still missing" until the
    -- map was closed and reopened -- precisely at the moment the player has just
    -- earned the entry and is most likely to look.
    ns.Events:Subscribe("RARE_COLLECTION_CHANGED", self, Refresh)
end
