setfenv(1, VoiceOver)

local CURRENT_MODULE_VERSION = 1
local FORCE_ENABLE_DISABLED_MODULES = true
local LOAD_ALL_MODULES = true
local USE_MODULE_DISABLE_SETTINGS = true

---@class DataModuleMetadata
---@field AddonName string Addon name
---@field LoadOnDemand boolean Whether the module can be dynamically loaded (TOC ##LoadOnDemand)
---@field ModuleVersion number Module's data format version (TOC ##X-VoiceOver-DataModule-Version, must be CURRENT_MODULE_VERSION to be loaded)
---@field ModulePriority number Module's priority (TOC ##X-VoiceOver-DataModule-Priority, larger number = higher priority)
---@field ContentVersion? string Module's content version (TOC ##Version)
---@field Title string Module's title (TOC ##Title or addon name if missing)
---@field Maps number[] Map IDs in which the module should load (TOC ##X-VoiceOver-DataModule-Maps)

---@class DataModule
---@field METADATA DataModuleMetadata
---@field GetSoundPath fun(self: DataModule, fileName: string, event: SoundEvent): string Function implemented in the module that returns the sound path for the desired voiceover
---@field GossipLookupByNPCID table<number, table<string, string>> Maps Creature ID and fuzzy-searchable gossip text to gossip text hash
---@field GossipLookupByNPCName table<string, table<string, string>> Maps Creature name and fuzzy-searchable gossip text to gossip text hash
---@field GossipLookupByObjectID table<number, table<string, string>> Maps GameObject ID and fuzzy-searchable gossip text to gossip text hash
---@field GossipLookupByObjectName table<string, table<string, string>> Maps GameObject name and fuzzy-searchable gossip text to gossip text hash
---@field DialogueLookupByNPCName table<string, table<string, {hash: string, text: string}>> Maps NPC name and dialogue hash to dialogue data
---@field QuestIDLookup table<QuestIDLookupSource, table<string, number|table<string, number|table<string, number>>>> Maps quest title to quest ID or (if there is ambiguity) maps quest title and quest giver name to quest ID or (if there is ambiguity) maps quest title and quest giver name and fuzzy-searchable quest text to quest ID
---@field NPCIDLookupByQuestID table<number, number> Maps Quest ID to quest giver Creature ID
---@field ObjectIDLookupByQuestID table<number, number> Maps Quest ID to quest giver GameObject ID
---@field ItemIDLookupByQuestID table<number, number> Maps Quest ID to quest giver Item ID
---@field NPCNameLookupByNPCID table<number, string> Maps Creature ID to Creature name
---@field ObjectNameLookupByObjectID table<number, string> Maps GameObject ID to GameObject name
---@field ItemNameLookupByItemID table<number, string> Maps Item ID to Item name
---@field SoundLengthLookupByFileName table<string, number> Maps sound filenames to their duration in seconds

---@class AvailableDataModule
---@field AddonName string Addon name
---@field Title string Module's title (TOC ##Title)
---@field ContentVersion string Module's content version (TOC ##Version)
---@field RelevantAboveVersion? number Interface number above which (inclusive) it makes sense to show this module as available to download or update
---@field RelevantBelowVersion? number Interface number below which (exclusive) it makes sense to show this module as available to download or update
---@field URL string URL where the module can be downloaded

DataModules =
{
    --- Store the modules present in Interface\AddOns folder, whether they're loaded or not
    ---@type table<string, DataModuleMetadata>
    presentModules = {},

    --- Stores present modules with a consistent ordering (which key-value hashmaps don't provide) to avoid bugs that can only be reproduced randomly
    ---@type DataModuleMetadata[]
    presentModulesOrdered = {},

    --- Stores the modules that were already loaded and registered
    ---@type table<string, DataModule>
    registeredModules = {},

    --- Stores registered modules with a consistent ordering (which key-value hashmaps don't provide) to avoid bugs that can only be reproduced randomly
    ---@type DataModule[]
    registeredModulesOrdered = {}, -- To have a consistent ordering of modules (which key-value hashmaps don't provide) to avoid bugs that can only be reproduced randomly

    --- Stores modules known to exist to present the player with information on how to download or update them
    ---@type AvailableDataModule[]
    availableModules = {
        {
            AddonName = "AI_VoiceOver_Projects",
            Title = "All Projects on CurseForge",
            ContentVersion = "1.0",
            RelevantAboveVersion = 0,
            URL = "https://www.curseforge.com/members/darkneo/projects",
            Description =
            "Visit this page to find all available AI VoiceOver projects. Here you can download and update various voice packs and data modules for the AI VoiceOver addon."
        },
        -- enUS
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_enUS_HQ",       Title = "WarWithin (enUS) - HighQuality",                  ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-warwithin-en-us-f-v2-0" },
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_enUS_LQ",       Title = "WarWithin (enUS) - LowQuality",                   ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/vo-us-v01" },
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_Karesh_enUS_MQ", Title = "WarWithin - Karesh (enUS) - MQ",                  ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-warwithin-karesh-enus-mq" },
        { AddonName = "AI_VoiceOver_Sounds_Dragonflight_enUS_HQ",    Title = "Dragonflight (enUS) - HighQuality",               ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-dragonflight-enus-highquality" },
        { AddonName = "AI_VoiceOver_Sounds_Dragonflight_enUS_LQ",    Title = "Dragonflight (enUS) - LowQuality",                ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-dragonflight-en-us" },
        { AddonName = "AI_VoiceOver_Sounds_Legion_enUS",             Title = "Legion (enUS)",                                   ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-legion-enus" },
        { AddonName = "AI_VoiceOver_Sounds_WotLK_enUS",              Title = "Wrath of the Lich King (enUS)",                   ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/ai-voiceoverdata-wrath-of-the-lich-king-enus-mq" },
        { AddonName = "AI_VoiceOver_Sounds_Cataclysm_enUS",           Title = "Cataclysm (enUS)",                               ContentVersion = "1.0", Language = "enUS", Parts = {
            { name = "Part 1", URL = "https://www.curseforge.com/wow/addons/ai-vod-cataclysm-enus-part1" },
            { name = "Part 2", URL = "https://www.curseforge.com/wow/addons/ai-vod-cataclysm-enus-part2" },
            { name = "Part 3", URL = "https://www.curseforge.com/wow/addons/ai-vod-cataclysm-enus-part3" },
            { name = "Part 4", URL = "https://www.curseforge.com/wow/addons/ai-vod-cataclysm-enus-part4" },
        }},
        { AddonName = "AI_VoiceOver_Sounds_TBC_enUS_LQ",             Title = "TBC (enUS) - LowQuality",                        ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-tbc-enus-lowquality" },
        { AddonName = "AI_VoiceOver_Sounds_Basics_enUS_MQ",          Title = "Basics Starting Zones (enUS) - MQ",              ContentVersion = "1.0", Language = "enUS", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-basics-starting-zones-retail" },
        -- esMX
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_esMX_HQ",       Title = "WarWithin (esMX) - High Quality",                ContentVersion = "1.0", Language = "esMX", URL = "https://www.curseforge.com/wow/addons/la010101" },
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_Karesh_esMX_HQ", Title = "WarWithin - Karesh (esMX) - HQ",                ContentVersion = "1.0", Language = "esMX", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-warwithin-karesh-esmx-hq" },
        { AddonName = "AI_VoiceOver_Sounds_Legion_esMX",             Title = "Legion (esMX)",                                   ContentVersion = "1.0", Language = "esMX", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-legion-esmx" },
        { AddonName = "AI_VoiceOver_Sounds_WotLK_esMX",              Title = "Wrath of the Lich King (esMX)",                   ContentVersion = "1.0", Language = "esMX", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-wrath-of-the-lich-king-esmx" },
        { AddonName = "AI_VoiceOver_Sounds_Pandaria_esMX_MQ",        Title = "Pandaria Classic (esMX) - MQ",                   ContentVersion = "1.0", Language = "esMX", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-pandaria-classic-esmx-mq" },
        { AddonName = "AI_VoiceOver_Sounds_TBC_esMX_MQ",             Title = "TBC (esMX) - MediumQuality",                     ContentVersion = "1.0", Language = "esMX", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-tbc-esmx-mediumquality" },
        { AddonName = "AI_VoiceOver_Sounds_VanillaLatam_esMX",       Title = "Vanilla Latam (esMX)",                           ContentVersion = "1.0", Language = "esMX", Parts = {
            { name = "Part 1", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-vanillalatam-esmx-part1" },
            { name = "Part 2", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-vanillalatam-esmx-part2" },
            { name = "Part 3", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-vanillalatam-esmx-part3" },
        }},
        { AddonName = "AI_VoiceOver_Sounds_Midnight_esMX",           Title = "Midnight (esMX)",                                 ContentVersion = "1.0", Language = "esMX", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-midnight-esmx" },
        -- esES
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_esES_HQ",       Title = "WarWithin (esES) - Solo Minahonda - HQ",         ContentVersion = "1.0", Language = "esES", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-warwithin-eses-solo-minahonda" },
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_esES_LQ",       Title = "WarWithin (esES) - Low Quality",                  ContentVersion = "1.0", Language = "esES", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-warwithin-es-es" },
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_Karesh_esES_MQ", Title = "WarWithin - Karesh (esES) - MQ",                 ContentVersion = "1.0", Language = "esES", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-warwithin-karesh-eses-mq" },
        { AddonName = "AI_VoiceOver_Sounds_Pandaria_esES_MQ",        Title = "Pandaria Classic (esES) - MQ",                   ContentVersion = "1.0", Language = "esES", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-pandaria-classic-eses-mq" },
        { AddonName = "AI_VoiceOver_Sounds_PandariaRemix_esES",      Title = "Pandaria Remix (esES)",                           ContentVersion = "1.0", Language = "esES", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-retail-pandaria-remix" },
        { AddonName = "AI_VoiceOver_Sounds_Vanilla_esES_HQ",         Title = "Vanilla (esES) - High Quality",                  ContentVersion = "1.0", Language = "esES", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-vanilla-eses-high-quality" },
        { AddonName = "AI_VoiceOver_Sounds_Vanilla_esES_Parts",      Title = "Vanilla (esES) - Parts",                         ContentVersion = "1.0", Language = "esES", Parts = {
            { name = "Part 1", URL = "https://www.curseforge.com/wow/addons/ai-vod-vanilla-eses-part1" },
            { name = "Part 2", URL = "https://www.curseforge.com/wow/addons/ai-vod-vanilla-eses-part2" },
            { name = "Part 3", URL = "https://www.curseforge.com/wow/addons/ai-vod-vanilla-eses-part3" },
            { name = "Part 4", URL = "https://www.curseforge.com/wow/addons/ai-vod-vanilla-eses-part4" },
        }},
        { AddonName = "AI_VoiceOver_Sounds_CatClassic_esES",         Title = "Cataclysm Classic (esES)",                       ContentVersion = "1.0", Language = "esES", Parts = {
            { name = "Part 1", URL = "https://www.curseforge.com/wow/addons/cataclysm-classic-es-es-part1" },
            { name = "Part 2", URL = "https://www.curseforge.com/wow/addons/ai-voiceoverdata-cataclysmclassic-eses-part2" },
            { name = "Part 3", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-cataclysm-classic-es-es-part3" },
            { name = "Part 4", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-cataclysm-classic-es-es-part4" },
        }},
        -- deDE
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_deDE",          Title = "WarWithin (deDE)",                                ContentVersion = "1.0", Language = "deDE", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-war-within-de-de" },
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_Karesh_deDE_MQ", Title = "WarWithin - Karesh (deDE) - MQ",                ContentVersion = "1.0", Language = "deDE", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-warwithin-karesh-dede-mq" },
        { AddonName = "AI_VoiceOver_Sounds_Legion_deDE",             Title = "Legion (deDE)",                                   ContentVersion = "1.0", Language = "deDE", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-legion-dede" },
        { AddonName = "AI_VoiceOver_Sounds_Vanilla_deDE_HQ",         Title = "Vanilla (deDE) - High Quality",                  ContentVersion = "1.0", Language = "deDE", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-vanilla-dede-high-quality" },
        -- ptBR
        { AddonName = "AI_VoiceOver_Sounds_WarWithin_ptBR_LQ",       Title = "WarWithin (ptBR) - Low Quality",                 ContentVersion = "1.0", Language = "ptBR", URL = "https://www.curseforge.com/wow/addons/voiceover-sounds-warwithin-pt-br" },
        { AddonName = "AI_VoiceOver_Sounds_Vanilla_ptBR_LQ",         Title = "Vanilla (ptBR) - LowQuality",                   ContentVersion = "1.0", Language = "ptBR", URL = "https://www.curseforge.com/wow/addons/ai-voiceover-sounds-vanilla-ptbr-lowquality" },
    },
}

---@param a DataModule|DataModuleMetadata
---@param b DataModule|DataModuleMetadata
local function SortModules(a, b)
    a = a.METADATA or a
    b = b.METADATA or b
    if a.ModulePriority ~= b.ModulePriority then
        return a.ModulePriority > b.ModulePriority
    end
    return a.AddonName < b.AddonName
end

---@param name string Addon name
---@param module DataModule Module data table
function DataModules:Register(name, module)
    assert(not self.registeredModules[name], format([[Module "%s" already registered]], name))

    local metadata = assert(self.presentModules[name],
        format([[Module "%s" attempted to register but wasn't detected during addon enumeration]], name))
    local moduleVersion = assert(tonumber(C_AddOns.GetAddOnMetadata(name, "X-VoiceOver-DataModule-Version")),
        format([[Module "%s" is missing data format version]], name))

    -- Ideally if module format would ever change - there should be fallbacks in place to handle outdated formats
    assert(moduleVersion == CURRENT_MODULE_VERSION,
        format([[Module "%s" contains outdated data format (version %d, expected %d)]], name, moduleVersion,
            CURRENT_MODULE_VERSION))

    module.METADATA = metadata

    self.registeredModules[name] = module
    table.insert(self.registeredModulesOrdered, module)

    -- Order the modules by priority (higher first) then by name (case-sensitive alphabetical)
    -- Modules with higher priority will be iterated through first, so one can create a module with "overrides" for data in other modules by simply giving it a higher priority
    table.sort(self.registeredModulesOrdered, SortModules)
end

function DataModules:HasRegisteredModules()
    return next(self.registeredModules) ~= nil
end

function DataModules:GetModule(name)
    return self.registeredModules[name]
end

function DataModules:GetModules()
    return ipairs(self.registeredModulesOrdered)
end

function DataModules:GetPresentModule(name)
    return self.presentModules[name]
end

function DataModules:GetPresentModules()
    return ipairs(self.presentModulesOrdered)
end

function DataModules:GetAvailableModules()
    return ipairs(self.availableModules)
end

function DataModules:EnumerateAddons()
    local playerName = UnitName("player")
    for i = 1, C_AddOns.GetNumAddOns() do
        local moduleVersion = tonumber(C_AddOns.GetAddOnMetadata(i, "X-VoiceOver-DataModule-Version"))
        if moduleVersion and (FORCE_ENABLE_DISABLED_MODULES or C_AddOns.GetAddOnEnableState(playerName, i) ~= 0) then
            local name = C_AddOns.GetAddOnInfo(i)
            local mapsString = C_AddOns.GetAddOnMetadata(i, "X-VoiceOver-DataModule-Maps")
            local maps = {}
            if mapsString then
                for _, mapString in ipairs({ strsplit(",", mapsString) }) do
                    local map = tonumber(mapString)
                    if map then
                        maps[map] = true
                    end
                end
            end
            ---@type DataModuleMetadata
            local module =
            {
                AddonName = name,
                LoadOnDemand = C_AddOns.IsAddOnLoadOnDemand(name),
                ModuleVersion = moduleVersion,
                ModulePriority = tonumber(C_AddOns.GetAddOnMetadata(name, "X-VoiceOver-DataModule-Priority")) or 0,
                ContentVersion = C_AddOns.GetAddOnMetadata(name, "Version"),
                Title = C_AddOns.GetAddOnMetadata(name, "Title") or name,
                Maps = maps,
            }
            self.presentModules[name] = module
            table.insert(self.presentModulesOrdered, module)

            -- Maybe in the future we can load modules based on the map the player is in (select(8, GetInstanceInfo())), but for now - just load everything
            if LOAD_ALL_MODULES and C_AddOns.IsAddOnLoadOnDemand(name) and 
               (not USE_MODULE_DISABLE_SETTINGS or not self:IsModuleDisabled(name)) then
                DataModules:LoadModule(module)
            end
        end
    end

    table.sort(self.presentModulesOrdered, SortModules)
    for order, module in self:GetPresentModules() do
        Options:AddDataModule(module, order)
    end

    for order, module in self:GetAvailableModules() do
        local min = module.RelevantAboveVersion
        local max = module.RelevantBelowVersion
        if (not min or Version.Interface >= min) and (not max or Version.Interface < max) then
            local present = self.presentModules[module.AddonName]
            local update = present and present.ContentVersion ~= module.ContentVersion
            if not present or update then
                Options:AddAvailableDataModule(module, order, update)
            end
        end
    end
end

-- We deliberately use a high ##Interface version in TOC to ensure that all clients will load it.
-- Otherwise pre-classic-rerelease clients will refuse to load addons with version < 20000.
-- Here we temporarily enable "Load out of date AddOns" to load the module, and restore the user's setting afterwards.
-- These cvars can be nil, so have to store the fact of them being changed in a separate variable.
local prev_checkAddonVersion, changed_checkAddonVersion
local prev_lastAddonVersion, changed_lastAddonVersion -- Added in 5.x
local addonWasDisabled = {}
local function EnableOutOfDate(addon)
    if not changed_checkAddonVersion then
        prev_checkAddonVersion = GetCVar("checkAddonVersion")
        SetCVar("checkAddonVersion", 0)
        changed_checkAddonVersion = true
    end
    if not changed_lastAddonVersion then
        prev_lastAddonVersion = GetCVar("lastAddonVersion")
        SetCVar("lastAddonVersion", Version.Interface)
        changed_lastAddonVersion = true
    end

    addonWasDisabled[addon] = C_AddOns.GetAddOnEnableState(UnitName("player"), addon) == 0
    if FORCE_ENABLE_DISABLED_MODULES and addonWasDisabled[addon] then
        C_AddOns.EnableAddOn(addon)
    end
end
local function RestoreOutOfDate(addon)
    if changed_checkAddonVersion then
        SetCVar("checkAddonVersion", prev_checkAddonVersion)
        changed_checkAddonVersion = nil
    end
    if changed_lastAddonVersion then
        SetCVar("lastAddonVersion", prev_lastAddonVersion)
        changed_lastAddonVersion = nil
    end

    if FORCE_ENABLE_DISABLED_MODULES and addonWasDisabled[addon] then
        C_AddOns.DisableAddOn(addon)
    end
    addonWasDisabled[addon] = nil
end

---@param module DataModuleMetadata
function DataModules:LoadModule(module)
    if not module.LoadOnDemand or self:GetModule(module.AddonName) or C_AddOns.IsAddOnLoaded(module.AddonName) then
        return false
    end

    -- No cargar si el módulo está desactivado en la configuración
    if USE_MODULE_DISABLE_SETTINGS and self:IsModuleDisabled(module.AddonName) then
        return false, "DISABLED_IN_CONFIG"
    end

    EnableOutOfDate(module.AddonName)
    local loaded, reason = C_AddOns.LoadAddOn(module.AddonName)
    RestoreOutOfDate(module.AddonName)
    return loaded, reason
end

function DataModules:GetModuleAddOnInfo(module)
    EnableOutOfDate(module.AddonName)
    local name, title, notes, loadable, reason = C_AddOns.GetAddOnInfo(module.AddonName)
    RestoreOutOfDate(module.AddonName)
    return name, title, notes, loadable, reason
end

local function replaceDoubleQuotes(text)
    return string.gsub(text, '"', "'")
end

---@param soundData SoundData
---@return string|nil hash
function DataModules:GetNPCGossipTextHash(soundData)
    local table, npc
    if soundData.unitGUID then
        local type = Utils:GetGUIDType(soundData.unitGUID)
        if Enums.GUID:IsCreature(type) then
            table = "GossipLookupByNPCID"
        elseif type == Enums.GUID.GameObject then
            table = "GossipLookupByObjectID"
        else
            return
        end
        npc = Utils:GetIDFromGUID(soundData.unitGUID)
    else
        table = soundData.unitIsObjectOrItem and "GossipLookupByObjectName" or "GossipLookupByNPCName"
        npc = replaceDoubleQuotes(soundData.name)
    end
    local text = soundData.text

    local text_entries = {}

    for _, module in self:GetModules() do
        local data = module[table]
        if data then
            local npc_gossip_table = data[npc]
            if npc_gossip_table then
                for text, hash in pairs(npc_gossip_table) do
                    text_entries[text] = text_entries[text] or
                        hash -- Respect module priority, don't overwrite the entry if there is already one
                end
            end
        end
    end

    local best_result = FuzzySearchBestKeys(text, text_entries)
    return best_result and best_result.value
end

local function getFirstNWords(text, n)
    local firstNWords = {}
    local count = 0

    for word in string.gmatch(text, "%S+") do
        count = count + 1
        table.insert(firstNWords, word)
        if count >= n then
            break
        end
    end

    return table.concat(firstNWords, " ")
end

local function getLastNWords(text, n)
    local lastNWords = {}
    local count = 0

    for word in string.gmatch(text, "%S+") do
        table.insert(lastNWords, word)
        count = count + 1
    end

    local startIndex = math.max(1, count - n + 1)
    local endIndex = count

    return table.concat(lastNWords, " ", startIndex, endIndex)
end

---@alias QuestIDLookupSource "accept"|"progress"|"complete"

---@param source QuestIDLookupSource
---@param title string
---@param npcName string
---@param text string
---@return number questID
function DataModules:GetQuestID(source, title, npcName, text)
    local cleanedTitle = replaceDoubleQuotes(title)
    local cleanedNPCName = replaceDoubleQuotes(npcName)
    local cleanedText = replaceDoubleQuotes(getFirstNWords(text, 15)) ..
        " " .. replaceDoubleQuotes(getLastNWords(text, 15))
    local text_entries = {}

    for _, module in self:GetModules() do
        local data = module.QuestIDLookup
        if data then
            local titleLookup = data[source][cleanedTitle]
            if titleLookup then
                if type(titleLookup) == "number" then
                    return titleLookup
                else
                    -- else titleLookup is a table and we need to search it further
                    local npcLookup = titleLookup[cleanedNPCName]
                    if npcLookup then
                        if type(npcLookup) == "number" then
                            return npcLookup
                        else
                            for text, ID in pairs(npcLookup) do
                                text_entries[text] = text_entries[text] or
                                    ID -- Respect module priority, don't overwrite the entry if there is already one
                            end
                        end
                    end
                end
            end
        end
    end

    local best_result = FuzzySearchBestKeys(cleanedText, text_entries)
    return best_result and best_result.value
end

---@param questID number
---@return GUID|nil type `Enums.GUID` type of the quest giver
---@return number|nil id ID of the quest giver
--- modifique este para obtener el primer valor
function DataModules:GetQuestLogQuestGiverTypeAndID(questID, useSecondValue)
    for _, module in self:GetModules() do
        -- Buscar en NPCs
        local data = module.NPCIDLookupByQuestID
        if data then
            local npcIDs = data[questID]
            if npcIDs then
                -- Usar el segundo valor si `useSecondValue` es verdadero
                if type(npcIDs) == "table" then
                    if useSecondValue and #npcIDs > 1 then
                        return Enums.GUID.Creature, npcIDs[2]
                    else
                        return Enums.GUID.Creature, npcIDs[1]
                    end
                else
                    return Enums.GUID.Creature, npcIDs
                end
            end
        end

        -- Buscar en Objetos
        data = module.ObjectIDLookupByQuestID
        if data then
            local objectIDs = data[questID]
            if objectIDs then
                -- Usar el segundo valor si `useSecondValue` es verdadero
                if type(objectIDs) == "table" then
                    if useSecondValue and #objectIDs > 1 then
                        return Enums.GUID.GameObject, objectIDs[2]
                    else
                        return Enums.GUID.GameObject, objectIDs[1]
                    end
                else
                    return Enums.GUID.GameObject, objectIDs
                end
            end
        end

        -- Buscar en Ítems
        data = module.ItemIDLookupByQuestID
        if data then
            local itemIDs = data[questID]
            if itemIDs then
                -- Usar el segundo valor si `useSecondValue` es verdadero
                if type(itemIDs) == "table" then
                    if useSecondValue and #itemIDs > 1 then
                        return Enums.GUID.Item, itemIDs[2]
                    else
                        return Enums.GUID.Item, itemIDs[1]
                    end
                else
                    return Enums.GUID.Item, itemIDs
                end
            end
        end
    end

    -- Si no se encontró ningún valor, retornar nil
    return nil
end

---@param type GUID `Enums.GUID` type of the desired object
---@param id number ID of the desired object
---@return string|nil name
function DataModules:GetObjectName(type, id)
    local table
    if Enums.GUID:IsCreature(type) then
        table = "NPCNameLookupByNPCID"
    elseif type == Enums.GUID.GameObject then
        table = "ObjectNameLookupByObjectID"
    elseif type == Enums.GUID.Item then
        table = "ItemNameLookupByItemID"
    else
        return
    end

    for _, module in self:GetModules() do
        local data = module[table]
        if data then
            local npcName = data[id]
            if npcName then
                return npcName
            end
        end
    end
end

---@param title string Título del GameObject
---@param mapID number ID del mapa actual
---@param coords string Coordenadas en formato "X.X,Y.Y"
---@return string|nil hash
function DataModules:GetGameObjectHash(title, mapID, coords)
    local cleanedTitle = replaceDoubleQuotes(title)
    local cleanedCoords = replaceDoubleQuotes(coords)

    -- Extraer coordenadas del jugador
    local playerX, playerY = cleanedCoords:match("([%d%.]+),([%d%.]+)")
    playerX = tonumber(playerX)
    playerY = tonumber(playerY)

    if not playerX or not playerY then
        return nil
    end

    -- Quitamos la tolerancia estricta y buscamos el MÁS CERCANO en todo el mapa
    local bestMatch = nil
    local minDistance = 999999 -- Valor inicial muy alto

    for _, module in self:GetModules() do
        local data = module.GameObjectLookupByTitleAndLocation
        if data then
            local title_table = data[cleanedTitle]
            if title_table then
                local map_table = title_table[mapID]
                if map_table then
                    -- Primero intentar búsqueda exacta
                    local coord_data = map_table[cleanedCoords]
                    if coord_data then
                        Debug:Print("Exact match found at distance 0.0", "GameObject")
                        return coord_data.hash or coord_data
                    end

                    -- Buscar el MÁS CERCANO
                    for storedCoords, coordData in pairs(map_table) do
                        local storedX, storedY = storedCoords:match("([%d%.]+),([%d%.]+)")
                        storedX = tonumber(storedX)
                        storedY = tonumber(storedY)

                        if storedX and storedY then
                            local distance = math.sqrt((playerX - storedX)^2 + (playerY - storedY)^2)

                            if distance < minDistance then
                                minDistance = distance
                                bestMatch = coordData.hash or coordData
                            end
                        end
                    end
                end
            end
        end

        if bestMatch then
            Debug:Print(string.format("Found closest match! Distance: %.2f", minDistance), "GameObject")
            return bestMatch
        end
    end

    return nil
end

---Obtiene la duración de un sonido por su hash/nombre de archivo
---@param fileName string Hash o nombre del archivo de sonido
---@return number|nil duration Duración en segundos o nil si no se encuentra
function DataModules:GetSoundLength(fileName)
    if not fileName then
        return nil
    end

    for _, module in self:GetModules() do
        local data = module.SoundLengthLookupByFileName
        if data and data[fileName] then
            return data[fileName]
        end
    end

    return nil
end

---@type table<SoundEvent, fun(soundData: SoundData): string|nil>
local getFileNameForEvent =
{
    [Enums.SoundEvent.QuestAccept]   = function(soundData)
        local npcId = soundData.unitGUID and Utils:GetIDFromGUID(soundData.unitGUID)
        if npcId then
            local specificFileName = format("%d-accept-%d", soundData.questID, npcId)
            for _, module in DataModules:GetModules() do
                local data = module.SoundLengthLookupByFileName
                if data and data[specificFileName] then
                    return specificFileName
                end
            end
        end

        -- 2da opción: Formato genérico
        local genericFileName = format("%d-accept", soundData.questID)
        for _, module in DataModules:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data and data[genericFileName] then
                return genericFileName
            end
        end

        -- 3ra opción: Buscar cualquier archivo que coincida con questID-accept-* (ignorando el npcID)
        local searchPattern = "^" .. soundData.questID .. "%-accept%-%d+$"
        for _, module in DataModules:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data then
                for fileName in pairs(data) do
                    if fileName:match(searchPattern) then
                        Debug:Print("Usando modo 3 (ignorando NPCID): " .. fileName, "QuestAccept")
                        return fileName
                    end
                end
            end
        end

        return genericFileName
    end,
    [Enums.SoundEvent.QuestProgress] = function(soundData)
        local npcId = soundData.unitGUID and Utils:GetIDFromGUID(soundData.unitGUID)
        if npcId then
            local specificFileName = format("%d-progress-%d", soundData.questID, npcId)
            for _, module in DataModules:GetModules() do
                local data = module.SoundLengthLookupByFileName
                if data and data[specificFileName] then
                    return specificFileName
                end
            end
        end

        -- 2da opción: Formato genérico
        local genericFileName = format("%d-progress", soundData.questID)
        for _, module in DataModules:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data and data[genericFileName] then
                return genericFileName
            end
        end

        -- 3ra opción: Buscar cualquier archivo que coincida con questID-progress-* (ignorando el npcID)
        local searchPattern = "^" .. soundData.questID .. "%-progress%-%d+$"
        for _, module in DataModules:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data then
                for fileName in pairs(data) do
                    if fileName:match(searchPattern) then
                        Debug:Print("Usando modo 3 (ignorando NPCID): " .. fileName, "QuestProgress")
                        return fileName
                    end
                end
            end
        end

        return genericFileName
    end,
    [Enums.SoundEvent.QuestComplete] = function(soundData)
        local npcId = soundData.unitGUID and Utils:GetIDFromGUID(soundData.unitGUID)
        if npcId then
            local specificFileName = format("%d-complete-%d", soundData.questID, npcId)
            for _, module in DataModules:GetModules() do
                local data = module.SoundLengthLookupByFileName
                if data and data[specificFileName] then
                    return specificFileName
                end
            end
        end

        -- 2da opción: Formato genérico sin NPC ID
        local genericFileName = format("%d-complete", soundData.questID)
        for _, module in DataModules:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data and data[genericFileName] then
                return genericFileName
            end
        end

        -- 3ra opción: Buscar cualquier archivo que coincida con questID-complete-* (ignorando el npcID)
        local searchPattern = "^" .. soundData.questID .. "%-complete%-%d+$"
        for _, module in DataModules:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data then
                for fileName in pairs(data) do
                    if fileName:match(searchPattern) then
                        Debug:Print("Usando modo 3 (ignorando NPCID): " .. fileName, "QuestComplete")
                        return fileName
                    end
                end
            end
        end

        return genericFileName
    end,
    [Enums.SoundEvent.QuestGreeting] = function(soundData) return DataModules:GetNPCGossipTextHash(soundData) end,
    [Enums.SoundEvent.Gossip]        = function(soundData) return DataModules:GetNPCGossipTextHash(soundData) end,
    [Enums.SoundEvent.ItemReceived]  = function(soundData) return soundData.fileName end, -- Añadir esto para manejar el evento ItemReceived
    [Enums.SoundEvent.NPCDialogue]   = function(soundData) return soundData.fileName end, -- Asegúrate de tener una lógica para este evento
    [Enums.SoundEvent.Dialogue]      = function(soundData) return soundData.fileName end, -- Diálogos de chat
    [Enums.SoundEvent.GameObject]    = function(soundData) return soundData.fileName end, -- GameObjects del mundo
    [Enums.SoundEvent.Misc]          = function(soundData) return soundData.fileName end, -- Archivos misceláneos (Cielonautica, etc.)

}
setmetatable(getFileNameForEvent,
    {
        __index = function(self, event)
            error(format([[Unhandled VoiceOver sound event %d "%s"]], event,
                Enums.SoundEvent:GetName(event) or "???"))
        end
    })

---@param soundData SoundData
---@return boolean found Whether the sound is found and can be played
function DataModules:PrepareSound(soundData)
    if soundData.event == Enums.SoundEvent.ItemReceived then
        if not soundData.fileName then
            soundData.fileName = getFileNameForEvent[soundData.event](soundData)
        end

        if soundData.fileName == nil then
            return false
        end

        -- Las claves de SoundLengthLookupByFileName se almacenan SIN .mp3.
        -- Quitamos la extension si viene incluida para que el lookup acierte.
        if soundData.fileName:find("%.mp3$") then
            soundData.fileName = soundData.fileName:gsub("%.mp3$", "")
        end

        for _, module in self:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data then
                local playerGenderedFileName = DataModules:AddPlayerGenderToFilename(soundData.fileName)
                local length = data[playerGenderedFileName]
                if length then
                    soundData.fileName = playerGenderedFileName
                else
                    length = data[soundData.fileName]
                end

                if length then
                    soundData.filePath = format([[Interface\AddOns\%s\generated\sounds\items\%s.mp3]],
                        module.METADATA.AddonName,
                        soundData.fileName)
                    soundData.length = length
                    soundData.module = module
                    return true
                end
            end
        end

        if soundData.filePath and soundData.length then
            soundData.module = {
                METADATA = {
                    AddonName = "AI_VoiceOver"
                }
            }
            return true
        end

        return false
    else
        if not soundData.fileName then
            if getFileNameForEvent[soundData.event] then
                soundData.fileName = getFileNameForEvent[soundData.event](soundData)
            else
                return false
            end
        end

        if soundData.fileName == nil then
            return false
        end

        for _, module in self:GetModules() do
            local data = module.SoundLengthLookupByFileName
            if data and data[soundData.fileName] then
                soundData.length = data[soundData.fileName]
                soundData.module = module

                local eventFolder = ""
                if soundData.event == Enums.SoundEvent.QuestAccept then
                    eventFolder = "quests"
                elseif soundData.event == Enums.SoundEvent.QuestComplete then
                    eventFolder = "quests"
                elseif soundData.event == Enums.SoundEvent.QuestProgress then
                    eventFolder = "quests"
                elseif soundData.event == Enums.SoundEvent.QuestGreeting then
                    eventFolder = "quests"
                elseif soundData.event == Enums.SoundEvent.Gossip then
                    eventFolder = "gossip"
                elseif soundData.event == Enums.SoundEvent.Dialogue then
                    eventFolder = "dialogues"
                elseif soundData.event == Enums.SoundEvent.GameObject then
                    eventFolder = "gameobjects"
                elseif soundData.event == Enums.SoundEvent.Misc then
                    eventFolder = "misc"
                elseif soundData.event == Enums.SoundEvent.Rare then
                    eventFolder = "rares"
                else
                    eventFolder = "misc"
                end

                soundData.filePath = format([[Interface\AddOns\%s\generated\sounds\%s\%s.mp3]],
                    module.METADATA.AddonName,
                    eventFolder,
                    soundData.fileName)

                return true
            end
        end

        return false
    end
end

function DataModules:AddPlayerGenderToFilename(fileName)
    local playerGender = UnitSex("player")

    if playerGender == 2 then     -- male
        return "m-" .. fileName
    elseif playerGender == 3 then -- female
        return "f-" .. fileName
    else                          -- unknown or error
        return fileName
    end
end

-- Nuevas funciones para manejar la activación/desactivación de módulos
function DataModules:IsModuleDisabled(addonName)
    return Addon.db.profile.DisabledModules and Addon.db.profile.DisabledModules[addonName]
end

function DataModules:SetModuleDisabled(addonName, disabled)
    if not Addon.db.profile.DisabledModules then
        Addon.db.profile.DisabledModules = {}
    end
    
    Addon.db.profile.DisabledModules[addonName] = disabled or nil
    
    -- Si estamos habilitando un módulo que estaba desactivado, intentamos cargarlo
    if not disabled and not self:GetModule(addonName) and not C_AddOns.IsAddOnLoaded(addonName) then
        local module = self:GetPresentModule(addonName)
        if module then
            self:LoadModule(module)
        end
    end
end

function DataModules:ReloadModules()
    -- Descargar todos los módulos (esto no es posible directamente en WoW, pero podemos limpiar nuestras referencias)
    self.registeredModules = {}
    self.registeredModulesOrdered = {}
    
    -- Volver a enumerar y cargar los módulos
    self:EnumerateAddons()
end
