-- 目标标记助手：打断技能识别、冷却检测、打断通报与插件同步。

TargetMarkerAssistant = TargetMarkerAssistant or {}
local TMA = TargetMarkerAssistant
local L = TargetMarkerAssistantL

local bitBand = bit and bit.band or bit32 and bit32.band
local INTERRUPT_SYNC_PREFIX = "TMAINT"
local INTERRUPT_SYNC_VERSION = "1"

local fallbackChannelColors = {
    SAY = { r = 1.00, g = 1.00, b = 1.00 },
    YELL = { r = 1.00, g = 0.25, b = 0.25 },
    DANMAKU = { r = 1.00, g = 0.82, b = 0.00 },
    PARTY = { r = 0.67, g = 0.67, b = 1.00 },
    RAID = { r = 1.00, g = 0.49, b = 0.04 },
    INSTANCE_CHAT = { r = 1.00, g = 0.49, b = 0.04 },
    RAID_WARNING = { r = 1.00, g = 0.28, b = 0.04 },
}

local function GetChannelColor(value)
    local info = (CHAT_TYPE_INFO and CHAT_TYPE_INFO[value]) or (ChatTypeInfo and ChatTypeInfo[value])
    if info and info.r and info.g and info.b then
        return info.r, info.g, info.b
    end

    local color = fallbackChannelColors[value] or fallbackChannelColors.SAY
    return color.r, color.g, color.b
end

local function ColorizeChannelText(text, value)
    local r, g, b = GetChannelColor(value)
    return string.format("|cff%02x%02x%02x%s|r", math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5), text or "")
end

TMA.ColorizeChannelText = ColorizeChannelText

local function PrintLocalMessage(message)
    local text = TMA.GetPublicChatText and TMA.GetPublicChatText(message, "") or tostring(message or "")
    if DEFAULT_CHAT_FRAME and text and text ~= "" then
        pcall(DEFAULT_CHAT_FRAME.AddMessage, DEFAULT_CHAT_FRAME, "|cff00ff00" .. L["ADDON_TITLE"] .. "|r " .. text)
    end
end

TMA.PrintLocalMessage = PrintLocalMessage

function TMA.DebugInterrupt(msg)
    if not TMA.DB or not TMA.DB.debugInterruptAnnounce then
        return
    end

    local text = TMA.GetPublicChatText and TMA.GetPublicChatText(msg, "<protected>") or tostring(msg or "")
    if DEFAULT_CHAT_FRAME and text ~= "" then
        pcall(DEFAULT_CHAT_FRAME.AddMessage, DEFAULT_CHAT_FRAME, "|cff00ccff[TMA-打断调试]|r " .. text)
    end
end

function TMA.GetDebugReasonText(reason)
    local reasonText = tostring(reason or "")
    local map = {
        ACTIONBAR_UPDATE_COOLDOWN = "动作条冷却更新",
        SPELL_UPDATE_COOLDOWN = "法术冷却更新",
        UNIT_SPELLCAST_SENT = "玩家开始释放技能",
        UNIT_SPELLCAST_SUCCEEDED = "玩家技能释放成功",
        PLAYER_INTERRUPT_SPELL_USED = "玩家打断技能使用",
    }

    local base, suffix = reasonText:match("^([^:]+)(:.*)$")
    if base and map[base] then
        return map[base] .. (suffix == ":retry" and "复查" or "")
    end

    return map[reasonText] or reasonText
end

function TMA.IsChallengeModeActiveSafe()
    if C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive then
        local ok, active = pcall(C_ChallengeMode.IsChallengeModeActive)
        if ok and active then
            return true
        end
    end
    return false
end

local function IsChallengeModeActiveSafe()
    return TMA.IsChallengeModeActiveSafe()
end

function TMA.IsAddonChatRestricted()
    if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown then
        local ok, locked = pcall(C_ChatInfo.InChatMessagingLockdown)
        if ok and locked then
            return true
        end
    end

    if C_RestrictedActions
        and C_RestrictedActions.IsAddOnRestrictionActive
        and Enum
        and Enum.AddOnRestrictionType
        and Enum.AddOnRestrictionType.Chat
    then
        local ok, restricted = pcall(C_RestrictedActions.IsAddOnRestrictionActive, Enum.AddOnRestrictionType.Chat)
        if ok and restricted then
            return true
        end
    end

    return false
end

local function IsAddonChatRestricted()
    return TMA.IsAddonChatRestricted()
end

local lastInterruptKey, lastInterruptTime
local lastInterruptSkillKey, lastInterruptSkillTime
local lastInterruptAttemptAt, lastInterruptAttemptSpellID, lastInterruptAttemptSpellName
TMA.RecentGroupInterruptCasts = TMA.RecentGroupInterruptCasts or {}
local activeCombatCasts = {}
local interruptCastCacheUnits = { "target", "focus", "mouseover" }
local INTERRUPT_CAST_CACHE_WINDOW = 8
TMA.TargetFocusCastCache = TMA.TargetFocusCastCache or {}
local lastActionCooldownStarts = {}
local announcedInterruptCooldowns = {}
local MIN_INTERRUPT_ANNOUNCE_COOLDOWN_DURATION = 2
local INTERRUPT_COOLDOWN_DETECTION_WINDOW = 4
local INTERRUPT_COOLDOWN_START_TOLERANCE = 1

local interruptSpellIDs = {
    [1766] = true, -- Kick
    [2139] = true, -- Counterspell
    [6552] = true, -- Pummel
    [31935] = true, -- Avenger's Shield
    [96231] = true, -- Rebuke
    [47528] = true, -- Mind Freeze
    [183752] = true, -- Disrupt
    [106839] = true, -- Skull Bash
    [78675] = true, -- Solar Beam
    [116705] = true, -- Spear Hand Strike
    [15487] = true, -- Silence
    [57994] = true, -- Wind Shear
    [147362] = true, -- Counter Shot
    [187707] = true, -- Muzzle
    [351338] = true, -- Quell
    [19647] = true, -- Spell Lock
    [119910] = true, -- Spell Lock
    [132409] = true, -- Spell Lock
    [171138] = true, -- Shadow Lock
    [171139] = true, -- Shadow Lock
    [171140] = true, -- Shadow Lock
    [115781] = true, -- Optical Blast
    [347008] = true, -- Axe Toss
    [89766] = true, -- Axe Toss
    [119914] = true, -- Axe Toss
}

local interruptStopSpellIDs = {
    [108194] = true, -- Asphyxiate
    [207167] = true, -- Blinding Sleet
    [49576] = true, -- Death Grip
    [179057] = true, -- Chaos Nova
    [211881] = true, -- Fel Eruption
    [202137] = true, -- Sigil of Silence
    [207684] = true, -- Sigil of Misery
    [217832] = true, -- Imprison
    [5211] = true, -- Mighty Bash
    [99] = true, -- Incapacitating Roar
    [132469] = true, -- Typhoon
    [22570] = true, -- Maim
    [19577] = true, -- Intimidation
    [187650] = true, -- Freezing Trap
    [213691] = true, -- Scatter Shot
    [186387] = true, -- Bursting Shot
    [31661] = true, -- Dragon's Breath
    [157981] = true, -- Blast Wave
    [157980] = true, -- Supernova
    [113724] = true, -- Ring of Frost
    [119381] = true, -- Leg Sweep
    [115078] = true, -- Paralysis
    [116844] = true, -- Ring of Peace
    [324312] = true, -- Clash
    [853] = true, -- Hammer of Justice
    [115750] = true, -- Blinding Light
    [20066] = true, -- Repentance
    [8122] = true, -- Psychic Scream
    [88625] = true, -- Holy Word: Chastise
    [64044] = true, -- Psychic Horror
    [408] = true, -- Kidney Shot
    [1833] = true, -- Cheap Shot
    [2094] = true, -- Blind
    [1776] = true, -- Gouge
    [192058] = true, -- Capacitor Totem
    [51490] = true, -- Thunderstorm
    [197214] = true, -- Sundering
    [51514] = true, -- Hex
    [30283] = true, -- Shadowfury
    [6789] = true, -- Mortal Coil
    [5484] = true, -- Howl of Terror
    [5782] = true, -- Fear
    [107570] = true, -- Storm Bolt
    [46968] = true, -- Shockwave
    [5246] = true, -- Intimidating Shout
    [360806] = true, -- Sleep Walk
    [357214] = true, -- Wing Buffet
    [368970] = true, -- Tail Swipe
    [20549] = true, -- War Stomp
    [107079] = true, -- Quaking Palm
    [255654] = true, -- Bull Rush
    [287712] = true, -- Haymaker
}

local knownNonInterruptSpellIDs = {
    [122] = true, -- Frost Nova: root effect; tooltip mentions breaking the freeze, not interrupting spellcasts.
}

TMA.InterruptOptions = {
    DEATHKNIGHT = {
        [250] = { 47528 },
        [251] = { 47528 },
        [252] = { 47528 },
    },
    DEMONHUNTER = {
        [577] = { 183752 },
        [581] = { 183752 },
    },
    DRUID = {
        [102] = { 78675 },
        [103] = { 106839 },
        [104] = { 106839 },
        [105] = { 106839 },
    },
    EVOKER = {
        [1467] = { 351338 },
        [1468] = { 351338 },
        [1473] = { 351338 },
    },
    HUNTER = {
        [253] = { 147362 },
        [254] = { 147362 },
        [255] = { 187707 },
    },
    MAGE = {
        [62] = { 2139 },
        [63] = { 2139 },
        [64] = { 2139 },
    },
    MONK = {
        [268] = { 116705 },
        [269] = { 116705 },
        [270] = { 116705 },
    },
    PALADIN = {
        [65] = { 96231 },
        [66] = { 96231 },
        [70] = { 96231 },
    },
    PRIEST = {
        [258] = { 15487 },
    },
    ROGUE = {
        [259] = { 1766 },
        [260] = { 1766 },
        [261] = { 1766 },
    },
    SHAMAN = {
        [262] = { 57994 },
        [263] = { 57994 },
        [264] = { 57994 },
    },
    WARLOCK = {
        [265] = { 19647, 119910 },
        [266] = { 19647, 119910 },
        [267] = { 19647, 119910 },
    },
    WARRIOR = {
        [71] = { 6552 },
        [72] = { 6552 },
        [73] = { 6552 },
    },
}

