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

	One unit button: its anatomy, its layout, and its update pipeline.

	Buttons are created by the secure group headers in UI/Header.lua. This file
	never creates a button - it decorates one that already exists, and it never
	touches a secure attribute. That separation is what keeps Boom taint-free:
	everything here is ordinary insecure UI hanging off a frame whose protected
	surface is owned entirely by Blizzard's header code.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local UnitButton = Boom:NewModule("UnitButton")

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 = math.min, math.max, math.floor

local UnitIsUnit, UnitExists = UnitIsUnit, UnitExists
local UnitIsConnected, UnitIsDeadOrGhost = UnitIsConnected, UnitIsDeadOrGhost
local UnitHasVehicleUI = UnitHasVehicleUI

--------------------------------------------------------------------------------
-- Construction
--------------------------------------------------------------------------------

--- Decorate a freshly created secure header child. Runs exactly once per button.
function UnitButton:Setup(button)
	if button.boomReady then return end

	-- No SetFrameStrata here: it is a protected call on a header child, and the
	-- header can create children mid-combat. Children inherit the header's
	-- strata anyway, which is what we want.

	-- Drop shadow sits behind everything, as a sibling-level frame.
	button.shadow = Skin:CreateShadow(button, 3, 0.5)

	-- Solid backing so the button never shows the world through gaps.
	button.bg = button:CreateTexture(nil, "BACKGROUND", nil, -6)
	button.bg:SetAllPoints()
	button.bg:SetTexture(Media.WHITE)
	button.bg:SetVertexColor(0.05, 0.05, 0.06, 1)

	----------------------------------------------------------------- health bar
	local health = CreateFrame("StatusBar", nil, button)
	health:SetStatusBarTexture(Media:GetBarTexture())
	health:SetMinMaxValues(0, 1)
	health:SetValue(1)
	health:SetFrameLevel(button:GetFrameLevel() + 1)
	button.healthBar = health

	-- The "missing health" fill. Drawn as a texture behind the bar rather than
	-- a second StatusBar: one less frame per unit, forty units per raid.
	health.loss = health:CreateTexture(nil, "BACKGROUND", nil, -1)
	health.loss:SetAllPoints()
	health.loss:SetTexture(Media:GetBackgroundTexture())

	health.gloss = Skin:CreateGloss(health, 0.16)
	health.gloss:SetDrawLayer("ARTWORK", 5)

	------------------------------------------------------------------ power bar
	local power = CreateFrame("StatusBar", nil, button)
	power:SetStatusBarTexture(Media:GetBarTexture())
	power:SetMinMaxValues(0, 1)
	power:SetValue(1)
	power:SetFrameLevel(button:GetFrameLevel() + 1)
	power.bg = power:CreateTexture(nil, "BACKGROUND")
	power.bg:SetAllPoints()
	power.bg:SetTexture(Media.WHITE)
	power.bg:SetVertexColor(0.08, 0.08, 0.1, 1)
	button.powerBar = power

	-------------------------------------------------------------------- overlay
	-- Everything textual or iconic parents here so a single frame level change
	-- lifts all of it above the bars.
	local overlay = CreateFrame("Frame", nil, button)
	overlay:SetAllPoints()
	overlay:SetFrameLevel(button:GetFrameLevel() + 4)
	button.overlay = overlay

	--------------------------------------------------------------------- border
	button.border = Util.CreateBorder(button, "OVERLAY")

	------------------------------------------------------------------ behaviour
	button:SetScript("OnEnter", UnitButton.OnEnter)
	button:SetScript("OnLeave", UnitButton.OnLeave)
	button:HookScript("OnShow", UnitButton.OnShow)
	button:HookScript("OnAttributeChanged", UnitButton.OnAttributeChanged)

	button.BoomUpdate = UnitButton.BoomUpdate

	Boom.Indicators:CreateAll(button)

	-- Only now is the button genuinely usable. Setting the flag last means a
	-- button whose construction threw is left un-flagged, so Frames:ScanChildren
	-- picks it up and tries again instead of orphaning it forever.
	button.boomReady = true

	self:ApplyLayout(button)

	-- Pick up whatever unit the header already assigned.
	UnitButton.OnAttributeChanged(button, "unit", button:GetAttribute("unit"))

	Boom:SendMessage("BOOM_BUTTON_CREATED", button)
