--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Engine/Range.lua

	Who can you actually reach right now?

	Range is the one piece of unit state the client will not push at us: there is
	no RANGE_CHANGED event, so it has to be polled. That makes this the only
	timer-driven engine in the addon, and the only place where sloppiness is
	visibly expensive - a naive implementation re-tests forty units several times
	a second and repaints every frame each pass.

	Three things keep it cheap:
	  * we poll only units that a button is actually showing, not the roster,
	  * we cache the last answer per unit and mark dirty ONLY on a transition,
	  * the ticker stops dead when the feature is switched off.

	The probe is a real friendly spell of the player's specialization, so a Holy
	Priest fades at 40yd and a Restoration Shaman at 40yd but a Mistweaver's
	Enveloping Mist at 40yd - whatever their kit actually says. When no probe
	spell is usable we fall back to UnitInRange (group members, 40yd) and finally
	to visibility.

	Every ambiguous answer resolves to "in range". Fading a frame the healer
	could have reached is a far worse failure than not fading one they cannot.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Range = Boom:NewModule("Range")

local Util   = Boom.Util
local UPDATE = Boom.UPDATE

local pairs, next, wipe = pairs, next, wipe
local tonumber, type = tonumber, type

local UnitExists    = UnitExists
local UnitIsUnit    = UnitIsUnit
local UnitIsVisible = UnitIsVisible
local UnitInRange   = UnitInRange

-- Resolved once: the flat IsSpellInRange global is gone in 12.0.
local IsSpellInRange = C_Spell and C_Spell.IsSpellInRange

--------------------------------------------------------------------------------
-- State
--------------------------------------------------------------------------------

-- unit token -> boolean. The previous answer, so Update can detect transitions.
local cache = {}

-- Scratch set reused every pass: a unit can be shown by more than one button
-- (party + raid layouts both up, main tank panel, ...) and must only be tested
-- once per tick.
local seen = {}

local probeSpell   -- spellID we currently test with, or nil
local ticker

Range.DEFAULT_INTERVAL = 0.20

-- Below this the poll costs more than the smoothness it buys.
local MIN_INTERVAL = 0.05

--------------------------------------------------------------------------------
-- Probe spell selection
--------------------------------------------------------------------------------

--- SpellDB lives in another file and may legitimately not have an entry for a
--- spec (or not be loaded at all in a partial install). Everything here is
--- optional; a nil probe simply demotes us to the UnitInRange fallback.
local function resolveProbeSpell()
	local profile = Boom.profile
	if not profile or not profile.range then return nil end

	-- "default" means the user explicitly asked for the plain 40yd group check.
	if profile.range.mode ~= "spell" then return nil end

	if not IsSpellInRange then return nil end

	local db = Boom.SpellDB
	if not db or type(db.GetRangeSpell) ~= "function" then return nil end

	local config = Boom.Config
	local specID = config and config.GetPlayerSpecID and config:GetPlayerSpecID()
	if not specID then return nil end

	local ok, result = pcall(db.GetRangeSpell, db, specID)
	if not ok then
		Boom:Debug("GetRangeSpell failed:", result)
		return nil
	end

	-- Tolerate either a bare id or a spell entry table.
	local spellID = result
	if type(spellID) == "table" then spellID = spellID.id or spellID[1] end
	spellID = tonumber(spellID)
	if not spellID then return nil end

	-- A talent can remove the spell from under us, and IsSpellInRange on an
	-- unknown spell answers nil forever - which would silently pin every unit
	-- to the fallback path.
	if not Util.IsPlayerSpell(spellID) then return nil end

	return spellID
end

--- Recompute the probe. Cheap, so it is safe to call on any spec/talent churn.
function Range:RefreshSpell()
	local previous = probeSpell
	probeSpell = resolveProbeSpell()

	if probeSpell ~= previous then
		Boom:Debug("range probe spell:", probeSpell or "none",
			probeSpell and (Util.GetSpellName(probeSpell) or "?") or "")
		-- The new probe may disagree with the old one everywhere, so throw the
		-- cached answers away rather than trusting a stale comparison.
		self:Invalidate()
	end
end

function Range:GetProbeSpellID()
	return probeSpell
end

--------------------------------------------------------------------------------
-- The test
--------------------------------------------------------------------------------

