--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	UI/Indicators.lua

	The indicator framework, plus the bar-family indicators that live on top of
	the health bar.

	An indicator is a small declarative object:

	    Indicators:Register("healthText", {
	        label  = "Health Text",                 -- Boom.L key, shown in options
	        order  = 20,                            -- draw / layout order
	        flags  = Boom.UPDATE.HEALTH,            -- which dirty bits wake it
	        create = function(button) ... end,      -- build widgets once
	        layout = function(button, obj, cfg) end,-- geometry + colours (cold path)
	        update = function(button, obj, cfg) end,-- content (hot path)
	    })

	`cfg` is always Boom.profile.indicators[id]; the framework skips update
	entirely when cfg.enabled is false, so indicators never test that themselves.

	Hot-path discipline: `update` runs for every dirty button every frame it is
	dirty. No table allocation, no string concatenation unless the value changed,
	no pcall. The driver in Core/Init.lua already wraps the whole button update.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Indicators = Boom:NewModule("Indicators")

local Util   = Boom.Util
local Media  = Boom.Media
local Skin   = Boom.Skin
local UPDATE = Boom.UPDATE

local band, bor = bit.band, bit.bor
local min, max, floor, abs = math.min, math.max, math.floor, math.abs
local tsort, tinsert = table.sort, table.insert

--------------------------------------------------------------------------------
-- Registry
--------------------------------------------------------------------------------

local defs   = {}
local sorted = {}
local dirtyOrder = true

