---@diagnostic disable: undefined-field, undefined-global
local _, TRB = ...
TRB.Functions = TRB.Functions or {}
TRB.Functions.Castbar = {}

--[[
	Functions.Castbar: the render + event bridge for the castbar bar type.
	Drives TRB.Data.castbar (Classes/Castbar.lua) from UNIT_SPELLCAST_* events (fed by
	Functions/SpellCast.lua), places the static overlays/threshold lines (latency, pushback,
	channel ticks, empower stages) once per cast, and runs a dedicated per-frame updater for a
	smooth fill and the show/hide fade. Bar text values are exposed separately by Functions/BarText.
]]

-- Dedicated per-frame frame: drives the smooth fill + self-healing visibility while a cast is active.
-- Enabled on cast start, disabled once idle.
local castbarFrame = CreateFrame("Frame")
castbarFrame:Hide()
local isRunning = false
-- GetTime() of the most recent cast end while a fade-out (fadeDelay/fadeDuration) is in progress.
local fadeOutStart = nil
-- Whether the OnShow re-assert hook has been installed on PlayerCastingBarFrame.
local blizzardCastbarHookInstalled = false
-- Permanently-hidden holder frame the Blizzard cast bar gets reparented under, and its original
-- parent for restoring. Reparenting is taint-safe (C-side only, no Blizzard Lua runs), unlike SetUnit.
local blizzardCastbarHolder = nil
local blizzardCastbarOriginalParent = nil

---Returns the active spec's settings, castbar bar settings, and castbar colors (or nils).
---@return table? settings, table? barSettings, table? colors
function TRB.Functions.Castbar:GetActiveSettings()
	local settings = TRB.Functions.Class:GetActiveDisplaySettings()
	if settings == nil then
		return nil, nil, nil
	end
	local barSettings = settings.bars and settings.bars.castbar
	local colors = settings.colors and settings.colors.bars and settings.colors.bars.castbar
	return settings, barSettings, colors
end

---Returns the castbar visibility entry (displayBar.castbar) from the given (or active) composed settings.
---@param settings table?
---@return table?
function TRB.Functions.Castbar:GetVisibilitySettings(settings)
	if settings == nil then
		settings = TRB.Functions.Class:GetActiveDisplaySettings()
	end
	return settings and settings.displayBar and settings.displayBar.castbar
end

---Whether the castbar is enabled at all: "Never Show" fully disables castbar processing (the model stays
---idle and no events are tracked), exactly like the old opt-in checkbox being unchecked.
---@param visibility table?
---@return boolean
function TRB.Functions.Castbar:IsEnabled(visibility)
	return visibility ~= nil and visibility.neverShow ~= true
end

---Hides or restores the default Blizzard cast bar per the active spec's settings: hidden while
---`bars.castbar.disableBlizzardCastbar` is set and the addon castbar is enabled (not Never Show).
---Checked on load/spec/talent changes, option toggles, and the Blizzard bar's own OnShow.
---@param settings table? # Composed spec settings to evaluate; defaults to the active display settings (pass explicitly during spec activation, before the composite key is stamped)
function TRB.Functions.Castbar:UpdateBlizzardCastbarVisibility(settings)
	if PlayerCastingBarFrame == nil then
		return
	end
	if settings == nil then
		settings = TRB.Functions.Class:GetActiveDisplaySettings()
	end
	local barSettings = settings and settings.bars and settings.bars.castbar
	local visibility = self:GetVisibilitySettings(settings)
	local shouldDisable = barSettings ~= nil and barSettings.disableBlizzardCastbar == true and self:IsEnabled(visibility)
	-- Blizzard re-parents the cast bar back to UIParent on spec/talent changes (Edit Mode relayout),
	-- so compare actual parentage rather than a cached flag.
	local currentlyDisabled = blizzardCastbarHolder ~= nil and PlayerCastingBarFrame:GetParent() == blizzardCastbarHolder
	if shouldDisable == currentlyDisabled then
		return
	end
	if shouldDisable then
		if blizzardCastbarHolder == nil then
			blizzardCastbarHolder = CreateFrame("Frame")
			blizzardCastbarHolder:Hide()
		end
		blizzardCastbarOriginalParent = PlayerCastingBarFrame:GetParent()
		PlayerCastingBarFrame:SetParent(blizzardCastbarHolder)
		-- Catch future re-parents: re-evaluate whenever the Blizzard bar becomes visible again.
		if not blizzardCastbarHookInstalled then
			blizzardCastbarHookInstalled = true
			PlayerCastingBarFrame:HookScript("OnShow", function()
				TRB.Functions.Castbar:UpdateBlizzardCastbarVisibility()
			end)
		end
	else
		PlayerCastingBarFrame:SetParent(blizzardCastbarOriginalParent or UIParent)
	end
end

---Whether the given cast state ("cast"/"channel"/"empower") may show per the visibility conditions.
---@param visibility table?
---@param state string
---@return boolean
function TRB.Functions.Castbar:IsCastTypeAllowed(visibility, state)
	if visibility == nil or visibility.neverShow == true then
		return false
	end
	if visibility.alwaysShow then
		return true
	end
	local conditions = visibility.conditions
	if conditions == nil then
		return true
	end
	if state == "channel" then
		return conditions.channeling == true
	elseif state == "empower" then
		return conditions.empowered == true
	end
	return conditions.casting == true