end

--------------------------------------------------------------------------------
-- Layout
--------------------------------------------------------------------------------

function UnitButton:ApplyLayout(button)
	local app  = Boom.profile.appearance
	local pcfg = Boom.profile.powerBar

	local barTexture = Media:GetBarTexture()
	local bgTexture  = Media:GetBackgroundTexture()

	------------------------------------------------------------------- borders
	button.border:SetSize(app.borderSize or 1)
	button.border:SetColor(Util.Unpack(app.borderColor))
	if (app.borderSize or 1) > 0 then button.border:Show() else button.border:Hide() end

	if button.shadow then
		button.shadow:SetShown(app.dropShadow ~= false)
		if button.shadow.texture then
			button.shadow.texture:SetVertexColor(0, 0, 0, app.dropShadowAlpha or 0.5)
		end
	end

	local inset = app.borderSize or 1

	------------------------------------------------------------------ geometry
	local health = button.healthBar
	local power  = button.powerBar

	health:SetStatusBarTexture(barTexture)
	health.loss:SetTexture(bgTexture)
	power:SetStatusBarTexture(barTexture)

	health:SetOrientation(app.barOrientation == "VERTICAL" and "VERTICAL" or "HORIZONTAL")
	-- StatusBar fills from the left/bottom by default; reverse covers the rest.
	local reverse = (app.barFillDirection == "RIGHT") or (app.barFillDirection == "TOP")
	health:SetReverseFill(reverse)

	health:ClearAllPoints()
	power:ClearAllPoints()

	if pcfg.enabled then
		local size = max(1, pcfg.height or 4)
		if pcfg.position == "RIGHT" then
			power:SetOrientation("VERTICAL")
			power:SetPoint("TOPRIGHT", button, "TOPRIGHT", -inset, -inset)
			power:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", -inset, inset)
			power:SetWidth(size)

			health:SetPoint("TOPLEFT", button, "TOPLEFT", inset, -inset)
			health:SetPoint("BOTTOMRIGHT", power, "BOTTOMLEFT", -1, 0)
		else
			power:SetOrientation("HORIZONTAL")
			power:SetPoint("BOTTOMLEFT", button, "BOTTOMLEFT", inset, inset)
			power:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", -inset, inset)
			power:SetHeight(size)

			health:SetPoint("TOPLEFT", button, "TOPLEFT", inset, -inset)
			health:SetPoint("BOTTOMRIGHT", power, "TOPRIGHT", 0, 1)
		end
		power:Show()
	else
		power:Hide()
		health:SetPoint("TOPLEFT", button, "TOPLEFT", inset, -inset)
		health:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", -inset, inset)
	end

	health.gloss:SetShown(app.gradientOverlay ~= false)
	if app.gradientOverlay ~= false then
		Skin:SetGradient(health.gloss, "VERTICAL", 0, 0, 0, 0, 1, 1, 1, app.gradientOverlayAlpha or 0.18)
	end

	Boom.Indicators:LayoutAll(button)

	Boom:MarkDirty(button, UPDATE.ALL)
end

--------------------------------------------------------------------------------
-- Unit assignment
--------------------------------------------------------------------------------

function UnitButton.OnAttributeChanged(button, name, value)
	-- The pet header assigns the OWNER token to `unit` and carries "pet" in
	-- `unitsuffix`; the unit that actually exists is the two concatenated.
	-- Watching only `unit` would make every pet frame draw its owner.
	if name ~= "unit" and name ~= "unitsuffix" then return end

	local unit = button:GetAttribute("unit")
	local suffix = button:GetAttribute("unitsuffix")
	if unit and suffix then unit = unit .. suffix end

	if unit == "" then unit = nil end
	if button.unit == unit then return end

	local previous = button.unit
	button.unit = unit
	button.displayUnit = unit

	if Boom.Frames and Boom.Frames.SetButtonUnit then
		Boom.Frames:SetButtonUnit(button, previous, unit)
	end

	if unit then
		UnitButton.RefreshVehicleUnit(button)
		Boom:MarkDirty(button, UPDATE.ALL)
		Boom:SendMessage("BOOM_UNIT_CHANGED", button, unit)
	end