function Indicators:Register(id, def)
	def.id    = id
	def.order = def.order or 100
	def.flags = def.flags or UPDATE.ALL
	defs[id]  = def
	sorted[#sorted + 1] = def
	dirtyOrder = true
end

function Indicators:Get(id)
	return defs[id]
end

local function ensureOrder()
	if not dirtyOrder then return end
	tsort(sorted, function(a, b)
		if a.order == b.order then return a.id < b.id end
		return a.order < b.order
	end)
	dirtyOrder = false
end

function Indicators:IterateDefs()
	ensureOrder()
	local i = 0
	return function()
		i = i + 1
		return sorted[i]
	end
end

--------------------------------------------------------------------------------
-- Per-button lifecycle
--------------------------------------------------------------------------------

function Indicators:CreateAll(button)
	button.indicators = button.indicators or {}

	for def in self:IterateDefs() do
		if not button.indicators[def.id] and def.create then
			local ok, obj = pcall(def.create, button)
			if ok and obj then
				button.indicators[def.id] = obj
			elseif not ok then
				Boom:Debug("indicator create failed:", def.id, obj)
				def.broken = true
			end
		end
	end
end

function Indicators:LayoutAll(button)
	if not button.indicators then return end
	local cfgs = Boom.profile.indicators

	for def in self:IterateDefs() do
		local obj = button.indicators[def.id]
		local cfg = cfgs[def.id]
		if obj and cfg and def.layout and not def.broken then
			local ok, err = pcall(def.layout, button, obj, cfg)
			if not ok then
				Boom:Debug("indicator layout failed:", def.id, err)
				def.broken = true
			end
		end
	end
end

--- Hot path. Called from button:BoomUpdate.
function Indicators:UpdateButton(button, flags)
	local list = button.indicators
	if not list then return end

	ensureOrder()
	local cfgs = Boom.profile.indicators

	for i = 1, #sorted do
		local def = sorted[i]
		if not def.broken and band(flags, def.flags) ~= 0 then
			local cfg = cfgs[def.id]
			if cfg and cfg.enabled then
				local obj = list[def.id]
				if obj and def.update then
					def.update(button, obj, cfg)
				end
			end
		end
	end
end

--------------------------------------------------------------------------------
-- Shared helpers used by indicator implementations
--------------------------------------------------------------------------------

--- Standard placement: anchor `obj` inside `button.overlay` using cfg.point/x/y.
function Indicators:Position(obj, button, cfg, parent)
	obj:ClearAllPoints()
	obj:SetPoint(cfg.point or "CENTER", parent or button.overlay, cfg.point or "CENTER",
		cfg.x or 0, cfg.y or 0)
end

--- Offsets for laying a row/column of icons out from an anchor.
local DIRECTION_STEP = {
	RIGHT = { 1, 0 },
	LEFT  = { -1, 0 },
	UP    = { 0, 1 },
	DOWN  = { 0, -1 },
}

function Indicators:GetDirectionStep(direction)
	local step = DIRECTION_STEP[direction or "RIGHT"] or DIRECTION_STEP.RIGHT
	return step[1], step[2]
end

--------------------------------------------------------------------------------
-- Aura icon widget
--
-- Shared by hots, debuffs, dispel, shield, externals and support. One
-- implementation means one look, one bug surface, one place to tune.
--------------------------------------------------------------------------------

local auraIconMeta = {}

--- Create a square aura icon with cooldown swipe, stack count and a coloured
--- border. Returns a Frame with :SetAura(auraData, cfg) and :Clear().
function Indicators:CreateAuraIcon(parent, opts)
	opts = opts or {}

	local icon = CreateFrame("Frame", nil, parent)
	icon:SetSize(opts.size or 14, opts.size or 14)

	icon.texture = icon:CreateTexture(nil, "ARTWORK")
	icon.texture:SetPoint("TOPLEFT", 1, -1)
	icon.texture:SetPoint("BOTTOMRIGHT", -1, 1)
	-- Crop the stock icon border so small icons stay readable.
	icon.texture:SetTexCoord(0.08, 0.92, 0.08, 0.92)

	icon.border = Util.CreateBorder(icon, "OVERLAY")
	icon.border:SetSize(1)
	icon.border:SetColor(0, 0, 0, 1)

	icon.cooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
	icon.cooldown:SetAllPoints(icon.texture)
	icon.cooldown:SetHideCountdownNumbers(true)
	icon.cooldown:SetDrawEdge(false)
	icon.cooldown:SetReverse(true)
	icon.cooldown:SetSwipeColor(0, 0, 0, 0.6)

	-- Stack count top-right, timer along the bottom. They used to be BOTTOMRIGHT
	-- and CENTER, whose boxes intersect inside a 14px icon, so a stacking debuff
	-- rendered its count straight through its own countdown. Splitting them to
	-- opposite ends is what every established aura display converged on.
	icon.count = icon:CreateFontString(nil, "OVERLAY")
	icon.count:SetPoint("TOPRIGHT", icon, "TOPRIGHT", 1, 1)
	icon.count:SetJustifyH("RIGHT")
	if icon.count.SetWordWrap then icon.count:SetWordWrap(false) end
	Util.SetFont(icon.count, Media:GetFont(), opts.countFontSize or 9, "OUTLINE")

	icon.duration = icon:CreateFontString(nil, "OVERLAY")
	icon.duration:SetPoint("BOTTOM", icon, "BOTTOM", 0, -1)
	icon.duration:SetJustifyH("CENTER")
	if icon.duration.SetWordWrap then icon.duration:SetWordWrap(false) end
	Util.SetFont(icon.duration, Media:GetFont(), opts.durationFontSize or 9, "OUTLINE")

	for name, func in pairs(auraIconMeta) do
		icon[name] = func
	end

	icon:Hide()
	return icon
end

--- Apply an aura to the icon. `cfg` supplies showDuration / showStacks /
--- expiryWarning behaviour.
local issecretvalue = _G.issecretvalue or function() return false end

function auraIconMeta:SetAura(aura, cfg, borderColor)
	-- The aura engine already refuses to cache auras whose fields are secret,
	-- but this widget is public API for the indicator files, so it re-checks the
	-- two fields it does arithmetic on rather than trusting its caller.
	if not aura or issecretvalue(aura.duration) or issecretvalue(aura.expirationTime) then
		self:Clear()
		return
	end

	self.spellId = aura.spellId
	self.texture:SetTexture(aura.icon or Media.ICONS.question)

	local applications = aura.applications or 0
	if issecretvalue(applications) then applications = 0 end
	if cfg and cfg.showStacks and applications > 1 then
		self.count:SetText(applications)
		self.count:Show()
	else
		self.count:Hide()
	end

	local duration    = aura.duration or 0
	local expiration  = aura.expirationTime or 0

	if duration > 0 and expiration > 0 then
		self.cooldown:SetCooldown(expiration - duration, duration)
		self.cooldown:Show()
		self.expiration = expiration
	else
		self.cooldown:Hide()
		self.expiration = nil
	end

	-- Numeric countdown is optional: at 14px it is noise, at 20px it helps.
	if cfg and cfg.showDuration and self.expiration then
		self.duration:Show()
		self:UpdateDuration()
	else
		self.duration:Hide()
	end

	if borderColor then
		self.border:SetColor(borderColor[1], borderColor[2], borderColor[3], 1)
	else
		self.border:SetColor(0, 0, 0, 1)
	end

	self.warnThreshold = cfg and cfg.expiryWarning or nil
	self:Show()
end

function auraIconMeta:UpdateDuration()
	if not self.expiration then return end
	local remaining = self.expiration - GetTime()
	if remaining <= 0 then
		self.duration:SetText("")
		return
	end
	self.duration:SetText(Util.FormatTime(remaining))

	if self.warnThreshold and remaining <= self.warnThreshold then
		self.duration:SetTextColor(1, 0.4, 0.4)
	else
		self.duration:SetTextColor(1, 1, 1)
	end
end

function auraIconMeta:Clear()
	self.spellId = nil
	self.expiration = nil
	self:Hide()
end

--- Grow a pool of aura icons on demand.
function Indicators:GetIconPool(button, key, parent, opts)
	button.iconPools = button.iconPools or {}
	local pool = button.iconPools[key]
	if not pool then
		pool = { icons = {}, parent = parent }
		button.iconPools[key] = pool
	end
	pool.parent = parent or pool.parent

	function pool:Acquire(index, size)
		local icon = self.icons[index]
		if not icon then
			icon = Indicators:CreateAuraIcon(self.parent, opts)
			self.icons[index] = icon
		end
		if size then icon:SetSize(size, size) end
		return icon
	end

	function pool:HideFrom(index)
		for i = index, #self.icons do
			self.icons[i]:Clear()
		end
	end

	function pool:Layout(cfg, anchorParent)
		-- Clamped, not merely defaulted: a profile written by an older build can
		-- hold a zero, and `size + spacing` reaching zero turns the fit maths
		-- below into a division by zero - which yields NaN, and a NaN loop bound
		-- silently draws nothing at all rather than erroring.
		local size = max(1, tonumber(cfg.size) or 14)
		local spacing = max(0, tonumber(cfg.spacing) or 1)
		local dx, dy = Indicators:GetDirectionStep(cfg.direction)
		local count = max(1, tonumber(cfg.max) or 3)

		-- A run of icons stepping out from an anchor has no idea where the button
		-- ends, so "max = 3" at size 18 growing DOWN from LEFT used to paint two
		-- of the three onto whichever unit sits below. Drop what does not fit.
		local extent = (dx ~= 0) and (anchorParent:GetWidth() or 0)
			or (dy ~= 0) and (anchorParent:GetHeight() or 0)
			or 0

		if extent > 0 then
			local offset = (dx ~= 0) and abs(cfg.x or 0) or abs(cfg.y or 0)
			local fits = floor((extent - offset + spacing) / (size + spacing))
			count = max(1, min(count, fits))
		end

		for i = 1, count do
			local icon = self:Acquire(i, size)
			icon:ClearAllPoints()
			icon:SetPoint(cfg.point or "TOPLEFT", anchorParent, cfg.point or "TOPLEFT",
				(cfg.x or 0) + dx * (size + spacing) * (i - 1),
				(cfg.y or 0) + dy * (size + spacing) * (i - 1))
			-- Clamped against the icon as well as the config: a durationFontSize of
			-- 9 on a 14px icon draws "9.5" wider than the icon it belongs to and
			-- spills into the neighbouring one.
			if icon.duration then
				Util.SetFont(icon.duration, Media:GetFont(),
					min(cfg.durationFontSize or 99, max(6, floor(size * 0.55))), "OUTLINE")
			end
			if icon.count then
				Util.SetFont(icon.count, Media:GetFont(),
					min(cfg.countFontSize or 99, max(6, floor(size * 0.5))), "OUTLINE")
			end
		end
		-- Anything beyond the configured max must not linger from a bigger setting.
		self:HideFrom(count + 1)
	end

	return pool
end

--------------------------------------------------------------------------------
-- Bar overlay geometry
--
-- The health bar can be horizontal or vertical and can fill from either end.
-- Overlays (incoming heals, absorbs) are plain textures positioned by fraction
-- along the fill axis, so all of them share this one function.
--------------------------------------------------------------------------------

--- Place `tex` over `bar` covering the range [startFrac, startFrac + widthFrac].
--- Returns false and hides the texture when the span is empty.
function Indicators:SetBarSpan(tex, bar, startFrac, widthFrac)
	if not widthFrac or widthFrac <= 0.0005 then
		tex:Hide()
		return false
	end

	startFrac = Util.Clamp(startFrac or 0, 0, 1)
	widthFrac = Util.Clamp(widthFrac, 0, 1 - startFrac)
	if widthFrac <= 0.0005 then
		tex:Hide()
		return false
	end

	local w, h = bar:GetWidth(), bar:GetHeight()
	if not w or w <= 0 or not h or h <= 0 then
		tex:Hide()
		return false
	end

	local orientation = bar:GetOrientation()
	local reverse = bar:GetReverseFill()

	tex:ClearAllPoints()

	if orientation == "VERTICAL" then
		local size = h * widthFrac
		local offset = h * startFrac
		if reverse then
			tex:SetPoint("TOPLEFT", bar, "TOPLEFT", 0, -offset)
			tex:SetPoint("TOPRIGHT", bar, "TOPRIGHT", 0, -offset)
		else
			tex:SetPoint("BOTTOMLEFT", bar, "BOTTOMLEFT", 0, offset)
			tex:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", 0, offset)
		end
		tex:SetHeight(size)
	else
		local size = w * widthFrac
		local offset = w * startFrac
		if reverse then
			tex:SetPoint("TOPRIGHT", bar, "TOPRIGHT", -offset, 0)
			tex:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", -offset, 0)
		else
			tex:SetPoint("TOPLEFT", bar, "TOPLEFT", offset, 0)
			tex:SetPoint("BOTTOMLEFT", bar, "BOTTOMLEFT", offset, 0)
		end
		tex:SetWidth(size)
	end

	tex:Show()
	return true
end

--------------------------------------------------------------------------------
-- Bar overlay indicators
--------------------------------------------------------------------------------

local function createOverlayTexture(button, drawOrder)
	local tex = button.healthBar:CreateTexture(nil, "ARTWORK", nil, drawOrder)
	tex:SetTexture(Media:GetBarTexture())
	tex:Hide()
	return tex
end

-- Incoming heals ------------------------------------------------------------

Indicators:Register("incomingHeal", {
	label = "Incoming Heals",
	order = 5,
	flags = bor(UPDATE.HEALTH, UPDATE.COLOR),

	create = function(button)
		return createOverlayTexture(button, 1)
	end,

	layout = function(button, tex, cfg)
		tex:SetTexture(Media:GetBarTexture())
		local r, g, b = Util.Unpack(cfg.color)
		tex:SetVertexColor(r, g, b, cfg.alpha or 0.35)
		if not cfg.enabled then tex:Hide() end
	end,

	update = function(button, tex, cfg)
		local unit = button.displayUnit
		if not unit then tex:Hide() return end

		local cur, maxHP, frac = Boom.Status:GetHealth(unit)
		-- Overlays are positioned by pixel arithmetic, which is impossible while
		-- the client is hiding the numbers. The health bar itself still works.
		if frac == nil then tex:Hide() return end
		if not maxHP or maxHP <= 0 then tex:Hide() return end

		-- Prediction amounts can be secret independently of health, so the
		-- flag has to be honoured here too - comparing a secret throws.
		local incoming, _, _, secret = Boom.Status:GetPrediction(unit)
		if secret then tex:Hide() return end
		if not incoming or incoming <= 0 then tex:Hide() return end

		-- Allow a sliver of overheal to remain visible so the healer can see the
		-- cast will cap - clipping it dead at 100% hides useful information.
		local limit = (cfg.maxOverflow or 1.0) - frac
		Indicators:SetBarSpan(tex, button.healthBar, frac, min(incoming / maxHP, max(limit, 0)))
	end,
})

-- Absorb shields ------------------------------------------------------------

Indicators:Register("absorb", {
	label = "Absorb Shield",
	order = 6,
	flags = bor(UPDATE.HEALTH, UPDATE.COLOR),

	create = function(button)
		local obj = {}
		obj.bar = createOverlayTexture(button, 2)

		-- A bright edge marker when the shield exceeds missing health, which is
		-- the moment a healer should stop topping that player up.
		obj.overflow = button.healthBar:CreateTexture(nil, "OVERLAY", nil, 3)
		obj.overflow:SetTexture(Media.WHITE)
		obj.overflow:SetWidth(2)
		obj.overflow:Hide()

		return obj
	end,

	layout = function(button, obj, cfg)
		obj.bar:SetTexture(Media:GetBarTexture())
		local r, g, b = Util.Unpack(cfg.color)
		obj.bar:SetVertexColor(r, g, b, cfg.alpha or 0.55)
		obj.overflow:SetVertexColor(r, g, b, 0.95)
		if not cfg.enabled then
			obj.bar:Hide()
			obj.overflow:Hide()
		end
	end,

	update = function(button, obj, cfg)
		local unit = button.displayUnit
		if not unit then
			obj.bar:Hide()
			obj.overflow:Hide()
			return
		end

		local _, maxHP, frac = Boom.Status:GetHealth(unit)
		if frac == nil or not maxHP or maxHP <= 0 then
			obj.bar:Hide()
			obj.overflow:Hide()
			return
		end

		local incoming, absorb, _, secret = Boom.Status:GetPrediction(unit)
		if secret or not absorb or absorb <= 0 then
			obj.bar:Hide()
			obj.overflow:Hide()
			return
		end

		local start = min(frac + (incoming or 0) / maxHP, 1)
		local width = absorb / maxHP

		Indicators:SetBarSpan(obj.bar, button.healthBar, start, width)

		if cfg.showOverAbsorbGlow and (start + width) > 1 then
			obj.overflow:ClearAllPoints()
			if button.healthBar:GetOrientation() == "VERTICAL" then
				obj.overflow:SetPoint("TOPLEFT", button.healthBar, "TOPLEFT", 0, 0)
				obj.overflow:SetPoint("TOPRIGHT", button.healthBar, "TOPRIGHT", 0, 0)
				obj.overflow:SetHeight(2)
			else
				obj.overflow:SetPoint("TOPRIGHT", button.healthBar, "TOPRIGHT", 0, 0)
				obj.overflow:SetPoint("BOTTOMRIGHT", button.healthBar, "BOTTOMRIGHT", 0, 0)
				obj.overflow:SetWidth(2)
			end
			obj.overflow:Show()
		else
			obj.overflow:Hide()
		end
	end,
})

-- Heal absorbs (Necrotic Wound and friends) ---------------------------------

Indicators:Register("healAbsorb", {
	label = "Heal Absorb",
	order = 7,
	flags = bor(UPDATE.HEALTH, UPDATE.COLOR),

	create = function(button)
		return createOverlayTexture(button, 3)
	end,

	layout = function(button, tex, cfg)
		tex:SetTexture(Media:GetBarTexture())
		local r, g, b = Util.Unpack(cfg.color)
		tex:SetVertexColor(r, g, b, cfg.alpha or 0.6)
		if not cfg.enabled then tex:Hide() end
	end,

	update = function(button, tex, cfg)
		local unit = button.displayUnit
		if not unit then tex:Hide() return end

		local _, maxHP, frac = Boom.Status:GetHealth(unit)
		if frac == nil or not maxHP or maxHP <= 0 then tex:Hide() return end

		local _, _, healAbsorb, secret = Boom.Status:GetPrediction(unit)
		if secret or not healAbsorb or healAbsorb <= 0 then tex:Hide() return end

		-- Drawn inside current health, back from the fill edge: it represents
		-- healing that will be eaten, so it belongs on top of what is there.
		local width = min(healAbsorb / maxHP, frac)
		Indicators:SetBarSpan(tex, button.healthBar, frac - width, width)
	end,
})

--------------------------------------------------------------------------------
-- Highlights
--------------------------------------------------------------------------------

-- Aggro ---------------------------------------------------------------------

Indicators:Register("aggro", {
	label = "Aggro Highlight",
	order = 60,
	flags = bor(UPDATE.THREAT, UPDATE.COLOR),

	create = function(button)
		local frame = CreateFrame("Frame", nil, button.overlay)
		frame:SetAllPoints(button)
		frame:SetFrameLevel(button.overlay:GetFrameLevel() + 2)
		frame.border = Util.CreateBorder(frame, "OVERLAY")
		frame.border:Hide()
		frame:Hide()
		return frame
	end,

	layout = function(button, frame, cfg)
		frame:SetAllPoints(button)
		frame.border:SetSize(cfg.thickness or 2)
		if not cfg.enabled or cfg.mode == "off" then frame:Hide() end
	end,

	update = function(button, frame, cfg)
		local unit = button.displayUnit
		if not unit or cfg.mode == "off" then
			frame:Hide()
			return
		end

		if cfg.onlyTanks and not Util.IsTank(unit) then
			frame:Hide()
			return
		end

		local status = Boom.Status:GetThreat(unit)
		if not status or status < 1 then
			frame:Hide()
			return
		end

		local r, g, b = GetThreatStatusColor(status)
		if cfg.mode == "border" or cfg.mode == "glow" then
			frame.border:SetColor(r, g, b, 1)
			frame.border:Show()
			frame:Show()
		else
			frame:Hide()
		end
	end,
})

-- Target highlight ----------------------------------------------------------

Indicators:Register("targetHighlight", {
	label = "Target Highlight",
	order = 61,
	flags = bor(UPDATE.TARGET, UPDATE.COLOR),

	create = function(button)
		local frame = CreateFrame("Frame", nil, button.overlay)
		frame:SetPoint("TOPLEFT", button, "TOPLEFT", -1, 1)
		frame:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", 1, -1)
		frame:SetFrameLevel(button.overlay:GetFrameLevel() + 3)
		frame.border = Util.CreateBorder(frame, "OVERLAY")
		frame:Hide()
		return frame
	end,

	layout = function(button, frame, cfg)
		frame.border:SetSize(cfg.thickness or 2)
		frame.border:SetColor(Util.Unpack(cfg.color))
		if not cfg.enabled then frame:Hide() end
	end,

	update = function(button, frame, cfg)
		local unit = button.unit
		if unit and UnitIsUnit(unit, "target") then
			frame:Show()
		else
			frame:Hide()
		end
	end,
})

-- Mouseover highlight -------------------------------------------------------

Indicators:Register("mouseoverHighlight", {
	label = "Mouseover Highlight",
	order = 62,
	flags = UPDATE.COLOR,

	create = function(button)
		local tex = button.overlay:CreateTexture(nil, "OVERLAY", nil, 6)
		tex:SetAllPoints(button)
		tex:SetTexture(Media.WHITE)
		tex:SetBlendMode("ADD")
		tex:Hide()
		button.mouseoverTexture = tex
		return tex
	end,

	layout = function(button, tex, cfg)
		tex:SetAllPoints(button)
		local r, g, b, a = Util.Unpack(cfg.color, 0.35)
		tex:SetVertexColor(r, g, b, a)
		if not cfg.enabled then tex:Hide() end
	end,

	update = function() end,
})

--------------------------------------------------------------------------------
-- Duration ticker
--
-- Aura icons show a swipe (free) and optionally a numeric countdown (not free).
-- One shared 0.1s ticker updates every visible countdown rather than each icon
-- running its own OnUpdate.
--------------------------------------------------------------------------------

function Indicators:OnEnable()
	Boom:NewTicker(0.1, function()
		local frames = Boom.Frames
		if not frames then return end

		for _, button in frames:IterateButtons() do
			local pools = button.iconPools
			if pools and button:IsVisible() then
				for _, pool in pairs(pools) do
					local icons = pool.icons
					for i = 1, #icons do
						local icon = icons[i]
						if icon.expiration and icon:IsShown() and icon.duration:IsShown() then
							icon:UpdateDuration()
						end
					end
				end
			end
		end
	end)
end