end

---Whether a hard-hide condition (In Vehicle) currently suppresses the castbar display. The model keeps
---tracking while force-hidden so the bar reappears mid-cast when the condition clears.
---@param visibility table?
---@return boolean
function TRB.Functions.Castbar:IsForceHidden(visibility)
	local hideConditions = visibility and visibility.hideConditions
	return hideConditions ~= nil and hideConditions.inVehicle == true and UnitInVehicle("player") == true
end

---Container alpha to rest at while no cast is active: activeAlpha when Always Show, else inactiveAlpha.
---@param visibility table?
---@return number # 0..1
function TRB.Functions.Castbar:GetIdleAlpha(visibility)
	if visibility == nil or visibility.neverShow == true then
		return 0
	end
	if visibility.alwaysShow then
		return (visibility.activeAlpha or 100) / 100
	end
	return (visibility.inactiveAlpha or 0) / 100
end

---Returns the castbar BarGroup, or nil if not constructed.
---@return TRB.Classes.BarGroup?
function TRB.Functions.Castbar:GetGroup()
	local barGroups = TRB.Frames.barGroups
	return barGroups and barGroups.castbar or nil
end

---Returns the castbar's single node, or nil.
---@return TRB.Classes.BarNode?
function TRB.Functions.Castbar:GetNode()
	local group = self:GetGroup()
	return group and group:GetNode(1) or nil
end

---Resolves a channel tick profile for a spellId from the castbar settings (built-in + user edits).
---@param barSettings table?
---@param spellId integer?
---@return table?
function TRB.Functions.Castbar:GetTickProfile(barSettings, spellId)
	if barSettings == nil or spellId == nil or issecretvalue(spellId) then
		return nil
	end
	local profiles = barSettings.tickProfiles
	if type(profiles) ~= "table" then
		return nil
	end
	return profiles[spellId]
end

---A talent/buff-conditional bonus-tick rule for a channeled spell, registered per spec in
---TRB.Data.castbarTickModifiersRegistry (code data, not settings; keyed "class_spec" like the profiles
---registry). bonusTicks: a number is a flat bonus whenever the talent is active; a table is the TOTAL
---bonus keyed by talent rank (unmapped ranks resolve to 0). Bonuses only apply to fixedCount profiles.
---@class TRB.Classes.CastbarTickModifier
---@field public talentId integer # Talent (definition spellId) gating this rule, checked via the TRB.Data.talents cache
---@field public buffId integer? # When set, the player must also have this buff when the channel starts (checked against the spec's tracked snapshot when one exists, else a live aura scan)
---@field public bonusTicks number|table<integer, number>

-- Modifier rules are static code data; each spec's getter result is cached after the first lookup
-- (false = spec has no registered modifiers).
---@type table<string, table<integer, TRB.Classes.CastbarTickModifier[]>|false>
local tickModifiersCache = {}

---Returns the active spec's tick modifier rules for a spellId, or nil when none are registered.
---@param spellId integer
---@return TRB.Classes.CastbarTickModifier[]?
local function GetTickModifiers(spellId)
	local character = TRB.Data.character
	local key = character and character.compositeKey
	if key == nil then
		return nil
	end
	local rules = tickModifiersCache[key]
	if rules == nil then
		local registry = TRB.Data.castbarTickModifiersRegistry
		local getter = registry and registry[key]
		rules = type(getter) == "function" and getter() or false
		tickModifiersCache[key] = rules
	end
	return rules and rules[spellId] or nil
end

---Evaluates one modifier rule against live talent/buff state.
---@param rule TRB.Classes.CastbarTickModifier
---@return number # Bonus tick count this rule contributes (0 when its conditions aren't met)
local function EvaluateTickModifier(rule)
	local talents = TRB.Data.talents
	local talent = talents and talents.talents and talents.talents[rule.talentId] or nil
	if talent == nil or not talent:IsActive() then
		return 0
	end
	if rule.buffId ~= nil then
		-- Prefer the spec's tracked snapshot (handles secret aura data via instance-id binding);
		-- fall back to a live aura scan for buffs no spec module tracks.
		local snapshotData = TRB.Data.snapshotData
		local snapshot = snapshotData and snapshotData.snapshots and snapshotData.snapshots[rule.buffId]
		if snapshot ~= nil then
			if not snapshot.buff.isActive then
				return 0
			end
		elseif TRB.Functions.Aura:FindBuffById(rule.buffId) == nil then
			return 0
		end
	end
	local bonus = rule.bonusTicks
	if type(bonus) == "table" then
		return bonus[talent.currentRank] or 0
	end
	return bonus or 0
end

