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

	Everything a unit button draws that is not a bar: the three text indicators,
	the seven icon indicators, and the six aura indicators.

	This file registers into the framework built in UI/Indicators.lua and creates
	no module of its own - registration happens at file scope, at load time, so
	UI/UnitButton.lua finds a complete registry the first time it decorates a
	button.

	Two disciplines run through the whole file.

	  * COLD vs HOT. `layout` may allocate, measure and re-anchor freely; it runs
	    when settings change. `update` runs for every dirty button on every frame
	    it is dirty - forty units in a raid pull - so it allocates nothing and
	    builds no strings unless the value it would display actually changed.
	    Every setter goes through a cached-value guard for that reason.

	  * WHICH UNIT. `button.displayUnit` is what the frame is showing (the pet
	    unit while a player drives a vehicle) and answers "how much health, which
	    auras". `button.unit` is who the frame belongs to and answers "is this the
	    raid leader, is a ready check pending" - group state lives on the player
	    token, not on their vehicle.

	The `label` field is a Boom.L KEY, not translated text: the options list does
	the lookup at display time so a language switch retitles the list without
	rebuilding the registry. That is why this file barely touches L.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Indicators = Boom.Indicators

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

local bor = bit.bor
local floor, min, max, huge = math.floor, math.min, math.max, math.huge
local format = string.format

local GetTime                 = GetTime
local UnitName                = UnitName
local UnitGroupRolesAssigned  = UnitGroupRolesAssigned
local UnitIsGroupLeader       = UnitIsGroupLeader
local UnitIsGroupAssistant    = UnitIsGroupAssistant
local GetRaidTargetIndex      = GetRaidTargetIndex

--------------------------------------------------------------------------------
-- Cached setters
--
-- SetText / SetTexture / SetTexCoord all push work into the C layer and dirty
-- the render batch. Individually they are cheap; forty buttons times a dozen
-- widgets times sixty frames a second is not. Every one of them is therefore
-- fronted by a comparison against the last value we wrote.
--------------------------------------------------------------------------------

local function setText(fs, text)
	if fs.boomText ~= text then
		fs.boomText = text
		fs:SetText(text)
	end
end

local function setTextColor(fs, r, g, b, a)
	a = a or 1
	if fs.boomR ~= r or fs.boomG ~= g or fs.boomB ~= b or fs.boomA ~= a then
		fs.boomR, fs.boomG, fs.boomB, fs.boomA = r, g, b, a
		fs:SetTextColor(r, g, b, a)
	end
end

local function setTexture(tex, path)
	if tex.boomPath ~= path then
		tex.boomPath = path
		tex:SetTexture(path)
	end
end

local function setCoords(tex, left, right, top, bottom)
	if tex.boomCL ~= left or tex.boomCR ~= right or tex.boomCT ~= top or tex.boomCB ~= bottom then
		tex.boomCL, tex.boomCR, tex.boomCT, tex.boomCB = left, right, top, bottom
		tex:SetTexCoord(left, right, top, bottom)
	end
end

local function setVertexColor(tex, r, g, b)
	if tex.boomVR ~= r or tex.boomVG ~= g or tex.boomVB ~= b then
		tex.boomVR, tex.boomVG, tex.boomVB = r, g, b
		tex:SetVertexColor(r, g, b)
	end
end

--- Forget what we believe is on a texture. Required after anything OTHER than
--- the setters above has written to it - a cache that still claims the old path
--- would make the next setTexture with that same path a no-op and leave whatever
--- the third party drew in place.
local function invalidateTexture(tex)
	tex.boomPath = nil
	tex.boomCL, tex.boomCR, tex.boomCT, tex.boomCB = nil, nil, nil, nil
end

--------------------------------------------------------------------------------
-- Widget factories
--------------------------------------------------------------------------------

--- All text and icon indicators parent to button.overlay, which sits above both
--- bars. Sublevel decides who wins where two of them are configured to the same
--- spot; see the statusText registration for the one case that matters.
local function createFontString(button, sublevel)
	local fs = button.overlay:CreateFontString(nil, "OVERLAY")
	fs:SetDrawLayer("OVERLAY", sublevel or 2)
	fs:SetJustifyH("CENTER")

	-- A FontString with no width auto-sizes to the whole string on one line and
	-- grows outward from its anchor with nothing to stop it, so a long name or a
	-- long translated status word ("Rahatsiz etmeyin") runs straight off the
	-- button onto its neighbours. Width is applied per layout below; these two
	-- turn the overflow into an ellipsis instead of a second line.
	if fs.SetWordWrap then fs:SetWordWrap(false) end
	if fs.SetMaxLines then fs:SetMaxLines(1) end

	fs:Hide()
	return fs
end

--- How wide a text indicator is allowed to be on this button.
---
--- GetWidth is the truth when it is available, but a header child created inside
--- the restricted environment is sized by the INITIAL_CONFIG snippet, which may
--- not have run yet the first time we lay out - so fall back to the profile
--- rather than clamping to zero and ellipsising the name forever.
local function textWidthFor(button)
	local width = button:GetWidth()

	if not width or width <= 1 then
		local layouts = Boom.profile and Boom.profile.layouts
		local kind    = button.boomKind or (button:GetParent() and button:GetParent().boomKind)
		local layout  = layouts and (layouts[kind or "raid"] or layouts.raid)
		width = layout and layout.width or 90
	end

	local border = (Boom.profile and Boom.profile.appearance
		and Boom.profile.appearance.borderSize) or 1

	return max(20, width - (border * 2) - 4)
