local addonName, L = ...

local LSM = LibStub and LibStub("LibSharedMedia-3.0", true)

L.registeredMediaPlugins = L.registeredMediaPlugins or {}
L.availableSounds = {}
L.soundIndex = {}
L.availableVoices = {}
L.voiceIndex = {}
L.availableImages = {}
L.imageIndex = {}
L.availableFonts = {}
L.fontIndex = {}
L.ttsPlaybackState = "idle"
L.temporaryTTSCVarRestore = nil
L.previewSoundHandle = nil
L.activeSoundHandle = nil

local FALLBACK_FONTS = {
    { key = "Friz Quadrata TT", name = "Friz Quadrata TT", file = [[Fonts\FRIZQT__.TTF]] },
    { key = "Arial Narrow", name = "Arial Narrow", file = [[Fonts\ARIALN.TTF]] },
    { key = "Morpheus", name = "Morpheus", file = [[Fonts\MORPHEUS_CYR.TTF]] },
    { key = "Skurri", name = "Skurri", file = [[Fonts\SKURRI_CYR.TTF]] },
}

local function CopyEntry(entry)
    local copy = {}
    for key, value in pairs(entry) do
        copy[key] = value
    end
    return copy
end

local function ParseAnimatedImageDataFromPath(path)
    if type(path) ~= "string" or path == "" then
        return nil
    end

    -- Animated TGA metadata is encoded in the filename so media packs can stay
    -- data-only and do not need extra sidecar files for sprite sheet dimensions.
    local rows, columns, frames, frameWidth, frameHeight, fileWidth, fileHeight =
        path:match("%.x(%d+)y(%d+)f(%d+)w(%d+)h(%d+)W(%d+)H(%d+)%.[Tt][Gg][Aa]$")
    if rows then
        return {
            animated = true,
            rows = tonumber(rows),
            columns = tonumber(columns),
            frames = tonumber(frames),
            frameWidth = tonumber(frameWidth),
            frameHeight = tonumber(frameHeight),
            fileWidth = tonumber(fileWidth),
            fileHeight = tonumber(fileHeight),
        }
    end

    local compactRows, compactColumns, compactFrames =
        path:match("%.x(%d+)y(%d+)f(%d+)%.[Tt][Gg][Aa]$")
    if compactRows then
        return {
            animated = true,
            rows = tonumber(compactRows),
            columns = tonumber(compactColumns),
            frames = tonumber(compactFrames),
        }
    end

    return nil
end

local function NormalizeAnimatedImageEntry(image)
    local parsedImageData = ParseAnimatedImageDataFromPath(image.file)
    local wantsAnimation = image.animated == true or image.animationType == "tga" or parsedImageData ~= nil

    if not wantsAnimation then
        return image
    end

    image.animated = true
    image.animationType = image.animationType or "tga"

    if parsedImageData then
        for key, value in pairs(parsedImageData) do
            if image[key] == nil then
                image[key] = value
            end
        end
    end

    image.rows = tonumber(image.rows)
    image.columns = tonumber(image.columns)
    image.frames = tonumber(image.frames)
    image.frameWidth = tonumber(image.frameWidth)
    image.frameHeight = tonumber(image.frameHeight)
    image.fileWidth = tonumber(image.fileWidth)
    image.fileHeight = tonumber(image.fileHeight)
    image.frameRate = tonumber(image.frameRate)

    -- If the metadata is incomplete, fall back to treating the entry as static
    -- instead of leaving a half-configured animated image in the registry.
    if not image.rows or image.rows <= 0 then
        image.animated = false
        return image
    end
    if not image.columns or image.columns <= 0 then
        image.animated = false
        return image
    end
    if not image.frames or image.frames <= 0 then
        image.frames = image.rows * image.columns
    end

    return image
end

