MCDVoiceCooldownDB = (type(MCDVoiceCooldownDB) == "table") and MCDVoiceCooldownDB or {}

local ADDON_NAME = ...
local ENTRY_MIN = 1
local ALL_CLASSES_ID = 0
local ALL_SPECS_ID = 0
local OBJECT_TYPE_SPELL = "spell"
local OBJECT_TYPE_ITEM = "item"

local MODE_TTS = "tts"
local MODE_SOUND = "sound"
local GetSpellInfo = rawget(_G, "GetSpellInfo")
local GetItemInfo = rawget(_G, "GetItemInfo")
local GetItemIcon = rawget(_G, "GetItemIcon")
local GetItemSpell = rawget(_G, "GetItemSpell")
local TextToSpeech_SpeakText = rawget(_G, "TextToSpeech_SpeakText")
local UIDropDownMenu_Initialize = rawget(_G, "UIDropDownMenu_Initialize")
local UIDropDownMenu_CreateInfo = rawget(_G, "UIDropDownMenu_CreateInfo")
local UIDropDownMenu_AddButton = rawget(_G, "UIDropDownMenu_AddButton")
local UIDropDownMenu_SetText = rawget(_G, "UIDropDownMenu_SetText")
local UIDropDownMenu_SetWidth = rawget(_G, "UIDropDownMenu_SetWidth")
local UIDropDownMenu_JustifyText = rawget(_G, "UIDropDownMenu_JustifyText")
local UIDropDownMenu_DisableDropDown = rawget(_G, "UIDropDownMenu_DisableDropDown")
local UIDropDownMenu_EnableDropDown = rawget(_G, "UIDropDownMenu_EnableDropDown")
local GetNumClasses = rawget(_G, "GetNumClasses")
local GetClassInfo = rawget(_G, "GetClassInfo")
local GetNumSpecializationsForClassID = rawget(_G, "GetNumSpecializationsForClassID")
local GetSpecializationInfoForClassID = rawget(_G, "GetSpecializationInfoForClassID")
local NS = rawget(_G, "MCDVoiceCooldownNS") or {}
_G.MCDVoiceCooldownNS = NS

local L = NS.L or function(key, ...)
    if select("#", ...) > 0 then
        return string.format(tostring(key), ...)
    end
    return tostring(key)
end
NS.ADDON_DISPLAY_NAME = NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME")
NS.ADDON_SHORT_NAME = NS.ADDON_SHORT_NAME or L("ADDON_SHORT_NAME")
NS.ADDON_CHAT_PREFIX = "|cffff7f50[MCD-CVA]|r"
NS.ADDON_ICON = "Interface\\AddOns\\MCDVoiceCooldown\\AppIcon.png"

local frame = CreateFrame("Frame")
local cooldowns = {}
local delayedCastSuccess = {}

-- 物品支持说明：
-- 真实物品ID用于显示名称/图标；物品使用后触发的法术ID(triggerSpellID)用于 UNIT_SPELLCAST_SUCCEEDED 监听。
-- GetItemSpell / C_Item.GetItemSpell 只在非战斗或资料加载回调里解析；战斗中只使用已保存的 triggerSpellID。
local PENDING_ITEM_RESOLVE = {}

local function IsCombatLocked()
    return type(InCombatLockdown) == "function" and InCombatLockdown()
end

local function RequestItemData(itemID)
    itemID = tonumber(itemID) or 0
    if itemID <= 0 then
        return
    end
    if C_Item and C_Item.RequestLoadItemDataByID then
        pcall(C_Item.RequestLoadItemDataByID, itemID)
    end
end

