-- KiraUI-Plugin_CombatText.lua
-- A small floating combat-message feed. Flashes short stacking messages that fade out:
--   * Interrupt announce: "Interrupted [Spell]" when YOU or your pet interrupt
--   * + Combat / - Combat on entering / leaving combat
--   * Death alert: "<class-coloured name> dead" for the player and party/raid members
--   * Focus death: the focus's name + " dead"
--
-- Midnight (12.0) note: addons may no longer register COMBAT_LOG_EVENT_UNFILTERED


local addonName, ns = ...
local _G = _G

local CreateFrame = CreateFrame
local C_Timer = C_Timer
local GetTime = GetTime
local UnitGUID = UnitGUID
local UnitName = UnitName
local UnitClass = UnitClass
local UnitExists = UnitExists
local UnitIsUnit = UnitIsUnit
local UnitIsDead = UnitIsDead
local UnitInParty, UnitInRaid = UnitInParty, UnitInRaid
local UnitTokenFromGUID = UnitTokenFromGUID
local C_ClassColor = C_ClassColor
local C_Spell = C_Spell
local IsPlayerSpell = IsPlayerSpell
local issecretvalue = issecretvalue
local ipairs, pairs = ipairs, pairs
local tsort = table.sort
local mmax, mabs = math.max, math.abs
local sformat = string.format
local STANDARD_TEXT_FONT = STANDARD_TEXT_FONT

ns.hasCombatText = true

local function secret(v) return issecretvalue and issecretvalue(v) end
local Round = ns.Round

-- The group tokens we announce deaths for, built once. In a raid the death
-- watcher takes an unfiltered UNIT_HEALTH stream, so this test runs constantly
-- and has to be a hash lookup rather than a pair of string patterns.
local WATCHED_UNIT = {}
for i = 1, 4 do WATCHED_UNIT['party' .. i] = true end
for i = 1, 40 do WATCHED_UNIT['raid' .. i] = true end

-- Interrupted spells we never want to announce (generic/system) - from the WA.
local INT_BLACKLIST = { [240447] = true, [240448] = true }

-- Player interrupt abilities (any class). Used only to recognise YOUR interrupt
-- cast so we can attribute the matching UNIT_SPELLCAST_INTERRUPTED to you.
local INTERRUPT_SET = {}
for _, id in ipairs({
	6552, 1766, 2139, 57994, 47528, 183752, 116705, 96231, 147362, 187707, 106839, 351338,
}) do INTERRUPT_SET[id] = true end

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	lock = false,
	x = 0,
	y = 150,
	grow = 'DOWN',          -- DOWN or UP
	spacing = 2,
	duration = 1.5,
	fontSize = 22,                                      -- fallback / default size
	outline = 'OUTLINE',
	interruptEnable = true,
	interruptSize = 22,
	enterEnable = true,
	enterColor = { 230 / 255, 230 / 255, 230 / 255 },   -- E6E6E6
	enterSize = 22,
	leaveEnable = true,
	leaveColor = { 115 / 255, 129 / 255, 255 / 255 },   -- 7381FF
	leaveSize = 22,
	deathEnable = true,
	deathColor = { 0, 155 / 255, 255 / 255 },           -- 009BFF
	deathSize = 22,                                     -- party/raid + player death size
	focusDeathEnable = true,
	focusDeathColor = { 0, 155 / 255, 255 / 255 },      -- 009BFF (own focus colour)
	focusDeathSize = 22,
	ttsEnable = false,      -- speak '<Name> dead' on party/raid deaths
	ttsShortName = false,   -- speak only the first 5 letters of the name
	ttsVolume = 100,        -- 0..100
	ttsVoiceName = 'Zira',  -- voice matched by name; '' = Blizzard TTS setting
	focusTtsEnable = false, -- speak 'Focus dead' (shares volume/voice above)
	-- "X ready" announcements driven by the Bloodlust / Potion modules
	bloodlustAnnounce = true,
	bloodlustText = 'Bloodlust Ready',
	bloodlustColor = { 1, 0.4, 0.1 },
	bloodlustSize = 24,
	potionAnnounce = true,
	potionText = 'Potion Ready',
	potionColor = { 0.2, 1, 0.4 },
	potionSize = 24,
}

