-- Cadence: timeline-driven reminders for healing cooldowns.
-- Core.lua -- saved variables, defaults, event plumbing, slash commands.

local ADDON, ns = ...

ns.ADDON = ADDON
ns.VERSION = (C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata(ADDON, "Version")) or "1.0.0"

--------------------------------------------------------------------------------
-- Defaults
--------------------------------------------------------------------------------

local DEFAULTS = {
    version = 1,
    settings = {
        enabled       = true,
        lead          = 5,        -- default countdown length, seconds
        holdTime      = 1.5,      -- how long "NOW" stays up after the cue lands
        maxVisible    = 3,        -- alerts stacked at once
        scale         = 1.0,
        zoom          = 1.2,     -- timeline pixels per second
        locked        = true,
        anchor        = { point = "CENTER", relPoint = "CENTER", x = 0, y = 180 },

        tts           = false,    -- no UI for this; /cadence voice on
        ttsWhen       = "lead",   -- "lead" | "now" | "both"
        ttsVoiceID    = nil,      -- nil = first available voice
        ttsRate       = 0,        -- -10 .. 10
        ttsVolume     = 100,      -- 0 .. 100
        ttsDestination = nil,     -- nil = first available; see /cadence voice cycle

        sound         = true,     -- audio when the cue lands
        soundFile     = nil,      -- optional path, overrides the blip
        soundChannel  = "Master",
        raidOnly      = false,    -- ignore encounters outside a raid group

        -- The upcoming list
        queue = {
            enabled  = true,
            maxRows  = 8,
            scale    = 1.0,
            anchor   = { point = "RIGHT", relPoint = "RIGHT", x = -260, y = 60 },
        },
    },

    -- [encounterID] = { name = "Boss Name", reminders = { <reminder>, ... } }
    encounters = {},

    -- [encounterID] = "Boss Name" -- every boss you have pulled, for the dropdown
    seen = {},
}

-- A reminder looks like:
-- {
--   id       = "r1724…",     unique string
--   enabled  = true,
--   trigger  = "PULL",        -- "PULL" (seconds into the fight) or "BOSSMOD" (anchored to a boss-mod bar)
--   at       = 105,           -- PULL only: seconds after ENCOUNTER_START
--   match    = "Venom Nova",  -- BOSSMOD only: substring of the bar text, case-insensitive
--   occurrence = 1,           -- BOSSMOD only: which matching bar to use, 0 = every one
--   offset   = -3,            -- BOSSMOD only: seconds relative to the bar finishing (negative = before)
--   lead     = 5,             -- countdown length; nil = use the global default
--   spellID  = 740,           -- optional, drives the icon and the spoken name
--   label    = "Tranquility", -- shown text; filled from spellID when blank
--   note     = "with Barrier",-- optional second line
-- }

--------------------------------------------------------------------------------
-- Utilities
--------------------------------------------------------------------------------

function ns.Print(msg, ...)
    if select("#", ...) > 0 then msg = msg:format(...) end
    print("|cffbf2222Cadence|r: " .. tostring(msg))
end

local idCounter = 0
function ns.NewID()
    idCounter = idCounter + 1
    return ("r%d%d"):format(math.floor(GetTime() * 100), idCounter)
end

function ns.FormatTime(sec)
    sec = math.max(0, math.floor((sec or 0) + 0.5))
    return ("%d:%02d"):format(math.floor(sec / 60), sec % 60)
end

-- Accepts "1:45", "105", "1m45", "1:45.5" -> seconds, or nil.
function ns.ParseTime(str)
    if type(str) ~= "string" then return nil end
    str = str:gsub("%s", "")
    if str == "" then return nil end
    local m, s = str:match("^(%d+):(%d+%.?%d*)$")
    if m then return tonumber(m) * 60 + tonumber(s) end
    local m2, s2 = str:match("^(%d+)m(%d+)s?$")
    if m2 then return tonumber(m2) * 60 + tonumber(s2) end
    local plain = str:match("^(%d+%.?%d*)s?$")
    if plain then return tonumber(plain) end
    return nil
end

