-- Cadence: Display.lua -- the on-screen cue and the voice line.

local ADDON, ns = ...

ns.Display = {}
local D = ns.Display

local FONT = "Fonts\\FRIZQT__.TTF"
local active = {}   -- alerts currently holding a slot, top to bottom
local fading = {}   -- alerts on their way out, no longer holding a slot
local pool = {}

-- Anything this close to its moment counts as landed. Without it a cue with no
-- run-up -- one placed at 0:00, which fires the instant it is shown -- can catch
-- a few milliseconds of remaining time and flash a "1" for one frame before
-- flipping to NOW. Rounding it away is imperceptible on a real countdown.
local LANDED = 0.05

--------------------------------------------------------------------------------
-- Anchor
--------------------------------------------------------------------------------

function D:Init()
    local s = CadenceDB.settings

    local anchor = CreateFrame("Frame", "CadenceAnchor", UIParent)
    anchor:SetSize(220, 30)
    anchor:SetPoint(s.anchor.point, UIParent, s.anchor.relPoint, s.anchor.x, s.anchor.y)
    anchor:SetMovable(true)
    anchor:SetClampedToScreen(true)
    anchor:RegisterForDrag("LeftButton")
    anchor:SetScript("OnDragStart", function(self) self:StartMoving() end)
    anchor:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local point, _, relPoint, x, y = self:GetPoint()
        s.anchor.point, s.anchor.relPoint, s.anchor.x, s.anchor.y = point, relPoint, x, y
    end)

    local bg = anchor:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(ns.COLORS.redBright[1], ns.COLORS.redBright[2], ns.COLORS.redBright[3], 0.4)
    anchor.bg = bg

    local label = anchor:CreateFontString(nil, "OVERLAY")
    label:SetFont(FONT, 12, "OUTLINE")
    label:SetPoint("CENTER")
    label:SetText("Cadence — drag to move")
    label:SetTextColor(unpack(ns.COLORS.goldLight))
    anchor.label = label

    self.anchor = anchor
    self:UpdateAnchor()
end

function D:UpdateAnchor()
    if not self.anchor then return end
    local locked = CadenceDB.settings.locked
    self.anchor:EnableMouse(not locked)
    self.anchor.bg:SetShown(not locked)
    self.anchor.label:SetShown(not locked)
    self.anchor:SetScale(CadenceDB.settings.scale or 1)
end

--------------------------------------------------------------------------------
-- Alert frames
--------------------------------------------------------------------------------

local function CreateAlert()
    local f = CreateFrame("Frame", nil, UIParent)
    f:SetSize(340, 84)
    f:Hide()

    f.icon = f:CreateTexture(nil, "ARTWORK")
    f.icon:SetSize(64, 64)
    f.icon:SetPoint("LEFT")
    f.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

    f.iconBorder = f:CreateTexture(nil, "BACKGROUND")
    f.iconBorder:SetPoint("TOPLEFT", f.icon, -2, 2)
    f.iconBorder:SetPoint("BOTTOMRIGHT", f.icon, 2, -2)
    f.iconBorder:SetColorTexture(ns.COLORS.red[1], ns.COLORS.red[2], ns.COLORS.red[3], 0.95)

    f.count = f:CreateFontString(nil, "OVERLAY")
    f.count:SetFont(FONT, 34, "OUTLINE")
    f.count:SetPoint("CENTER", f.icon, "CENTER", 0, 0)

    f.title = f:CreateFontString(nil, "OVERLAY")
    f.title:SetFont(FONT, 26, "OUTLINE")
    f.title:SetPoint("LEFT", f.icon, "RIGHT", 10, 10)
    f.title:SetJustifyH("LEFT")

    f.note = f:CreateFontString(nil, "OVERLAY")
    f.note:SetFont(FONT, 15, "OUTLINE")
    f.note:SetPoint("TOPLEFT", f.title, "BOTTOMLEFT", 0, -4)
    f.note:SetJustifyH("LEFT")
    f.note:SetTextColor(unpack(ns.COLORS.boneDim))

    f.fade = f:CreateAnimationGroup()
    local a = f.fade:CreateAnimation("Alpha")
    -- No start delay. The delay made sense while a fading cue kept its slot; now
    -- the slot is handed over the instant the hold ends, so any pause at full
    -- alpha is time spent sitting on top of the cue that just moved up into it.
    a:SetFromAlpha(1); a:SetToAlpha(0); a:SetDuration(0.25); a:SetStartDelay(0)
    f.fade:SetScript("OnFinished", function() f:Hide() end)

    return f
end

