--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Core/Util.lua

	Small, dependency-free helpers used across the addon. Everything here is a
	plain function on Boom.Util - no lifecycle, no events.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Util = {}
Boom.Util = Util

local floor, abs, min, max = math.floor, math.abs, math.min, math.max
local format, gsub, sub, len = string.format, string.gsub, string.sub, string.len
local type, tonumber, tostring = type, tonumber, tostring

--------------------------------------------------------------------------------
-- Numbers and time
--------------------------------------------------------------------------------

function Util.Round(value, decimals)
	local mult = 10 ^ (decimals or 0)
	return floor(value * mult + 0.5) / mult
end

function Util.Clamp(value, lo, hi)
	if value < lo then return lo end
	if value > hi then return hi end
	return value
end

--- 1234 -> "1.2k", 1234567 -> "1.2M". Negative values keep their sign.
function Util.FormatNumber(value)
	if not value then return "" end
	local sign = value < 0 and "-" or ""
	local v = abs(value)

	if v >= 1e9 then
		return format("%s%.1fB", sign, v / 1e9)
	elseif v >= 1e6 then
		return format("%s%.1fM", sign, v / 1e6)
	elseif v >= 1e4 then
		return format("%s%.0fk", sign, v / 1e3)
	elseif v >= 1e3 then
		return format("%s%.1fk", sign, v / 1e3)
	end
	return format("%s%d", sign, v)
end

--- Aura / cooldown style duration text.
function Util.FormatTime(seconds)
	if not seconds or seconds <= 0 then return "" end

	if seconds >= 3600 then
		return format("%dh", floor(seconds / 3600 + 0.5))
	elseif seconds >= 60 then
		return format("%dm", floor(seconds / 60 + 0.5))
	elseif seconds >= 10 then
		return format("%d", floor(seconds))
	end
	return format("%.1f", seconds)
end

--------------------------------------------------------------------------------
-- Strings
--------------------------------------------------------------------------------

--- Trim a unit name to `maxLength` visible characters, honouring UTF-8 so we do
--- not slice a multi-byte glyph in half.
function Util.ShortenName(name, maxLength)
	if not name then return "" end
	if not maxLength or maxLength <= 0 then return name end

	local count, byte = 0, 1
	local length = #name
	while byte <= length do
		local c = name:byte(byte)
		local step = 1
		if c >= 240 then step = 4
		elseif c >= 224 then step = 3
		elseif c >= 192 then step = 2 end

		count = count + 1
		if count > maxLength then
			return sub(name, 1, byte - 1)
		end
		byte = byte + step
	end
	return name
end

--- "Playername-Realm" -> "Playername"
function Util.StripRealm(name)
	if not name then return "" end
	local short = name:match("^([^%-]+)")
	return short or name
end

--------------------------------------------------------------------------------
-- Colours
--------------------------------------------------------------------------------

-- Last-resort palette. Only reached if both C_ClassColor and RAID_CLASS_COLORS
-- are unavailable, which should never happen, but a grey raid frame is a much
-- worse failure than a slightly stale hex value.
local CLASS_COLOR_FALLBACK = {
	WARRIOR     = { 0.78, 0.61, 0.43 },
	PALADIN     = { 0.96, 0.55, 0.73 },
	HUNTER      = { 0.67, 0.83, 0.45 },
	ROGUE       = { 1.00, 0.96, 0.41 },
	PRIEST      = { 1.00, 1.00, 1.00 },
	DEATHKNIGHT = { 0.77, 0.12, 0.23 },
	SHAMAN      = { 0.00, 0.44, 0.87 },
	MAGE        = { 0.25, 0.78, 0.92 },
	WARLOCK     = { 0.53, 0.53, 0.93 },
	MONK        = { 0.00, 1.00, 0.60 },
	DRUID       = { 1.00, 0.49, 0.04 },
	DEMONHUNTER = { 0.64, 0.19, 0.79 },
	EVOKER      = { 0.20, 0.58, 0.50 },
}
Util.CLASS_COLOR_FALLBACK = CLASS_COLOR_FALLBACK