function TMA.IsInterruptSpellID(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return false end
    if knownNonInterruptSpellIDs[spellID] then return false end
    for knownSpellID in pairs(interruptSpellIDs) do
        if TMA.SafeValueEquals(knownSpellID, spellID) then return true end
    end
    for knownSpellID in pairs(interruptStopSpellIDs) do
        if TMA.SafeValueEquals(knownSpellID, spellID) then return true end
    end
    local custom = TargetMarkerAssistantDB and TargetMarkerAssistantDB.customInterruptSpellIDs
    if type(custom) == "table" then
        for customSpellID, enabled in pairs(custom) do
            local numericCustomSpellID = TMA.SafeNumber(customSpellID)
            if enabled
                and numericCustomSpellID
                and not knownNonInterruptSpellIDs[numericCustomSpellID]
                and (interruptSpellIDs[numericCustomSpellID] or interruptStopSpellIDs[numericCustomSpellID])
                and TMA.SafeValueEquals(numericCustomSpellID, spellID)
            then
                return true
            end
        end
    end

    return false
end

function TMA.IsHardInterruptSpellID(spellID)
    spellID = TMA.SafeNumber(spellID)
    return spellID and interruptSpellIDs[spellID] == true
end

function TMA.IsInterruptStopSpellID(spellID)
    spellID = TMA.SafeNumber(spellID)
    return spellID and interruptStopSpellIDs[spellID] == true
end

function TMA.SafeValueEquals(left, right)
    if left == nil or right == nil then return false end
    local ok, same = pcall(function()
        return left == right
    end)
    return ok and same == true
end

function TMA.SafeDebugText(value, fallback)
    if value == nil then return fallback or "" end
    if type(value) == "number" or type(value) == "boolean" then
        return tostring(value)
    end
    if type(value) == "string" and TMA.IsPublicChatText and TMA.IsPublicChatText(value) then
        return value
    end

    local ok, text = pcall(function()
        return tostring(value)
    end)
    if ok and type(text) == "string" and TMA.IsPublicChatText and TMA.IsPublicChatText(text) then
        return text
    end
    return fallback or "<protected>"
end

function TMA.GetPlainTextValue(value)
    if value == nil then return nil end
    local ok, text = pcall(tostring, value)
    if not ok then
        return nil
    end

    return text
end

function TMA.SafeNumber(value)
    local ok, numberValue = pcall(tonumber, value)
    if ok and type(numberValue) == "number" then return numberValue end
end

function TMA.ReadUnitCastingInfoSnapshot(unit)
    if not unit or not UnitCastingInfo then return nil end

    local ok, first, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellID = pcall(UnitCastingInfo, unit)
    if not ok or first == nil then return nil end

    local snapshot = {
        active = true,
        castType = "cast",
        startTimeMS = 0,
        endTimeMS = 0,
        castID = 0,
    }

    if type(first) == "table" then
        local okFields, tableSpellID, tableStartTimeMS, tableEndTimeMS, tableCastID = pcall(function()
            return first.spellID, first.startTimeMS, first.endTimeMS, first.castID
        end)
        if okFields then
            snapshot.spellID = TMA.SafeNumber(tableSpellID)
            snapshot.startTimeMS = TMA.SafeNumber(tableStartTimeMS) or 0
            snapshot.endTimeMS = TMA.SafeNumber(tableEndTimeMS) or 0
            snapshot.castID = TMA.SafeNumber(tableCastID) or 0
        end
    else
        snapshot.spellID = TMA.SafeNumber(spellID)
        snapshot.startTimeMS = TMA.SafeNumber(startTimeMS) or 0
        snapshot.endTimeMS = TMA.SafeNumber(endTimeMS) or 0
        snapshot.castID = TMA.SafeNumber(castID) or 0
    end

    return snapshot
end

function TMA.ReadUnitChannelInfoSnapshot(unit)
    if not unit or not UnitChannelInfo then return nil end

    local ok, first, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellID = pcall(UnitChannelInfo, unit)
    if not ok or first == nil then return nil end

    local snapshot = {
        active = true,
        castType = "channel",
        startTimeMS = 0,
        endTimeMS = 0,
        castID = 0,
    }

    if type(first) == "table" then
        local okFields, tableSpellID, tableStartTimeMS, tableEndTimeMS, tableCastID = pcall(function()
            return first.spellID, first.startTimeMS, first.endTimeMS, first.castID
        end)
        if okFields then
            snapshot.spellID = TMA.SafeNumber(tableSpellID)
            snapshot.startTimeMS = TMA.SafeNumber(tableStartTimeMS) or 0
            snapshot.endTimeMS = TMA.SafeNumber(tableEndTimeMS) or 0
            snapshot.castID = TMA.SafeNumber(tableCastID) or 0
        end
    else
        snapshot.spellID = TMA.SafeNumber(spellID)
        snapshot.startTimeMS = TMA.SafeNumber(startTimeMS) or 0
        snapshot.endTimeMS = TMA.SafeNumber(endTimeMS) or 0
    end

    return snapshot
end

function TMA.SafeIsNonEmptyText(value)
    if value == nil then return false end
    local ok, nonEmpty = pcall(function()
        return tostring(value) ~= ""
    end)
    return ok and nonEmpty == true
end

function TMA.IsPublicChatText(value)
    if type(value) ~= "string" then return false end
    local okEmpty, isEmpty = pcall(function()
        return value == ""
    end)
    if not okEmpty then return false end
    if isEmpty then return true end

    if not TMA.PublicTextProbe and UIParent and CreateFrame then
        local okCreate, probe = pcall(function()
            local probeFrame = CreateFrame("Frame")
            probeFrame:Hide()
            return probeFrame:CreateFontString(nil, "BACKGROUND", "GameFontNormal")
        end)
        if okCreate then
            TMA.PublicTextProbe = probe
        end
    end

    local probe = TMA.PublicTextProbe
    if not probe or not probe.SetText then
        return false
    end

    local ok = pcall(probe.SetText, probe, value)
    pcall(probe.SetText, probe, "")
    return ok == true
end

function TMA.GetPublicChatText(value, fallback)
    local text = TMA.GetPlainTextValue(value)
    if TMA.SafeValueEquals(text, "<protected>") then
        text = nil
    end
    if text and TMA.IsPublicChatText(text) then return text end

    text = TMA.GetPlainTextValue(fallback)
    if TMA.SafeValueEquals(text, "<protected>") then
        text = nil
    end
    if text and TMA.IsPublicChatText(text) then return text end
    return ""
end

function TMA.GetSpellInfoName(info)
    if type(info) ~= "table" then return nil end
    return TMA.GetPlainTextValue(info.name)
end

local function IsSameCombatName(left, right)
    if not left or not right then return false end

    local ok, matched = pcall(function()
        if Ambiguate then
            left = Ambiguate(left, "none")
            right = Ambiguate(right, "none")
        end

        local leftShort = left:match("^[^-]+") or left
        local rightShort = right:match("^[^-]+") or right
        return left == right or leftShort == rightShort
    end)
    return ok and matched == true
end

local function IsMineCombatLogSource(sourceGUID, sourceName, sourceFlags)
    local playerGUID = UnitGUID("player")
    local petGUID = UnitGUID("pet")
    local vehicleGUID = UnitGUID("vehicle")
    if TMA.SafeValueEquals(sourceGUID, playerGUID)
        or TMA.SafeValueEquals(sourceGUID, petGUID)
        or TMA.SafeValueEquals(sourceGUID, vehicleGUID)
    then
        return true
    end

    if TMA.CombatFlagsContain and TMA.CombatFlagsContain(sourceFlags, COMBATLOG_OBJECT_AFFILIATION_MINE) then
        return true
    end

    if IsSameCombatName(sourceName, UnitName("player")) then
        return true
    end

    return false
end

function TMA.GetSpellNameByID(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return nil end
    if C_Spell and C_Spell.GetSpellInfo then
        local ok, info = pcall(C_Spell.GetSpellInfo, spellID)
        local name = ok and TMA.GetSpellInfoName(info)
        if name then
            return name
        end
    end
    if C_Spell and C_Spell.GetSpellLink then
        local ok, name = pcall(function()
            local link = C_Spell.GetSpellLink(spellID)
            if type(link) == "string" then
                return link:match("|h%[(.-)%]|h")
            end
        end)
        name = TMA.GetPlainTextValue(name)
        if name then
            return name
        end
    end
    if GetSpellInfo then
        local ok, name = pcall(GetSpellInfo, spellID)
        name = ok and TMA.GetPlainTextValue(name)
        if name then
            return name
        end
    end
    if GetSpellLink then
        local ok, name = pcall(function()
            local link = GetSpellLink(spellID)
            if type(link) == "string" then
                return link:match("|h%[(.-)%]|h")
            end
        end)
        name = TMA.GetPlainTextValue(name)
        if name then
            return name
        end
    end
end

function TMA.GetSpellDisplayInfo(spellID, fallbackName)
    spellID = TMA.SafeNumber(spellID)
    local display = {
        name = fallbackName,
        icon = 134400,
        spellID = spellID,
    }

    if spellID and C_Spell and C_Spell.GetSpellInfo then
        local ok, info = pcall(C_Spell.GetSpellInfo, spellID)
        if ok and type(info) == "table" then
            display.name = TMA.GetSpellInfoName(info) or display.name
            display.icon = info.iconID or info.iconFileID or info.icon or display.icon
            display.spellID = TMA.SafeNumber(info.spellID) or spellID
        end
    end

    return display
end

function TMA.GetPlayerClassFile()
    if UnitClass then
        local _, classFile = UnitClass("player")
        return classFile
    end
end

function TMA.GetPlayerSpecID()
    if not GetSpecialization or not GetSpecializationInfo then return nil end
    local specIndex = GetSpecialization()
    if not specIndex then return nil end
    local specID = GetSpecializationInfo(specIndex)
    return tonumber(specID)
end

function TMA.AddInterruptOption(items, seen, spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID or seen[spellID] then return end

    local spellName = TMA.GetSpellNameByID(spellID)
    if not spellName then return end

    seen[spellID] = true
    items[#items + 1] = {
        text = string.format("%s（%d）", spellName, spellID),
        value = spellID,
        spellID = spellID,
        spellName = spellName,
    }
end

function TMA.GetInterruptOptionsForCurrentSpec()
    local items = {}
    local seen = {}
    local classFile = TMA.GetPlayerClassFile()
    local specID = TMA.GetPlayerSpecID()
    local classOptions = classFile and TMA.InterruptOptions[classFile]
    if not classOptions then return items end

    local specOptions = specID and classOptions[specID]
    if specOptions then
        for _, spellID in ipairs(specOptions) do
            TMA.AddInterruptOption(items, seen, spellID)
        end
        return items
    end

    for _, spellIDs in pairs(classOptions) do
        for _, spellID in ipairs(spellIDs) do
            TMA.AddInterruptOption(items, seen, spellID)
        end
    end

    return items
end

function TMA.PlayerKnowsSpell(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return false end

    if C_SpellBook then
        if C_SpellBook.IsSpellKnownOrOverridesKnown then
            local ok, known = pcall(C_SpellBook.IsSpellKnownOrOverridesKnown, spellID)
            if ok and known then return true end
        end
        if C_SpellBook.IsSpellKnown then
            local ok, known = pcall(C_SpellBook.IsSpellKnown, spellID)
            if ok and known then return true end
        end
    end
    if IsPlayerSpell then
        local ok, known = pcall(IsPlayerSpell, spellID)
        if ok and known then return true end
    end
    if IsSpellKnown then
        local ok, known = pcall(IsSpellKnown, spellID, false)
        if ok and known then return true end
    end
    if GetNumSpellTabs and GetSpellTabInfo and GetSpellBookItemInfo then
        local ok, known = pcall(function()
            for tabIndex = 1, GetNumSpellTabs() do
                local _, _, offset, numSlots = GetSpellTabInfo(tabIndex)
                offset = tonumber(offset) or 0
                numSlots = tonumber(numSlots) or 0
                for slot = offset + 1, offset + numSlots do
                    local _, bookSpellID = GetSpellBookItemInfo(slot, BOOKTYPE_SPELL)
                    if tonumber(bookSpellID) == spellID then
                        return true
                    end
                end
            end
            return false
        end)
        if ok and known then return true end
    end

    return false
end

function TMA.SaveInterruptSpell(spellID, spellName, input)
    if not TargetMarkerAssistantDB then return false end
    spellID = TMA.SafeNumber(spellID)
    spellName = spellName or TMA.GetSpellNameByID(spellID)
    if not spellID or not spellName then return false end

    TargetMarkerAssistantDB.interruptSpellID = spellID
    TargetMarkerAssistantDB.interruptSpellName = spellName
    if input ~= nil then
        TargetMarkerAssistantDB.interruptSpellInput = input
    end
    if TMA.ResetInterruptCooldownReadyAlert then
        TMA.ResetInterruptCooldownReadyAlert()
    end
    if TMA.QueueInterruptFocusMacroUpdate then
        TMA.QueueInterruptFocusMacroUpdate()
    end

    return true
end

function TMA.AutoDetectInterruptSpell(force)
    if not TargetMarkerAssistantDB then return false end
    if not force and TargetMarkerAssistantDB.interruptSpellMode ~= "auto" then return false end

    local options = TMA.GetInterruptOptionsForCurrentSpec()
    for _, option in ipairs(options) do
        if TMA.PlayerKnowsSpell(option.spellID) then
            local saved = TMA.SaveInterruptSpell(option.spellID, option.spellName, "")
            if saved then
                TMA.DebugInterrupt("自动识别打断技能成功：技能ID=" .. TMA.SafeDebugText(option.spellID, "") .. "，技能名=" .. TMA.SafeDebugText(option.spellName, ""))
                return true
            end
        end
    end

    for spellID in pairs(interruptSpellIDs) do
        if TMA.PlayerKnowsSpell(spellID) then
            local spellName = TMA.GetSpellNameByID(spellID)
            local saved = TMA.SaveInterruptSpell(spellID, spellName, "")
            if saved then
                TMA.DebugInterrupt("自动扫描法术书识别打断技能成功：技能ID=" .. TMA.SafeDebugText(spellID, "") .. "，技能名=" .. TMA.SafeDebugText(spellName, ""))
                return true
            end
        end
    end

    TMA.DebugInterrupt("自动识别打断技能未命中，将改用玩家施法/冷却事件识别。")
    return false
end

function TMA.NormalizeSpellName(name)
    name = TMA.GetPublicChatText(name, "")
    if name == "" then return "" end

    local ok, normalized = pcall(function()
        local plain = name:match("%[(.-)%]") or name
        plain = plain:gsub("^%s+", ""):gsub("%s+$", "")
        return plain:lower()
    end)
    if ok and normalized then return normalized end
    return ""
end

function TMA.GetConfiguredInterruptSpellName()
    if not TargetMarkerAssistantDB then return "" end

    local mode = TargetMarkerAssistantDB.interruptSpellMode or "auto"
    local name
    if mode == "manual" then
        name = TargetMarkerAssistantDB.interruptSpellInput or TargetMarkerAssistantDB.interruptSpellName
    else
        name = TargetMarkerAssistantDB.interruptSpellName or TargetMarkerAssistantDB.interruptSpellInput
    end
    name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "")
    return name
end

local function FindInterruptSpellIDByName(spellName)
    local normalized = TMA.NormalizeSpellName(spellName)
    if normalized == "" then return nil end

    local function scanIDs(ids)
        if type(ids) ~= "table" then return nil end
        for id, enabled in pairs(ids) do
            if enabled then
                local name = TMA.GetSpellNameByID(id)
                if TMA.NormalizeSpellName(name) == normalized then
                    return tonumber(id)
                end
            end
        end
    end

    return scanIDs(interruptSpellIDs)
        or scanIDs(interruptStopSpellIDs)
        or scanIDs(TargetMarkerAssistantDB and TargetMarkerAssistantDB.customInterruptSpellIDs)
end

function TMA.IsConfiguredInterruptSpell(spellID, spellName)
    local configuredID = TargetMarkerAssistantDB and TMA.SafeNumber(TargetMarkerAssistantDB.interruptSpellID)
    if configuredID and TMA.SafeValueEquals(TMA.SafeNumber(spellID), configuredID) then
        return true
    end

    local configured = TMA.GetConfiguredInterruptSpellName()
    if configured == "" then return false end

    local actual = spellName or TMA.GetSpellNameByID(spellID)
    if not actual then return false end

    configured = TMA.NormalizeSpellName(configured)
    local ok, normalizedActual = pcall(function()
        local actualText = TMA.GetPlainTextValue(actual)
        if not TMA.SafeIsNonEmptyText(actualText) then return nil end
        return TMA.NormalizeSpellName(actualText)
    end)
    if not ok or not normalizedActual then return false end

    local matched = false
    ok = pcall(function()
        matched = configured == normalizedActual
            or normalizedActual:find(configured, 1, true)
            or configured:find(normalizedActual, 1, true)
    end)

    return ok and matched or false
end

local function ResolveSucceededSpell(unit, castGUID, spellID, ...)
    local okSpellID, numericSpellID = pcall(tonumber, spellID)
    spellID = okSpellID and numericSpellID or nil
    local spellName = spellID and TMA.GetSpellNameByID(spellID)
    if spellID or spellName then
        return spellID, spellName
    end

    local function resolveValue(value)
        local okID, candidateID = pcall(tonumber, value)
        if not okID then
            candidateID = nil
        end

        if candidateID then
            local candidateName = TMA.GetSpellNameByID(candidateID)
            if candidateName then
                return candidateID, candidateName
            end
        elseif type(value) == "string" then
            local okText, text = pcall(function()
                local raw = tostring(value)
                if raw == "" or raw:find("^Cast%-") then return nil end
                return raw
            end)

            if okText and text then
                local normalized = TMA.NormalizeSpellName(text)
                local configured = TMA.NormalizeSpellName(TMA.GetConfiguredInterruptSpellName())
                if configured ~= "" and (normalized == configured or normalized:find(configured, 1, true) or configured:find(normalized, 1, true)) then
                    return FindInterruptSpellIDByName(text), text
                end
            end
        end
    end

    for i = 1, select("#", ...) do
        local candidateID, candidateName = resolveValue(select(i, ...))
        if candidateID or candidateName then
            return candidateID, candidateName
        end
    end
end

local INTERRUPT_COOLDOWN_FALLBACK = {
    [2139] = 24,    -- Counterspell
    [1766] = 15,    -- Kick
    [6552] = 15,    -- Pummel
    [31935] = 15,   -- Avenger's Shield
    [96231] = 15,   -- Rebuke
    [57994] = 12,   -- Wind Shear
    [47528] = 15,   -- Mind Freeze
    [183752] = 15,  -- Disrupt
    [116705] = 15,  -- Spear Hand Strike
    [106839] = 15,  -- Skull Bash
    [78675] = 60,   -- Solar Beam
    [15487] = 45,   -- Silence
    [147362] = 24,  -- Counter Shot
    [187707] = 15,  -- Muzzle
    [351338] = 40,  -- Quell
    [19647] = 24,   -- Spell Lock
    [119910] = 24,  -- Spell Lock
    [132409] = 24,  -- Spell Lock
    [171138] = 24,  -- Shadow Lock
    [171139] = 24,  -- Shadow Lock
    [171140] = 24,  -- Shadow Lock
    [115781] = 24,  -- Optical Blast
    [347008] = 30,  -- Axe Toss
    [89766] = 30,   -- Axe Toss
    [119914] = 30,  -- Axe Toss
    [20549] = 90,   -- War Stomp
    [107079] = 120, -- Quaking Palm
    [255654] = 120, -- Bull Rush
    [287712] = 150, -- Haymaker
}

function TMA.IsInterruptAnnounceContextValid()
    return IsInGroup()
end

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

function TMA.CombatFlagsContain(sourceFlags, flag)
    if not bitBand or not sourceFlags or not flag then return false end
    local ok, matched = pcall(function()
        return bitBand(sourceFlags, flag) ~= 0
    end)
    return ok and matched == true
end

function TMA.GetGroupUnitByName(name)
    local ok, unit = pcall(function()
        if not name then return nil end
        if IsSameCombatName(name, UnitName("player")) then return "player" end

        for i = 1, 4 do
            local partyUnit = "party" .. i
            if UnitExists(partyUnit) and IsSameCombatName(name, UnitName(partyUnit)) then
                return partyUnit
            end
        end

        for i = 1, 40 do
            local raidUnit = "raid" .. i
            if UnitExists(raidUnit) and IsSameCombatName(name, UnitName(raidUnit)) then
                return raidUnit
            end
        end
    end)
    if ok then return unit end
end

function TMA.GetGroupUnitByCombatSource(sourceGUID, sourceName, sourceFlags)
    if TMA.CombatFlagsContain(sourceFlags, COMBATLOG_OBJECT_AFFILIATION_MINE) then
        return "player"
    end

    local ok, guidUnit = pcall(GetGroupUnitByGUID, sourceGUID)
    if ok and guidUnit then return guidUnit end

    local unit = TMA.GetGroupUnitByName(sourceName)
    if unit then return unit end
end

function TMA.IsPlayerCombatSource(sourceGUID, sourceName, sourceFlags)
    if IsMineCombatLogSource(sourceGUID, sourceName, sourceFlags) then return true end
    return TMA.GetGroupUnitByCombatSource(sourceGUID, sourceName, sourceFlags) == "player"
end

local function GetShortPlayerName(fullName)
    local ok, shortName = pcall(function()
        fullName = TMA.GetPlainTextValue(fullName) or UNKNOWN or "Unknown"
        if Ambiguate then
            local name = Ambiguate(fullName, "short")
            if TMA.SafeIsNonEmptyText(name) then
                return name
            end
        end
        if strsplit then
            local name = strsplit("-", fullName)
            if TMA.SafeIsNonEmptyText(name) then
                return name
            end
        end
        return fullName:match("^[^-]+") or fullName
    end)
    if ok and TMA.SafeIsNonEmptyText(shortName) then
        return shortName
    end
    return UNKNOWN or "Unknown"
end

function TMA.GetClassFileByUnit(unit)
    if not unit then return nil end
    if UnitClassBase then
        local classFile = UnitClassBase(unit)
        if classFile then return classFile end
    end
    if UnitClass then
        local _, classFile = UnitClass(unit)
        return classFile
    end
end

local function GetClassFileByGUID(guid)
    return TMA.GetClassFileByUnit(GetGroupUnitByGUID(guid))
end

local function ColorizePlayerName(shortName, classFile)
    shortName = TMA.GetPlainTextValue(shortName) or UNKNOWN or "Unknown"
    if not TargetMarkerAssistantDB.showClassColorInLocalNotice then
        return shortName
    end

    local color
    if classFile and C_ClassColor and C_ClassColor.GetClassColor then
        local ok, result = pcall(C_ClassColor.GetClassColor, classFile)
        if ok then color = result end
    end
    if not color and classFile and RAID_CLASS_COLORS then
        color = RAID_CLASS_COLORS[classFile]
    end
    if not color then return shortName end

    local hex
    if color.GenerateHexColor then
        local ok, result = pcall(color.GenerateHexColor, color)
        if ok then hex = result end
    end
    if not hex and color.r and color.g and color.b then
        hex = string.format("ff%02x%02x%02x", math.floor(color.r * 255), math.floor(color.g * 255), math.floor(color.b * 255))
    end
    if not hex then return shortName end

    if #hex == 6 then hex = "ff" .. hex end
    return "|c" .. hex .. shortName .. "|r"
end

function TMA.GetInterruptSourceDisplayName(sourceGUID, sourceName, sourceFlags)
    if TMA.IsPlayerCombatSource(sourceGUID, nil, sourceFlags) then
        return GetShortPlayerName(UnitName and UnitName("player") or L["UNKNOWN_PLAYER"] or "玩家")
    end

    local unit = GetGroupUnitByGUID(sourceGUID)
    if unit and UnitName then
        local ok, name = pcall(UnitName, unit)
        if ok and TMA.SafeIsNonEmptyText(name) then
            return GetShortPlayerName(name)
        end
    end

    local safeName = GetShortPlayerName(sourceName)
    if TMA.SafeIsNonEmptyText(safeName)
        and not TMA.SafeValueEquals(safeName, UNKNOWN or "Unknown")
        and not TMA.SafeValueEquals(safeName, "Unknown")
    then
        return safeName
    end
    return L["UNKNOWN_PLAYER"] or "队友"
end

local function ResolveConfiguredInterruptSpell(input)
    input = tostring(input or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if input == "" then return nil, nil end

    local numericID = tonumber(input)
    if numericID then
        if C_Spell and C_Spell.GetSpellInfo then
            local ok, info = pcall(C_Spell.GetSpellInfo, numericID)
            local name = ok and TMA.GetSpellInfoName(info)
            if name then
                return tonumber(info.spellID) or numericID, name
            end
        end
        return numericID, TMA.GetSpellNameByID(numericID) or tostring(numericID)
    end

    if C_Spell and C_Spell.GetSpellInfo then
        local ok, info = pcall(C_Spell.GetSpellInfo, input)
        local name = ok and TMA.GetSpellInfoName(info)
        if name then
            return tonumber(info.spellID), name
        end
    end

    local spellID = FindInterruptSpellIDByName(input)
    if spellID then
        return spellID, TMA.GetSpellNameByID(spellID) or input
    end

    return nil, nil
end

local function SetConfiguredInterruptSpellInput(input)
    if not TargetMarkerAssistantDB then return end
    input = tostring(input or ""):gsub("^%s+", ""):gsub("%s+$", "")
    TargetMarkerAssistantDB.interruptSpellMode = "manual"
    TargetMarkerAssistantDB.interruptSpellInput = input
    TargetMarkerAssistantDB.interruptSpellID = nil
    TargetMarkerAssistantDB.interruptSpellName = nil

    if input == "" then
        if RefreshOptionsPanel then RefreshOptionsPanel() end
        return
    end

    local spellID, spellName = ResolveConfiguredInterruptSpell(input)
    if not spellName then
        PrintLocalMessage(L["INVALID_SPELL_ID"] or "技能 ID 无效")
        if RefreshOptionsPanel then RefreshOptionsPanel() end
        return
    end

    TMA.SaveInterruptSpell(spellID, spellName, input)
    if RefreshOptionsPanel then RefreshOptionsPanel() end
end

local function GetConfiguredInterruptSpellID()
    if not TargetMarkerAssistantDB then return nil end
    local mode = TargetMarkerAssistantDB.interruptSpellMode or "auto"
    if TargetMarkerAssistantDB.interruptSpellID then
        return tonumber(TargetMarkerAssistantDB.interruptSpellID)
    end

    if mode == "auto" then
        if TMA.AutoDetectInterruptSpell(true) and TargetMarkerAssistantDB.interruptSpellID then
            return tonumber(TargetMarkerAssistantDB.interruptSpellID)
        end
        return nil
    end

    local spellID, spellName = ResolveConfiguredInterruptSpell(TargetMarkerAssistantDB.interruptSpellInput)
    if spellID then
        TargetMarkerAssistantDB.interruptSpellID = spellID
        TargetMarkerAssistantDB.interruptSpellName = spellName
        return spellID
    end
end

local updatingInterruptFocusMacro = false

function TMA.GetInterruptFocusMacroName()
    local locale = GetLocale and GetLocale() or ""
    if locale == "zhCN" or locale == "zhTW" then
        return "断"
    end
    return "INT"
end

local function GetInterruptFocusMacroAlternateName()
    local current = TMA.GetInterruptFocusMacroName()
    return current == "INT" and "断" or "INT"
end

local function GetInterruptFocusMacroSpell()
    if not TargetMarkerAssistantDB then return nil, nil end

    local spellID = TMA.SafeNumber(TargetMarkerAssistantDB.interruptSpellID)
    if not spellID and TargetMarkerAssistantDB.interruptSpellMode == "auto" and TMA.AutoDetectInterruptSpell then
        TMA.AutoDetectInterruptSpell(true)
        spellID = TMA.SafeNumber(TargetMarkerAssistantDB.interruptSpellID)
    end

    local display = TMA.GetSpellDisplayInfo(
        spellID,
        TargetMarkerAssistantDB.interruptSpellName or TargetMarkerAssistantDB.interruptSpellInput
    )
    local spellName = TMA.GetPublicChatText(display and display.name, "")
    if spellName == "" then
        spellName = TMA.GetPublicChatText(TargetMarkerAssistantDB.interruptSpellName or TargetMarkerAssistantDB.interruptSpellInput, "")
    end
    if spellName == "" or (TMA.IsUnknownInterruptedSpellName and TMA.IsUnknownInterruptedSpellName(spellName)) then
        return nil, nil
    end

    return spellName, display and display.icon or 134400
end

local function BuildInterruptFocusMacroText(spellName)
    return "#showtooltip " .. spellName
        .. "\n/stopcasting"
        .. "\n/cast [mod:alt,@target,exists,harm,nodead] " .. spellName
        .. "; [@focus,exists,harm,nodead] " .. spellName
        .. "; [@target,exists,harm,nodead] " .. spellName
end

local function FindMacroIndexByName(macroName)
    if not GetMacroInfo or not macroName then return nil end

    local accountMacroLimit = MAX_ACCOUNT_MACROS or 120
    local characterMacroLimit = MAX_CHARACTER_MACROS or 18
    for index = 1, accountMacroLimit do
        local name = GetMacroInfo(index)
        if name == macroName then
            return index
        end
    end

    for index = accountMacroLimit + 1, accountMacroLimit + characterMacroLimit do
        local name = GetMacroInfo(index)
        if name == macroName then
            return index
        end
    end
end

local function FindInterruptFocusMacroIndex()
    local index = FindMacroIndexByName(TMA.GetInterruptFocusMacroName())
    if index then return index end
    if TargetMarkerAssistantDB and TargetMarkerAssistantDB.interruptFocusMacroCreated then
        return FindMacroIndexByName(GetInterruptFocusMacroAlternateName())
    end
end

local function EnsureInterruptFocusMacro(createIfMissing, silent)
    if updatingInterruptFocusMacro then return nil end
    if not TargetMarkerAssistantDB then return nil end
    if not CreateMacro or not EditMacro or not PickupMacro then
        if not silent then PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_UNAVAILABLE"] or "当前客户端不支持创建宏。") end
        return nil
    end
    if InCombatLockdown and InCombatLockdown() then
        pendingInterruptFocusMacroUpdate = true
        if not silent then PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_COMBAT_LOCKED"] or "战斗中无法创建或更新宏，脱战后会自动更新。") end
        return nil
    end

    updatingInterruptFocusMacro = true

    local spellName, spellIcon = GetInterruptFocusMacroSpell()
    if not spellName then
        updatingInterruptFocusMacro = false
        if not silent then PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_NO_SPELL"] or "当前没有可用的打断技能。") end
        return nil
    end

    local macroName = TMA.GetInterruptFocusMacroName()
    local macroText = BuildInterruptFocusMacroText(spellName)
    local icon = spellIcon or 134400
    local macroIndex = FindInterruptFocusMacroIndex()

    if macroIndex then
        local ok = pcall(EditMacro, macroIndex, macroName, icon, macroText, true)
        if not ok then
            ok = pcall(EditMacro, macroIndex, macroName, icon, macroText)
        end
        if not ok then
            updatingInterruptFocusMacro = false
            if not silent then PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_UPDATE_FAILED"] or "无法更新焦点打断宏。") end
            return nil
        end
        TargetMarkerAssistantDB.interruptFocusMacroCreated = true
        pendingInterruptFocusMacroUpdate = false
        updatingInterruptFocusMacro = false
        return FindMacroIndexByName(macroName) or macroIndex
    end

    if not createIfMissing then
        TargetMarkerAssistantDB.interruptFocusMacroCreated = false
        pendingInterruptFocusMacroUpdate = false
        updatingInterruptFocusMacro = false
        return nil
    end

    if GetNumMacros then
        local _, characterCount = GetNumMacros()
        if (tonumber(characterCount) or 0) >= (MAX_CHARACTER_MACROS or 18) then
            updatingInterruptFocusMacro = false
            if not silent then PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_FULL"] or "角色专用宏栏位已满，无法创建焦点打断宏。") end
            return nil
        end
    end

    local okCreate, createdIndex = pcall(CreateMacro, macroName, icon, macroText, true)
    if not okCreate or not createdIndex then
        updatingInterruptFocusMacro = false
        if not silent then PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_CREATE_FAILED"] or "无法创建焦点打断宏。") end
        return nil
    end

    TargetMarkerAssistantDB.interruptFocusMacroCreated = true
    pendingInterruptFocusMacroUpdate = false
    updatingInterruptFocusMacro = false
    return FindMacroIndexByName(macroName) or createdIndex
end

function TMA.QueueInterruptFocusMacroUpdate()
    if not TargetMarkerAssistantDB then
        return
    end
    EnsureInterruptFocusMacro(false, true)
end

function TMA.PickupInterruptFocusMacro()
    local macroIndex = EnsureInterruptFocusMacro(true, false)
    if not macroIndex then return false end

    local okPickup = pcall(PickupMacro, macroIndex)
    if not okPickup then
        okPickup = pcall(PickupMacro, TMA.GetInterruptFocusMacroName())
    end
    if not okPickup then
        PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_PICKUP_FAILED"] or "无法拾取焦点打断宏。")
        return false
    end

    PrintLocalMessage(L["INTERRUPT_FOCUS_MACRO_READY"] or "已生成焦点打断宏，请拖放到动作条。")
    return true
end

local function NormalizeInterruptCooldownDuration(duration)
    duration = tonumber(duration)
    if duration and duration > 1.5 then
        return math.ceil(duration)
    end
end

local function GetInterruptCooldownReadyKey(spellID, startTime)
    spellID = TMA.SafeNumber(spellID)
    startTime = TMA.SafeNumber(startTime)
    if not spellID or not startTime or startTime <= 0 then return nil end
    return tostring(spellID) .. ":" .. string.format("%.3f", startTime)
end

local function GetInterruptCooldownReadyStartFromKey(spellID, cooldownKey)
    spellID = TMA.SafeNumber(spellID)
    if not spellID or not cooldownKey then return nil end

    local keySpellID, keyStartTime = tostring(cooldownKey):match("^(%d+):([%d%.]+)$")
    keySpellID = TMA.SafeNumber(keySpellID)
    keyStartTime = TMA.SafeNumber(keyStartTime)
    if keySpellID and keyStartTime and TMA.SafeValueEquals(keySpellID, spellID) then
        return keyStartTime
    end
end

local function EnsureInterruptCooldownCache()
    if not TargetMarkerAssistantDB then return nil end
    if type(TargetMarkerAssistantDB.interruptSpellCooldowns) ~= "table" then
        TargetMarkerAssistantDB.interruptSpellCooldowns = {}
    end
    return TargetMarkerAssistantDB.interruptSpellCooldowns
end

local function CacheInterruptCooldownDuration(spellID, duration)
    spellID = TMA.SafeNumber(spellID)
    local seconds = NormalizeInterruptCooldownDuration(duration)
    if not spellID or not seconds then return end

    local cache = EnsureInterruptCooldownCache()
    if cache then
        pcall(function()
            cache[spellID] = seconds
        end)
    end
end

local function GetCachedInterruptCooldownDuration(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return nil end

    local cache = EnsureInterruptCooldownCache()
    if not cache then return nil end

    local ok, duration = pcall(function()
        return cache[spellID] or cache[tostring(spellID)]
    end)
    if ok then
        return NormalizeInterruptCooldownDuration(duration)
    end
end

local function GetFallbackTableInterruptCooldown(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return nil end
    return NormalizeInterruptCooldownDuration(INTERRUPT_COOLDOWN_FALLBACK[spellID])
end

local function BuildCooldownSnapshot(startTime, duration, sourceSpellID, sourceType)
    local ok, snapshot = pcall(function()
        startTime = tonumber(startTime) or 0
        duration = tonumber(duration) or 0

        local remaining = 0
        if startTime > 0 and duration > 0 then
            remaining = startTime + duration - (GetTime and GetTime() or 0)
            if remaining < 0 then remaining = 0 end
        end

        return {
            startTime = startTime,
            duration = duration,
            remaining = math.ceil(remaining),
            sourceSpellID = TMA.SafeNumber(sourceSpellID),
            sourceType = sourceType,
        }
    end)

    if ok then
        return snapshot
    end
end

local function IsActiveCooldownSnapshot(snapshot)
    return snapshot
        and snapshot.startTime
        and snapshot.duration
        and snapshot.remaining
        and snapshot.startTime > 0
        and snapshot.duration >= MIN_INTERRUPT_ANNOUNCE_COOLDOWN_DURATION
        and snapshot.remaining > 0
end

local function ReadCachedLiveCooldownSnapshot(spellID)
    spellID = TMA.SafeNumber(spellID)
    local cache = TMA.InterruptCooldownLiveSnapshotCache
    if not spellID or type(cache) ~= "table" then return nil end

    local snapshot = cache[spellID]
    local now = GetTime and GetTime() or 0
    if not snapshot or not snapshot.at or now - snapshot.at > 0.5 then
        return nil
    end

    return true, snapshot.startTime, snapshot.duration, snapshot.remaining, snapshot.sourceSpellID, snapshot.sourceType
end

local function CacheLiveCooldownSnapshot(spellID, startTime, duration, remaining, sourceSpellID, sourceType)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return end

    TMA.InterruptCooldownLiveSnapshotCache = TMA.InterruptCooldownLiveSnapshotCache or {}
    TMA.InterruptCooldownLiveSnapshotCache[spellID] = {
        at = GetTime and GetTime() or 0,
        valid = true,
        startTime = startTime,
        duration = duration,
        remaining = remaining,
        sourceSpellID = sourceSpellID,
        sourceType = sourceType,
    }
end

local function GetLiveSpellCooldownInfo(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then
        return nil
    end

    local hasCached, cachedStart, cachedDuration, cachedRemaining, cachedSourceID, cachedSourceType = ReadCachedLiveCooldownSnapshot(spellID)
    if hasCached then
        return cachedStart, cachedDuration, cachedRemaining, cachedSourceID, cachedSourceType
    end

    local candidates = {}
    local seen = {}
    local function addCandidate(candidateID)
        candidateID = TMA.SafeNumber(candidateID)
        if candidateID and not seen[candidateID] then
            seen[candidateID] = true
            candidates[#candidates + 1] = candidateID
        end
    end

    addCandidate(spellID)

    if C_Spell and C_Spell.GetOverrideSpell then
        local okOverride, overrideID = pcall(C_Spell.GetOverrideSpell, spellID)
        if okOverride then
            addCandidate(overrideID)
        end
    end

    if FindSpellOverrideByID then
        local okOverride, overrideID = pcall(FindSpellOverrideByID, spellID)
        if okOverride then
            addCandidate(overrideID)
        end
    end

    if C_Spell and C_Spell.GetSpellInfo then
        local okInfo, info = pcall(C_Spell.GetSpellInfo, spellID)
        if okInfo and type(info) == "table" then
            addCandidate(info.spellID)
        end
    end

    local configuredName = TMA.NormalizeSpellName(TMA.GetConfiguredInterruptSpellName and TMA.GetConfiguredInterruptSpellName() or "")
    if configuredName == "" then
        configuredName = TMA.NormalizeSpellName(TMA.GetSpellNameByID(spellID))
    end

    local best
    local function remember(snapshot)
        if not snapshot then return end
        if IsActiveCooldownSnapshot(snapshot) then
            if not IsActiveCooldownSnapshot(best)
                or snapshot.remaining > best.remaining
                or snapshot.duration > best.duration
            then
                best = snapshot
            end
            return
        end

        if not best or (snapshot.duration or 0) > (best.duration or 0) then
            best = snapshot
        end
    end

    local function readSpellCooldown(spellIdentifier, sourceSpellID, sourceType)
        sourceSpellID = TMA.SafeNumber(sourceSpellID) or TMA.SafeNumber(spellIdentifier) or spellID
        if C_Spell and C_Spell.GetSpellCooldown then
            local ok, cdInfo = pcall(C_Spell.GetSpellCooldown, spellIdentifier)
            if ok and type(cdInfo) == "table" then
                remember(BuildCooldownSnapshot(cdInfo.startTime, cdInfo.duration, sourceSpellID, sourceType or "C_Spell"))
            end
        end

        if GetSpellCooldown then
            local ok, startTime, duration = pcall(GetSpellCooldown, spellIdentifier)
            if ok and startTime ~= nil then
                remember(BuildCooldownSnapshot(startTime, duration, sourceSpellID, sourceType or "GetSpellCooldown"))
            end
        end
    end

    for i = 1, #candidates do
        readSpellCooldown(candidates[i], candidates[i])
    end

    if configuredName ~= "" then
        readSpellCooldown(configuredName, spellID, "C_SpellName")
    end

    local function finishSnapshot()
        if best then
            CacheInterruptCooldownDuration(spellID, best.duration)
            if best.sourceSpellID and best.sourceSpellID ~= spellID then
                CacheInterruptCooldownDuration(best.sourceSpellID, best.duration)
            end

            if IsActiveCooldownSnapshot(best) then
                local debugKey = tostring(spellID) .. ":" .. tostring(best.sourceType or "") .. ":" .. tostring(best.sourceSpellID or "") .. ":" .. string.format("%.3f", best.startTime or 0)
                if not TMA.SafeValueEquals(TMA.LastLiveInterruptCooldownDebugKey, debugKey) then
                    TMA.LastLiveInterruptCooldownDebugKey = debugKey
                    TMA.DebugInterrupt(
                        "读取到真实打断冷却：技能ID=" .. TMA.SafeDebugText(spellID, "")
                        .. "，查询ID=" .. TMA.SafeDebugText(best.sourceSpellID or spellID, "")
                        .. "，来源=" .. TMA.SafeDebugText(best.sourceType, "")
                        .. "，持续=" .. tostring(NormalizeInterruptCooldownDuration(best.duration) or 0)
                        .. "秒，剩余=" .. tostring(TMA.SafeNumber(best.remaining) or 0)
                        .. "秒。"
                    )
                end
            end

            CacheLiveCooldownSnapshot(spellID, best.startTime, best.duration, best.remaining, best.sourceSpellID, best.sourceType)
            return best.startTime, best.duration, best.remaining, best.sourceSpellID, best.sourceType
        end

        CacheLiveCooldownSnapshot(spellID, nil, nil, nil, nil, nil)
    end

    if IsActiveCooldownSnapshot(best) then
        return finishSnapshot()
    end

    if C_SpellBook and C_SpellBook.FindSpellBookSlotForSpell and C_SpellBook.GetSpellBookItemCooldown then
        local function readSpellBookCooldown(spellIdentifier, sourceSpellID)
            local okSlot, slot, bank = pcall(C_SpellBook.FindSpellBookSlotForSpell, spellIdentifier)
            if okSlot and slot then
                local okCooldown, startTime, duration = pcall(C_SpellBook.GetSpellBookItemCooldown, slot, bank)
                if okCooldown then
                    if type(startTime) == "table" then
                        remember(BuildCooldownSnapshot(startTime.startTime, startTime.duration, sourceSpellID, "SpellBook"))
                    else
                        remember(BuildCooldownSnapshot(startTime, duration, sourceSpellID, "SpellBook"))
                    end
                end
            end
        end

        for i = 1, #candidates do
            readSpellBookCooldown(candidates[i], candidates[i])
        end
        if configuredName ~= "" then
            readSpellBookCooldown(configuredName, spellID)
        end
    end

    if IsActiveCooldownSnapshot(best) then
        return finishSnapshot()
    end

    if GetActionInfo and GetActionCooldown then
        TMA.InterruptCooldownActionScanAt = TMA.InterruptCooldownActionScanAt or {}
        local now = GetTime and GetTime() or 0
        local lastScanAt = TMA.InterruptCooldownActionScanAt[spellID]
        if lastScanAt and now - lastScanAt < 1 then
            return finishSnapshot()
        end
        TMA.InterruptCooldownActionScanAt[spellID] = now

        for slot = 1, 180 do
            local okInfo, actionType, actionID = pcall(GetActionInfo, slot)
            local actionSpellID = TMA.SafeNumber(actionID)
            local matched = okInfo and actionType == "spell" and actionSpellID and seen[actionSpellID]
            if okInfo and actionType == "spell" and actionSpellID and not matched and configuredName ~= "" then
                local actionName = TMA.NormalizeSpellName(TMA.GetSpellNameByID(actionSpellID))
                matched = actionName ~= ""
                    and (actionName == configuredName
                        or actionName:find(configuredName, 1, true)
                        or configuredName:find(actionName, 1, true))
            end

            if okInfo and actionType == "macro" and actionID and not matched and configuredName ~= "" then
                if GetMacroSpell then
                    local okMacroSpell, macroSpellName, _, macroSpellID = pcall(GetMacroSpell, actionID)
                    local numericMacroSpellID = TMA.SafeNumber(macroSpellID) or TMA.SafeNumber(macroSpellName)
                    if numericMacroSpellID and (numericMacroSpellID == spellID or seen[numericMacroSpellID]) then
                        actionSpellID = numericMacroSpellID
                        matched = true
                    elseif type(macroSpellName) == "string" then
                        local macroSpellNameNormalized = TMA.NormalizeSpellName(macroSpellName)
                        matched = macroSpellNameNormalized ~= ""
                            and (macroSpellNameNormalized == configuredName
                                or macroSpellNameNormalized:find(configuredName, 1, true)
                                or configuredName:find(macroSpellNameNormalized, 1, true))
                    end
                end
                if not matched and GetMacroInfo then
                    local okMacroInfo, macroName, _, macroBody = pcall(GetMacroInfo, actionID)
                    local macroText = tostring((okMacroInfo and (macroBody or macroName)) or "")
                    matched = macroText ~= ""
                        and (macroText:find(tostring(spellID), 1, true)
                            or macroText:find(TMA.GetConfiguredInterruptSpellName and TMA.GetConfiguredInterruptSpellName() or "", 1, true))
                end
                if matched then
                    actionSpellID = actionSpellID or spellID
                end
            end

            if matched then
                local okCooldown, startTime, duration = pcall(GetActionCooldown, slot)
                if okCooldown then
                    remember(BuildCooldownSnapshot(startTime, duration, actionSpellID, "ActionBar"))
                end
            end
        end
    end

    return finishSnapshot()
end

local function GetRealCooldownRemaining(spellID)
    local _, _, remaining = GetLiveSpellCooldownInfo(spellID)
    return remaining
end

local function GetActiveInterruptCooldownInfo(spellID)
    local startTime, duration, remaining = GetLiveSpellCooldownInfo(spellID)
    startTime = TMA.SafeNumber(startTime)
    duration = TMA.SafeNumber(duration)
    remaining = TMA.SafeNumber(remaining)
    if not startTime or not duration or not remaining then return nil end
    if startTime <= 0 or duration < MIN_INTERRUPT_ANNOUNCE_COOLDOWN_DURATION or remaining <= 0 then return nil end
    return startTime, duration, remaining
end

local function IsInterruptCooldownAlreadyAnnounced(spellID, startTime, duration)
    spellID = TMA.SafeNumber(spellID)
    startTime = TMA.SafeNumber(startTime)
    duration = TMA.SafeNumber(duration)
    if not spellID or not startTime or not duration then return false end

    local now = GetTime and GetTime() or 0
    local endTime = startTime + duration
    local key = TMA.SafeDebugText(spellID, "0")
    local previous = announcedInterruptCooldowns[key]
    if previous then
        local previousStart = TMA.SafeNumber(previous.startTime)
        local previousEnd = TMA.SafeNumber(previous.endTime)
        if previousStart and math.abs(previousStart - startTime) < 0.5 then
            return true
        end
        if previousEnd and now < previousEnd - 0.5 and (not previousStart or startTime <= previousStart + 1.5) then
            return true
        end
    end

    announcedInterruptCooldowns[key] = {
        startTime = startTime,
        endTime = endTime,
    }
    return false
end

local function IsCooldownFromRecentInterruptAttempt(spellID, startTime)
    local now = GetTime and GetTime() or 0
    if not lastInterruptAttemptAt or now - lastInterruptAttemptAt > INTERRUPT_COOLDOWN_DETECTION_WINDOW then
        return false, "没有最近的打断技能使用记录。"
    end

    spellID = TMA.SafeNumber(spellID)
    local attemptSpellID = TMA.SafeNumber(lastInterruptAttemptSpellID)
    if spellID and attemptSpellID and spellID ~= attemptSpellID then
        return false, "冷却技能与最近打断技能不一致。"
    end

    startTime = TMA.SafeNumber(startTime)
    if startTime and startTime > 0 and startTime < lastInterruptAttemptAt - INTERRUPT_COOLDOWN_START_TOLERANCE then
        return false, "当前冷却早于本次打断动作。"
    end

    return true
end

function TMA.IsRecentConfirmedInterruptCast(spellID)
    local now = GetTime and GetTime() or 0
    if not TMA.LastInterruptAttemptSucceededAt or now - TMA.LastInterruptAttemptSucceededAt > INTERRUPT_COOLDOWN_DETECTION_WINDOW then
        return false
    end

    spellID = TMA.SafeNumber(spellID)
    local succeededSpellID = TMA.SafeNumber(TMA.LastInterruptAttemptSucceededSpellID)
    if spellID and succeededSpellID and spellID ~= succeededSpellID then
        return false
    end

    if TMA.LastInterruptAttemptSucceededToken
        and TMA.LastInterruptAttemptToken
        and not TMA.SafeValueEquals(TMA.LastInterruptAttemptSucceededToken, TMA.LastInterruptAttemptToken)
    then
        return false
    end

    return true
end

local function DebugInterruptAttemptCancel(message)
    local token = tostring(TMA.LastInterruptAttemptToken or lastInterruptAttemptSpellID or "")
    local key = token .. ":" .. tostring(message or "")
    if TMA.SafeValueEquals(TMA.LastInterruptAttemptCancelDebugKey, key) then return end
    TMA.LastInterruptAttemptCancelDebugKey = key
    TMA.DebugInterrupt(message)
end

local function GetBaseOrFallbackInterruptCooldown(interruptSpellID)
    interruptSpellID = TMA.SafeNumber(interruptSpellID)
    if not interruptSpellID then return 0 end

    if GetSpellBaseCooldown then
        local ok, seconds = pcall(function()
            local baseMS = tonumber(GetSpellBaseCooldown(interruptSpellID)) or 0
            if baseMS > 0 then
                return math.ceil(baseMS / 1000)
            end
        end)
        if ok and seconds then return seconds end
    end

    return INTERRUPT_COOLDOWN_FALLBACK[interruptSpellID] or 0
end

local function GetPlayerInterruptCooldownDuration(interruptSpellID)
    interruptSpellID = TMA.SafeNumber(interruptSpellID)
    if not interruptSpellID then return 0 end

    local _, liveDuration = GetLiveSpellCooldownInfo(interruptSpellID)
    local liveSeconds = NormalizeInterruptCooldownDuration(liveDuration)
    if liveSeconds then return liveSeconds end

    local cachedSeconds = GetCachedInterruptCooldownDuration(interruptSpellID)
    if cachedSeconds then return cachedSeconds end

    return GetBaseOrFallbackInterruptCooldown(interruptSpellID)
end

local function GetObservedInterruptCooldownDuration(interruptSpellID)
    interruptSpellID = TMA.SafeNumber(interruptSpellID)
    if not interruptSpellID then return nil end

    local _, liveDuration = GetLiveSpellCooldownInfo(interruptSpellID)
    local liveSeconds = NormalizeInterruptCooldownDuration(liveDuration)
    if liveSeconds then return liveSeconds end

    return GetCachedInterruptCooldownDuration(interruptSpellID)
end

function TMA.ResetInterruptCooldownReadyAlert()
    TMA.InterruptCooldownReadyKey = nil
    TMA.InterruptCooldownReadySpellID = nil
    TMA.InterruptCooldownReadyDuration = nil
    TMA.InterruptCooldownReadyExpectedEndAt = nil
    TMA.InterruptCooldownReadyInactiveConfirmKey = nil
    TMA.InterruptCooldownReadyInactiveConfirmAt = nil
    TMA.InterruptCooldownReadySerial = (TMA.InterruptCooldownReadySerial or 0) + 1
    TMA.MythicInterruptCooldownReadyKey = nil
    TMA.MythicInterruptCooldownReadySpellID = nil
    TMA.MythicInterruptCooldownReadyExpectedAt = nil
    TMA.MythicInterruptCooldownReadySerial = (TMA.MythicInterruptCooldownReadySerial or 0) + 1
end

local function GetMythicInterruptCooldownReadyDuration(spellID)
    local cachedSeconds = GetCachedInterruptCooldownDuration(spellID)
    if cachedSeconds and cachedSeconds > 0 then
        return cachedSeconds, "缓存真实冷却"
    end

    local fallbackSeconds = GetFallbackTableInterruptCooldown(spellID)
    if fallbackSeconds and fallbackSeconds > 0 then
        return fallbackSeconds, "兜底表"
    end
end

function TMA.ScheduleMythicInterruptCooldownReadyAlert(spellID, spellName, reason)
    if not TMA.IsChallengeModeActiveSafe or not TMA.IsChallengeModeActiveSafe() then
        return false
    end

    if TargetMarkerAssistantDB and TargetMarkerAssistantDB.enableInterruptCooldownReadyAlert == false then
        TMA.ResetInterruptCooldownReadyAlert()
        return true
    end

    local configuredSpellID = GetConfiguredInterruptSpellID()
    spellID = TMA.SafeNumber(spellID) or configuredSpellID
    if not spellID then
        TMA.DebugInterrupt("大秘境冷却结束提示已跳过：未识别到监控打断技能。")
        return true
    end
    if configuredSpellID and not TMA.SafeValueEquals(configuredSpellID, spellID) then
        return true
    end

    local duration, durationSource = GetMythicInterruptCooldownReadyDuration(spellID)
    if not duration or duration <= 0 then
        TMA.DebugInterrupt("大秘境冷却结束提示已跳过：没有缓存真实冷却，也没有命中兜底表。")
        return true
    end

    TMA.InterruptCooldownReadyKey = nil
    TMA.InterruptCooldownReadySpellID = nil
    TMA.InterruptCooldownReadyDuration = nil
    TMA.InterruptCooldownReadyExpectedEndAt = nil
    TMA.InterruptCooldownReadyInactiveConfirmKey = nil
    TMA.InterruptCooldownReadyInactiveConfirmAt = nil
    TMA.InterruptCooldownReadySerial = (TMA.InterruptCooldownReadySerial or 0) + 1

    local now = GetTime and GetTime() or 0
    local token = TMA.LastInterruptAttemptSucceededToken
        or TMA.LastInterruptAttemptToken
        or string.format("%.3f", now)
    local cooldownKey = "mplus:" .. tostring(spellID) .. ":" .. tostring(token)
    if TMA.SafeValueEquals(TMA.MythicInterruptCooldownReadyKey, cooldownKey) then
        return true
    end

    TMA.MythicInterruptCooldownReadySerial = (TMA.MythicInterruptCooldownReadySerial or 0) + 1
    TMA.MythicInterruptCooldownReadyKey = cooldownKey
    TMA.MythicInterruptCooldownReadySpellID = spellID
    TMA.MythicInterruptCooldownReadyExpectedAt = now + duration

    local serial = TMA.MythicInterruptCooldownReadySerial
    local publicSpellName = TMA.GetPublicSpellNameByID(spellID)
        or (TMA.IsPublicChatText(spellName) and spellName)
        or (TargetMarkerAssistantDB and TargetMarkerAssistantDB.interruptSpellName)
        or (L["INTERRUPT_SPELL"] or "打断技能")
    TMA.DebugInterrupt(
        "大秘境冷却结束提示已按" .. TMA.SafeDebugText(durationSource, "兜底")
        .. "计时：技能ID=" .. TMA.SafeDebugText(spellID, "")
        .. "，时长=" .. tostring(duration)
        .. "秒，来源=" .. TMA.GetDebugReasonText(reason)
    )

    C_Timer.After(math.max(duration + 0.08, 0.15), function()
        if serial ~= TMA.MythicInterruptCooldownReadySerial then return end
        if not TMA.SafeValueEquals(TMA.MythicInterruptCooldownReadyKey, cooldownKey) then return end
        if TargetMarkerAssistantDB and TargetMarkerAssistantDB.enableInterruptCooldownReadyAlert == false then return end

        local activeConfiguredSpellID = GetConfiguredInterruptSpellID()
        if activeConfiguredSpellID and not TMA.SafeValueEquals(activeConfiguredSpellID, spellID) then
            return
        end

        if TMA.SafeValueEquals(TMA.LastInterruptCooldownReadyAlertKey, cooldownKey) then return end
        TMA.LastInterruptCooldownReadyAlertKey = cooldownKey
        TMA.MythicInterruptCooldownReadyKey = nil
        TMA.MythicInterruptCooldownReadySpellID = nil
        TMA.MythicInterruptCooldownReadyExpectedAt = nil

        TMA.DebugInterrupt("大秘境监控打断技能冷却结束：" .. TMA.SafeDebugText(publicSpellName, tostring(spellID)))
        if TMA.ShowInterruptCooldownReadyAlert then
            TMA.ShowInterruptCooldownReadyAlert(spellID, publicSpellName)
        elseif UIErrorsFrame then
            UIErrorsFrame:AddMessage(string.format(L["INTERRUPT_COOLDOWN_READY_ALERT"] or "%s 冷却结束", publicSpellName), 0.45, 0.85, 1)
        end
    end)

    return true
end

local function GetInterruptCooldownReadyExpectedEndAt(spellID, cooldownKey)
    local expectedEndAt = TMA.SafeNumber(TMA.InterruptCooldownReadyExpectedEndAt)
    local startTime = GetInterruptCooldownReadyStartFromKey(spellID, cooldownKey)
    if not startTime then
        return expectedEndAt
    end

    local duration = TMA.SafeNumber(TMA.InterruptCooldownReadyDuration)
        or GetCachedInterruptCooldownDuration(spellID)
        or GetBaseOrFallbackInterruptCooldown(spellID)
    if duration and duration > 0 then
        local keyExpectedEndAt = startTime + duration
        if not expectedEndAt or keyExpectedEndAt > expectedEndAt then
            expectedEndAt = keyExpectedEndAt
            TMA.InterruptCooldownReadyExpectedEndAt = expectedEndAt
        end
    end

    return expectedEndAt
end

function TMA.CheckInterruptCooldownReadyAlert(spellID, cooldownKey, serial)
    spellID = TMA.SafeNumber(spellID)
    if not spellID or not cooldownKey then return end
    if serial ~= TMA.InterruptCooldownReadySerial then return end
    if not TMA.SafeValueEquals(TMA.InterruptCooldownReadyKey, cooldownKey) then return end

    local configuredSpellID = GetConfiguredInterruptSpellID()
    if configuredSpellID and not TMA.SafeValueEquals(configuredSpellID, spellID) then
        TMA.ResetInterruptCooldownReadyAlert()
        return
    end

    local startTime, duration, remaining = GetActiveInterruptCooldownInfo(spellID)
    if startTime then
        TMA.InterruptCooldownReadyInactiveConfirmKey = nil
        TMA.InterruptCooldownReadyInactiveConfirmAt = nil
        if duration and duration > 0 then
            TMA.InterruptCooldownReadyDuration = duration
            TMA.InterruptCooldownReadyExpectedEndAt = startTime + duration
        end
        local activeKey = GetInterruptCooldownReadyKey(spellID, startTime)
        if not TMA.SafeValueEquals(activeKey, cooldownKey) then return end
        local retryDelay = math.max((TMA.SafeNumber(remaining) or 0) + 0.08, 0.15)
        C_Timer.After(retryDelay, function()
            TMA.CheckInterruptCooldownReadyAlert(spellID, cooldownKey, serial)
        end)
        return
    end

    local now = GetTime and GetTime() or 0
    local expectedEndAt = GetInterruptCooldownReadyExpectedEndAt(spellID, cooldownKey)
    if expectedEndAt and now < expectedEndAt - 0.25 then
        local retryDelay = math.max(expectedEndAt - now + 0.08, 0.15)
        C_Timer.After(retryDelay, function()
            TMA.CheckInterruptCooldownReadyAlert(spellID, cooldownKey, serial)
        end)
        return
    end

    local inactiveConfirmKey = tostring(cooldownKey) .. ":inactive"
    if not TMA.SafeValueEquals(TMA.InterruptCooldownReadyInactiveConfirmKey, inactiveConfirmKey) then
        TMA.InterruptCooldownReadyInactiveConfirmKey = inactiveConfirmKey
        TMA.InterruptCooldownReadyInactiveConfirmAt = now
        C_Timer.After(0.18, function()
            TMA.CheckInterruptCooldownReadyAlert(spellID, cooldownKey, serial)
        end)
        return
    end

    if TMA.SafeValueEquals(TMA.LastInterruptCooldownReadyAlertKey, cooldownKey) then return end
    TMA.LastInterruptCooldownReadyAlertKey = cooldownKey
    TMA.InterruptCooldownReadyKey = nil
    TMA.InterruptCooldownReadySpellID = nil
    TMA.InterruptCooldownReadyDuration = nil
    TMA.InterruptCooldownReadyExpectedEndAt = nil
    TMA.InterruptCooldownReadyInactiveConfirmKey = nil
    TMA.InterruptCooldownReadyInactiveConfirmAt = nil

    local spellName = TMA.GetPublicSpellNameByID(spellID)
        or (TargetMarkerAssistantDB and TargetMarkerAssistantDB.interruptSpellName)
        or (L["INTERRUPT_SPELL"] or "打断技能")
    TMA.DebugInterrupt("监控打断技能冷却结束：" .. TMA.SafeDebugText(spellName, tostring(spellID)))
    if TMA.ShowInterruptCooldownReadyAlert then
        TMA.ShowInterruptCooldownReadyAlert(spellID, spellName)
    elseif UIErrorsFrame then
        UIErrorsFrame:AddMessage(string.format(L["INTERRUPT_COOLDOWN_READY_ALERT"] or "%s 冷却结束", spellName), 0.45, 0.85, 1)
    end
end

function TMA.TrackConfiguredInterruptCooldownReady(reason)
    if TargetMarkerAssistantDB and TargetMarkerAssistantDB.enableInterruptCooldownReadyAlert == false then
        if TMA.InterruptCooldownReadyKey then
            TMA.ResetInterruptCooldownReadyAlert()
        end
        return
    end

    local spellID = GetConfiguredInterruptSpellID()
    if not spellID then return end

    local startTime, duration, remaining = GetActiveInterruptCooldownInfo(spellID)
    if not startTime then
        if TMA.InterruptCooldownReadyKey
            and TMA.SafeValueEquals(TMA.InterruptCooldownReadySpellID, spellID)
        then
            TMA.CheckInterruptCooldownReadyAlert(spellID, TMA.InterruptCooldownReadyKey, TMA.InterruptCooldownReadySerial)
        end
        return
    end

    local cooldownKey = GetInterruptCooldownReadyKey(spellID, startTime)
    if not cooldownKey then return end
    local expectedEndAt = (TMA.SafeNumber(startTime) or 0) + (TMA.SafeNumber(duration) or 0)
    if TMA.SafeValueEquals(TMA.InterruptCooldownReadyKey, cooldownKey) then
        if duration and duration > 0 then
            TMA.InterruptCooldownReadyDuration = duration
        end
        TMA.InterruptCooldownReadyExpectedEndAt = expectedEndAt
        return
    end

    TMA.InterruptCooldownReadySerial = (TMA.InterruptCooldownReadySerial or 0) + 1
    TMA.InterruptCooldownReadyKey = cooldownKey
    TMA.InterruptCooldownReadySpellID = spellID
    TMA.InterruptCooldownReadyDuration = duration
    TMA.InterruptCooldownReadyExpectedEndAt = expectedEndAt
    TMA.InterruptCooldownReadyInactiveConfirmKey = nil
    TMA.InterruptCooldownReadyInactiveConfirmAt = nil

    local delay = math.max((TMA.SafeNumber(remaining) or 0) + 0.08, 0.15)
    TMA.DebugInterrupt(
        "已追踪监控打断技能冷却结束：技能ID=" .. TMA.SafeDebugText(spellID, "")
        .. "，剩余=" .. tostring(TMA.SafeNumber(remaining) or 0)
        .. "秒，来源=" .. TMA.GetDebugReasonText(reason)
    )
    local serial = TMA.InterruptCooldownReadySerial
    C_Timer.After(delay, function()
        TMA.CheckInterruptCooldownReadyAlert(spellID, cooldownKey, serial)
    end)
end

local function GetInterruptCooldownSeconds(interruptSpellID, sourceGUID, sourceName, sourceFlags)
    interruptSpellID = TMA.SafeNumber(interruptSpellID)
    if TMA.IsPlayerCombatSource(sourceGUID, nil, sourceFlags) then
        local cooldownSpellID = interruptSpellID or GetConfiguredInterruptSpellID()
        local _, _, remaining = GetActiveInterruptCooldownInfo(cooldownSpellID)
        if remaining then
            return remaining
        end
        return nil
    end

    return GetBaseOrFallbackInterruptCooldown(interruptSpellID)
end

local function SanitizeChatPlainText(value, fallback)
    local fallbackText = TMA.GetPublicChatText(fallback, "")
    local ok, sanitized = pcall(function()
        if type(value) ~= "string" then
            value = fallbackText
        end

        value = tostring(value or fallbackText)
        value = value:gsub("|c%x%x%x%x%x%x%x%x", "")
        value = value:gsub("|r", "")
        value = value:gsub("|H.-|h(.-)|h", "%1")
        value = value:gsub("|T.-|t", "")
        value = value:gsub("|A.-|a", "")
        value = value:gsub("|K.-|k", "")
        value = value:gsub("^%s+", ""):gsub("%s+$", "")
        return value
    end)

    if ok and sanitized then
        sanitized = TMA.GetPublicChatText(sanitized, "")
        if sanitized == "" then
            return fallbackText
        end

        local okNonEmpty, isNonEmpty = pcall(function()
            return sanitized ~= ""
        end)
        if okNonEmpty then
            if isNonEmpty then
                return sanitized
            end
        else
            return sanitized
        end
    end

    return fallbackText
end

local function SanitizeLocalVisualText(value, fallback)
    local fallbackText = SanitizeChatPlainText(fallback, "")
    local ok, text = pcall(function()
        if type(value) ~= "string" then
            value = fallbackText
        end

        value = tostring(value or fallbackText)
        value = value:gsub("[\r\n]+", " ")
        value = value:gsub("|K.-|k", "")
        return value
    end)

    if ok and text and TMA.IsPublicChatText(text) then
        return text
    end
    return fallbackText
end

function TMA.GetUnknownInterruptedSpellText()
    return L["UNKNOWN_INTERRUPTED_SPELL"] or L["UNKNOWN_SPELL"] or "未知法术"
end

function TMA.GetKnownInterruptedSpellName(spellID)
    if TMA.SafeValueEquals(TMA.SafeNumber(spellID), 1278882) then
        return "奥术震击"
    end
end

function TMA.RememberKnownInterruptedSpell(spellID, spellName)
    local numericSpellID = TMA.SafeNumber(spellID)
    local publicName = TMA.GetPublicChatText(spellName, "")
    if not numericSpellID and not TMA.SafeIsNonEmptyText(publicName) then return end

    TMA.LastKnownInterruptedSpellID = numericSpellID
    TMA.LastKnownInterruptedSpellName = publicName ~= "" and publicName or TMA.GetKnownInterruptedSpellName(numericSpellID)
    TMA.LastKnownInterruptedSpellAt = GetTime and GetTime() or 0
end

function TMA.GetPublicSpellNameByID(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return nil end

    local name = TMA.GetPublicChatText(TMA.GetSpellNameByID(spellID), "")
    if TMA.SafeIsNonEmptyText(name) and not TMA.IsUnknownInterruptedSpellName(name) then
        return name
    end

    if C_TooltipInfo and C_TooltipInfo.GetSpellByID then
        local ok, tooltip = pcall(C_TooltipInfo.GetSpellByID, spellID)
        if ok and tooltip and tooltip.lines and tooltip.lines[1] then
            name = TMA.GetPublicChatText(tooltip.lines[1].leftText, "")
            if TMA.SafeIsNonEmptyText(name) and not TMA.IsUnknownInterruptedSpellName(name) then
                return name
            end
        end
    end

    name = TMA.GetPublicChatText(TMA.GetKnownInterruptedSpellName(spellID), "")
    if TMA.SafeIsNonEmptyText(name) then
        return name
    end

    if TMA.SafeValueEquals(TMA.SafeNumber(TMA.LastKnownInterruptedSpellID), spellID)
        and TMA.LastKnownInterruptedSpellAt
        and (GetTime and GetTime() or 0) - TMA.LastKnownInterruptedSpellAt <= 8
    then
        name = TMA.GetPublicChatText(TMA.LastKnownInterruptedSpellName, "")
        if TMA.SafeIsNonEmptyText(name) then return name end
    end
end

function TMA.IsUnknownInterruptedSpellName(value)
    local ok, unknown = pcall(function()
        local text = SanitizeChatPlainText(value, "")
        text = text:gsub("^%s+", ""):gsub("%s+$", "")
        if text == "" then return true end

        local bareText = text:match("^%[(.-)%]$") or text
        local unknownText = TMA.GetUnknownInterruptedSpellText()
        local unknownSpell = L["UNKNOWN_SPELL"] or UNKNOWN or "Unknown"
        return bareText == unknownText
            or bareText == unknownSpell
            or bareText == "未知法术"
            or bareText == "未知法術"
            or bareText == "unknown spell"
            or bareText == "Unknown"
            or bareText == "<protected>"
    end)
    if ok and unknown == true then
        local plainText = TMA.GetPlainTextValue(value)
        if plainText then
            local okPlain, plainUnknown = pcall(function()
                local bareText = plainText:match("^%[(.-)%]$") or plainText
                local unknownText = TMA.GetUnknownInterruptedSpellText()
                local unknownSpell = L["UNKNOWN_SPELL"] or UNKNOWN or "Unknown"
                return bareText == ""
                    or bareText == unknownText
                    or bareText == unknownSpell
                    or bareText == "未知法术"
                    or bareText == "未知法術"
                    or bareText == "unknown spell"
                    or bareText == "Unknown"
                    or bareText == "<protected>"
            end)
            if okPlain then return plainUnknown == true end
            return false
        end
    end
    if ok then return unknown == true end
    return false
end

local function GetFallbackInterruptSpellText()
    return L["INTERRUPT_SPELL"] or "打断技能"
end

local function GetSpellIconTexture(spellID)
    spellID = TMA.SafeNumber(spellID)
    if not spellID then return nil end

    if C_Spell and C_Spell.GetSpellTexture then
        local ok, texture = pcall(C_Spell.GetSpellTexture, spellID)
        if ok and texture then return texture end
    end

    if C_Spell and C_Spell.GetSpellInfo then
        local ok, info = pcall(C_Spell.GetSpellInfo, spellID)
        if ok and info and info.iconID then return info.iconID end
    end

    if GetSpellTexture then
        local ok, texture = pcall(GetSpellTexture, spellID)
        if ok and texture then return texture end
    end
end

local function GetInterruptSpellLocalDisplay(interruptSpellID, interruptSpellName)
    local spellName = SanitizeChatPlainText(interruptSpellName, GetFallbackInterruptSpellText())
    local icon = GetSpellIconTexture(interruptSpellID)
    local iconText = ""
    if icon then
        iconText = string.format("|T%s:18:18:0:0|t ", tostring(icon))
    end
    return SanitizeLocalVisualText(iconText .. "|cff71d5ff" .. spellName .. "|r", spellName)
end

local function SplitLeadingInlineIcon(text)
    if type(text) ~= "string" then return "", text end
    local iconText, rest = text:match("^(|T.-|t)%s*(.*)$")
    if iconText and rest then
        return iconText .. " ", rest
    end
    return "", text
end

local function GetInterruptSpellDisplayName(interruptSpellID, interruptSpellName)
    local name = TMA.GetPublicSpellNameByID(interruptSpellID)
    if not TMA.SafeIsNonEmptyText(name) and TMA.IsPublicChatText(interruptSpellName) then
        name = interruptSpellName
    end
    if not TMA.SafeIsNonEmptyText(name) then
        name = TMA.GetPublicChatText(TargetMarkerAssistantDB and TargetMarkerAssistantDB.interruptSpellName, "")
    end
    if not TMA.SafeIsNonEmptyText(name) then
        name = TMA.GetPublicChatText(TargetMarkerAssistantDB and TargetMarkerAssistantDB.interruptSpellInput, "")
    end

    return SanitizeChatPlainText(name, GetFallbackInterruptSpellText())
end

local function FormatInterruptUsedMessage(playerName, interruptSpellName, cooldownSeconds)
    cooldownSeconds = tonumber(cooldownSeconds) or 0
    playerName = SanitizeChatPlainText(playerName, L["UNKNOWN_PLAYER"] or "玩家")
    interruptSpellName = SanitizeChatPlainText(interruptSpellName, GetFallbackInterruptSpellText())
    if interruptSpellName == "" then
        interruptSpellName = GetFallbackInterruptSpellText()
    end

    local template = L["INTERRUPT_CHAT_TEMPLATE_DEFAULT"] or "{player} used [{interrupt}]. Next interrupt in {cooldown} sec."
    template = SanitizeChatPlainText(template, "{player} used [{interrupt}]. Next interrupt in {cooldown} sec.")
    template = template:gsub("{player}", function() return playerName end)
    template = template:gsub("{interrupt}", function() return interruptSpellName end)
    template = template:gsub("{cooldown}", function() return tostring(cooldownSeconds) end)
    return template
end

local function GetDefaultInterruptChatTemplate()
    return L["INTERRUPT_CHAT_TEMPLATE_DEFAULT"] or "{player} 已施放 [{interrupt}]，下次打断还有 {cooldown} 秒。"
end

local function GetInterruptAnnounceTemplate()
    local template = TargetMarkerAssistantDB and TargetMarkerAssistantDB.interruptAnnounceTemplate or nil
    if type(template) ~= "string" or template == "" then
        template = GetDefaultInterruptChatTemplate()
    end

    template = SanitizeChatPlainText(template, GetDefaultInterruptChatTemplate())
    if template:find("{target}", 1, true) then
        template = GetDefaultInterruptChatTemplate()
    end

    return template
end

local function FormatInterruptChatTemplate(playerName, interruptSpellName, cooldownSeconds)
    playerName = SanitizeChatPlainText(playerName, L["UNKNOWN_PLAYER"] or "玩家")
    interruptSpellName = SanitizeChatPlainText(interruptSpellName, GetFallbackInterruptSpellText())
    cooldownSeconds = TMA.SafeNumber(cooldownSeconds) or 0

    local text = GetInterruptAnnounceTemplate()
    text = text:gsub("{player}", function() return playerName end)
    text = text:gsub("{interrupt}", function() return interruptSpellName end)
    text = text:gsub("{spell}", function() return interruptSpellName end)
    text = text:gsub("{cooldown}", function() return tostring(cooldownSeconds or 0) end)
    return SanitizeChatPlainText(text, FormatInterruptUsedMessage(playerName, interruptSpellName, cooldownSeconds or 0))
end

function TMA.FormatInterruptLocalTemplateWithTemplate(template, playerText, interruptText, cooldownSeconds)
    local plainPlayerText = SanitizeChatPlainText(playerText, L["UNKNOWN_PLAYER"] or "玩家")
    local plainInterruptText = SanitizeChatPlainText(interruptText, GetFallbackInterruptSpellText())
    playerText = SanitizeLocalVisualText(playerText, plainPlayerText)
    interruptText = SanitizeLocalVisualText(interruptText, plainInterruptText)
    cooldownSeconds = TMA.SafeNumber(cooldownSeconds) or 0

    local text = SanitizeChatPlainText(template, GetInterruptAnnounceTemplate())
    if text == "" or text:find("{target}", 1, true) then
        text = GetInterruptAnnounceTemplate()
    end
    local interruptIconText, interruptBodyText = SplitLeadingInlineIcon(interruptText)
    if interruptIconText ~= "" then
        text = text:gsub("%[{interrupt}%]", function()
            return interruptIconText .. "[" .. interruptBodyText .. "]"
        end)
        text = text:gsub("%[{spell}%]", function()
            return interruptIconText .. "[" .. interruptBodyText .. "]"
        end)
    end
    text = text:gsub("{player}", function() return playerText end)
    text = text:gsub("{interrupt}", function() return interruptText end)
    text = text:gsub("{spell}", function() return interruptText end)
    text = text:gsub("{cooldown}", function() return tostring(cooldownSeconds or 0) end)
    return SanitizeLocalVisualText(text, FormatInterruptUsedMessage(plainPlayerText, plainInterruptText, cooldownSeconds or 0))
end

local function FormatInterruptLocalTemplate(playerText, interruptText, cooldownSeconds)
    return TMA.FormatInterruptLocalTemplateWithTemplate(GetInterruptAnnounceTemplate(), playerText, interruptText, cooldownSeconds)
end

local function ReadVisibleUnitCastSpellID(unit)
    if not unit or not UnitExists or not UnitExists(unit) then return nil end

    local snapshot = TMA.ReadUnitCastingInfoSnapshot and TMA.ReadUnitCastingInfoSnapshot(unit)
    if snapshot and snapshot.active then
        local numericSpellID = TMA.SafeNumber(snapshot.spellID)
        if numericSpellID then
            return numericSpellID, "cast"
        end
    end

    snapshot = TMA.ReadUnitChannelInfoSnapshot and TMA.ReadUnitChannelInfoSnapshot(unit)
    if snapshot and snapshot.active then
        local numericSpellID = TMA.SafeNumber(snapshot.spellID)
        if numericSpellID then
            return numericSpellID, "channel"
        end
    end
end

local function GetVisibleUnitCastInfo(unit)
    local spellID, castType = ReadVisibleUnitCastSpellID(unit)
    if not spellID then return nil end
    return spellID, TMA.GetPublicSpellNameByID(spellID), castType
end

function TMA.GetVisibleInterruptCastDebugSnapshot()
    local units = { "target", "focus", "mouseover" }
    for i = 1, 5 do
        units[#units + 1] = "boss" .. tostring(i)
    end
    for i = 1, 5 do
        units[#units + 1] = "arena" .. tostring(i)
    end

    local parts = {}
    local visibleNameplateCount = 0
    local visibleNameplateCast
    local function DescribeUnit(unit)
        if not UnitExists or not UnitExists(unit) then return nil end
        local spellID, spellName, castType = GetVisibleUnitCastInfo(unit)
        if spellID then
            return unit
                .. " " .. tostring(castType or "cast")
                .. " spellID=" .. tostring(spellID)
                .. " name=" .. (spellName or "未公开")
        end
        return unit .. " 无公开spellID"
    end

    for i = 1, #units do
        local text = DescribeUnit(units[i])
        if text then parts[#parts + 1] = text end
    end
    for i = 1, 40 do
        local unit = "nameplate" .. tostring(i)
        if UnitExists and UnitExists(unit) then
            visibleNameplateCount = visibleNameplateCount + 1
            if not visibleNameplateCast then
                local spellID, spellName, castType = GetVisibleUnitCastInfo(unit)
                if spellID then
                    visibleNameplateCast = unit
                        .. " " .. tostring(castType or "cast")
                        .. " spellID=" .. tostring(spellID)
                        .. " name=" .. (spellName or "未公开")
                end
            end
        end
    end
    parts[#parts + 1] = "nameplate可见数=" .. tostring(visibleNameplateCount)
    if visibleNameplateCast then
        parts[#parts + 1] = visibleNameplateCast
    end

    if not parts[1] then return "无可见单位" end
    return table.concat(parts, "；")
end

local function CacheInterruptCast(unit, spellID, spellName, reason, castGUID)
    if not TMA.IsInterruptCastUnitToken(unit) then return end

    local resolvedSpellID = TMA.SafeNumber(spellID)
    if not resolvedSpellID then return end

    local resolvedSpellName = TMA.GetPublicSpellNameByID(resolvedSpellID)
    if not resolvedSpellName and TMA.IsPublicChatText(spellName) then
        resolvedSpellName = spellName
    end

    local now = GetTime and GetTime() or 0
    local cache = {
        unit = unit,
        castGUID = castGUID,
        spellID = resolvedSpellID,
        spellName = resolvedSpellName,
        guid = UnitGUID and UnitGUID(unit) or nil,
        name = nil,
        at = now,
        reason = reason,
    }

    activeCombatCasts[unit] = cache
    TMA.TargetFocusCastCache[unit] = cache
    TMA.LastTrackedCast = cache
    TMA.LastPreInterruptCast = cache
    if TMA.RememberKnownInterruptedSpell then
        TMA.RememberKnownInterruptedSpell(resolvedSpellID, resolvedSpellName)
    end

    local debugName = resolvedSpellName or "未公开"
    local debugKey = tostring(unit) .. ":" .. tostring(resolvedSpellID) .. ":" .. debugName
    local isPolling = reason == "poll" or reason == "poll-visible"
    if not isPolling or not TMA.SafeValueEquals(TMA.TargetFocusCastDebugKey, debugKey) then
        TMA.TargetFocusCastDebugKey = debugKey
        TMA.DebugInterrupt(
            "缓存打断前读条：单位=" .. tostring(unit)
            .. "，来源=" .. TMA.GetDebugReasonText(reason)
            .. "，技能ID=" .. tostring(resolvedSpellID)
            .. "，技能名=" .. debugName
        )
    end

    return cache
end

function TMA.RefreshTargetFocusCastCache(reason)
    local bestCache
    for _, unit in ipairs(interruptCastCacheUnits) do
        local spellID, spellName = GetVisibleUnitCastInfo(unit)
        if spellID then
            local cached = CacheInterruptCast(unit, spellID, spellName, reason)
            bestCache = bestCache or cached
        else
            activeCombatCasts[unit] = nil
            TMA.TargetFocusCastCache[unit] = nil
        end
    end
    return bestCache
end
local function GetPreviewCooldownSeconds()
    local spellID = GetConfiguredInterruptSpellID()
    if not spellID then
        return tonumber(TMA.LastInterruptPreviewCooldownSeconds) or 0
    end

    local remaining = GetRealCooldownRemaining(spellID)
    if remaining and remaining > 0 then
        return remaining
    end
    return GetPlayerInterruptCooldownDuration(spellID) or 0
end

local function GetInterruptPreviewData()
    local playerName = TMA.LastInterruptPreviewPlayerName
    local classFile = TMA.LastInterruptPreviewClassFile
    if type(playerName) ~= "string" or playerName == "" then
        playerName = GetShortPlayerName(UnitName and UnitName("player") or L["UNKNOWN_PLAYER"] or "玩家")
        classFile = TMA.GetPlayerClassFile and TMA.GetPlayerClassFile() or nil
    end
    local interruptSpellID = GetConfiguredInterruptSpellID()
    local interruptSpellName = GetInterruptSpellDisplayName(
        interruptSpellID,
        TargetMarkerAssistantDB and (TargetMarkerAssistantDB.interruptSpellName or TargetMarkerAssistantDB.interruptSpellInput)
    )
    local cooldownSeconds = GetPreviewCooldownSeconds()

    return {
        playerName = SanitizeChatPlainText(playerName, L["UNKNOWN_PLAYER"] or "玩家"),
        coloredPlayerName = ColorizePlayerName(playerName, classFile),
        interruptSpellID = interruptSpellID,
        interruptSpellName = interruptSpellName,
        coloredInterruptSpellName = "|cff71d5ff" .. interruptSpellName .. "|r",
        localInterruptSpellDisplay = GetInterruptSpellLocalDisplay(interruptSpellID, interruptSpellName),
        spellID = interruptSpellID,
        spellName = interruptSpellName,
        coloredSpellName = "|cff71d5ff" .. interruptSpellName .. "|r",
        cooldownSeconds = cooldownSeconds,
        channel = "DANMAKU",
    }
end

local function FormatInterruptChatPreview()
    local preview = GetInterruptPreviewData()

    local template = GetInterruptAnnounceTemplate()
    template = SanitizeChatPlainText((L["INTERRUPT_CHAT_PREVIEW_PREFIX"] or "") .. template, L["INTERRUPT_CHAT_PREVIEW"] or "")

    local replacements = {
        ["{player}"] = preview.coloredPlayerName,
        ["{interrupt}"] = preview.coloredInterruptSpellName,
        ["{spell}"] = preview.coloredInterruptSpellName,
        ["{cooldown}"] = ColorizeChannelText(tostring(preview.cooldownSeconds or 0), preview.channel),
    }
    local result = {}
    local index = 1

    while index <= #template do
        local nextStart, nextEnd, nextToken
        for token in pairs(replacements) do
            local startPos, endPos = template:find(token, index, true)
            if startPos and (not nextStart or startPos < nextStart) then
                nextStart, nextEnd, nextToken = startPos, endPos, token
            end
        end

        if not nextStart then
            local text = template:sub(index)
            if text ~= "" then
                result[#result + 1] = ColorizeChannelText(text, preview.channel)
            end
            break
        end

        if nextStart > index then
            local text = template:sub(index, nextStart - 1)
            if text ~= "" then
                result[#result + 1] = ColorizeChannelText(text, preview.channel)
            end
        end

        result[#result + 1] = replacements[nextToken]
        index = nextEnd + 1
    end

    return table.concat(result)
end

local function FormatInterruptDanmakuPreviewMessage()
    local preview = GetInterruptPreviewData()
    return FormatInterruptLocalTemplate(preview.coloredPlayerName, preview.localInterruptSpellDisplay, preview.cooldownSeconds)
end

local DispatchInterruptNotice
local SendInterruptAddonSync
local HandleInterruptAddonSyncMessage

local function BuildInterruptMessagesWithCooldown(sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, cooldownSeconds)
    local shortName = TMA.GetInterruptSourceDisplayName(sourceGUID, sourceName, sourceFlags)
    local classFile = GetClassFileByGUID(sourceGUID) or (TMA.IsPlayerCombatSource(sourceGUID, nil, sourceFlags) and TMA.GetPlayerClassFile())
    local coloredName = ColorizePlayerName(shortName, classFile)
    local safeShortName = SanitizeChatPlainText(shortName, L["UNKNOWN_PLAYER"] or "未知玩家")
    local safeInterruptSpellName = GetInterruptSpellDisplayName(interruptSpellID, interruptSpellName)
    cooldownSeconds = tonumber(cooldownSeconds) or 0
    if not TMA.IsPublicChatText(coloredName) then
        coloredName = safeShortName
    end

    local chatMsg = FormatInterruptChatTemplate(safeShortName, safeInterruptSpellName, cooldownSeconds)
    local localMsg = FormatInterruptLocalTemplate(
        coloredName,
        GetInterruptSpellLocalDisplay(interruptSpellID, safeInterruptSpellName),
        cooldownSeconds
    )

    TMA.LastInterruptPreviewPlayerName = safeShortName
    TMA.LastInterruptPreviewClassFile = classFile
    TMA.LastInterruptPreviewInterruptSpellID = TMA.SafeNumber(interruptSpellID)
    TMA.LastInterruptPreviewInterruptSpellName = safeInterruptSpellName
    TMA.LastInterruptedSpellID = nil
    TMA.LastInterruptedSpellName = nil
    TMA.LastInterruptPreviewCooldownSeconds = cooldownSeconds
    return localMsg, chatMsg
end

local function BuildFallbackInterruptMessages(sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, cooldownSeconds)
    local okName, shortName = pcall(TMA.GetInterruptSourceDisplayName, sourceGUID, sourceName, sourceFlags)
    if not okName or not TMA.SafeIsNonEmptyText(shortName) then
        shortName = UnitName and UnitName("player") or L["UNKNOWN_PLAYER"] or "玩家"
    end

    local safeShortName = SanitizeChatPlainText(shortName, L["UNKNOWN_PLAYER"] or "玩家")
    local interruptSpellDisplayName = GetInterruptSpellDisplayName(interruptSpellID, interruptSpellName)
    cooldownSeconds = TMA.SafeNumber(cooldownSeconds) or 0

    return FormatInterruptUsedMessage(safeShortName, interruptSpellDisplayName, cooldownSeconds),
        FormatInterruptChatTemplate(safeShortName, interruptSpellDisplayName, cooldownSeconds)
end

local function BuildAndDispatchInterruptNotice(sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, options)
    options = options or {}
    local isPlayerInterrupt = TMA.IsPlayerCombatSource(sourceGUID, nil, sourceFlags)
    local cooldownSpellID = interruptSpellID or (isPlayerInterrupt and GetConfiguredInterruptSpellID()) or interruptSpellID
    local immediateLocal = options.immediateLocal == true
    local cooldownStart, cooldownDuration, activeCooldown
    if isPlayerInterrupt then
        if immediateLocal then
            activeCooldown = GetCachedInterruptCooldownDuration(cooldownSpellID) or GetBaseOrFallbackInterruptCooldown(cooldownSpellID)
            cooldownDuration = activeCooldown
            cooldownStart = TMA.LastInterruptAttemptSucceededAt or lastInterruptAttemptAt or (GetTime and GetTime() or 0)
        else
            cooldownStart, cooldownDuration, activeCooldown = GetActiveInterruptCooldownInfo(cooldownSpellID)
            if not activeCooldown then
                if options.allowCooldownFallback and TMA.IsRecentConfirmedInterruptCast(cooldownSpellID) then
                    activeCooldown = GetPlayerInterruptCooldownDuration(cooldownSpellID)
                    if not activeCooldown or activeCooldown <= 0 then
                        activeCooldown = GetBaseOrFallbackInterruptCooldown(cooldownSpellID)
                    end
                    cooldownDuration = activeCooldown
                    cooldownStart = TMA.LastInterruptAttemptSucceededAt or lastInterruptAttemptAt or (GetTime and GetTime() or 0)
                    TMA.DebugInterrupt("真实冷却未读到：已确认打断技能释放成功，使用基础冷却兜底通报。")
                else
                    TMA.DebugInterrupt("打断通报已取消：打断技能未进入真实冷却，可能是施放失败或目标不可打断。")
                    return
                end
            end
        end
        if not immediateLocal and IsInterruptCooldownAlreadyAnnounced(cooldownSpellID, cooldownStart, cooldownDuration) then
            TMA.DebugInterrupt("已阻止：同一个打断冷却周期已经通报过。")
            return
        end
    end

    local cooldown = activeCooldown
    if not immediateLocal then
        local okCooldown, result = pcall(GetInterruptCooldownSeconds, cooldownSpellID, sourceGUID, sourceName, sourceFlags)
        if okCooldown then
            cooldown = result
        else
            TMA.DebugInterrupt("读取打断冷却失败：" .. TMA.SafeDebugText(result, ""))
            if not isPlayerInterrupt then
                cooldown = GetBaseOrFallbackInterruptCooldown(cooldownSpellID)
            end
        end
    end
    if isPlayerInterrupt and (not cooldown or cooldown <= 0) then
        cooldown = activeCooldown
    end

    local okBuild, localMsg, chatMsg = pcall(BuildInterruptMessagesWithCooldown, sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, cooldown)
    if not okBuild then
        TMA.DebugInterrupt("构建打断通报文本失败，改用纯文本兜底：" .. TMA.SafeDebugText(localMsg, ""))
        local okFallback, fallbackLocalMsg, fallbackChatMsg = pcall(BuildFallbackInterruptMessages, sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, cooldown)
        if okFallback then
            localMsg, chatMsg = fallbackLocalMsg, fallbackChatMsg
        else
            TMA.DebugInterrupt("纯文本兜底构建失败，改用最小通报：" .. TMA.SafeDebugText(fallbackLocalMsg, ""))
            local fallbackInterruptName = GetInterruptSpellDisplayName(interruptSpellID, interruptSpellName)
            cooldown = TMA.SafeNumber(cooldown) or 0
            localMsg = FormatInterruptUsedMessage("玩家", fallbackInterruptName, cooldown)
            chatMsg = localMsg
        end
    end

    chatMsg = SanitizeChatPlainText(chatMsg, "")
    localMsg = SanitizeLocalVisualText(localMsg, chatMsg)
    if chatMsg == "" then
        TMA.DebugInterrupt("打断通报已取消：最终通报文本不是可用的普通字符串。")
        return
    end

    local now = GetTime and GetTime() or 0
    local localNoticeKey = tostring(options.localNoticeKey or chatMsg)
    local localNoticeAlreadyShown = TMA.SafeValueEquals(TMA.LastInterruptImmediateLocalNoticeKey, localNoticeKey)
        and TMA.LastInterruptImmediateLocalNoticeAt
        and now - TMA.LastInterruptImmediateLocalNoticeAt < 2.5
    if options.localOnly then
        if localNoticeAlreadyShown then
            TMA.DebugInterrupt("已阻止：同一次打断动作的即时本地弹幕已显示。")
            return
        end
    else
        if TMA.SafeValueEquals(TMA.LastInterruptAnnounceText, chatMsg)
            and TMA.LastInterruptAnnounceAt
            and now - TMA.LastInterruptAnnounceAt < 2.5
        then
            TMA.DebugInterrupt("已阻止：同一条打断通报文本在事件风暴窗口内重复发送。")
            return
        end
        TMA.LastInterruptAnnounceText = chatMsg
        TMA.LastInterruptAnnounceAt = now
    end

    TMA.DebugInterrupt(
        "准备显示打断通报：打断技能=" .. TMA.SafeDebugText(TMA.LastInterruptPreviewInterruptSpellName, GetFallbackInterruptSpellText())
        .. "，冷却=" .. tostring(cooldown)
        .. "秒"
    )
    if not localNoticeAlreadyShown then
        DispatchInterruptNotice(localMsg)
        if options.localOnly then
            TMA.LastInterruptImmediateLocalNoticeKey = localNoticeKey
            TMA.LastInterruptImmediateLocalNoticeAt = now
        end
    else
        TMA.DebugInterrupt("同一次打断动作已显示即时本地弹幕，本次只继续处理同步。")
    end
    if isPlayerInterrupt and SendInterruptAddonSync and ((not options.localOnly) or options.syncImmediately) then
        local syncKey = tostring(options.localNoticeKey or localNoticeKey or chatMsg)
        local syncAlreadySent = TMA.SafeValueEquals(TMA.LastInterruptAddonSyncKey, syncKey)
            and TMA.LastInterruptAddonSyncAt
            and now - TMA.LastInterruptAddonSyncAt < 2.5
        if syncAlreadySent then
            TMA.DebugInterrupt("已阻止：同一次打断动作的插件同步已发送。")
        else
            TMA.LastInterruptAddonSyncKey = syncKey
            TMA.LastInterruptAddonSyncAt = now
            SendInterruptAddonSync(cooldownSpellID or interruptSpellID, TMA.LastInterruptPreviewInterruptSpellName, cooldown, localMsg)
        end
    end
end

local function ShowInterruptNotice(localMsg)
    if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.enableInterruptAnnounce then
        return
    end
    if not TMA.IsInterruptAnnounceContextValid() then
        return
    end

    if TMA.ShowRestrictedInterruptDanmaku then
        local ok, shown = pcall(TMA.ShowRestrictedInterruptDanmaku, SanitizeLocalVisualText(localMsg, ""))
        if not ok then
            TMA.DebugInterrupt("本地弹幕调用出错：" .. tostring(shown))
            shown = false
        end
        if ok and shown then
            return
        end
        TMA.DebugInterrupt("本地弹幕未显示，已跳过聊天窗口兜底输出。")
        return
    end

    TMA.DebugInterrupt("本地弹幕模块不可用，已跳过聊天窗口兜底输出。")
end

DispatchInterruptNotice = function(localMsg)
    if not TMA.IsInterruptAnnounceContextValid() then return end
    ShowInterruptNotice(localMsg)
end

local function RegisterInterruptAddonSyncPrefix()
    if TMA.InterruptAddonSyncPrefixRegistered then
        return true
    end

    local ok, registered
    if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
        ok, registered = pcall(C_ChatInfo.RegisterAddonMessagePrefix, INTERRUPT_SYNC_PREFIX)
    elseif RegisterAddonMessagePrefix then
        ok, registered = pcall(RegisterAddonMessagePrefix, INTERRUPT_SYNC_PREFIX)
    end

    if ok and registered ~= false then
        TMA.InterruptAddonSyncPrefixRegistered = true
        TMA.DebugInterrupt("插件同步前缀已注册：" .. INTERRUPT_SYNC_PREFIX)
        return true
    end

    TMA.DebugInterrupt("插件同步前缀注册失败。")
    return false
end

local function GetInterruptSyncDistributions()
    local distributions = {}
    local seen = {}
    local function addDistribution(distribution)
        if not distribution or seen[distribution] then return end
        seen[distribution] = true
        distributions[#distributions + 1] = distribution
    end

    if not IsInGroup or not IsInGroup() then return distributions end

    if IsChallengeModeActiveSafe() then
        addDistribution("PARTY")
        addDistribution("INSTANCE_CHAT")
        return distributions
    end

    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        addDistribution("INSTANCE_CHAT")
    end
    if IsInRaid and IsInRaid() then
        addDistribution("RAID")
    else
        addDistribution("PARTY")
    end

    return distributions
end

function TMA.ShouldUseLocalGroupInterruptFallback()
    return IsChallengeModeActiveSafe() or IsAddonChatRestricted()
end

function TMA.IsOwnSpellcastUnit(unit)
    if not unit then return false end
    if unit == "player" or unit == "pet" or unit == "vehicle" then return true end
    if UnitIsUnit then
        return UnitIsUnit(unit, "player")
            or UnitIsUnit(unit, "pet")
            or UnitIsUnit(unit, "vehicle")
    end
    return false
end

function TMA.GetGroupInterruptOwnerUnit(unit)
    if not unit or TMA.IsOwnSpellcastUnit(unit) then return nil end
    if not UnitExists or not UnitExists(unit) then return nil end

    if unit:match("^party%d+$") or unit:match("^raid%d+$") then
        return unit
    end

    local partyPetIndex = unit:match("^partypet(%d+)$")
    if partyPetIndex then
        local ownerUnit = "party" .. partyPetIndex
        if UnitExists(ownerUnit) then return ownerUnit end
    end

    local raidPetIndex = unit:match("^raidpet(%d+)$")
    if raidPetIndex then
        local ownerUnit = "raid" .. raidPetIndex
        if UnitExists(ownerUnit) then return ownerUnit end
    end

    if UnitIsPlayer and UnitIsPlayer(unit) and UnitName then
        local ok, unitName = pcall(UnitName, unit)
        if ok and unitName then
            return TMA.GetGroupUnitByName(unitName)
        end
    end
end

function TMA.RecentlyHandledGroupInterrupt(ownerName, spellID, now)
    spellID = TMA.SafeNumber(spellID)
    if not ownerName or not spellID then return false end

    if TMA.LastInterruptSyncReceiveAt
        and now - TMA.LastInterruptSyncReceiveAt < 1.5
        and TMA.SafeValueEquals(TMA.LastInterruptSyncReceivePlayerName, ownerName)
        and TMA.SafeNumber(TMA.LastInterruptSyncReceiveSpellID) == spellID
    then
        return true
    end

    if TMA.HasRecentGroupInterruptCast(ownerName, spellID, now, 2.5) then
        return true
    end

    TMA.MarkRecentGroupInterruptCast(ownerName, spellID, now)
    return false
end

function TMA.GetRecentGroupInterruptKey(ownerName, spellID)
    spellID = TMA.SafeNumber(spellID)
    if not ownerName or not spellID then return nil end
    return tostring(ownerName) .. ":" .. tostring(spellID)
end

function TMA.TrimRecentGroupInterruptCasts(now)
    local recentGroupInterruptCasts = TMA.RecentGroupInterruptCasts or {}
    TMA.RecentGroupInterruptCasts = recentGroupInterruptCasts
    for key, seenAt in pairs(recentGroupInterruptCasts) do
        if not seenAt or now - seenAt > 4 then
            recentGroupInterruptCasts[key] = nil
        end
    end
    return recentGroupInterruptCasts
end

function TMA.HasRecentGroupInterruptCast(ownerName, spellID, now, window)
    local key = TMA.GetRecentGroupInterruptKey(ownerName, spellID)
    if not key then return false end
    local recentGroupInterruptCasts = TMA.TrimRecentGroupInterruptCasts(now or (GetTime and GetTime() or 0))
    local seenAt = recentGroupInterruptCasts[key]
    return seenAt and now and now - seenAt < (window or 2.5)
end

function TMA.MarkRecentGroupInterruptCast(ownerName, spellID, now)
    local key = TMA.GetRecentGroupInterruptKey(ownerName, spellID)
    if not key then return end
    local recentGroupInterruptCasts = TMA.TrimRecentGroupInterruptCasts(now or (GetTime and GetTime() or 0))
    recentGroupInterruptCasts[key] = now or (GetTime and GetTime() or 0)
end

local function EncodeSyncField(value)
    local text = SanitizeChatPlainText(value, "")
    text = text:gsub("%%", "%%p")
    text = text:gsub("\t", "%%t")
    text = text:gsub("\n", "%%n")
    text = text:gsub("\r", "%%r")
    return text
end

local function DecodeSyncField(value)
    local text = tostring(value or "")
    text = text:gsub("%%r", "\r")
    text = text:gsub("%%n", "\n")
    text = text:gsub("%%t", "\t")
    text = text:gsub("%%p", "%%")
    return SanitizeChatPlainText(text, "")
end

function TMA.EncodeSyncVisualField(value)
    local text = SanitizeLocalVisualText(value, "")
    text = text:gsub("%%", "%%p")
    text = text:gsub("\t", "%%t")
    text = text:gsub("\n", "%%n")
    text = text:gsub("\r", "%%r")
    return text
end

function TMA.DecodeSyncVisualField(value)
    local text = tostring(value or "")
    text = text:gsub("%%r", "\r")
    text = text:gsub("%%n", "\n")
    text = text:gsub("%%t", "\t")
    text = text:gsub("%%p", "%%")
    return SanitizeLocalVisualText(text, "")
end

local function SplitSyncPayload(message)
    local parts = {}
    message = tostring(message or "")
    local startIndex = 1
    while true do
        local tabIndex = message:find("\t", startIndex, true)
        if tabIndex then
            parts[#parts + 1] = message:sub(startIndex, tabIndex - 1)
            startIndex = tabIndex + 1
        else
            parts[#parts + 1] = message:sub(startIndex)
            break
        end
    end
    return parts
end

local function IsSelfAddonSender(sender)
    local playerName = UnitName and UnitName("player") or nil
    if not playerName then return false end
    return IsSameCombatName(sender, playerName) or IsSameCombatName(GetShortPlayerName(sender), playerName)
end

SendInterruptAddonSync = function(interruptSpellID, interruptSpellName, cooldownSeconds, syncMessage)
    if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.interruptAddonSyncEnabled then return end
    if not TargetMarkerAssistantDB.enableInterruptAnnounce then return end
    if not TMA.IsInterruptAnnounceContextValid() then return end
    if not RegisterInterruptAddonSyncPrefix() then return end

    local distributions = GetInterruptSyncDistributions()
    if not distributions[1] then return end

    TMA.InterruptSyncSerial = (TMA.InterruptSyncSerial or 0) + 1
    local playerName = GetShortPlayerName(UnitName and UnitName("player") or "player")
    local classFile = TMA.GetPlayerClassFile and TMA.GetPlayerClassFile() or ""
    local encodedSpellName = EncodeSyncField(interruptSpellName or GetInterruptSpellDisplayName(interruptSpellID))
    local encodedClassFile = EncodeSyncField(classFile)
    local encodedPlayerName = EncodeSyncField(playerName)
    local encodedMessage = TMA.EncodeSyncVisualField(syncMessage or "")
    local payload = table.concat({
        "I",
        INTERRUPT_SYNC_VERSION,
        tostring(TMA.InterruptSyncSerial),
        tostring(TMA.SafeNumber(interruptSpellID) or 0),
        encodedSpellName,
        tostring(TMA.SafeNumber(cooldownSeconds) or 0),
        encodedClassFile,
        encodedPlayerName,
        encodedMessage,
    }, "\t")

    local function sendPayload(distribution, syncPayload)
        if C_ChatInfo and C_ChatInfo.SendAddonMessage then
            return pcall(C_ChatInfo.SendAddonMessage, INTERRUPT_SYNC_PREFIX, syncPayload, distribution)
        elseif SendAddonMessage then
            return pcall(SendAddonMessage, INTERRUPT_SYNC_PREFIX, syncPayload, distribution)
        end
        return false, "SendAddonMessage unavailable"
    end

    local legacyPayload
    local sentDistributions = {}
    local sentWithoutMessage = false
    local lastErr
    for i = 1, #distributions do
        local distribution = distributions[i]
        local ok, result = sendPayload(distribution, payload)
        if not ok or result == false then
            if not legacyPayload then
                legacyPayload = table.concat({
                    "I",
                    INTERRUPT_SYNC_VERSION,
                    tostring(TMA.InterruptSyncSerial),
                    tostring(TMA.SafeNumber(interruptSpellID) or 0),
                    encodedSpellName,
                    tostring(TMA.SafeNumber(cooldownSeconds) or 0),
                    encodedClassFile,
                    encodedPlayerName,
                }, "\t")
            end
            ok, result = sendPayload(distribution, legacyPayload)
            if ok and result ~= false then
                sentWithoutMessage = true
            end
        end

        if ok and result ~= false then
            sentDistributions[#sentDistributions + 1] = distribution
        else
            lastErr = result
        end
    end

    if sentDistributions[1] then
        local distributionText = table.concat(sentDistributions, "/")
        if sentWithoutMessage then
            TMA.DebugInterrupt("插件同步已发送：" .. distributionText .. "（通报文本过长，已退回旧格式）")
        else
            TMA.DebugInterrupt("插件同步已发送：" .. distributionText)
        end
    else
        TMA.DebugInterrupt("插件同步发送失败：" .. TMA.SafeDebugText(lastErr, "不可用"))
    end
end

HandleInterruptAddonSyncMessage = function(prefix, message, channel, sender)
    if prefix ~= INTERRUPT_SYNC_PREFIX then return end
    if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.interruptAddonSyncEnabled then return end
    if not TargetMarkerAssistantDB.enableInterruptAnnounce then return end
    if not TMA.IsInterruptAnnounceContextValid() then return end
    if IsSelfAddonSender(sender) then return end

    local parts = SplitSyncPayload(message)
    if parts[1] ~= "I" or parts[2] ~= INTERRUPT_SYNC_VERSION then return end

    local serial = DecodeSyncField(parts[3])
    local spellID = TMA.SafeNumber(parts[4])
    local spellName = DecodeSyncField(parts[5])
    local cooldownSeconds = TMA.SafeNumber(parts[6]) or 0
    local classFile = DecodeSyncField(parts[7])
    local playerName = DecodeSyncField(parts[8])
    local syncedMessage = TMA.DecodeSyncVisualField(parts[9])

    if not spellID or not TMA.SafeIsNonEmptyText(spellName) then return end
    if not TMA.SafeIsNonEmptyText(playerName) then
        playerName = GetShortPlayerName(sender)
    end

    local duplicateKey = tostring(sender or playerName or "") .. ":" .. tostring(serial or "") .. ":" .. tostring(spellID)
    local now = GetTime and GetTime() or 0
    TMA.InterruptSyncReceiveSeen = TMA.InterruptSyncReceiveSeen or {}
    for key, seenAt in pairs(TMA.InterruptSyncReceiveSeen) do
        if not seenAt or now - seenAt > 5 then
            TMA.InterruptSyncReceiveSeen[key] = nil
        end
    end
    if TMA.InterruptSyncReceiveSeen[duplicateKey] then
        return
    end
    TMA.LastInterruptSyncReceiveKey = duplicateKey
    TMA.LastInterruptSyncReceiveAt = now
    TMA.InterruptSyncReceiveSeen[duplicateKey] = now

    local unit = TMA.GetGroupUnitByName(playerName) or TMA.GetGroupUnitByName(sender)
    classFile = classFile ~= "" and classFile or (unit and TMA.GetClassFileByUnit(unit)) or nil
    local safePlayerName = SanitizeChatPlainText(GetShortPlayerName(playerName), L["UNKNOWN_PLAYER"] or "队友")
    local coloredName = ColorizePlayerName(safePlayerName, classFile)
    if not TMA.IsPublicChatText(coloredName) then
        coloredName = safePlayerName
    end
    TMA.LastInterruptSyncReceivePlayerName = safePlayerName
    TMA.LastInterruptSyncReceiveSpellID = spellID
    if TMA.HasRecentGroupInterruptCast(safePlayerName, spellID, now, 2.5) then
        TMA.DebugInterrupt("已阻止：队友打断已由本地队伍事件兜底显示。")
        return
    end

    local localMsg
    if TMA.SafeIsNonEmptyText(syncedMessage) then
        localMsg = SanitizeLocalVisualText(syncedMessage, "")
    else
        localMsg = TMA.FormatInterruptLocalTemplateWithTemplate(
            GetInterruptAnnounceTemplate(),
            coloredName,
            GetInterruptSpellLocalDisplay(spellID, spellName),
            cooldownSeconds
        )
    end
    TMA.DebugInterrupt("收到队友打断同步：" .. safePlayerName .. "，技能=" .. spellName)
    ShowInterruptNotice(localMsg)
end

local function HandleGroupInterruptSpellcast(unit, castGUID, spellID, spellName)
    if not TMA.ShouldUseLocalGroupInterruptFallback() then return end
    if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.enableInterruptAnnounce then return end
    if not TargetMarkerAssistantDB.interruptAddonSyncEnabled then return end
    if not TMA.IsInterruptAnnounceContextValid() then return end

    spellID = TMA.SafeNumber(spellID)
    if not TMA.IsInterruptSpellID(spellID) then return end

    local ownerUnit = TMA.GetGroupInterruptOwnerUnit(unit)
    if not ownerUnit then return end

    local ownerName = UnitName and UnitName(ownerUnit) or nil
    ownerName = SanitizeChatPlainText(GetShortPlayerName(ownerName), L["UNKNOWN_PLAYER"] or "队友")
    if ownerName == "" then return end

    local now = GetTime and GetTime() or 0
    if TMA.RecentlyHandledGroupInterrupt(ownerName, spellID, now) then return end

    local classFile = TMA.GetClassFileByUnit(ownerUnit)
    local coloredName = ColorizePlayerName(ownerName, classFile)
    if not TMA.IsPublicChatText(coloredName) then
        coloredName = ownerName
    end

    local interruptName = GetInterruptSpellDisplayName(spellID, spellName)
    local cooldown = GetBaseOrFallbackInterruptCooldown(spellID)
    local localMsg = FormatInterruptLocalTemplate(
        coloredName,
        GetInterruptSpellLocalDisplay(spellID, interruptName),
        cooldown
    )

    TMA.DebugInterrupt(
        "本地捕获队友打断技能："
        .. ownerName
        .. "，技能=" .. TMA.SafeDebugText(interruptName, tostring(spellID))
        .. "，来源=" .. TMA.SafeDebugText(unit, "")
    )
    ShowInterruptNotice(localMsg)
end

local function HandleInterruptEvent(sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, options)
    local isPlayerInterrupt = TMA.IsPlayerCombatSource(sourceGUID, nil, sourceFlags)
    local now = GetTime and GetTime() or 0
    if isPlayerInterrupt and TMA.LastInterruptAttemptToken then
        local attemptDispatchKey = "attempt:" .. tostring(TMA.LastInterruptAttemptToken)
        if TMA.SafeValueEquals(TMA.LastInterruptDispatchToken, attemptDispatchKey)
            and TMA.LastInterruptDispatchAt
            and now - TMA.LastInterruptDispatchAt < 4
        then
            TMA.DebugInterrupt("已阻止：同一次玩家打断动作已经认领过通报。")
            return
        end
        TMA.LastInterruptDispatchToken = attemptDispatchKey
        TMA.LastInterruptDispatchAt = now
    end

    local dispatchKey = tostring(TMA.LastInterruptAttemptToken or TMA.SafeNumber(interruptSpellID) or 0)
    local duplicateWindow = 2.5
    if TMA.SafeValueEquals(lastInterruptKey, dispatchKey) and lastInterruptTime and now - lastInterruptTime < duplicateWindow then
        TMA.DebugInterrupt("已阻止：同一次打断通报已处理。")
        return
    end
    lastInterruptKey = dispatchKey
    lastInterruptTime = now

    if isPlayerInterrupt then
        local localNoticeKey = "attempt:" .. dispatchKey
        BuildAndDispatchInterruptNotice(sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, {
            immediateLocal = true,
            localOnly = true,
            syncImmediately = true,
            localNoticeKey = localNoticeKey,
        })

        options = options or {}
        options.localNoticeKey = localNoticeKey
        local function announceOwnInterrupt()
            if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.enableInterruptAnnounce then return end
            if not TMA.IsInterruptAnnounceContextValid() then return end

            if TMA.DB and TMA.DB.debugInterruptAnnounce then
                local cooldownSpellID = interruptSpellID or GetConfiguredInterruptSpellID()
                local realCooldown = GetRealCooldownRemaining(cooldownSpellID)
                if not realCooldown or realCooldown <= 0 then
                    if options and options.allowCooldownFallback then
                        TMA.DebugInterrupt("真实冷却未开始：尝试使用确认施放兜底通报。")
                    else
                        TMA.DebugInterrupt("真实冷却未开始：暂不发送打断通报。")
                    end
                end
            end

            BuildAndDispatchInterruptNotice(sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, options)
        end

        C_Timer.After(0.05, announceOwnInterrupt)
        return
    end

    BuildAndDispatchInterruptNotice(sourceGUID, sourceName, sourceFlags, interruptSpellID, interruptSpellName, options)
end

local function ShowImmediateOwnInterruptNotice(spellID, spellName)
    if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.enableInterruptAnnounce then return end
    if not TMA.IsInterruptAnnounceContextValid() then return end

    spellID = TMA.SafeNumber(spellID) or GetConfiguredInterruptSpellID()
    if not spellID then return end

    local token = TMA.LastInterruptAttemptToken or tostring(spellID)
    BuildAndDispatchInterruptNotice(
        UnitGUID and UnitGUID("player") or nil,
        UnitName and UnitName("player") or nil,
        COMBATLOG_OBJECT_AFFILIATION_MINE,
        spellID,
        spellName or lastInterruptAttemptSpellName,
        {
            immediateLocal = true,
            localOnly = true,
            syncImmediately = true,
            localNoticeKey = "attempt:" .. tostring(token),
        }
    )
end

function TMA.GetSpellNameFromEvent(spellID, ...)
    local spellName = TMA.GetPublicSpellNameByID(spellID)
    if spellName then return spellName end
end

function TMA.IsInterruptCastUnitToken(unit)
    if type(unit) ~= "string" then return false end
    if unit == "target" or unit == "focus" or unit == "mouseover" then return true end
    if unit:match("^boss%d+$") or unit:match("^arena%d+$") or unit:match("^nameplate%d+$") then return true end
    return false
end

function TMA.GetInterruptCastScanUnits()
    local units = { "target", "focus", "mouseover" }
    for i = 1, 5 do
        units[#units + 1] = "boss" .. tostring(i)
    end
    for i = 1, 5 do
        units[#units + 1] = "arena" .. tostring(i)
    end
    for i = 1, 40 do
        units[#units + 1] = "nameplate" .. tostring(i)
    end
    return units
end

function TMA.CaptureVisibleTrackedCast(unit, reason)
    if not TMA.IsInterruptCastUnitToken(unit) then return false end
    if not UnitExists or not UnitExists(unit) then return false end

    local ok, spellID, spellName = pcall(GetVisibleUnitCastInfo, unit)
    if not ok or not spellID then
        return false
    end

    return CacheInterruptCast(unit, spellID, spellName, reason) ~= nil
end

function TMA.CaptureBestVisibleInterruptCast(reason)
    local units = TMA.GetInterruptCastScanUnits()
    for i = 1, #units do
        if TMA.CaptureVisibleTrackedCast(units[i], reason) then
            return true, TMA.LastTrackedCast
        end
    end
    return false
end

function TMA.ClearTrackedUnitCast(unit)
    if TMA.IsInterruptCastUnitToken(unit) then
        activeCombatCasts[unit] = nil
        TMA.TargetFocusCastCache[unit] = nil
    end
end

function TMA.GetRecentTrackedCastCandidate(now, preferredGUID)
    now = now or (GetTime and GetTime() or 0)

    local candidates = {}
    local function AddCandidate(cast)
        if cast then
            candidates[#candidates + 1] = cast
        end
    end

    for _, unit in ipairs(interruptCastCacheUnits) do
        AddCandidate(activeCombatCasts[unit])
        AddCandidate(TMA.TargetFocusCastCache[unit])
    end
    AddCandidate(TMA.LastPreInterruptCast)
    AddCandidate(TMA.LastTrackedCast)
    for unit, cast in pairs(activeCombatCasts) do
        if cast then
            cast.unit = cast.unit or unit
            AddCandidate(cast)
        end
    end

    local function IsUsableCast(cast)
        local publicName = cast and (TMA.GetPublicSpellNameByID(cast.spellID) or cast.spellName)
        return cast
            and now - (cast.at or 0) <= INTERRUPT_CAST_CACHE_WINDOW
            and (cast.spellID or (TMA.SafeIsNonEmptyText(publicName) and not TMA.IsUnknownInterruptedSpellName(publicName)))
    end

    local function FindByGUID(guid)
        if not guid then return nil end
        for i = 1, #candidates do
            local cast = candidates[i]
            if IsUsableCast(cast) and cast.guid and TMA.SafeValueEquals(cast.guid, guid) then
                return cast
            end
        end
    end

    local matched = FindByGUID(preferredGUID)
    if matched then return matched end

    for i = 1, #candidates do
        local cast = candidates[i]
        if IsUsableCast(cast) then
            return cast
        end
    end
end

function TMA.TryDispatchActiveTrackedCastFallback(reason)
    if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.enableInterruptAnnounce then return end
    if not TMA.IsInterruptAnnounceContextValid() then return end

    local now = GetTime and GetTime() or 0
    if not lastInterruptAttemptAt or now - lastInterruptAttemptAt > INTERRUPT_COOLDOWN_DETECTION_WINDOW then
        TMA.DebugInterrupt("主动兜底已取消：没有最近的打断技能使用记录。")
        return
    end

    local cooldownSpellID = TMA.SafeNumber(lastInterruptAttemptSpellID) or GetConfiguredInterruptSpellID()
    local cooldownStart = GetActiveInterruptCooldownInfo(cooldownSpellID)
    if not cooldownStart then
        if TMA.LastInterruptAttemptSucceededAt and now - TMA.LastInterruptAttemptSucceededAt < 1.2 then
            return
        end
        if TMA.IsRecentConfirmedInterruptCast(cooldownSpellID) then
            TMA.DebugInterrupt("主动兜底：技能已确认释放，但冷却暂未读到，改用基础冷却通报。")
            HandleInterruptEvent(
                UnitGUID("player"),
                UnitName("player"),
                COMBATLOG_OBJECT_AFFILIATION_MINE,
                lastInterruptAttemptSpellID,
                lastInterruptAttemptSpellName,
                { allowCooldownFallback = true }
            )
            return
        end
        DebugInterruptAttemptCancel("主动兜底已取消：打断技能未进入真实冷却。")
        return
    end
    local isRecentCooldown, cooldownBlockedReason = IsCooldownFromRecentInterruptAttempt(cooldownSpellID, cooldownStart)
    if not isRecentCooldown then
        DebugInterruptAttemptCancel("主动兜底已取消：" .. TMA.SafeDebugText(cooldownBlockedReason, "冷却不属于本次打断动作。"))
        return
    end

    local fallbackKey = "attempt:"
        .. tostring(TMA.LastInterruptAttemptToken or TMA.SafeNumber(lastInterruptAttemptSpellID) or 0)
    local duplicateWindow = 2.5
    if TMA.SafeValueEquals(lastInterruptSkillKey, fallbackKey) and lastInterruptSkillTime and now - lastInterruptSkillTime < duplicateWindow then
        return
    end

    lastInterruptSkillKey = fallbackKey
    lastInterruptSkillTime = now
    TMA.DebugInterrupt(
        "准备按打断动作通报：打断技能ID=" .. TMA.SafeDebugText(lastInterruptAttemptSpellID, "")
    )

    HandleInterruptEvent(
        UnitGUID("player"),
        UnitName("player"),
        COMBATLOG_OBJECT_AFFILIATION_MINE,
        lastInterruptAttemptSpellID,
        lastInterruptAttemptSpellName
    )
end

function TMA.ScheduleTrackedCastFallback(reason)
    local delays = { 0.08, 0.25, 0.6, 1.0, 1.5 }
    for i = 1, #delays do
        local delay = delays[i]
        local retryIndex = i
        C_Timer.After(delay, function()
            local suffix = retryIndex == 1 and "" or (":retry" .. tostring(retryIndex - 1))
            TMA.TryDispatchActiveTrackedCastFallback(tostring(reason or "PLAYER_INTERRUPT_SPELL_USED") .. suffix)
        end)
    end
end

local function ShouldRecordInterruptAttemptFromSpell(spellID, spellName)
    if TMA.IsConfiguredInterruptSpell(spellID, spellName) then
        return true, true
    end
    if TMA.IsHardInterruptSpellID(spellID) then
        return true, true
    end
    if TMA.IsInterruptStopSpellID(spellID) then
        local recentCast = TMA.GetRecentTrackedCastCandidate and TMA.GetRecentTrackedCastCandidate(GetTime and GetTime() or 0)
        if recentCast then
            return true, true
        end
        TMA.DebugInterrupt("已忽略控制类断条技能：未捕获到正在施法的可见目标。")
    end
    return false, false
end

function TMA.RememberInterruptAttemptFromSpell(unit, spellID, spellName, confirmedCast)
    if not TMA.IsInterruptAnnounceContextValid() then return end
    if not spellID and not spellName then return end
    local shouldRecord, allowImmediateNotice = ShouldRecordInterruptAttemptFromSpell(spellID, spellName)
    if not shouldRecord then
        return
    end

    local now = GetTime and GetTime() or 0
    local attemptKey = tostring(TMA.SafeNumber(spellID) or 0)
    if TMA.SafeValueEquals(TMA.LastInterruptAttemptKey, attemptKey)
        and TMA.LastInterruptAttemptRecordedAt
        and now - TMA.LastInterruptAttemptRecordedAt < 2.5
    then
        if confirmedCast then
            TMA.LastInterruptAttemptSucceededAt = now
            TMA.LastInterruptAttemptSucceededSpellID = TMA.SafeNumber(spellID)
            TMA.LastInterruptAttemptSucceededToken = TMA.LastInterruptAttemptToken
            lastInterruptAttemptSpellName = lastInterruptAttemptSpellName
                or TMA.GetPublicSpellNameByID(spellID)
                or (TMA.IsPublicChatText(spellName) and spellName)
                or nil
            TMA.DebugInterrupt(
                "确认打断技能释放成功：技能ID=" .. TMA.SafeDebugText(TMA.LastInterruptAttemptSucceededSpellID, "")
            )
            if allowImmediateNotice then
                ShowImmediateOwnInterruptNotice(TMA.LastInterruptAttemptSucceededSpellID, lastInterruptAttemptSpellName or spellName)
            end
            if TMA.ScheduleTrackedCastFallback then
                TMA.ScheduleTrackedCastFallback("PLAYER_INTERRUPT_SPELL_SUCCEEDED")
            end
        end
        return
    end
    TMA.LastInterruptAttemptKey = attemptKey
    TMA.LastInterruptAttemptRecordedAt = now
    TMA.InterruptAttemptSerial = (TMA.InterruptAttemptSerial or 0) + 1
    TMA.LastInterruptAttemptToken = tostring(TMA.InterruptAttemptSerial)

    lastInterruptAttemptAt = now
    lastInterruptAttemptSpellID = TMA.SafeNumber(spellID)
    lastInterruptAttemptSpellName = TMA.GetPublicSpellNameByID(spellID) or (TMA.IsPublicChatText(spellName) and spellName) or nil
    TMA.LastInterruptAttemptSucceededAt = confirmedCast and now or nil
    TMA.LastInterruptAttemptSucceededSpellID = confirmedCast and lastInterruptAttemptSpellID or nil
    TMA.LastInterruptAttemptSucceededToken = confirmedCast and TMA.LastInterruptAttemptToken or nil
    TMA.DebugInterrupt(
        "记录打断技能使用：技能ID=" .. TMA.SafeDebugText(lastInterruptAttemptSpellID, "")
    )
    if confirmedCast then
        TMA.DebugInterrupt(
            "确认打断技能释放成功：技能ID=" .. TMA.SafeDebugText(TMA.LastInterruptAttemptSucceededSpellID, "")
        )
        if allowImmediateNotice then
            ShowImmediateOwnInterruptNotice(lastInterruptAttemptSpellID, lastInterruptAttemptSpellName or spellName)
        end
    end

    if TMA.ScheduleTrackedCastFallback then
        TMA.ScheduleTrackedCastFallback("PLAYER_INTERRUPT_SPELL_USED")
    end
end

function TMA.GetSpellCooldownSnapshot(spellID)
    return GetLiveSpellCooldownInfo(spellID)
end

function TMA.ScanConfiguredInterruptCooldown(reason)
    if not TargetMarkerAssistantDB or not TargetMarkerAssistantDB.enableInterruptAnnounce then return end
    if not TMA.IsInterruptAnnounceContextValid() then return end

    local spellID = GetConfiguredInterruptSpellID()
    if not spellID then
        return
    end

    local startTime, duration, remaining = GetActiveInterruptCooldownInfo(spellID)
    if not startTime then
        return
    end
    local isRecentCooldown, cooldownBlockedReason = IsCooldownFromRecentInterruptAttempt(spellID, startTime)
    if not isRecentCooldown then
        TMA.DebugInterrupt("冷却事件已忽略：" .. TMA.SafeDebugText(cooldownBlockedReason, "冷却不属于本次打断动作。"))
        return
    end

    local cooldownKey = TMA.SafeDebugText(spellID, "0") .. ":" .. TMA.SafeDebugText(startTime, "0")
    local cooldownKeyID = TMA.SafeDebugText(spellID, "0")
    local okLastCooldown, lastCooldownKey = pcall(function()
        return lastActionCooldownStarts[cooldownKeyID]
    end)
    if okLastCooldown and TMA.SafeValueEquals(lastCooldownKey, cooldownKey) then return end
    pcall(function()
        lastActionCooldownStarts[cooldownKeyID] = cooldownKey
    end)
    if TMA.SafeValueEquals(TMA.LastInterruptCooldownScanKey, cooldownKeyID)
        and TMA.LastInterruptCooldownScanAt
        and (GetTime and GetTime() or 0) - TMA.LastInterruptCooldownScanAt < 6
    then
        return
    end
    TMA.LastInterruptCooldownScanKey = cooldownKeyID
    TMA.LastInterruptCooldownScanAt = GetTime and GetTime() or 0

    local spellName = TMA.GetPublicSpellNameByID(spellID) or TargetMarkerAssistantDB.interruptSpellName or TargetMarkerAssistantDB.interruptSpellInput
    local now = GetTime and GetTime() or 0
    local fallbackKey = "attempt:" .. tostring(TMA.LastInterruptAttemptToken or TMA.SafeNumber(lastInterruptAttemptSpellID) or spellID or 0)
    lastInterruptSkillKey = fallbackKey
    lastInterruptSkillTime = now
    TMA.DebugInterrupt("冷却事件确认打断技能进入真实冷却，准备通报。")
    BuildAndDispatchInterruptNotice(
        UnitGUID and UnitGUID("player") or nil,
        UnitName and UnitName("player") or nil,
        COMBATLOG_OBJECT_AFFILIATION_MINE,
        spellID,
        spellName
    )
end

local function RememberInterruptAttempt(unit, spellID, spellName, confirmedCast)
    spellID = TMA.SafeNumber(spellID)
    spellName = spellName or TMA.GetSpellNameByID(spellID)
    TMA.RememberInterruptAttemptFromSpell(unit, spellID, spellName, confirmedCast)
end


TMA.ResolveSucceededSpell = ResolveSucceededSpell
TMA.ResolveConfiguredInterruptSpell = ResolveConfiguredInterruptSpell
TMA.SetConfiguredInterruptSpellInput = SetConfiguredInterruptSpellInput
TMA.GetConfiguredInterruptSpellID = GetConfiguredInterruptSpellID
TMA.GetBaseOrFallbackInterruptCooldown = GetBaseOrFallbackInterruptCooldown
TMA.GetPlayerInterruptCooldownDuration = GetPlayerInterruptCooldownDuration
TMA.GetObservedInterruptCooldownDuration = GetObservedInterruptCooldownDuration
TMA.SanitizeChatPlainText = SanitizeChatPlainText
TMA.SanitizeLocalVisualText = SanitizeLocalVisualText
TMA.GetDefaultInterruptChatTemplate = GetDefaultInterruptChatTemplate
TMA.FormatInterruptChatPreview = FormatInterruptChatPreview
TMA.FormatInterruptDanmakuPreviewMessage = FormatInterruptDanmakuPreviewMessage
TMA.GetShortPlayerName = GetShortPlayerName
TMA.ColorizePlayerName = ColorizePlayerName
TMA.GetClassFileByGUID = GetClassFileByGUID
TMA.GetInterruptSpellDisplayName = GetInterruptSpellDisplayName
TMA.GetInterruptSpellLocalDisplay = GetInterruptSpellLocalDisplay
TMA.FormatInterruptLocalTemplate = FormatInterruptLocalTemplate
TMA.RegisterInterruptAddonSyncPrefix = RegisterInterruptAddonSyncPrefix
TMA.HandleInterruptAddonSyncMessage = HandleInterruptAddonSyncMessage
TMA.HandleGroupInterruptSpellcast = HandleGroupInterruptSpellcast
TMA.DispatchInterruptNotice = DispatchInterruptNotice
TMA.RememberInterruptAttempt = RememberInterruptAttempt

function TMA.DispatchTestInterruptNotice()
    if not TMA.IsInterruptAnnounceContextValid() then
        TMA.DebugInterrupt("测试失败：玩家不在队伍或团队中，打断通报不会生效。")
        return false
    end

    local playerName = GetShortPlayerName(UnitName("player") or "player")
    local classFile = GetClassFileByGUID(UnitGUID("player"))
    local coloredName = ColorizePlayerName(playerName, classFile)
    local configuredSpellID = GetConfiguredInterruptSpellID()
    local interruptSpellName = GetInterruptSpellDisplayName(configuredSpellID, TargetMarkerAssistantDB and TargetMarkerAssistantDB.interruptSpellName)
    local localMsg = FormatInterruptLocalTemplate(
        coloredName,
        GetInterruptSpellLocalDisplay(configuredSpellID, interruptSpellName),
        24
    )
    TMA.DebugInterrupt("已执行 /tma testinterrupt，正在测试通报分发逻辑。")
    DispatchInterruptNotice(localMsg)
    return true
end