local function QueueItemResolve(itemID, entry)
    itemID = tonumber(itemID) or 0
    if itemID <= 0 or type(entry) ~= "table" then
        return false
    end

    local bucket = PENDING_ITEM_RESOLVE[itemID]
    if type(bucket) ~= "table" then
        bucket = {}
        PENDING_ITEM_RESOLVE[itemID] = bucket
    end

    for _, existing in ipairs(bucket) do
        if existing == entry then
            RequestItemData(itemID)
            return false
        end
    end

    bucket[#bucket + 1] = entry
    RequestItemData(itemID)
    return true
end

local MAPPED_SPELL_TO_PRIMARY = {}
local MAPPED_RUNTIME_CFG = {}
local CAST_SUCCESS_CFG = {}
local BLOODLUST_CFG = { customSoundPaths = {} }
local LAST_BLOODLUST_EXPIRATION = 0

local UPDATE_INTERVAL_COMBAT = 0.03
local UPDATE_INTERVAL_IDLE = 0.08
local updateElapsed = 0
local updating = false
local EXHAUSTION_IDS = { 57723, 57724, 80354, 95809, 160455, 207400, 264689, 390435 }
local EXHAUSTION_DURATION = 600
local FRESH_WINDOW = 5

local function GetCurrentClassSpec()
    local _, _, classID = UnitClass("player")
    local specIndex = GetSpecialization()
    local specID = specIndex and GetSpecializationInfo(specIndex) or 0
    return classID or 0, specID or 0
end

local function EnsureSpecTable(root, classID, specID)
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    if type(root) ~= "table" or classID < 0 or specID < 0 then
        return nil
    end
    if type(root[classID]) ~= "table" then
        root[classID] = {}
    end
    if type(root[classID][specID]) ~= "table" then
        root[classID][specID] = {}
    end
    return root[classID][specID]
end

local function GetSpecTable(root, classID, specID)
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    if type(root) ~= "table" or classID < 0 or specID < 0 then
        return nil
    end
    local classMap = root[classID]
    if type(classMap) ~= "table" then
        return nil
    end
    local specMap = classMap[specID]
    if type(specMap) ~= "table" then
        return nil
    end
    return specMap
end

local function IsActiveScopeLoaded(entryClassID, entrySpecID, currentClassID, currentSpecID)
    entryClassID = tonumber(entryClassID) or 0
    entrySpecID = tonumber(entrySpecID) or 0
    currentClassID = tonumber(currentClassID) or 0
    currentSpecID = tonumber(currentSpecID) or 0

    if entryClassID == ALL_CLASSES_ID then
        return true
    end

    return entryClassID == currentClassID and (entrySpecID == currentSpecID or entrySpecID == ALL_SPECS_ID)
end

local function EnsureDB()
    local db = MCDVoiceCooldownDB
    if type(db) ~= "table" then
        db = {}
        MCDVoiceCooldownDB = db
    end
    if type(db.specConfigs) ~= "table" then
        db.specConfigs = {}
    end
    if type(db.castSuccessConfigs) ~= "table" then
        db.castSuccessConfigs = {}
    end
    if type(db.bloodlustConfig) ~= "table" then
        db.bloodlustConfig = {}
    end
    if type(db.bloodlustConfig.customSoundPaths) ~= "table" then
        db.bloodlustConfig.customSoundPaths = { "", "", "", "", "" }
    end
    if type(db.deletedEntries) ~= "table" then
        db.deletedEntries = {}
    end
    if type(db.languageMode) ~= "string" then
        db.languageMode = "auto"
    end
    return db
end


local function NormalizeEntryTypeValue(value)
    value = tostring(value or "cooldown")
    if value ~= "cast" then
        value = "cooldown"
    end
    return value
end

local function NormalizeObjectTypeValue(value)
    value = tostring(value or OBJECT_TYPE_SPELL):lower()
    if value ~= OBJECT_TYPE_ITEM then
        value = OBJECT_TYPE_SPELL
    end
    return value
end

local function EnsureDeletedEntryStore()
    local db = EnsureDB()
    if type(db.deletedEntries) ~= "table" then
        db.deletedEntries = {}
    end
    if type(db.deletedEntries.keys) ~= "table" then
        db.deletedEntries.keys = {}
    end
    if type(db.deletedEntries.signatures) ~= "table" then
        db.deletedEntries.signatures = {}
    end
    return db.deletedEntries
end

local function BuildDeletedExactKey(classID, specID, index)
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    index = tonumber(index) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    if classID < 0 or specID < 0 or index <= 0 then
        return nil
    end
    return tostring(classID) .. ":" .. tostring(specID) .. ":" .. tostring(index)
end

local function BuildDeletedEntrySignature(classID, specID, entry)
    if type(entry) ~= "table" then
        return nil
    end
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    local spellId = tonumber(entry.spellId or entry.itemID) or 0
    if classID < 0 or specID < 0 or spellId <= 0 then
        return nil
    end
    local entryType = NormalizeEntryTypeValue(entry.entryType)
    local objectType = NormalizeObjectTypeValue(entry.objectType)
    return tostring(classID) .. ":" .. tostring(specID) .. ":" .. entryType .. ":" .. objectType .. ":" .. tostring(math.floor(spellId))
end

local function MarkEntryDeleted(classID, specID, entry, index)
    local store = EnsureDeletedEntryStore()
    local exactKey = BuildDeletedExactKey(classID, specID, index)
    if exactKey then
        store.keys[exactKey] = true
    end
    local signature = BuildDeletedEntrySignature(classID, specID, entry)
    if signature then
        store.signatures[signature] = true
    end
end

local function ClearEntryDeletedMarker(classID, specID, entry, index)
    local store = EnsureDeletedEntryStore()
    local exactKey = BuildDeletedExactKey(classID, specID, index)
    if exactKey then
        store.keys[exactKey] = nil
    end
    local signature = BuildDeletedEntrySignature(classID, specID, entry)
    if signature then
        store.signatures[signature] = nil
    end
end

local function IsEntryMarkedDeleted(classID, specID, entry, index)
    local store = EnsureDeletedEntryStore()
    local exactKey = BuildDeletedExactKey(classID, specID, index)
    if exactKey and store.keys[exactKey] then
        return true
    end
    local signature = BuildDeletedEntrySignature(classID, specID, entry)
    return signature and store.signatures[signature] == true
end

local function PurgeDeletedEntriesFromRoot(root)
    if type(root) ~= "table" then
        return false
    end
    local changed = false
    for classIDKey, classMap in pairs(root) do
        local classID = tonumber(classIDKey)
        if classID and classID >= 0 and type(classMap) == "table" then
            for specIDKey, specMap in pairs(classMap) do
                local specID = tonumber(specIDKey)
                if specID and specID >= 0 and type(specMap) == "table" then
                    if classID == ALL_CLASSES_ID then
                        specID = ALL_SPECS_ID
                    end
                    for index, entry in pairs(specMap) do
                        local entryIndex = tonumber(index) or 0
                        if entryIndex > 0 and type(entry) == "table" and IsEntryMarkedDeleted(classID, specID, entry, entryIndex) then
                            specMap[index] = nil
                            changed = true
                        end
                    end
                end
            end
        end
    end
    return changed
end

local function PurgeDeletedEntries()
    local db = EnsureDB()
    local changed = false
    changed = PurgeDeletedEntriesFromRoot(db.specConfigs) or changed
    changed = PurgeDeletedEntriesFromRoot(db.castSuccessConfigs) or changed
    return changed
end

local function EnsureCastSuccessMap(classID, specID)
    local specMap = EnsureSpecTable(EnsureDB().castSuccessConfigs, classID, specID)
    if type(specMap) ~= "table" then
        return nil
    end
    return specMap
end

local function GetCastSuccessMap(classID, specID)
    local map = GetSpecTable(EnsureDB().castSuccessConfigs, classID, specID)
    if type(map) ~= "table" then
        return {}
    end
    return map
end

local function EnsureEntryMap(classID, specID)
    local specMap = EnsureSpecTable(EnsureDB().specConfigs, classID, specID)
    if type(specMap) ~= "table" then
        return nil
    end
    return specMap
end

local function GetStoredEntryMap(classID, specID)
    local map = GetSpecTable(EnsureDB().specConfigs, classID, specID)
    if type(map) ~= "table" then
        return {}
    end
    return map
end

local function GetEntry(map, index)
    local entry = map and map[index]
    if type(entry) ~= "table" then
        return nil
    end
    local spellId = tonumber(entry.spellId) or 0
    if spellId <= 0 then
        return nil
    end
    return entry
end

local function GetOrderedEntryIndices(map)
    local indices = {}
    if type(map) ~= "table" then
        return indices
    end

    for index, entry in pairs(map) do
        index = tonumber(index) or 0
        if index >= ENTRY_MIN and type(entry) == "table" and (tonumber(entry.spellId) or 0) > 0 then
            indices[#indices + 1] = index
        end
    end

    table.sort(indices)
    return indices
end

local function ResolveSpellName(spellId)
    spellId = tonumber(spellId) or 0
    if spellId <= 0 then
        return ""
    end

    if C_Spell and C_Spell.GetSpellName then
        local ok, name = pcall(C_Spell.GetSpellName, spellId)
        if ok and type(name) == "string" then
            return name
        end
    end

    if type(GetSpellInfo) == "function" then
        local ok, name = pcall(GetSpellInfo, spellId)
        if ok and type(name) == "string" then
            return name
        end
    end

    return ""
end

local function ResolveSpellIcon(spellId)
    spellId = tonumber(spellId) or 0
    if spellId <= 0 then
        return nil
    end

    if C_Spell and C_Spell.GetSpellTexture then
        local ok, icon = pcall(C_Spell.GetSpellTexture, spellId)
        if ok and (type(icon) == "number" or type(icon) == "string") then
            return icon
        end
    end

    if type(GetSpellInfo) == "function" then
        local ok, _, _, icon = pcall(GetSpellInfo, spellId)
        if ok and (type(icon) == "number" or type(icon) == "string") then
            return icon
        end
    end

    return nil
end

local function ResolveSpellBaseCooldownSeconds(spellId)
    spellId = tonumber(spellId) or 0
    if spellId <= 0 then
        return nil
    end

    if type(GetSpellBaseCooldown) == "function" then
        local ok, cooldownMS = pcall(GetSpellBaseCooldown, spellId)
        cooldownMS = ok and tonumber(cooldownMS) or 0
        if cooldownMS and cooldownMS > 0 then
            return tonumber(string.format("%.2f", cooldownMS / 1000))
        end
    end

    return nil
end


local function ResolveItemName(itemID)
    itemID = tonumber(itemID) or 0
    if itemID <= 0 then
        return ""
    end

    if C_Item and C_Item.GetItemNameByID then
        local ok, name = pcall(C_Item.GetItemNameByID, itemID)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end

    if C_Item and C_Item.GetItemInfo then
        local ok, name = pcall(C_Item.GetItemInfo, itemID)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end

    if type(GetItemInfo) == "function" then
        local ok, name = pcall(GetItemInfo, itemID)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end

    if C_Item and C_Item.RequestLoadItemDataByID then
        pcall(C_Item.RequestLoadItemDataByID, itemID)
    end

    return ""
end

local function ResolveItemIcon(itemID)
    itemID = tonumber(itemID) or 0
    if itemID <= 0 then
        return nil
    end

    if C_Item and C_Item.GetItemIconByID then
        local ok, icon = pcall(C_Item.GetItemIconByID, itemID)
        if ok and (type(icon) == "number" or type(icon) == "string") then
            return icon
        end
    end

    if type(GetItemIcon) == "function" then
        local ok, icon = pcall(GetItemIcon, itemID)
        if ok and (type(icon) == "number" or type(icon) == "string") then
            return icon
        end
    end

    return nil
end

local function ResolveItemUseSpellInfo(itemID, allowInCombat)
    itemID = tonumber(itemID) or 0
    if itemID <= 0 then
        return nil, nil
    end

    if not allowInCombat and IsCombatLocked() then
        RequestItemData(itemID)
        return nil, nil
    end

    if C_Item and C_Item.GetItemSpell then
        local ok, spellName, spellID = pcall(C_Item.GetItemSpell, itemID)
        spellID = ok and tonumber(spellID) or 0
        if spellID and spellID > 0 then
            return tostring(spellName or ""), math.floor(spellID)
        end
    end

    if type(GetItemSpell) == "function" then
        local ok, spellName, spellID = pcall(GetItemSpell, itemID)
        spellID = ok and tonumber(spellID) or 0
        if spellID and spellID > 0 then
            return tostring(spellName or ""), math.floor(spellID)
        end
    end

    RequestItemData(itemID)
    return nil, nil
end

local function ResolveItemUseSpellID(itemID, allowInCombat)
    local _, spellID = ResolveItemUseSpellInfo(itemID, allowInCombat)
    return spellID
end

local function ResolveItemTriggerForEntry(entry, quiet, noQueue)
    if type(entry) ~= "table" then
        return true
    end

    if tostring(entry.objectType or ""):lower() ~= OBJECT_TYPE_ITEM then
        return true
    end

    local itemID = tonumber(entry.itemID or entry.spellId) or 0
    if itemID <= 0 then
        return false
    end

    entry.objectType = OBJECT_TYPE_ITEM
    entry.itemID = math.floor(itemID)
    entry.spellId = math.floor(itemID)

    local savedTrigger = tonumber(entry.triggerSpellID or entry.triggerSpellId) or 0
    if savedTrigger > 0 then
        entry.triggerSpellID = math.floor(savedTrigger)
        entry.triggerSpellId = nil
        return true
    end

    if IsCombatLocked() then
        if not noQueue then
            QueueItemResolve(itemID, entry)
        end
        return false
    end

    local triggerSpellName, triggerSpellID = ResolveItemUseSpellInfo(itemID, true)
    triggerSpellID = tonumber(triggerSpellID) or 0
    if triggerSpellID > 0 then
        entry.triggerSpellID = math.floor(triggerSpellID)
        entry.triggerSpellId = nil
        entry.triggerSpellName = tostring(triggerSpellName or entry.triggerSpellName or "")

        if tostring(entry.spellName or "") == "" then
            local itemName = ResolveItemName(itemID)
            if itemName ~= "" then
                entry.spellName = itemName
            end
        end
        return true
    end

    if not noQueue then
        QueueItemResolve(itemID, entry)
    end
    if not quiet then
        DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " 物品ID " .. tostring(itemID) .. " 暂时没有解析到触发法术ID，已等待物品资料加载/脱战后自动补全。")
    end
    return false
end

local function ResolveObjectType(objectID, objectType)
    objectID = tonumber(objectID) or 0
    objectType = tostring(objectType or ""):lower()
    if objectType == OBJECT_TYPE_ITEM then
        return OBJECT_TYPE_ITEM
    end
    if objectType == OBJECT_TYPE_SPELL then
        return OBJECT_TYPE_SPELL
    end

    -- 自动识别：有“使用物品触发法术”的 ID 优先视为物品；否则按技能处理。
    if ResolveItemUseSpellID(objectID) then
        return OBJECT_TYPE_ITEM
    end
    if ResolveSpellName(objectID) ~= "" then
        return OBJECT_TYPE_SPELL
    end
    if ResolveItemName(objectID) ~= "" then
        return OBJECT_TYPE_ITEM
    end
    return OBJECT_TYPE_SPELL
end

local function ResolveObjectName(objectID, objectType)
    objectID = tonumber(objectID) or 0
    objectType = ResolveObjectType(objectID, objectType)
    if objectType == OBJECT_TYPE_ITEM then
        local itemName = ResolveItemName(objectID)
        if itemName ~= "" then
            return itemName
        end
    end

    local spellName = ResolveSpellName(objectID)
    if spellName ~= "" then
        return spellName
    end

    if objectType ~= OBJECT_TYPE_ITEM then
        return ResolveItemName(objectID)
    end
    return ""
end

local function ResolveObjectIcon(objectID, objectType)
    objectID = tonumber(objectID) or 0
    objectType = ResolveObjectType(objectID, objectType)
    if objectType == OBJECT_TYPE_ITEM then
        return ResolveItemIcon(objectID) or ResolveSpellIcon(objectID)
    end
    return ResolveSpellIcon(objectID) or ResolveItemIcon(objectID)
end

local function ResolveObjectBaseCooldownSeconds(objectID, objectType)
    objectType = ResolveObjectType(objectID, objectType)
    if objectType == OBJECT_TYPE_ITEM then
        -- 物品冷却继续使用用户填写的固定CD，不主动读取物品冷却，避免引入 Secret Value 逻辑。
        return nil
    end
    return ResolveSpellBaseCooldownSeconds(objectID)
end

local function GetObjectTriggerSpellID(objectID, objectType, triggerSpellID)
    objectID = tonumber(objectID) or 0
    objectType = ResolveObjectType(objectID, objectType)
    triggerSpellID = tonumber(triggerSpellID) or 0
    if triggerSpellID > 0 then
        return math.floor(triggerSpellID)
    end
    if objectID <= 0 then
        return 0
    end
    if objectType == OBJECT_TYPE_ITEM then
        -- 物品不能用物品ID直接匹配 UNIT_SPELLCAST_SUCCEEDED。
        -- 只返回已保存/非战斗解析到的 triggerSpellID；战斗中不会实时拆解。
        return ResolveItemUseSpellID(objectID) or 0
    end
    return math.floor(objectID)
end

local function MakeObjectKey(objectType, objectID)
    objectType = ResolveObjectType(objectID, objectType)
    return tostring(objectType) .. ":" .. tostring(math.floor(tonumber(objectID) or 0))
end

local function ResolveTalentName(talentId)
    talentId = tonumber(talentId) or 0
    if talentId <= 0 then
        return ""
    end

    -- 用户可直接填写“天赋对应的法术ID”。这是最常见、最直观的写法。
    local spellName = ResolveSpellName(talentId)
    if spellName ~= "" then
        return spellName
    end

    -- 也兼容填写 TraitDefinitionID：从定义里取 overrideName 或 spellID。
    if C_Traits and C_Traits.GetDefinitionInfo then
        local ok, info = pcall(C_Traits.GetDefinitionInfo, talentId)
        if ok and type(info) == "table" then
            if type(info.overrideName) == "string" and info.overrideName ~= "" then
                return info.overrideName
            end
            if tonumber(info.spellID) and tonumber(info.spellID) > 0 then
                local name = ResolveSpellName(info.spellID)
                if name ~= "" then
                    return name
                end
            end
            if tonumber(info.overriddenSpellID) and tonumber(info.overriddenSpellID) > 0 then
                local name = ResolveSpellName(info.overriddenSpellID)
                if name ~= "" then
                    return name
                end
            end
        end
    end

    if type(GetPvpTalentInfoByID) == "function" then
        local ok, name = pcall(GetPvpTalentInfoByID, talentId)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end

    return ""
end

local function GetActiveTalentConfigID()
    if C_ClassTalents and C_ClassTalents.GetActiveConfigID then
        local ok, configID = pcall(C_ClassTalents.GetActiveConfigID)
        if ok and tonumber(configID) and tonumber(configID) > 0 then
            return tonumber(configID)
        end
    end
    return nil
end

local function TalentDefinitionMatches(definitionID, talentId)
    talentId = tonumber(talentId) or 0
    definitionID = tonumber(definitionID) or 0
    if talentId <= 0 or definitionID <= 0 then
        return false
    end
    if definitionID == talentId then
        return true
    end
    if C_Traits and C_Traits.GetDefinitionInfo then
        local ok, info = pcall(C_Traits.GetDefinitionInfo, definitionID)
        if ok and type(info) == "table" then
            if tonumber(info.spellID) == talentId then
                return true
            end
            if tonumber(info.overriddenSpellID) == talentId then
                return true
            end
        end
    end
    return false
end

local function IsTalentSelected(talentId)
    talentId = tonumber(talentId) or 0
    if talentId <= 0 then
        return true
    end

    local configID = GetActiveTalentConfigID()
    if not configID or not (C_Traits and C_Traits.GetConfigInfo and C_Traits.GetTreeNodes and C_Traits.GetNodeInfo and C_Traits.GetEntryInfo) then
        -- 无法读取天赋树时不要误拦截冷却提示。
        return true
    end

    local okConfig, configInfo = pcall(C_Traits.GetConfigInfo, configID)
    if not okConfig or type(configInfo) ~= "table" or type(configInfo.treeIDs) ~= "table" then
        return true
    end

    for _, treeID in ipairs(configInfo.treeIDs) do
        local okNodes, nodeIDs = pcall(C_Traits.GetTreeNodes, treeID)
        if okNodes and type(nodeIDs) == "table" then
            for _, nodeID in ipairs(nodeIDs) do
                local okNode, nodeInfo = pcall(C_Traits.GetNodeInfo, configID, nodeID)
                if okNode and type(nodeInfo) == "table" then
                    local rank = math.max(tonumber(nodeInfo.activeRank) or 0, tonumber(nodeInfo.currentRank) or 0)
                    if rank > 0 then
                        local entryIDs = {}
                        local activeEntry = nodeInfo.activeEntry or nodeInfo.currentEntry
                        if type(activeEntry) == "table" and tonumber(activeEntry.entryID) then
                            entryIDs[#entryIDs + 1] = tonumber(activeEntry.entryID)
                        elseif type(nodeInfo.entryIDs) == "table" then
                            for _, entryID in ipairs(nodeInfo.entryIDs) do
                                entryIDs[#entryIDs + 1] = tonumber(entryID)
                            end
                        end

                        for _, entryID in ipairs(entryIDs) do
                            if entryID and entryID > 0 then
                                local okEntry, entryInfo = pcall(C_Traits.GetEntryInfo, configID, entryID)
                                if okEntry and type(entryInfo) == "table" and TalentDefinitionMatches(entryInfo.definitionID, talentId) then
                                    return true
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    return false
end

local function GetRuntimeUpdateInterval()
    return UnitAffectingCombat("player") and UPDATE_INTERVAL_COMBAT or UPDATE_INTERVAL_IDLE
end

local function GetSelectedVoiceID()
    if C_TTSSettings and C_TTSSettings.GetVoiceOptionID then
        local ok, voiceID = pcall(C_TTSSettings.GetVoiceOptionID)
        if ok and tonumber(voiceID) and tonumber(voiceID) >= 0 then
            return tonumber(voiceID)
        end
    end

    if C_VoiceChat and C_VoiceChat.GetTtsVoices then
        local ok, voices = pcall(C_VoiceChat.GetTtsVoices)
        if ok and type(voices) == "table" then
            for _, voice in ipairs(voices) do
                local voiceID = tonumber(type(voice) == "table" and (voice.voiceID or voice[1])) or -1
                if voiceID >= 0 then
                    return voiceID
                end
            end
        end
    end

    return nil
end

local function SpeakTextTTS(text, rate)
    text = tostring(text or ""):match("^%s*(.-)%s*$")
    if text == "" then
        return false
    end

    rate = math.max(-10, math.min(10, tonumber(rate) or 0))

    local voiceID = GetSelectedVoiceID()
    if not voiceID then
        DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " " .. L("MSG_NO_TTS"))
        return false
    end

    if C_VoiceChat and C_VoiceChat.SpeakText then
        local ok = pcall(C_VoiceChat.SpeakText, voiceID, text, rate, 100, false)
        if ok then
            return true
        end
    end

    if type(TextToSpeech_SpeakText) == "function" then
        local ok = pcall(TextToSpeech_SpeakText, text)
        if ok then
            return true
        end
    end

    DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " " .. L("MSG_TTS_FAILED", text))
    return false
end

local function NormalizeSoundPath(path)
    path = tostring(path or ""):match("^%s*(.-)%s*$")
    if path == "" then
        return ""
    end

    if NS.AceOptions and type(NS.AceOptions.NormalizeSoundPath) == "function" then
        return NS.AceOptions:NormalizeSoundPath(path)
    end

    return (path:gsub("/", "\\"))
end

local function PlayVoiceFile(path)
    path = NormalizeSoundPath(path)
    if path == "" then
        return false
    end

    if type(PlaySoundFile) ~= "function" then
        DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " " .. L("MSG_NO_CUSTOM_AUDIO"))
        return false
    end

    -- PlaySoundFile returns true only when the sound is actually queued.
    -- nil means it did not play, so do not treat nil as success.
    local ok, willPlay = pcall(PlaySoundFile, path, "Master")
    if ok and willPlay == true then
        return true
    end

    DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " " .. L("MSG_SOUND_FAILED", path))
    return false