end

local function layoutFontString(button, fs, cfg, defaultSize)
	Util.SetFont(fs, Media:GetFont(),
		cfg.fontSize or defaultSize or Media:GetFontSize(),
		cfg.outline or Media:GetFontOutline())

	Indicators:Position(fs, button, cfg)

	fs:SetWidth(textWidthFor(button))

	-- Truncate on the side that is running out of room: a string anchored LEFT
	-- grows right, so it must be cut on the right, and vice versa. Centred text
	-- grows both ways and is cut on both.
	local point = cfg.point or "CENTER"
	if point:find("LEFT") then
		fs:SetJustifyH("LEFT")
	elseif point:find("RIGHT") then
		fs:SetJustifyH("RIGHT")
	else
		fs:SetJustifyH("CENTER")
	end

	-- Drop the caches: a font or maxLength change must be allowed to rewrite the
	-- same string, and colour caches must not survive a palette edit.
	fs.boomText  = nil
	fs.boomR     = nil
	fs.rawName   = nil
	fs.lastValue = nil

	if not cfg.enabled then fs:Hide() end
end

local function createIconTexture(button, sublevel)
	local tex = button.overlay:CreateTexture(nil, "OVERLAY", nil, sublevel or 3)
	tex:Hide()
	return tex
end

local function layoutIconTexture(button, tex, cfg, defaultSize)
	local size = cfg.size or defaultSize or 12
	tex:SetSize(size, size)
	Indicators:Position(tex, button, cfg)
	tex:SetAlpha(cfg.alpha or 1)
	if not cfg.enabled then tex:Hide() end
end

--------------------------------------------------------------------------------
-- Dispel colours
--
-- Data/ClassData.lua owns the palette, but this file loads whether or not that
-- table resolved, and a dispel indicator with no colour is worse than useless -
-- it is a lie about what the debuff is. Hence the fallback.
--------------------------------------------------------------------------------

local FALLBACK_DISPEL_COLORS = {
	Magic   = { 0.20, 0.60, 1.00 },
	Curse   = { 0.60, 0.00, 1.00 },
	Disease = { 0.60, 0.40, 0.00 },
	Poison  = { 0.00, 0.60, 0.00 },
	Bleed   = { 0.80, 0.10, 0.10 },
	none    = { 0.80, 0.00, 0.00 },
}

--- Returns a shared table, never a fresh one: this is called from update().
local function dispelColor(dispelName)
	-- ClassData sources these from the client's own AuraUtil palette when it can,
	-- so Boom follows any future retint and the colourblind mode for free, and
	-- caches the result so this stays allocation-free.
	local classData = Boom.ClassData
	if classData and classData.GetDebuffColorTable then
		return classData:GetDebuffColorTable(dispelName)
	end

	return FALLBACK_DISPEL_COLORS[dispelName or "none"] or FALLBACK_DISPEL_COLORS.none
end

--------------------------------------------------------------------------------
-- TEXT: unit name
--------------------------------------------------------------------------------

Indicators:Register("name", {
	label = "Unit Name",
	order = 30,
	flags = bor(UPDATE.NAME, UPDATE.STATUS, UPDATE.COLOR, UPDATE.VEHICLE),

	create = function(button)
		return createFontString(button, 2)
	end,

	layout = function(button, fs, cfg)
		layoutFontString(button, fs, cfg, 12)
	end,

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

		-- While driving a vehicle the frame reads a pet token, so the name shown
		-- would be the vehicle's. Some people want that, some want the frame to
		-- go quiet; the option decides.
		if cfg.hideInVehicle and unit ~= button.unit then
			fs:Hide()
			return
		end

		-- Through the roster, never a bare UnitName. The client answers with the
		-- UNKNOWNOBJECT placeholder for a group member whose name has not arrived
		-- yet, and drawing "Unknown" is worse than drawing nothing; the roster
		-- collapses that to nil and repaints this button when the real name lands
		-- (Engine/Roster.lua, UNIT_NAME_UPDATE).
		--
		-- Deliberately not `Roster:GetName(unit) or UnitName(unit)`: nil is the
		-- roster's ANSWER, not a sign it could not answer, and falling through on
		-- it would put "Unknown" straight back on the frame.
		local name
		if Boom.Roster then
			name = Boom.Roster:GetName(unit)
		else
			name = UnitName(unit)
		end

		if not name then
			fs:Hide()
			return
		end

		-- Shortening walks the string byte by byte for UTF-8 safety, so it only
		-- runs when the name or the limit actually moved.
		if name ~= fs.rawName or cfg.maxLength ~= fs.boomMaxLength then
			fs.rawName = name
			fs.boomMaxLength = cfg.maxLength
			setText(fs, Util.ShortenName(name, cfg.maxLength))
		end

		if cfg.classColor then
			setTextColor(fs, Util.GetUnitClassColor(unit))
		else
			setTextColor(fs, Util.Unpack(cfg.color))
		end

		fs:Show()
	end,
})

--------------------------------------------------------------------------------
-- TEXT: health
--------------------------------------------------------------------------------

