-- InterruptTracker for OrzUI
-- 显示层：在 oUF party 框架右侧叠加打断图标（32px、边框、冷却动画）。
-- CD 跨玩家同步：AddonMessage "OrzUI_IT"（自身打断实时广播给队友）。

local _, ns = ...
local B, C, L, DB = unpack(ns)

local LCG = LibStub("LibCustomGlow-1.0-OrzUI")

local strsplit, strmatch, tonumber, pairs, ipairs, wipe = strsplit, strmatch, tonumber, pairs, ipairs, wipe
local GetTime, UnitGUID, UnitClass, UnitExists, UnitName, UnitIsUnit = GetTime, UnitGUID, UnitClass, UnitExists, UnitName, UnitIsUnit
local C_Spell, C_ChatInfo, C_Timer = C_Spell, C_ChatInfo, C_Timer

-------------------------------------------------------------------------------
-- ═══════════════════════════════════════════════════════════════════════════
-- TAINT RESOLVER
-- Midnight 中 party spellID / aura spellId 是 secret number，查表直接 throw。
-- 通过隐藏 Slider 的 OnValueChanged（C++ 上下文，untainted）洗白。
-- ═══════════════════════════════════════════════════════════════════════════
do
	local _s = CreateFrame("Slider", nil, UIParent)
	_s:SetMinMaxValues(0, 9999999)
	_s:SetSize(1, 1)
	_s:Hide()
	local _r = nil
	_s:SetScript("OnValueChanged", function(_, v) _r = v end)

	function IT_ResolveNumber(raw)
		if raw == nil then return nil end
		if type(raw) == "number" then
			local ok = pcall(function() local _ = ({[raw]=1})[raw] end)
			if ok then return raw end
		end
		local okF, s = pcall(string.format, "%.0f", raw)
		if okF and s then
			local n = tonumber(s)
			if n then
				local ok2 = pcall(function() local _ = ({[n]=1})[n] end)
				if ok2 then return n end
			end
		end
		pcall(_s.SetValue, _s, 0)
		_r = nil
		local ok3 = pcall(_s.SetValue, _s, raw)
		if ok3 and _r and _r ~= 0 then
			return math.floor(_r + 0.5)
		end
		return nil
	end

	-- ScrubViaBytes: 从 tostring(raw) 逐字节重建数字
	function IT_ScrubViaBytes(raw)
		if raw == nil then return nil end
		if type(raw) == "number" then
			local ok = pcall(function() local _ = ({[raw]=1})[raw] end)
			if ok then return raw end
		end
		local okS, s = pcall(tostring, raw)
		if not okS or not s then return nil end
		local n = 0
		local hasDigit = false
		for i = 1, 20 do
			local okB, b = pcall(string.byte, s, i)
			if not okB then return nil end
			if not b then break end
			if b >= 48 and b <= 57 then
				n = n * 10 + (b - 48)
				hasDigit = true
			elseif b == 46 then
				break
			elseif b == 45 and not hasDigit then
				return nil
			else
				return nil
			end
		end
		if not hasDigit then return nil end
		local okP = pcall(function() local _ = ({[n]=1})[n] end)
		if okP then return n end
		return nil
	end
end

-------------------------------------------------------------------------------
-- ═══════════════════════════════════════════════════════════════════════════
-- 打断技能数据库
-- ═══════════════════════════════════════════════════════════════════════════

-- 按职业默认（class → { id, cd, name }）
local CLASS_INTERRUPTS = {
	DEATHKNIGHT = { id = 47528,  cd = 15, name = "Mind Freeze" },
	DEMONHUNTER = { id = 183752, cd = 15, name = "Disrupt" },
	DRUID       = { id = 106839, cd = 15, name = "Skull Bash" },
	EVOKER      = { id = 351338, cd = 40, name = "Quell" },
	HUNTER      = { id = 147362, cd = 24, name = "Counter Shot" },
	MAGE        = { id = 2139,   cd = 24, name = "Counterspell" },
	MONK        = { id = 116705, cd = 15, name = "Spear Hand Strike" },
	PALADIN     = { id = 96231,  cd = 15, name = "Rebuke" },
	PRIEST      = { id = 15487,  cd = 45, name = "Silence" },
	ROGUE       = { id = 1766,   cd = 15, name = "Kick" },
	SHAMAN      = { id = 57994,  cd = 12, name = "Wind Shear" },
	WARLOCK     = { id = 19647,  cd = 24, name = "Spell Lock" },
	WARRIOR     = { id = 6552,   cd = 15, name = "Pummel" },
}

local EXTRA_INTERRUPTS = {
	[89766]  = { id = 89766,  cd = 30, name = "Axe Toss", class = "WARLOCK" },
	[115781] = { id = 115781, cd = 24, name = "Optical Blast", class = "WARLOCK" },
}