-- repair any saved colour an older build left as a non-table
local function Migrate(cfg)
	if type(cfg.enterColor) ~= 'table' then cfg.enterColor = { 230 / 255, 230 / 255, 230 / 255 } end
	if type(cfg.leaveColor) ~= 'table' then cfg.leaveColor = { 115 / 255, 129 / 255, 255 / 255 } end
	if type(cfg.deathColor) ~= 'table' then cfg.deathColor = { 0, 155 / 255, 255 / 255 } end
	if type(cfg.focusDeathColor) ~= 'table' then cfg.focusDeathColor = { 0, 155 / 255, 255 / 255 } end
	if type(cfg.bloodlustColor) ~= 'table' then cfg.bloodlustColor = { 1, 0.4, 0.1 } end
	if type(cfg.potionColor) ~= 'table' then cfg.potionColor = { 0.2, 1, 0.4 } end
end

local function GetCfg() return ns.ModuleCfg('combatText', DEFAULTS, Migrate) end
ns.CTGetCfg = GetCfg

local function Hex(c)
	c = c or { 1, 1, 1 }
	return sformat('%02x%02x%02x', Round((c[1] or 1) * 255), Round((c[2] or 1) * 255), Round((c[3] or 1) * 255))
end
local function Colorize(text, c) return '|cff' .. Hex(c) .. text .. '|r' end

----------------------------------------------------------------------
-- Message container + pooled frames
----------------------------------------------------------------------
local container
local pool = {}
local previewActive = false

local function ApplyPosition()
	if not container then return end
	local cfg = GetCfg()
	container:ClearAllPoints()
	container:SetPoint('CENTER', _G.UIParent, 'CENTER', cfg.x or 0, cfg.y or 150)
end

local Arrange  -- forward decl

local function EnsureContainer()
	if container then return end
	container = CreateFrame('Frame', 'KiraUICombatText', _G.UIParent)
	container:SetSize(50, 20)
	container:SetFrameStrata('HIGH')
	container:SetMovable(true)
	container:EnableMouse(false)
	container:RegisterForDrag('RightButton')
	ns.MakeDraggable(container, GetCfg, 'x', 'y', function() ApplyPosition() end)
	ApplyPosition()
end

