--[[ 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 }
local RARE_PIN_TEMPLATE = "ShinyHunterRareMapPinTemplate"
local SPOT_PIN_TEMPLATE = "ShinyHunterSearchSpotPinTemplate"
local dataProvider
local installed = false
local target
local selectedRoutes = {}
local nextColor = 1

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

local function Refresh()
    if dataProvider and dataProvider.GetMap and dataProvider:GetMap() then
        dataProvider:RefreshAllData()
    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

-- 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_FRAME_LEVEL_AREA_POI")
    self:SetScalingLimits(1, 1.0, 1.15)
    self:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end

function ShinyHunterRareMapPinMixin:SetPassThroughButtons() end

function ShinyHunterRareMapPinMixin:OnAcquired(routeKey, x, y, color)
    self.routeKey = routeKey
    self:SetPosition(x, y)
    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:OnEnter()
    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 outcome = ns.Collection.RareState:GetOutcome(npcID)
        local status = ns.L["DETAIL_UNDISCOVERED"]
        local r, g, b = 1.00, 0.35, 0.30
        if outcome == "both" then
            status, r, g, b = ns.L["RARE_CATALOG_CAPTURED_AND_KILLED"], 1.00, 0.76, 0.24
        elseif outcome == "captured" then
            status, r, g, b = ns.L["RARE_CATALOG_CAPTURED"], 0.45, 0.95, 0.62
        elseif outcome == "killed" then
            status, r, g, b = ns.L["RARE_CATALOG_KILLED"], 0.96, 0.38, 0.24
        end
        GameTooltip:AddDoubleLine(FamilyName(record),
            status, 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:OnLeave() 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_FRAME_LEVEL_AREA_POI")
    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.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:OnEnter()
    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:OnLeave() 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)

function ProviderMixin:RemoveAllData()
    local map = self:GetMap()
    if not map then return end
    -- `RemoveAllPinsByTemplate` asserts on Retail 12.0.7 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 that the canvas has created.
    local pools = map.pinPools
    if pools and pools[RARE_PIN_TEMPLATE] then
        map:RemoveAllPinsByTemplate(RARE_PIN_TEMPLATE)
    end
    if pools and pools[SPOT_PIN_TEMPLATE] then
        map:RemoveAllPinsByTemplate(SPOT_PIN_TEMPLATE)
    end
end

local function AcquireRarePin(map, routeKey, locations)
    local colorIndex = selectedRoutes[routeKey]
    local color = colorIndex and ROUTE_COLORS[colorIndex]
    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
        previous = map:AcquirePin(SPOT_PIN_TEMPLATE,
            routeKey, location, color, previous)
    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)
    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
        C_Timer.After(0, Refresh)
    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
        Refresh()
    end)
    ns.Events:Subscribe("COLLECTION_CHANGED", self, Refresh)
end