-- 专精覆盖（不同技能或不同 CD）
local SPEC_INTERRUPT_OVERRIDES = {
	[102]  = { id = 78675,  cd = 60,  name = "Solar Beam" },          -- Balance Druid
	[105]  = {},                                                       -- Resto Druid (no kick)
	[65]   = {},                                                       -- Holy Paladin (no kick)
	[264]  = { id = 57994,  cd = 12,  name = "Wind Shear" },          -- Resto Shaman
	[270]  = {},                                                       -- Mistweaver (no kick)
	[257]  = {},                                                       -- Holy Priest (no kick)
	[256]  = {},                                                       -- Disc Priest (no kick)
	[1468] = {},                                                       -- Preservation Evoker (no kick)
}

local HEALER_NO_INTERRUPT_FALLBACK = {
	DRUID = true,
	EVOKER = true,
	MONK = true,
	PALADIN = true,
	PRIEST = true,
}

-- alias spellID → canonical spellID（变体 spellID 映射）
local SPELL_ALIASES = {
	[132409] = 19647,  [119898] = 19647,  [119910] = 19647,
	[171138] = 19647,  [171140] = 19647,  [212619] = 19647,
	[119914] = 89766,  -- Axe Toss → Spell Lock (Warlock demo)
	[455395] = 63560,
}

-- 全局查找表
local ALL_INTERRUPTS      = {}   -- [spellID] = { id, cd, name, class }
local ALL_INTERRUPTS_STR  = {}   -- [tostring(spellID)] = data
local ALL_INTERRUPTS_BY_NAME = {}  -- [spellName] = data

do
	for cls, data in pairs(CLASS_INTERRUPTS) do
		data.class = cls
		ALL_INTERRUPTS[data.id] = data
		ALL_INTERRUPTS_STR[tostring(data.id)] = data
	end
	for id, data in pairs(EXTRA_INTERRUPTS) do
		ALL_INTERRUPTS[id] = data
		ALL_INTERRUPTS_STR[tostring(id)] = data
	end
	for specID, ov in pairs(SPEC_INTERRUPT_OVERRIDES) do
		if ov.id then
			ov.class = nil
			ALL_INTERRUPTS[ov.id] = ov
			ALL_INTERRUPTS_STR[tostring(ov.id)] = ov
		end
	end
	-- 按名字查找
	for id, data in pairs(ALL_INTERRUPTS) do
		local ok, name = pcall(C_Spell.GetSpellName, id)
		if ok and name and name ~= "" then
			ALL_INTERRUPTS_BY_NAME[name] = data
		end
	end
	-- alias 也要进名册（用 alias spellID 查 spelleName）
	for aliasID, targetID in pairs(SPELL_ALIASES) do
		local ok, name = pcall(C_Spell.GetSpellName, aliasID)
		if ok and name and name ~= "" then
			ALL_INTERRUPTS_BY_NAME[name] = ALL_INTERRUPTS[targetID]
		end
	end
end

-- 玩家注册表：记录每个队伍成员的打断技能和 CD
local kickRegistry = {}     -- [name] = { spellID, baseCd, cdEnd, class }
local kickNoInterrupt = {}  -- [name] = true（治疗无打断职业）
local extraKicks       = {} -- [name] = { {spellID, baseCd, cdEnd, name}, ... }

-- 读取或创建注册条目
local function KickRegCreate(name)
	if not kickRegistry[name] then
		kickRegistry[name] = { spellID = 0, baseCd = 15, cdEnd = 0, class = nil }
	end
	return kickRegistry[name]
end

-------------------------------------------------------------------------------
-- ═══════════════════════════════════════════════════════════════════════════
-- Taint-safe 打断判定（三层：名称 → ID → 注册表回退）
-- ═══════════════════════════════════════════════════════════════════════════
local function ResolveInterruptSpell(spellID)
	-- 1) 通过 GetSpellName（常见场景，即使 spellID tainted 也能取到名字）
	local ok1, spellName = pcall(C_Spell.GetSpellName, spellID)
	if ok1 and spellName and type(spellName) == "string" then
		-- spellName 可能是 secret string，直接做 key 会 throw，需 pcall 包裹
		local okN, data = pcall(function() return ALL_INTERRUPTS_BY_NAME[spellName] end)
		if okN and data then return data, data.id, spellName end
	end
	-- 2) 通过数字 ID（干净环境）
	local cleanID = IT_ResolveNumber(spellID)
	if cleanID then
		local data = ALL_INTERRUPTS[cleanID]
		if data then return data, cleanID, spellName end
		-- alias
		local aliasTarget = SPELL_ALIASES[cleanID]
		if aliasTarget then
			local data2 = ALL_INTERRUPTS[aliasTarget]
			if data2 then return data2, aliasTarget, spellName end
		end
	end
	-- 3) 通过 tostring + 字节重建（最深回退，参照 IT_ScrubViaBytes）
	local scrubbed = IT_ScrubViaBytes(spellID)
	if scrubbed then
		local data = ALL_INTERRUPTS[scrubbed]
		if data then return data, scrubbed, spellName end
	end
	return nil, nil, spellName
end

-------------------------------------------------------------------------------
-- ═══════════════════════════════════════════════════════════════════════════
-- 自动注册队伍成员
-- 在 GROUP_ROSTER_UPDATE 时根据职业/专精自动设置打断信息
-- ═══════════════════════════════════════════════════════════════════════════

local cdState, iconCache, attachedFrames
local RefreshUnitDisplay, AttachToPartyFrames, SendSync, GetUnitFromFrame