---Resolves the effective tick profile for a channel start: the saved baseline profile adjusted by any
---registered talent/buff-conditional bonus ticks. Returns a copy when a bonus applies -- saved settings
---are never mutated. Call once per channel start and reuse via model.profile: buff-gated bonuses (e.g.
---a buff consumed by this very cast) must not be re-evaluated mid-channel.
---@param barSettings table?
---@param spellId integer?
---@return table?
function TRB.Functions.Castbar:ResolveTickProfile(barSettings, spellId)
	local profile = self:GetTickProfile(barSettings, spellId)
	if profile == nil then
		return nil
	end
	local rules = GetTickModifiers(spellId)
	if rules == nil or profile.mode ~= "fixedCount" or (profile.tickCount or 0) <= 0 then
		return profile
	end
	local bonus = 0
	for _, rule in ipairs(rules) do
		bonus = bonus + EvaluateTickModifier(rule)
	end
	if bonus == 0 then
		return profile
	end
	local effective = {}
	for k, v in pairs(profile) do
		effective[k] = v
	end
	effective.tickCount = profile.tickCount + bonus
	return effective
end

-- ============================================================================
-- Overlay / threshold-line texture pool (fraction-positioned, on the node frame)
-- ============================================================================

---Lazily builds (and returns) the castbar overlay texture pool bound to the current node frame.
---Rebuilt if the node frame changed (e.g., bar groups were reconstructed).
---@param node TRB.Classes.BarNode
---@return table
local function GetOverlayPool(node)
	local frame = node.frame
	---@diagnostic disable-next-line: inject-field
	local pool = frame._trbCastbarOverlays
	if pool and pool.frame == frame then
		return pool
	end
	pool = { frame = frame, ticks = {}, empower = {}, segments = {} }
	pool.latency = frame:CreateTexture(nil, "OVERLAY")
	pool.latency:SetColorTexture(1, 0, 0, 0.5)
	pool.latency:Hide()
	pool.pushback = frame:CreateTexture(nil, "OVERLAY")
	pool.pushback:SetColorTexture(1, 0, 1, 0.5)
	pool.pushback:Hide()
	---@diagnostic disable-next-line: inject-field
	frame._trbCastbarOverlays = pool
	return pool
end

---Places a vertical (horizontal bar) or horizontal (vertical bar) line at a timeline fraction.
---@param tex table # Texture
---@param node TRB.Classes.BarNode
---@param frac number # Timeline fraction 0..1 (0 = fill start)
---@param isVertical boolean # True when the bar fills vertically
---@param thickness number
local function PlaceLine(tex, node, frac, isVertical, thickness)
	local border = node.border or 0
	local w, h = node.width, node.height
	local innerW = math.max(1, w - 2 * border)
	local innerH = math.max(1, h - 2 * border)
	local fillDirection = node.fillDirection or "leftRight"
	tex:ClearAllPoints()
	if isVertical then
		local yFromBottom
		if fillDirection == "topBottom" then
			yFromBottom = border + (1 - frac) * innerH
		else
			yFromBottom = border + frac * innerH
		end
		tex:SetPoint("BOTTOMLEFT", node.frame, "BOTTOMLEFT", border, yFromBottom - thickness / 2)
		tex:SetSize(innerW, thickness)
	else
		local xFromLeft
		if fillDirection == "rightLeft" then
			xFromLeft = border + (1 - frac) * innerW
		else
			xFromLeft = border + frac * innerW
		end
		tex:SetPoint("BOTTOMLEFT", node.frame, "BOTTOMLEFT", xFromLeft - thickness / 2, border)
		tex:SetSize(thickness, innerH)
	end
end

---Places a filled region spanning timeline fractions [fA, fB] (fA < fB).
---@param tex table # Texture
---@param node TRB.Classes.BarNode
---@param fA number
---@param fB number
---@param isVertical boolean
local function PlaceRegion(tex, node, fA, fB, isVertical)
	local border = node.border or 0
	local w, h = node.width, node.height
	local innerW = math.max(1, w - 2 * border)
	local innerH = math.max(1, h - 2 * border)
	local fillDirection = node.fillDirection or "leftRight"
	if fA > fB then fA, fB = fB, fA end
	tex:ClearAllPoints()
	if isVertical then
		local y1, y2
		if fillDirection == "topBottom" then
			y1 = border + (1 - fB) * innerH
			y2 = border + (1 - fA) * innerH
		else
			y1 = border + fA * innerH
			y2 = border + fB * innerH
		end
		tex:SetPoint("BOTTOMLEFT", node.frame, "BOTTOMLEFT", border, y1)
		tex:SetSize(innerW, math.max(1, y2 - y1))
	else
		local x1, x2
		if fillDirection == "rightLeft" then
			x1 = border + (1 - fB) * innerW
			x2 = border + (1 - fA) * innerW
		else
			x1 = border + fA * innerW
			x2 = border + fB * innerW
		end
		tex:SetPoint("BOTTOMLEFT", node.frame, "BOTTOMLEFT", x1, border)
		tex:SetSize(math.max(1, x2 - x1), innerH)
	end
end

---Returns a pooled tick line texture at index i, creating it on demand.
local function GetTickTexture(pool, node, i)
	if pool.ticks[i] == nil then
		local t = node.frame:CreateTexture(nil, "OVERLAY")
		t:SetDrawLayer("OVERLAY", 2)
		pool.ticks[i] = t
	end
	return pool.ticks[i]