end

local function GetRandomNonEmptyPath(paths)
    local candidates = {}
    if type(paths) ~= "table" then
        return nil
    end

    for _, value in ipairs(paths) do
        value = NormalizeSoundPath(value)
        if value ~= "" then
            candidates[#candidates + 1] = value
        end
    end

    if #candidates <= 0 then
        return nil
    end

    if #candidates == 1 then
        return candidates[1]
    end

    return candidates[math.random(1, #candidates)]
end

local function PlayReadyNotification(cfg)
    if type(cfg) ~= "table" then
        return false
    end

    local mode = tostring(cfg.notifyMode or MODE_SOUND)
    if mode == MODE_TTS then
        local text = tostring(cfg.ttsText or ""):match("^%s*(.-)%s*$")
        if text == "" then
            text = (tostring(cfg.spellName or "") ~= "" and tostring(cfg.spellName) or tostring(cfg.spellId or "")) ..
                L("TTS_READY_DEFAULT")
        end
        return SpeakTextTTS(text, cfg.ttsRate)
    end

    local path = tostring(cfg.soundPath or ""):match("^%s*(.-)%s*$")
    if path == "" then
        DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " " .. L("MSG_NO_SOUND_PATH"))
        return false
    end
    return PlayVoiceFile(path)
end

local function PlayCastSuccessNotification(cfg)
    if type(cfg) ~= "table" then
        return false
    end

    local mode = tostring(cfg.notifyMode or MODE_SOUND)
    if mode == MODE_TTS then
        local text = tostring(cfg.ttsText or ""):match("^%s*(.-)%s*$")
        if text == "" then
            text = (tostring(cfg.spellName or "") ~= "" and tostring(cfg.spellName) or tostring(cfg.spellId or "")) ..
                L("TTS_CAST_SUCCESS_DEFAULT")
        end
        return SpeakTextTTS(text, cfg.ttsRate)
    end

    local path = tostring(cfg.soundPath or ""):match("^%s*(.-)%s*$")
    if path == "" then
        DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " " .. L("MSG_NO_CAST_SOUND_PATH"))
        return false
    end
    return PlayVoiceFile(path)
end

local StartUpdate

local function ClearDelayedCastSuccessTimers()
    wipe(delayedCastSuccess)
end

local function HasDelayedCastSuccessTimers()
    return next(delayedCastSuccess) ~= nil
end

local function QueueCastSuccessNotification(triggerSpellID, cfg)
    if type(cfg) ~= "table" then
        return false
    end

    triggerSpellID = tonumber(triggerSpellID) or tonumber(cfg.triggerSpellID) or tonumber(cfg.spellId) or 0
    local delaySeconds = math.max(0, tonumber(cfg.delaySeconds) or 0)
    if not (cfg.delayEnabled == true and delaySeconds > 0 and triggerSpellID > 0) then
        return PlayCastSuccessNotification(cfg)
    end

    -- 同一个成功施法触发器再次命中时，覆盖旧倒计时并从当前时刻重新计时。
    delayedCastSuccess[triggerSpellID] = {
        fireAt = GetTime() + delaySeconds,
        cfg = {
            spellId = tonumber(cfg.spellId) or 0,
            objectID = tonumber(cfg.objectID or cfg.spellId) or 0,
            objectType = tostring(cfg.objectType or OBJECT_TYPE_SPELL),
            triggerSpellID = triggerSpellID,
            spellName = tostring(cfg.spellName or ""),
            notifyMode = tostring(cfg.notifyMode or MODE_SOUND),
            ttsText = tostring(cfg.ttsText or ""),
            ttsRate = math.max(-10, math.min(10, tonumber(cfg.ttsRate) or 0)),
            soundPath = NormalizeSoundPath(cfg.soundPath or ""),
        },
    }
    StartUpdate()
    return true
end

local function RebuildBloodlustConfig()
    local db = EnsureDB()
    wipe(BLOODLUST_CFG.customSoundPaths)

    local source = type(db.bloodlustConfig) == "table" and db.bloodlustConfig.customSoundPaths or nil
    if type(source) ~= "table" then
        return
    end

    for i = 1, 5 do
        BLOODLUST_CFG.customSoundPaths[i] = NormalizeSoundPath(source[i] or "")
    end
end

local function PlayBloodlustNotification(cfg)
    local path = GetRandomNonEmptyPath(type(cfg) == "table" and cfg.customSoundPaths or BLOODLUST_CFG.customSoundPaths)
    if not path then
        DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " " .. L("MSG_NO_BLOODLUST_SOUND_PATH"))
        return false
    end
    return PlayVoiceFile(path)