-- Life states in which a health number says nothing a healer can act on, so the
-- status word gets the pixels to itself. AFK, DND and PHASED are absent on
-- purpose: those players are alive and their health still matters.
local HEALTH_TEXT_SUPPRESSED_BY = {
	DEAD    = true,
	GHOST   = true,
	OFFLINE = true,
	FEIGN   = true,
}

Indicators:Register("healthText", {
	label = "Health Text",
	order = 31,
	flags = UPDATE.HEALTH,

	create = function(button)
		return createFontString(button, 2)
	end,

	layout = function(button, fs, cfg)
		layoutFontString(button, fs, cfg, 11)
		if cfg.color then
			setTextColor(fs, Util.Unpack(cfg.color))
		end
	end,

	update = function(button, fs, cfg)
		local unit = button.displayUnit
		local mode = cfg.format or "deficit"

		if not unit or mode == "none" then
			fs:Hide()
			return
		end

		-- Stand down while the status word owns this unit. A corpse's health
		-- deficit is noise, and drawing both meant two strings rendering into the
		-- same pixels - draw sublevel decides paint order, not occlusion, so the
		-- glyphs interleaved into something unreadable rather than one winning.
		--
		-- Only the states where the number is genuinely meaningless: an AFK or
		-- DND player is alive and a healer still needs their health.
		if HEALTH_TEXT_SUPPRESSED_BY[Boom.Status:GetStatus(unit) or ""] then
			fs:Hide()
			return
		end

		local current, maximum, fraction = Boom.Status:GetHealth(unit)

		-- Inside instances the client hands addons secret values: storable and
		-- displayable through a status bar, but impossible to do arithmetic on.
		-- There is no percentage or deficit to compute, so say nothing rather
		-- than error. The bar itself keeps working.
		if fraction == nil then
			fs:Hide()
			return
		end

		if not maximum or maximum <= 0 then
			fs:Hide()
			return
		end

		-- A full bar already says "full". Any number on it is noise the eye has
		-- to filter out on nineteen other frames.
		if cfg.hideAtFull and current >= maximum then
			fs:Hide()
			return
		end

		local percent = floor(current / maximum * 100 + 0.5)

		-- Two scalars identify the rendered string for every format, so the
		-- string itself is only built when one of them moves.
		local a, b
		if mode == "percent" then
			a = percent
		elseif mode == "current" then
			a = current
		elseif mode == "current_percent" then
			a, b = current, percent
		else
			a = current - maximum
		end

		if a ~= fs.lastValue or b ~= fs.lastValue2 or mode ~= fs.lastMode then
			fs.lastValue, fs.lastValue2, fs.lastMode = a, b, mode

			if mode == "percent" then
				setText(fs, format("%d%%", percent))
			elseif mode == "current" then
				setText(fs, Util.FormatNumber(current))
			elseif mode == "current_percent" then
				setText(fs, format("%s  %d%%", Util.FormatNumber(current), percent))
			else
				-- Deficit is the healer's number: how much of a heal fits.
				setText(fs, Util.FormatNumber(current - maximum))
			end
		end

		fs:Show()
	end,
})

--------------------------------------------------------------------------------
-- TEXT: status
--
-- Dead / offline / ghost / phased.
--
-- This used to claim that a higher draw sublevel than healthText made the word
-- "win over" the number when both were anchored to the same spot. That is not
-- what sublevel does: it decides which string is painted second, not which one
-- is suppressed, so both rendered and their glyphs interleaved into mush. The
-- suppression is now explicit, in healthText's own update - see
-- HEALTH_TEXT_SUPPRESSED_BY above it. The sublevel is kept because it still
-- decides paint order in the states where both are legitimately visible.
--------------------------------------------------------------------------------

Indicators:Register("statusText", {
	label = "Status Text",
	order = 32,

	-- HEALTH is in here on purpose. Death and resurrection have no dedicated
	-- unit event - all the client says is that health moved - so a rez arrives
	-- as UNIT_HEALTH carrying UPDATE.HEALTH alone. Waking only on STATUS meant
	-- "Dead" was written by whichever status event happened to land near the
	-- death and then never taken back, which is exactly the bug this fixes.
	-- Blizzard's own CompactUnitFrame calls UpdateStatusText out of its
	-- UNIT_HEALTH branch for the same reason.
	--
	-- Deliberately narrowed to this one indicator rather than ORing STATUS into
	-- UNIT_HEALTH in Engine/Status.lua: that would also wake resurrect, summon
	-- and missingBuff on every health tick of every button, and missingBuff is
	-- expensive. GetStatus is seven boolean Unit* calls with no allocation, and
	-- setText already no-ops on an unchanged string.
	flags = bor(UPDATE.STATUS, UPDATE.HEALTH),

	create = function(button)
		return createFontString(button, 5)
	end,

	layout = function(button, fs, cfg)
		layoutFontString(button, fs, cfg, 12)
		if cfg.color then
			setTextColor(fs, Util.Unpack(cfg.color))
		end
	end,

	update = function(button, fs, cfg)
		local unit = button.displayUnit
		local text = unit and Boom.Status:GetStatusText(unit)

		if not text then
			-- Blank as well as hide. Hiding alone leaves the last word inside the
			-- FontString (setText caches it in fs.boomText), so any later Show -
			-- a layout pass, a profile switch, a recycled button - would put
			-- "Dead" back on a living player.
			setText(fs, "")
			fs:Hide()
			return
		end

		setText(fs, text)
		fs:Show()
	end,
})