end

--- While a player is driving a vehicle their own unit id reports the vehicle's
--- health. Blizzard exposes the passenger through the matching pet unit, so we
--- read from that while leaving the secure `unit` attribute untouched - it is
--- header-owned and casting must still resolve to the real player.
function UnitButton.RefreshVehicleUnit(button)
	local unit = button.unit
	if not unit then return end

	local display = unit
	if UnitHasVehicleUI and UnitHasVehicleUI(unit) then
		local vehicle
		if unit == "player" then
			vehicle = "pet"
		else
			local index = unit:match("^raid(%d+)$")
			if index then
				vehicle = "raidpet" .. index
			else
				index = unit:match("^party(%d+)$")
				if index then vehicle = "partypet" .. index end
			end
		end
		if vehicle and UnitExists(vehicle) then
			display = vehicle
		end
	end

	button.displayUnit = display
end

function UnitButton.OnShow(button)
	Boom:MarkDirty(button, UPDATE.ALL)
end

--------------------------------------------------------------------------------
-- Mouse
--------------------------------------------------------------------------------

function UnitButton.OnEnter(button)
	if button.mouseoverTexture then
		local cfg = Boom.profile.indicators.mouseoverHighlight
		if cfg and cfg.enabled then button.mouseoverTexture:Show() end
	end
	if Boom.Tooltip then Boom.Tooltip:Show(button) end
end

function UnitButton.OnLeave(button)
	if button.mouseoverTexture then button.mouseoverTexture:Hide() end
	if Boom.Tooltip then Boom.Tooltip:Hide(button) end
end

--------------------------------------------------------------------------------
-- The update pipeline
--------------------------------------------------------------------------------

--- The bar is always driven with RAW values, never a precomputed fraction.
--- SetMinMaxValues and SetValue both accept the client's secret values from
--- tainted code, so this one path works identically inside and outside
--- instances - and it never divides, which is what would break in instances.
local function updateHealth(button, unit)
	local cur, maxHP, frac = Boom.Status:GetHealth(unit)
	local bar = button.healthBar

	if frac == nil then
		-- Values are secret. Pass them straight through; the bar can read them
		-- even though we cannot.
		bar:SetMinMaxValues(0, maxHP)
		bar:SetValue(cur)
		return
	end

	if not maxHP or maxHP <= 0 then
		bar:SetMinMaxValues(0, 1)
		bar:SetValue(0)
		return
	end

	bar:SetMinMaxValues(0, maxHP)
	bar:SetValue(cur)
end

local function updateHealthColor(button, unit)
	local app = Boom.profile.appearance
	local bar = button.healthBar

	local r, g, b

	-- Both flags can come back secret inside an instance, and testing a secret
	-- is a hard error. Util.IsFalse/IsTrue collapse "withheld" to "no", so the
	-- bar keeps its normal colour rather than throwing mid-update.
	if Util.IsFalse(UnitIsConnected(unit)) then
		r, g, b = 0.45, 0.45, 0.45
	elseif Util.IsTrue(UnitIsDeadOrGhost(unit)) then
		r, g, b = 0.35, 0.35, 0.38
	elseif app.healthColorMode == "class" then
		r, g, b = Util.GetUnitClassColor(unit)
	elseif app.healthColorMode == "gradient" then
		local _, _, frac = Boom.Status:GetHealth(unit)
		if frac then
			r, g, b = Util.HealthGradient(frac)
		else
			-- No usable number while values are secret; class colour still reads.
			r, g, b = Util.GetUnitClassColor(unit)
		end
	else
		r, g, b = Util.Unpack(app.staticHealthColor)
	end

	bar:SetStatusBarColor(r, g, b, 1)

	-- Background of the bar: either flat dark, or a dimmed class colour which
	-- keeps raid-wide class identification readable even at low health.
	local lr, lg, lb, la
	if app.backgroundColorMode == "class" then
		local cr, cg, cb = Util.GetUnitClassColor(unit)
		local m = app.backgroundClassMultiplier or 0.22
		lr, lg, lb, la = cr * m, cg * m, cb * m, 1
	elseif app.backgroundColorMode == "custom" then
		lr, lg, lb, la = Util.Unpack(app.backgroundColor)
	else
		lr, lg, lb, la = Util.Unpack(app.backgroundColor)
	end
	bar.loss:SetVertexColor(lr, lg, lb, la)