local function computeRange(unit)
	-- You are always within reach of yourself, including while in a vehicle
	-- where the spell probe would answer for the vehicle instead.
	if UnitIsUnit(unit, "player") then return true end

	-- A button can briefly point at a unit that has already left the group.
	-- That is "unknown", not "far away" - the header is about to hide it.
	if not UnitExists(unit) then return true end

	-- Invisible covers both genuinely distant and phased/instanced-away units.
	-- Secret-restricted inside instances, and testing a secret throws, so a
	-- withheld answer has to mean "do not claim they are out of range" - the
	-- spell probe below is the authority anyway.
	if Util.IsFalse(UnitIsVisible(unit)) then return false end

	if probeSpell then
		local result = IsSpellInRange(probeSpell, unit)
		-- Comparing a secret is an error just like testing one, so an unreadable
		-- probe is treated the same as the client refusing to answer: fall
		-- through rather than guess.
		if result ~= nil and not Util.IsSecret(result) then
			-- Boolean today, 1/0 in older builds - accept both.
			return result == true or result == 1
		end
		-- nil means the client refused to answer (unit not a valid target for
		-- this spell, spell temporarily unusable). Fall through, do not guess.
	end

	if UnitInRange then
		local inRange, checked = UnitInRange(unit)
		-- checkedRange is false for anyone outside your party/raid. Both returns
		-- can be secret; an unreadable answer falls through to "in range" rather
		-- than fading every frame in the raid.
		if Util.IsTrue(checked) then return Util.IsTrue(inRange) end
	end

	return true
end

--- Public query. Answers from the cache when the ticker has already tested this
--- unit, otherwise measures on the spot - UI/UnitButton asks about the vehicle
--- display unit, which the ticker never iterates.
function Range:IsInRange(unit)
	if not unit then return true end

	local profile = Boom.profile
	if profile and profile.range and not profile.range.enabled then return true end

	local cached = cache[unit]
	if cached ~= nil then return cached end

	local result = computeRange(unit)
	cache[unit] = result
	return result
end

--------------------------------------------------------------------------------
-- Ticker
--------------------------------------------------------------------------------

--- One poll pass. Marks UPDATE.RANGE only where the answer actually flipped;
--- in a stable raid this does no work at all beyond the range calls themselves.
function Range:Update()
	local profile = Boom.profile
	if not profile or not profile.range or not profile.range.enabled then return end

	local frames = Boom.Frames
	if not frames then return end

	wipe(seen)

	for _, button in frames:IterateButtons() do
		local unit = button.unit
		if unit and not seen[unit] then
			seen[unit] = true

			local result = computeRange(unit)
			if cache[unit] ~= result then
				cache[unit] = result
				Boom:MarkUnitDirty(unit, UPDATE.RANGE)
			end
		end
	end
end

--- Drop every cached answer and repaint anything currently faded, so no frame
--- is left dimmed by a decision that no longer applies.
function Range:Invalidate()
	if not next(cache) then return end

	for unit, wasInRange in pairs(cache) do
		if wasInRange == false then
			Boom:MarkUnitDirty(unit, UPDATE.RANGE)
		end
	end
	wipe(cache)
end

function Range:Stop()
	if ticker then
		ticker:Cancel()
		ticker = nil
	end
	self:Invalidate()
end

--- (Re)start the poll from the current settings. Safe to call repeatedly.
function Range:Start()
	self:Stop()

	local profile = Boom.profile
	if not profile or not profile.range or not profile.range.enabled then return end

	local interval = tonumber(profile.range.updateInterval) or Range.DEFAULT_INTERVAL
	if interval < MIN_INTERVAL then interval = MIN_INTERVAL end

	ticker = Boom:NewTicker(interval, function()
		Range:Update()
	end)
end

function Range:IsRunning()
	return ticker ~= nil
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function Range:OnEnable()
	self:RefreshSpell()
	self:Start()

	-- SPELLS_CHANGED arrives in bursts during login and every talent save.
	local function refreshSoon()
		Boom:Throttle("range-probe", 0.5, function()
			Range:RefreshSpell()
		end)
	end

	self:RegisterEvent("SPELLS_CHANGED", refreshSoon)
	-- Talent loadout swaps do not always ripple through SPELLS_CHANGED in time.
	self:RegisterEvent("TRAIT_CONFIG_UPDATED", refreshSoon)

	self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED", function(_, _, unit)
		if unit and unit ~= "player" then return end
		refreshSoon()
	end)

	self:RegisterMessage("BOOM_SPEC_CHANGED", refreshSoon)

	-- Units come and go; their cached answers must not outlive them.
	self:RegisterEvent("GROUP_ROSTER_UPDATE", function()
		Boom:Throttle("range-roster", 0.2, function()
			Range:Invalidate()
		end)
	end)

	self:RegisterMessage("BOOM_CONFIG_CHANGED", function(_, _, path)
		if path == "range.enabled" or path == "range.updateInterval" then
			Range:Start()
		elseif path == "range.mode" then
			Range:RefreshSpell()
			Range:Start()
		end
	end)

	self:RegisterMessage("BOOM_PROFILE_CHANGED", function()
		Range:RefreshSpell()
		Range:Start()
	end)
end