--------------------------------------------------------------------------------
-- ICON: role
--------------------------------------------------------------------------------

Indicators:Register("roleIcon", {
	label = "Role Icon",
	order = 40,
	flags = UPDATE.ROLE,

	create = function(button)
		local tex = createIconTexture(button, 3)
		setTexture(tex, Media.ICONS.roleTank)
		return tex
	end,

	layout = function(button, tex, cfg)
		layoutIconTexture(button, tex, cfg, 12)
	end,

	update = function(button, tex, cfg)
		-- Role belongs to the player, not to the vehicle they are sitting in.
		local unit = button.unit
		if not unit then
			tex:Hide()
			return
		end

		local role = UnitGroupRolesAssigned(unit)
		local coords = role and Media.ROLE_COORDS[role]

		-- In a healing UI the damage dealers are the default; only the two roles
		-- that change how you treat a frame are worth the pixels.
		if not coords or (role == "DAMAGER" and cfg.hideDamager) then
			tex:Hide()
			return
		end

		setTexture(tex, Media.ICONS.roleTank)
		setCoords(tex, coords[1], coords[2], coords[3], coords[4])
		tex:Show()
	end,
})

--------------------------------------------------------------------------------
-- ICON: leader / assistant
--------------------------------------------------------------------------------

Indicators:Register("leaderIcon", {
	label = "Leader Icon",
	order = 41,
	flags = UPDATE.ROLE,

	create = function(button)
		local tex = createIconTexture(button, 3)
		setCoords(tex, 0, 1, 0, 1)
		return tex
	end,

	layout = function(button, tex, cfg)
		layoutIconTexture(button, tex, cfg, 12)
	end,

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

		local path
		if UnitIsGroupLeader and UnitIsGroupLeader(unit) then
			path = Media.ICONS.leader
		elseif UnitIsGroupAssistant and UnitIsGroupAssistant(unit) then
			path = Media.ICONS.assistant
		end

		if not path then
			tex:Hide()
			return
		end

		setTexture(tex, path)
		tex:Show()
	end,
})

--------------------------------------------------------------------------------
-- ICON: raid target marker
--------------------------------------------------------------------------------

Indicators:Register("raidTarget", {
	label = "Raid Target Marker",
	order = 42,
	flags = UPDATE.RAIDICON,

	create = function(button)
		local tex = createIconTexture(button, 3)
		setTexture(tex, Media.ICONS.raidTargets)
		return tex
	end,

	layout = function(button, tex, cfg)
		layoutIconTexture(button, tex, cfg, 16)
	end,

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

		local index = Boom.Status:GetRaidTargetIndex(unit)
		if not index then
			tex:Hide()
			return
		end

		-- Routed through ClassData rather than computing texture coordinates
		-- here: the marker index is one of the values the client can withhold,
		-- and SetRaidTargetIconTexture accepts it where arithmetic would throw.
		-- The cached setters are skipped for the same reason; this only runs on
		-- RAID_TARGET_UPDATE, so it is not a hot path.
		if Boom.ClassData:SetRaidTargetTexture(tex, index) then
			invalidateTexture(tex)
		end
	end,
})

--------------------------------------------------------------------------------
-- ICON: ready check
--------------------------------------------------------------------------------

local READY_CHECK_TEXTURES = {
	ready    = Media.ICONS.readyCheck,
	notready = Media.ICONS.notReady,
	waiting  = Media.ICONS.waiting,
}

Indicators:Register("readyCheck", {
	label = "Ready Check",
	order = 43,
	flags = UPDATE.READY,

	create = function(button)
		local tex = createIconTexture(button, 4)
		setCoords(tex, 0, 1, 0, 1)
		return tex
	end,

	layout = function(button, tex, cfg)
		layoutIconTexture(button, tex, cfg, 22)
	end,

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

		local state = Boom.Status:GetReadyCheck(unit)
		local path = state and READY_CHECK_TEXTURES[state]
		if not path then
			tex:Hide()
			return
		end

		setTexture(tex, path)
		tex:Show()
	end,
})

--------------------------------------------------------------------------------
-- ICON: incoming resurrection
--------------------------------------------------------------------------------

Indicators:Register("resurrect", {
	label = "Resurrection",
	order = 44,
	flags = UPDATE.STATUS,

	create = function(button)
		local tex = createIconTexture(button, 4)
		setTexture(tex, Media.ICONS.resurrect)
		setCoords(tex, 0, 1, 0, 1)
		return tex
	end,

	layout = function(button, tex, cfg)
		layoutIconTexture(button, tex, cfg, 20)
	end,

	update = function(button, tex, cfg)
		local unit = button.unit
		if not unit or not Boom.Status:GetResurrect(unit) then
			tex:Hide()
			return
		end

		setTexture(tex, Media.ICONS.resurrect)
		tex:Show()
	end,
})

--------------------------------------------------------------------------------
-- ICON: incoming summon
--------------------------------------------------------------------------------

