-- KiraUI-Plugin_ControlUndead.lua
-- Death Knight "Control Undead" - an icon with the remaining control time.
--
-- Control Undead (111673) dominates an undead target for 5 minutes. Nothing in
-- the default UI tells you how much of that window is left, so the minion tends
-- to break free at the worst possible moment.
--
-- THE TRIGGER IS THE CAST, AND ONLY THE CAST
--
-- Same reasoning as the Shroud module: your own UNIT_SPELLCAST_SUCCEEDED stays
-- readable in every kind of content, keys included, while aura data goes SECRET
-- inside a key - which is exactly where this matters. The charm also does not
-- put a readable aura on YOU, so there is nothing on the player to watch even
-- when data is plain.
--
-- Because only a Death Knight can cast 111673, matching the ID gates the
-- feature by itself. The class check below is a second belt for the frame
-- building, not the thing the timer depends on.
--
-- THE COUNTDOWN IS OUR OWN CLOCK
--
-- Plain arithmetic from the moment of the cast: no secret values are ever read
-- into it, so it cannot raise and cannot drift into restricted data. The five
-- minutes is a constant rather than a setting - it does not vary in practice,
-- and an encounter that breaks the charm early is caught by the pet watch
-- below rather than by asking the player to guess a number in advance.
--
-- EARLY EXIT WHEN THE MINION IS GONE
--
-- A dead or broken charm should clear the icon rather than let it count down to
-- zero on nothing. The charmed undead becomes your pet, so UNIT_PET is the
-- signal. The pet GUID is captured shortly after the cast and compared on each
-- pet change - that way an Unholy DK whose ghoul is still out does not see the
-- icon cleared just because a pet exists, and does see it cleared when the
-- charmed mob specifically is lost. GUIDs can be SECRET in restricted content,
-- so an unreadable GUID degrades to a plain "is there any pet at all" test
-- rather than disabling the check.

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

local CreateFrame = CreateFrame
local UIParent = UIParent
local GetTime = GetTime
local issecretvalue = issecretvalue
local pairs, pcall = pairs, pcall
local mfloor, mceil = math.floor, math.ceil
local format = string.format

ns.hasControlUndead = true

local CU_SPELL = 111673          -- Control Undead (the cast, not the charm)
local CU_ICON_FALLBACK = 237511  -- spell_shadow_deathsembrace

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

-- CLASS GATE, evaluated at RUNTIME.
--
-- A load-time UnitClass read can fire before player data exists and would
-- silently kill the module for a real Death Knight. A SECRET class token is
-- treated as "allow" for the same reason: failing closed here hides the icon
-- from the exact person who wants it.
local isDK
local function IsDeathKnight()
	if isDK == nil then
		local ok, _, cls = pcall(_G.UnitClass, 'player')
		if not ok then return true end
		if secret(cls) then return true end
		if cls then isDK = (cls == 'DEATHKNIGHT') end
	end
	return isDK ~= false
end

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
-- Control Undead lasts 5 minutes, always.
local DURATION = 300

local DEFAULTS = {
	enable = false,
	lock = true,
	iconSize = 44,
	textSize = 0,            -- 0 = auto from icon size
	x = 0,
	y = -160,
	strata = 'MEDIUM',
	hideOnPetLost = true,    -- clear when the charmed minion dies or breaks
	showSwipe = true,        -- cooldown swipe under the number
	warnAt = 30,             -- recolour the number under this many seconds
	textColor = { 1, 1, 1, 1 },
	warnColor = { 1, 0.3, 0.3, 1 },
}

local function GetCfg() return ns.ModuleCfg('controlUndead', DEFAULTS) end
ns.CUGetCfg = GetCfg

local function IconTexture()
	local get = _G.C_Spell and _G.C_Spell.GetSpellTexture
	if get then
		local ok, tex = pcall(get, CU_SPELL)
		if ok and tex and not secret(tex) then return tex end
	end
	return CU_ICON_FALLBACK
end

----------------------------------------------------------------------
-- State (all plain numbers, all ours)
----------------------------------------------------------------------
local castAt, expiresAt = 0, 0
local petGUID                    -- GUID of the charmed minion, when readable
local petKnown                   -- false when the GUID could not be read
local preview
local frame, btn
local ticker

local function Active() return expiresAt > 0 and expiresAt > GetTime() end

----------------------------------------------------------------------
-- Display
----------------------------------------------------------------------
local ApplySettings -- fwd