-- The countdown a reminder actually gets.
--
-- A countdown cannot start before the fight does. A cue placed at 0:00 has no
-- run-up at all, so it fires on the pull rather than pretending to count down
-- from five seconds before ENCOUNTER_START; one at 0:03 gets three seconds, not
-- five. Boss-timer cues are anchored to a bar rather than to the pull, so they
-- keep whatever countdown they were given.
function ns.EffectiveLead(reminder)
    if not reminder then return 0 end

    local lead = reminder.lead
    if lead == nil or lead < 0 then lead = CadenceDB.settings.lead or 5 end

    if reminder.trigger ~= "BOSSMOD" then
        local at = reminder.at or 0
        if at < lead then lead = math.max(0, at) end
    end
    return lead
end

local function CopyDefaults(src, dst)
    if type(dst) ~= "table" then dst = {} end
    for k, v in pairs(src) do
        if type(v) == "table" then
            dst[k] = CopyDefaults(v, dst[k])
        elseif dst[k] == nil then
            dst[k] = v
        end
    end
    return dst
end

--------------------------------------------------------------------------------
-- Colours
--------------------------------------------------------------------------------

-- Horde: blood red, black, and the bone-gold used on their banners.
ns.COLORS = {
    red       = { 0.50, 0.08, 0.08 },
    redBright = { 0.75, 0.13, 0.13 },
    redDim    = { 0.28, 0.05, 0.05 },
    gold      = { 0.78, 0.64, 0.30 },
    goldLight = { 0.92, 0.80, 0.45 },
    bone      = { 0.87, 0.83, 0.74 },
    boneDim   = { 0.62, 0.58, 0.51 },
    black     = { 0.06, 0.04, 0.04 },
}

-- One colour per timeline row. Deliberately walked in order rather than hashed
-- off the spell name: a hash gives a stable colour per ability but happily hands
-- two neighbouring rows the same tone, and neighbouring contrast is the only
-- thing that helps when you are scanning a plan. Muted enough to sit under the
-- red and gold chrome without fighting it.
ns.LANE_COLORS = {
    { 0.55, 0.33, 0.72 },   -- amethyst
    { 0.78, 0.26, 0.28 },   -- crimson
    { 0.44, 0.49, 0.56 },   -- slate
    { 0.80, 0.33, 0.60 },   -- magenta
    { 0.24, 0.66, 0.66 },   -- teal
    { 0.82, 0.63, 0.26 },   -- amber
    { 0.33, 0.47, 0.76 },   -- indigo
    { 0.44, 0.64, 0.35 },   -- moss
    { 0.80, 0.47, 0.25 },   -- ember
    { 0.58, 0.58, 0.78 },   -- periwinkle
}

function ns.Hex(name)
    local c = ns.COLORS[name] or ns.COLORS.bone
    return ("|cff%02x%02x%02x"):format(
        math.floor(c[1] * 255 + 0.5),
        math.floor(c[2] * 255 + 0.5),
        math.floor(c[3] * 255 + 0.5))
end

-- Tints a Blizzard button so it reads as Horde rather than default blue-grey.
function ns.TintButton(button)
    if not button then return button end
    pcall(function()
        local c = ns.COLORS.redBright
        local n = button.GetNormalTexture and button:GetNormalTexture()
        if n then n:SetVertexColor(c[1], c[2], c[3]) end
        local pu = button.GetPushedTexture and button:GetPushedTexture()
        if pu then pu:SetVertexColor(c[1] * 0.8, c[2] * 0.8, c[3] * 0.8) end
        local h = button.GetHighlightTexture and button:GetHighlightTexture()
        if h then h:SetVertexColor(ns.COLORS.gold[1], ns.COLORS.gold[2], ns.COLORS.gold[3]) end
        local t = button.GetFontString and button:GetFontString()
        if t then t:SetTextColor(ns.COLORS.goldLight[1], ns.COLORS.goldLight[2], ns.COLORS.goldLight[3]) end
    end)
    return button
end

-- Tints the border art of a BasicFrameTemplate window.
function ns.TintFrame(frame)
    if not frame then return frame end
    local c = ns.COLORS.redBright
    pcall(function()
        for _, key in ipairs({ "Bg", "TopTileStreaks" }) do
            local part = frame[key]
            if part and part.SetVertexColor then part:SetVertexColor(0.35, 0.15, 0.15) end
        end
        local border = frame.NineSlice or frame.Border
        if border then
            if border.SetVertexColor then
                border:SetVertexColor(c[1], c[2], c[3])
            else
                for _, piece in pairs(border) do
                    if type(piece) == "table" and piece.SetVertexColor then
                        piece:SetVertexColor(c[1], c[2], c[3])
                    end
                end
            end
        end
    end)
    return frame