end

---Returns a pooled empower stage line texture at index i, creating it on demand.
local function GetEmpowerTexture(pool, node, i)
	if pool.empower[i] == nil then
		local t = node.frame:CreateTexture(nil, "OVERLAY")
		t:SetDrawLayer("OVERLAY", 3)
		pool.empower[i] = t
	end
	return pool.empower[i]
end

---Returns a pooled empower segment-fill texture at index i, creating it on demand. Sits at ARTWORK --
---above the bar background but below the OVERLAY latency/pushback/tick/boundary overlays, so those still
---render on top of the segment fills.
local function GetSegmentTexture(pool, node, i)
	if pool.segments[i] == nil then
		local t = node.frame:CreateTexture(nil, "ARTWORK")
		t:SetDrawLayer("ARTWORK", 1)
		pool.segments[i] = t
	end
	return pool.segments[i]
end

---Hides all pooled overlay textures.
local function HideOverlays(pool)
	if pool == nil then return end
	if pool.latency then pool.latency:Hide() end
	if pool.pushback then pool.pushback:Hide() end
	for _, t in ipairs(pool.ticks) do t:Hide() end
	for _, t in ipairs(pool.empower) do t:Hide() end
	for _, t in ipairs(pool.segments) do t:Hide() end
end

-- ============================================================================
-- Per-state fill color
-- ============================================================================

---Applies the fill color for the current cast state (and empower stage), honoring uninterruptible.
---@param node TRB.Classes.BarNode
---@param colors table
---@param model TRB.Classes.Castbar
---@param barSettings table? # Castbar settings; when empowerSegmentedFill is set the empower fill is blanked
local function ApplyStateFillColor(node, colors, model, barSettings)
	local Color = TRB.Functions.Color
	if colors == nil then return end

	-- Empowered casts always use the empower stage colors, even when uninterruptible: stage progression
	-- is the point of the bar, so it should never be flattened to a plain "can't interrupt" color.
	if model.notInterruptible and colors.uninterruptible and model.state ~= "empower" then
		Color:ApplyFillColor(node, colors.uninterruptible)
		return
	end

	if model.state == "channel" then
		Color:ApplyFillColor(node, colors.channel or colors.bar)
	elseif model.state == "empower" then
		-- Segmented-fill mode: blank the main fill so DrawEmpowerSegments can paint each level's segment in
		-- its own color; there is no single advancing fill color to apply here.
		if barSettings and barSettings.empowerSegmentedFill then
			Color:ApplyFillColor(node, "00000000")
			return
		end
		local stageColors = colors.empowerStages
		-- GetCurrentEmpowerStage returns the absolute empower level reached: 0 while charging toward Level I,
		-- then 1..N as each level threshold is crossed. Color maps directly: stage 0 -> base, stage N ->
		-- levelN (clamped to the game's max of 4 empower levels).
		local stage = model:GetCurrentEmpowerStage()
		local entry
		if stageColors then
			if stage <= 0 then
				entry = stageColors.base
			else
				entry = stageColors["level" .. math.min(stage, 4)]
			end
		end
		Color:ApplyFillColor(node, entry or colors.bar)
	else
		Color:ApplyFillColor(node, colors.bar)
	end
end

---Draws the per-level empower segment fills (segmented-fill mode). The main bar fill is blanked in this
---mode; each segment between empower thresholds fills in its own level color up to the live progress.
---Boundaries are {0} + empowerStageFractions + {1.0}: segment 0 is `base`, segment k is `levelN` (clamped
---to 4) -- the same stage->color mapping as ApplyStateFillColor's single-color path. Redrawn every frame.
---@param node TRB.Classes.BarNode
---@param colors table
---@param model TRB.Classes.Castbar
---@param fill number # Current timeline fill fraction (0..1)
local function DrawEmpowerSegments(node, colors, model, fill)
	local pool = GetOverlayPool(node)
	local stageColors = colors and colors.empowerStages
	local fractions = model.empowerStageFractions
	local n = fractions and #fractions or 0
	if stageColors == nil or n == 0 then
		-- Fractions not computed yet: nothing to segment, so clear any leftover segment textures.
		for _, t in ipairs(pool.segments) do t:Hide() end
		return
	end

	local isVertical = TRB.Functions.Bar:IsVerticalFill(node.fillDirection)
	local drawn = 0
	-- N+1 segments (k = 0..n): [0,f1], [f1,f2], ..., [fn,1.0]. Each fills from its start to min(fill, end).
	for k = 0, n do
		local a = (k == 0) and 0 or fractions[k]
		local b = (k == n) and 1 or fractions[k + 1]
		if b > a and fill > a + 0.0001 then
			local entry = (k == 0) and stageColors.base or stageColors["level" .. math.min(k, 4)]
			local color = (type(entry) == "table" and entry.color) or entry
			if color then
				drawn = drawn + 1
				local t = GetSegmentTexture(pool, node, drawn)
				local cr, cg, cb, ca = TRB.Functions.Color:GetRGBAFromString(color, true)
				t:SetColorTexture(cr, cg, cb, ca)
				PlaceRegion(t, node, a, math.min(fill, b), isVertical)
				t:Show()
			end
		end
	end
	-- Hide any pooled segment textures not used this frame (e.g. earlier stages once fill passes them is
	-- fine -- they stay drawn; this only trims the tail beyond the highest-index segment drawn).
	for i = drawn + 1, #pool.segments do
		pool.segments[i]:Hide()
	end
end

-- ============================================================================
-- Setup (once per cast) and teardown
-- ============================================================================

---Returns the latency overlay fraction actually shown for the current cast/channel (0 when disabled by
---settings or when latency is zero). SetupOverlays draws the zone on the bar's ending side (high-fraction
---end for casts/empowers, low-fraction end for depleting channels). Also feeds cast pushback placement so
---the pushback region sits flush against the latency zone.
---@param model TRB.Classes.Castbar
---@param barSettings table?
---@param colors table?
---@return number
local function GetShownLatencyFraction(model, barSettings, colors)
	if not (colors and colors.latency and colors.latency.enabled and barSettings and barSettings.showLatency ~= false) then
		return 0
	end
	return model:GetLatencyFraction() or 0