end

local function CheckExhaustionFresh()
    if not C_UnitAuras or not C_UnitAuras.GetPlayerAuraBySpellID then
        return false, nil
    end

    local now = GetTime()
    for _, spellId in ipairs(EXHAUSTION_IDS) do
        local aura = C_UnitAuras.GetPlayerAuraBySpellID(spellId)
        if aura and aura.expirationTime then
            local remaining = (tonumber(aura.expirationTime) or 0) - now
            if remaining >= (EXHAUSTION_DURATION - FRESH_WINDOW) then
                return true, tonumber(aura.expirationTime) or 0
            end
        end
    end

    return false, nil
end

local function RebuildRuntimeConfig()
    wipe(MAPPED_SPELL_TO_PRIMARY)
    wipe(MAPPED_RUNTIME_CFG)

    local classID, specID = GetCurrentClassSpec()

    local function addEntriesFromMap(entryMap, scopeClassID, scopeSpecID)
        for _, index in ipairs(GetOrderedEntryIndices(entryMap)) do
            local entry = GetEntry(entryMap, index)
            if entry and tostring(entry.entryType or "cooldown") ~= "cast" then
                local objectID = tonumber(entry.spellId) or 0
                local objectType = ResolveObjectType(objectID, entry.objectType)
                if objectType == OBJECT_TYPE_ITEM then
                    ResolveItemTriggerForEntry(entry, true)
                end
                local triggerSpellID = GetObjectTriggerSpellID(objectID, objectType, entry.triggerSpellID or entry.triggerSpellId)
                local talentId = tonumber(entry.talentId) or 0
                local checkTalent = entry.checkTalent == true and talentId > 0
                local talentOK = checkTalent and IsTalentSelected(talentId)
                local fixedCD = tonumber(entry.baseCD) or 0
                local talentCD = tonumber(entry.talentCD) or 0
                local effectiveCD = (talentOK and talentCD > 0) and talentCD or fixedCD
                if objectID > 0 and triggerSpellID > 0 and effectiveCD > 0 then
                    local objectKey = MakeObjectKey(objectType, objectID)
                    -- 触发事件里拿到的是技能ID；物品会映射到该物品“使用时触发的技能ID”。
                    MAPPED_SPELL_TO_PRIMARY[triggerSpellID] = objectKey
                    MAPPED_RUNTIME_CFG[objectKey] = {
                        spellId = objectID,
                        objectID = objectID,
                        objectType = objectType,
                        triggerSpellID = triggerSpellID,
                        spellName = tostring(entry.spellName or ""),
                        baseCD = effectiveCD,
                        fixedCD = true,
                        chargeInput = math.max(1, math.floor(tonumber(entry.chargeInput or entry.charge) or 1)),
                        notifyMode = tostring(entry.notifyMode or MODE_SOUND),
                        ttsText = tostring(entry.ttsText or ""),
                        ttsRate = math.max(-10, math.min(10, tonumber(entry.ttsRate) or 0)),
                        soundPath = NormalizeSoundPath(entry.soundPath or ""),
                        checkTalent = checkTalent,
                        talentMatched = talentOK == true,
                        talentId = talentId,
                        talentName = tostring(entry.talentName or ""),
                        talentCD = talentCD,
                        fixedBaseCD = fixedCD,
                        index = index,
                        scopeClassID = tonumber(scopeClassID) or 0,
                        scopeSpecID = tonumber(scopeSpecID) or 0,
                    }
                end
            end
        end
    end

    -- 优先级：全职业 < 当前职业全专精 < 当前职业当前专精；后加入的覆盖前面的通用配置。
    addEntriesFromMap(GetStoredEntryMap(ALL_CLASSES_ID, ALL_SPECS_ID), ALL_CLASSES_ID, ALL_SPECS_ID)
    addEntriesFromMap(GetStoredEntryMap(classID, ALL_SPECS_ID), classID, ALL_SPECS_ID)
    if specID ~= ALL_SPECS_ID then
        addEntriesFromMap(GetStoredEntryMap(classID, specID), classID, specID)
    end