local specCache = {} -- [guid] = specID

-- 处理单个单位的注册（player / party）
local function RegisterUnitInterrupt(u)
	if not UnitExists(u) then return end
	local name = UnitName(u)
	local _, cls = UnitClass(u)
	if not name or not cls then return end

	local guid = UnitGUID(u)
	local role = UnitGroupRolesAssigned(u)

	local specID = specCache[guid]
	if not specID and GetInspectSpecialization then
		local ok, id = pcall(GetInspectSpecialization, u)
		if ok and id and id > 0 then
			specID = id
			specCache[guid] = id
		end
	end

	local kickData
	local skipKick = false
	if specID then
		local ov = SPEC_INTERRUPT_OVERRIDES[specID]
		if ov then
			if ov.id then
				kickData = ov
			else
				kickNoInterrupt[name] = true
				kickRegistry[name] = nil
				skipKick = true
			end
		end
	end
	if not specID and role == "HEALER" and HEALER_NO_INTERRUPT_FALLBACK[cls] then
		kickNoInterrupt[name] = true
		kickRegistry[name] = nil
		skipKick = true
	end
	if not skipKick then
		if not kickData then
			kickData = CLASS_INTERRUPTS[cls]
		end
		if kickData then
			kickNoInterrupt[name] = nil
			local entry = KickRegCreate(name)
			entry.class = cls
			entry.spellID = kickData.id
			entry.baseCd = kickData.cd
			if entry.cdEnd == 0 then entry.cdEnd = 0 end
		end
	end
end

local function AutoRegisterParty()
	local activeNames, activeGUIDs = {}, {}
	local units = { "player", "party1", "party2", "party3", "party4" }
	for _, unit in ipairs(units) do
		if UnitExists(unit) then
			local name = UnitName(unit)
			local guid = UnitGUID(unit)
			if name then activeNames[name] = true end
			if guid then activeGUIDs[guid] = true end
		end
	end

	for name in pairs(kickRegistry) do
		if not activeNames[name] then
			kickRegistry[name] = nil
			extraKicks[name] = nil
		end
	end
	for name in pairs(kickNoInterrupt) do
		if not activeNames[name] then
			kickNoInterrupt[name] = nil
		end
	end
	for guid in pairs(specCache) do
		if not activeGUIDs[guid] then
			specCache[guid] = nil
		end
	end
	if cdState then
		for unit, state in pairs(cdState) do
			local guid = UnitGUID(unit)
			if not UnitExists(unit) or (state.guid and state.guid ~= guid) then
				cdState[unit] = nil
			end
		end
	end

	RegisterUnitInterrupt("player")
	for i = 1, 4 do
		RegisterUnitInterrupt("party" .. i)
	end

	if RefreshUnitDisplay then
		for _, unit in ipairs(units) do
			RefreshUnitDisplay(unit)
		end
	end
end

-- ═══════════════════════════════════════════════════════════════════════════
-- CD 状态 & 显示层
-- ═══════════════════════════════════════════════════════════════════════════

local module = B:RegisterModule("InterruptTracker")
local syncPrefix = "OrzUI_IT"

cdState = {}       -- [unitToken] = { spellID, cdEndTime, icon, cdDuration }
iconCache = {}     -- [spellID] = texture path
attachedFrames = {} -- [oUF frame] = { overlay, texture, cd, border, unit }

local ICON_SIZE = 21
local DEFAULT_ICON = select(2, pcall(C_Spell.GetSpellTexture, 1766)) or "Interface\\Icons\\inv_misc_questionmark"

-- 图标位置偏移（支持拖动）
local DEFAULT_OFFSET = { x = 0, y = 3 }
local function GetInterruptTrackerOffset()
	if not OrzUISetDB then return DEFAULT_OFFSET end
	if not OrzUISetDB.InterruptTrackerOffset then
		OrzUISetDB.InterruptTrackerOffset = { x = DEFAULT_OFFSET.x, y = DEFAULT_OFFSET.y }
	end
	return OrzUISetDB.InterruptTrackerOffset
end

local dragOverlay, dragStartX, dragStartY, dragStartOffX, dragStartOffY
local function ApplyInterruptOffset()
	local off = GetInterruptTrackerOffset()
	for _, display in pairs(attachedFrames) do
		local overlay = display.overlay
		local parent = overlay:GetParent()
		local healthBar = parent.Health or parent
		overlay:ClearAllPoints()
		overlay:SetPoint("BOTTOMLEFT", healthBar, "TOPLEFT", off.x, off.y)
	end
end

local function OnInterruptDragUpdate(self)
	if not dragOverlay then return end
	local curX, curY = GetCursorPosition()
	local scale = UIParent:GetEffectiveScale()
	curX, curY = curX / scale, curY / scale
	local off = GetInterruptTrackerOffset()
	off.x = dragStartOffX + (curX - dragStartX)
	off.y = dragStartOffY + (curY - dragStartY)
	ApplyInterruptOffset()
end