end

---Places the static overlays and threshold lines for the freshly-started cast/channel/empower.
---@param model TRB.Classes.Castbar
function TRB.Functions.Castbar:SetupOverlays(model)
	local node = self:GetNode()
	if node == nil then return end
	local _, barSettings, colors = self:GetActiveSettings()
	local pool = GetOverlayPool(node)
	HideOverlays(pool)

	local isVertical = TRB.Functions.Bar:IsVerticalFill(node.fillDirection)
	local tickThickness = (barSettings and barSettings.border and barSettings.border >= 2) and 2 or 1

	-- Latency zone: the final `latency` window (safe-to-queue region), drawn on the bar's ending side. A
	-- cast/empower grows L->R and ends at fraction 1, so its zone is [1-latFrac, 1]; a channel depletes
	-- toward fraction 0, so its ending-side zone is [0, latFrac].
	local latFrac = GetShownLatencyFraction(model, barSettings, colors)
	if latFrac > 0 then
		-- latFrac > 0 implies colors.latency exists (see GetShownLatencyFraction).
		---@diagnostic disable-next-line: need-check-nil
		local r, g, b, a = TRB.Functions.Color:GetRGBAFromString(colors.latency.color, true)
		pool.latency:SetColorTexture(r, g, b, a)
		if model.state == "channel" then
			PlaceRegion(pool.latency, node, 0, latFrac, isVertical)
		else
			PlaceRegion(pool.latency, node, 1 - latFrac, 1, isVertical)
		end
		pool.latency:Show()
	end

	-- Channel ticks sit at each tick's bar fraction (see ComputeChannelTicks, which mirrors fixedRate for depletion).
	if colors and colors.tick and colors.tick.enabled ~= false and barSettings and barSettings.showTicks ~= false
		and model.state == "channel" and model.ticks then
		local r, g, b, a = TRB.Functions.Color:GetRGBAFromString(colors.tick.color, true)
		for i, tick in ipairs(model.ticks) do
			if tick.fraction >= -0.0001 and tick.fraction < 1.0001 then
				local t = GetTickTexture(pool, node, i)
				t:SetColorTexture(r, g, b, a)
				PlaceLine(t, node, tick.fraction, isVertical, tickThickness)
				t:Show()
			end
		end
	end

	-- Empower stage boundary lines.
	if barSettings and barSettings.showEmpowerStages ~= false and model.state == "empower"
		and model.empowerStageFractions then
		local lineColor = (colors and colors.tick and colors.tick.color) or "FFFFFFFF"
		local r, g, b, a = TRB.Functions.Color:GetRGBAFromString(lineColor, true)
		for i, frac in ipairs(model.empowerStageFractions) do
			-- Skip the final boundary at ~1.0 (the bar end needs no line).
			if frac > 0.0001 and frac < 0.9999 then
				local t = GetEmpowerTexture(pool, node, i)
				t:SetColorTexture(r, g, b, a)
				PlaceLine(t, node, frac, isVertical, tickThickness)
				t:Show()
			end
		end
	end
end

---Repositions the pushback overlay to reflect the current accumulated pushback (called on delay events).
---@param model TRB.Classes.Castbar
function TRB.Functions.Castbar:UpdatePushbackOverlay(model)
	local node = self:GetNode()
	if node == nil then return end
	local _, barSettings, colors = self:GetActiveSettings()
	local pool = GetOverlayPool(node)
	pool.pushback:Hide()
	if colors == nil or colors.pushback == nil or colors.pushback.enabled == false then return end
	if barSettings and barSettings.showPushback == false then return end
	local pbFrac = model:GetPushbackFraction()
	if pbFrac == nil then return end
	local isVertical = TRB.Functions.Bar:IsVerticalFill(node.fillDirection)
	local r, g, b, a = TRB.Functions.Color:GetRGBAFromString(colors.pushback.color, true)
	pool.pushback:SetColorTexture(r, g, b, a)
	-- Pushback sits flush against the inner edge of the latency zone (or the bar end when no latency
	-- overlay is shown), since latency always occupies the final stretch of the timeline.
	local outer = 1 - GetShownLatencyFraction(model, barSettings, colors)
	PlaceRegion(pool.pushback, node, math.max(0, outer - pbFrac), outer, isVertical)
	pool.pushback:Show()