--- Class colours, honouring the CUSTOM_CLASS_COLORS addon when present.
function Util.GetClassColorByToken(token)
	if not token then return 0.6, 0.6, 0.6 end

	local custom = _G.CUSTOM_CLASS_COLORS and _G.CUSTOM_CLASS_COLORS[token]
	if custom then return custom.r, custom.g, custom.b end

	if C_ClassColor and C_ClassColor.GetClassColor then
		local color = C_ClassColor.GetClassColor(token)
		if color then return color.r, color.g, color.b end
	end

	local color = RAID_CLASS_COLORS and RAID_CLASS_COLORS[token]
	if color then return color.r, color.g, color.b end

	color = CLASS_COLOR_FALLBACK[token]
	if color then return color[1], color[2], color[3] end

	return 0.6, 0.6, 0.6
end

function Util.GetUnitClassColor(unit)
	if not unit then return 0.6, 0.6, 0.6 end
	local _, token = UnitClass(unit)
	return Util.GetClassColorByToken(token)
end

--- Green -> yellow -> red by health fraction. Used by healthColorMode "gradient".
function Util.HealthGradient(fraction)
	fraction = Util.Clamp(fraction or 1, 0, 1)
	if fraction > 0.5 then
		local t = (fraction - 0.5) * 2
		return (1 - t) * 1.0 + t * 0.15, (1 - t) * 0.82 + t * 0.75, (1 - t) * 0.16 + t * 0.35
	end
	local t = fraction * 2
	return (1 - t) * 0.85 + t * 1.0, (1 - t) * 0.12 + t * 0.82, (1 - t) * 0.12 + t * 0.16
end

function Util.Darken(r, g, b, factor)
	return r * factor, g * factor, b * factor
end

--------------------------------------------------------------------------------
-- Secret booleans
--
-- Midnight made the unit flag queries secret-restricted the same way it did
-- health: inside instances UnitIsAFK, UnitIsDeadOrGhost and friends hand back a
-- value that may be stored and passed on but NOT tested. Writing the obvious
--     if UnitIsAFK(unit) then
-- therefore throws "attempt to perform boolean test on a secret boolean value"
-- in the middle of a frame update - a hard error, not a silent nil.
--
-- Nothing can ask what a secret holds, so "cannot know" collapses to "do not
-- claim it". These two exist so that decision is made in one place and reads
-- the same at every call site.
--------------------------------------------------------------------------------

local issecretvalue = _G.issecretvalue or function() return false end

--- Is the client withholding this value? Comparing a secret is an error just
--- like testing one, so anything that wants to inspect a value at all has to
--- ask this first.
function Util.IsSecret(value)
	return issecretvalue(value) and true or false
end

--- True only when the client actually said true.
function Util.IsTrue(value)
	if issecretvalue(value) then return false end
	return value and true or false
end

--- True only when the client actually said false.
---
--- Deliberately NOT `not Util.IsTrue(v)`. That would read a withheld value as
--- false, and the callers that use this are asking inverted questions - "is
--- this unit disconnected?" - so it would report every unit in an instance as
--- offline.
function Util.IsFalse(value)
	if issecretvalue(value) then return false end
	return not value
end

--- Pack a colour table {r,g,b,a} into arguments, with sane fallbacks.
function Util.Unpack(color, defaultAlpha)
	if type(color) ~= "table" then return 1, 1, 1, defaultAlpha or 1 end
	return color[1] or 1, color[2] or 1, color[3] or 1, color[4] or defaultAlpha or 1
end

function Util.ToHex(r, g, b)
	return format("%02x%02x%02x", floor(r * 255 + 0.5), floor(g * 255 + 0.5), floor(b * 255 + 0.5))
end

--------------------------------------------------------------------------------
-- Spells (cached - GetSpellInfo is not free and we call it every aura scan)
--------------------------------------------------------------------------------

local nameCache, iconCache = {}, {}

function Util.GetSpellName(spellID)
	if not spellID then return nil end
	local cached = nameCache[spellID]
	if cached ~= nil then
		return cached ~= false and cached or nil
	end

	local name
	if C_Spell and C_Spell.GetSpellName then
		name = C_Spell.GetSpellName(spellID)
	end
	if not name and C_Spell and C_Spell.GetSpellInfo then
		local info = C_Spell.GetSpellInfo(spellID)
		name = info and info.name
	end

	nameCache[spellID] = name or false
	return name