local function EnsureFrame()
	if frame then return end
	frame = CreateFrame('Frame', 'KiraUIControlUndead', UIParent)
	frame:SetFrameStrata('MEDIUM')
	frame:SetClampedToScreen(true)
	frame:SetMovable(true)
	frame:EnableMouse(false)
	frame:RegisterForDrag('RightButton')
	ns.MakeDraggable(frame, GetCfg, 'x', 'y', function() ApplySettings() end)

	btn = CreateFrame('Frame', nil, frame)
	btn.border = btn:CreateTexture(nil, 'BACKGROUND')
	btn.border:SetColorTexture(0, 0, 0, 1)
	btn.border:SetAllPoints()

	btn.Icon = btn:CreateTexture(nil, 'ARTWORK')
	btn.Icon:SetPoint('TOPLEFT', 1, -1)
	btn.Icon:SetPoint('BOTTOMRIGHT', -1, 1)
	btn.Icon:SetTexture(IconTexture())
	btn.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

	-- The swipe is decoration only; the number below is driven by our own clock,
	-- so a swipe that is hidden or unsupported never costs us the timer.
	btn.Cooldown = CreateFrame('Cooldown', nil, btn, 'CooldownFrameTemplate')
	btn.Cooldown:SetAllPoints()
	btn.Cooldown:SetDrawEdge(false)
	btn.Cooldown:SetDrawBling(false)
	-- Reverse: the shaded wedge GROWS as the charm runs out, so the icon is
	-- clear while you still have time and fully covered as it expires - the
	-- same direction the Buff Watch and Ready Check icons sweep.
	btn.Cooldown:SetReverse(true)
	if btn.Cooldown.SetHideCountdownNumbers then btn.Cooldown:SetHideCountdownNumbers(true) end

	btn.Text = btn.Cooldown:CreateFontString(nil, 'OVERLAY')
	btn.Text:SetPoint('CENTER')

	frame:Hide()
end

-- 5:00 while there are minutes left, plain seconds under a minute.
local function FormatTime(rem)
	if rem >= 60 then
		local m = mfloor(rem / 60)
		local s = mfloor(rem % 60)
		return format('%d:%02d', m, s)
	end
	return format('%d', mceil(rem))
end

ApplySettings = function()
	EnsureFrame()
	local cfg = GetCfg()
	local sz = cfg.iconSize or 44

	frame:ClearAllPoints()
	frame:SetPoint('CENTER', UIParent, 'CENTER', cfg.x or 0, cfg.y or -160)
	frame:SetSize(sz, sz)
	frame:SetFrameStrata(cfg.strata or 'MEDIUM')

	btn:SetSize(sz, sz)
	btn:ClearAllPoints()
	btn:SetPoint('CENTER', frame, 'CENTER', 0, 0)
	btn.Icon:SetTexture(IconTexture())

	local ts = cfg.textSize or 0
	if ts <= 0 then ts = Round(sz * 0.42) end
	btn.Text:SetFont(ns.GetFont(), ts, 'OUTLINE')
end

local function Render()
	if not frame then return end
	local cfg = GetCfg()
	local rem = preview and 137 or (expiresAt - GetTime())

	if not preview and (not cfg.enable or rem <= 0) then
		frame:Hide()
		return
	end

	btn.Text:SetText(FormatTime(rem))
	local c = (rem <= (cfg.warnAt or 30)) and (cfg.warnColor or DEFAULTS.warnColor)
		or (cfg.textColor or DEFAULTS.textColor)
	btn.Text:SetTextColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)

	frame:Show()
	btn:Show()
end

----------------------------------------------------------------------
-- Timer control
----------------------------------------------------------------------
local function StopTicker()
	if ticker then ticker:Cancel() ticker = nil end
end

local function Clear()
	expiresAt, castAt = 0, 0
	petGUID, petKnown = nil, nil
	StopTicker()
	if btn and btn.Cooldown then
		if btn.Cooldown.Clear then btn.Cooldown:Clear() else btn.Cooldown:SetCooldown(0, 0) end
	end
	if frame and not preview then frame:Hide() end
end
ns.CUClear = Clear