end

---Applies the full visible render state (dimensions, fill color, overlays, alpha, Show) onto the
---castbar group/node. Idempotent and cheap to call every frame; the expensive overlay/color setup is
---only re-run when the node's frame changes (i.e., bar groups were reconstructed out from under us).
---Deliberately bypasses the BarGroup fade system: render transitions and HideResourceBar zero every
---group's alpha AND hide it, but the castbar isn't part of BarVisibility:ProcessBars so nothing ever
---restores it. Re-asserting alpha/visibility directly here each frame makes the castbar self-heal.
---@param group TRB.Classes.BarGroup
---@param node TRB.Classes.BarNode
---@param colors table?
---@param model TRB.Classes.Castbar
---@param barSettings table?
---@param visibility table? # displayBar.castbar entry; supplies activeAlpha
function TRB.Functions.Castbar:ApplyVisibleState(group, node, colors, model, barSettings, visibility)
	if self._renderedFrame ~= node.frame then
		-- New/rebuilt node frame: (re)place the static overlays and per-state fill color on it.
		self._renderedFrame = node.frame
		node:SetMinMax(0, 1)
		ApplyStateFillColor(node, colors, model, barSettings)
		self:SetupOverlays(model)
	end
	local activeAlpha = ((visibility and visibility.activeAlpha) or 100) / 100
	-- Keep the shared fade fields synced so anything that consults them stays consistent, but we
	-- drive the actual container alpha directly rather than through UpdateFade.
	group.targetAlpha = activeAlpha
	group.currentAlpha = activeAlpha
	if not group.isVisible then
		group:Show()
		-- The group just transitioned hidden->visible (fresh cast, or after a render transition /
		-- reconstruction hid it). Mark visibility dirty so the next ProcessBars re-runs BarText:Show and
		-- reveals this castbar's anchored text frames (whose isVisible tracks this group's isVisible).
		TRB.Functions.BarVisibility:MarkDirty()
	end
	node:Show()
	group.containerFrame:SetAlpha(activeAlpha)
end

---Applies the fully-hidden state (alpha 0 + Hide) without touching the model.
---@param group TRB.Classes.BarGroup
local function ApplyHiddenState(group)
	group.targetAlpha = 0
	group.currentAlpha = 0
	if group.isVisible then
		group:Hide()
	end
	group.containerFrame:SetAlpha(0)
end

---Applies the idle (no active cast) visible state: empty fill, no overlays, resting at idleAlpha.
---Used for Always Show and non-zero inactive alpha; re-asserted per frame so it self-heals after
---render transitions the same way the active render does.
---@param group TRB.Classes.BarGroup
---@param node TRB.Classes.BarNode
---@param idleAlpha number # 0..1
function TRB.Functions.Castbar:ApplyIdleState(group, node, idleAlpha)
	if self._renderedFrame ~= nil then
		self._renderedFrame = nil
		---@diagnostic disable-next-line: inject-field
		HideOverlays(node.frame._trbCastbarOverlays)
	end
	node:SetMinMax(0, 1)
	node:SetValue(0)
	group.targetAlpha = idleAlpha
	group.currentAlpha = idleAlpha
	if not group.isVisible then
		group:Show()
		TRB.Functions.BarVisibility:MarkDirty()
	end
	node:Show()
	group.containerFrame:SetAlpha(idleAlpha)
end

---Begins showing the castbar for a freshly-started cast: sizes the node, applies color/overlays, shows
---it at the active alpha, and starts the per-frame updater. No-op if the castbar is disabled (Never Show).
function TRB.Functions.Castbar:BeginRender()
	local settings, barSettings, colors = self:GetActiveSettings()
	local visibility = self:GetVisibilitySettings(settings)
	if not self:IsEnabled(visibility) then
		return
	end
	local group = self:GetGroup()
	local node = self:GetNode()
	if group == nil or node == nil then
		return
	end
	local model = TRB.Data.castbar
	fadeOutStart = nil

	node:SetValue(0)
	self._renderedFrame = nil -- force ApplyVisibleState to (re)place overlays for this fresh cast
	if self:IsForceHidden(visibility) then
		-- Hard-hide condition active (e.g. In Vehicle): keep tracking but stay hidden; the per-frame
		-- updater reveals the bar mid-cast if the condition clears.
		ApplyHiddenState(group)
	else
		self:ApplyVisibleState(group, node, colors, model, barSettings, visibility)
	end

	-- Mark visibility dirty so the next ProcessBars pass re-evaluates and (via its castbar-active check)
	-- keeps isTracking true — that's what makes the shared, class-driven UpdateResourceBarText keep
	-- rendering this castbar's anchored bar text for the duration of the cast. Bar text is NOT rendered
	-- here or from the per-frame updater; it flows through the single existing path (no double updates).
	TRB.Functions.BarVisibility:MarkDirty()

	isRunning = true
	castbarFrame:Show()