end

local function RebuildCastSuccessConfig()
    wipe(CAST_SUCCESS_CFG)

    local classID, specID = GetCurrentClassSpec()

    local function addCastEntry(entry, index, scopeClassID, scopeSpecID)
        if type(entry) ~= "table" then
            return
        end
        local objectID = tonumber(entry.spellId) or 0
        local objectType = ResolveObjectType(objectID, entry.objectType)
        if objectType == OBJECT_TYPE_ITEM then
            ResolveItemTriggerForEntry(entry, true)
        end
        local triggerSpellID = GetObjectTriggerSpellID(objectID, objectType, entry.triggerSpellID or entry.triggerSpellId)
        if objectID > 0 and triggerSpellID > 0 then
            -- 当前专精配置会覆盖全职业/全专精配置。
            CAST_SUCCESS_CFG[triggerSpellID] = {
                spellId = objectID,
                objectID = objectID,
                objectType = objectType,
                triggerSpellID = triggerSpellID,
                spellName = tostring(entry.spellName or ""),
                notifyMode = tostring(entry.notifyMode or MODE_SOUND),
                ttsText = tostring(entry.ttsText or ""),
                ttsRate = math.max(-10, math.min(10, tonumber(entry.ttsRate) or 0)),
                soundPath = NormalizeSoundPath(entry.soundPath or ""),
                delayEnabled = entry.delayEnabled == true,
                delaySeconds = math.max(0, tonumber(entry.delaySeconds) or 0),
                index = index,
                scopeClassID = tonumber(scopeClassID) or 0,
                scopeSpecID = tonumber(scopeSpecID) or 0,
            }
        end
    end

    local function addLegacyCastMap(castMap, scopeClassID, scopeSpecID)
        for _, index in ipairs(GetOrderedEntryIndices(castMap)) do
            addCastEntry(GetEntry(castMap, index), index, scopeClassID, scopeSpecID)
        end
    end

    local function addUnifiedCastMap(entryMap, scopeClassID, scopeSpecID)
        for _, index in ipairs(GetOrderedEntryIndices(entryMap)) do
            local entry = GetEntry(entryMap, index)
            if entry and tostring(entry.entryType or "cooldown") == "cast" then
                addCastEntry(entry, index, scopeClassID, scopeSpecID)
            end
        end
    end

    -- 优先级：全职业 < 当前职业全专精 < 当前职业当前专精。
    addLegacyCastMap(GetCastSuccessMap(ALL_CLASSES_ID, ALL_SPECS_ID), ALL_CLASSES_ID, ALL_SPECS_ID)
    addUnifiedCastMap(GetStoredEntryMap(ALL_CLASSES_ID, ALL_SPECS_ID), ALL_CLASSES_ID, ALL_SPECS_ID)

    addLegacyCastMap(GetCastSuccessMap(classID, ALL_SPECS_ID), classID, ALL_SPECS_ID)
    addUnifiedCastMap(GetStoredEntryMap(classID, ALL_SPECS_ID), classID, ALL_SPECS_ID)

    if specID ~= ALL_SPECS_ID then
        addLegacyCastMap(GetCastSuccessMap(classID, specID), classID, specID)
        addUnifiedCastMap(GetStoredEntryMap(classID, specID), classID, specID)
    end