-- Pending is neutral, accepted is done, declined needs a second summon. The
-- shared Blizzard texture carries no state, so the tint does.
local SUMMON_COLORS = {
	PENDING  = { 1.00, 1.00, 1.00 },
	ACCEPTED = { 0.35, 0.90, 0.45 },
	DECLINED = { 1.00, 0.35, 0.35 },
}

Indicators:Register("summon", {
	label = "Incoming Summon",
	order = 45,
	flags = UPDATE.STATUS,

	create = function(button)
		local tex = createIconTexture(button, 4)
		setTexture(tex, Media.ICONS.summon)
		setCoords(tex, 0, 1, 0, 1)
		return tex
	end,

	layout = function(button, tex, cfg)
		layoutIconTexture(button, tex, cfg, 20)
	end,

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

		local state = Boom.Status:GetSummon(unit)
		if not state then
			tex:Hide()
			return
		end

		local color = SUMMON_COLORS[state] or SUMMON_COLORS.PENDING
		setTexture(tex, Media.ICONS.summon)
		setVertexColor(tex, color[1], color[2], color[3])
		tex:Show()
	end,
})

--------------------------------------------------------------------------------
-- ICON: Blizzard private auras
--
-- Encounter mechanics that only the affected player may see (Sepulcher's
-- Rune of Damnation and everything since). The client refuses to tell an addon
-- what they are; it will however draw them for us if we hand it an anchor. So
-- Boom supplies parent frames and Blizzard fills them in.
--
-- Anchors are owned by the client until we hand the id back, which makes them
-- the one thing in this file with real lifetime management: every anchor must
-- be released before the unit token behind it is reused, or a raid1 anchor
-- keeps drawing a different player's mechanic.
--------------------------------------------------------------------------------

local AddPrivateAuraAnchor    = C_UnitAuras and C_UnitAuras.AddPrivateAuraAnchor
local RemovePrivateAuraAnchor = C_UnitAuras and C_UnitAuras.RemovePrivateAuraAnchor
local privateAurasSupported   = (AddPrivateAuraAnchor and RemovePrivateAuraAnchor) and true or false

if not privateAurasSupported then
	Boom:Debug(L["Private auras are not available on this client."])
end

local function releasePrivateAnchors(obj)
	local ids = obj.ids
	for i = #ids, 1, -1 do
		if RemovePrivateAuraAnchor then
			pcall(RemovePrivateAuraAnchor, ids[i])
		end
		ids[i] = nil
	end
	obj.anchoredUnit = nil
end