end

---Ends the castbar render, hiding instantly and clearing overlays. Used for disable (Never Show) and
---other immediate teardowns; natural cast ends go through BeginFadeOut instead.
function TRB.Functions.Castbar:EndRender()
	local group = self:GetGroup()
	local node = self:GetNode()
	self._renderedFrame = nil
	fadeOutStart = nil
	if node then
		---@diagnostic disable-next-line: inject-field
		HideOverlays(node.frame._trbCastbarOverlays)
	end
	if group then
		ApplyHiddenState(group)
	end
	-- Re-evaluate visibility now the cast is over: isTracking / bar text can revert to whatever the
	-- standard bars dictate (e.g. hide out of combat), and the castbar-anchored text stops updating.
	TRB.Functions.BarVisibility:MarkDirty()
	isRunning = false
	castbarFrame:Hide()
end

---Begins the post-cast fade-out honoring fadeDelay/fadeDuration, resting at the idle alpha (Always Show /
---inactive alpha) when done. Falls back to an instant EndRender when no fade is configured and nothing
---rests visible.
function TRB.Functions.Castbar:BeginFadeOut()
	local settings = TRB.Functions.Class:GetActiveDisplaySettings()
	local visibility = self:GetVisibilitySettings(settings)
	local group = self:GetGroup()
	local delay = (visibility and visibility.fadeDelay) or 0
	local duration = (visibility and visibility.fadeDuration) or 0
	if group == nil or not self:IsEnabled(visibility) or self:IsForceHidden(visibility)
		or (delay <= 0 and duration <= 0 and self:GetIdleAlpha(visibility) <= 0) then
		self:EndRender()
		return
	end
	-- The per-frame updater's idle branch drives the hold, fade, and final resting state.
	fadeOutStart = GetTime()
	isRunning = true
	castbarFrame:Show()
end

---Ensures the idle Always Show / inactive-alpha display is running when no cast is active. Cheap and
---safe to call every tick (ProcessBars does); starts the per-frame updater when the resting alpha is
---non-zero so the idle bar renders and self-heals.
function TRB.Functions.Castbar:EnsureIdleState()
	if isRunning then
		return
	end
	local model = TRB.Data.castbar
	if model ~= nil and model:IsActive() then
		return
	end
	local visibility = self:GetVisibilitySettings(nil)
	if self:GetIdleAlpha(visibility) > 0 then
		isRunning = true
		castbarFrame:Show()
	end
end

-- ============================================================================
-- Per-frame updater
-- ============================================================================