local function MakeMsg()
	local f = CreateFrame('Frame', nil, container)
	f.text = f:CreateFontString(nil, 'OVERLAY')
	f.text:SetPoint('CENTER')
	f.fade = f:CreateAnimationGroup()
	f.fadeAnim = f.fade:CreateAnimation('Alpha')
	f.fadeAnim:SetFromAlpha(1)
	f.fadeAnim:SetToAlpha(0)
	f.fade:SetScript('OnFinished', function()
		f.__active = false
		f:Hide()
		Arrange()
	end)
	f:Hide()
	pool[#pool + 1] = f
	return f
end

Arrange = function()
	if not container then return end
	local cfg = GetCfg()
	local grow = cfg.grow or 'DOWN'
	local spacing = cfg.spacing or 2
	local dir = (grow == 'UP') and 1 or -1
	local point = (grow == 'UP') and 'BOTTOM' or 'TOP'
	local act = {}
	for _, f in ipairs(pool) do if f.__active then act[#act + 1] = f end end
	tsort(act, function(a, b) return (a.__born or 0) > (b.__born or 0) end) -- newest at anchor
	local y, maxW = 0, 1
	for _, f in ipairs(act) do
		f:ClearAllPoints()
		f:SetPoint(point, container, point, 0, y)
		y = y + dir * (f:GetHeight() + spacing)
		if f:GetWidth() > maxW then maxW = f:GetWidth() end
	end
	container:SetSize(maxW, mmax(20, mabs(y)))
end

local function ShowMessage(text, noFade, sizeOverride)
	if not text then return end
	EnsureContainer()
	local cfg = GetCfg()
	local size = sizeOverride or cfg.fontSize or 22
	local f
	for _, m in ipairs(pool) do if not m.__active then f = m break end end
	if not f then f = MakeMsg() end
	f.text:SetFont(ns.GetFont(), size, cfg.outline or 'OUTLINE')
	f.text:SetText(text)
	f:SetSize(mmax(f.text:GetStringWidth() + 8, 20), mmax(f.text:GetStringHeight(), size))
	f:SetAlpha(1)
	f.__active = true
	f.__born = GetTime()
	f:Show()
	f.fade:Stop()
	if not noFade then
		local dur = cfg.duration or 1.5
		local fade = 0.4
		f.fadeAnim:SetStartDelay(mmax(0, dur - fade))
		f.fadeAnim:SetDuration(fade)
		f.fade:Play()
	end
	Arrange()
	return f
end

----------------------------------------------------------------------
-- Message builders
----------------------------------------------------------------------
local function DeathText(unit)
	local cfg = GetCfg()
	local name = UnitName(unit)
	if not name or secret(name) then return nil end
	local nameStr = name
	local _, class = UnitClass(unit)
	if class and not secret(class) and C_ClassColor and C_ClassColor.GetClassColor then
		local cc = C_ClassColor.GetClassColor(class)
		if cc and cc.WrapTextInColorCode then nameStr = cc:WrapTextInColorCode(name) end
	end
	return nameStr .. ' ' .. Colorize('dead', cfg.deathColor)
end

----------------------------------------------------------------------
-- Interrupt detection (no combat log).
-- We mark the time of YOUR interrupt cast, then attribute any nearby
-- UNIT_SPELLCAST_INTERRUPTED on an enemy unit to you.
----------------------------------------------------------------------
local lastKick = -10
local lastAnnounce = -10

local function OnPlayerCast(spellID)
	-- secrecy check FIRST: truthiness-testing a secret value errors
	if secret(spellID) then return end
	if spellID and INTERRUPT_SET[spellID] then
		lastKick = GetTime()
	end
end

local function OnUnitInterrupted(unit, spellID, interruptedBy)
	local cfg = GetCfg()
	if not cfg.enable or not cfg.interruptEnable or previewActive then return end
	-- Token-string filter only: UnitIsUnit() can return a SECRET boolean for enemy
	-- units in 12.0, which would taint. 'player'/'pet' here means YOUR cast got
	-- interrupted (not you interrupting), so skip those.
	if not unit or unit == 'player' or unit == 'pet' then return end

	-- Midnight passes WHO interrupted (a GUID) right in the event. Direct
	-- attribution: works with ANY interrupting ability (Rebuke, Avenger's
	-- Shield, Storm Bolt...), no spell list needed. Secrecy check FIRST; when
	-- the GUID is secret/absent, fall back to the old kick-window heuristic.
	if not (issecretvalue and issecretvalue(interruptedBy)) and interruptedBy ~= nil then
		local mine = interruptedBy == UnitGUID('player')
		if not mine then
			local pg = UnitGUID('pet')
			mine = pg ~= nil and interruptedBy == pg
		end
		if not mine then return end -- someone else's interrupt: stay silent
	else
		if GetTime() - lastKick > 0.6 then return end -- not from your interrupt
	end

	-- One interrupt fires this event once per unit token aimed at the same enemy
	-- (target, nameplateN, bossN...). Collapse that burst into a single message.
	if GetTime() - lastAnnounce < 0.4 then return end
	lastAnnounce = GetTime()
	local spellName
	-- secrecy check FIRST, then use the value
	if not secret(spellID) and spellID then
		if INT_BLACKLIST[spellID] then return end
		if C_Spell and C_Spell.GetSpellInfo then
			local info = C_Spell.GetSpellInfo(spellID)
			if info and info.name and not secret(info.name) then spellName = info.name end
		end
	end
	if spellName then
		ShowMessage('|cff9FBFFFInterrupted ' .. spellName .. '|r', false, cfg.interruptSize)
	else
		ShowMessage('|cff9FBFFFInterrupted!|r', false, cfg.interruptSize)
	end
end

----------------------------------------------------------------------
-- Death detection via the real UNIT_DIED event (no combat log, no polling).
----------------------------------------------------------------------
local deathThrottle = { count = 0, reset = 0 }

local function AnnounceGroupDeath(unit)
	local cfg = GetCfg()
	if not cfg.deathEnable then return end
	local now = GetTime()
	if now > deathThrottle.reset then
		deathThrottle.count = 0
		deathThrottle.reset = now + 10
	end
	if deathThrottle.count >= 5 then return end
	deathThrottle.count = deathThrottle.count + 1
	local txt = DeathText(unit)
	if txt then ShowMessage(txt, false, cfg.deathSize) end
end

----------------------------------------------------------------------
-- TTS death announce: speaks '<Name> dead' for party/raid deaths.
-- Silence rules (all local plain state, no secrets involved):
--  * never on YOUR own death
--  * wipe damping: the 2nd+ death within 5s is not spoken (one voice line
--    per burst, never x4 spam), and the burst holds silence for 10s
--  * tank-down rule: once a TANK dies (including you), everything after
--    it this combat is presumed a wipe and stays silent until combat ends
----------------------------------------------------------------------
local ttsDeaths = {}         -- recent death timestamps (incl. your own)
local ttsHoldUntil = 0       -- burst/wipe hold
local ttsTankDown = false    -- a tank died this combat

-- First N characters of a PLAIN name (callers secret-check first), stepping
-- whole UTF-8 sequences so multi-byte names (Cyrillic, accents) never get cut
-- mid-character and fed to the TTS engine as garbage bytes.
local function ShortName(name, n)
	n = n or 5
	local i, count, len = 1, 0, #name
	while i <= len and count < n do
		local c = name:byte(i)
		local step = (c >= 240 and 4) or (c >= 224 and 3) or (c >= 192 and 2) or 1
		i = i + step
		count = count + 1
	end
	return name:sub(1, i - 1)
end

local function TTSSpeak(text)
	local cfg = GetCfg()
	local VC = _G.C_VoiceChat
	if not (VC and VC.SpeakText) then return end
	-- current signature: SpeakText(voiceID, text, rate, volume, overlap) -
	-- there is NO destination argument anymore (see TextToSpeechFrame.lua)
	-- Voice is resolved BY NAME on every call (the list is tiny), so it needs
	-- no login-time setup and keeps working whenever the voice list loads.
	local voiceID
	local TS = _G.C_TTSSettings
	local voices = VC.GetTtsVoices and VC.GetTtsVoices()
	local want = tostring(cfg.ttsVoiceName or 'Zira'):lower()
	if voices and want ~= '' then
		for _, v in ipairs(voices) do
			if v.name and v.name:lower():find(want, 1, true) then voiceID = v.voiceID break end
		end
	end
	if type(voiceID) ~= 'number' and TS and TS.GetVoiceOptionID then
		voiceID = TS.GetVoiceOptionID(0) -- Blizzard's TTS config voice
	end
	if type(voiceID) ~= 'number' then
		voiceID = voices and voices[1] and voices[1].voiceID or 0
	end
	local rate = (TS and TS.GetSpeechRate and TS.GetSpeechRate()) or 0
	-- volume comes from OUR slider, passed per call - independent of the
	-- volume in Blizzard's Text to Speech config
	VC.SpeakText(voiceID, text, rate, cfg.ttsVolume or 100, true)
end
ns.CTTTSPreview = function()
	local me = UnitName('player')
	local name = (me and not secret(me)) and me or 'Player'
	if GetCfg().ttsShortName then name = ShortName(name, 5) end
	TTSSpeak(name .. ' dead')
end

local function TTSRecordDeath()
	local now = GetTime()
	local kept = {}
	for _, ts in ipairs(ttsDeaths) do
		if now - ts < 5 then kept[#kept + 1] = ts end
	end
	kept[#kept + 1] = now
	ttsDeaths = kept
	return #kept
end

-- your own death: never spoken, but it counts toward the wipe window and,
-- if you are the tank, arms the tank-down silence for the rest of the fight
local function TTSOwnDeath()
	TTSRecordDeath()
	if _G.UnitGroupRolesAssigned and _G.UnitGroupRolesAssigned('player') == 'TANK' then
		ttsTankDown = true
	end
end

local function TTSResetCombat()
	ttsDeaths = {}
	ttsHoldUntil = 0
	ttsTankDown = false
end
ns.CTTTSOwnDeath = TTSOwnDeath

local function SpeakDeath(unit)
	local cfg = GetCfg()
	if not cfg.ttsEnable then return end
	local mine = UnitIsUnit(unit, 'player')
	if (not secret(mine)) and mine then TTSOwnDeath() return end
	local now = GetTime()
	local burst = TTSRecordDeath()
	-- tank-down: this and everything after stays silent until combat ends
	local role = _G.UnitGroupRolesAssigned and _G.UnitGroupRolesAssigned(unit)
	local isTank = (not secret(role)) and role == 'TANK'
	if ttsTankDown then return end
	local suppressed = now < ttsHoldUntil or burst >= 2
	if burst >= 2 then ttsHoldUntil = now + 10 end
	if isTank then ttsTankDown = true end
	if suppressed then return end
	local name = UnitName(unit)
	if not name or secret(name) then return end
	if cfg.ttsShortName then name = ShortName(name, 5) end
	TTSSpeak(name .. ' dead')
end

-- One announce per death, whichever path sees it first: the GUID path
-- (out in the world) and the health-transition path (M+ / restricted, where
-- the GUID and its token are SECRET) both funnel through this latch.
-- Latch states: nil = never seen (prime silently - someone already dead
-- when we join must NOT be announced), false = seen alive, true = dead.
local deadLatch = {}
local function AnnounceUnitDeath(unit)
	if deadLatch[unit] then return end
	deadLatch[unit] = true
	-- feign death fires death signals too (hunters); plain-readable = skip
	local feign = _G.UnitIsFeignDeath and _G.UnitIsFeignDeath(unit)
	if not secret(feign) and feign then return end
	local cfg = GetCfg()
	SpeakDeath(unit)
	if cfg.deathEnable then AnnounceGroupDeath(unit) end
end

local function OnUnitDied(deadGUID)
	local cfg = GetCfg()
	if not cfg.enable or previewActive then return end
	if deadGUID == nil then return end

	-- (Focus death moved to the engine-driven indicator below: enemy GUIDs
	-- and tokens are SECRET in restricted combat, so one-shot matching here
	-- can never work there.)

	-- Resolve the (possibly SECRET) dead GUID to a plain unit-token string.
	-- In restricted instanced content this token comes back SECRET and this
	-- path goes dark - the health-transition watcher below covers it there.
	local unit = UnitTokenFromGUID and UnitTokenFromGUID(deadGUID)
	if type(unit) ~= 'string' or secret(unit) then return end

	if WATCHED_UNIT[unit] then
		AnnounceUnitDeath(unit)
	end
end

----------------------------------------------------------------------
-- M+ / restricted-content death watcher. UNIT_DIED's GUID is secret
-- there, but UnitIsDeadOrGhost('party1') with a PLAIN token still returns
-- a plain boolean - so watch health transitions and announce alive->dead.
-- The latch resets when the unit is alive again (release/ghost keeps the
-- latch, so no re-announce) or on roster changes.
----------------------------------------------------------------------
local function CheckUnitDeath(unit)
	local cfg = GetCfg()
	if not cfg.enable or previewActive then return end
	if not (cfg.deathEnable or cfg.ttsEnable) then return end
	if not UnitExists(unit) then deadLatch[unit] = nil return end
	-- offline units report unreliable death state - drop their latch so
	-- they re-prime when they come back
	local conn = _G.UnitIsConnected and _G.UnitIsConnected(unit)
	if not secret(conn) and conn == false then deadLatch[unit] = nil return end
	local dead = _G.UnitIsDeadOrGhost and _G.UnitIsDeadOrGhost(unit)
	if secret(dead) then return end -- can't read here; GUID path may still work
	if dead then
		if deadLatch[unit] == false then
			-- seen ALIVE before, dead now: a real, fresh death
			AnnounceUnitDeath(unit)
		elseif deadLatch[unit] == nil then
			deadLatch[unit] = true -- first sighting: prime silently
		end
	else
		deadLatch[unit] = false -- alive (and re-armed)
	end
end

-- Registration is picked per group size so the handler runs as rarely as
-- possible: in a PARTY (M+) the engine filters UNIT_HEALTH to party1-4 for
-- us (RegisterUnitEvent takes 2 units, so two helper frames); only in a
-- RAID do we take the unfiltered stream (raid1-40 can't be filtered);
-- solo, or with the death alerts switched off, nothing is registered at all.
--
-- That last case matters: the raid stream is the busiest event this addon can
-- subscribe to, and paying for it to reach a handler that returns immediately
-- is the most expensive way to have a feature turned off.
local pdParty1 = CreateFrame('Frame')
local pdParty2 = CreateFrame('Frame')
local function OnPartyHealth(_, _, unit)
	if type(unit) ~= 'string' or secret(unit) then return end
	CheckUnitDeath(unit)
end
pdParty1:SetScript('OnEvent', OnPartyHealth)
pdParty2:SetScript('OnEvent', OnPartyHealth)

local pdEvents = CreateFrame('Frame')
pdEvents:RegisterEvent('GROUP_ROSTER_UPDATE')
pdEvents:RegisterEvent('PLAYER_ENTERING_WORLD')

-- Safe to read settings here: this only ever runs from an event or from Apply,
-- never at file load, so SavedVariables are in place by the time it is called.
local function UpdateDeathWatch()
	deadLatch = {}
	local cfg = GetCfg()
	local want = cfg.enable and (cfg.deathEnable or cfg.ttsEnable)
	if want and _G.IsInRaid() then
		pdEvents:RegisterEvent('UNIT_HEALTH')
		pdParty1:UnregisterAllEvents()
		pdParty2:UnregisterAllEvents()
	elseif want and _G.IsInGroup() then
		pdEvents:UnregisterEvent('UNIT_HEALTH')
		pdParty1:RegisterUnitEvent('UNIT_HEALTH', 'party1', 'party2')
		pdParty2:RegisterUnitEvent('UNIT_HEALTH', 'party3', 'party4')
	else
		pdEvents:UnregisterEvent('UNIT_HEALTH')
		pdParty1:UnregisterAllEvents()
		pdParty2:UnregisterAllEvents()
	end
end
ns.CTUpdateDeathWatch = UpdateDeathWatch

pdEvents:SetScript('OnEvent', function(_, event, unit)
	if event ~= 'UNIT_HEALTH' then
		UpdateDeathWatch()
		return
	end
	if type(unit) ~= 'string' or secret(unit) then return end
	if WATCHED_UNIT[unit] then CheckUnitDeath(unit) end
end)

----------------------------------------------------------------------
-- Preview
----------------------------------------------------------------------
local function HideAll()
	for _, f in ipairs(pool) do f.fade:Stop() f.__active = false f:Hide() end
	Arrange()
end

local function ShowPreview()
	HideAll()
	local cfg = GetCfg()
	local me = DeathText('player') or (UnitName('player') .. ' ' .. Colorize('dead', cfg.deathColor))
	ShowMessage(Colorize(cfg.enterText or '+Combat', cfg.enterColor), true, cfg.enterSize)
	ShowMessage('|cff9FBFFFInterrupted Counterspell|r', true, cfg.interruptSize)
	ShowMessage(me, true, cfg.deathSize)
	ShowMessage(Colorize('FOCUS dead', cfg.focusDeathColor), true, cfg.focusDeathSize)
	ShowMessage(Colorize(cfg.leaveText or '-Combat', cfg.leaveColor), true, cfg.leaveSize)
end

ns.CTIsPreview = function() return previewActive end
ns.CTSetPreview = function(state)
	previewActive = state and true or false
	EnsureContainer()
	container:EnableMouse(previewActive and not GetCfg().lock or false)
	if previewActive then ShowPreview() else HideAll() end
end

----------------------------------------------------------------------
-- Apply / drivers
----------------------------------------------------------------------
----------------------------------------------------------------------
-- Focus death, Midnight-proof: enemy GUIDs and unit tokens are SECRET in
-- restricted combat, so matching UNIT_DIED to the focus is impossible
-- there. Instead a dedicated 'FOCUS dead' text is driven by the ENGINE:
-- UnitHealthPercent maps the focus's (secret) health through a step curve
-- straight to the text's alpha - visible exactly while the focus is at
-- zero health, and our code never observes a single value.
----------------------------------------------------------------------
local focusDeadFrame, focusDeadCurve

local function EnsureFocusDead()
	if focusDeadFrame then return end
	local CU, En = _G.C_CurveUtil, _G.Enum
	if not (CU and CU.CreateCurve and En and En.LuaCurveType and _G.UnitHealthPercent) then return end
	focusDeadCurve = CU.CreateCurve()
	focusDeadCurve:SetType(En.LuaCurveType.Step)
	focusDeadCurve:AddPoint(0, 1)        -- dead (0 health) -> fully visible
	focusDeadCurve:AddPoint(0.0001, 0)   -- any health at all -> invisible
	focusDeadFrame = CreateFrame('Frame', nil, UIParent)
	focusDeadFrame:SetSize(2, 2)
	focusDeadFrame:SetFrameStrata('MEDIUM')
	focusDeadFrame.text = focusDeadFrame:CreateFontString(nil, 'OVERLAY')
	focusDeadFrame.text:SetPoint('CENTER')
	focusDeadFrame:Hide()
end

local function UpdateFocusDead()
	local cfg = GetCfg()
	EnsureFocusDead()
	if not focusDeadFrame then return end
	if not cfg.enable or not cfg.focusDeathEnable or previewActive
		or not UnitExists('focus') then
		focusDeadFrame:Hide()
		return
	end
	focusDeadFrame:ClearAllPoints()
	focusDeadFrame:SetPoint('CENTER', UIParent, 'CENTER', cfg.x or 0, cfg.y or 0)
	focusDeadFrame.text:SetFont(ns.GetFont(), cfg.focusDeathSize or 22, cfg.outline or 'OUTLINE')
	local c = cfg.focusDeathColor or { 0, 155 / 255, 1 }
	focusDeadFrame.text:SetTextColor(c[1] or 0, c[2] or 0.6, c[3] or 1)
	focusDeadFrame.text:SetText('FOCUS dead')
	focusDeadFrame:Show()
	-- the engine computes dead/alive; we just forward the result to alpha
	focusDeadFrame:SetAlpha(_G.UnitHealthPercent('focus', false, focusDeadCurve) or 0)
end

-- Focus death TTS. The death CHECK is Lua-side (UnitIsDeadOrGhost), so in
-- restricted instanced combat the value comes back SECRET and we stay silent -
-- only the engine-driven on-screen indicator can work there. In open world /
-- unrestricted content it speaks once per focus death.
local focusTtsSpoken = false
local function CheckFocusTts(primeOnly)
	local cfg = GetCfg()
	if not cfg.focusTtsEnable or previewActive then return end
	if not UnitExists('focus') then focusTtsSpoken = false return end
	local dead = _G.UnitIsDeadOrGhost and _G.UnitIsDeadOrGhost('focus')
	if secret(dead) then return end -- restricted: can't read death state
	if primeOnly then
		-- new focus: remember its CURRENT state, never announce a corpse
		focusTtsSpoken = dead and true or false
		return
	end
	if dead then
		if not focusTtsSpoken then
			focusTtsSpoken = true
			TTSSpeak('Focus dead')
		end
	else
		focusTtsSpoken = false
	end
end
ns.CTTTSPreviewFocus = function() TTSSpeak('Focus dead') end

local fdEvents = CreateFrame('Frame')
fdEvents:RegisterUnitEvent('UNIT_HEALTH', 'focus')
fdEvents:RegisterUnitEvent('UNIT_MAXHEALTH', 'focus')
fdEvents:RegisterEvent('PLAYER_FOCUS_CHANGED')
fdEvents:RegisterEvent('PLAYER_ENTERING_WORLD')
fdEvents:SetScript('OnEvent', function(_, event)
	UpdateFocusDead()
	CheckFocusTts(event ~= 'UNIT_HEALTH' and event ~= 'UNIT_MAXHEALTH')
end)

local function Apply()
	EnsureContainer()
	ApplyPosition()
	container:EnableMouse(previewActive and not GetCfg().lock or false)
	if previewActive then ShowPreview() end
	UpdateFocusDead()
	-- toggling the death alerts has to add or drop the UNIT_HEALTH subscription,
	-- not just change what the handler does with it
	if ns.CTUpdateDeathWatch then ns.CTUpdateDeathWatch() end
end
ns.CTApply = function() C_Timer.After(0, Apply) end

-- Called by the Bloodlust / Potion modules when their thing is ready. Shows the
-- configured "X Ready" text in the combat-text feed (if that announce is on).
ns.CTAnnounce = function(kind)
	local cfg = GetCfg()
	if not cfg.enable or previewActive then return end
	if kind == 'bloodlust' and cfg.bloodlustAnnounce then
		ShowMessage(Colorize(cfg.bloodlustText or 'Bloodlust Ready', cfg.bloodlustColor), false, cfg.bloodlustSize)
	elseif kind == 'potion' and cfg.potionAnnounce then
		ShowMessage(Colorize(cfg.potionText or 'Potion Ready', cfg.potionColor), false, cfg.potionSize)
	end
end

-- Player / pet interrupt casts (filtered to those two units).
local castf = CreateFrame('Frame')
castf:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player', 'pet')
castf:SetScript('OnEvent', function(_, _, _, _, spellID)
	OnPlayerCast(spellID)
end)

local f = CreateFrame('Frame')
f:RegisterEvent('PLAYER_ENTERING_WORLD')
f:RegisterEvent('PLAYER_REGEN_DISABLED')
f:RegisterEvent('PLAYER_REGEN_ENABLED')
f:RegisterEvent('PLAYER_DEAD')
f:RegisterEvent('UNIT_DIED')
f:RegisterEvent('UNIT_SPELLCAST_INTERRUPTED')
f:RegisterEvent('UNIT_SPELLCAST_CHANNEL_STOP')
f:SetScript('OnEvent', function(_, event, arg1, arg2, arg3, arg4)
	if event == 'UNIT_DIED' then
		OnUnitDied(arg1)
		return
	elseif event == 'UNIT_SPELLCAST_INTERRUPTED' then
		-- args: unit, castGUID, spellID, interruptedBy (Midnight)
		OnUnitInterrupted(arg1, arg3, arg4)
		return
	elseif event == 'UNIT_SPELLCAST_CHANNEL_STOP' then
		-- interrupted CHANNELS end with CHANNEL_STOP carrying interruptedBy;
		-- a natural channel end has none (and no kick window), so it's silent.
		-- secrecy check FIRST - comparing a secret against nil would error
		if (issecretvalue and issecretvalue(arg4)) or arg4 ~= nil then
			OnUnitInterrupted(arg1, arg3, arg4)
		end
		return
	end
	local cfg = GetCfg()
	if event == 'PLAYER_ENTERING_WORLD' then
		C_Timer.After(0.5, Apply)
	elseif event == 'PLAYER_REGEN_DISABLED' then
		if cfg.enable and cfg.enterEnable and not previewActive then
			ShowMessage(Colorize(cfg.enterText or '+Combat', cfg.enterColor), false, cfg.enterSize)
		end
	elseif event == 'PLAYER_REGEN_ENABLED' then
		TTSResetCombat() -- combat over: wipe damping + tank-down rule reset
		if cfg.enable and cfg.leaveEnable and not previewActive then
			ShowMessage(Colorize(cfg.leaveText or '-Combat', cfg.leaveColor), false, cfg.leaveSize)
		end
	elseif event == 'PLAYER_DEAD' then
		if cfg.ttsEnable then TTSOwnDeath() end
		if cfg.enable and cfg.deathEnable and not previewActive then
			local txt = DeathText('player')
			if txt then ShowMessage(txt, false, cfg.deathSize) end
		end
	end
end)