local function OnInterruptDragStart(self)
	dragOverlay = self
	local x, y = GetCursorPosition()
	local scale = UIParent:GetEffectiveScale()
	dragStartX, dragStartY = x / scale, y / scale
	local off = GetInterruptTrackerOffset()
	dragStartOffX, dragStartOffY = off.x, off.y
	self:SetScript("OnUpdate", OnInterruptDragUpdate)
end

local function OnInterruptDragStop(self)
	self:SetScript("OnUpdate", nil)
	dragOverlay = nil
	if OrzUISetDB then
		local off = GetInterruptTrackerOffset()
		OrzUISetDB.InterruptTrackerOffset = { x = off.x, y = off.y }
	end
end

-- 前向声明

local function GetSpellIcon(spellID)
	if not spellID then return nil end
	if iconCache[spellID] then return iconCache[spellID] end
	local icon = C_Spell.GetSpellTexture(spellID)
	if icon then iconCache[spellID] = icon end
	return icon
end

local function IsPartyUnit(unit)
	return unit == "player" or unit == "party1" or unit == "party2"
		or unit == "party3" or unit == "party4"
end

local function NormalizeName(name)
	if not name then return nil end
	if Ambiguate then
		return Ambiguate(name, "short")
	end
	return strmatch(name, "^([^-]+)") or name
end

local function FindPartyUnitByGUID(guid)
	if not guid then return nil end
	if UnitGUID("player") == guid then return "player" end
	for i = 1, 4 do
		local unit = "party" .. i
		if UnitExists(unit) and UnitGUID(unit) == guid then
			return unit
		end
	end
end

local function FindPartyUnitByName(name)
	local shortName = NormalizeName(name)
	if not shortName then return nil end
	if NormalizeName(UnitName("player")) == shortName then return "player" end
	for i = 1, 4 do
		local unit = "party" .. i
		if UnitExists(unit) and NormalizeName(UnitName(unit)) == shortName then
			return unit
		end
	end
end

local function FindPartyUnitByPetGUID(guid)
	if not guid then return nil end
	if UnitGUID("pet") == guid then return "player" end
	for i = 1, 4 do
		local petUnit = "partypet" .. i
		if UnitExists(petUnit) and UnitGUID(petUnit) == guid then
			return "party" .. i
		end
	end
end

-------------------------------------------------------------------------------
-- CD 状态更新 — 从打断检测结果写入 cdState
-------------------------------------------------------------------------------
local function UpdateInterruptCD(unitToken, spellID, baseCd, cdEndTime)
	if not unitToken or not spellID then return end
	if not IsPartyUnit(unitToken) then return end

	local now = GetTime()
	local guid = UnitGUID(unitToken)

	-- 防抖动：如果已有更新的 CD，跳过
	local existing = cdState[unitToken]
	if existing and existing.guid == guid and existing.cdEndTime and existing.cdEndTime > now and existing.cdEndTime > cdEndTime then
		return
	end

	local icon = GetSpellIcon(spellID)
	cdState[unitToken] = {
		guid = guid,
		spellID = spellID,
		cdEndTime = cdEndTime,
		icon = icon,
		cdDuration = baseCd,
	}

	RefreshUnitDisplay(unitToken)
end

-------------------------------------------------------------------------------
-- 插件通信同步（仅自身打断广播给队友）
-------------------------------------------------------------------------------
function SendSync(unit, spellID, cdDuration, cdEndTime)
	local guid = UnitGUID(unit)
	if not guid then return end
	local remaining = cdEndTime and (cdEndTime - GetTime()) or cdDuration
	if not remaining or remaining <= 0 then return end
	local msg = guid .. "|" .. spellID .. "|" .. cdDuration .. "|" .. remaining .. "|" .. GetTime()
	C_ChatInfo.SendAddonMessage(syncPrefix, msg, "PARTY")
end

local function OnSyncReceived(prefix, message)
	if prefix ~= syncPrefix then return end

	local guid, spellIDStr, cdDurationStr, remainingStr = strsplit("|", message)
	local spellID = tonumber(spellIDStr)
	local cdDuration = tonumber(cdDurationStr)
	local remaining = tonumber(remainingStr)
	if not guid or not spellID or not cdDuration or not remaining then return end
	local unit = FindPartyUnitByGUID(guid)
	if not IsPartyUnit(unit) then return end
	if not UnitExists(unit) then return end

	-- 计算经过时间，避免网络延迟覆盖本地的精确时间
	if remaining > 0 then
		UpdateInterruptCD(unit, spellID, cdDuration, GetTime() + remaining)
	end
end

-------------------------------------------------------------------------------
-- ═══════════════════════════════════════════════════════════════════════════
-- 打断检测核心
-- ═══════════════════════════════════════════════════════════════════════════

-- ── 自身打断 ─────────────────────────────────────────────────────────────
local function HandleOwnKick(spellID)
	local now = GetTime()
	local myName = UnitName("player")
	if not myName then return end

	local resolvedID = SPELL_ALIASES[spellID] or spellID
	local spellData = ALL_INTERRUPTS[resolvedID]
	if not spellData then return end

	local entry = KickRegCreate(myName)
	local _, cls = UnitClass("player")
	entry.class = cls
	entry.spellID = resolvedID
	entry.baseCd = spellData.cd
	entry.cdEnd = now + spellData.cd

	UpdateInterruptCD("player", resolvedID, spellData.cd, now + spellData.cd)
	SendSync("player", resolvedID, spellData.cd, now + spellData.cd)