end

local function SyncCooldownState(spellId, now)
    local cd = cooldowns[spellId]
    if not cd then
        return nil
    end

    now = tonumber(now) or GetTime()
    local maxCharge = math.max(1, math.floor(tonumber(cd.charge) or 1))
    local singleCD = tonumber(cd.singleCD) or 0
    if singleCD <= 0 then
        cooldowns[spellId] = nil
        return nil
    end

    if not cd.nextChargeAt then
        cd.nextChargeAt = now + singleCD
    end

    while cd.currentCharge < maxCharge and cd.nextChargeAt and now >= cd.nextChargeAt do
        cd.currentCharge = cd.currentCharge + 1
        if cd.currentCharge < maxCharge then
            cd.nextChargeAt = cd.nextChargeAt + singleCD
        else
            cd.nextChargeAt = nil
        end
    end

    return cd
end

local function EstimateCharges(cd)
    if not cd then
        return 0
    end
    local maxCharge = math.max(1, math.floor(tonumber(cd.charge) or 1))
    local currentCharge = math.floor(tonumber(cd.currentCharge) or 0)
    if currentCharge < 0 then
        currentCharge = 0
    end
    if currentCharge > maxCharge then
        currentCharge = maxCharge
    end
    return currentCharge
end

local function RefreshRuntimeCooldowns()
    local now = GetTime()
    for spellId in pairs(cooldowns) do
        SyncCooldownState(spellId, now)
    end
end

StartUpdate = function()
    if updating then
        return
    end

    updating = true
    updateElapsed = 0
    frame:SetScript("OnUpdate", function(_, elapsed)
        updateElapsed = updateElapsed + elapsed
        if updateElapsed < GetRuntimeUpdateInterval() then
            return
        end
        updateElapsed = 0

        local now = GetTime()
        local active = false

        for spellId, cd in pairs(cooldowns) do
            local wasUsable = EstimateCharges(cd) > 0
            cd = SyncCooldownState(spellId, now)
            if cd then
                local isUsable = EstimateCharges(cd) > 0
                if not wasUsable and isUsable and not cd.notified then
                    PlayReadyNotification(cd)
                    cd.notified = true
                end

                if EstimateCharges(cd) >= math.max(1, math.floor(tonumber(cd.charge) or 1)) then
                    cooldowns[spellId] = nil
                else
                    active = true
                end
            end
        end

        for triggerSpellID, timer in pairs(delayedCastSuccess) do
            local fireAt = tonumber(timer and timer.fireAt) or 0
            if fireAt > 0 and now >= fireAt then
                PlayCastSuccessNotification(timer.cfg)
                delayedCastSuccess[triggerSpellID] = nil
            else
                active = true
            end
        end

        if not active then
            updating = false
            frame:SetScript("OnUpdate", nil)
        end
    end)
end

local function StartCooldown(spellId)
    spellId = tonumber(spellId) or 0
    if spellId <= 0 or not MAPPED_SPELL_TO_PRIMARY[spellId] then
        return
    end

    local primaryKey = MAPPED_SPELL_TO_PRIMARY[spellId]
    local cfg = MAPPED_RUNTIME_CFG[primaryKey]
    if not cfg or (tonumber(cfg.baseCD) or 0) <= 0 then
        return
    end

    -- 12.0.5：急速API在受污染插件逻辑中可能返回 Secret Number。
    -- 为避免 “attempt to compare/use a secret number value” 报错，冷却只按用户填写的固定CD计算。
    local singleCD = tonumber(cfg.baseCD) or 0
    singleCD = tonumber(string.format("%.2f", singleCD)) or singleCD
    if singleCD <= 0 then
        return
    end

    local maxCharge = math.max(1, math.floor(tonumber(cfg.chargeInput) or 1))
    local now = GetTime()
    local cd = SyncCooldownState(primaryKey, now)
    local displayName = cfg.spellName
    if tostring(displayName or "") == "" then
        displayName = ResolveObjectName(cfg.objectID or cfg.spellId, cfg.objectType)
    end

    if cd then
        cd.singleCD = singleCD
        cd.charge = maxCharge
        cd.spellName = displayName
        cd.objectType = cfg.objectType
        cd.objectID = cfg.objectID or cfg.spellId
        cd.notifyMode = tostring(cfg.notifyMode or MODE_SOUND)
        cd.ttsText = tostring(cfg.ttsText or "")
        cd.ttsRate = math.max(-10, math.min(10, tonumber(cfg.ttsRate) or 0))
        cd.soundPath = NormalizeSoundPath(cfg.soundPath or "")
        if cd.currentCharge > 0 then
            cd.currentCharge = cd.currentCharge - 1
        else
            cd.currentCharge = 0
            cd.nextChargeAt = now + singleCD
        end
        if cd.currentCharge <= 0 then
            cd.notified = false
            if not cd.nextChargeAt then
                cd.nextChargeAt = now + singleCD
            end
        end
    else
        local currentCharge = maxCharge - 1
        if currentCharge < 0 then
            currentCharge = 0
        end
        if currentCharge < maxCharge then
            cooldowns[primaryKey] = {
                singleCD = singleCD,
                charge = maxCharge,
                currentCharge = currentCharge,
                nextChargeAt = now + singleCD,
                spellName = displayName,
                objectType = cfg.objectType,
                objectID = cfg.objectID or cfg.spellId,
                notifyMode = tostring(cfg.notifyMode or MODE_SOUND),
                ttsText = tostring(cfg.ttsText or ""),
                ttsRate = math.max(-10, math.min(10, tonumber(cfg.ttsRate) or 0)),
                soundPath = NormalizeSoundPath(cfg.soundPath or ""),
                notified = (currentCharge > 0),
                spellId = cfg.objectID or cfg.spellId,
            }
        end
    end

    RefreshRuntimeCooldowns()
    StartUpdate()
end

local function GetUsedEntryCount(map)
    return #GetOrderedEntryIndices(map)
end

local function FindFirstFreeIndex(map)
    local nextIndex = ENTRY_MIN
    for _, index in ipairs(GetOrderedEntryIndices(map)) do
        if index > nextIndex then
            return nextIndex
        end
        if index == nextIndex then
            nextIndex = nextIndex + 1
        end
    end
    return nextIndex
end

local function ResolveClassName(classID)
    classID = tonumber(classID) or 0
    if classID == ALL_CLASSES_ID then
        return L("ALL_CLASSES")
    end
    if classID < 0 then
        return "-"
    end

    if type(GetNumClasses) == "function" and type(GetClassInfo) == "function" then
        local classCount = tonumber(GetNumClasses()) or 0
        for classIndex = 1, classCount do
            local ok, className, _, candidateClassID = pcall(GetClassInfo, classIndex)
            if ok and tonumber(candidateClassID) == classID and type(className) == "string" and className ~= "" then
                return className
            end
        end
    end

    return L("FALLBACK_CLASS", tostring(classID))