end

function Util.GetSpellIcon(spellID)
	if not spellID then return nil end
	local cached = iconCache[spellID]
	if cached ~= nil then
		return cached ~= false and cached or nil
	end

	local icon
	if C_Spell and C_Spell.GetSpellTexture then
		icon = C_Spell.GetSpellTexture(spellID)
	end

	iconCache[spellID] = icon or false
	return icon
end

function Util.SpellExists(spellID)
	if not spellID then return false end
	if C_Spell and C_Spell.DoesSpellExist then
		return C_Spell.DoesSpellExist(spellID)
	end
	return Util.GetSpellName(spellID) ~= nil
end

--- Does the player currently have this spell available (talented and learned)?
---
--- Deliberately does NOT lead with IsPlayerSpell / IsSpellKnown. Those two now
--- live in Blizzard_DeprecatedSpellBook, which returns early unless the
--- `loadDeprecationFallbacks` CVar is set - so on a client with that switched
--- off they are simply nil, and this function is on the critical path for every
--- preset, every dispel capability check and every support buff. C_SpellBook is
--- what the deprecation shim itself calls.
function Util.IsPlayerSpell(spellID)
	if not spellID then return false end

	local bank = Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player

	if C_SpellBook then
		if C_SpellBook.IsSpellKnown then
			local ok, known = pcall(C_SpellBook.IsSpellKnown, spellID, bank)
			if ok and known then return true end
		end
		-- Catches spells the player has through a talent override, where the
		-- base ID is "known" only via the replacement.
		if C_SpellBook.IsSpellInSpellBook then
			local ok, known = pcall(C_SpellBook.IsSpellInSpellBook, spellID, bank, true)
			if ok and known then return true end
		end
	end

	if _G.IsPlayerSpell and _G.IsPlayerSpell(spellID) then return true end
	if _G.IsSpellKnown and _G.IsSpellKnown(spellID) then return true end

	return false
end

--- Cooldown info normalised across API generations.
--- Returns start, duration, enabled.
function Util.GetSpellCooldown(spellID)
	if C_Spell and C_Spell.GetSpellCooldown then
		local info = C_Spell.GetSpellCooldown(spellID)
		if info then
			return info.startTime or 0, info.duration or 0, info.isEnabled ~= false
		end
		return 0, 0, true
	end
	return 0, 0, true
end

function Util.GetSpellCharges(spellID)
	if C_Spell and C_Spell.GetSpellCharges then
		local info = C_Spell.GetSpellCharges(spellID)
		if info then
			return info.currentCharges, info.maxCharges, info.cooldownStartTime, info.cooldownDuration
		end
	end
	return nil
end

--------------------------------------------------------------------------------
-- Units
--------------------------------------------------------------------------------

function Util.UnitClassToken(unit)
	if not unit then return nil end
	return (select(2, UnitClass(unit)))
end

function Util.IsHealer(unit)
	return UnitGroupRolesAssigned(unit) == "HEALER"
end

function Util.IsTank(unit)
	return UnitGroupRolesAssigned(unit) == "TANK"
end

--- True when the unit's frame should show the vehicle/pet unit instead.
function Util.ShouldUseVehicleUnit(unit)
	return UnitHasVehicleUI and UnitHasVehicleUI(unit) or false
end

--------------------------------------------------------------------------------
-- Secure group header growth mapping
--
-- SecureGroupHeaderTemplate expresses direction through the anchor point that
-- each child attaches by:  point "TOP" makes children stack downwards,
-- "LEFT" makes them stack rightwards, and so on. columnAnchorPoint does the
-- same for whole columns. We expose friendly names instead.
--------------------------------------------------------------------------------

local GROWTH = {
	DOWN_RIGHT = { point = "TOP",    column = "LEFT"   },
	DOWN_LEFT  = { point = "TOP",    column = "RIGHT"  },
	UP_RIGHT   = { point = "BOTTOM", column = "LEFT"   },
	UP_LEFT    = { point = "BOTTOM", column = "RIGHT"  },
	RIGHT_DOWN = { point = "LEFT",   column = "TOP"    },
	RIGHT_UP   = { point = "LEFT",   column = "BOTTOM" },
	LEFT_DOWN  = { point = "RIGHT",  column = "TOP"    },
	LEFT_UP    = { point = "RIGHT",  column = "BOTTOM" },
}
Util.GROWTH_MODES = GROWTH