local function AcquireAlert()
    local f = table.remove(pool)
    if not f then f = CreateAlert() end
    f.released = false
    f.landed = false
    f.detached = false
    f.fade:Stop()
    f:SetAlpha(1)
    -- A pooled frame still carries the anchor it had last time. Drop it and stay
    -- hidden until Restack has placed it, or it renders for one frame in the old
    -- spot and visibly jumps.
    f:ClearAllPoints()
    f:Hide()
    return f
end

-- Gives up the slot the moment the cue starts fading, instead of holding it for
-- the length of the animation. The frame is re-anchored to the place it already
-- occupies, so it fades where it stands while everything below slides up past it.
-- Holding the slot meant the rest of the stack sat under an invisible frame for
-- most of a second before moving.
local function DetachAlert(f)
    if not f or f.released or f.detached then return end

    local slot
    for i, v in ipairs(active) do
        if v == f then slot = i; table.remove(active, i) break end
    end
    f.detached = true
    fading[#fading + 1] = f

    if D.anchor and slot then
        f:ClearAllPoints()
        f:SetPoint("TOP", D.anchor, "TOP", 0, -((slot - 1) * 90))
        -- Behind the live stack: the cue moving up into this slot has to be the
        -- one you read, not the one dissolving underneath it.
        f:SetFrameLevel(2)
    end
    D:Restack()
end

local function ReleaseAlert(f)
    -- An alert can be retired twice: once when the fight ends and again by its own
    -- expiry timer. Without this guard it lands in the pool twice and the same
    -- frame gets handed out for two cues at once.
    if not f or f.released then return end
    f.released = true

    f:SetScript("OnUpdate", nil)
    f:Hide()
    for i, v in ipairs(active) do
        if v == f then table.remove(active, i) break end
    end
    for i, v in ipairs(fading) do
        if v == f then table.remove(fading, i) break end
    end
    pool[#pool + 1] = f
    D:Restack()
end

function D:Restack()
    local anchor = self.anchor
    if not anchor then return end
    for i, f in ipairs(active) do
        f:ClearAllPoints()
        f:SetScale(CadenceDB.settings.scale or 1)
        f:SetFrameLevel(10 + i)
        f:SetPoint("TOP", anchor, "TOP", 0, -((i - 1) * 90))
    end
end

function D:Reposition() self:Restack() end

--------------------------------------------------------------------------------
-- Voice
--------------------------------------------------------------------------------

local voiceWarned = false

-- SpeakText hands the utterance to one of several destinations. They do not all
-- reach the speakers on every setup, so the destination is selectable and the
-- default is the queued one, which is the most widely reliable.
local DEST_ORDER = { "QueuedLocalPlayback", "LocalPlayback", "ScreenReader" }

function D:GetDestinations()
    local out = {}
    if Enum and Enum.VoiceTtsDestination then
        for _, name in ipairs(DEST_ORDER) do
            local v = Enum.VoiceTtsDestination[name]
            if v ~= nil then out[#out + 1] = { name = name, value = v } end
        end
    end
    if #out == 0 then out[1] = { name = "fallback", value = 1 } end
    return out
end

function D:ResolveDestination()
    local s = CadenceDB.settings
    local list = self:GetDestinations()
    if s.ttsDestination then
        for _, d in ipairs(list) do
            if d.value == s.ttsDestination then return d.value, d.name end
        end
    end
    return list[1].value, list[1].name
end

function D:GetVoices()
    local out = {}
    if C_VoiceChat and C_VoiceChat.GetTtsVoices then
        local ok, list = pcall(C_VoiceChat.GetTtsVoices)
        if ok and list then
            for _, v in ipairs(list) do out[#out + 1] = v end
        end
    end
    return out
end

-- Picks a voice: yours if it is still installed, then whatever Blizzard's own
-- Text to Speech panel is set to, then the first available one.
function D:ResolveVoiceID()
    local s = CadenceDB.settings
    local voices = self:GetVoices()

    if s.ttsVoiceID then
        for _, v in ipairs(voices) do
            if v.voiceID == s.ttsVoiceID then return v.voiceID, v.name end
        end
    end

    if C_TTSSettings and C_TTSSettings.GetVoiceOptionID then
        local ok, id = pcall(C_TTSSettings.GetVoiceOptionID, 1)
        if ok and id then
            for _, v in ipairs(voices) do
                if v.voiceID == id then return v.voiceID, v.name end
            end
            return id, "client default"
        end
    end

    if voices[1] then return voices[1].voiceID, voices[1].name end
    return nil, nil
end

-- Returns success plus a reason, so the failure can be reported instead of
-- disappearing into a pcall.
function D:Speak(text, force)
    local s = CadenceDB.settings
    if not text or text == "" then return false, "nothing to say" end
    if not force and not s.tts then return false, "voice is switched off in the panel" end

    if not (C_VoiceChat and C_VoiceChat.SpeakText) then
        return false, "this client has no SpeakText API"
    end

    local volume = s.ttsVolume or 100
    if volume <= 0 then return false, "Cadence voice volume is 0" end

    local voiceID = self:ResolveVoiceID()
    if not voiceID then
        if not voiceWarned then
            voiceWarned = true
            ns.Print("No text-to-speech voices are available to the game. Run /cadence voice.")
        end
        return false, "no voices installed"
    end

    local dest = self:ResolveDestination()
    local ok, err = pcall(C_VoiceChat.SpeakText, voiceID, text, dest, s.ttsRate or 0, volume)
    if not ok then return false, tostring(err) end
    return true
end

-- /cadence voice
function D:VoiceReport()
    local s = CadenceDB.settings
    local voices = self:GetVoices()

    ns.Print("SpeakText API: %s", (C_VoiceChat and C_VoiceChat.SpeakText) and "present" or "MISSING")
    ns.Print("voices the game can see: %d", #voices)
    for i, v in ipairs(voices) do
        if i <= 5 then ns.Print("  [%s] %s", tostring(v.voiceID), tostring(v.name)) end
    end

    local id, name = self:ResolveVoiceID()
    ns.Print("chosen voice: %s (%s)", tostring(name or "none"), tostring(id))

    local destValue, destName = self:ResolveDestination()
    local names = {}
    for i, d in ipairs(self:GetDestinations()) do names[#names + 1] = ("%d=%s"):format(i, d.name) end
    ns.Print("destination: %s (%s). Available: %s", tostring(destName), tostring(destValue),
        table.concat(names, ", "))
    ns.Print("Cadence voice enabled: %s, volume %s, rate %s",
        tostring(s.tts), tostring(s.ttsVolume), tostring(s.ttsRate))

    if C_TTSSettings and C_TTSSettings.GetSpeechVolume then
        local ok, v = pcall(C_TTSSettings.GetSpeechVolume)
        if ok then ns.Print("game Text to Speech volume: %s", tostring(v)) end
    end

    -- Sound CVars worth seeing when audio exists but speech does not.
    if C_CVar and C_CVar.GetCVar then
        for _, cvar in ipairs({ "Sound_EnableAllSound", "Sound_EnableErrorSpeech",
                                "Sound_MasterVolume", "Sound_OutputDriverName",
                                "Sound_VoiceChatOutputDriverName", "Sound_EnableVoiceChat" }) do
            local ok, v = pcall(C_CVar.GetCVar, cvar)
            if ok and v ~= nil and v ~= "" then ns.Print("  %s = %s", cvar, tostring(v)) end
        end
    end

    local spoke, why = self:Speak("Cadence voice test", true)
    if spoke then
        ns.Print("Sent a test line on destination %s.", tostring(destName))
        if #voices == 0 then
            ns.Print("Install a system voice, then restart WoW.")
        else
            ns.Print("Heard nothing? WoW plays speech through the voice chat device, not sound effects.")
            ns.Print("Check Options > Social > Voice Chat: enable it and set Output Device to your headset or speakers.")
            ns.Print("Then run /cadence voice cycle to try each destination in turn.")
        end
    else
        ns.Print("Could not speak: %s", tostring(why))
    end
end

-- Speaks a numbered line through every destination in turn, a few seconds apart,
-- so you can hear which one actually reaches your speakers.
function D:VoiceCycle()
    local list = self:GetDestinations()
    local s = CadenceDB.settings
    local voiceID = self:ResolveVoiceID()
    if not voiceID then
        ns.Print("No voice available. Run /cadence voice.")
        return
    end

    ns.Print("Trying %d destinations, three seconds apart. Note which number you hear.", #list)
    for i, d in ipairs(list) do
        C_Timer.After((i - 1) * 3, function()
            ns.Print("  %d: %s", i, d.name)
            pcall(C_VoiceChat.SpeakText, voiceID,
                ("Option %d, %s"):format(i, d.name), d.value, s.ttsRate or 0, s.ttsVolume or 100)
        end)
    end
    C_Timer.After(#list * 3, function()
        ns.Print("Heard one of them? Lock it in with /cadence voice 1, 2 or 3.")
        ns.Print("Heard none? The speech is going to a device you are not listening to — check Options > Social > Voice Chat > Output Device.")
    end)
end

function D:SetVoiceDestination(index)
    local list = self:GetDestinations()
    local d = list[index]
    if not d then
        ns.Print("Pick a number between 1 and %d.", #list)
        return
    end
    CadenceDB.settings.ttsDestination = d.value
    ns.Print("Voice destination set to %s.", d.name)
    self:Speak("Voice set", true)
end

--------------------------------------------------------------------------------
-- Sound files
--------------------------------------------------------------------------------
--
-- An alternative to speech that does not touch the voice chat audio path at all.
-- PlaySoundFile goes out through the normal sound channels, so it works on setups
-- where SpeakText is silent. Point a reminder at any .ogg or .mp3 in your WoW
-- folder -- including the voice packs that ship with DBM or BigWigs.

local soundWarned = false

function D:PlaySoundPath(path, channel)
    if not path or path == "" then return false, "no file set" end
    if not PlaySoundFile then return false, "no PlaySoundFile on this client" end

    local ok, willPlay = pcall(PlaySoundFile, path, channel or CadenceDB.settings.soundChannel or "Master")
    if not ok then return false, tostring(willPlay) end
    if willPlay == false then
        return false, "the game could not open that file"
    end
    return true
end

-- The audible part of a cue.
--
-- Two independent audio signals:
--
--   1. Voicepack clip — speaks the cooldown name. Skipped when the reminder has
--      r.blip set, which tells the cue to use the plain alert tone instead.
--
--   2. Alert tone — the configured sound that actually gets your attention. Falls
--      through: reminder's own file → global default → built-in blip.
--
-- They play at the same time and through the same channel. The split exists
-- because they serve different purposes: the voice identifies *what* is coming,
-- the tone ensures you *hear* it even with the voice low.
function D:PlayCueSound(reminder)
    local s = CadenceDB.settings
    if not s.sound then return end
    if reminder and reminder.mute then return end

    -- 1. Voicepack: speaks the cooldown name. Skipped when the reminder is set
    --    to use the plain blip instead of the voice clip.
    if not (reminder and reminder.blip) then
        local clip = reminder and ns.Media:ClipForReminder(reminder)
        if clip then
            -- Failure is silent — the clip always ships with the addon and a bad
            -- path is obvious when testing; a per-cue warning would be disruptive.
            self:PlaySoundPath(clip)
        end
    end

    -- 2. Alert tone: only when the reminder explicitly asks for it (r.blip), or
    --    has a custom file, or a global default is configured. Normal mode is the
    --    voice clip alone -- the tone is opt-in, not a fallback that always fires.
    local path = (reminder and reminder.sound ~= "" and reminder.sound) or s.soundFile
    local wantBlip = reminder and reminder.blip

    if wantBlip then
        PlaySound(SOUNDKIT and SOUNDKIT.RAID_WARNING or 8959, "Master")
    elseif path and path ~= "" then
        local ok, why = self:PlaySoundPath(path)
        if not ok and not soundWarned then
            soundWarned = true
            ns.Print("Sound file failed (%s): %s", tostring(path), tostring(why))
        end
    end
end

-- /cadence sound <path>
function D:TestSound(path)
    if not path or path == "" then
        local current = CadenceDB.settings.soundFile
        ns.Print("Default cue sound: %s", (current and current ~= "") and current or "the built-in blip")
        ns.Print("Set one with: /cadence sound Interface\\AddOns\\Cadence\\Sounds\\tranq.ogg")
        ns.Print("Clear it with: /cadence sound none")
        return
    end

    if path == "none" or path == "clear" then
        CadenceDB.settings.soundFile = nil
        ns.Print("Default cue sound cleared.")
        return
    end

    local ok, why = self:PlaySoundPath(path)
    if ok then
        CadenceDB.settings.soundFile = path
        ns.Print("Played and saved as the default cue sound.")
    else
        ns.Print("Could not play that: %s", tostring(why))
        ns.Print("The path is relative to your World of Warcraft folder and needs double backslashes.")
    end
end

--------------------------------------------------------------------------------
-- Showing a cue
--------------------------------------------------------------------------------

-- reminder: the saved reminder table
-- fireAt:   absolute GetTime() value the cooldown should go out
-- minVisible keeps a batch of simultaneous cues from evicting each other when the
-- batch is larger than the usual stack limit.
function D:ShowCue(reminder, fireAt, minVisible)
    local s = CadenceDB.settings
    local label = reminder.label
    if (not label or label == "") and reminder.spellID then
        label = ns.SpellLabel(reminder.spellID)
    end
    label = label or "Cooldown"

    local f = AcquireAlert()
    f.title:SetText(label)
    f.note:SetText(reminder.note or "")

    local icon = reminder.spellID and ns.GetSpellIcon(reminder.spellID)
    if icon then
        f.icon:SetTexture(icon)
        f.icon:Show(); f.iconBorder:Show()
        f.count:SetPoint("CENTER", f.icon, "CENTER", 0, 0)
    else
        f.icon:Hide(); f.iconBorder:Hide()
        f.count:SetPoint("CENTER", f, "LEFT", 32, 0)
    end

    table.insert(active, f)

    -- Over the limit, retire cues that have already had their moment, oldest
    -- first. A cue that has not fired yet is never dropped: the stack grows
    -- instead. It used to evict active[1], which is the OLDEST entry and so the
    -- one closest to happening -- five overlapping cues against a limit of three
    -- meant the two most imminent ones vanished a second before they were due,
    -- with no alert and no sound.
    local cap = math.max(s.maxVisible or 3, minVisible or 0)
    while #active > cap do
        local victim
        for _, v in ipairs(active) do
            if v.landed and v ~= f then victim = v break end
        end
        if not victim then break end
        ReleaseAlert(victim)
    end

    -- Place it, fill in the first frame of the countdown, and only then show it.
    self:Restack()

    local remaining = fireAt - GetTime()
    if remaining > LANDED then
        f.count:SetText(("%d"):format(math.ceil(remaining)))
        if remaining <= 2 then
            f.count:SetTextColor(unpack(ns.COLORS.redBright))
        else
            f.count:SetTextColor(unpack(ns.COLORS.goldLight))
        end
        f.title:SetTextColor(unpack(ns.COLORS.bone))
    else
        f.count:SetText("")
        f.title:SetTextColor(unpack(ns.COLORS.goldLight))
    end

    f:Show()

    if s.tts and (s.ttsWhen == "lead" or s.ttsWhen == "both") then
        local spoke, why = self:Speak(label)
        if not spoke and not voiceWarned and why ~= "voice is switched off in the panel" then
            voiceWarned = true
            ns.Print("Voice failed: %s. Run /cadence voice.", tostring(why))
        end
    end

    local spoken, holdUntil = false, nil
    f:SetScript("OnUpdate", function(self_)
        local remaining = fireAt - GetTime()
        if remaining > LANDED then
            self_.count:SetText(("%d"):format(math.ceil(remaining)))
            if remaining <= 2 then
                self_.count:SetTextColor(unpack(ns.COLORS.redBright))
            else
                self_.count:SetTextColor(unpack(ns.COLORS.goldLight))
            end
            self_.title:SetTextColor(unpack(ns.COLORS.bone))
        else
            if not holdUntil then
                holdUntil = GetTime() + (CadenceDB.settings.holdTime or 1.5)
                self_.landed = true
                self_.count:SetText("")
                self_.title:SetTextColor(unpack(ns.COLORS.goldLight))
                self_.title:SetText(label .. "  —  NOW")
                D:PlayCueSound(reminder)
                if not spoken and CadenceDB.settings.tts
                   and (CadenceDB.settings.ttsWhen == "now" or CadenceDB.settings.ttsWhen == "both") then
                    spoken = true
                    D:Speak(label .. " now")
                end
            elseif GetTime() >= holdUntil then
                self_:SetScript("OnUpdate", nil)
                DetachAlert(self_)
                self_.fade:Play()
                -- Just past the animation, so the frame is back in the pool
                -- promptly rather than sitting invisible for half a second.
                C_Timer.After(0.35, function() ReleaseAlert(self_) end)
            end
        end
    end)

    return f
end

-- Cues that land at the same moment are shown together, in one frame, stacked
-- down the screen in the order they will be needed.
function D:ShowGroup(items)
    local n = #items
    for _, item in ipairs(items) do
        self:ShowCue(item.r, item.fireAt, n)
    end
end

function D:ActiveCount() return #active end

-- Exposed so tests can inspect the real frames.
function D.TestFrame(i) return active[i] end
function D.TestSlotFrame(i) return active[i] end

function D.TestSlot(f)
    for i, v in ipairs(active) do if v == f then return i end end
end

-- Top to bottom, which is the order they are read on screen.
function D:ActiveLabels()
    local out = {}
    for i, f in ipairs(active) do out[i] = f.title:GetText() end
    return out
end

function D:ClearAll()
    -- Fading frames no longer sit in the stack, so they need clearing explicitly
    -- or a cue from the last pull is left drifting on screen after the fight.
    for i = #fading, 1, -1 do ReleaseAlert(fading[i]) end
    for i = #active, 1, -1 do ReleaseAlert(active[i]) end
end