castbarFrame:SetScript("OnUpdate", function()
	local self = TRB.Functions.Castbar
	local model = TRB.Data.castbar
	local group = self:GetGroup()
	if group == nil then
		isRunning = false
		castbarFrame:Hide()
		return
	end
	local node = group:GetNode(1)
	local settings, barSettings, colors = self:GetActiveSettings()
	local visibility = self:GetVisibilitySettings(settings)

	if model and model:IsActive() and node then
		local now = GetTime()

		-- Safety: if events were missed and the timeline elapsed, tear down.
		if model.endTime and now > model.endTime + 0.25 then
			model:Stop()
			self:BeginFadeOut()
			return
		end

		-- Yield to an active render transition: it deliberately hides ALL bars briefly during a
		-- reconstruction. We simply re-assert on the next frame after it ends, so the castbar isn't
		-- left permanently hidden (which is exactly what happened before: the transition hides + zeroes
		-- the castbar's alpha, ProcessBars restores every OTHER bar afterward, but never the castbar).
		if not TRB.Functions.Bar:IsRenderTransitionActive() then
			if not self:IsEnabled(visibility) then
				-- Never Show flipped mid-cast: stop tracking entirely.
				model:Stop()
				self:EndRender()
				return
			end
			if self:IsForceHidden(visibility) then
				ApplyHiddenState(group)
			else
				self:ApplyVisibleState(group, node, colors, model, barSettings, visibility)

				local _, _, _, fill = model:GetProgress(now)
				node:SetValue(fill)

				-- Empower fill advances with the current stage. In segmented mode the main fill is blanked
				-- and each level's segment fills to the live progress in its own color instead.
				if model.state == "empower" then
					ApplyStateFillColor(node, colors, model, barSettings)
					if barSettings and barSettings.empowerSegmentedFill then
						DrawEmpowerSegments(node, colors, model, fill)
					end
				end
			end
		end
	elseif node then
		-- Idle: hold/fade out after a cast ends, then rest at the idle alpha (Always Show / inactive
		-- alpha) or fully hide and stop the updater until the next cast.
		local idleAlpha = self:GetIdleAlpha(visibility)
		if self:IsForceHidden(visibility) then
			idleAlpha = 0
			fadeOutStart = nil
		end

		if fadeOutStart ~= nil and self:IsEnabled(visibility) then
			local delay = (visibility and visibility.fadeDelay) or 0
			local duration = (visibility and visibility.fadeDuration) or 0
			local elapsed = GetTime() - fadeOutStart
			local activeAlpha = ((visibility and visibility.activeAlpha) or 100) / 100
			local fadeAlpha
			if elapsed < delay then
				-- Hold the finished bar at active alpha for the fade delay.
				fadeAlpha = activeAlpha
			elseif duration > 0 and elapsed < delay + duration then
				fadeAlpha = activeAlpha + (idleAlpha - activeAlpha) * ((elapsed - delay) / duration)
			end
			if fadeAlpha ~= nil then
				-- Self-heal like the active render: transitions/HideResourceBar hide every group and only
				-- ProcessBars entries get restored, so re-assert visibility each frame while fading.
				if not TRB.Functions.Bar:IsRenderTransitionActive() then
					group.targetAlpha = fadeAlpha
					group.currentAlpha = fadeAlpha
					if not group.isVisible then
						group:Show()
						TRB.Functions.BarVisibility:MarkDirty()
					end
					node:Show()
					group.containerFrame:SetAlpha(fadeAlpha)
				end
				return
			end
			fadeOutStart = nil
		end

		if idleAlpha > 0 then
			-- Keep running: the idle display must self-heal against render transitions.
			if not TRB.Functions.Bar:IsRenderTransitionActive() then
				self:ApplyIdleState(group, node, idleAlpha)
			end
		else
			self._renderedFrame = nil
			ApplyHiddenState(group)
			isRunning = false
			castbarFrame:Hide()
		end
	end
end)

-- ============================================================================
-- Event bridge (called from Functions/SpellCast.lua)
-- ============================================================================

---Handles a player UNIT_SPELLCAST_* event for the castbar model + render.
---@param event trbSpellCastType|string
---@param spellId integer?
function TRB.Functions.Castbar:OnSpellCastEvent(event, spellId)
	local model = TRB.Data.castbar
	if model == nil then
		return
	end
	local settings, barSettings = self:GetActiveSettings()
	local visibility = self:GetVisibilitySettings(settings)

	-- Only track when the castbar is enabled for this spec ("Never Show" disables it). Keeping the model
	-- idle otherwise ensures IsActive() (which ProcessBars' isTracking and UpdateResourceBarText's
	-- early-out consult) is only ever true when the castbar is actually in use.
	if not self:IsEnabled(visibility) then
		if model:IsActive() then
			model:Stop()
			self:EndRender()
		end
		return
	end

	---Stops tracking a start event whose cast type is deselected in the Show Bar When conditions.
	---@param state string
	---@return boolean # true when the event should be ignored
	local function RejectDisallowedType(state)
		if self:IsCastTypeAllowed(visibility, state) then
			return false
		end
		-- A tracked cast may chain into a disallowed type (e.g. cast into channel): end the old render.
		if model:IsActive() then
			model:Stop()
			self:BeginFadeOut()
		end
		return true
	end

	if event == "UNIT_SPELLCAST_START" then
		if RejectDisallowedType("cast") then return end
		model:StartCast(spellId)
		self:BeginRender()
	elseif event == "UNIT_SPELLCAST_CHANNEL_START" then
		if RejectDisallowedType("channel") then return end
		-- Resolve the channel spellId (event arg may be secret) before the profile lookup.
		local channelId = spellId
		if channelId == nil or issecretvalue(channelId) then
			channelId = select(8, UnitChannelInfo("player"))
			if issecretvalue(channelId) then channelId = nil end
		end
		local profile = self:ResolveTickProfile(barSettings, channelId)
		model:StartChannel(channelId, profile)
		self:BeginRender()
	elseif event == "UNIT_SPELLCAST_EMPOWER_START" then
		if RejectDisallowedType("empower") then return end
		model:StartEmpower(spellId)
		self:BeginRender()
	elseif event == "UNIT_SPELLCAST_DELAYED" then
		model:Delayed()
		if isRunning then self:UpdatePushbackOverlay(model) end
	elseif event == "UNIT_SPELLCAST_CHANNEL_UPDATE" then
		model:ChannelUpdate()
		if isRunning then
			-- Channel end shifted (e.g. a chain): recompute ticks from the profile resolved at channel
			-- start (buff-gated bonuses must not re-evaluate mid-channel -- the buff may have been consumed
			-- by this very cast), then re-place overlays so the latency zone reflects the new duration.
			if model.profile then
				model:ComputeChannelTicks(model.profile, model:GetHasteMultiplier())
			end
			self:SetupOverlays(model)
		end
	elseif event == "UNIT_SPELLCAST_STOP" or event == "UNIT_SPELLCAST_CHANNEL_STOP"
		or event == "UNIT_SPELLCAST_EMPOWER_STOP" or event == "UNIT_SPELLCAST_INTERRUPTED" then
		-- Chain carry (when eligible) is banked inside Stop from state the model already tracks.
		model:Stop()
		self:BeginFadeOut()
	elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
		-- A successful instant cast produces no bar; only tear down if the tracked cast finished.
		-- START/CHANNEL_START/STOP drive the visible lifecycle, so nothing to do here.
	end
end