end

--------------------------------------------------------------------------------
-- Known raids
--------------------------------------------------------------------------------
--
-- Bosses are preloaded by NAME, not by encounter ID. Blizzard's DungeonEncounterID
-- values are not published anywhere reliable, and a wrong one would mean a boss
-- that silently never fires. Instead each preloaded boss gets a negative
-- placeholder key, and the first time you pull it the real ID is learned from
-- ENCOUNTER_START and the plans move across. Any ID below is one that has been
-- seen in a real plan export.

ns.RAIDS = {
    {
        name = "The Venomous Abyss",
        season = "Midnight Season 2",
        bosses = {
            { name = "Nek'zali the Soulcoiler" },
            { name = "Entombed Sentinels" },
            { name = "Vashnik the Malignant" },
            { name = "The Lost Explorers" },
            { name = "Sszorak" },
            { name = "The Twin Fangs", encounterID = 3421 },
            { name = "The Coiled Altar" },
            { name = "Ula'tek" },
        },
    },
}

-- Stable so a plan written against a placeholder survives a reload.
local function PlaceholderID(raidIndex, bossIndex)
    return -(raidIndex * 100 + bossIndex)
end

-- Every preloaded boss, with the key it would use.
function ns.GetPreloadEntries()
    local out = {}
    for ri, raid in ipairs(ns.RAIDS) do
        for bi, boss in ipairs(raid.bosses) do
            out[#out + 1] = {
                id = boss.encounterID or PlaceholderID(ri, bi),
                name = boss.name,
                raid = raid.name,
                order = ri * 100 + bi,
                pending = boss.encounterID == nil,
            }
        end
    end
    return out
end

function ns.GetPreloadByName(name)
    if not name then return nil end
    local lower = name:lower()
    for _, entry in ipairs(ns.GetPreloadEntries()) do
        if entry.name:lower() == lower then return entry end
    end
    return nil
end

function ns.GetPreloadByID(id)
    for _, entry in ipairs(ns.GetPreloadEntries()) do
        if entry.id == id then return entry end
    end
    return nil
end