end

local function ResolveSpecName(classID, specID)
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    if classID == ALL_CLASSES_ID then
        return L("ALL_SPECS")
    end
    if classID < 0 or specID < 0 then
        return "-"
    end
    if specID == ALL_SPECS_ID then
        return L("ALL_SPECS")
    end

    if type(GetNumSpecializationsForClassID) == "function" and type(GetSpecializationInfoForClassID) == "function" then
        local specCount = tonumber(GetNumSpecializationsForClassID(classID)) or 0
        for specIndex = 1, specCount do
            local ok, candidateSpecID, specName = pcall(GetSpecializationInfoForClassID, classID, specIndex)
            if ok and tonumber(candidateSpecID) == specID and type(specName) == "string" and specName ~= "" then
                return specName
            end
        end
    end

    return L("FALLBACK_SPEC", tostring(specID))
end

local function BuildInlineTexture(texture, size, left, right, top, bottom)
    if type(texture) ~= "string" and type(texture) ~= "number" then
        return ""
    end
    size = tonumber(size) or 14
    if left and right and top and bottom then
        return string.format("|T%s:%d:%d:0:0:256:256:%d:%d:%d:%d|t ", tostring(texture), size, size, left, right, top,
            bottom)
    end
    return string.format("|T%s:%d:%d|t ", tostring(texture), size, size)
end

local function GetClassColorCode(classFile)
    local color = RAID_CLASS_COLORS and classFile and RAID_CLASS_COLORS[classFile]
    if not color then
        return nil
    end
    local r = math.floor(((tonumber(color.r) or 1) * 255) + 0.5)
    local g = math.floor(((tonumber(color.g) or 1) * 255) + 0.5)
    local b = math.floor(((tonumber(color.b) or 1) * 255) + 0.5)
    return string.format("|cff%02x%02x%02x", r, g, b)
end

local function BuildClassDisplayText(className, classFile)
    local icon = ""
    local coords = CLASS_ICON_TCOORDS and classFile and CLASS_ICON_TCOORDS[classFile]
    if coords then
        icon = BuildInlineTexture("Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES", 14,
            math.floor((coords[1] or 0) * 256), math.floor((coords[2] or 1) * 256),
            math.floor((coords[3] or 0) * 256), math.floor((coords[4] or 1) * 256))
    end

    local name = tostring(className or "-")
    local colorCode = GetClassColorCode(classFile)
    if colorCode then
        return icon .. colorCode .. name .. "|r"
    end
    return icon .. name
end

local function BuildSpecDisplayText(specName, specIcon)
    local icon = BuildInlineTexture(specIcon, 14)
    return icon .. tostring(specName or "-")
end

local function GetClassOptions()
    local options = {
        { classID = ALL_CLASSES_ID, className = L("ALL_CLASSES"), classFile = nil },
    }
    if type(GetNumClasses) ~= "function" or type(GetClassInfo) ~= "function" then
        return options
    end

    local classCount = tonumber(GetNumClasses()) or 0
    for classIndex = 1, classCount do
        local ok, className, classFile, classID = pcall(GetClassInfo, classIndex)
        classID = tonumber(classID) or 0
        if ok and classID > 0 and type(className) == "string" and className ~= "" then
            options[#options + 1] = {
                classID = classID,
                className = className,
                classFile = classFile,
            }
        end
    end

    return options
end

local function GetSpecOptionsByClass(classID)
    classID = tonumber(classID) or 0
    local options = {
        {
            specID = ALL_SPECS_ID,
            specName = L("ALL_SPECS"),
            specIcon = nil,
        }
    }
    if classID == ALL_CLASSES_ID then
        return options
    end
    if classID < 0 then
        return options
    end

    if type(GetNumSpecializationsForClassID) ~= "function" or type(GetSpecializationInfoForClassID) ~= "function" then
        return options
    end

    local specCount = tonumber(GetNumSpecializationsForClassID(classID)) or 0
    for specIndex = 1, specCount do
        local ok, specID, specName, _, specIcon = pcall(GetSpecializationInfoForClassID, classID, specIndex)
        if ok and tonumber(specID) and tonumber(specID) > 0 then
            options[#options + 1] = {
                specID = tonumber(specID),
                specName = (type(specName) == "string" and specName ~= "") and specName or L("FALLBACK_SPEC", tostring(specID)),
                specIcon = specIcon,
            }
        end
    end

    return options
end

local function GetTargetClassSpec()
    if NS.AceOptions and type(NS.AceOptions.GetState) == "function" then
        local state = NS.AceOptions:GetState()
        local classID = tonumber(state and state.classID) or 0
        local specID = tonumber(state and state.specID) or 0
        if classID >= 0 and specID >= 0 then
            if classID == ALL_CLASSES_ID then
                return ALL_CLASSES_ID, ALL_SPECS_ID
            end
            return classID, specID
        end
    end

    return GetCurrentClassSpec()
end

local function BuildSummaryText()
    local classID, specID = GetTargetClassSpec()
    local map = GetStoredEntryMap(classID, specID)
    return L("MSG_SCOPE_COUNT", GetUsedEntryCount(map))
end

local function GetEntryRange()
    local classID, specID = GetTargetClassSpec()
    local map = GetStoredEntryMap(classID, specID)
    local maxIndex = ENTRY_MIN
    for index in pairs(map) do
        local candidate = tonumber(index) or 0
        if candidate >= ENTRY_MIN and candidate > maxIndex then
            maxIndex = candidate
        end
    end
    return ENTRY_MIN, maxIndex
end

function RefreshPanel()
    if NS.AceOptions and type(NS.AceOptions.RefreshUI) == "function" then
        NS.AceOptions:RefreshUI()
    end
end

local function OnProfileChanged(resetCooldowns)
    -- Only wipe active timers when the playable profile really changes.
    -- World entry, spellbook refreshes, and talent refreshes can happen while a
    -- fixed cooldown is already running; wiping here makes the ready alert vanish.
    if resetCooldowns then
        wipe(cooldowns)
        ClearDelayedCastSuccessTimers()
    end
    RebuildRuntimeConfig()
    RebuildCastSuccessConfig()
    RebuildBloodlustConfig()
    RefreshRuntimeCooldowns()
    RefreshPanel()
end

NS.API = NS.API or {}
NS.API.RefreshPanel = RefreshPanel
local function WalkEntryRoot(root, callback)
    if type(root) ~= "table" or type(callback) ~= "function" then
        return false
    end

    local changed = false
    for _, classMap in pairs(root) do
        if type(classMap) == "table" then
            for _, specMap in pairs(classMap) do
                if type(specMap) == "table" then
                    for _, entry in pairs(specMap) do
                        if type(entry) == "table" and tostring(entry.objectType or ""):lower() == OBJECT_TYPE_ITEM then
                            if callback(entry) then
                                changed = true
                            end
                        end
                    end
                end
            end
        end
    end
    return changed
end

local function ResolveAllStoredItemTriggers(quiet)
    local db = EnsureDB()
    local changed = false

    local function resolveEntry(entry)
        local before = tonumber(entry.triggerSpellID or entry.triggerSpellId) or 0
        ResolveItemTriggerForEntry(entry, quiet)
        local after = tonumber(entry.triggerSpellID or entry.triggerSpellId) or 0
        return before <= 0 and after > 0
    end

    changed = WalkEntryRoot(db.specConfigs, resolveEntry) or changed
    changed = WalkEntryRoot(db.castSuccessConfigs, resolveEntry) or changed
    return changed
end