end

local function updatePower(button, unit)
	local cfg = Boom.profile.powerBar
	local bar = button.powerBar
	if not cfg.enabled then
		bar:Hide()
		return
	end

	local cur, maxPower, frac, powerType = Boom.Status:GetPower(unit)

	if cfg.onlyManaUsers and powerType ~= Enum.PowerType.Mana then
		bar:Hide()
		return
	end

	if cfg.onlyGroupHealers and not Util.IsHealer(unit) then
		bar:Hide()
		return
	end

	if frac == nil then
		bar:SetMinMaxValues(0, maxPower)
		bar:SetValue(cur)
	elseif not maxPower or maxPower <= 0 then
		bar:Hide()
		return
	else
		bar:SetMinMaxValues(0, maxPower)
		bar:SetValue(cur)
	end

	if cfg.colorByType then
		local r, g, b = Boom.ClassData:GetPowerColor(powerType, Util.UnitClassToken(unit))
		bar:SetStatusBarColor(r, g, b, 1)
	else
		bar:SetStatusBarColor(0.3, 0.5, 0.9, 1)
	end

	bar:Show()
end

local function updateAlpha(button, unit)
	local app = Boom.profile.appearance
	local alpha = 1

	if Util.IsFalse(UnitIsConnected(unit)) then
		alpha = app.alphaOffline or 0.5
	elseif Util.IsTrue(UnitIsDeadOrGhost(unit)) then
		alpha = app.alphaDead or 0.65
	-- Range is asked about button.unit, not the display unit: a player in a
	-- vehicle is still the unit your spell would land on, and it is the token
	-- the range ticker actually keeps up to date.
	elseif Boom.profile.range.enabled and not Boom.Range:IsInRange(button.unit) then
		alpha = app.alphaOutOfRange or 0.4
	end

	if button.boomAlpha ~= alpha then
		button.boomAlpha = alpha
		button:SetAlpha(alpha)
	end
end

function UnitButton.BoomUpdate(button, flags)
	local unit = button.unit
	if not unit or not button:IsShown() then
		-- The flags die with this call - the scheduler swaps the dirty table out
		-- before dispatching - so a skipped update loses them for good. Remember
		-- that this button owes a full redraw instead, or a party reshuffle that
		-- blinks a unit out for one frame leaves the previous occupant's status
		-- text on screen forever.
		button.boomPending = true
		return
	end

	if button.boomPending then
		button.boomPending = nil
		flags = UPDATE.ALL
	end

	if band(flags, UPDATE.VEHICLE) ~= 0 then
		UnitButton.RefreshVehicleUnit(button)
	end

	local display = button.displayUnit or unit
	if not UnitExists(display) then
		-- The header will hide us shortly; do not draw stale data meanwhile.
		button.boomPending = true
		return
	end

	if band(flags, bor(UPDATE.HEALTH, UPDATE.STATUS)) ~= 0 then
		updateHealth(button, display)
	end

	if band(flags, bor(UPDATE.HEALTH, UPDATE.COLOR, UPDATE.NAME, UPDATE.STATUS)) ~= 0 then
		updateHealthColor(button, display)
	end

	if band(flags, bor(UPDATE.POWER, UPDATE.COLOR, UPDATE.NAME)) ~= 0 then
		updatePower(button, display)
	end

	if band(flags, bor(UPDATE.RANGE, UPDATE.STATUS, UPDATE.HEALTH)) ~= 0 then
		updateAlpha(button, display)
	end

	Boom.Indicators:UpdateButton(button, flags)
end

--------------------------------------------------------------------------------
-- Settings reactions
--------------------------------------------------------------------------------

function UnitButton:OnEnable()
	local function relayoutAll()
		if not Boom.Frames then return end
		for _, button in Boom.Frames:IterateButtons() do
			UnitButton:ApplyLayout(button)
		end
	end

	self:RegisterMessage("BOOM_LAYOUT_CHANGED", relayoutAll)
	self:RegisterMessage("BOOM_APPEARANCE_CHANGED", relayoutAll)
	self:RegisterMessage("BOOM_PROFILE_CHANGED", relayoutAll)
end
