-- WarBoard_Proximity.lua
-- Player-vs-point proximity detection for objective TalkingHead lines.
--
-- Two clearly separated layers:
--   1. DETECTION (WB.Proximity) — generic. Knows nothing about objectives or
--      TalkingHeads. You Register(id, mapID, x, y, radius, data) points and it fires
--      OnEnter / OnLeave callbacks as the player crosses each radius. Technique:
--      C_Map.GetBestMapForUnit + GetPlayerMapPosition, a parent-map walk for interiors,
--      a short position cache, and a ticker that only runs while the player is on a map
--      that actually owns points. Distance is measured in MAP-PERCENT (not yards),
--      corrected by the map's aspect ratio so a percent radius is a true circle rather
--      than an ellipse.
--   2. CONSUMER — objective proximity → TalkingHead. Reads WB.WarEffort.Objectives,
--      registers any with a `proximity` block, and on entry plays the configured line
--      (gated by War Mode + a cooldown / once-per-session latch so it never spams).

local addonName, WB = ...

WB.Proximity = WB.Proximity or {}
local P = WB.Proximity

-- ════════════════════════════════════════════════════════════════════════════
--  1. DETECTION LAYER (generic)
-- ════════════════════════════════════════════════════════════════════════════

local points      = {}     -- all registered points
local pointsByMap = {}     -- mapID -> { point, ... }   (fast "any points here?" gate)
local enterCbs    = {}     -- listeners fired with (point) on entry
local leaveCbs    = {}     -- listeners fired with (point) on exit