local function ResolvePendingItems()
    if IsCombatLocked() then
        return false
    end

    local changed = false

    for itemID, entries in pairs(PENDING_ITEM_RESOLVE) do
        local keep = {}
        if type(entries) == "table" then
            for _, entry in ipairs(entries) do
                if type(entry) == "table" then
                    local before = tonumber(entry.triggerSpellID or entry.triggerSpellId) or 0
                    local ok = ResolveItemTriggerForEntry(entry, true, true)
                    local after = tonumber(entry.triggerSpellID or entry.triggerSpellId) or 0
                    if ok and before <= 0 and after > 0 then
                        changed = true
                    elseif not ok then
                        keep[#keep + 1] = entry
                    end
                end
            end
        end

        if #keep > 0 then
            PENDING_ITEM_RESOLVE[itemID] = keep
        else
            PENDING_ITEM_RESOLVE[itemID] = nil
        end
    end

    if changed then
        RebuildRuntimeConfig()
        RebuildCastSuccessConfig()
        RefreshRuntimeCooldowns()
        if NS.API and type(NS.API.RefreshPanel) == "function" then
            NS.API.RefreshPanel()
        end
        DEFAULT_CHAT_FRAME:AddMessage(NS.ADDON_CHAT_PREFIX .. " 已自动补全物品触发法术ID。")
    end

    return changed
end

NS.API.GetCurrentClassSpec = GetCurrentClassSpec
NS.API.IsActiveScopeLoaded = IsActiveScopeLoaded
NS.API.GetTargetClassSpec = GetTargetClassSpec
NS.API.GetStoredEntryMap = GetStoredEntryMap
NS.API.GetCastSuccessMap = GetCastSuccessMap
NS.API.EnsureCastSuccessMap = EnsureCastSuccessMap
NS.API.GetEntry = GetEntry
NS.API.PlayReadyNotification = PlayReadyNotification
NS.API.PlayCastSuccessNotification = PlayCastSuccessNotification
NS.API.QueueCastSuccessNotification = QueueCastSuccessNotification
NS.API.ClearDelayedCastSuccessTimers = ClearDelayedCastSuccessTimers
NS.API.PlayBloodlustNotification = PlayBloodlustNotification
NS.API.RebuildCastSuccessConfig = RebuildCastSuccessConfig
NS.API.RebuildBloodlustConfig = RebuildBloodlustConfig
NS.API.GetModes = function()
    return MODE_TTS, MODE_SOUND
end
NS.API.GetEntryRange = function()
    return GetEntryRange()
end
NS.API.GetSummaryText = BuildSummaryText
NS.API.ResolveClassName = ResolveClassName
NS.API.ResolveSpecName = ResolveSpecName
NS.API.ResolveSpellName = ResolveSpellName
NS.API.ResolveSpellIcon = ResolveSpellIcon
NS.API.ResolveSpellBaseCooldownSeconds = ResolveSpellBaseCooldownSeconds
NS.API.ResolveItemName = ResolveItemName
NS.API.ResolveItemIcon = ResolveItemIcon
NS.API.ResolveItemUseSpellID = ResolveItemUseSpellID
NS.API.ResolveItemTriggerForEntry = ResolveItemTriggerForEntry
NS.API.ResolveAllStoredItemTriggers = ResolveAllStoredItemTriggers
NS.API.ResolvePendingItems = ResolvePendingItems
NS.API.MarkEntryDeleted = MarkEntryDeleted
NS.API.ClearEntryDeletedMarker = ClearEntryDeletedMarker
NS.API.IsEntryMarkedDeleted = IsEntryMarkedDeleted
NS.API.PurgeDeletedEntries = PurgeDeletedEntries
NS.API.ResolveObjectType = ResolveObjectType
NS.API.ResolveObjectName = ResolveObjectName
NS.API.ResolveObjectIcon = ResolveObjectIcon
NS.API.ResolveObjectBaseCooldownSeconds = ResolveObjectBaseCooldownSeconds
NS.API.GetObjectTriggerSpellID = GetObjectTriggerSpellID
NS.API.ResolveTalentName = ResolveTalentName
NS.API.IsTalentSelected = IsTalentSelected
NS.API.EnsureEntryMap = EnsureEntryMap
NS.API.FindFirstFreeIndex = FindFirstFreeIndex
NS.API.GetOrderedEntryIndices = GetOrderedEntryIndices
NS.API.RebuildRuntimeConfig = RebuildRuntimeConfig
NS.API.RefreshRuntimeCooldowns = RefreshRuntimeCooldowns

frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
frame:RegisterEvent("PLAYER_TALENT_UPDATE")
frame:RegisterEvent("SPELLS_CHANGED")
frame:RegisterEvent("ITEM_DATA_LOAD_RESULT")
frame:RegisterEvent("PLAYER_REGEN_ENABLED")
frame:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
frame:RegisterUnitEvent("UNIT_AURA", "player")

frame:SetScript("OnEvent", function(_, event, ...)
    if event == "PLAYER_LOGIN" then
        if NS.Core and NS.Core.Database and type(NS.Core.Database.Initialize) == "function" then
            NS.Core.Database:Initialize()
        end
        EnsureDB()
        PurgeDeletedEntries()
        ResolveAllStoredItemTriggers(true)
        RebuildRuntimeConfig()
        RebuildCastSuccessConfig()
        RebuildBloodlustConfig()
        RefreshRuntimeCooldowns()
        if NS.AceOptions and type(NS.AceOptions.Initialize) == "function" then
            NS.AceOptions:Initialize()
        end
        if NS.Core and NS.Core.ConfigPanel and type(NS.Core.ConfigPanel.Initialize) == "function" then
            NS.Core.ConfigPanel:Initialize()
        end
        if NS.Core and NS.Core.Commands and type(NS.Core.Commands.Initialize) == "function" then
            NS.Core.Commands:Initialize()
        end
        if NS.Core and NS.Core.MinimapButton and type(NS.Core.MinimapButton.Initialize) == "function" then
            NS.Core.MinimapButton:Initialize()
            if type(NS.Core.MinimapButton.Show) == "function" then
                NS.Core.MinimapButton:Show()
            end
        end
        print("[MCD-CVA] " .. L("MSG_LOADED"))
    elseif event == "PLAYER_ENTERING_WORLD" or event == "SPELLS_CHANGED" or event == "PLAYER_TALENT_UPDATE" then
        if event == "PLAYER_ENTERING_WORLD" and NS.Core and NS.Core.MinimapButton and type(NS.Core.MinimapButton.Initialize) == "function" then
            NS.Core.MinimapButton:Initialize()
            if type(NS.Core.MinimapButton.Show) == "function" then
                NS.Core.MinimapButton:Show()
            end
        end
        PurgeDeletedEntries()
        ResolveAllStoredItemTriggers(true)
        OnProfileChanged(false)
    elseif event == "PLAYER_SPECIALIZATION_CHANGED" then
        local unit = ...
        if unit == "player" then
            PurgeDeletedEntries()
            ResolveAllStoredItemTriggers(true)
            OnProfileChanged(true)
        end
    elseif event == "ITEM_DATA_LOAD_RESULT" then
        ResolvePendingItems()
    elseif event == "PLAYER_REGEN_ENABLED" then
        ResolvePendingItems()
        -- 只清除施法成功“延时播放”队列；固定CD计时器 cooldowns 必须继续保留。
        ClearDelayedCastSuccessTimers()
    elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellId = ...
        if unit == "player" or unit == "pet" then
            StartCooldown(spellId)
            local castCfg = CAST_SUCCESS_CFG[spellId]
            if castCfg then
                QueueCastSuccessNotification(spellId, castCfg)
            end
        end
    elseif event == "UNIT_AURA" then
        local unit = ...
        if unit == "player" then
            local isFresh, expirationTime = CheckExhaustionFresh()
            if isFresh and expirationTime and expirationTime > 0 and expirationTime ~= LAST_BLOODLUST_EXPIRATION then
                LAST_BLOODLUST_EXPIRATION = expirationTime
                PlayBloodlustNotification(BLOODLUST_CFG)
            end
        end
    end
end)