local function SafeInsertSound(entry)
    if not entry or not entry.source or not entry.key or not entry.name then
        return
    end

    local uid = L:MakeUniqueID(entry.source, entry.key)
    if L.soundIndex[uid] then
        return
    end

    L.availableSounds[#L.availableSounds + 1] = entry
    L.soundIndex[uid] = entry
end

local function HasExistingSoundMatch(name, file)
    local normalizedName = name and tostring(name):lower() or nil
    local normalizedFile = file and tostring(file):lower() or nil

    for _, entry in ipairs(L.availableSounds) do
        local entryName = entry.rawName or entry.name
        if normalizedName and entryName and tostring(entryName):lower() == normalizedName then
            return true
        end
        if normalizedFile and entry.file and tostring(entry.file):lower() == normalizedFile then
            return true
        end
    end

    return false
end

local function SafeInsertImage(entry)
    if not entry or not entry.key or not entry.name or not entry.file then
        return
    end

    if L.imageIndex[entry.key] then
        return
    end

    L.availableImages[#L.availableImages + 1] = entry
    L.imageIndex[entry.key] = entry
end

local function SafeInsertFont(entry)
    if not entry or not entry.key or not entry.name or not entry.file then
        return
    end

    if L.fontIndex[entry.key] then
        return
    end

    L.availableFonts[#L.availableFonts + 1] = entry
    L.fontIndex[entry.key] = entry
end

local function ResolveReplacementValue(context, key)
    if not context then
        return ""
    end

    local value = context[key]
    if value == nil then
        return ""
    end

    return tostring(value)
end

function L:GetCustomSoundTable()
    return nil
end

function L:RegisterMediaPlugin(pluginName, pluginData)
    if type(pluginName) ~= "string" or pluginName == "" or type(pluginData) ~= "table" then
        return false
    end

    -- Normalize third-party media into the same schema used internally so the rest
    -- of the addon can rebuild lists without caring where entries came from.
    local normalized = {
        name = pluginName,
        sounds = {},
        images = {},
        fonts = {},
    }

    for _, entry in ipairs(pluginData.sounds or {}) do
        if entry.key and entry.name and entry.file then
            local sound = CopyEntry(entry)
            sound.source = sound.source or pluginName
            normalized.sounds[#normalized.sounds + 1] = sound
        end
    end

    for _, entry in ipairs(pluginData.images or {}) do
        if entry.key and entry.name and entry.file then
            local image = CopyEntry(entry)
            image = NormalizeAnimatedImageEntry(image)
            normalized.images[#normalized.images + 1] = image
        end
    end

    for _, entry in ipairs(pluginData.fonts or {}) do
        if entry.key and entry.name and entry.file then
            normalized.fonts[#normalized.fonts + 1] = CopyEntry(entry)
        end
    end

    self.registeredMediaPlugins[pluginName] = normalized

    if self.initialized then
        self:RefreshRegisteredMedia()
    end

    return true
end

function L:GetRegisteredMediaPlugins()
    return self.registeredMediaPlugins
end

function L:RefreshRegisteredMedia()
    -- Rebuild every derived lookup after plugin changes so currently selected media
    -- stays valid and the settings UI/runtime can refresh from one entry point.
    self:RebuildSoundList()
    self:RebuildImageList()
    self:RebuildFontList()
    self:EnsureValidSelectedSound("bloodlust")
    self:EnsureValidSelectedSound("piTrack")
    self:EnsureValidSelectedSound("piRequest")
    self:EnsureValidSelectedImage("bloodlust")
    self:EnsureValidSelectedImage("piTrack")
    self:EnsureValidSelectedImage("piRequest")
    self:EnsureValidSelectedFont("bloodlust")
    self:EnsureValidSelectedFont("piTrack")
    self:EnsureValidSelectedFont("piRequest")
    self:ApplyOverlayVisuals("bloodlust")
    self:ApplyTextOverlayVisuals(self.BLOODLUST_VISUAL_TOTAL, "bloodlust")
    if self.NotifySettingsChanged then
        self:NotifySettingsChanged("media_lists")
    end
end

function L:RebuildSoundList()
    wipe(self.availableSounds)
    wipe(self.soundIndex)

    for _, pluginData in pairs(self.registeredMediaPlugins) do
        for _, entry in ipairs(pluginData.sounds or {}) do
            SafeInsertSound({
                source = entry.source,
                key = entry.key,
                name = entry.displayName or entry.name,
                file = entry.file,
                rawName = entry.name,
            })
        end
    end

    if LSM and type(LSM.List) == "function" and type(LSM.Fetch) == "function" then
        -- Merge LibSharedMedia sounds after custom/plugin sounds. Sorting below keeps
        -- custom content at the top while still exposing the wider shared library.
        for _, soundName in ipairs(LSM:List("sound") or {}) do
            local soundFile = LSM:Fetch("sound", soundName, true)
            if soundName and soundFile and not HasExistingSoundMatch(soundName, soundFile) then
                local key = "lsm:" .. tostring(soundName)
                local source = "LibSharedMedia"
                local uid = self:MakeUniqueID(source, key)

                if not self.soundIndex[uid] then
                    SafeInsertSound({
                        source = source,
                        key = key,
                        name = soundName,
                        file = soundFile,
                        rawName = soundName,
                    })
                end
            end
        end
    end

    table.sort(self.availableSounds, function(a, b)
        local aCustom = a.source ~= "LibSharedMedia"
        local bCustom = b.source ~= "LibSharedMedia"

        if aCustom ~= bCustom then
            return aCustom
        end

        if a.source ~= b.source then
            return a.source < b.source
        end

        return a.name:lower() < b.name:lower()
    end)
end

function L:RebuildImageList()
    wipe(self.availableImages)
    wipe(self.imageIndex)

    for _, pluginData in pairs(self.registeredMediaPlugins) do
        for _, entry in ipairs(pluginData.images or {}) do
            if entry.key and entry.name and entry.file then
                SafeInsertImage(entry)
            end
        end
    end

    table.sort(self.availableImages, function(a, b)
        return a.name:lower() < b.name:lower()
    end)
end

function L:RebuildFontList()
    wipe(self.availableFonts)
    wipe(self.fontIndex)

    for _, pluginData in pairs(self.registeredMediaPlugins) do
        for _, entry in ipairs(pluginData.fonts or {}) do
            SafeInsertFont(entry)
        end
    end

    if LSM and type(LSM.HashTable) == "function" and type(LSM.Fetch) == "function" then
        local fontTable = LSM:HashTable("font")
        if type(fontTable) == "table" then
            for key, _ in pairs(fontTable) do
                local file = LSM:Fetch("font", key, true)
                if key and file then
                    SafeInsertFont({
                        key = key,
                        name = key,
                        file = file,
                    })
                end
            end
        end
    end

    for _, entry in ipairs(FALLBACK_FONTS) do
        SafeInsertFont(entry)
    end

    table.sort(self.availableFonts, function(a, b)
        return a.name:lower() < b.name:lower()
    end)
end

local function NormalizeVoiceEntry(voice)
    if type(voice) ~= "table" then
        return nil
    end

    local id = voice.voiceID or voice.voiceId or voice.id or voice.optionID or voice[1]
    local name = voice.name or voice.displayName or voice.voiceName or voice.label or voice[2]
    if id == nil then
        return nil
    end

    id = tonumber(id) or id
    if not name or name == "" then
        name = "Voice " .. tostring(id)
    end

    return {
        id = id,
        name = tostring(name),
    }
end

function L:RebuildVoiceList()
    wipe(self.availableVoices)
    wipe(self.voiceIndex)

    if not C_VoiceChat or type(C_VoiceChat.GetTtsVoices) ~= "function" then
        return
    end

    -- Blizzard's voice payload shape varies by API version, so normalize each item
    -- before storing it in the runtime index.
    local voices = C_VoiceChat.GetTtsVoices()
    if type(voices) ~= "table" then
        return
    end

    for _, voice in ipairs(voices) do
        local normalized = NormalizeVoiceEntry(voice)
        if normalized and not self.voiceIndex[normalized.id] then
            self.availableVoices[#self.availableVoices + 1] = normalized
            self.voiceIndex[normalized.id] = normalized
        end
    end

    table.sort(self.availableVoices, function(a, b)
        return a.name:lower() < b.name:lower()
    end)
end

function L:FindSelectedSound(profileKey)
    local source = self:GetProfileDBValue(profileKey, "selectedSoundSource")
    local key = self:GetProfileDBValue(profileKey, "selectedSoundKey")
    return self.soundIndex[self:MakeUniqueID(source, key)]
end

function L:EnsureValidSelectedSound(profileKey)
    local selected = self:FindSelectedSound(profileKey)
    if selected then
        return selected
    end

    -- Prefer the addon default first, then gracefully fall back to the first
    -- available entry so the UI never ends up pointing at a dead selection.
    local profile = self.GetAlertProfileDefinition and self:GetAlertProfileDefinition(profileKey) or nil
    local preferredSource = (profile and profile.defaultSoundSource) or self.DEFAULT_SOUND_SOURCE
    local preferredKey = (profile and profile.defaultSoundKey) or self.DEFAULT_SOUND_KEY
    local preferredUID = self:MakeUniqueID(preferredSource, preferredKey)
    if self.soundIndex[preferredUID] then
        local source, key = self:SplitUniqueID(preferredUID)
        self:SetProfileDBValue(profileKey, "selectedSoundSource", source)
        self:SetProfileDBValue(profileKey, "selectedSoundKey", key)
        return self.soundIndex[preferredUID]
    end

    if self.availableSounds[1] then
        self:SetProfileDBValue(profileKey, "selectedSoundSource", self.availableSounds[1].source)
        self:SetProfileDBValue(profileKey, "selectedSoundKey", self.availableSounds[1].key)
        return self.availableSounds[1]
    end

    return nil
end

function L:EnsureValidSelectedImage(profileKey)
    local selectedKey = self:GetProfileDBValue(profileKey, "selectedImageKey")
    local selected = self.imageIndex[selectedKey]
    if selected then
        return selected
    end

    local profile = self.GetAlertProfileDefinition and self:GetAlertProfileDefinition(profileKey) or nil
    local preferredKey = (profile and profile.defaultImageKey) or self.DEFAULT_IMAGE_KEY

    if self.imageIndex[preferredKey] then
        self:SetProfileDBValue(profileKey, "selectedImageKey", preferredKey)
        return self.imageIndex[preferredKey]
    end

    if self.availableImages[1] then
        self:SetProfileDBValue(profileKey, "selectedImageKey", self.availableImages[1].key)
        return self.availableImages[1]
    end

    return nil
end

function L:IsAnimatedImageEntry(entry)
    return type(entry) == "table"
        and entry.animated == true
        and entry.animationType == "tga"
        and type(entry.rows) == "number" and entry.rows > 0
        and type(entry.columns) == "number" and entry.columns > 0
        and type(entry.frames) == "number" and entry.frames > 0
end

function L:EnsureValidSelectedFont(profileKey)
    local selectedKey = self:GetProfileDBValue(profileKey, "selectedFontKey")
    local selected = self.fontIndex[selectedKey]
    if selected then
        return selected
    end

    local profile = self.GetAlertProfileDefinition and self:GetAlertProfileDefinition(profileKey) or nil
    local preferredKey = (profile and profile.defaultSelectedFontKey) or self.DEFAULT_TEXT_FONT_KEY
    local fallbackKey = (profile and profile.defaultSelectedFontFallbackKey) or self.DEFAULT_TEXT_FONT_KEY

    if self.fontIndex[preferredKey] then
        self:SetProfileDBValue(profileKey, "selectedFontKey", preferredKey)
        return self.fontIndex[preferredKey]
    end

    if self.fontIndex[fallbackKey] then
        self:SetProfileDBValue(profileKey, "selectedFontKey", fallbackKey)
        return self.fontIndex[fallbackKey]
    end

    if self.availableFonts[1] then
        self:SetProfileDBValue(profileKey, "selectedFontKey", self.availableFonts[1].key)
        return self.availableFonts[1]
    end

    self:SetProfileDBValue(profileKey, "selectedFontKey", self.DEFAULT_TEXT_FONT_KEY)
    return {
        key = self.DEFAULT_TEXT_FONT_KEY,
        name = self.DEFAULT_TEXT_FONT_KEY,
        file = [[Fonts\FRIZQT__.TTF]],
    }
end

function L:EnsureValidSelectedVoice(profileKey)
    local voiceID = self:GetProfileDBValue(profileKey, "ttsVoiceID")
    local selected = voiceID and self.voiceIndex[voiceID] or nil
    if selected then
        return selected
    end

    if self.availableVoices[1] then
        self:SetProfileDBValue(profileKey, "ttsVoiceID", self.availableVoices[1].id)
        return self.availableVoices[1]
    end

    self:SetProfileDBValue(profileKey, "ttsVoiceID", nil)
    return nil
end

function L:IsUsingTTS(profileKey)
    return self:GetProfileDBValue(profileKey, "useTTS") == true
end

function L:BeginTemporaryTTSCVar()
    if type(GetCVarBool) ~= "function" or type(SetCVar) ~= "function" then
        return
    end

    -- Temporarily force the client-side TTS cvar on for previews/playback, then
    -- restore the user's original preference when playback ends.
    self.temporaryTTSCVarRestore = GetCVarBool("textToSpeech")
    if not self.temporaryTTSCVarRestore then
        pcall(SetCVar, "textToSpeech", 1)
    end
end

function L:RestoreTemporaryTTSCVar()
    if self.temporaryTTSCVarRestore == nil then
        return
    end

    if type(SetCVar) == "function" then
        pcall(SetCVar, "textToSpeech", self.temporaryTTSCVarRestore and 1 or 0)
    end

    self.temporaryTTSCVarRestore = nil
end

function L:StopSpeakingIfPossible()
    if C_VoiceChat and type(C_VoiceChat.StopSpeakingText) == "function" then
        C_VoiceChat.StopSpeakingText()
    end

    if self.ttsPlaybackState == "pending" or self.ttsPlaybackState == "started" then
        self.ttsPlaybackState = "stopped"
    end

    self:RestoreTemporaryTTSCVar()

    if self.NotifySettingsChanged then
        self:NotifySettingsChanged("tts_playback_stopped")
    end
end

function L:StopPreviewSoundIfPossible()
    local handle = self.previewSoundHandle
    if handle and type(StopSound) == "function" then
        pcall(StopSound, handle, 0)
    end

    self.previewSoundHandle = nil
end

function L:StopActiveSoundIfPossible()
    local handle = self.activeSoundHandle
    if handle and type(StopSound) == "function" then
        pcall(StopSound, handle, 0)
    end

    self.activeSoundHandle = nil
end

function L:StopPreviewAudioIfPossible()
    self:StopPreviewSoundIfPossible()
    self:StopSpeakingIfPossible()
end

function L:StopAllAudioIfPossible()
    self:StopPreviewSoundIfPossible()
    self:StopActiveSoundIfPossible()
    self:StopSpeakingIfPossible()
end

function L:ExpandAlertText(template, context)
    local text = tostring(template or "")
    -- Token expansion is intentionally tiny and explicit so profile strings stay
    -- easy to reason about and safe for user-edited text templates.
    local replacements = {
        ["{pi_giver}"] = ResolveReplacementValue(context, "piGiverName"),
        ["{pi_requester}"] = ResolveReplacementValue(context, "requesterName"),
        ["{pi_target}"] = ResolveReplacementValue(context, "piTargetName"),
    }

    for token, replacement in pairs(replacements) do
        text = text:gsub(token, replacement ~= "" and replacement or "?")
    end

    return text
end

function L:GetProfileTTSText(profileKey, context)
    local text = self:Trim(self:GetProfileDBValue(profileKey, "ttsText"))
    if text == "" then
        local profile = self:GetAlertProfileDefinition(profileKey)
        text = profile.defaultTTSText or self.DEFAULT_TTS_TEXT
    end
    return self:ExpandAlertText(text, context)
end

function L:PlaySelectedSound(profileKey, options)
    options = options or {}

    local entry = self:EnsureValidSelectedSound(profileKey)
    if not entry then
        self:Print("No valid sound configured.")
        return false
    end

    local channel = self:GetProfileDBValue(profileKey, "soundChannel") or self.DEFAULT_SOUND_CHANNEL
    local ok, handle
    if options.isPreview then
        -- Previews should be single-shot; stop any previous preview audio first.
        self:StopPreviewAudioIfPossible()
    end

    if entry.soundKitID then
        ok, handle = PlaySound(entry.soundKitID, channel)
    elseif entry.file then
        ok, handle = PlaySoundFile(entry.file, channel)
    end

    if not ok then
        self:Print("Could not play selected sound.")
        return false
    end

    if options.isPreview then
        self.previewSoundHandle = handle
    else
        self.activeSoundHandle = handle
    end

    return true
end

function L:TrySpeakTextCompatibility(voiceID, text)
    -- Blizzard has shipped multiple SpeakText signatures over time. Try the known
    -- variants so TTS keeps working across client revisions without branching hard.
    local rate = 0
    local volume = 100

    if C_TTSSettings and type(C_TTSSettings.GetSpeechRate) == "function" then
        rate = C_TTSSettings.GetSpeechRate()
    end
    if C_TTSSettings and type(C_TTSSettings.GetSpeechVolume) == "function" then
        volume = C_TTSSettings.GetSpeechVolume()
    end

    local ok1 = pcall(C_VoiceChat.SpeakText, voiceID, text, rate, volume, false)
    if ok1 then
        return true
    end

    local destination = 1
    if Enum and Enum.VoiceTtsDestination and Enum.VoiceTtsDestination.LocalPlayback then
        destination = Enum.VoiceTtsDestination.LocalPlayback
    end

    local ok2 = pcall(C_VoiceChat.SpeakText, voiceID, text, destination, rate, volume)
    if ok2 then
        return true
    end

    local ok3 = pcall(C_VoiceChat.SpeakText, voiceID, text, 0, 100)
    if ok3 then
        return true
    end

    return false
end

function L:PlaySelectedTTS(profileKey, context, options)
    options = options or {}

    if not C_VoiceChat or type(C_VoiceChat.SpeakText) ~= "function" then
        self:Print("TTS API is not available.")
        return false
    end

    local voice = self:EnsureValidSelectedVoice(profileKey)
    if not voice then
        self:Print("No TTS voice available.")
        return false
    end

    local text = self:GetProfileTTSText(profileKey, context)
    if text == "" then
        self:Print("TTS text is empty.")
        return false
    end

    self:BeginTemporaryTTSCVar()

    if C_TTSSettings and type(C_TTSSettings.SetVoiceOption) == "function" then
        pcall(C_TTSSettings.SetVoiceOption, 0, tonumber(voice.id) or voice.id)
        pcall(C_TTSSettings.SetVoiceOption, 1, tonumber(voice.id) or voice.id)
    end

    if options.isPreview then
        self:StopPreviewAudioIfPossible()
    else
        self:StopSpeakingIfPossible()
    end

    self.ttsPlaybackState = "pending"

    local ok = self:TrySpeakTextCompatibility(tonumber(voice.id) or voice.id, text)
    if not ok then
        self.ttsPlaybackState = "failed"
        self:RestoreTemporaryTTSCVar()
        self:Print("TTS playback call failed.")
        return false
    end

    return true
end

function L:PlayCurrentAlert(profileKey, context, options)
    profileKey = profileKey or "bloodlust"
    if self:IsUsingTTS(profileKey) then
        return self:PlaySelectedTTS(profileKey, context, options)
    end
    return self:PlaySelectedSound(profileKey, options)
end

_G.LustomizerMediaAPI = _G.LustomizerMediaAPI or {}

function _G.LustomizerMediaAPI:RegisterPlugin(pluginName, pluginData)
    return L:RegisterMediaPlugin(pluginName, pluginData)
end