-- Moves plans written against a placeholder onto the real encounter ID the first
-- time the boss is pulled. Merges rather than overwrites if both exist.
function ns.BindEncounter(encounterID, encounterName)
    if not encounterID or not encounterName or encounterID <= 0 then return nil end

    local entry = ns.GetPreloadByName(encounterName)
    if not entry or entry.id == encounterID then return nil end

    local db = CadenceDB
    local stale = db.encounters[entry.id]
    if not stale then return nil end

    local target = db.encounters[encounterID]
    if not target then
        db.encounters[encounterID] = stale
        target = stale
    else
        target.plans = target.plans or {}
        for _, plan in ipairs(stale.plans or {}) do
            target.plans[#target.plans + 1] = plan
        end
        if stale.bars then
            target.bars = target.bars or {}
            for k, v in pairs(stale.bars) do
                if target.bars[k] == nil then target.bars[k] = v end
            end
        end
    end

    target.name = encounterName
    target.pending = nil
    db.encounters[entry.id] = nil
    ns.GetActivePlan(encounterID)

    ns.Print("Linked %s to its encounter ID. Your plans moved across.", encounterName)
    return entry.id, encounterID
end

--------------------------------------------------------------------------------
-- Encounters and plans
--------------------------------------------------------------------------------
--
-- An encounter holds any number of named plans -- heroic and mythic, or one per
-- split raid -- and one of them is active. Everything that reads reminders reads
-- them from the active plan.

local function NewPlan(name)
    return { id = ns.NewID(), name = name or "Default", reminders = {} }
end

function ns.GetEncounter(encounterID, create)
    if not encounterID then return nil end
    local db = CadenceDB
    local enc = db.encounters[encounterID]
    if not enc and create then
        local preload = ns.GetPreloadByID(encounterID)
        enc = {
            name = db.seen[encounterID] or (preload and preload.name) or ("Encounter " .. encounterID),
            plans = {},
        }
        if preload then
            enc.raid = preload.raid
            enc.order = preload.order
            if encounterID < 0 then enc.pending = true end
        end
        local plan = NewPlan("Default")
        enc.plans[1] = plan
        enc.activePlan = plan.id
        db.encounters[encounterID] = enc
    end
    return enc
end

function ns.GetPlans(encounterID)
    local enc = ns.GetEncounter(encounterID)
    return (enc and enc.plans) or {}
end

function ns.GetPlanByID(encounterID, planID)
    for _, plan in ipairs(ns.GetPlans(encounterID)) do
        if plan.id == planID then return plan end
    end
    return nil
end

function ns.GetPlanByName(encounterID, name)
    if not name then return nil end
    local lower = name:lower()
    for _, plan in ipairs(ns.GetPlans(encounterID)) do
        if plan.name and plan.name:lower() == lower then return plan end
    end
    return nil
end

function ns.GetActivePlan(encounterID, create)
    local enc = ns.GetEncounter(encounterID, create)
    if not enc then return nil end

    enc.plans = enc.plans or {}
    if #enc.plans == 0 then
        local plan = NewPlan("Default")
        enc.plans[1] = plan
        enc.activePlan = plan.id
    end

    local plan = ns.GetPlanByID(encounterID, enc.activePlan)
    if not plan then
        plan = enc.plans[1]
        enc.activePlan = plan.id
    end
    return plan
end

-- The reminder list everything else works with.
function ns.GetReminders(encounterID)
    local plan = ns.GetActivePlan(encounterID)
    return (plan and plan.reminders) or {}
end

function ns.SetActivePlan(encounterID, planID)
    local enc = ns.GetEncounter(encounterID)
    if not enc then return nil end
    local plan = ns.GetPlanByID(encounterID, planID)
    if not plan then return nil end
    enc.activePlan = planID
    return plan
end

-- Names are made unique so two plans are never indistinguishable in the menu.
function ns.AddPlan(encounterID, name, activate)
    local enc = ns.GetEncounter(encounterID, true)
    local base = (name and name ~= "") and name or "New plan"
    local final, n = base, 2
    while ns.GetPlanByName(encounterID, final) do
        final = ("%s (%d)"):format(base, n)
        n = n + 1
    end

    local plan = NewPlan(final)
    enc.plans[#enc.plans + 1] = plan
    if activate ~= false then enc.activePlan = plan.id end
    return plan
end

function ns.RenamePlan(encounterID, planID, newName)
    local plan = ns.GetPlanByID(encounterID, planID)
    if not plan or not newName or newName == "" then return nil end
    local existing = ns.GetPlanByName(encounterID, newName)
    if existing and existing.id ~= planID then return nil, "A plan with that name already exists." end
    plan.name = newName
    return plan
end

function ns.DuplicatePlan(encounterID, planID)
    local source = ns.GetPlanByID(encounterID, planID)
    if not source then return nil end

    local copy = ns.AddPlan(encounterID, (source.name or "Plan") .. " copy")
    copy.difficultyName = source.difficultyName
    for _, r in ipairs(source.reminders) do
        local dup = {}
        for k, v in pairs(r) do dup[k] = v end
        dup.id = ns.NewID()
        copy.reminders[#copy.reminders + 1] = dup
    end
    return copy
end

-- The last plan is never removed; it is emptied instead, so an encounter always
-- has somewhere to put reminders.
function ns.DeletePlan(encounterID, planID)
    local enc = ns.GetEncounter(encounterID)
    if not enc then return nil end

    if #enc.plans <= 1 then
        local plan = enc.plans[1]
        if plan then wipe(plan.reminders) end
        return plan, "emptied"
    end

    for i, plan in ipairs(enc.plans) do
        if plan.id == planID then
            table.remove(enc.plans, i)
            if enc.activePlan == planID then
                enc.activePlan = enc.plans[math.min(i, #enc.plans)].id
            end
            return ns.GetActivePlan(encounterID), "deleted"
        end
    end
    return nil
end

-- Older saves kept a single reminder list on the encounter. Fold it into a plan.
local function MigratePlans()
    local moved = 0
    for id, enc in pairs(CadenceDB.encounters) do
        if not enc.plans then
            local plan = NewPlan("Default")
            plan.reminders = enc.reminders or {}
            enc.plans = { plan }
            enc.activePlan = plan.id
            enc.reminders = nil
            moved = moved + 1
        elseif enc.reminders then
            -- Both shapes present: keep the loose list as its own plan rather
            -- than discarding it.
            if #enc.reminders > 0 then
                local plan = NewPlan("Recovered")
                plan.reminders = enc.reminders
                enc.plans[#enc.plans + 1] = plan
            end
            enc.reminders = nil
        end
        ns.GetActivePlan(id)
    end
    return moved
end

ns.MigratePlans = MigratePlans

-- Sorted list of { id = encounterID, name = "…" } for the boss dropdown.
function ns.GetKnownEncounters()
    local db, out = CadenceDB, {}
    local byID, byName = {}, {}

    local function add(entry)
        if byID[entry.id] then return end
        -- A boss already known by its real ID must not also appear as a placeholder.
        local lower = entry.name:lower()
        if byName[lower] and entry.id < 0 then return end
        if byName[lower] and byName[lower].id < 0 and entry.id > 0 then
            for i, existing in ipairs(out) do
                if existing.id == byName[lower].id then table.remove(out, i) break end
            end
            byID[byName[lower].id] = nil
        end
        byID[entry.id] = entry
        byName[lower] = entry
        out[#out + 1] = entry
    end

    for id, enc in pairs(db.encounters) do
        local preload = ns.GetPreloadByID(id)
        add({
            id = id,
            name = enc.name or ("Encounter " .. id),
            raid = enc.raid or (preload and preload.raid),
            order = enc.order or (preload and preload.order) or 10000,
            pending = id < 0,
            plans = #(enc.plans or {}),
        })
    end

    for id, name in pairs(db.seen) do
        local preload = ns.GetPreloadByID(id)
        add({ id = id, name = name, raid = preload and preload.raid,
              order = (preload and preload.order) or 10000, plans = 0 })
    end

    for _, entry in ipairs(ns.GetPreloadEntries()) do
        add({ id = entry.id, name = entry.name, raid = entry.raid,
              order = entry.order, pending = entry.pending, plans = 0 })
    end

    table.sort(out, function(a, b)
        if a.order ~= b.order then return a.order < b.order end
        return a.name < b.name
    end)
    return out
end

-- Abilities learned from boss-mod bars during past pulls, earliest first.
function ns.GetKnownBars(encounterID)
    local enc = encounterID and CadenceDB.encounters[encounterID]
    local out = {}
    if not enc or not enc.bars then return out end
    for text, bar in pairs(enc.bars) do
        out[#out + 1] = {
            text = text,
            count = bar.count or 1,
            duration = bar.duration,
            firstSeen = bar.firstSeen or 0,
        }
    end
    table.sort(out, function(a, b)
        if a.firstSeen ~= b.firstSeen then return a.firstSeen < b.firstSeen end
        return a.text < b.text
    end)
    return out
end

-- Removes a boss from the list. Preloaded bosses come back, since they are part
-- of the shipped roster rather than something learned.
function ns.ForgetEncounter(encounterID)
    if not encounterID then return nil end
    local enc = CadenceDB.encounters[encounterID]
    local plans = 0
    for _, plan in ipairs((enc and enc.plans) or {}) do plans = plans + #plan.reminders end
    if plans > 0 then return nil, "That boss still has reminders. Delete them first." end

    CadenceDB.encounters[encounterID] = nil
    CadenceDB.seen[encounterID] = nil
    return true
end

--------------------------------------------------------------------------------
-- Events
--------------------------------------------------------------------------------

-- Applies label overrides to reminders saved before the override existed.
-- Only placeholder labels are touched: anything you typed yourself is left alone.
local function ApplyLabelOverrides()
    local changed = 0
    for _, enc in pairs(CadenceDB.encounters) do
      for _, plan in ipairs(enc.plans or {}) do
        for _, r in ipairs(plan.reminders or {}) do
            local override = r.spellID and ns.SPELL_LABELS[r.spellID]
            if override and r.label ~= override then
                local placeholder = (r.label == nil) or (r.label == "")
                    or (r.label == "Spell " .. r.spellID)
                    or (r.label == tostring(r.spellID))
                    or (r.label == ns.GetSpellName(r.spellID))
                if placeholder then
                    r.label = override
                    changed = changed + 1
                end
            end
        end
      end
    end
    if changed > 0 then
        ns.RebuildSpellIndex()
        ns.Print("Renamed %d reminders to their current labels.", changed)
    end
end

local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("ENCOUNTER_START")
f:RegisterEvent("ENCOUNTER_END")

local function OnEvent(_, event, ...)
    if event == "ADDON_LOADED" then
        local name = ...
        if name ~= ADDON then return end
        CadenceDB = CopyDefaults(DEFAULTS, CadenceDB)
        ns.db = CadenceDB
        MigratePlans()
        ApplyLabelOverrides()

    elseif event == "PLAYER_LOGIN" then
        -- Isolated: a failure in one of these must not stop the other two.
        for _, step in ipairs({
            { "display",  function() ns.Display:Init() end },
            { "queue",    function() ns.Queue:Init() end },
            { "schedule", function() ns.Schedule:Init() end },
            { "options",  function() ns.Options:Init() end },
        }) do
            local ok, err = pcall(step[2])
            if not ok then
                ns.Print("%s failed to start: %s", step[1], tostring(err))
            end
        end

    elseif event == "ENCOUNTER_START" then
        local encounterID, encounterName, difficultyID, groupSize = ...

        -- Only raid bosses join the list. Dungeon and scenario encounters fire
        -- this event too, and would otherwise bury the raid roster.
        local instanceType = select(2, IsInInstance())
        local isRaid = instanceType == "raid"
        if isRaid or CadenceDB.encounters[encounterID] then
            CadenceDB.seen[encounterID] = encounterName
        end

        -- First pull of a preloaded boss: adopt the real ID.
        local oldID = isRaid and ns.BindEncounter(encounterID, encounterName) or nil
        if oldID and ns.Options and ns.Options.selectedEncounter == oldID then
            ns.Options.selectedEncounter = encounterID
        end
        if CadenceDB.settings.enabled then
            if CadenceDB.settings.raidOnly and not IsInRaid() then return end
            ns.Schedule:StartEncounter(encounterID, encounterName, difficultyID)
        end

    elseif event == "ENCOUNTER_END" then
        ns.Schedule:Stop()
    end
end

f:SetScript("OnEvent", OnEvent)

-- Exposed so the event path itself can be tested, not just its helpers.
function ns.TestEventHandler(event, ...) return OnEvent(f, event, ...) end

--------------------------------------------------------------------------------
-- Slash commands
--------------------------------------------------------------------------------

SLASH_CADENCE1 = "/cadence"
SLASH_CADENCE2 = "/cdn"
SlashCmdList.CADENCE = function(input)
    local cmd, rest = input:match("^(%S*)%s*(.-)$")
    cmd = (cmd or ""):lower()

    if cmd == "" or cmd == "config" then
        ns.Options:Open()
    elseif cmd == "import" or cmd == "export" or cmd == "share" then
        ns.Options:OpenDialog()
    elseif cmd == "unlock" then
        CadenceDB.settings.locked = false
        ns.Display:UpdateAnchor()
        ns.Print("Anchor unlocked. Drag it, then run /cadence lock.")
    elseif cmd == "lock" then
        CadenceDB.settings.locked = true
        ns.Display:UpdateAnchor()
        ns.Print("Anchor locked.")
    elseif cmd == "test" then
        ns.Schedule:TestRun(nil, rest == "real")
    elseif cmd == "stop" then
        ns.Schedule:Stop()
        ns.Print("Stopped.")
    elseif cmd == "voice" or cmd == "tts" then
        if rest == "on" then
            CadenceDB.settings.tts = true
            ns.Print("Voice on.")
        elseif rest == "off" then
            CadenceDB.settings.tts = false
            ns.Print("Voice off.")
        elseif rest == "cycle" then
            ns.Display:VoiceCycle()
        elseif tonumber(rest) then
            ns.Display:SetVoiceDestination(tonumber(rest))
        else
            ns.Display:VoiceReport()
        end
    elseif cmd == "bosses" then
        for _, e in ipairs(ns.GetKnownEncounters()) do
            ns.Print("  %s%s  [%s]", e.name, e.pending and "  (not pulled yet)" or "",
                tostring(e.id))
        end
    elseif cmd == "sound" then
        if rest == "list" or rest == "scan" then
            ns.Print("Bundled voice pack: %d clips across %d spec groups.",
                ns.Media:CountSounds(), #ns.Media:GetSoundGroups())
            ns.Print("Browse them from the sound picker in the reminder editor.")
        else
            ns.Display:TestSound(rest)
        end
    elseif cmd == "queue" or cmd == "upcoming" then
        ns.Queue:TogglePreview()
    elseif cmd == "debug" then
        ns.Options:Debug()
    else
        ns.Print("Commands: config, import, bosses, queue, voice, sound, test, stop, unlock, lock, debug")
    end
end
