-- KiraUI-Plugin_ToS.lua
-- Temple of Sethraliss, first boss: Adderis and Aspix.
--
-- One of the two bosses gains "Storm Blessed" (1289229), which makes it immune
-- to damage, and the pair pass it back and forth as they take damage. Missing
-- the swap means several seconds of hitting an immune target, so this calls it
-- out loudly: a sound the moment it lands, and an on-screen line naming the
-- boss you should actually be on.
--
-- WHY THREE DETECTION PATHS
--
-- On 12.x AddPrivateAuraAppliedSound was RENAMED to AddAuraAppliedSound - the
-- word "Private" was dropped because it is no longer limited to private auras.
-- So on a current client it does work for an ordinary boss buff like this one,
-- and it is the cheapest path there is: the client plays the sound itself, with
-- no event handler and no polling. The old name is still tried for older
-- clients. Note it is SOUND ONLY - see the display note below.
--
-- The combat log is NOT available. On 12.x COMBAT_LOG_EVENT_UNFILTERED is
-- flagged HasRestrictions, so an addon registering it triggers
-- ADDON_ACTION_FORBIDDEN - it is not a thing that can be pcall'd around. That
-- rules out the SPELL_AURA_APPLIED route entirely.
--
-- What is left is UNIT_AURA on the boss frames, read through
-- GetUnitAuraBySpellID. That call is documented as returning secret data in
-- restricted content, which a key is - so the on-screen SWAP line is
-- best-effort and may not appear in a key even though the sound does. The
-- sound is the reliable part, and that is by design: AddAuraAppliedSound
-- exists precisely so addons can react to auras they are not allowed to read.

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

local CreateFrame = CreateFrame
local UIParent = UIParent
local issecretvalue = issecretvalue
local pairs, ipairs, pcall, type = pairs, ipairs, pcall, type
local mfloor = math.floor

ns.hasToS = true

local function secret(v) return issecretvalue and issecretvalue(v) end

-- Storm Blessed: the damage immunity the pair trade between themselves.
local SHIELD_ID = 1289229
-- FileDataID (not a SoundKitID) - see RegisterSounds below for why that matters
local RAID_WARNING_FILE = 567397
-- SoundKitID, for PlaySound() only
local RAID_WARNING_KIT = 8959
local DEFAULT_SOUND = '|cFF00FF00Kira - Shields|r'

local LSM = ns.LSM

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	soundEnabled = true,
	sound = DEFAULT_SOUND,
	dungeonOnly = true,   -- stay silent everywhere except this dungeon
	soundMigrated = false,
}

local function Migrate(cfg)
	-- the on-screen half was removed; drop its leftover keys once
	if cfg.showIcon ~= nil or cfg.iconSize ~= nil then
		cfg.showIcon, cfg.showText, cfg.iconSize, cfg.textSize = nil, nil, nil, nil
		cfg.lock, cfg.x, cfg.y = nil, nil, nil
	end
	-- One-time move to the shield sound. Only touches installs still sitting on
	-- the old default; anything deliberately chosen is left alone.
	if not cfg.soundMigrated then
		cfg.soundMigrated = true
		if cfg.sound == '|cFF00FF00Kira - Potion|r' then cfg.sound = DEFAULT_SOUND end
	end
end

local function GetCfg() return ns.ModuleCfg('tos', DEFAULTS, Migrate) end
ns.ToSGetCfg = GetCfg