end

-- ── 队友打断（从信号关联系统驱动） ──────────────────────────────────────
local function HandlePartyCast(memberName, spellID, unit)
	local now = GetTime()

	local resolvedID = SPELL_ALIASES[spellID] or spellID
	local spellData = ALL_INTERRUPTS[resolvedID]
	if not spellData then return end

	local entry = kickRegistry[memberName]
	if not entry then
		-- 首次见到 → 自动注册
		entry = KickRegCreate(memberName)
		entry.spellID = resolvedID
		entry.baseCd = spellData.cd
		entry.class = spellData.class
	end

	-- 更新 CD
	local baseCd = spellData.cd
	if resolvedID == entry.spellID then
		entry.cdEnd = now + baseCd
	else
		-- 额外打断技能（恶魔术 Axe Toss 等）
		if not extraKicks[memberName] then extraKicks[memberName] = {} end
		extraKicks[memberName][resolvedID] = now + baseCd
	end

	-- 映射 unit 名称到 unitToken
	local unitToken
	if memberName == UnitName("player") then
		unitToken = "player"
	else
		for i = 1, 4 do
			local pu = "party" .. i
			if UnitExists(pu) and UnitName(pu) == memberName then
				unitToken = pu
				break
			end
		end
	end
	if unit then
		if unit:find("^partypet") then
			local idx = unit:match("partypet(%d)")
			unitToken = idx and "party" .. idx
		elseif unit == "pet" then
			unitToken = "player"
		elseif IsPartyUnit(unit) then
			unitToken = unit
		end
	end
	unitToken = unitToken or FindPartyUnitByName(memberName)

	if unitToken then
		UpdateInterruptCD(unitToken, resolvedID, baseCd, now + baseCd)
	end
end

-- ── 信号容器与关联系统 ───────────────────────────────────────────────────
local signalTape       = {}    -- { { kind, unit, at, consumed }, ... }
local signalSeq        = 0
local needsCorrelation = false
local lastCorrelateAt  = 0
local recentCasts      = {}    -- [memberName] = { t, spellID }

local SIGNAL_RETENTION   = 0.35
local CORRELATE_INTERVAL = 0.04
local MATCH_WINDOW       = 0.055
local AURA_SUPPRESS      = 0.028