--- Returns point, columnAnchorPoint for a friendly growth name.
function Util.GetGrowthAnchors(growth)
	local g = GROWTH[growth] or GROWTH.DOWN_RIGHT
	return g.point, g.column
end

--------------------------------------------------------------------------------
-- Tables
--------------------------------------------------------------------------------

function Util.Contains(tbl, value)
	for i = 1, #tbl do
		if tbl[i] == value then return true, i end
	end
	return false
end

function Util.Count(tbl)
	local n = 0
	for _ in pairs(tbl) do n = n + 1 end
	return n
end

function Util.SortedKeys(tbl, comparator)
	local keys = {}
	for k in pairs(tbl) do keys[#keys + 1] = k end
	table.sort(keys, comparator)
	return keys
end

--------------------------------------------------------------------------------
-- Frame helpers
--------------------------------------------------------------------------------

--- Apply a font to a FontString from Boom's media settings.
---
--- Every piece of text in the addon comes through here, which makes it the one
--- place worth being defensive. SetFont is a hard error in the client when the
--- path is not a string or the size is not a positive number, and both can
--- arrive that way honestly: `cfg.fontSize or default` keeps a stored 0 because
--- 0 is truthy in Lua, and a profile carried over from an older build can hold
--- values no current slider can produce. One bad number would otherwise take
--- out a whole frame update.
local FALLBACK_FONT = "Fonts\\FRIZQT__.TTF"

-- "no outline" is the EMPTY STRING, never nil.
--
-- SetFont's signature is (fontFile, height, flags) and the client validates all
-- three. Passing nil for flags - which reads perfectly naturally in Lua, and
-- which older clients accepted - fails on this one with
--     bad argument #3 to 'SetFont' (Usage: local success = self:SetFont(...))
-- so EVERY piece of text configured with outline "NONE" silently lost its font:
-- the first call threw, the fallback threw for exactly the same reason, and the
-- FontString was left with no font at all, which renders as nothing.
--
-- That is why this is a constant rather than an inline `or nil`.
local NO_OUTLINE = ""

-- Font paths that the client would not take. Remembered so a broken font is
-- reported once rather than on every one of the ~900 SetFont calls a single
-- relayout makes.
local badFonts = {}

--- Try one font. Returns true only if the client actually accepted it.
---
--- SetFont fails in two different ways and both have to be caught: it returns
--- false when the file cannot be loaded, and it raises when the arguments are
--- the wrong shape. A missing file is not hypothetical - Media:Font can hand
--- back a LibSharedMedia path belonging to another addon, and that addon may
--- since have been disabled, leaving a perfectly well-formed path to a file
--- that is not there any more.
local function applyFont(fontString, path, size, outline)
	local ok, accepted = pcall(fontString.SetFont, fontString, path, size, outline)
	-- Older clients return nothing at all on success, so only an explicit false
	-- counts as a refusal.
	return ok and accepted ~= false
end

--- Normalise whatever a profile holds into a flags string the client accepts.
--- Unknown values collapse to "no outline" rather than being passed through:
--- an old profile can hold anything, and a rejected flags argument costs the
--- FontString its font entirely.
local VALID_OUTLINE = {
	OUTLINE = "OUTLINE",
	THICKOUTLINE = "THICKOUTLINE",
	MONOCHROME = "MONOCHROME",
	["OUTLINE, MONOCHROME"] = "OUTLINE, MONOCHROME",
	["THICKOUTLINE, MONOCHROME"] = "THICKOUTLINE, MONOCHROME",
}

function Util.NormalizeOutline(outline)
	if type(outline) ~= "string" then return NO_OUTLINE end
	return VALID_OUTLINE[outline] or NO_OUTLINE
end

function Util.SetFont(fontString, fontPath, size, outline)
	if not fontString or type(fontString.SetFont) ~= "function" then return end

	if type(fontPath) ~= "string" or fontPath == "" then
		fontPath = FALLBACK_FONT
	end

	size = tonumber(size)
	-- The `size ~= size` test catches NaN, which is a number and would still
	-- throw. Clamped rather than defaulted: a user who set 4 meant "small".
	if not size or size ~= size or size < 1 then
		size = 12
	elseif size > 64 then
		size = 64
	end

	outline = Util.NormalizeOutline(outline)

	-- A font already known to be unusable is not tried again. A single relayout
	-- makes roughly 900 of these calls, and re-attempting a font the client has
	-- already refused means two failed calls into C for every one of them, for
	-- the rest of the session, to reach the same fallback every time.
	if badFonts[fontPath] then
		fontPath = FALLBACK_FONT
	end

	-- Each rung drops one thing that could be the problem: first the outline,
	-- then the font file. Only when the stock font with no flags is refused as
	-- well is the object genuinely unable to take a font.
	local applied = applyFont(fontString, fontPath, size, outline)

	if not applied and outline ~= NO_OUTLINE then
		applied = applyFont(fontString, fontPath, size, NO_OUTLINE)
		if applied then outline = NO_OUTLINE end
	end

	if not applied then
		-- Say so once, then carry on with the font the game itself uses.
		--
		-- A NOTICE, not an error. Nothing is broken: the text renders, in the
		-- game's own font, and the cause is almost always a font offered by
		-- another addon through LibSharedMedia whose file this client does not
		-- actually have. The user needs to know which font it was and that they
		-- can pick a different one - not a red line that reads like a crash.
		if not badFonts[fontPath] then
			badFonts[fontPath] = true
			if Boom.ReportNotice then
				Boom:ReportNotice("a font", format(
					"the font %q could not be loaded, so the game's own font is being used instead.",
					fontPath),
					"Pick another one under /boom -> Appearance -> Font to stop this message.")
			end
		end

		applied = (fontPath ~= FALLBACK_FONT) and applyFont(fontString, FALLBACK_FONT, size, outline)

		if not applied then
			applied = applyFont(fontString, FALLBACK_FONT, size, NO_OUTLINE)
			if applied then outline = NO_OUTLINE end
		end

		if not applied then
			-- Even the stock font was refused, which means this object cannot
			-- take one at all. Leave it with whatever it already had rather than
			-- erroring; the shadow calls below would fail for the same reason.
			return
		end
	end

	-- Unoutlined text needs a shadow to stay legible over a health bar; outlined
	-- text already has its own edge and a shadow only muddies it.
	if outline == NO_OUTLINE then
		fontString:SetShadowOffset(1, -1)
		fontString:SetShadowColor(0, 0, 0, 1)
	else
		fontString:SetShadowOffset(0, 0)
	end
end

--- Create a 1px-per-side border made of four textures. Cheaper and crisper than
--- a backdrop at small frame sizes, and it never bleeds at non-integer scales.
function Util.CreateBorder(parent, layer)
	local border = {}
	for _, side in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
		local tex = parent:CreateTexture(nil, layer or "OVERLAY", nil, 7)
		tex:SetColorTexture(0, 0, 0, 1)
		border[side] = tex
	end

	function border:SetSize(size)
		self.size = size
		self:Layout()
	end

	function border:Layout()
		local s = self.size or 1
		self.TOP:ClearAllPoints()
		self.TOP:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
		self.TOP:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)
		self.TOP:SetHeight(s)

		self.BOTTOM:ClearAllPoints()
		self.BOTTOM:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, 0)
		self.BOTTOM:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", 0, 0)
		self.BOTTOM:SetHeight(s)

		self.LEFT:ClearAllPoints()
		self.LEFT:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -s)
		self.LEFT:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, s)
		self.LEFT:SetWidth(s)

		self.RIGHT:ClearAllPoints()
		self.RIGHT:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -s)
		self.RIGHT:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", 0, s)
		self.RIGHT:SetWidth(s)
	end

	function border:SetColor(r, g, b, a)
		for _, side in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
			self[side]:SetColorTexture(r, g, b, a or 1)
		end
	end

	function border:Show()
		for _, side in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do self[side]:Show() end
	end

	function border:Hide()
		for _, side in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do self[side]:Hide() end
	end

	border:SetSize(1)
	return border
end