-- x, y are normalized map coords (0..1); radius is in map-percent units (e.g. 4 = ~4%
-- of the map's width). `data` is an opaque payload handed back to the callbacks.
function P:Register(id, mapID, x, y, radius, data)
    if not (id and mapID and x and y) then return end
    local pt = { id = id, mapID = mapID, x = x, y = y, radius = radius or 4, data = data }
    points[#points + 1] = pt
    pointsByMap[mapID] = pointsByMap[mapID] or {}
    table.insert(pointsByMap[mapID], pt)
    return pt
end

function P:OnEnter(fn) enterCbs[#enterCbs + 1] = fn end
function P:OnLeave(fn) leaveCbs[#leaveCbs + 1] = fn end

-- Aspect ratio per map (yards-per-y-unit / yards-per-x-unit). Multiplying the y delta by
-- this keeps the detection circle round in the world instead of stretched. Computed once
-- per map and cached. Falls back to 1.0 if the world-pos API is unavailable for the map.
local aspectByMap = {}
local function MapAspect(mapID)
    if aspectByMap[mapID] then return aspectByMap[mapID] end
    local ar = 1.0
    pcall(function()
        if not (C_Map and C_Map.GetWorldPosFromMapPos and CreateVector2D) then return end
        local _, w0 = C_Map.GetWorldPosFromMapPos(mapID, CreateVector2D(0, 0))
        local _, wX = C_Map.GetWorldPosFromMapPos(mapID, CreateVector2D(1, 0))
        local _, wY = C_Map.GetWorldPosFromMapPos(mapID, CreateVector2D(0, 1))
        if w0 and wX and wY then
            local dX = math.sqrt((wX.x - w0.x) ^ 2 + (wX.y - w0.y) ^ 2)
            local dY = math.sqrt((wY.x - w0.x) ^ 2 + (wY.y - w0.y) ^ 2)
            if dX > 0 and dY > 0 then ar = dY / dX end
        end
    end)
    aspectByMap[mapID] = ar
    return ar
end

-- Player position, cached briefly and resolved to a map that actually owns points
-- (walks up parent maps to cover caves / upper floors). Returns mapID, px, py (×100).
local cMap, cPx, cPy, cTime = nil, nil, nil, 0
local POS_TTL = 0.25
local function PlayerPos()
    local now = GetTime()
    if now - cTime < POS_TTL then return cMap, cPx, cPy end
    cTime = now
    cMap, cPx, cPy = nil, nil, nil

    local ok, mapID = pcall(C_Map.GetBestMapForUnit, "player")
    if not ok or not mapID then return nil end

    local resolved, depth = mapID, 0
    while not pointsByMap[resolved] and depth < 3 do
        local ok2, info = pcall(C_Map.GetMapInfo, resolved)
        if not ok2 or not info or not info.parentMapID or info.parentMapID == 0 then break end
        resolved, depth = info.parentMapID, depth + 1
    end
    if not pointsByMap[resolved] then return nil end

    local ok3, pos = pcall(C_Map.GetPlayerMapPosition, resolved, "player")
    if not ok3 or not pos then return nil end
    local ok4, px, py = pcall(pos.GetXY, pos)
    if not ok4 or not px then return nil end

    cMap, cPx, cPy = resolved, px * 100, py * 100
    return cMap, cPx, cPy
end

local inside = {}   -- id -> true while the player is within that point's radius
local ticker

local function Scan()
    local mapID, px, py = PlayerPos()
    if not mapID then return end
    local list = pointsByMap[mapID]
    if not list then return end
    local ar = MapAspect(mapID)

    for _, pt in ipairs(list) do
        local dx = pt.x * 100 - px
        local dy = (pt.y * 100 - py) * ar
        -- compare squared distance vs squared radius (no sqrt in the hot path)
        local within = (dx * dx + dy * dy) <= (pt.radius * pt.radius)
        if within and not inside[pt.id] then
            inside[pt.id] = true
            for _, fn in ipairs(enterCbs) do pcall(fn, pt) end
        elseif not within and inside[pt.id] then
            inside[pt.id] = nil
            for _, fn in ipairs(leaveCbs) do pcall(fn, pt) end
        end
    end
end

-- Start the 1s scan only while the current map (or a parent) owns points; stop it and
-- forget inside-state otherwise, so the ticker is truly idle everywhere else.
local function UpdateTicker()
    local mapID = PlayerPos()
    if mapID and not ticker then
        ticker = C_Timer.NewTicker(1.0, Scan)
        Scan()   -- immediate first check on zone-in
    elseif not mapID and ticker then
        ticker:Cancel(); ticker = nil
        wipe(inside)
    end
end
P._UpdateTicker = UpdateTicker   -- exposed so the consumer can re-gate after registering

-- One-shot "am I within `radius` map-percent of (mapID, x, y) RIGHT NOW?" — aspect-corrected like
-- the scanner. Independent of Register/the ticker, so it works for gating an interaction that
-- shares an NPC id across several spots (only the one at this exact point should respond).
function P:IsWithin(mapID, x, y, radius)
    if not (mapID and x and y) then return false end
    local okMap, curMap = pcall(C_Map.GetBestMapForUnit, "player")
    if not okMap or curMap ~= mapID then return false end
    local okPos, pos = pcall(C_Map.GetPlayerMapPosition, mapID, "player")
    if not okPos or not pos then return false end
    local okXY, px, py = pcall(pos.GetXY, pos)
    if not okXY or not px then return false end
    local ar = MapAspect(mapID)
    local dx = (x - px) * 100
    local dy = (y - py) * 100 * ar
    return (dx * dx + dy * dy) <= ((radius or 1) * (radius or 1))
end

local watcher = CreateFrame("Frame")
watcher:RegisterEvent("PLAYER_ENTERING_WORLD")
watcher:RegisterEvent("ZONE_CHANGED_NEW_AREA")
watcher:RegisterEvent("ZONE_CHANGED")
watcher:RegisterEvent("ZONE_CHANGED_INDOORS")
watcher:SetScript("OnEvent", function()
    cTime = 0          -- force a fresh position read on any zone change
    UpdateTicker()
end)

-- ════════════════════════════════════════════════════════════════════════════
--  2. OBJECTIVE CONSUMER (objective proximity → TalkingHead)
-- ════════════════════════════════════════════════════════════════════════════
-- Separate from the detection layer above — it only uses the public WB.Proximity API
-- plus WB.UI.PlayTalkingHead. Future objective types (NPC/player-kill, bounties, campaign
-- zones) can either reuse this or subscribe their own OnEnter handler.

local history = {}   -- objective id -> { lastTrigger = GetTime(), sessionDone = bool }

local function WarModeOK()
    if C_PvP then
        if C_PvP.IsWarModeActive  then return C_PvP.IsWarModeActive()  end
        if C_PvP.IsWarModeDesired then return C_PvP.IsWarModeDesired() end
    end
    return true   -- API unavailable: don't block the feature
end

local function ShouldSpeak(obj)
    local prox = obj.proximity
    if not (prox and prox.talkingHead) then return false end
    if not WarModeOK() then return false end
    -- Already completed today → stay silent until the daily reset clears it back to the
    -- full-reward (non-repeatable) version.
    if WB.WarEffort and WB.WarEffort.IsComplete and WB.WarEffort.IsComplete(obj.id) then
        return false
    end
    local h = history[obj.id]
    if h then
        if prox.triggerOncePerSession and h.sessionDone then return false end
        if prox.cooldown and (GetTime() - (h.lastTrigger or 0)) < prox.cooldown then return false end
    end
    return true
end

-- The center-screen War Objective banner is ON for every proximity objective by default
-- (opt out with proximity.popup = false). NO cooldown — the proximity detector only fires
-- OnEnter once per genuine outside→inside transition, so it shows each time you enter the area.
local function ShouldPopup(obj)
    local prox = obj.proximity
    if not prox or prox.popup == false then return false end
    if not WarModeOK() then return false end
    if WB.WarEffort and WB.WarEffort.IsComplete and WB.WarEffort.IsComplete(obj.id) then return false end
    return true
end

-- Current progress (you + party) toward an objective's goal, for the tracker line.
local function CurrentProgress(obj)
    local cur = (WB.WarEffort and WB.WarEffort.GetProgress and WB.WarEffort.GetProgress(obj.id)) or 0
    return cur, obj.goal or 0
end

-- Tracker progress label (e.g. "Alliance NPCs killed"). Explicit proximity.progressLabel wins;
-- otherwise it's derived from the "Kill N <target> at/in/near <place>." description.
local function DeriveLabel(obj)
    local prox = obj.proximity
    if prox and prox.progressLabel then return prox.progressLabel end
    local d = obj.desc
    if type(d) ~= "string" then return nil end
    local target = d:match("^[Kk]ill%s+%d+%s+(.-)%s+[Aa]t%s")
                or d:match("^[Kk]ill%s+%d+%s+(.-)%s+[Ii]n%s")
                or d:match("^[Kk]ill%s+%d+%s+(.-)%s+[Nn]ear%s")
    if target and target ~= "" then return target .. " killed" end
    return nil
end

-- ── ENTER: TalkingHead + popup + tracker entry ───────────────────────────────
P:OnEnter(function(pt)
    local obj = pt.data
    if type(obj) ~= "table" then return end
    local prox = obj.proximity
    if not prox then return end

    obj.isPlayerInside = true   -- state for the future "pin objective" feature

    -- Locked acquisition objective (gated behind a learned spell): you can't work it yet, so
    -- it never announces or tracks. Clear any stale tracker entry and stop here.
    if WB.WarEffort and WB.WarEffort.IsLocked and WB.WarEffort.IsLocked(obj) then
        if WB.Tracker and WB.Tracker.RemoveObjective then WB.Tracker:RemoveObjective(obj.id) end
        return
    end

    -- 1) Faction leader TalkingHead line (existing behaviour).
    if ShouldSpeak(obj) then
        history[obj.id] = history[obj.id] or {}
        history[obj.id].lastTrigger = GetTime()
        if prox.triggerOncePerSession then history[obj.id].sessionDone = true end
        if WB.UI and WB.UI.PlayTalkingHead then
            WB.UI.PlayTalkingHead(prox.talkingHead)   -- accepts a registry key or an inline spec
        end
    end

    -- 2) Center-screen "War Objective" banner — on by default (opt out: proximity.popup = false),
    --    no cooldown (fires on each genuine entry).
    if ShouldPopup(obj) and WB.Popup and WB.Popup.Show then
        local lbl = (type(prox.popup) == "table" and prox.popup.label) or "War Objective"
        -- Scope the banner sound + quicker-dismiss per objective, on the objective's own cooldown
        -- so it returns in step with this objective's TalkingHead line.
        WB.Popup:Show(obj.title, lbl, obj.id, prox.cooldown)
    end

    -- 3) War Board objective-tracker entry while inside — on by default (opt out: tracker = false).
    if prox.tracker ~= false and WarModeOK() and WB.Tracker and WB.Tracker.AddObjective then
        local cur, total = CurrentProgress(obj)
        local name = (WB.WarEffort and WB.WarEffort.DisplayTitle and WB.WarEffort.DisplayTitle(obj)) or obj.title
        WB.Tracker:AddObjective(obj.id, name, DeriveLabel(obj), cur, total)
    end
end)

-- ── LEAVE: clear the tracker entry (unless pinned — future feature) ───────────
P:OnLeave(function(pt)
    local obj = pt.data
    if type(obj) ~= "table" then return end
    obj.isPlayerInside = false
    -- FUTURE "pin objective": a pinned objective stays in the tracker after leaving.
    -- Show rule will become: keep if (obj.isPlayerInside or obj.isPinned). For now nothing
    -- is pinned, so leaving always clears it.
    if obj.isPinned then return end
    if WB.Tracker and WB.Tracker.RemoveObjective then
        WB.Tracker:RemoveObjective(obj.id)
    end
end)

-- Register every objective that opted into proximity, faction-filtered to this player.
local function RegisterObjectives()
    if not (WB.WarEffort and WB.WarEffort.Objectives) then return end
    local myFaction = (WB.faction or UnitFactionGroup("player") or "Horde"):lower()
    for _, obj in ipairs(WB.WarEffort.Objectives) do
        local prox, m = obj.proximity, obj.marker
        if prox and m and m.mapID and m.x and m.y then
            if not obj.faction or obj.faction == myFaction then
                P:Register(obj.id, m.mapID, m.x, m.y, prox.radius, obj)
            end
        end
    end
    UpdateTicker()   -- in case we logged in already standing in a relevant zone
end

local reg = CreateFrame("Frame")
reg:RegisterEvent("PLAYER_LOGIN")
reg:SetScript("OnEvent", RegisterObjectives)