local function PushSignal(kind, unit)
	signalSeq = signalSeq + 1
	signalTape[#signalTape + 1] = { seq = signalSeq, kind = kind, unit = unit, at = GetTime(), consumed = false }
	needsCorrelation = true
end

local function PruneSignalTape(now)
	local kept = {}
	local minAt = now - SIGNAL_RETENTION
	for i = 1, #signalTape do
		local s = signalTape[i]
		if s and s.at and s.at >= minAt then kept[#kept + 1] = s end
	end
	signalTape = kept
end

-- 关联失败时的回退：按距离/目标/唯一离线判断归属
local function AttributeInterruptToParty(mobUnit)
	local now = GetTime()
	local mobX, mobY, _, mobMap = UnitPosition(mobUnit)
	local MAX_RANGE = 35

	local function dist(pu)
		if not mobX or not mobMap then return nil end
		local px, py, _, pmap = UnitPosition(pu)
		if not px or pmap ~= mobMap then return nil end
		return math.sqrt((mobX - px) ^ 2 + (mobY - py) ^ 2)
	end

	local candidates = {}
	for i = 1, 4 do
		local pu = "party" .. i
		if UnitExists(pu) then
			local name = UnitName(pu)
			if name and kickRegistry[name] and kickRegistry[name].spellID > 0 then
				local entry = kickRegistry[name]
				local onCD = entry.cdEnd and entry.cdEnd > now
				if not onCD then
					local d = dist(pu)
					candidates[#candidates + 1] = {
						unit = pu, name = name, spellID = entry.spellID,
						targetMatches = UnitIsUnit(pu .. "target", mobUnit),
						dist = d, inRange = (d == nil or d <= MAX_RANGE),
					}
				end
			end
		end
	end

	local function pickClosest(set)
		if #set == 0 then return nil end
		if #set == 1 then return set[1] end
		local best, bestD, fallback
		for _, c in ipairs(set) do
			if c.dist then
				if not best or c.dist < bestD then best = c; bestD = c.dist end
			elseif not fallback then fallback = c end
		end
		return best or fallback
	end

	local targeting, inRange = {}, {}
	for _, c in ipairs(candidates) do
		if c.targetMatches then targeting[#targeting + 1] = c end
		if c.inRange then inRange[#inRange + 1] = c end
	end

	local winner
	if     #targeting == 1 then winner = targeting[1]
	elseif #targeting > 1  then winner = pickClosest(targeting)
	elseif #inRange == 1   then winner = inRange[1]
	elseif #inRange > 1    then winner = pickClosest(inRange)
	elseif #candidates == 1 then winner = candidates[1]
	end

	if winner then
		recentCasts[winner.name] = { t = now, spellID = winner.spellID }
		HandlePartyCast(winner.name, winner.spellID, winner.unit)
	end
end

-- ── OnUpdate 信号关联 ──────────────────────────────────────────────────
local function CorrelateSignals()
	local now = GetTime()
	if not needsCorrelation then return end
	if now - lastCorrelateAt < CORRELATE_INTERVAL then return end
	lastCorrelateAt = now
	PruneSignalTape(now)

	local casts, interrupts, auras = {}, {}, {}
	for i = 1, #signalTape do
		local s = signalTape[i]
		if s and not s.consumed then
			if     s.kind == "cast"      then casts[#casts + 1] = s
			elseif s.kind == "interrupt" then interrupts[#interrupts + 1] = s
			elseif s.kind == "aura"      then auras[#auras + 1] = s
			end
		end
	end

	if #interrupts == 0 or #casts == 0 then needsCorrelation = false; return end

	table.sort(interrupts, function(a, b) return (a.at or 0) < (b.at or 0) end)
	local freshest = interrupts[#interrupts]

	-- 抑制：多个 interrupt 信号在 18ms 内（同一事件多次触发）
	local clustered = 0
	for i = 1, #interrupts do
		if math.abs((interrupts[i].at or 0) - (freshest.at or 0)) <= 0.018 then clustered = clustered + 1 end
	end
	if clustered > 1 then
		for i = 1, #interrupts do interrupts[i].consumed = true end
		needsCorrelation = false; return
	end

	-- 抑制：相同 nameplate 上 aura 在 28ms 内（假阳性过滤）
	for i = 1, #auras do
		if auras[i].unit == freshest.unit and math.abs((freshest.at or 0) - (auras[i].at or 0)) <= AURA_SUPPRESS then
			freshest.consumed = true; needsCorrelation = false; return
		end
	end

	-- 在 55ms 窗口内找最佳 cast 匹配
	local bestCast, bestDiff = nil, math.huge
	for i = 1, #casts do
		local diff = math.abs((freshest.at or 0) - (casts[i].at or 0))
		if diff <= MATCH_WINDOW and diff < bestDiff then bestDiff = diff; bestCast = casts[i] end
	end

	freshest.consumed = true
	if bestCast then
		bestCast.consumed = true
		local u = bestCast.unit
		local memberName
		if u and u:find("^partypet") then
			local idx = u:match("partypet(%d)")
			memberName = idx and UnitName("party" .. idx)
		else
			memberName = u and UnitName(u)
		end
		if memberName then
			local rc = recentCasts[memberName]
			if rc and rc.spellID then
				HandlePartyCast(memberName, rc.spellID, u)
			end
		end
	end

	needsCorrelation = false
end

-------------------------------------------------------------------------------
-- ═══════════════════════════════════════════════════════════════════════════
-- 事件帧 — 延迟注册，避免战斗中加载时触发 RegisterEvent 保护错误
-- ═══════════════════════════════════════════════════════════════════════════

-- 1. 玩家自身施法检测（spellID 干净，直接判定）
local playerCastFrame = CreateFrame("Frame")
playerCastFrame:SetScript("OnEvent", function(_, _, unit, _, spellID)
		if unit == "pet" then
			-- Pet spellID 可能 taint
			local usedID
			local ok, hit = pcall(function() return ALL_INTERRUPTS[spellID] end)
			if ok and hit then usedID = spellID end
			if not usedID then
				local scrubbed = IT_ScrubViaBytes(spellID)
				if scrubbed and ALL_INTERRUPTS[scrubbed] then usedID = scrubbed end
			end
			if not usedID then usedID = IT_ResolveNumber(spellID) end
			if usedID and ALL_INTERRUPTS[usedID] then
				HandleOwnKick(usedID)
			end
		else
			local resolvedID = SPELL_ALIASES[spellID] or spellID
			if ALL_INTERRUPTS[resolvedID] then
				HandleOwnKick(resolvedID)
			end
		end
end)

-- 2. 队伍施法 → 发送 cast 信号给关联系统（不直接判定，因为 spellID 可能 taint）
local _lastMemberCast = {}
local partyCastFrame = CreateFrame("Frame")
partyCastFrame:SetScript("OnEvent", function(_, _, unit, _, spellID)
		if not unit then return end

		local memberName
		if unit:find("^partypet") then
			local idx = unit:match("partypet(%d)")
			memberName = idx and UnitName("party" .. idx)
		else
			memberName = UnitName(unit)
		end
		if not memberName then return end

		-- 尝试解析打断技能（三步：名称 → ID → 最深回退）
		local data, cleanID, spellName = ResolveInterruptSpell(spellID)

		if data then
			recentCasts[memberName] = { t = GetTime(), spellID = data.id }
			PushSignal("cast", unit)
		elseif not spellName then
			-- spellID 完全 taint，用注册表回退
			local entry = kickRegistry[memberName]
			if entry and entry.spellID > 0 then
				local onCD = entry.cdEnd and entry.cdEnd > GetTime()
				if not onCD then
					-- 任何施法都可能是指令 → 推信号（关联系统会过滤）
					recentCasts[memberName] = { t = GetTime(), spellID = entry.spellID }
					PushSignal("cast", unit)
				end
			end
		end

		-- 记录最后施法时间（用于关联系统的距离判定）
		_lastMemberCast[memberName] = GetTime()
end)

-- 3. Nameplate：UNIT_SPELLCAST_INTERRUPTED + UNIT_AURA（信号源）
local nameplateSignalFrame = CreateFrame("Frame")
nameplateSignalFrame:SetScript("OnEvent", function(_, event, unit)
		if not unit or not unit:find("^nameplate") then return end

		if event == "UNIT_SPELLCAST_INTERRUPTED" then
			PushSignal("interrupt", unit)
			-- 异步归属判定（给信号关联一次机会，失败则回退到距离/目标判定）
			C_Timer.After(0, function()
				AttributeInterruptToParty(unit)
			end)
		elseif event == "UNIT_AURA" then
			PushSignal("aura", unit)
		end
end)

-- 4. OnUpdate 信号关联
do
	local f = CreateFrame("Frame")
	f:SetScript("OnUpdate", function()
		if needsCorrelation then CorrelateSignals() end
	end)
end

-- 5. 插件通信接收
local combatLogFrame = CreateFrame("Frame")
combatLogFrame:SetScript("OnEvent", function()
	local _, eventType, _, sourceGUID, _, _, _, _, _, _, _, _, _, _, extraSpellID = CombatLogGetCurrentEventInfo()
	if eventType ~= "SPELL_INTERRUPT" then return end

	local data, cleanID = ResolveInterruptSpell(extraSpellID)
	if not data or not cleanID then return end

	local unit = FindPartyUnitByGUID(sourceGUID) or FindPartyUnitByPetGUID(sourceGUID)
	if not unit then return end

	if unit == "player" then
		HandleOwnKick(cleanID)
		return
	end

	local ownerName = UnitName(unit)
	if not ownerName then return end
	HandlePartyCast(ownerName, cleanID, unit)
end)

local addonMessageFrame = CreateFrame("Frame")
addonMessageFrame:SetScript("OnEvent", function(_, _, prefix, message)
	OnSyncReceived(prefix, message)
end)

-- 6. 队伍变化 / 进出副本 → 重新注册 + 重新附加
local rosterFrame = CreateFrame("Frame")
rosterFrame:SetScript("OnEvent", function(_, event)
	if event == "GROUP_ROSTER_UPDATE" then
		AutoRegisterParty()
	end
	C_Timer.After(0.5, function()
		AutoRegisterParty()
		AttachToPartyFrames()
	end)
end)

local eventsRegistered
local function RegisterTrackerEvents()
	if eventsRegistered then return end

	if InCombatLockdown and InCombatLockdown() then
		C_Timer.After(1, RegisterTrackerEvents)
		return
	end

	playerCastFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player", "pet")
	for i = 1, 4 do
		partyCastFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "party" .. i)
		partyCastFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "partypet" .. i)
	end
	nameplateSignalFrame:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED")
	nameplateSignalFrame:RegisterEvent("UNIT_AURA")
	-- COMBAT_LOG_EVENT_UNFILTERED currently triggers ADDON_ACTION_FORBIDDEN
	-- when registered from this module on Midnight clients. Keep the handler
	-- defined for a future XML/preload registration path, but do not register
	-- it here; UNIT_SPELLCAST/nameplate signals still drive the tracker.
	addonMessageFrame:RegisterEvent("CHAT_MSG_ADDON")
	rosterFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
	rosterFrame:RegisterEvent("PLAYER_ENTERING_WORLD")

	eventsRegistered = true
end

C_Timer.After(0, RegisterTrackerEvents)

-- 7. OnUpdate 清理过期 CD
local elapsedSinceClean = 0
local CLEAN_INTERVAL = 0.5
do
	local f = CreateFrame("Frame")
	f:SetScript("OnUpdate", function(_, elapsed)
		elapsedSinceClean = elapsedSinceClean + elapsed
		if elapsedSinceClean >= CLEAN_INTERVAL then
			elapsedSinceClean = 0
			local now = GetTime()
			for unit, state in pairs(cdState) do
				if state.cdEndTime and state.cdEndTime <= now then
					cdState[unit] = nil
					RefreshUnitDisplay(unit)
				end
			end
		end
	end)
end

-------------------------------------------------------------------------------
-- 显示层：oUF 框架图标叠加（32px + 边框 + 冷却动画）
-------------------------------------------------------------------------------
local function CreateInterruptOverlay(frame, unit)
	if attachedFrames[frame] then return end

	local overlay = CreateFrame("Frame", nil, frame)
	overlay:SetSize(ICON_SIZE, ICON_SIZE)
	-- 锚定到生命条右侧（与生命条同高居中），不在整个 frame 的右上角
	local healthBar = frame.Health or frame
	if C.db["UFs"]["PartyDirec"] > 2 then
		overlay:SetPoint("BOTTOMLEFT", healthBar, "TOPLEFT", 3, 3) 
	else
	    overlay:SetPoint("LEFT", healthBar, "RIGHT", 3, -3)
	end
	--local off = GetInterruptTrackerOffset()
	--overlay:SetPoint("BOTTOMLEFT", healthBar, "TOPLEFT", off.x, off.y)
	overlay:SetFrameLevel(frame:GetFrameLevel() + 15)

	--[[ 允许鼠标拖动位置
	overlay:EnableMouse(true)
	overlay:RegisterForDrag("LeftButton")
	overlay:SetScript("OnDragStart", OnInterruptDragStart)
	overlay:SetScript("OnDragStop", OnInterruptDragStop)]]

	-- oUF 风格黑色边框（与生命条一致）
	B.SetBD(overlay, 0)

	local texture = overlay:CreateTexture(nil, "ARTWORK")
	texture:SetAllPoints()
	texture:SetTexCoord(0.07, 0.93, 0.07, 0.93)

	-- 行军蚂蚁高亮帧（打断就绪时发光）
	local glowFrame = B.CreateGlowFrame(overlay, ICON_SIZE)

	local cd = CreateFrame("Cooldown", nil, overlay, "CooldownFrameTemplate")
	cd:SetAllPoints()
	cd:SetDrawEdge(false)
	cd:SetDrawBling(false)
	cd:SetHideCountdownNumbers(false)
	cd:SetSwipeColor(0, 0, 0, 0.8)
	cd:SetScript("OnCooldownDone", function()
		texture:SetDesaturated(false)
	end)

	attachedFrames[frame] = {
		overlay = overlay,
		texture = texture,
		cd = cd,
		glowFrame = glowFrame,
		unit = unit,
	}

	RefreshUnitDisplay(unit)
end

RefreshUnitDisplay = function(unit)
	if not unit then return end

	local state = cdState[unit]
	local unitGUID = UnitGUID(unit)
	if state and state.guid and state.guid ~= unitGUID then
		cdState[unit] = nil
		state = nil
	end
	local unitName = UnitName(unit)
	local entry = unitName and kickRegistry[unitName]

	-- 无打断职业 → 隐藏
	if unitName and kickNoInterrupt[unitName] then
	for _, display in pairs(attachedFrames) do
		local displayUnit = GetUnitFromFrame(display.overlay:GetParent()) or display.unit
		display.unit = displayUnit
		if displayUnit == unit then
				display.overlay:Hide()
				LCG.PixelGlow_Stop(display.glowFrame)
			end
		end
		return
	end

	-- 根据注册表确定该单位的打断图标
	local displaySpellID = entry and entry.spellID
	if not displaySpellID or displaySpellID == 0 then
	for _, display in pairs(attachedFrames) do
		local displayUnit = GetUnitFromFrame(display.overlay:GetParent()) or display.unit
		display.unit = displayUnit
		if displayUnit == unit then
				display.overlay:Hide()
				LCG.PixelGlow_Stop(display.glowFrame)
			end
		end
		return
	end

	local icon = (state and state.icon) or GetSpellIcon(displaySpellID) or DEFAULT_ICON

	for _, display in pairs(attachedFrames) do
		local displayUnit = GetUnitFromFrame(display.overlay:GetParent()) or display.unit
		display.unit = displayUnit
		if displayUnit == unit then
			display.overlay:Show()
			display.texture:SetTexture(icon)

			local onCD = state and state.cdEndTime and state.cdEndTime > GetTime()
			if onCD then
				display.cd:SetCooldown(state.cdEndTime - state.cdDuration, state.cdDuration)
				display.texture:SetDesaturated(true)
				LCG.PixelGlow_Stop(display.glowFrame)
			else
				display.cd:Clear()
				display.texture:SetDesaturated(false)
				LCG.PixelGlow_Start(display.glowFrame)
			end
			return
		end
	end
end

GetUnitFromFrame = function(frame)
	return frame.unit or (frame.GetAttribute and frame:GetAttribute("unit"))
end

AttachToPartyFrames = function()
	-- oUF:SpawnHeader 创建的 unit button 是 header 的子 frame，
	-- 不在 ns.oUF.objects 中。遍历 oUF_Party 的子 frame 才能找到 player + party。
	local header = _G["oUF_Party"]
	if not header then return end
	if header.IsForbidden and header:IsForbidden() then return end

	for _, child in ipairs({ header:GetChildren() }) do
		if not (child.IsForbidden and child:IsForbidden()) then
			local unit = GetUnitFromFrame(child)
			if unit and IsPartyUnit(unit) then
				if attachedFrames[child] then
					attachedFrames[child].unit = unit
					RefreshUnitDisplay(unit)
				else
					CreateInterruptOverlay(child, unit)
				end
			end
		end
	end
end

-------------------------------------------------------------------------------
-- module:OnLogin — 仅处理需要登录后的操作
-------------------------------------------------------------------------------
local commReady = false

function module:OnLogin()
	if not C.db["Misc"]["InterruptTracker"] then return end
	-- 注册插件通信前缀
	local ok = pcall(C_ChatInfo.RegisterAddonMessagePrefix, syncPrefix)
	if ok then commReady = true end

	-- 首次附加图标（延迟等待 oUF 完成初始化）
	C_Timer.After(0.5, function()
		AutoRegisterParty()
		AttachToPartyFrames()
	end)
end