--- Build one anchor per configured slot. Cold path only: this allocates the
--- argument tables the API requires.
local function applyPrivateAnchors(button, obj, cfg)
	releasePrivateAnchors(obj)

	if not privateAurasSupported or not cfg or not cfg.enabled then return end

	local unit = button.unit
	if not unit then return end

	local size = cfg.size or 20
	local count = cfg.max or 2

	for i = 1, count do
		local slot = obj.slots[i]
		if slot then
			local ok, id = pcall(AddPrivateAuraAnchor, {
				unitToken            = unit,
				auraIndex            = i,
				parent               = slot,
				isContainer          = false,
				showCountdownFrame   = cfg.showCountdownFrame ~= false,
				showCountdownNumbers = cfg.showCountdownNumbers ~= false,
				iconInfo = {
					iconWidth  = size,
					iconHeight = size,
					iconAnchor = {
						point         = "CENTER",
						relativeTo    = slot,
						relativePoint = "CENTER",
						offsetX       = 0,
						offsetY       = 0,
					},
				},
			})

			if ok and id then
				obj.ids[#obj.ids + 1] = id
			end
		end
	end

	obj.anchoredUnit = unit
end

Indicators:Register("privateAuras", {
	label = "Private Auras",
	order = 46,
	flags = UPDATE.LAYOUT,

	create = function(button)
		local obj = { slots = {}, ids = {} }

		-- A container so the whole row can be anchored and hidden as one thing,
		-- while each private aura still gets the individual parent frame the
		-- API demands.
		obj.frame = CreateFrame("Frame", nil, button.overlay)
		obj.frame:SetSize(1, 1)
		obj.frame:SetFrameLevel(button.overlay:GetFrameLevel() + 1)

		return obj
	end,

	layout = function(button, obj, cfg)
		if not privateAurasSupported then
			obj.frame:Hide()
			return
		end

		if not cfg.enabled then
			releasePrivateAnchors(obj)
			obj.frame:Hide()
			return
		end

		local size    = cfg.size or 20
		local count   = cfg.max or 2
		local spacing = 2

		obj.frame:SetSize(size * count + spacing * (count - 1), size)
		Indicators:Position(obj.frame, button, cfg)

		for i = 1, count do
			local slot = obj.slots[i]
			if not slot then
				slot = CreateFrame("Frame", nil, obj.frame)
				obj.slots[i] = slot
			end
			slot:SetSize(size, size)
			slot:ClearAllPoints()
			slot:SetPoint("LEFT", obj.frame, "LEFT", (size + spacing) * (i - 1), 0)
			slot:Show()
		end

		for i = count + 1, #obj.slots do
			obj.slots[i]:Hide()
		end

		obj.frame:Show()

		-- Geometry moved, so the client's icons have to be re-parented onto the
		-- new slot sizes; there is no "resize anchor" call, only add and remove.
		applyPrivateAnchors(button, obj, cfg)
	end,

	update = function(button, obj, cfg)
		if not privateAurasSupported then return end

		-- The header reassigns unit tokens as the roster changes; catching it
		-- here as well as on BOOM_UNIT_CHANGED means a missed message can never
		-- leave an anchor pointed at the wrong player for more than one layout.
		if obj.anchoredUnit ~= button.unit then
			applyPrivateAnchors(button, obj, cfg)
		end
	end,
})

-- Re-anchoring must happen the instant a button changes hands. This is a plain
-- handler owner rather than a module: the file registers behaviour, it is not
-- a component with a lifecycle.
if privateAurasSupported then
	local privateAuraWatcher = setmetatable({}, Boom.ModuleBase)

	privateAuraWatcher:RegisterMessage("BOOM_UNIT_CHANGED", function(_, _, button)
		local obj = button and button.indicators and button.indicators.privateAuras
		if not obj then return end

		local cfg = Boom.profile and Boom.profile.indicators.privateAuras
		if not cfg then return end

		applyPrivateAnchors(button, obj, cfg)
	end)
end

--------------------------------------------------------------------------------
-- AURA: my HoTs
--
-- Only the player's own heal-over-time effects, because the question they
-- answer is "have I already covered this person", and another druid's Rejuv
-- does not answer it.
--------------------------------------------------------------------------------

Indicators:Register("hots", {
	label = "My HoTs",
	order = 50,
	flags = UPDATE.AURA,

	create = function(button)
		return Indicators:GetIconPool(button, "hots", button.overlay)
	end,

	layout = function(button, pool, cfg)
		pool.parent = button.overlay
		if not cfg.enabled then
			pool:HideFrom(1)
			return
		end
		pool:Layout(cfg, button.overlay)
	end,

	update = function(button, pool, cfg)
		local unit = button.displayUnit
		if not unit then
			pool:HideFrom(1)
			return
		end

		local list = Boom.Auras:GetMyHots(unit)
		local shown = min(#list, cfg.max or 5)
		local size = cfg.size or 14

		for i = 1, shown do
			pool:Acquire(i, size):SetAura(list[i], cfg)
		end

		pool:HideFrom(shown + 1)
	end,
})

--------------------------------------------------------------------------------
-- AURA: important debuffs
--
-- Curated mechanics plus anything the client flags as a boss aura. Boss auras
-- are drawn larger because they are the ones that decide whether the next
-- global goes on this player or the raid.
--------------------------------------------------------------------------------

Indicators:Register("debuffs", {
	label = "Important Debuffs",
	order = 51,
	flags = UPDATE.AURA,

	create = function(button)
		return Indicators:GetIconPool(button, "debuffs", button.overlay)
	end,

	layout = function(button, pool, cfg)
		pool.parent = button.overlay
		if not cfg.enabled then
			pool:HideFrom(1)
			return
		end
		pool:Layout(cfg, button.overlay)
	end,

	update = function(button, pool, cfg)
		local unit = button.displayUnit
		if not unit then
			pool:HideFrom(1)
			return
		end

		local list = Boom.Auras:GetImportantDebuffs(unit)
		local shown = min(#list, cfg.max or 3)

		local size  = cfg.size or 16
		local scale = cfg.bossAuraScale or 1

		for i = 1, shown do
			local aura = list[i]
			local iconSize = aura.isBossAura and floor(size * scale + 0.5) or size

			-- The school colour tells a dispeller at a glance whether the icon
			-- is theirs to solve; the dispel indicator then makes it loud.
			pool:Acquire(i, iconSize):SetAura(aura, cfg, dispelColor(aura.dispelName))
		end

		pool:HideFrom(shown + 1)
	end,
})

--------------------------------------------------------------------------------
-- AURA: dispellable debuff
--
-- The single most important thing on the frame. Everything else on a raid frame
-- competes for attention; this one has to win, and it has to win in peripheral
-- vision, because the healer is looking at the boss.
--
-- Three signals, each deliberately chosen:
--   * the icon says WHAT it is,
--   * the button border recoloured to the debuff school says WHERE it is - a
--     shape change on the outline is the fastest thing the eye picks up in a
--     grid of identical rectangles,
--   * an optional pulsing glow in the same colour says it is still there.
-- None of them is a full-frame flash. Loud, not gaudy.
--------------------------------------------------------------------------------

--- Put the button's border back the way UI/UnitButton left it.
local function restoreButtonBorder(button)
	if not button.boomDispelBorder then return end
	button.boomDispelBorder = nil

	local border = button.border
	if not border then return end

	local app = Boom.profile.appearance
	border:SetSize(app.borderSize or 1)
	border:SetColor(Util.Unpack(app.borderColor))
end

Indicators:Register("dispel", {
	label = "Dispellable Debuff",
	order = 52,
	flags = UPDATE.AURA,

	create = function(button)
		local pool = Indicators:GetIconPool(button, "dispel", button.overlay)

		if not pool.glow then
			pool.glow = Skin:CreateGlow(button, 4)
			-- Above the bars so it reads, below the overlay so it never smears
			-- the name and health text it is meant to draw attention past.
			pool.glow:SetFrameLevel(button:GetFrameLevel() + 3)
		end

		return pool
	end,

	layout = function(button, pool, cfg)
		pool.parent = button.overlay

		local size = cfg.size or 20
		local icon = pool:Acquire(1, size)
		Indicators:Position(icon, button, cfg)
		pool:HideFrom(2)

		if not cfg.enabled then
			icon:Clear()
			pool.glow:Stop()
			restoreButtonBorder(button)
			pool.active = false
			return
		end

		-- UI/UnitButton has just reset the border to profile values, so any
		-- override we were holding is already gone; forget it and let the next
		-- update re-apply if the debuff is still there.
		button.boomDispelBorder = nil
		pool.active = false
		pool.lastSchool = nil
	end,

	update = function(button, pool, cfg)
		local unit = button.displayUnit
		local aura = unit and Boom.Auras:GetDispellable(unit)

		if not aura then
			if pool.active then
				pool.active = false
				pool.lastSchool = nil
				pool.icons[1]:Clear()
				pool.glow:Stop()
				restoreButtonBorder(button)
			end
			return
		end

		local school = aura.dispelName or "none"
		local color  = dispelColor(school)

		if cfg.showIcon ~= false then
			local icon = pool:Acquire(1, cfg.size or 20)
			icon:SetAura(aura, cfg, color)
			-- A swipe on a debuff you are about to remove is decoration; the
			-- option exists because some people read it as urgency instead.
			if cfg.showCooldown == false then icon.cooldown:Hide() end
		else
			pool.icons[1]:Clear()
		end

		-- Border and glow only change when the school does, which for a given
		-- debuff is once.
		if not pool.active or pool.lastSchool ~= school then
			pool.lastSchool = school

			if cfg.highlightBorder and button.border then
				button.boomDispelBorder = true
				button.border:SetSize(cfg.borderThickness or 2)
				button.border:SetColor(color[1], color[2], color[3], 1)
			elseif not cfg.highlightBorder then
				restoreButtonBorder(button)
			end

			if cfg.glow then
				pool.glow:SetColor(color[1], color[2], color[3])
				pool.glow:Start()
			else
				pool.glow:Stop()
			end
		end

		pool.active = true
	end,
})

--------------------------------------------------------------------------------
-- AURA: shield
--
-- One icon for the absorb currently applied, with the remaining absorb value
-- next to it. The number is the useful half: a Power Word: Shield with 200
-- points left is not a shield.
--
-- The flags include HEALTH as well as AURA on purpose. Absorb totals decay with
-- incoming damage and fire UNIT_ABSORB_AMOUNT_CHANGED, which is a HEALTH-family
-- event; on AURA alone the number would sit there lying until the aura itself
-- changed.
--------------------------------------------------------------------------------

Indicators:Register("shield", {
	label = "Shields",
	order = 53,
	flags = bor(UPDATE.AURA, UPDATE.HEALTH),

	create = function(button)
		local pool = Indicators:GetIconPool(button, "shield", button.overlay)

		if not pool.amount then
			pool.amount = button.overlay:CreateFontString(nil, "OVERLAY")
			pool.amount:SetDrawLayer("OVERLAY", 5)
			pool.amount:Hide()
		end

		return pool
	end,

	layout = function(button, pool, cfg)
		pool.parent = button.overlay

		local size = cfg.size or 14
		local icon = pool:Acquire(1, size)
		Indicators:Position(icon, button, cfg)
		pool:HideFrom(2)

		Util.SetFont(pool.amount, Media:GetFont(), max(8, floor(size * 0.62)), "OUTLINE")
		pool.amount:ClearAllPoints()
		-- Anchored by its RIGHT edge, not its centre: the shield icon sits near the
		-- right edge of the button, so a centred "125.3k" grew straight off the
		-- frame. Growing inward keeps it on the unit it belongs to.
		pool.amount:SetPoint("TOPRIGHT", icon, "BOTTOMRIGHT", 0, 1)
		pool.amount:SetJustifyH("RIGHT")
		if pool.amount.SetWordWrap then pool.amount:SetWordWrap(false) end
		pool.amount.boomText = nil
		pool.lastAbsorb = nil

		if not cfg.enabled then
			icon:Clear()
			pool.amount:Hide()
		end
	end,

	update = function(button, pool, cfg)
		local unit = button.displayUnit
		local aura = unit and Boom.Auras:GetShield(unit)

		if not aura then
			pool.icons[1]:Clear()
			pool.amount:Hide()
			pool.lastAbsorb = nil
			return
		end

		pool:Acquire(1, cfg.size or 14):SetAura(aura, cfg)

		if not cfg.showAmount then
			pool.amount:Hide()
			return
		end

		local _, absorb, _, secret = Boom.Status:GetPrediction(unit)
		-- A secret amount cannot be compared or formatted; the icon still shows
		-- that a shield is up, which is the part that matters most.
		if secret or not absorb or absorb <= 0 then
			pool.amount:Hide()
			pool.lastAbsorb = nil
			return
		end

		if absorb ~= pool.lastAbsorb then
			pool.lastAbsorb = absorb
			setText(pool.amount, Util.FormatNumber(absorb))
		end

		pool.amount:Show()
	end,
})

--------------------------------------------------------------------------------
-- AURA: defensive externals
--
-- Someone else's cooldown is already on this player. One big icon, because the
-- decision it drives is binary: do not spend yours here.
--------------------------------------------------------------------------------

--- Pick the external worth showing when several overlap. Longest remaining
--- wins, and a permanent one outranks everything: the icon is answering "how
--- long is this player already covered for".
local function pickPrimaryExternal(list)
	local best, bestRemaining
	local now = GetTime()

	for i = 1, #list do
		local aura = list[i]
		local expiration = aura.expirationTime
		local remaining = (expiration and expiration > 0) and (expiration - now) or huge

		if not best or remaining > bestRemaining then
			best, bestRemaining = aura, remaining
		end
	end

	return best
end

Indicators:Register("externals", {
	label = "Defensive Externals",
	order = 54,
	flags = UPDATE.AURA,

	create = function(button)
		return Indicators:GetIconPool(button, "externals", button.overlay)
	end,

	layout = function(button, pool, cfg)
		pool.parent = button.overlay

		local icon = pool:Acquire(1, cfg.size or 22)
		Indicators:Position(icon, button, cfg)
		pool:HideFrom(2)

		if icon.duration then
			Util.SetFont(icon.duration, Media:GetFont(),
				cfg.durationFontSize or max(9, floor((cfg.size or 22) * 0.45)), "OUTLINE")
		end

		if not cfg.enabled then icon:Clear() end
	end,

	update = function(button, pool, cfg)
		local unit = button.displayUnit
		-- The Support panel's "Track Externals" switch has to reach here: it is
		-- the only thing this indicator draws, so without the check the setting
		-- was a checkbox that did nothing.
		if not unit or not Boom.Support:ShouldTrackExternals() then
			pool.icons[1]:Clear()
			return
		end

		local list = Boom.Auras:GetExternals(unit)
		if not list or #list == 0 then
			pool.icons[1]:Clear()
			return
		end

		local aura = pickPrimaryExternal(list)
		if not aura then
			pool.icons[1]:Clear()
			return
		end

		-- A green border reads as "protected" without needing a legend.
		pool:Acquire(1, cfg.size or 22):SetAura(aura, cfg, Skin.colors.good)
	end,
})

--------------------------------------------------------------------------------
-- AURA: support buffs
--
-- The headline feature. Augmentation Evoker is the reference case: Ebon Might
-- and Prescience have to be readable across twenty frames while you are also
-- doing your rotation.
--
-- The half that makes it work is the negative space. Showing who HAS the buff
-- is easy and nearly useless - you already know, you cast it. What you cannot
-- hold in your head is who does not. So when a player is a legitimate target
-- for your primary support buff and is missing it, the frame draws that buff's
-- own icon as a dim, outlined ghost. Missing coverage becomes a shape you can
-- scan for instead of a fact you have to remember.
--------------------------------------------------------------------------------

local PLACEHOLDER_ALPHA  = 0.35
local PLACEHOLDER_BORDER = { 1.00, 0.78, 0.25 }

--- Undo the ghost treatment before an icon is reused for a real aura.
local function clearPlaceholder(icon)
	if not icon.boomPlaceholder then return end
	icon.boomPlaceholder = nil
	icon:SetAlpha(1)
	icon.texture:SetDesaturated(false)
	icon.texture:SetVertexColor(1, 1, 1)
end

local function setPlaceholder(icon, texturePath)
	icon.boomPlaceholder = true
	icon.spellId = nil
	icon.expiration = nil

	icon.texture:SetTexture(texturePath)
	icon.texture:SetDesaturated(true)
	icon.texture:SetVertexColor(1, 1, 1)

	icon.count:Hide()
	icon.duration:Hide()
	icon.cooldown:Hide()

	icon.border:SetColor(PLACEHOLDER_BORDER[1], PLACEHOLDER_BORDER[2], PLACEHOLDER_BORDER[3], 1)
	icon:SetAlpha(PLACEHOLDER_ALPHA)
	icon:Show()
end

Indicators:Register("support", {
	label = "Support Buffs",
	order = 55,
	-- Not just AURA: the "missing buff" placeholder also depends on the target
	-- being alive, connected and in range, so it has to re-evaluate when those
	-- change or it would keep nagging about someone who just walked out of range.
	flags = bor(UPDATE.AURA, UPDATE.RANGE, UPDATE.STATUS),

	create = function(button)
		return Indicators:GetIconPool(button, "support", button.overlay)
	end,

	layout = function(button, pool, cfg)
		pool.parent = button.overlay
		if not cfg.enabled then
			pool:HideFrom(1)
			return
		end
		pool:Layout(cfg, button.overlay)
	end,

	update = function(button, pool, cfg)
		local unit = button.displayUnit
		if not unit then
			pool:HideFrom(1)
			return
		end

		local list = Boom.Auras:GetSupportBuffs(unit)
		local shown = min(#list, cfg.max or 3)
		local size = cfg.size or 18

		for i = 1, shown do
			local icon = pool:Acquire(i, size)
			clearPlaceholder(icon)
			icon:SetAura(list[i], cfg)
		end

		-- Nothing on this player: the interesting case.
		if shown == 0 then
			local Support = Boom.Support
			if Support and Support.IsMissingBuff and Support:IsMissingBuff(button.unit) then
				local spellID = Support:GetPrimaryBuff()
				local texturePath = spellID and Util.GetSpellIcon(spellID)
				if texturePath then
					setPlaceholder(pool:Acquire(1, size), texturePath)
					shown = 1
				end
			end
		end

		pool:HideFrom(shown + 1)
	end,
})