local function Start()
	EnsureFrame()
	ApplySettings()

	local cfg = GetCfg()

	castAt = GetTime()
	expiresAt = castAt + DURATION
	petGUID, petKnown = nil, nil

	if cfg.showSwipe and btn.Cooldown then
		btn.Cooldown:SetCooldown(castAt, DURATION)
		btn.Cooldown:Show()
	elseif btn.Cooldown then
		btn.Cooldown:Hide()
	end

	-- The charmed mob does not become the pet in the same frame as the cast
	-- succeeding, so the GUID is captured a moment later.
	_G.C_Timer.After(0.4, function()
		if not Active() then return end
		local ok, guid = pcall(_G.UnitGUID, 'pet')
		if ok and guid and not secret(guid) then
			petGUID, petKnown = guid, true
		else
			-- Unreadable: fall back to "any pet at all" on the next UNIT_PET.
			petGUID, petKnown = nil, false
		end
	end)

	StopTicker()
	-- 0.25s is plenty for a five-minute readout and costs nothing; the ticker
	-- only exists while a charm is actually running.
	ticker = _G.C_Timer.NewTicker(0.25, function()
		if preview then return end
		if not Active() then Clear() return end
		Render()
	end)
	Render()
end

-- Called on UNIT_PET. Returns true when the charmed minion is judged gone.
local function MinionLost()
	local okE, exists = pcall(_G.UnitExists, 'pet')
	if not okE then return false end
	if secret(exists) then return false end
	if not exists then return true end

	-- A pet exists. If we know which one was ours, a different GUID means the
	-- charm ended and something else (an Unholy ghoul, say) is out now.
	if petKnown and petGUID then
		local okG, guid = pcall(_G.UnitGUID, 'pet')
		if okG and guid and not secret(guid) then
			return guid ~= petGUID
		end
	end
	return false
end

----------------------------------------------------------------------
-- Apply / preview / diagnostics
----------------------------------------------------------------------
ns.CUApply = function()
	if not GetCfg().enable then
		if not preview then Clear() end
		if frame and not preview then frame:Hide() end
		return
	end
	EnsureFrame()
	ApplySettings()
	Render()
end

ns.CUIsPreview = function() return preview and true or false end
ns.CUSetPreview = function(state)
	preview = state and true or false
	EnsureFrame()
	ApplySettings()
	frame:EnableMouse(preview)
	if preview then
		if btn.Cooldown then
			if GetCfg().showSwipe then
				btn.Cooldown:SetCooldown(GetTime() - 163, 300)
				btn.Cooldown:Show()
			else
				btn.Cooldown:Hide()
			end
		end
		Render()
	else
		-- Preview must not leave a fake charm behind: zero the real state and
		-- repaint, exactly like the WoG bar learned to.
		Clear()
		Render()
	end
end

ns.CUTest = function()
	local function p(...) print('|cffff7d0aKiraUI-ControlUndead|r', ...) end
	p('--- Control Undead diagnostics ---')
	p('class gate  =', IsDeathKnight() and 'Death Knight (or unknown -> allowed)' or 'not a DK')
	local cfg = GetCfg()
	p('enabled     =', cfg.enable and 'yes' or 'no', '| duration =', DURATION, 's')
	if Active() then
		p('charm       = running,', format('%.1fs left', expiresAt - GetTime()))
	else
		p('charm       = not running')
	end
	local okE, exists = pcall(_G.UnitExists, 'pet')
	p('pet exists  =', okE and tostring(exists) or 'unreadable')
	p('pet GUID    =', petKnown and 'captured' or (petKnown == false and 'SECRET - using exists-only check' or 'not captured yet'))
	p('Cast Control Undead on an undead target, then run this again.')
end

----------------------------------------------------------------------
-- Events
----------------------------------------------------------------------
local evt = CreateFrame('Frame')
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player')
evt:RegisterUnitEvent('UNIT_PET', 'player')
evt:SetScript('OnEvent', function(_, event, unit, _, spellID)
	if preview then return end
	local cfg = GetCfg()
	if not cfg.enable then return end

	if event == 'UNIT_SPELLCAST_SUCCEEDED' then
		-- Only a Death Knight can cast this, so the ID is the real gate.
		if spellID and not secret(spellID) and spellID == CU_SPELL then
			if not IsDeathKnight() then return end
			Start()
		end

	elseif event == 'UNIT_PET' then
		if cfg.hideOnPetLost and Active() then
			-- Ignore the pet appearing right after the cast; only losing it counts.
			if GetTime() - castAt > 0.5 and MinionLost() then Clear() end
		end

	else -- PLAYER_ENTERING_WORLD: zoning always breaks the charm
		Clear()
		ApplySettings()
	end
end)