ns.ToSSoundOptions = function()
	local out = {}
	local lib = LSM()
	local hash = lib and lib.HashTable and lib:HashTable('sound')
	if hash then
		local names = {}
		for name in pairs(hash) do names[#names + 1] = name end
		table.sort(names, function(a, b)
			local sa = tostring(a):gsub('|c%x%x%x%x%x%x%x%x', ''):gsub('|r', ''):lower()
			local sb = tostring(b):gsub('|c%x%x%x%x%x%x%x%x', ''):gsub('|r', ''):lower()
			return sa < sb
		end)
		for _, n in ipairs(names) do out[#out + 1] = { value = n, text = n } end
	end
	if #out == 0 then out[1] = { value = DEFAULT_SOUND, text = DEFAULT_SOUND } end
	return out
end

----------------------------------------------------------------------
-- Where are we
--
-- Temple of Sethraliss is instance 1877. The gate is a convenience, not a
-- safety net: the spell ID is unique to this encounter, so being wrong about
-- the instance can only cost a missed warning, never a false one. That is why
-- an unreadable instance ID counts as "yes, we might be here".
----------------------------------------------------------------------
local TOS_INSTANCE = 1877
local function InDungeon()
	if not GetCfg().dungeonOnly then return true end
	local f = _G.GetInstanceInfo
	if not f then return true end
	-- name, instanceType, difficultyID, difficultyName, maxPlayers,
	-- dynamicDifficulty, isDynamic, instanceID  <- eight, not seven
	local ok, _, instType, _, _, _, _, _, instID = pcall(f)
	if not ok then return true end
	if secret(instID) or secret(instType) then return true end
	if instID == TOS_INSTANCE then return true end
	return false
end

----------------------------------------------------------------------
-- No on-screen display
--
-- This is a SOUND-ONLY alert by choice. It used to draw an icon and a
-- "SWAP TO: <boss>" line, which meant resolving the other boss's name from
-- boss1..boss5 - and unit names go secret in restricted content, which is
-- exactly where this encounter is played. The line was therefore least
-- reliable in a key, the one place it mattered.
----------------------------------------------------------------------

-- Handles returned by AddAuraAppliedSound, so the registration can be undone.
-- Declared here, ahead of every user, because both PlayAlert and
-- UnregisterSounds read it before RegisterSounds has ever filled it.
local soundIDs = {}

-- Our own sound, used for the combat-log path and the Test button. When the
-- client-side registration above is live it fires too, so this is skipped to
-- avoid playing the alert twice.
local function PlayAlert(force)
	local cfg = GetCfg()
	if not cfg.soundEnabled then return end
	if not force and #soundIDs > 0 then return end
	local lib = LSM()
	local path
	if lib and lib.Fetch then
		-- a failed pcall returns the error STRING as its second value, which
		-- would pass a bare type check and get handed to PlaySoundFile
		local okF, p = pcall(lib.Fetch, lib, 'sound', cfg.sound)
		if okF and type(p) == 'string' and p ~= '' then path = p end
	end
	if path and _G.PlaySoundFile then
		pcall(_G.PlaySoundFile, path, 'Master')
	elseif _G.PlaySound then
		pcall(_G.PlaySound, RAID_WARNING_KIT, 'Master')  -- PlaySound wants the KIT id
	end
end

-- Who currently holds the shield. Kept purely so the alert fires on a CHANGE
-- of holder rather than on every aura update - nothing is drawn from it.
local shieldedGUID

local function SetShielded(guid)
	local wasGUID = shieldedGUID
	shieldedGUID = guid
	-- only alert when the holder actually CHANGES, so a re-application or a
	-- state re-sync does not repeat the sound every UNIT_AURA
	if guid and guid ~= wasGUID then PlayAlert() end
end

----------------------------------------------------------------------
-- Events
----------------------------------------------------------------------
local evt = CreateFrame('Frame')
local function Reg(e)
	return pcall(evt.RegisterEvent, evt, e)
end
Reg('PLAYER_ENTERING_WORLD')
Reg('ENCOUNTER_START')
Reg('ENCOUNTER_END')
Reg('INSTANCE_ENCOUNTER_ENGAGE_UNIT')
Reg('UNIT_AURA')

evt:SetScript('OnEvent', function(_, event, ...)
	local cfg = GetCfg()
	if not cfg.enable then return end

	if event == 'ENCOUNTER_END' or event == 'PLAYER_ENTERING_WORLD' then
		SetShielded(nil)
		return
	end

	-- The only visual path there is. A successful read is authoritative; a
	-- failed one is ambiguous - it means either "no shield" or "not allowed to
	-- look", and those cannot be told apart. So a read that finds nothing only
	-- clears the display when at least one boss WAS readable, otherwise a
	-- restricted key would silently drop the warning the moment it appeared.
	if event == 'UNIT_AURA' or event == 'INSTANCE_ENCOUNTER_ENGAGE_UNIT'
		or event == 'ENCOUNTER_START' then
		if not InDungeon() then return end
		local CUA = _G.C_UnitAuras
		if not (CUA and CUA.GetUnitAuraBySpellID) then return end
		local sawAnyBoss, readable = false, false
		for i = 1, 5 do
			local unit = 'boss' .. i
			if _G.UnitExists and _G.UnitExists(unit) then
				sawAnyBoss = true
				local okA, aura = pcall(CUA.GetUnitAuraBySpellID, unit, SHIELD_ID)
				if okA then
					readable = true
					if aura and not secret(aura) then
						local okG, g = pcall(_G.UnitGUID, unit)
						if okG and g and not secret(g) and g ~= shieldedGUID then
							SetShielded(g)
						end
						return
					end
				end
			end
		end
		if shieldedGUID and readable and sawAnyBoss then SetShielded(nil) end
	end
end)

----------------------------------------------------------------------
-- Path 2: the client-side applied sound.
--
-- The client plays this itself the instant the aura lands - no event, no
-- handler, no polling, and no dependence on us reading anything. That makes it
-- the most reliable of the three, so it is registered for every unit token the
-- pair can occupy.
--
-- It is SOUND ONLY - the API takes a sound file and nothing else. That suits
-- this module exactly, since the on-screen half was removed.
--
-- HasRestrictions, so every call is guarded: a refusal must not take the rest
-- of this file with it. IDs are kept so the registration can be undone when
-- the module is switched off, otherwise the sound would outlive the setting.
----------------------------------------------------------------------
local function UnregisterSounds()
	local CUA = _G.C_UnitAuras
	local rem = CUA and (CUA.RemoveAuraAppliedSound or CUA.RemovePrivateAuraAppliedSound)
	if rem then
		for _, id in ipairs(soundIDs) do pcall(rem, id) end
	end
	soundIDs = {}
end

local function RegisterSounds()
	if #soundIDs > 0 then return end
	local CUA = _G.C_UnitAuras
	-- 12.x name first, pre-12.x name as the fallback
	local add = CUA and (CUA.AddAuraAppliedSound or CUA.AddPrivateAuraAppliedSound)
	if not add then return end
	local cfg = GetCfg()
	if not cfg.soundEnabled then return end

	-- TWO DIFFERENT ID SPACES, and mixing them fails SILENTLY.
	--
	-- PlaySound() takes a SoundKitID - 8959 is SOUNDKIT.RAID_WARNING.
	-- AddAuraAppliedSound takes soundFileID, a FileDataID. Passing the kit ID
	-- there is simply an unknown file, so nothing plays and nothing complains.
	-- 567397 is the actual raid-warning FILE.
	--
	-- Better still: if the sound picked in options resolves to a real path,
	-- pass it as soundFileName so the client-side alert uses YOUR sound rather
	-- than a hardcoded default.
	local args = {
		unitToken = nil,
		spellID = SHIELD_ID,
		outputChannel = 'master',
	}
	local lib = LSM()
	if lib and lib.Fetch then
		local okF, path = pcall(lib.Fetch, lib, 'sound', cfg.sound)
		if okF and type(path) == 'string' and path ~= '' then args.soundFileName = path end
	end
	if not args.soundFileName then args.soundFileID = RAID_WARNING_FILE end

	for _, unit in ipairs({ 'boss1', 'boss2', 'boss3', 'boss4', 'target', 'focus' }) do
		args.unitToken = unit
		local ok, id = pcall(add, args)
		if ok and type(id) == 'number' then soundIDs[#soundIDs + 1] = id end
	end
end

----------------------------------------------------------------------
-- Apply
----------------------------------------------------------------------
local ApplySettings = function()
	local cfg = GetCfg()
	-- re-registered from scratch whenever the settings change, so toggling the
	-- sound off actually silences it
	UnregisterSounds()
	if cfg.enable and cfg.soundEnabled then RegisterSounds() end
end
ns.ToSApply = ApplySettings

-- Preview is a no-op: there is nothing on screen to place. Kept as a stub so
-- Edit Mode and the options page can call it without special-casing this one.
ns.ToSIsPreview = function() return false end
ns.ToSSetPreview = function() end

ns.ToSTest = function()
	PlayAlert(true)
end
