local Resonance = LibStub("AceAddon-3.0"):GetAddon("Resonance")
local L = Resonance_L

-- Localize Lua built-ins and WoW API functions to avoid per-call
-- _G hash lookups (same rationale as Core.lua).
local type = type
local tostring = tostring
local tonumber = tonumber
local pairs = pairs
local ipairs = ipairs
local pcall = pcall
local wipe = wipe
local unpack = unpack
local math = math
local table = table
local coroutine = coroutine

local MuteSoundFile = MuteSoundFile
local UnmuteSoundFile = UnmuteSoundFile
local PlaySoundFile = PlaySoundFile
local IsPlayerSpell = IsPlayerSpell
local C_Timer = C_Timer

local MAX_MUTE_DEPTH = Resonance.MAX_MUTE_DEPTH

---------------------------------------------------------------------------
-- Sound database search (coroutine-based to avoid frame hitches on large DBs)
---------------------------------------------------------------------------
local SEARCH_BATCH_SIZE = 2000 -- entries per frame before yielding

local activeSearches = {} -- key -> { co, ticker, callback, id }
local searchGeneration = {} -- key -> monotonic counter to detect stale results

local function cancelSearch(key)
  local s = activeSearches[key]
  if s then
    if s.ticker then
      s.ticker:Cancel()
    end
    activeSearches[key] = nil
  end
end

local function searchDB(sourceDB, query, key, callback, prefixPool, l10nTable, extraMatchFn)
  cancelSearch(key)
  if not sourceDB then
    callback({})
    return
  end
  local terms = {}
  for word in query:lower():gmatch("%S+") do
    terms[#terms + 1] = word
  end
  if #terms == 0 then
    callback({})
    return
  end

  searchGeneration[key] = (searchGeneration[key] or 0) + 1
  local gen = searchGeneration[key]

  -- Pre-lowercase prefix pool once to avoid per-entry allocation
  local lowerPrefixes
  if prefixPool then
    lowerPrefixes = {}
    for idx, p in ipairs(prefixPool) do
      lowerPrefixes[idx] = p:lower()
    end
  end

  local co = coroutine.create(function()
    local results = {}
    for i, entry in ipairs(sourceDB) do
      if prefixPool then
        -- Prefix-pooled format: "filename#FID#prefixIdx"
        -- Match against filename and prefix separately to avoid
        -- concatenating a full path string for every entry.
        local fn, fidStr, pIdx = entry:match("([^#]+)#([^#]+)#([^#]+)")
        if fn and fidStr and pIdx then
          local lfn = fn:lower()
          local idx = tonumber(pIdx)
          local lprefix = idx and lowerPrefixes[idx] or ""
          local match = true
          for _, t in ipairs(terms) do
            if not lfn:find(t, 1, true) and not lprefix:find(t, 1, true) then
              match = false
              break
            end
          end
          if match then
            local prefix = idx and prefixPool[idx] or ""
            results[#results + 1] = { path = prefix .. fn, fileDataID = tonumber(fidStr) }
          end
        end
      else
        local path, fid = entry:match("([^#]+)#([^#]+)")
        if path and fid then
          local lp = path:lower()
          local match = true
          for _, t in ipairs(terms) do
            if not lp:find(t, 1, true) then
              match = false
              break
            end
          end
          -- For NPC search: also check L10N table if English name didn't match
          if not match and l10nTable then
            local npcID = tonumber(fid)
            local l10n = npcID and l10nTable[npcID]
            if l10n then
              local ll = l10n:lower()
              match = true
              for _, t in ipairs(terms) do
                if not ll:find(t, 1, true) then
                  match = false
                  break
                end
              end
            end
          end
          if not match and extraMatchFn then
            match = extraMatchFn(tonumber(fid), terms)
          end
          if match then
            results[#results + 1] = { path = path, fileDataID = tonumber(fid) }
          end
        end
      end
      if i % SEARCH_BATCH_SIZE == 0 then
        coroutine.yield()
      end
    end
    return results
  end)

  local ticker
  local function step()
    if searchGeneration[key] ~= gen then
      if ticker then
        ticker:Cancel()
      end
      activeSearches[key] = nil
      return
    end
    local ok, results = coroutine.resume(co)
    if not ok then
      activeSearches[key] = nil
      if ticker then
        ticker:Cancel()
      end
      if Resonance.db and Resonance.db.profile.debug then
        Resonance.msg("Search error (" .. key .. "): " .. tostring(results))
      end
      callback({})
    elseif coroutine.status(co) == "dead" then
      activeSearches[key] = nil
      if ticker then
        ticker:Cancel()
      end
      callback(results or {})
    end
  end

  -- Run the first batch immediately for responsiveness
  step()
  if coroutine.status(co) ~= "dead" then
    ticker = C_Timer.NewTicker(0.01, step)
    activeSearches[key] = { co = co, ticker = ticker, gen = gen }
  end
end

local function hasSpellDB()
  return Resonance.SpellSounds and #Resonance.SpellSounds > 0
end

local function formatSoundDisplay(path, fid)
  local filename = path:match("([^/\\]+)$") or path
  local parent = path:match("([^/\\]+)[/\\][^/\\]+$")
  if parent then
    return filename .. "  |cff999999" .. parent .. "/|r  |cff808080#" .. fid .. "|r"
  end
  return filename .. "  |cff808080#" .. fid .. "|r"
end

-- Append matching custom sounds to search results.
-- Sources, in order:
--   1. Resonance's own db.profile.customSounds (files in Resonance_Sounds/)
--   2. LibSharedMedia-3.0 sounds registered by other addons (optional)
-- Entries from (1) take precedence on name collisions so the user's own
-- choices always win over anything another addon registered under the
-- same name.
local function appendCustomSounds(results, query)
  local custom = Resonance.db and Resonance.db.profile and Resonance.db.profile.customSounds
  local terms = {}
  for word in query:lower():gmatch("%S+") do
    terms[#terms + 1] = word
  end
  if #terms == 0 then
    return
  end

  local function matches(searchStr)
    searchStr = searchStr:lower()
    for _, t in ipairs(terms) do
      if not searchStr:find(t, 1, true) then
        return false
      end
    end
    return true
  end

  local seenNames = {}
  local root = Resonance.CUSTOM_SOUNDS_ROOT or "Interface\\AddOns\\Resonance_Sounds\\"
  if custom then
    for _, entry in ipairs(custom) do
      if entry and entry.name and entry.file then
        seenNames[entry.name] = true
        if matches(entry.name .. " " .. entry.file) then
          local path = root .. entry.file
          results[#results + 1] = {
            path = path,
            fileDataID = path,
            display = "|cff00cc66" .. entry.name .. "|r |cff888888(" .. entry.file .. ")|r",
            subdisplay = "Custom Sound",
            isCustom = true,
          }
        end
      end
    end
  end

  -- LSM sounds from other addons. LSM may not be loaded (optional dep).
  -- Entries can be string paths (most common) or FileDataID numbers;
  -- both are valid inputs for PlaySoundFile, so we preserve the type
  -- when building the reference that gets stored in cfg.sound.
  local lsm = Resonance.getLSM and Resonance.getLSM()
  if lsm and lsm.HashTable then
    local ok, lsmTable = pcall(lsm.HashTable, lsm, "sound")
    if ok and type(lsmTable) == "table" then
      for name, lsmPath in pairs(lsmTable) do
        if
          not seenNames[name]
          and type(name) == "string"
          and name ~= ""
          and (type(lsmPath) == "string" and lsmPath ~= "" or type(lsmPath) == "number")
          and matches(name)
        then
          -- `ref` is what gets stored in cfg.sound when the user picks
          -- this entry. `displayPath` is a string so that any downstream
          -- display-formatting code that reads `r.path` doesn't explode
          -- on a number (defensive — current callers already set their
          -- own display/subdisplay before seeing appended results).
          local ref = lsmPath
          local displayPath = type(lsmPath) == "string" and lsmPath or ("#" .. tostring(lsmPath))
          results[#results + 1] = {
            path = displayPath,
            fileDataID = ref,
            display = "|cff66ccff" .. name .. "|r |cff888888(SharedMedia)|r",
            subdisplay = "SharedMedia Sound",
            isCustom = true,
            isLSM = true,
          }
        end
      end
    end
  end
end

-- Reverse lookup: FID -> path (built lazily, cached)
local fidPathCache
local function lookupFIDPath(fid)
  if not fidPathCache then
    fidPathCache = {}
    local dbs = {
      { Resonance.SpellSounds, Resonance.SpellSoundPrefixes },
      { Resonance.CharacterSounds, Resonance.CharacterSoundPrefixes },
    }
    for _, pair in ipairs(dbs) do
      local entries, prefixes = pair[1], pair[2]
      if entries then
        if prefixes then
          for _, entry in ipairs(entries) do
            local fn, id, pIdx = entry:match("([^#]+)#([^#]+)#([^#]+)")
            if fn and id and pIdx then
              local idx = tonumber(pIdx)
              local prefix = idx and prefixes[idx] or ""
              fidPathCache[tonumber(id)] = prefix .. fn
            end
          end
        else
          for _, entry in ipairs(entries) do
            local path, id = entry:match("([^#]+)#([^#]+)")
            if path and id then
              fidPathCache[tonumber(id)] = path
            end
          end
        end
      end
    end
  end
  return fidPathCache[fid]
end

---------------------------------------------------------------------------
-- Race/gender mapping for vocalization search
---------------------------------------------------------------------------
local RACE_SOUND_KEYS = {
  Human = "human",
  Orc = "orc",
  Dwarf = "dwarf",
  NightElf = "nightelf",
  Scourge = "undead",
  Tauren = "tauren",
  Gnome = "gnome",
  Troll = "troll",
  BloodElf = "bloodelf",
  Draenei = "draenei",
  Worgen = "worgen",
  Goblin = "goblin",
  Pandaren = "pandaren",
  Nightborne = "nightborne",
  HighmountainTauren = "highmountain",
  VoidElf = "void_elf",
  LightforgedDraenei = "lightforged",
  ZandalariTroll = "zandalari",
  KulTiran = "kul_tiran",
  MagharOrc = "maghar",
  DarkIronDwarf = "dark_iron",
  Mechagnome = "mechagnome",
  Vulpera = "vulpera",
  Dracthyr = "dracthyr",
  Earthen = "earthen",
}
local GENDER_KEYS = { [2] = "male", [3] = "female" }

local function getPlayerVoxKey()
  local _, engRace = UnitRace("player")
  local gender = UnitSex("player")
  local rk = RACE_SOUND_KEYS[engRace] or engRace:lower()
  local gk = GENDER_KEYS[gender] or "male"
  return rk .. gk
end

---------------------------------------------------------------------------
-- Preview-safe play (temporarily unmutes if needed)
---------------------------------------------------------------------------
local function isFIDMuted(fid)
  local profile = Resonance.db.profile
  if profile.mute_file_data_ids and profile.mute_file_data_ids[fid] then
    return true
  end
  if Resonance.autoMutedFIDs and (Resonance.autoMutedFIDs[fid] or 0) > 0 then
    return true
  end
  if Resonance.voxMutedFIDs and Resonance.voxMutedFIDs[fid] then
    return true
  end
  if Resonance.weaponMutedFIDs and Resonance.weaponMutedFIDs[fid] then
    return true
  end
  if Resonance.creatureMutedFIDs and Resonance.creatureMutedFIDs[fid] then
    return true
  end
  if Resonance.professionMutedFIDs and Resonance.professionMutedFIDs[fid] then
    return true
  end
  if Resonance.npcMutedFIDs and Resonance.npcMutedFIDs[fid] then
    return true
  end
  return false
end

local function safePlaySound(value)
  if not value then
    return nil
  end
  local fid = tonumber(value)
  local _, handle
  if fid then
    for _ = 1, MAX_MUTE_DEPTH do
      UnmuteSoundFile(fid)
    end
    _, handle = PlaySoundFile(fid, "Master")
    -- Re-mute once per active source instead of MAX_MUTE_DEPTH times
    -- to avoid inflating WoW's internal refcount (which would leave
    -- sounds permanently muted after the preview).
    if isFIDMuted(fid) then
      C_Timer.After(0.5, function()
        local p = Resonance.db.profile
        if p.mute_file_data_ids and p.mute_file_data_ids[fid] then
          MuteSoundFile(fid)
        end
        if Resonance.autoMutedFIDs and (Resonance.autoMutedFIDs[fid] or 0) > 0 then
          MuteSoundFile(fid)
        end
        if Resonance.voxMutedFIDs and Resonance.voxMutedFIDs[fid] then
          MuteSoundFile(fid)
        end
        if Resonance.weaponMutedFIDs and Resonance.weaponMutedFIDs[fid] then
          MuteSoundFile(fid)
        end
        if Resonance.creatureMutedFIDs and Resonance.creatureMutedFIDs[fid] then
          MuteSoundFile(fid)
        end
        if Resonance.professionMutedFIDs and Resonance.professionMutedFIDs[fid] then
          MuteSoundFile(fid)
        end
        if Resonance.npcMutedFIDs and Resonance.npcMutedFIDs[fid] then
          MuteSoundFile(fid)
        end
      end)
    end
  else
    _, handle = PlaySoundFile(value, "Master")
  end
  return handle
end

-- Play/Stop toggle state
local activePreviewBtn -- currently playing button (only one at a time)
local activePreviewHandles = {}
local activePreviewTimer -- auto-reset timer

local function resetPreviewBtn()
  if activePreviewBtn and activePreviewBtn.icon then
    activePreviewBtn.icon:SetVertexColor(1, 1, 1)
  end
  activePreviewBtn = nil
end

local function stopAllPreviews()
  for _, h in ipairs(activePreviewHandles) do
    StopSound(h, 0)
  end
  wipe(activePreviewHandles)
  if activePreviewTimer then
    activePreviewTimer:Cancel()
    activePreviewTimer = nil
  end
  resetPreviewBtn()
end

-- Wire an icon button as a Play/Stop toggle. getSoundFn() returns the value(s) to play.
local function wirePlayStop(btn, getSoundFn)
  btn:SetScript("OnClick", function()
    if activePreviewBtn == btn then
      -- Already playing: stop
      stopAllPreviews()
      return
    end
    -- Stop any other preview first
    stopAllPreviews()
    -- Play new sound(s)
    local snd = getSoundFn()
    if type(snd) == "table" then
      for _, s in ipairs(snd) do
        local h = safePlaySound(s)
        if h then
          activePreviewHandles[#activePreviewHandles + 1] = h
        end
      end
      if snd.random and #snd.random > 0 then
        local pick = snd.random[math.random(1, #snd.random)]
        local h = safePlaySound(pick)
        if h then
          activePreviewHandles[#activePreviewHandles + 1] = h
        end
      end
    else
      local h = safePlaySound(snd)
      if h then
        activePreviewHandles[#activePreviewHandles + 1] = h
      end
    end
    if #activePreviewHandles > 0 then
      activePreviewBtn = btn
      -- Tint green to indicate playing (distinct from delete/remove icons)
      btn.icon:SetVertexColor(0.3, 1, 0.3)
      -- Auto-reset after sound likely finishes (most spell sounds are 1-3s)
      activePreviewTimer = C_Timer.NewTimer(5, function()
        activePreviewTimer = nil
        wipe(activePreviewHandles)
        resetPreviewBtn()
      end)
    end
  end)
end

---------------------------------------------------------------------------
-- Spell search — async cache of player-known spells
--
-- Building the cache requires ~170k C_Spell.GetSpellName() calls which
-- would freeze the client for several seconds if done synchronously.
-- We use a coroutine that yields every SPELL_CACHE_BATCH entries,
-- spreading the work across frames with a 10ms ticker.
-- The cache starts building proactively on panel OnShow so it's usually
-- ready before the user starts typing.
---------------------------------------------------------------------------
local playerSpellCache -- nil = not started; table = building or ready
local spellCacheBusy = false -- true while the coroutine is running
local spellCacheTicker -- C_Timer ticker driving the build coroutine
local spellCacheCallbacks = {} -- functions to call when build finishes
local SPELL_CACHE_BATCH = 5000

local function startBuildPlayerSpellCache(onComplete)
  if playerSpellCache and not spellCacheBusy then
    if onComplete then
      onComplete()
    end
    return
  end
  if onComplete then
    spellCacheCallbacks[#spellCacheCallbacks + 1] = onComplete
  end
  if spellCacheBusy then
    return
  end

  spellCacheBusy = true
  playerSpellCache = {}
  local seen = {}
  local getName = C_Spell and C_Spell.GetSpellName

  local co = coroutine.create(function()
    -- Primary source: all spells with mute data
    if Resonance.SpellMuteData and getName then
      local count = 0
      for sid in pairs(Resonance.SpellMuteData) do
        if not seen[sid] then
          local ok, name = pcall(getName, sid)
          if ok and name and name ~= "" then
            seen[sid] = true
            playerSpellCache[#playerSpellCache + 1] = { spellID = sid, name = name, known = IsPlayerSpell(sid) }
          end
        end
        count = count + 1
        if count % SPELL_CACHE_BATCH == 0 then
          coroutine.yield()
        end
      end
    end

    -- Secondary source: spellbook (catches spells without mute data, e.g. on
    -- TBC where the SpellVisual chain doesn't yield sounds for Shadow Bolt
    -- and many others).
    if C_SpellBook and C_SpellBook.GetNumSpellBookItems and Enum and Enum.SpellBookSpellBank then
      -- Modern (retail) API
      local ok, numSpells = pcall(C_SpellBook.GetNumSpellBookItems, Enum.SpellBookSpellBank.Player)
      if ok and numSpells then
        for i = 1, numSpells do
          local ok2, info = pcall(C_SpellBook.GetSpellBookItemInfo, i, Enum.SpellBookSpellBank.Player)
          if ok2 and info then
            local sid = info.spellID or info.actionID
            if sid and not seen[sid] then
              local name = (getName and getName(sid)) or info.name
              if name and name ~= "" then
                seen[sid] = true
                playerSpellCache[#playerSpellCache + 1] = { spellID = sid, name = name }
              end
            end
          end
        end
      end
    elseif _G.GetNumSpellTabs and _G.GetSpellTabInfo and _G.GetSpellBookItemInfo then
      -- Legacy (Classic) spellbook API. Walk every tab; for each entry, the
      -- SPELL bookType + index pair is what GetSpellBookItemInfo / GetSpellLink
      -- accept. Use GetSpellLink to recover the spellID since GetSpellBookItemInfo
      -- on Classic returns only (itemType, spellID) starting in late-2.5.x but
      -- older builds return only (itemType, id). GetSpellLink works everywhere.
      local BOOKTYPE_SPELL = _G.BOOKTYPE_SPELL or "spell"
      for tab = 1, _G.GetNumSpellTabs() do
        local _, _, offset, numEntries = _G.GetSpellTabInfo(tab)
        offset = offset or 0
        numEntries = numEntries or 0
        for i = offset + 1, offset + numEntries do
          local link = _G.GetSpellLink and _G.GetSpellLink(i, BOOKTYPE_SPELL)
          local sid = link and tonumber(link:match("Hspell:(%d+)"))
          if sid and not seen[sid] then
            local name = (getName and getName(sid)) or _G.GetSpellBookItemName(i, BOOKTYPE_SPELL)
            if name and name ~= "" then
              seen[sid] = true
              playerSpellCache[#playerSpellCache + 1] = { spellID = sid, name = name, known = true }
            end
          end
        end
      end
    end

    -- Tertiary source: talent-transformed spell overrides (e.g. Raging Blow → Crushing Blow)
    local getOverride = C_Spell and C_Spell.GetOverrideSpell
    if getOverride and getName then
      local base = {}
      for _, entry in ipairs(playerSpellCache) do
        base[#base + 1] = entry.spellID
      end
      for _, sid in ipairs(base) do
        local ok, overrideID = pcall(getOverride, sid)
        if ok and overrideID and overrideID ~= sid and not seen[overrideID] then
          local name = getName(overrideID)
          if name and name ~= "" then
            seen[overrideID] = true
            playerSpellCache[#playerSpellCache + 1] = { spellID = overrideID, name = name }
          end
        end
      end
    end
  end)

  local function step()
    local ok, err = coroutine.resume(co)
    if not ok then
      -- Coroutine errored: log in debug mode and reset the cache so
      -- searchSpells returns nil (triggering the "Loading..." state)
      -- rather than serving partial results.
      if spellCacheTicker then
        spellCacheTicker:Cancel()
        spellCacheTicker = nil
      end
      spellCacheBusy = false
      playerSpellCache = nil
      if Resonance.db and Resonance.db.profile.debug then
        Resonance.msg("Spell cache error: " .. tostring(err))
      end
      wipe(spellCacheCallbacks)
      return
    end
    if coroutine.status(co) == "dead" then
      if spellCacheTicker then
        spellCacheTicker:Cancel()
        spellCacheTicker = nil
      end
      spellCacheBusy = false
      local pending = { unpack(spellCacheCallbacks) }
      wipe(spellCacheCallbacks)
      for _, cb in ipairs(pending) do
        cb()
      end
    end
  end

  step()
  if spellCacheBusy then
    spellCacheTicker = C_Timer.NewTicker(0.01, step)
  end
end

local function invalidateSpellCache()
  if spellCacheTicker then
    spellCacheTicker:Cancel()
    spellCacheTicker = nil
  end
  spellCacheBusy = false
  wipe(spellCacheCallbacks)
  playerSpellCache = nil
  fidPathCache = nil
end

local function searchSpells(query)
  if not playerSpellCache or spellCacheBusy then
    return nil
  end
  local results = {}
  local terms = {}
  for word in query:lower():gmatch("%S+") do
    terms[#terms + 1] = word
  end
  if #terms == 0 then
    return results
  end

  for _, entry in ipairs(playerSpellCache) do
    local ln = entry.name:lower()
    local match = true
    for _, t in ipairs(terms) do
      if not ln:find(t, 1, true) then
        match = false
        break
      end
    end
    if match then
      results[#results + 1] = {
        spellID = entry.spellID,
        name = entry.name,
        known = entry.known,
        display = entry.name,
        subdisplay = "ID: " .. entry.spellID,
      }
    end
  end

  table.sort(results, function(a, b)
    if a.known ~= b.known then
      return a.known and true or false
    end
    return a.name < b.name
  end)
  return results
end

---------------------------------------------------------------------------
-- Debounce
---------------------------------------------------------------------------
local debounceTimers = {}
local function debounce(key, delay, fn)
  if debounceTimers[key] then
    debounceTimers[key]:Cancel()
  end
  debounceTimers[key] = C_Timer.NewTimer(delay, function()
    debounceTimers[key] = nil
    fn()
  end)
end

---------------------------------------------------------------------------
-- Constants
---------------------------------------------------------------------------
local ROW_HEIGHT = 22
local CONTENT_WIDTH = 580
local AUTOCOMPLETE_ROWS = 8

local CLASS_DISPLAY = LOCALIZED_CLASS_NAMES_MALE
  or {
    WARRIOR = "Warrior",
    MAGE = "Mage",
    ROGUE = "Rogue",
    PALADIN = "Paladin",
    DRUID = "Druid",
    WARLOCK = "Warlock",
    PRIEST = "Priest",
    SHAMAN = "Shaman",
    HUNTER = "Hunter",
    DEATHKNIGHT = "Death Knight",
    MONK = "Monk",
    DEMONHUNTER = "Demon Hunter",
    EVOKER = "Evoker",
  }

-- Display order for class groups in spell list
local CLASS_ORDER = {
  "WARRIOR",
  "PALADIN",
  "HUNTER",
  "ROGUE",
  "PRIEST",
  "DEATHKNIGHT",
  "SHAMAN",
  "MAGE",
  "WARLOCK",
  "MONK",
  "DRUID",
  "DEMONHUNTER",
  "EVOKER",
}

---------------------------------------------------------------------------
-- Subcategory panel registration
---------------------------------------------------------------------------
local parentCategory
local subcategories = {} -- name -> { category, panel, built }

-- Tab order: Spell Sounds is the primary destination; Sound Browser sits
-- next to it because users discover sounds there before assigning them.
-- Custom Sounds groups with file-management.  Mute-related tabs cluster
-- together (Muting, Mount Sounds, Ambient).  Alerts and Presets tail.
local SUB_NAMES = {
  "General",
  "Spell Sounds",
  "Sound Browser",
  "Custom Sounds",
  "Muting",
  "Mount Sounds",
  "Ambient",
  "Alerts",
  "Presets",
  "Profiles",
}

local function createSubPanel()
  local p = CreateFrame("Frame")
  p:Hide()
  return p
end

local function registerAllPanels()
  if parentCategory then
    return
  end

  -- Main (parent) panel -- shows a brief landing page
  local mainPanel = createSubPanel()
  parentCategory = Settings.RegisterCanvasLayoutCategory(mainPanel, "Resonance")
  Settings.RegisterAddOnCategory(parentCategory)

  -- Landing page content (built once)
  mainPanel:SetScript("OnShow", function(self)
    self:SetScript("OnShow", nil)
    local title = self:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 16, -16)
    title:SetText("Resonance")
    local desc = self:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
    desc:SetWidth(500)
    desc:SetJustifyH("LEFT")
    desc:SetText(L["Select a subcategory on the left to configure Resonance."])
  end)

  -- Presets sub-panel is hidden on flavors without class-preset support
  -- (templates target retail spells/specs).  Other tabs ship reduced data
  -- on Classic (Ambient = cross-flavor General subset, Mount Sounds bridged
  -- via retail Mount→CSD chain), but stay visible so users can still mute
  -- by name/zone.
  for _, name in ipairs(SUB_NAMES) do
    local visible = true
    if name == "Presets" and not Resonance.Flavor.supportsClassPresets then
      visible = false
    end
    if visible then
      local p = createSubPanel()
      local subcat = Settings.RegisterCanvasLayoutSubcategory(parentCategory, p, L[name] or name)
      subcategories[name] = { category = subcat, panel = p, built = false }
    end
  end
end

-- Settings.OpenToCategory calls the protected OpenSettingsPanel(), which is
-- blocked during combat lockdown.  Defer to PLAYER_REGEN_ENABLED so the
-- panel opens automatically once combat ends.
local pendingOpen = false
Resonance.openOptions = function(subcategoryName)
  if not parentCategory then
    return
  end
  -- Ensure Resonance_Data (LoadOnDemand) is loaded before showing the UI
  local loaded, reason = Resonance.loadDataAddon()
  if not loaded then
    if reason == "DISABLED" then
      Resonance.msg(
        L["Resonance Data is disabled. Enable it in the AddOns menu (Esc > AddOns) and /reload to access sound configuration."]
      )
    elseif reason == "MISSING" or reason == "NOT_INSTALLED" then
      Resonance.msg(L["Resonance Data module not found. Reinstall Resonance to restore full functionality."])
    else
      Resonance.msg((L["Could not load Resonance Data: %s"]):format(reason or "unknown"))
    end
  end
  local targetID
  if subcategoryName and subcategories[subcategoryName] then
    targetID = subcategories[subcategoryName].category.ID
  else
    -- Default to General subcategory
    if subcategories["General"] then
      targetID = subcategories["General"].category.ID
    else
      targetID = parentCategory.ID
    end
  end
  if InCombatLockdown() then
    if not pendingOpen then
      pendingOpen = true
      local f = CreateFrame("Frame")
      f:RegisterEvent("PLAYER_REGEN_ENABLED")
      f:SetScript("OnEvent", function(self)
        self:UnregisterEvent("PLAYER_REGEN_ENABLED")
        pendingOpen = false
        if targetID then
          Settings.OpenToCategory(targetID)
        end
      end)
    end
    return
  end
  Settings.OpenToCategory(targetID)
end

---------------------------------------------------------------------------
-- Widget helpers
---------------------------------------------------------------------------
local function makeEditBox(parent, width, anchorTo, offX, offY, placeholder)
  local eb = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
  eb:SetSize(width, 22)
  eb:SetPoint("TOPLEFT", anchorTo, "BOTTOMLEFT", offX or 6, offY or -4)
  eb:SetAutoFocus(false)
  if placeholder then
    eb.placeholder = eb:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    eb.placeholder:SetPoint("LEFT", 4, 0)
    eb.placeholder:SetText(placeholder)
    local function upd(self)
      if self.placeholder then
        self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
      end
    end
    eb:SetScript("OnEditFocusGained", function(self)
      self.placeholder:Hide()
    end)
    eb:SetScript("OnEditFocusLost", upd)
    eb:SetScript("OnTextChanged", upd)
    eb:SetScript("OnShow", upd)
  end
  return eb
end

local function wireNumericEditBox(eb, onChange)
  eb:SetScript("OnTextChanged", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
    end
    local val = tonumber(self:GetText())
    onChange((val and val > 0) and val or nil)
  end)
  eb:SetScript("OnEditFocusGained", function(self)
    if self.placeholder then
      self.placeholder:Hide()
    end
  end)
  eb:SetScript("OnEditFocusLost", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "")
    end
  end)
  eb:SetScript("OnEnterPressed", function(self)
    self:ClearFocus()
  end)
  eb:SetScript("OnEscapePressed", function(self)
    self:ClearFocus()
  end)
end

local function makeIconButton(parent, icon, size, tooltip, onClick)
  local btn = CreateFrame("Button", nil, parent)
  btn:SetSize(size, size)
  local tex = btn:CreateTexture(nil, "ARTWORK")
  tex:SetAllPoints()
  tex:SetTexture(icon)
  btn.icon = tex
  btn:SetHighlightTexture(icon)
  btn:GetHighlightTexture():SetAlpha(0.3)
  if tooltip then
    btn:SetScript("OnEnter", function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      GameTooltip:AddLine(tooltip, 1, 1, 1)
      GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function()
      GameTooltip:Hide()
    end)
  end
  if onClick then
    btn:SetScript("OnClick", onClick)
  end
  btn.SetEnabled = function(self, enabled)
    if enabled then
      self:Enable()
      self.icon:SetDesaturated(false)
      self.icon:SetAlpha(1)
    else
      self:Disable()
      self.icon:SetDesaturated(true)
      self.icon:SetAlpha(0.3)
    end
  end
  return btn
end

-- Shared hidden button for measuring localized text widths
local _measureBtn
local function btnTextWidth(text)
  if not _measureBtn then
    _measureBtn = CreateFrame("Button", nil, UIParent, "UIPanelButtonTemplate")
    _measureBtn:Hide()
  end
  _measureBtn:SetText(text)
  return _measureBtn:GetFontString():GetStringWidth() or 0
end

local function makeButton(parent, text, minWidth, onClick)
  local btn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
  btn:SetText(text)
  local textW = btn:GetFontString():GetStringWidth() or 0
  btn:SetSize(math.max((minWidth or 0) + 4, textW + 16), 26)
  if onClick then
    btn:SetScript("OnClick", onClick)
  end
  return btn
end

local function makeClearButton(editBox, onClear)
  local clr = CreateFrame("Button", nil, editBox:GetParent())
  clr:SetSize(18, 18)
  clr:SetPoint("LEFT", editBox, "RIGHT", 2, 0)
  local nt = clr:CreateTexture(nil, "ARTWORK")
  nt:SetSize(10, 10)
  nt:SetPoint("CENTER")
  nt:SetTexture("Interface\\Buttons\\UI-StopButton")
  clr:SetHighlightTexture("Interface\\Buttons\\UI-StopButton")
  clr:GetHighlightTexture():SetAlpha(0.4)
  clr:Hide()
  clr:SetScript("OnClick", function()
    editBox:SetText("")
    if onClear then
      onClear()
    end
  end)
  editBox.clearBtn = clr
  return clr
end

local function makeRadio(parent)
  local rb = CreateFrame("CheckButton", nil, parent)
  rb:SetSize(16, 16)
  rb:SetNormalTexture("Interface\\Buttons\\UI-RadioButton")
  rb:GetNormalTexture():SetTexCoord(0, 0.25, 0, 1)
  rb:SetCheckedTexture("Interface\\Buttons\\UI-RadioButton")
  rb:GetCheckedTexture():SetTexCoord(0.25, 0.5, 0, 1)
  rb:SetHighlightTexture("Interface\\Buttons\\UI-RadioButton")
  rb:GetHighlightTexture():SetTexCoord(0.5, 0.75, 0, 1)
  rb.label = rb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  rb.label:SetPoint("LEFT", rb, "RIGHT", 4, 0)
  return rb
end

---------------------------------------------------------------------------
-- Autocomplete dropdown component
---------------------------------------------------------------------------
local ROW_HEIGHT_1 = ROW_HEIGHT -- single-line row
local ROW_HEIGHT_2 = ROW_HEIGHT + 14 -- two-line row (text + 2px gap + subtext)

local function createAutocomplete(searchBox, onPlay, onAction, actionDef, dropdownWidth, extraActions)
  -- actionDef: { label, tooltip, width, icon, altIcons } or string (legacy)
  -- When icon is set, renders a small icon button instead of a text button.
  -- altIcons: optional list of alternate icon paths (swapped via onRowRefresh).
  if type(actionDef) == "string" then
    actionDef = { label = actionDef }
  end
  local useIconAction = actionDef.icon ~= nil
  local w = dropdownWidth or CONTENT_WIDTH
  local dd = CreateFrame("Frame", nil, UIParent)
  dd:SetFrameStrata("TOOLTIP")
  dd:SetSize(w, AUTOCOMPLETE_ROWS * ROW_HEIGHT_1 + 18)
  dd:SetPoint("TOPLEFT", searchBox, "BOTTOMLEFT", -2, -2)
  dd:SetClampedToScreen(true)
  dd:Hide()

  local bg = dd:CreateTexture(nil, "BACKGROUND")
  bg:SetAllPoints()
  bg:SetColorTexture(0.05, 0.05, 0.07, 0.97)

  local border = dd:CreateTexture(nil, "BORDER")
  border:SetPoint("TOPLEFT", -1, 1)
  border:SetPoint("BOTTOMRIGHT", 1, -1)
  border:SetColorTexture(0.25, 0.25, 0.30, 0.7)

  dd.scrollOffset = 0
  dd.rows = {}

  -- Custom tooltip that renders above the TOOLTIP-strata dropdown
  local tip = CreateFrame("Frame", nil, dd)
  tip:SetFrameLevel(dd:GetFrameLevel() + 50)
  tip:SetSize(200, 30)
  tip:Hide()
  local tipBg = tip:CreateTexture(nil, "BACKGROUND")
  tipBg:SetAllPoints()
  tipBg:SetColorTexture(0, 0, 0, 0.92)
  local tipBorder = tip:CreateTexture(nil, "BORDER")
  tipBorder:SetPoint("TOPLEFT", -1, 1)
  tipBorder:SetPoint("BOTTOMRIGHT", 1, -1)
  tipBorder:SetColorTexture(0.4, 0.4, 0.45, 0.7)
  local tipText = tip:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  tipText:SetPoint("TOPLEFT", 6, -6)
  tipText:SetPoint("BOTTOMRIGHT", -6, 6)
  tipText:SetTextColor(1, 1, 1, 1)
  tipText:SetWordWrap(true)
  tipText:SetJustifyH("LEFT")

  local function addBtnTooltip(btn, text)
    if not text then
      return
    end
    btn:SetScript("OnEnter", function(self)
      tipText:SetText(text)
      local textW = tipText:GetStringWidth()
      tip:SetSize(math.min(textW + 16, 240), tipText:GetStringHeight() + 14)
      tip:ClearAllPoints()
      tip:SetPoint("BOTTOM", self, "TOP", 0, 4)
      tip:Show()
    end)
    btn:SetScript("OnLeave", function()
      tip:Hide()
    end)
  end

  -- Measure button text widths to size dynamically for localized labels
  local function measuredBtnW(label, minW)
    local tw = btnTextWidth(label)
    return math.max((minW or 0) + 4, tw + 16)
  end

  local primaryW
  if useIconAction then
    primaryW = 20
  else
    primaryW = measuredBtnW(actionDef.label, actionDef.width or 48)
    if actionDef.altLabels then
      for _, alt in ipairs(actionDef.altLabels) do
        primaryW = math.max(primaryW, measuredBtnW(alt, actionDef.width or 48))
      end
    end
  end
  local btnAreaW = primaryW + 4
  local extraWidths = {}
  for idx, ea in ipairs(extraActions or {}) do
    extraWidths[idx] = measuredBtnW(ea.label, ea.width or 40)
    btnAreaW = btnAreaW + extraWidths[idx] + 2
  end
  local textRightOffset = -(btnAreaW + 28) -- play button (22) + gaps

  for i = 1, AUTOCOMPLETE_ROWS do
    local row = CreateFrame("Frame", nil, dd)
    row:SetSize(w - 4, ROW_HEIGHT_1)
    row:SetPoint("TOPLEFT", 2, -(i - 1) * ROW_HEIGHT_1 - 2)

    row.stripe = row:CreateTexture(nil, "BACKGROUND")
    row.stripe:SetAllPoints()
    row.stripe:SetColorTexture(1, 1, 1, 0.06)
    row.stripe:Hide()

    row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.text:SetPoint("TOPLEFT", row, "TOPLEFT", 6, -3)
    row.text:SetPoint("RIGHT", row, "RIGHT", textRightOffset, 0)
    row.text:SetJustifyH("LEFT")
    row.text:SetWordWrap(false)

    row.sub = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    row.sub:SetPoint("TOPLEFT", row.text, "BOTTOMLEFT", 0, -2)
    row.sub:SetPoint("RIGHT", row, "RIGHT", textRightOffset, 0)
    row.sub:SetJustifyH("LEFT")
    row.sub:SetWordWrap(false)
    row.sub:SetTextColor(0.5, 0.5, 0.55)
    row.sub:Hide()

    -- Build buttons right-to-left: primary action -> extra actions -> play
    if useIconAction then
      row.actionBtn = makeIconButton(row, actionDef.icon, 20)
      row.actionBtn:SetPoint("RIGHT", row, "RIGHT", -4, 0)
      row.actionBtn:SetScript("OnClick", function()
        if row.entry then
          onAction(row.entry)
        end
      end)
      row.actionBtn.tooltipText = actionDef.label
      row.actionBtn:SetScript("OnEnter", function(self)
        tipText:SetText(self.tooltipText or "")
        local textW = tipText:GetStringWidth()
        tip:SetSize(math.min(textW + 16, 240), tipText:GetStringHeight() + 14)
        tip:ClearAllPoints()
        tip:SetPoint("BOTTOM", self, "TOP", 0, 4)
        tip:Show()
      end)
      row.actionBtn:SetScript("OnLeave", function()
        tip:Hide()
      end)
    else
      row.actionBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
      row.actionBtn:SetSize(primaryW, 22)
      row.actionBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0)
      row.actionBtn:SetText(actionDef.label)
      row.actionBtn:SetScript("OnClick", function()
        if row.entry then
          onAction(row.entry)
        end
      end)
      addBtnTooltip(row.actionBtn, actionDef.tooltip)
    end

    local prevBtn = row.actionBtn
    row.extraBtns = {}
    for j, ea in ipairs(extraActions or {}) do
      local btn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
      btn:SetSize(extraWidths[j], 22)
      btn:SetPoint("RIGHT", prevBtn, "LEFT", -2, 0)
      btn:SetText(ea.label)
      local callback = ea.onClick
      btn:SetScript("OnClick", function()
        if row.entry then
          callback(row.entry)
        end
      end)
      addBtnTooltip(btn, ea.tooltip)
      row.extraBtns[j] = btn
      prevBtn = btn
    end

    row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22)
    wirePlayStop(row.playBtn, function()
      if row.entry then
        return onPlay(row.entry)
      end
    end)
    row.playBtn:SetPoint("RIGHT", prevBtn, "LEFT", -4, 0)
    -- Use custom tip frame for tooltip (consistent with action button, avoids overlap)
    addBtnTooltip(row.playBtn, L["Play / Stop"])

    row:Hide()
    dd.rows[i] = row
  end

  dd.statusText = dd:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  dd.statusText:SetPoint("BOTTOMLEFT", 4, 2)
  dd.statusText:SetTextColor(0.55, 0.55, 0.55)

  dd.scrollHint = dd:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  dd.scrollHint:SetPoint("BOTTOMRIGHT", -10, 2)
  dd.scrollHint:SetTextColor(0.55, 0.55, 0.55)

  local scrollTrack = dd:CreateTexture(nil, "ARTWORK")
  scrollTrack:SetWidth(4)
  scrollTrack:SetPoint("TOPRIGHT", dd, "TOPRIGHT", -2, -2)
  scrollTrack:SetColorTexture(0.15, 0.15, 0.18, 0.6)
  scrollTrack:Hide()

  local scrollThumb = dd:CreateTexture(nil, "OVERLAY")
  scrollThumb:SetWidth(4)
  scrollThumb:SetColorTexture(0.5, 0.5, 0.55, 0.8)
  scrollThumb:Hide()

  local function refreshRows(self)
    local data = self.data or {}
    local off = self.scrollOffset
    local yOff = 2
    local visibleCount = 0
    for i, row in ipairs(self.rows) do
      local di = off + i
      if di <= #data then
        visibleCount = visibleCount + 1
        row.entry = data[di]
        row.text:SetText(data[di].display or data[di].path or "")
        local sub = data[di].subdisplay
        if sub and sub ~= "" then
          row.sub:SetText(sub)
          row.sub:Show()
          row:SetHeight(ROW_HEIGHT_2)
        else
          row.sub:Hide()
          row:SetHeight(ROW_HEIGHT_1)
        end
        -- Re-anchor text vertically depending on whether sub is shown
        row.text:ClearAllPoints()
        if sub and sub ~= "" then
          row.text:SetPoint("TOPLEFT", row, "TOPLEFT", 6, -3)
        else
          row.text:SetPoint("LEFT", 6, 0)
        end
        row.text:SetPoint("RIGHT", row, "RIGHT", textRightOffset, 0)
        -- Alternating stripe based on data index (not visual position)
        if di % 2 == 0 then
          row.stripe:Show()
        else
          row.stripe:Hide()
        end
        if self.onRowRefresh then
          self:onRowRefresh(row, data[di])
        end
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", dd, "TOPLEFT", 2, -yOff)
        row:SetSize(w - 4, row:GetHeight())
        row:Show()
        yOff = yOff + row:GetHeight()
      else
        row.entry = nil
        row:Hide()
      end
    end
    local contentH = yOff
    if #data > AUTOCOMPLETE_ROWS then
      self.scrollHint:SetText(
        L["Showing %d\226\128\147%d of %d"]:format(off + 1, math.min(off + AUTOCOMPLETE_ROWS, #data), #data)
      )
      scrollTrack:ClearAllPoints()
      scrollTrack:SetPoint("TOPRIGHT", dd, "TOPRIGHT", -2, -2)
      scrollTrack:SetPoint("BOTTOMRIGHT", dd, "BOTTOMRIGHT", -2, 16)
      scrollTrack:Show()
      scrollThumb:Show()
      local trackH = contentH - 2
      local thumbH = math.max(16, trackH * AUTOCOMPLETE_ROWS / #data)
      local maxOff = #data - AUTOCOMPLETE_ROWS
      local frac = maxOff > 0 and (off / maxOff) or 0
      scrollThumb:SetHeight(thumbH)
      scrollThumb:ClearAllPoints()
      scrollThumb:SetPoint("TOPRIGHT", scrollTrack, "TOPRIGHT", 0, -((trackH - thumbH) * frac))
    else
      self.scrollHint:SetText("")
      scrollTrack:Hide()
      scrollThumb:Hide()
    end
    dd:SetHeight(contentH + 18)
  end

  function dd:SetData(data, statusMsg)
    self.data = data or {}
    self.scrollOffset = 0
    self.statusText:SetText(statusMsg or "")
    refreshRows(self)
    self:SetShown(#self.data > 0 or (statusMsg and statusMsg ~= ""))
  end

  dd:EnableMouseWheel(true)
  dd:SetScript("OnMouseWheel", function(self, delta)
    local maxOff = math.max(0, #(self.data or {}) - AUTOCOMPLETE_ROWS)
    self.scrollOffset = math.max(0, math.min(maxOff, self.scrollOffset - delta * 3))
    refreshRows(self)
  end)

  return dd
end

---------------------------------------------------------------------------
-- Reusable sound input control: editbox + autocomplete dropdown + preview.
-- Single source of truth for sound-input UI across the addon. Built directly
-- on CreateFrame (not AceGUI) so it can be embedded in custom panels and in
-- AceConfig dialogs. Each call creates a fresh widget; reuse the returned
-- widget object — do not call this twice for the same logical input.
--
-- Required:
--   parent  — frame the editbox parents to.
--
-- Common options:
--   width             — editbox width (default 200).
--   height            — editbox height (default 22).
--   placeholder       — placeholder text shown when empty (default localized
--                       "FileDataID, path, or search").
--   getSearchResults(query, callback)
--                     — REQUIRED unless the input is purely value-bound. Caller
--                       supplies the search backend; invokes
--                       `callback(results, statusMessage?)` with array of
--                       entries. Each entry needs `fileDataID`, `path`, and
--                       optionally `display` (defaults to formatted path+FID).
--   onSelected(entry, widget)
--                     — Default: sets editbox text to entry.fileDataID.
--   onCommit(value)   — Called when user presses Enter or focus leaves.
--   onTextChanged(value) — Called as user types (not debounced).
--   actionLabel, actionWidth
--                     — Convenience wrappers for a text action button.
--   actionDef         — Full action button definition (overrides actionLabel /
--                       actionWidth). `{ label, tooltip, width, icon, altIcons }`
--                       — pass `icon` for an icon-button instead of text.
--   extraActions      — Forwarded to `createAutocomplete`.
--   showPlayButton    — Bool, default true. Renders a play/stop icon
--                       immediately right of the editbox; clicking it
--                       previews the editbox's current value via
--                       `Resonance.previewSound`.
--   playButtonSize    — Default 18.
--   resolvePlayValue(value)
--                     — Optional. Override how the play button resolves the
--                       editbox value before previewing (e.g., to map a
--                       custom-sound display name to a file path).
--   debounceKey       — Override the internal debounce key (auto-generated by
--                       default; only set this if multiple inputs are
--                       sharing one search budget).
--   minQueryLen       — Default 3.
--
-- Returns:
--   { editbox, dropdown, playButton,
--     GetValue(), SetValue(v), DoSearch(),
--     SetPoint(...), Show(), Hide() }
local _soundInputCounter = 0
local function createSoundInput(parent, opts)
  opts = opts or {}
  _soundInputCounter = _soundInputCounter + 1
  local id = _soundInputCounter
  local debounceKey = opts.debounceKey or ("soundInput" .. id)
  local minQueryLen = opts.minQueryLen or 3
  local placeholder = opts.placeholder
  if placeholder == nil then
    placeholder = L["FileDataID, path, or search"]
  end
  local width = opts.width or 200
  local height = opts.height or 22
  local showPlayButton = opts.showPlayButton ~= false

  local eb = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
  eb:SetSize(width, height)
  eb:SetAutoFocus(false)
  eb:SetTextInsets(0, 0, 3, 3)

  if placeholder and placeholder ~= "" then
    eb.placeholder = eb:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    eb.placeholder:SetPoint("LEFT", 4, 0)
    eb.placeholder:SetPoint("RIGHT", -4, 0)
    eb.placeholder:SetJustifyH("LEFT")
    eb.placeholder:SetText(placeholder)
  end

  local widget -- forward declared for closures below

  local dd
  dd = createAutocomplete(eb, opts.onPlay or function(e)
    if e then
      return e.fileDataID
    end
  end, function(e)
    if not e then
      return
    end
    if opts.onSelected then
      opts.onSelected(e, widget)
    else
      eb:SetText(tostring(e.fileDataID))
      eb:ClearFocus()
      if opts.onCommit then
        opts.onCommit(eb:GetText())
      end
    end
    dd:Hide()
  end, opts.actionDef or {
    label = opts.actionLabel or L["Use"],
    width = opts.actionWidth or 36,
  }, opts.dropdownWidth, opts.extraActions)

  local function doSearch()
    if not eb:HasFocus() then
      return
    end
    if not opts.getSearchResults then
      dd:Hide()
      return
    end
    local q = eb:GetText() or ""
    if #q < minQueryLen then
      dd:SetData({}, "")
      dd:Hide()
      return
    end
    opts.getSearchResults(q, function(results, statusMsg)
      results = results or {}
      local status = statusMsg
      if not status then
        status = (#results == 0) and L["No matches."] or L["%d results"]:format(#results)
      end
      dd:SetData(results, status)
    end)
  end

  local function refreshPlaceholder()
    if eb.placeholder then
      eb.placeholder:SetShown(eb:GetText() == "" and not eb:HasFocus())
    end
  end

  eb:SetScript("OnTextChanged", function(self)
    refreshPlaceholder()
    if opts.onTextChanged then
      opts.onTextChanged(self:GetText())
    end
    debounce(debounceKey, 0.3, doSearch)
  end)
  eb:SetScript("OnEnterPressed", function(self)
    self:ClearFocus()
    dd:Hide()
    if opts.onCommit then
      opts.onCommit(self:GetText())
    end
  end)
  eb:SetScript("OnEscapePressed", function(self)
    self:ClearFocus()
    dd:Hide()
  end)
  eb:SetScript("OnEditFocusGained", function()
    refreshPlaceholder()
  end)
  eb:SetScript("OnEditFocusLost", function(self)
    refreshPlaceholder()
    -- Defer commit and dropdown-hide so the dropdown's own play / action
    -- button clicks (which steal focus) get their handlers first. After the
    -- grace period: if the user is still interacting with the dropdown
    -- (mouse over it), do nothing — they're previewing or about to click
    -- "Use". Otherwise the focus loss is a real commit (clicked elsewhere,
    -- tabbed away, deleted text and moved on), so write the value through
    -- AceConfig and hide the dropdown.
    C_Timer.After(0.2, function()
      if self:HasFocus() then
        return
      end
      if dd:IsMouseOver() then
        return
      end
      if opts.onCommit then
        opts.onCommit(self:GetText())
      end
      dd:Hide()
    end)
  end)
  eb:SetScript("OnShow", refreshPlaceholder)

  local playBtn
  if showPlayButton then
    playBtn = makeIconButton(
      parent,
      "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up",
      opts.playButtonSize or 18,
      L["Play / Stop"]
    )
    playBtn:SetPoint("LEFT", eb, "RIGHT", 4, 0)
    local activeHandle
    playBtn:SetScript("OnClick", function()
      if activeHandle then
        StopSound(activeHandle, 0)
        activeHandle = nil
        return
      end
      local raw = eb:GetText()
      if not raw or raw == "" then
        return
      end
      -- previewSound resolves FID strings, LSM names, and paths via
      -- Resonance.resolveSoundValue; the optional opts.resolvePlayValue
      -- gives callers a final say (e.g., to map a display label to an
      -- underlying file). Apply the optional override first if present.
      local sound = raw
      if opts.resolvePlayValue then
        sound = opts.resolvePlayValue(sound)
        if sound == nil then
          return
        end
      end
      local ok, h = Resonance.previewSound(sound)
      if ok and h then
        activeHandle = h
        C_Timer.After(8, function()
          if activeHandle == h then
            StopSound(h, 0)
            activeHandle = nil
          end
        end)
      end
    end)
  end

  widget = {
    editbox = eb,
    dropdown = dd,
    playButton = playBtn,
    GetValue = function(self)
      return eb:GetText()
    end,
    SetValue = function(self, v)
      eb:SetText(v == nil and "" or tostring(v))
      eb:SetCursorPosition(0)
      refreshPlaceholder()
    end,
    DoSearch = doSearch,
    SetPoint = function(self, ...)
      eb:SetPoint(...)
    end,
    SetWidth = function(self, w)
      eb:SetWidth(w)
    end,
    Hide = function(self)
      eb:Hide()
      if playBtn then
        playBtn:Hide()
      end
      dd:Hide()
    end,
    Show = function(self)
      eb:Show()
      if playBtn then
        playBtn:Show()
      end
    end,
  }
  return widget
end

-- Default search-results provider: search Resonance.SpellSounds by name and
-- append matching custom sounds. Most call sites just want this; pass it as
-- `getSearchResults` to avoid duplicating the boilerplate.
local function defaultSpellSoundSearch(query, callback, debounceKey)
  if not hasSpellDB() then
    callback({}, "")
    return
  end
  -- Skip pure-FID and path-style queries (they're values, not searches).
  if tonumber(query) or query:find("\\") or query:find("/") then
    callback({}, "")
    return
  end
  searchDB(Resonance.SpellSounds, query, debounceKey or "soundInputDefault", function(results)
    for _, r in ipairs(results) do
      r.display = formatSoundDisplay(r.path, r.fileDataID)
    end
    appendCustomSounds(results, query)
    callback(results)
  end, Resonance.SpellSoundPrefixes)
end

---------------------------------------------------------------------------
-- Custom AceGUI widget: EditBox with sound file autocomplete
-- Used by AceConfig "input" options via dialogControl = "Resonance_SoundEditBox"
-- Wraps `createSoundInput` so AceConfig sites and custom panels share the
-- same widget. Only the AceGUI plumbing (SetText/GetText/Disabled/etc.)
-- lives here — actual UI behavior is in `createSoundInput`.
---------------------------------------------------------------------------
do
  local WidgetType = "Resonance_SoundEditBox"
  local WidgetVersion = 1
  local AceGUI = LibStub("AceGUI-3.0")

  local methods = {
    ["OnAcquire"] = function(self)
      self:SetWidth(200)
      self:SetDisabled(false)
      self:SetLabel()
      self:SetText()
      self:SetMaxLetters(0)
    end,

    ["OnRelease"] = function(self)
      self:ClearFocus()
      if self.dropdown then
        self.dropdown:Hide()
      end
    end,

    ["SetDisabled"] = function(self, disabled)
      self.disabled = disabled
      if disabled then
        self.editbox:EnableMouse(false)
        self.editbox:ClearFocus()
        self.editbox:SetTextColor(0.5, 0.5, 0.5)
        self.label:SetTextColor(0.5, 0.5, 0.5)
      else
        self.editbox:EnableMouse(true)
        self.editbox:SetTextColor(1, 1, 1)
        self.label:SetTextColor(1, 0.82, 0)
      end
    end,

    ["SetText"] = function(self, text)
      self.lasttext = text or ""
      self.editbox:SetText(text or "")
      self.editbox:SetCursorPosition(0)
    end,

    ["GetText"] = function(self)
      return self.editbox:GetText()
    end,

    ["SetLabel"] = function(self, text)
      if text and text ~= "" then
        self.label:SetText(text)
        self.label:Show()
        self.editbox:SetPoint("TOPLEFT", self.frame, "TOPLEFT", 7, -18)
        self:SetHeight(44)
        self.alignoffset = 30
      else
        self.label:SetText("")
        self.label:Hide()
        self.editbox:SetPoint("TOPLEFT", self.frame, "TOPLEFT", 7, 0)
        self:SetHeight(26)
        self.alignoffset = 12
      end
    end,

    ["SetMaxLetters"] = function(self, num)
      self.editbox:SetMaxLetters(num or 0)
    end,

    ["ClearFocus"] = function(self)
      self.editbox:ClearFocus()
      self.frame:SetScript("OnShow", nil)
    end,

    ["SetFocus"] = function(self)
      self.editbox:SetFocus()
    end,

    ["DisableButton"] = function(self, _disabled)
      -- no-op (no OK button in this widget)
    end,

    ["HighlightText"] = function(self, from, to)
      self.editbox:HighlightText(from, to)
    end,
  }

  local function Constructor()
    local num = AceGUI:GetNextWidgetNum(WidgetType)
    local frame = CreateFrame("Frame", nil, UIParent)
    frame:Hide()

    local label = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label:SetPoint("TOPLEFT", 0, -2)
    label:SetPoint("TOPRIGHT", 0, -2)
    label:SetJustifyH("LEFT")
    label:SetHeight(18)

    local widget -- forward declared so callbacks can reference it

    local inner = createSoundInput(frame, {
      width = 200,
      height = 19,
      placeholder = "",
      showPlayButton = true,
      debounceKey = "soundWidget" .. num,
      getSearchResults = function(query, cb)
        defaultSpellSoundSearch(query, cb, "soundWidget" .. num)
      end,
      onTextChanged = function(value)
        if widget then
          if tostring(value) ~= tostring(widget.lasttext) then
            widget:Fire("OnTextChanged", value)
            widget.lasttext = value
          end
        end
      end,
      onCommit = function(value)
        if widget then
          widget:Fire("OnEnterPressed", value)
        end
      end,
    })

    local editbox = inner.editbox
    editbox:SetFontObject(ChatFontNormal)
    editbox:SetMaxLetters(256)
    -- The helper anchors the play button to the editbox; for the AceGUI
    -- wrapper we want the inverse layout (editbox flexes, play button is
    -- pinned to the right edge), so re-anchor the play button FIRST to break
    -- the circular dependency before pointing the editbox at it.
    inner.playButton:ClearAllPoints()
    inner.playButton:SetPoint("BOTTOMRIGHT", 0, 0)
    editbox:ClearAllPoints()
    editbox:SetPoint("BOTTOMLEFT", 6, 0)
    editbox:SetPoint("BOTTOMRIGHT", inner.playButton, "BOTTOMLEFT", -4, 0)
    editbox:SetHeight(19)

    widget = {
      alignoffset = 30,
      editbox = editbox,
      label = label,
      frame = frame,
      dropdown = inner.dropdown,
      playButton = inner.playButton,
      doSearch = inner.DoSearch,
      type = WidgetType,
    }
    for method, func in pairs(methods) do
      widget[method] = func
    end
    editbox.obj = widget

    return AceGUI:RegisterAsWidget(widget)
  end

  AceGUI:RegisterWidgetType(WidgetType, Constructor, WidgetVersion)
end

---------------------------------------------------------------------------
-- Panel builders (extracted into functions to stay under Lua 5.1's
-- 200 local-variable limit).  Each receives a shared context table
-- `ctx` and populates it with cross-panel references.
---------------------------------------------------------------------------

-- Show a notice banner inside a scrollable content frame.
-- Returns the banner frame so builders can anchor content below it.
local function showDataUnavailableBanner(contentFrame, reason)
  local banner = CreateFrame("Frame", nil, contentFrame, "BackdropTemplate")
  banner:SetPoint("TOPLEFT", 0, 0)
  banner:SetPoint("RIGHT", contentFrame, "RIGHT", -16, 0)
  banner:SetHeight(48)
  banner:SetBackdrop({
    bgFile = "Interface\\BUTTONS\\WHITE8X8",
    edgeFile = "Interface\\BUTTONS\\WHITE8X8",
    edgeSize = 1,
  })
  banner:SetBackdropColor(0.3, 0.15, 0.05, 0.85)
  banner:SetBackdropBorderColor(0.6, 0.3, 0.1, 0.8)
  -- Warning icon
  local icon = banner:CreateTexture(nil, "OVERLAY")
  icon:SetSize(20, 20)
  icon:SetPoint("LEFT", 10, 0)
  icon:SetAtlas("services-icon-warning")
  local text = banner:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  text:SetPoint("LEFT", icon, "RIGHT", 8, 0)
  text:SetPoint("RIGHT", -12, 0)
  text:SetJustifyH("LEFT")
  text:SetSpacing(3)
  if reason == "DISABLED" then
    text:SetText(
      L["Resonance Data is disabled. Enable it in the AddOns menu (Esc > AddOns) and /reload to access sound configuration."]
    )
  elseif reason == "MISSING" or reason == "NOT_INSTALLED" then
    text:SetText(L["Resonance Data module not found. Reinstall Resonance to restore full functionality."])
  else
    text:SetText((L["Could not load Resonance Data: %s"]):format(reason or "unknown"))
  end
  -- Adjust height to fit text
  banner:SetScript("OnShow", function(self)
    local h = text:GetStringHeight()
    self:SetHeight(math.max(40, h + 20))
  end)
  return banner
end

-- Forward declarations for the builder functions (defined below).
local buildTab2_SpellSounds
local buildTab3_MutedSounds
local buildTab4_Presets
local buildTab5_Ambient
local buildTab_Alerts
local buildTab_CustomSounds
local buildTab_MountSounds

---------------------------------------------------------------------------
-- Tab 2: Spell Sounds
---------------------------------------------------------------------------
buildTab2_SpellSounds = function(ctx)
  local recalcContentHeight = ctx.recalcContentHeight
  local playerClass = ctx.playerClass
  local profile = Resonance.db.profile

  local spellTab = ctx.spellSoundsContent

  -- Data unavailable banner (anchors content below it)
  local spellBanner
  if not ctx.dataLoaded then
    spellBanner = showDataUnavailableBanner(spellTab, select(2, Resonance.loadDataAddon()))
  end

  -- Tab heading
  local spellTabHdr = spellTab:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  spellTabHdr:SetPoint(
    "TOPLEFT",
    spellBanner and spellBanner or spellTab,
    spellBanner and "BOTTOMLEFT" or "TOPLEFT",
    16,
    spellBanner and -12 or -16
  )
  spellTabHdr:SetText(L["Spell Sounds"])

  local spellTabDesc = spellTab:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  spellTabDesc:SetPoint("TOPLEFT", spellTabHdr, "BOTTOMLEFT", 0, -6)
  spellTabDesc:SetPoint("RIGHT", spellTab, "RIGHT", -16, 0)
  spellTabDesc:SetJustifyH("LEFT")
  spellTabDesc:SetSpacing(3)
  spellTabDesc:SetText(
    L["Configure replacement sounds for individual spells. Add spells by ID or name, then assign classic or custom sound files."]
  )

  -- Anchor frame positioned after the heading + description
  local spellBtnAnchor = CreateFrame("Frame", nil, spellTab)
  spellBtnAnchor:SetSize(1, 1)
  spellBtnAnchor:SetPoint("TOPLEFT", spellTabDesc, "BOTTOMLEFT", 0, -12)

  local clearAllSpellsBtn = makeButton(spellTab, L["Clear All"], 65, nil)
  clearAllSpellsBtn:SetPoint("TOPRIGHT", spellTabDesc, "BOTTOMRIGHT", 0, -12)

  -- Clear Presets only affects spells that came from a class preset; on
  -- flavors without class presets it's a no-op and gets hidden.
  local clearPresetsBtn = makeButton(spellTab, L["Clear Presets"], 90, nil)
  clearPresetsBtn:SetPoint("RIGHT", clearAllSpellsBtn, "LEFT", -4, 0)
  if not Resonance.Flavor.supportsClassPresets then
    clearPresetsBtn:Hide()
  end

  -- "Add Class Presets" / "Refresh Class Presets" — one-click preset load
  -- for the player's current class. Label and tooltip flip based on
  -- whether any spells in db.preset_spells already belong to this class:
  --   - Add: user has no entries from this class; load the template.
  --   - Refresh: user has entries; re-apply to pick up template updates
  --     (new spells, updated muteFIDs, etc.) without relogging.
  -- Class templates target retail spells/specs.  Hidden on flavors that
  -- don't support them; Add Spell anchors to clearPresetsBtn instead.
  local classPresetBtn = makeButton(spellTab, L["Add Class Presets"], 150, nil)
  classPresetBtn:SetPoint("RIGHT", clearPresetsBtn, "LEFT", -4, 0)
  if not Resonance.Flavor.supportsClassPresets then
    classPresetBtn:Hide()
  end

  -- One helper function to keep SetupOptions local-var count down; both
  -- label-refresh and OnClick logic live here. Inlines class detection and
  -- preset membership check.
  local function updateClassPresetBtn()
    if not Resonance.Flavor.supportsClassPresets then
      return
    end
    local className, classToken = UnitClass("player")
    local template = classToken and Resonance.ClassTemplates and Resonance.ClassTemplates[classToken]
    if not template then
      classPresetBtn:SetEnabled(false)
      classPresetBtn.tooltipText = L["No preset is available for your current class."]
      classPresetBtn:SetText(L["Add Class Presets"])
      return
    end
    local hasPreset = false
    local profile_ = Resonance.db and Resonance.db.profile
    if profile_ and profile_.preset_spells then
      for _, source in pairs(profile_.preset_spells) do
        if source == classToken then
          hasPreset = true
          break
        end
      end
    end
    classPresetBtn:SetEnabled(true)
    if hasPreset then
      classPresetBtn:SetText(L["Refresh Class Presets"])
      classPresetBtn.tooltipText =
        L["Re-apply the %s preset: pick up any new spells and updated auto-mute data from the latest template. Keeps your customised sounds."]:format(
          className or classToken
        )
    else
      classPresetBtn:SetText(L["Add Class Presets"])
      classPresetBtn.tooltipText =
        L["Load the built-in %s preset: adds recommended spell-sound replacements for your class."]:format(
          className or classToken
        )
    end
  end

  classPresetBtn:SetScript("OnEnter", function(self)
    if self.tooltipText then
      GameTooltip:SetOwner(self, "ANCHOR_TOP")
      GameTooltip:SetText(self.tooltipText, 1, 1, 1, 1, true)
      GameTooltip:Show()
    end
  end)
  classPresetBtn:SetScript("OnLeave", function()
    GameTooltip:Hide()
  end)

  classPresetBtn:SetScript("OnClick", function()
    local className, classToken = UnitClass("player")
    if not classToken or not (Resonance.ClassTemplates and Resonance.ClassTemplates[classToken]) then
      return
    end
    local wasRefresh = false
    local profile_ = Resonance.db and Resonance.db.profile
    if profile_ and profile_.preset_spells then
      for _, source in pairs(profile_.preset_spells) do
        if source == classToken then
          wasRefresh = true
          break
        end
      end
    end
    local added = select(1, Resonance:ApplyClassTemplate(classToken)) or 0
    if wasRefresh and Resonance.refreshPresetsFromTemplates then
      Resonance.refreshPresetsFromTemplates()
      Resonance.msg(L["Refreshed %s preset (%d new spells added)."]:format(className or classToken, added))
    else
      Resonance.msg(L["Added %s preset (%d spells)."]:format(className or classToken, added))
    end
    if ctx and ctx.refreshList then
      ctx.refreshList() -- triggers updateClassPresetBtn
    else
      updateClassPresetBtn()
    end
  end)

  local addBtn = makeButton(spellTab, L["+ Add Spell"], 80, nil)
  -- Class Presets and Clear Presets are both hidden on flavors that don't
  -- support class templates; anchor Add Spell directly to Clear All there.
  if Resonance.Flavor.supportsClassPresets then
    addBtn:SetPoint("RIGHT", classPresetBtn, "LEFT", -4, 0)
  else
    addBtn:SetPoint("RIGHT", clearAllSpellsBtn, "LEFT", -4, 0)
  end

  -- Table header
  local tableHeader = CreateFrame("Frame", nil, spellTab)
  tableHeader:SetHeight(ROW_HEIGHT)
  tableHeader:SetPoint("TOPLEFT", spellBtnAnchor, "TOPLEFT", 0, -(26 + 6))
  tableHeader:SetPoint("RIGHT", spellTab, "RIGHT", -4, 0)

  local headerBg = tableHeader:CreateTexture(nil, "BACKGROUND")
  headerBg:SetAllPoints()
  headerBg:SetColorTexture(0.15, 0.15, 0.18, 0.6)

  local hdrSpell = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  hdrSpell:SetPoint("LEFT", 4, 0)
  hdrSpell:SetWidth(200)
  hdrSpell:SetJustifyH("LEFT")
  hdrSpell:SetText(L["Spell"])

  local hdrSound = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  hdrSound:SetPoint("LEFT", hdrSpell, "RIGHT", 8, 0)
  hdrSound:SetPoint("RIGHT", tableHeader, "RIGHT", -58, 0)
  hdrSound:SetJustifyH("LEFT")
  hdrSound:SetText(L["Sound"])

  local hdrActions = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  hdrActions:SetPoint("RIGHT", tableHeader, "RIGHT", -2, 0)
  hdrActions:SetJustifyH("RIGHT")
  hdrActions:SetText(L["Actions"])

  local listContainer = CreateFrame("Frame", nil, spellTab)
  listContainer:SetPoint("TOPLEFT", tableHeader, "BOTTOMLEFT", 0, -2)
  listContainer:SetPoint("RIGHT", spellTab, "RIGHT", -4, 0)
  listContainer:SetHeight(ROW_HEIGHT)

  local listEmpty = listContainer:CreateFontString(nil, "OVERLAY", "GameFontDisable")
  listEmpty:SetPoint("TOPLEFT", 4, 0)
  listEmpty:SetText(L["No spells configured. Click '+ Add Spell' to get started."])

  local listRows = {}

  -------------------------------------------------------------------
  -- Editor frame (floating dialog)
  -------------------------------------------------------------------
  local editorFrame = CreateFrame("Frame", "ResonanceEditor", UIParent, "BackdropTemplate")
  editorFrame:SetSize(CONTENT_WIDTH + 20, 460)
  editorFrame:SetPoint("CENTER")
  editorFrame:SetFrameStrata("DIALOG")
  editorFrame:SetMovable(true)
  editorFrame:EnableMouse(true)
  editorFrame:SetClampedToScreen(true)
  editorFrame:Hide()

  editorFrame:SetBackdrop({
    bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
    edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
    tile = true,
    tileSize = 32,
    edgeSize = 24,
    insets = { left = 5, right = 5, top = 5, bottom = 5 },
  })
  editorFrame:SetBackdropColor(0.06, 0.06, 0.08, 1)

  ---------------------------------------------------------------------------
  -- Title bar (drag handle + close button)
  ---------------------------------------------------------------------------
  local titleBar = CreateFrame("Frame", nil, editorFrame)
  titleBar:SetHeight(28)
  titleBar:SetPoint("TOPLEFT", 6, -6)
  titleBar:SetPoint("TOPRIGHT", -6, -6)
  titleBar:EnableMouse(true)
  titleBar:RegisterForDrag("LeftButton")
  titleBar:SetScript("OnDragStart", function()
    editorFrame:StartMoving()
  end)
  titleBar:SetScript("OnDragStop", function()
    editorFrame:StopMovingOrSizing()
  end)

  local titleBarBg = titleBar:CreateTexture(nil, "BACKGROUND")
  titleBarBg:SetAllPoints()
  titleBarBg:SetColorTexture(0.12, 0.12, 0.16, 0.8)

  local edTitle = titleBar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  edTitle:SetPoint("LEFT", 6, 0)

  local edCloseBtn = CreateFrame("Button", nil, titleBar, "UIPanelCloseButton")
  edCloseBtn:SetPoint("TOPRIGHT", editorFrame, "TOPRIGHT", -2, -2)

  tinsert(UISpecialFrames, "ResonanceEditor")

  ---------------------------------------------------------------------------
  -- Editor state variables
  ---------------------------------------------------------------------------
  local editorSound = nil
  local editorDuration = nil
  local editorLoop = nil
  local editorTrigger = "cast"
  local editorChannel = nil
  local editorMultiHitWindow = nil
  local editorPrecastSound = nil
  local editorPrecastDuration = nil
  local editorPrecastLoop = nil
  local editorExclusions = {}
  -- Faction overrides bundled into one local to stay under Lua's 200-
  -- local-per-function cap (SetupOptions is already dense). Fields:
  --   hordeSound / hordePrecastSound / allianceSound / alliancePrecastSound
  --     — edited by the Advanced → Faction UI, written to cfg.horde.sound
  --       / cfg.horde.precastSound / cfg.alliance.sound /
  --       cfg.alliance.precastSound on save.
  --   preservedHordeImpacts / preservedAllianceImpacts
  --     — impact lists set via /res impact add ... horde|alliance. GUI
  --       doesn't edit these yet; carried across save verbatim.
  --   preservedHordeExtras / preservedAllianceExtras
  --     — faction duration/loop fields that slash may set but GUI doesn't.
  --   preservedMuteFIDs
  --     — cfg.muteFIDs is populated by class templates
  --       (e.g. Warrior Battle Shout's forced Revamp BattleRoar FIDs) and
  --       the GUI doesn't expose it. Round-tripping through the editor
  --       would drop it; preserve verbatim so template mutes survive an
  --       unrelated save.
  local editorFaction = {
    hordeSound = nil,
    hordePrecastSound = nil,
    allianceSound = nil,
    alliancePrecastSound = nil,
    preservedHordeImpacts = nil,
    preservedAllianceImpacts = nil,
    preservedHordeExtras = nil,
    preservedAllianceExtras = nil,
    preservedMuteFIDs = nil,
  }
  local editorSoundMode = "standard"
  local edAdvancedExpanded = false
  local editorCastEnabled = true
  local editorPrecastEnabled = false
  -- Impact sound list: array of { delay = <seconds>, sound = <fid|path> }.
  -- editorImpactEnabled gates whether the list is saved; empty list also
  -- behaves as disabled.
  local editorImpacts = {}
  local editorImpactEnabled = false

  -- Forward declarations
  local edResizeEditor
  local refreshAutoMuteSection
  local edRefreshSoundList
  local edRefreshPrecastDisplay
  local edRefreshImpactDisplay

  ---------------------------------------------------------------------------
  -- Spell input (ID or name search)
  ---------------------------------------------------------------------------
  local edSpellLabel = editorFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  edSpellLabel:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 12, -10)
  edSpellLabel:SetText(L["Spell (ID or name):"])

  local edSpellIDBox =
    makeEditBox(editorFrame, CONTENT_WIDTH - 80, edSpellLabel, 0, -2, L["e.g. 6343 or Mortal Strike"])

  local edSpellPreview = editorFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  edSpellPreview:SetPoint("TOPLEFT", edSpellIDBox, "BOTTOMLEFT", 0, -4)

  -- Spell search autocomplete dropdown
  local edSpellSearchDD
  edSpellSearchDD = createAutocomplete(edSpellIDBox, function(_e) end, function(e)
    if e and e.spellID then
      edSpellIDBox:SetText(tostring(e.spellID))
      edSpellSearchDD:Hide()
    end
  end, L["Use"], CONTENT_WIDTH)
  for _, row in ipairs(edSpellSearchDD.rows) do
    row.playBtn:Hide()
    row.playBtn:SetSize(1, 1)
  end

  local function edUpdateSpellPreview()
    local text = edSpellIDBox:GetText()
    local sid = tonumber(text)
    if sid and sid > 0 then
      local name = Resonance.getSpellName(sid)
      if name then
        local icon = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(sid)
        local iconStr = icon and ("|T" .. icon .. ":14:14:0:0|t ") or ""
        edSpellPreview:SetText(iconStr .. "|cff00ff00" .. name .. "|r  |cff888888(ID: " .. sid .. ")|r")
      else
        edSpellPreview:SetText("|cffff4444" .. L["Unknown spell"] .. "|r")
      end
    else
      edSpellPreview:SetText("")
    end
    if edSpellIDBox.placeholder then
      edSpellIDBox.placeholder:SetShown(text == "" and not edSpellIDBox:HasFocus())
    end
    if refreshAutoMuteSection and edSpellIDBox:IsEnabled() then
      refreshAutoMuteSection(sid)
    end
  end

  local function edDoSpellSearch()
    if not edSpellIDBox:HasFocus() then
      return
    end
    local q = edSpellIDBox:GetText()
    -- Pure numeric: not a name search
    if tonumber(q) then
      edSpellSearchDD:SetData({}, "")
      edSpellSearchDD:Hide()
      edUpdateSpellPreview()
      return
    end
    if #q < 2 then
      edSpellSearchDD:SetData({}, "")
      edSpellSearchDD:Hide()
      return
    end
    local results = searchSpells(q)
    if results == nil then
      edSpellSearchDD:SetData({}, L["Loading spell data..."])
      startBuildPlayerSpellCache(function()
        edDoSpellSearch()
      end)
      return
    end
    edSpellSearchDD:SetData(results, #results == 0 and L["No matches."] or L["%d results"]:format(#results))
  end

  local edSpellSearchClear = makeClearButton(edSpellIDBox, function()
    edSpellSearchDD:Hide()
    edUpdateSpellPreview()
  end)

  edSpellIDBox:SetScript("OnTextChanged", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
    end
    edSpellSearchClear:SetShown(self:GetText() ~= "")
    local q = self:GetText()
    if tonumber(q) then
      edUpdateSpellPreview()
      edSpellSearchDD:Hide()
    else
      edUpdateSpellPreview()
      debounce("edSpellSearch", 0.3, edDoSpellSearch)
    end
  end)
  edSpellIDBox:SetScript("OnEditFocusGained", function(self)
    if self.placeholder then
      self.placeholder:Hide()
    end
    edDoSpellSearch()
  end)
  edSpellIDBox:SetScript("OnEditFocusLost", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "")
    end
    C_Timer.After(0.2, function()
      if not edSpellIDBox:HasFocus() and not edSpellSearchDD:IsMouseOver() then
        edSpellSearchDD:Hide()
      end
    end)
  end)
  edSpellIDBox:SetScript("OnEnterPressed", function(self)
    edSpellSearchDD:Hide()
    self:ClearFocus()
  end)
  edSpellIDBox:SetScript("OnEscapePressed", function(self)
    edSpellSearchDD:Hide()
    self:ClearFocus()
  end)

  ---------------------------------------------------------------------------
  -- Mute-only checkbox
  ---------------------------------------------------------------------------
  local edMuteOnlyCheck = CreateFrame("CheckButton", nil, editorFrame, "UICheckButtonTemplate")
  edMuteOnlyCheck:SetSize(22, 22)
  edMuteOnlyCheck:SetPoint("TOPLEFT", edSpellPreview, "BOTTOMLEFT", 0, -6)
  edMuteOnlyCheck.text:SetFontObject("GameFontNormal")
  edMuteOnlyCheck.text:SetText(L["Mute only (no replacement sound)"])

  ---------------------------------------------------------------------------
  -- Tab button helper
  ---------------------------------------------------------------------------
  local function makeEditorTab(parent, text, onClick)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(1, 24)
    local bg = btn:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg.selected = false
    btn.bg = bg
    local label = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    label:SetPoint("CENTER", 0, 0)
    label:SetText(text)
    btn.label = label
    -- Size to fit text
    local tw = label:GetStringWidth()
    btn:SetSize(math.max(tw + 24, 80), 24)
    function btn:SetSelected(sel)
      bg.selected = sel
      if sel then
        bg:SetColorTexture(0.18, 0.18, 0.22, 1)
        label:SetTextColor(1, 0.82, 0, 1)
      else
        bg:SetColorTexture(0.08, 0.08, 0.10, 1)
        label:SetTextColor(0.6, 0.6, 0.6, 1)
      end
    end
    btn:SetScript("OnEnter", function(self)
      if not bg.selected then
        bg:SetColorTexture(0.14, 0.14, 0.18, 1)
      end
    end)
    btn:SetScript("OnLeave", function(self)
      if not bg.selected then
        bg:SetColorTexture(0.08, 0.08, 0.10, 1)
      end
    end)
    if onClick then
      btn:SetScript("OnClick", onClick)
    end
    btn:SetSelected(false)
    return btn
  end

  ---------------------------------------------------------------------------
  -- Tab bar
  ---------------------------------------------------------------------------
  local tabBar = CreateFrame("Frame", nil, editorFrame)
  tabBar:SetHeight(24)
  tabBar:SetPoint("TOPLEFT", edMuteOnlyCheck, "BOTTOMLEFT", 0, -6)
  tabBar:SetPoint("RIGHT", editorFrame, "RIGHT", -18, 0)

  local TAB_CONTENT_MIN_H = 170
  local TAB_CONTENT_MAX_H = 350
  local SOUND_LIST_MAX_H = ROW_HEIGHT * 8 -- show ~8 sounds before scrolling
  local tabContainer = CreateFrame("Frame", nil, editorFrame, "BackdropTemplate")
  tabContainer:SetHeight(TAB_CONTENT_MIN_H)
  tabContainer:SetPoint("TOPLEFT", tabBar, "BOTTOMLEFT", 0, 0)
  tabContainer:SetPoint("RIGHT", editorFrame, "RIGHT", -18, 0)
  tabContainer:SetBackdrop({
    bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile = true,
    tileSize = 16,
    edgeSize = 12,
    insets = { left = 3, right = 3, top = 3, bottom = 3 },
  })
  tabContainer:SetBackdropColor(0.08, 0.08, 0.1, 0.8)
  tabContainer:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.6)

  ---------------------------------------------------------------------------
  -- Cast Complete tab content
  ---------------------------------------------------------------------------
  local castTab = CreateFrame("Frame", nil, tabContainer)
  castTab:SetPoint("TOPLEFT", 8, -6)
  castTab:SetPoint("BOTTOMRIGHT", -8, 6)

  local castEnabledCheck = CreateFrame("CheckButton", nil, castTab, "UICheckButtonTemplate")
  castEnabledCheck:SetSize(22, 22)
  castEnabledCheck:SetPoint("TOPLEFT", 4, -4)
  castEnabledCheck.text:SetFontObject("GameFontHighlightSmall")
  castEnabledCheck.text:SetText(L["Enabled"])
  castEnabledCheck:SetScript("OnClick", function(self)
    editorCastEnabled = self:GetChecked() and true or false
  end)

  -- Sound list header with play-all / clear-all
  local castSoundLabel = castTab:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  castSoundLabel:SetPoint("TOPLEFT", castEnabledCheck, "BOTTOMLEFT", 0, -4)
  castSoundLabel:SetText(L["Sounds:"])

  local castPlayAllBtn =
    makeIconButton(castTab, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 20, L["Play all sounds / Stop"])
  wirePlayStop(castPlayAllBtn, function()
    return editorSound
  end)
  castPlayAllBtn:SetPoint("LEFT", castSoundLabel, "RIGHT", 6, 0)

  local castClearAllBtn = makeIconButton(
    castTab,
    "Interface\\Buttons\\UI-StopButton",
    16,
    L["Clear all sounds"],
    function()
      editorSound = nil
      edRefreshSoundList()
    end
  )
  castClearAllBtn:SetPoint("LEFT", castPlayAllBtn, "RIGHT", 4, 0)

  local castSoundScroll = CreateFrame("ScrollFrame", nil, castTab, "UIPanelScrollFrameTemplate")
  castSoundScroll:SetPoint("TOPLEFT", castSoundLabel, "BOTTOMLEFT", 0, -2)
  castSoundScroll:SetPoint("RIGHT", castTab, "RIGHT", -22, 0)
  castSoundScroll:SetHeight(ROW_HEIGHT)

  local castSoundListFrame = CreateFrame("Frame", nil, castSoundScroll)
  castSoundListFrame:SetWidth(CONTENT_WIDTH - 80)
  castSoundListFrame:SetHeight(ROW_HEIGHT)
  castSoundScroll:SetScrollChild(castSoundListFrame)

  -- Browse/File input section for Cast tab
  local castBrowseRadio = makeRadio(castTab)
  castBrowseRadio:SetPoint("TOPLEFT", castSoundScroll, "BOTTOMLEFT", 0, -4)
  castBrowseRadio.label:SetText(L["Browse"])

  local castFileRadio = makeRadio(castTab)
  castFileRadio:SetPoint("LEFT", castBrowseRadio.label, "RIGHT", 12, 0)
  castFileRadio.label:SetText(L["File Path / FID"])

  -- Forward-declare; the actual createSoundInput call happens later, after
  -- edSetSound / edAddSound / edAddRandomSound / edIsSequenceMode are defined
  -- (those locals must exist as upvalues before the closures are created).
  local castBrowseInput, castBrowseBox

  local castFileFrame = CreateFrame("Frame", nil, castTab)
  castFileFrame:SetPoint("TOPLEFT", castBrowseRadio, "BOTTOMLEFT", 0, -2)
  castFileFrame:SetSize(CONTENT_WIDTH - 40, 26)

  ---------------------------------------------------------------------------
  -- Sound mode & list helpers (manage editorSound table)
  ---------------------------------------------------------------------------
  local function formatSoundBrief(snd)
    if type(snd) == "number" then
      local path = lookupFIDPath(snd)
      if path then
        return (path:match("([^/\\]+)$") or path)
      end
      return "FID:" .. snd
    end
    return tostring(snd):match("[^/\\]+$") or tostring(snd)
  end

  local edSoundModeStandard, edSoundModeSequence
  local castFileRndBtn

  local function edIsSequenceMode()
    return editorSoundMode == "sequence"
  end

  local function edSetSound(snd)
    if edIsSequenceMode() then
      if snd == nil then
        editorSound = nil
      else
        editorSound = { sequence = { snd } }
      end
    else
      editorSound = snd
    end
    edRefreshSoundList()
  end

  local function edAddSound(snd)
    if edIsSequenceMode() then
      if editorSound == nil then
        editorSound = { snd, sequence = true }
      elseif type(editorSound) ~= "table" then
        editorSound = { editorSound, snd, sequence = true }
      else
        editorSound[#editorSound + 1] = snd
        editorSound.sequence = true
      end
    else
      if editorSound == nil then
        editorSound = snd
      elseif type(editorSound) ~= "table" then
        editorSound = { editorSound, snd }
      else
        editorSound[#editorSound + 1] = snd
      end
    end
    edRefreshSoundList()
  end

  local function edAddRandomSound(snd)
    if editorSound == nil then
      editorSound = { random = { snd } }
    elseif type(editorSound) ~= "table" then
      editorSound = { editorSound, random = { snd } }
    else
      if not editorSound.random then
        editorSound.random = {}
      end
      editorSound.random[#editorSound.random + 1] = snd
    end
    edRefreshSoundList()
  end

  local function edRemoveFixedSound(idx)
    if type(editorSound) ~= "table" then
      editorSound = nil
    else
      table.remove(editorSound, idx)
      if #editorSound == 1 and not editorSound.random then
        editorSound = editorSound[1]
      elseif #editorSound == 0 and not (editorSound.random and #editorSound.random > 0) then
        editorSound = nil
      end
    end
    edRefreshSoundList()
  end

  local function edRemoveRandomSound(idx)
    if type(editorSound) ~= "table" or not editorSound.random then
      return
    end
    table.remove(editorSound.random, idx)
    if #editorSound.random == 0 then
      editorSound.random = nil
      if #editorSound == 1 then
        editorSound = editorSound[1]
      elseif #editorSound == 0 then
        editorSound = nil
      end
    end
    edRefreshSoundList()
  end

  local function edRemoveSequenceSound(idx)
    if type(editorSound) ~= "table" or not editorSound.sequence then
      return
    end
    table.remove(editorSound, idx)
    if #editorSound == 1 then
      editorSound = editorSound[1]
    elseif #editorSound == 0 then
      editorSound = nil
    end
    edRefreshSoundList()
  end

  local function edSetSoundModeValue(mode)
    if mode == editorSoundMode then
      return
    end
    if mode == "sequence" then
      if type(editorSound) == "table" and not editorSound.sequence then
        if editorSound.random then
          for _, s in ipairs(editorSound.random) do
            editorSound[#editorSound + 1] = s
          end
          editorSound.random = nil
        end
        if #editorSound > 0 then
          editorSound.sequence = true
        end
      elseif type(editorSound) ~= "table" and editorSound ~= nil then
        editorSound = { editorSound, sequence = true }
      end
    else
      if type(editorSound) == "table" and editorSound.sequence then
        editorSound.sequence = nil
        if #editorSound == 1 then
          editorSound = editorSound[1]
        elseif #editorSound == 0 then
          editorSound = nil
        end
      end
    end
    editorSoundMode = mode
    if edSoundModeStandard then
      edSoundModeStandard:SetChecked(mode == "standard")
      edSoundModeSequence:SetChecked(mode == "sequence")
    end
    edRefreshSoundList()
  end

  -- Sound list rows (reusable, created on demand)
  local edSoundRows = {}

  edRefreshSoundList = function()
    stopAllPreviews()
    for _, row in ipairs(edSoundRows) do
      row:Hide()
    end

    local entries = {}
    if editorSound then
      if type(editorSound) == "table" then
        if editorSound.sequence then
          entries[#entries + 1] = { kind = "header", text = L["Sequence (1 per hit, in order):"] }
          for i, s in ipairs(editorSound) do
            entries[#entries + 1] = { kind = "sequence", sound = s, idx = i }
          end
        else
          for i, s in ipairs(editorSound) do
            entries[#entries + 1] = { kind = "fixed", sound = s, idx = i }
          end
          if editorSound.random and #editorSound.random > 0 then
            entries[#entries + 1] = { kind = "header", text = L["Random pool (1 picked per cast):"] }
            for i, s in ipairs(editorSound.random) do
              entries[#entries + 1] = { kind = "random", sound = s, idx = i }
            end
          end
        end
      else
        entries[#entries + 1] = { kind = "fixed", sound = editorSound, idx = 1 }
      end
    end

    if #entries == 0 then
      entries[#entries + 1] = { kind = "empty" }
    end

    local yOff = 0
    for i, entry in ipairs(entries) do
      local row = edSoundRows[i]
      if not row then
        row = CreateFrame("Frame", nil, castSoundListFrame)
        row:SetHeight(ROW_HEIGHT)

        row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.text:SetPoint("LEFT", 4, 0)
        row.text:SetJustifyH("LEFT")
        row.text:SetWordWrap(false)

        row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 20, L["Play / Stop"])
        row.playBtn:SetPoint("RIGHT", row, "RIGHT", -24, 0)

        row.removeBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 16, L["Remove sound"])
        row.removeBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0)

        edSoundRows[i] = row
      end

      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", castSoundListFrame, "TOPLEFT", 0, -yOff)
      row:SetPoint("RIGHT", castSoundListFrame, "RIGHT", 0, 0)

      if entry.kind == "header" then
        row.text:SetText("|cffaaaaaa" .. entry.text .. "|r")
        row.text:ClearAllPoints()
        row.text:SetPoint("LEFT", 4, 0)
        row.text:SetPoint("RIGHT", row, "RIGHT", -4, 0)
        row.playBtn:Hide()
        row.removeBtn:Hide()
      elseif entry.kind == "empty" then
        row.text:SetText("|cff888888" .. L["(none)"] .. "|r")
        row.text:ClearAllPoints()
        row.text:SetPoint("LEFT", 4, 0)
        row.text:SetPoint("RIGHT", row, "RIGHT", -4, 0)
        row.playBtn:Hide()
        row.removeBtn:Hide()
      else
        local display = formatSoundBrief(entry.sound)
        if entry.kind == "random" then
          display = "  " .. display
        elseif entry.kind == "sequence" then
          display = "  " .. entry.idx .. ". " .. display
        end
        row.text:SetText("|cff00ff00" .. display .. "|r")
        row.text:ClearAllPoints()
        row.text:SetPoint("LEFT", 4, 0)
        row.text:SetPoint("RIGHT", row.playBtn, "LEFT", -4, 0)
        row.playBtn:Show()
        local snd = entry.sound
        wirePlayStop(row.playBtn, function()
          return snd
        end)
        row.removeBtn:Show()
        local eKind, eIdx = entry.kind, entry.idx
        row.removeBtn:SetScript("OnClick", function()
          if eKind == "fixed" then
            edRemoveFixedSound(eIdx)
          elseif eKind == "sequence" then
            edRemoveSequenceSound(eIdx)
          else
            edRemoveRandomSound(eIdx)
          end
        end)
      end

      row:Show()
      yOff = yOff + ROW_HEIGHT
    end

    local listH = math.max(ROW_HEIGHT, yOff)
    castSoundListFrame:SetHeight(listH)

    local isSeq = edIsSequenceMode()
    if edSoundModeStandard then
      edSoundModeStandard:SetChecked(not isSeq)
      edSoundModeSequence:SetChecked(isSeq)
    end
    if castFileRndBtn then
      castFileRndBtn:SetShown(not isSeq)
    end

    -- Resize scroll frame: clamp to max visible rows
    local scrollH = math.min(listH, SOUND_LIST_MAX_H)
    castSoundScroll:SetHeight(scrollH)

    -- Re-anchor browse section below scroll frame
    castBrowseRadio:ClearAllPoints()
    castBrowseRadio:SetPoint("TOPLEFT", castSoundScroll, "BOTTOMLEFT", 0, -4)

    -- Resize editor to fit
    if edResizeEditor and editorFrame:IsShown() then
      edResizeEditor()
    end
  end

  -- Now that the spell-editor mutator helpers exist, build the cast browse
  -- input. Closures inside reference edSetSound/edAddSound/etc.; those locals
  -- must be in scope before the closures are created.
  castBrowseInput = createSoundInput(castTab, {
    width = CONTENT_WIDTH - 80,
    placeholder = L["Search spell sounds..."],
    showPlayButton = false, -- spell editor has its own row-level play buttons
    debounceKey = "edBrowse",
    dropdownWidth = CONTENT_WIDTH,
    actionDef = { label = L["Replace"], width = 52, tooltip = L["Replace all sounds with this one"] },
    onSelected = function(e, w)
      edSetSound(e.fileDataID)
      w.dropdown:Hide()
    end,
    extraActions = {
      {
        label = L["Add"],
        width = 36,
        tooltip = L["Add sound"],
        onClick = function(e)
          edAddSound(e.fileDataID)
          castBrowseInput.dropdown:Hide()
        end,
      },
      {
        label = L["+Rnd"],
        width = 38,
        tooltip = L["Add to the random pool (1 picked at random per cast)"],
        onClick = function(e)
          if edIsSequenceMode() then
            Resonance.msg(L["Switch to Standard mode to add random sounds."])
            return
          end
          edAddRandomSound(e.fileDataID)
          castBrowseInput.dropdown:Hide()
        end,
      },
    },
    getSearchResults = function(q, cb)
      if not hasSpellDB() then
        cb({}, "")
        return
      end
      if tonumber(q) or q:find("\\") or q:find("/") then
        cb({}, "")
        return
      end
      searchDB(Resonance.SpellSounds, q, "edBrowse", function(results)
        for _, r in ipairs(results) do
          local filename = r.path:match("([^/\\]+)$") or r.path
          local parent = r.path:match("([^/\\]+)[/\\][^/\\]+$")
          r.display = filename
          r.subdisplay = (parent and (parent .. "/  \194\183  ") or "") .. "#" .. r.fileDataID
        end
        appendCustomSounds(results, q)
        cb(results)
      end, Resonance.SpellSoundPrefixes)
    end,
  })
  castBrowseBox = castBrowseInput.editbox
  castBrowseBox:ClearAllPoints()
  castBrowseBox:SetPoint("TOPLEFT", castBrowseRadio, "BOTTOMLEFT", 0, -2)

  local edBrowseDD = castBrowseInput.dropdown

  -- Clear button next to the search box (helper provides the editbox + dropdown,
  -- but the clear ✕ is spell-editor-specific UX).
  local castBrowseClear = makeClearButton(castBrowseBox, function()
    edBrowseDD:Hide()
  end)
  castBrowseBox:HookScript("OnTextChanged", function(self)
    castBrowseClear:SetShown(self:GetText() ~= "")
  end)
  -- Re-trigger search when focus returns: existing UX shows results immediately
  -- on focus if there's still text in the box.
  castBrowseBox:HookScript("OnEditFocusGained", function()
    castBrowseInput.DoSearch()
  end)
  -- Defer hiding so users can click into the dropdown without it vanishing.
  castBrowseBox:HookScript("OnEditFocusLost", function()
    C_Timer.After(0.2, function()
      if not castBrowseBox:HasFocus() and not edBrowseDD:IsMouseOver() then
        edBrowseDD:Hide()
      end
    end)
  end)

  -- Cast tab File input
  local castFileBox = makeEditBox(castFileFrame, CONTENT_WIDTH - 260, castFileFrame, 0, 0, "path or FID")
  castFileBox:SetPoint("TOPLEFT", castFileFrame, "TOPLEFT", 0, 0)

  local castFilePlayBtn =
    makeIconButton(castFileFrame, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
  wirePlayStop(castFilePlayBtn, function()
    local v = castFileBox:GetText()
    return tonumber(v) or v
  end)
  castFilePlayBtn:SetPoint("LEFT", castFileBox, "RIGHT", 4, 0)

  local function castGetFileValue()
    local v = castFileBox:GetText()
    if v == "" then
      return nil
    end
    return tonumber(v) or v
  end

  local function addButtonTooltip(btn, text)
    btn:SetScript("OnEnter", function(self)
      GameTooltip:SetOwner(self, "ANCHOR_TOP")
      GameTooltip:AddLine(text, 1, 1, 1, true)
      GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function()
      GameTooltip:Hide()
    end)
  end

  local castFileReplaceBtn = makeButton(castFileFrame, L["Replace"], 52, function()
    local v = castGetFileValue()
    if v then
      edSetSound(v)
    end
  end)
  castFileReplaceBtn:SetPoint("LEFT", castFilePlayBtn, "RIGHT", 2, 0)
  addButtonTooltip(castFileReplaceBtn, L["Replace all sounds with this one"])

  local castFileAddBtn = makeButton(castFileFrame, L["Add"], 36, function()
    local v = castGetFileValue()
    if v then
      edAddSound(v)
    end
  end)
  castFileAddBtn:SetPoint("LEFT", castFileReplaceBtn, "RIGHT", 2, 0)
  addButtonTooltip(castFileAddBtn, L["Add sound"])

  castFileRndBtn = makeButton(castFileFrame, L["+Rnd"], 40, function()
    local v = castGetFileValue()
    if v then
      edAddRandomSound(v)
    end
  end)
  castFileRndBtn:SetPoint("LEFT", castFileAddBtn, "RIGHT", 2, 0)
  addButtonTooltip(castFileRndBtn, L["Add to the random pool (1 picked at random per cast)"])

  local function castSetInputMode(isBrowse)
    castBrowseRadio:SetChecked(isBrowse)
    castFileRadio:SetChecked(not isBrowse)
    castBrowseBox:SetShown(isBrowse)
    if castBrowseBox.clearBtn then
      castBrowseBox.clearBtn:SetShown(isBrowse and castBrowseBox:GetText() ~= "")
    end
    castFileFrame:SetShown(not isBrowse)
    if not isBrowse then
      edBrowseDD:Hide()
    end
  end
  castBrowseRadio:SetScript("OnClick", function()
    castSetInputMode(true)
  end)
  castFileRadio:SetScript("OnClick", function()
    castSetInputMode(false)
  end)

  -- Duration + Loop row (inside Cast tab, below browse/file input)
  local castDurLabel = castTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  castDurLabel:SetPoint("TOPLEFT", castBrowseRadio, "BOTTOMLEFT", 0, -30)
  castDurLabel:SetText(L["Duration:"])

  local castDurBox = CreateFrame("EditBox", nil, castTab, "InputBoxTemplate")
  castDurBox:SetSize(60, 22)
  castDurBox:SetPoint("LEFT", castDurLabel, "RIGHT", 6, 0)
  castDurBox:SetAutoFocus(false)
  do
    castDurBox.placeholder = castDurBox:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    castDurBox.placeholder:SetPoint("LEFT", 4, 0)
    castDurBox.placeholder:SetText("e.g. 1.5")
    local function upd(self)
      self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
    end
    castDurBox:SetScript("OnEditFocusGained", function(self)
      self.placeholder:Hide()
    end)
    castDurBox:SetScript("OnEditFocusLost", upd)
    castDurBox:SetScript("OnTextChanged", upd)
    castDurBox:SetScript("OnShow", upd)
  end
  wireNumericEditBox(castDurBox, function(val)
    editorDuration = val
  end)

  local castLoopCheck = CreateFrame("CheckButton", nil, castTab, "UICheckButtonTemplate")
  castLoopCheck:SetSize(22, 22)
  castLoopCheck:SetPoint("LEFT", castDurBox, "RIGHT", 8, 0)
  castLoopCheck.text:SetFontObject("GameFontNormalSmall")
  castLoopCheck.text:SetText(L["Loop"])
  castLoopCheck:SetScript("OnClick", function(self)
    editorLoop = self:GetChecked() and true or nil
  end)
  castLoopCheck:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    GameTooltip:AddLine(L["Repeat the sound until the next cast. Requires a duration to be set."], 1, 1, 1, true)
    GameTooltip:Show()
  end)
  castLoopCheck:SetScript("OnLeave", function()
    GameTooltip:Hide()
  end)

  ---------------------------------------------------------------------------
  -- Precast tab content (mirrors Cast tab: multi-sound, browse, duration, loop)
  ---------------------------------------------------------------------------
  local precastTab = CreateFrame("Frame", nil, tabContainer)
  precastTab:SetPoint("TOPLEFT", 8, -6)
  precastTab:SetPoint("BOTTOMRIGHT", -8, 6)
  precastTab:Hide()

  local precastEnabledCheck = CreateFrame("CheckButton", nil, precastTab, "UICheckButtonTemplate")
  precastEnabledCheck:SetSize(22, 22)
  precastEnabledCheck:SetPoint("TOPLEFT", 4, -4)
  precastEnabledCheck.text:SetFontObject("GameFontHighlightSmall")
  precastEnabledCheck.text:SetText(L["Enabled"])
  precastEnabledCheck:SetScript("OnClick", function(self)
    editorPrecastEnabled = self:GetChecked() and true or false
  end)

  -- Sound list header
  local precastSoundLabel = precastTab:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  precastSoundLabel:SetPoint("TOPLEFT", precastEnabledCheck, "BOTTOMLEFT", 0, -4)
  precastSoundLabel:SetText(L["Sounds:"])

  local precastPlayAllBtn =
    makeIconButton(precastTab, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 20, L["Play all sounds / Stop"])
  wirePlayStop(precastPlayAllBtn, function()
    return editorPrecastSound
  end)
  precastPlayAllBtn:SetPoint("LEFT", precastSoundLabel, "RIGHT", 6, 0)

  local precastClearAllBtn = makeIconButton(
    precastTab,
    "Interface\\Buttons\\UI-StopButton",
    16,
    L["Clear all sounds"],
    function()
      editorPrecastSound = nil
      edRefreshPrecastDisplay()
    end
  )
  precastClearAllBtn:SetPoint("LEFT", precastPlayAllBtn, "RIGHT", 4, 0)

  -- Precast sound list frame (mirrors cast sound list)
  local precastSoundScroll = CreateFrame("ScrollFrame", nil, precastTab, "UIPanelScrollFrameTemplate")
  precastSoundScroll:SetPoint("TOPLEFT", precastSoundLabel, "BOTTOMLEFT", 0, -2)
  precastSoundScroll:SetPoint("RIGHT", precastTab, "RIGHT", -22, 0)
  precastSoundScroll:SetHeight(ROW_HEIGHT)

  local precastSoundListFrame = CreateFrame("Frame", nil, precastSoundScroll)
  precastSoundListFrame:SetWidth(CONTENT_WIDTH - 80)
  precastSoundListFrame:SetHeight(ROW_HEIGHT)
  precastSoundScroll:SetScrollChild(precastSoundListFrame)

  local precastSoundRows = {}
  local precastBrowseRadio -- forward declaration for re-anchor inside refresh
  edRefreshPrecastDisplay = function()
    stopAllPreviews()
    for _, row in ipairs(precastSoundRows) do
      row:Hide()
    end

    local entries = {}
    if editorPrecastSound then
      if type(editorPrecastSound) == "table" then
        for i, s in ipairs(editorPrecastSound) do
          entries[#entries + 1] = { kind = "fixed", sound = s, idx = i }
        end
      else
        entries[#entries + 1] = { kind = "fixed", sound = editorPrecastSound, idx = 1 }
      end
    end

    local yOff = 0
    for i, entry in ipairs(entries) do
      local row = precastSoundRows[i]
      -- Re-create data widgets if the row was previously used as the
      -- empty-state placeholder (happens when the first sound is added).
      -- WoW 12.x SetPoint is strict about anchor-family validity and
      -- errors on anchor-to-nil; before the stricter check it silently
      -- anchored to parent, producing broken layout but no error.
      if not row or not row.playBtn then
        if not row then
          row = CreateFrame("Frame", nil, precastSoundListFrame)
          row:SetHeight(ROW_HEIGHT)
        end
        if not row.text then
          row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
          row.text:SetPoint("LEFT", 4, 0)
          row.text:SetJustifyH("LEFT")
        else
          row.text:SetFontObject("GameFontHighlightSmall")
          row.text:Show()
        end
        row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 16, L["Play / Stop"])
        wirePlayStop(row.playBtn, function()
          return entry.sound
        end)
        row.playBtn:SetPoint("RIGHT", -20, 0)
        row.removeBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 14, L["Remove"])
        row.removeBtn:SetPoint("RIGHT", 0, 0)
        row.stripe = row:CreateTexture(nil, "BACKGROUND")
        row.stripe:SetAllPoints()
        row.stripe:SetColorTexture(1, 1, 1, 0.04)
        precastSoundRows[i] = row
      end
      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", 0, -yOff)
      row:SetPoint("RIGHT", precastSoundListFrame, "RIGHT", 0, 0)
      local brief = formatSoundBrief(entry.sound)
      row.text:SetText(brief)
      row.text:ClearAllPoints()
      row.text:SetPoint("LEFT", 4, 0)
      row.text:SetPoint("RIGHT", row.playBtn, "LEFT", -4, 0)
      -- Re-wire play button for this sound
      wirePlayStop(row.playBtn, function()
        return entry.sound
      end)
      local capturedIdx = entry.idx
      row.removeBtn:SetScript("OnClick", function()
        if type(editorPrecastSound) == "table" then
          table.remove(editorPrecastSound, capturedIdx)
          if #editorPrecastSound == 0 then
            editorPrecastSound = nil
          elseif #editorPrecastSound == 1 and not editorPrecastSound.sequence then
            editorPrecastSound = editorPrecastSound[1]
          end
        else
          editorPrecastSound = nil
        end
        edRefreshPrecastDisplay()
      end)
      row.stripe:SetShown(i % 2 == 0)
      row:Show()
      yOff = yOff + ROW_HEIGHT
    end

    -- Empty state
    if #entries == 0 then
      local row = precastSoundRows[1]
      if not row then
        row = CreateFrame("Frame", nil, precastSoundListFrame)
        row:SetHeight(ROW_HEIGHT)
        row.text = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        row.text:SetPoint("LEFT", 4, 0)
        precastSoundRows[1] = row
      end
      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", 0, 0)
      row:SetPoint("RIGHT", precastSoundListFrame, "RIGHT", 0, 0)
      row.text:SetText("|cff888888" .. L["(none)"] .. "|r")
      if row.playBtn then
        row.playBtn:Hide()
      end
      if row.removeBtn then
        row.removeBtn:Hide()
      end
      if row.stripe then
        row.stripe:Hide()
      end
      row:Show()
      yOff = ROW_HEIGHT
    end

    local listH = math.max(yOff, ROW_HEIGHT)
    precastSoundListFrame:SetHeight(listH)

    -- Resize scroll frame: clamp to max visible rows
    local scrollH = math.min(listH, SOUND_LIST_MAX_H)
    precastSoundScroll:SetHeight(scrollH)

    -- Re-anchor browse section below scroll frame
    if precastBrowseRadio then
      precastBrowseRadio:ClearAllPoints()
      precastBrowseRadio:SetPoint("TOPLEFT", precastSoundScroll, "BOTTOMLEFT", 0, -4)
    end

    -- Resize editor to fit
    if edResizeEditor and editorFrame:IsShown() then
      edResizeEditor()
    end
  end

  -- Browse/File input for Precast tab
  precastBrowseRadio = makeRadio(precastTab)
  precastBrowseRadio:SetPoint("TOPLEFT", precastSoundListFrame, "BOTTOMLEFT", 0, -4)
  precastBrowseRadio.label:SetText(L["Browse"])

  local precastFileRadio = makeRadio(precastTab)
  precastFileRadio:SetPoint("LEFT", precastBrowseRadio.label, "RIGHT", 12, 0)
  precastFileRadio.label:SetText(L["File Path / FID"])

  -- Forward-declare; createSoundInput call is deferred until after
  -- precastAddSound is defined (its closure captures it as an upvalue).
  local precastBrowseInput, precastBrowseBox

  local precastFileFrame = CreateFrame("Frame", nil, precastTab)
  precastFileFrame:SetPoint("TOPLEFT", precastBrowseRadio, "BOTTOMLEFT", 0, -2)
  precastFileFrame:SetSize(CONTENT_WIDTH - 40, 26)

  -- Helper to add a sound to precast
  local function precastAddSound(snd)
    if not editorPrecastSound then
      editorPrecastSound = snd
    elseif type(editorPrecastSound) == "table" then
      editorPrecastSound[#editorPrecastSound + 1] = snd
    else
      editorPrecastSound = { editorPrecastSound, snd }
    end
    edRefreshPrecastDisplay()
  end

  local function precastReplaceSound(snd)
    editorPrecastSound = snd
    edRefreshPrecastDisplay()
  end

  precastBrowseInput = createSoundInput(precastTab, {
    width = CONTENT_WIDTH - 80,
    placeholder = L["Search spell sounds..."],
    showPlayButton = false,
    debounceKey = "precastBrowse",
    dropdownWidth = CONTENT_WIDTH,
    actionDef = { label = L["+ Add"], icon = "Interface\\Buttons\\UI-PlusButton-UP" },
    onSelected = function(e, w)
      precastAddSound(e.fileDataID)
      w.dropdown:Hide()
    end,
    getSearchResults = function(q, cb)
      if not hasSpellDB() then
        cb({}, "")
        return
      end
      if tonumber(q) or q:find("\\") or q:find("/") then
        cb({}, "")
        return
      end
      searchDB(Resonance.SpellSounds, q, "precastBrowse", function(results)
        for _, r in ipairs(results) do
          local filename = r.path:match("([^/\\]+)$") or r.path
          local parent = r.path:match("([^/\\]+)[/\\][^/\\]+$")
          r.display = filename
          r.subdisplay = (parent and (parent .. "/  \194\183  ") or "") .. "#" .. r.fileDataID
        end
        appendCustomSounds(results, q)
        cb(results)
      end, Resonance.SpellSoundPrefixes)
    end,
  })
  precastBrowseBox = precastBrowseInput.editbox
  precastBrowseBox:ClearAllPoints()
  precastBrowseBox:SetPoint("TOPLEFT", precastBrowseRadio, "BOTTOMLEFT", 0, -2)

  local precastBrowseDD = precastBrowseInput.dropdown

  local precastBrowseClear = makeClearButton(precastBrowseBox, function()
    precastBrowseDD:Hide()
  end)
  precastBrowseBox:HookScript("OnTextChanged", function(self)
    precastBrowseClear:SetShown(self:GetText() ~= "")
  end)
  precastBrowseBox:HookScript("OnEditFocusGained", function()
    precastBrowseInput.DoSearch()
  end)
  precastBrowseBox:HookScript("OnEditFocusLost", function()
    C_Timer.After(0.2, function()
      if not precastBrowseBox:HasFocus() and not precastBrowseDD:IsMouseOver() then
        precastBrowseDD:Hide()
      end
    end)
  end)

  -- Precast File input
  local precastFileBox = makeEditBox(precastFileFrame, CONTENT_WIDTH - 260, precastFileFrame, 0, 0, "FID or path")
  precastFileBox:SetPoint("TOPLEFT", precastFileFrame, "TOPLEFT", 0, 0)

  local precastFilePlayBtn =
    makeIconButton(precastFileFrame, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
  wirePlayStop(precastFilePlayBtn, function()
    local v = precastFileBox:GetText()
    return tonumber(v) or (v ~= "" and v or nil)
  end)
  precastFilePlayBtn:SetPoint("LEFT", precastFileBox, "RIGHT", 4, 0)

  local precastFileReplaceBtn = makeButton(precastFileFrame, L["Replace"], 52, function()
    local v = precastFileBox:GetText()
    local snd = tonumber(v) or (v ~= "" and v or nil)
    if snd then
      precastReplaceSound(snd)
    end
  end)
  precastFileReplaceBtn:SetPoint("LEFT", precastFilePlayBtn, "RIGHT", 2, 0)

  local precastFileAddBtn = makeButton(precastFileFrame, L["Add"], 36, function()
    local v = precastFileBox:GetText()
    local snd = tonumber(v) or (v ~= "" and v or nil)
    if snd then
      precastAddSound(snd)
    end
  end)
  precastFileAddBtn:SetPoint("LEFT", precastFileReplaceBtn, "RIGHT", 2, 0)

  local function precastSetInputMode(isBrowse)
    precastBrowseRadio:SetChecked(isBrowse)
    precastFileRadio:SetChecked(not isBrowse)
    precastBrowseBox:SetShown(isBrowse)
    if precastBrowseBox.clearBtn then
      precastBrowseBox.clearBtn:SetShown(isBrowse and precastBrowseBox:GetText() ~= "")
    end
    precastFileFrame:SetShown(not isBrowse)
    if not isBrowse then
      precastBrowseDD:Hide()
    end
  end
  precastBrowseRadio:SetScript("OnClick", function()
    precastSetInputMode(true)
  end)
  precastFileRadio:SetScript("OnClick", function()
    precastSetInputMode(false)
  end)

  -- Duration + Loop row (inside Precast tab)
  local precastDurLabel = precastTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  precastDurLabel:SetPoint("TOPLEFT", precastBrowseRadio, "BOTTOMLEFT", 0, -30)
  precastDurLabel:SetText(L["Duration:"])

  local precastDurBox = CreateFrame("EditBox", nil, precastTab, "InputBoxTemplate")
  precastDurBox:SetSize(60, 22)
  precastDurBox:SetAutoFocus(false)
  precastDurBox:SetPoint("LEFT", precastDurLabel, "RIGHT", 6, 0)
  do
    precastDurBox.placeholder = precastDurBox:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    precastDurBox.placeholder:SetPoint("LEFT", 4, 0)
    precastDurBox.placeholder:SetText("e.g. 1.5")
    local function upd(self)
      self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
    end
    precastDurBox:SetScript("OnEditFocusGained", function(self)
      self.placeholder:Hide()
    end)
    precastDurBox:SetScript("OnEditFocusLost", upd)
    precastDurBox:SetScript("OnTextChanged", upd)
    precastDurBox:SetScript("OnShow", upd)
  end
  wireNumericEditBox(precastDurBox, function(val)
    editorPrecastDuration = val
  end)

  local precastLoopCheck = CreateFrame("CheckButton", nil, precastTab, "UICheckButtonTemplate")
  precastLoopCheck:SetSize(22, 22)
  precastLoopCheck:SetPoint("LEFT", precastDurBox, "RIGHT", 8, 0)
  precastLoopCheck.text:SetFontObject("GameFontNormalSmall")
  precastLoopCheck.text:SetText(L["Loop"])
  precastLoopCheck:SetScript("OnClick", function(self)
    editorPrecastLoop = self:GetChecked() and true or nil
  end)
  precastLoopCheck:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    GameTooltip:AddLine(L["Repeat the sound until the next cast. Requires a duration to be set."], 1, 1, 1, true)
    GameTooltip:Show()
  end)
  precastLoopCheck:SetScript("OnLeave", function()
    GameTooltip:Hide()
  end)

  ---------------------------------------------------------------------------
  -- Impact tab content: list of { delay, sound } entries fired on cast
  -- complete. Layout mirrors the Precast tab: enable check, sounds header
  -- with play-all / clear-all, scrollable row list, and a single-line add
  -- form below. Each row is [remove] [delay] s  [sound brief]  [play],
  -- with the delay edit box showing per-entry timing (unique to impact).
  ---------------------------------------------------------------------------
  local impactTab = CreateFrame("Frame", nil, tabContainer)
  impactTab:SetPoint("TOPLEFT", 8, -6)
  impactTab:SetPoint("BOTTOMRIGHT", -8, 6)
  impactTab:Hide()

  local impactEnabledCheck = CreateFrame("CheckButton", nil, impactTab, "UICheckButtonTemplate")
  impactEnabledCheck:SetSize(22, 22)
  impactEnabledCheck:SetPoint("TOPLEFT", 4, -4)
  impactEnabledCheck.text:SetFontObject("GameFontHighlightSmall")
  impactEnabledCheck.text:SetText(L["Enabled"])
  impactEnabledCheck:SetScript("OnClick", function(self)
    editorImpactEnabled = self:GetChecked() and true or false
  end)

  -- Sound list header — matches Precast's layout: "Sounds:" label plus
  -- play-all / clear-all icon buttons to the right.
  local impactSoundLabel = impactTab:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  impactSoundLabel:SetPoint("TOPLEFT", impactEnabledCheck, "BOTTOMLEFT", 0, -4)
  impactSoundLabel:SetText(L["Sounds:"])

  local impactPlayAllBtn =
    makeIconButton(impactTab, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 20, L["Play all sounds / Stop"])
  wirePlayStop(impactPlayAllBtn, function()
    -- Reuse playSoundCollection shape by projecting just the sounds
    -- (delays are a cast-time concern; preview plays them back-to-back).
    if #editorImpacts == 0 then
      return nil
    end
    local sounds = {}
    for _, entry in ipairs(editorImpacts) do
      sounds[#sounds + 1] = entry.sound
    end
    return sounds
  end)
  impactPlayAllBtn:SetPoint("LEFT", impactSoundLabel, "RIGHT", 6, 0)

  local impactClearAllBtn = makeIconButton(
    impactTab,
    "Interface\\Buttons\\UI-StopButton",
    16,
    L["Clear all sounds"],
    function()
      wipe(editorImpacts)
      edRefreshImpactDisplay()
    end
  )
  impactClearAllBtn:SetPoint("LEFT", impactPlayAllBtn, "RIGHT", 4, 0)

  local impactSoundScroll = CreateFrame("ScrollFrame", nil, impactTab, "UIPanelScrollFrameTemplate")
  impactSoundScroll:SetPoint("TOPLEFT", impactSoundLabel, "BOTTOMLEFT", 0, -2)
  impactSoundScroll:SetPoint("RIGHT", impactTab, "RIGHT", -22, 0)
  impactSoundScroll:SetHeight(ROW_HEIGHT)

  local impactSoundListFrame = CreateFrame("Frame", nil, impactSoundScroll)
  impactSoundListFrame:SetWidth(CONTENT_WIDTH - 80)
  impactSoundListFrame:SetHeight(ROW_HEIGHT)
  impactSoundScroll:SetScrollChild(impactSoundListFrame)

  -- Dedicated empty-state row, outside the data-row pool. Precast reuses
  -- index 1 for its empty row which causes widgets to be missing on the
  -- transition from empty → populated; we avoid that here.
  local impactEmptyRow = CreateFrame("Frame", nil, impactSoundListFrame)
  impactEmptyRow:SetHeight(ROW_HEIGHT)
  impactEmptyRow:SetPoint("TOPLEFT", 0, 0)
  impactEmptyRow:SetPoint("RIGHT", impactSoundListFrame, "RIGHT", 0, 0)
  impactEmptyRow.text = impactEmptyRow:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  impactEmptyRow.text:SetPoint("LEFT", 4, 0)
  impactEmptyRow.text:SetText("|cff888888" .. L["(none)"] .. "|r")

  local impactSoundRows = {}
  local impactAddFrame -- forward reference, anchored below the list after refresh

  edRefreshImpactDisplay = function()
    stopAllPreviews()
    for _, row in ipairs(impactSoundRows) do
      row:Hide()
    end

    local yOff = 0
    for i, entry in ipairs(editorImpacts) do
      local row = impactSoundRows[i]
      if not row then
        row = CreateFrame("Frame", nil, impactSoundListFrame)
        row:SetHeight(ROW_HEIGHT)
        row.removeBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 14, L["Remove"])
        row.removeBtn:SetPoint("LEFT", 2, 0)
        row.delayBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate")
        row.delayBox:SetSize(38, 18)
        row.delayBox:SetPoint("LEFT", row.removeBtn, "RIGHT", 10, 0)
        row.delayBox:SetAutoFocus(false)
        row.delayBox:SetFontObject("GameFontHighlightSmall")
        row.delaySuffix = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        row.delaySuffix:SetPoint("LEFT", row.delayBox, "RIGHT", 2, 0)
        row.delaySuffix:SetText("s")
        row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.text:SetPoint("LEFT", row.delaySuffix, "RIGHT", 8, 0)
        row.text:SetJustifyH("LEFT")
        row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 16, L["Play / Stop"])
        row.playBtn:SetPoint("RIGHT", -4, 0)
        row.stripe = row:CreateTexture(nil, "BACKGROUND")
        row.stripe:SetAllPoints()
        row.stripe:SetColorTexture(1, 1, 1, 0.04)
        impactSoundRows[i] = row
      end
      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", 0, -yOff)
      row:SetPoint("RIGHT", impactSoundListFrame, "RIGHT", 0, 0)

      row.delayBox:SetText(("%.2f"):format(entry.delay or 0))
      local capturedIdx = i
      local function commitDelay(self)
        local v = tonumber(self:GetText())
        if v and v > 0 then
          editorImpacts[capturedIdx].delay = v
          self:SetText(("%.2f"):format(v))
        else
          self:SetText(("%.2f"):format(editorImpacts[capturedIdx].delay or 0))
        end
      end
      row.delayBox:SetScript("OnEnterPressed", function(self)
        commitDelay(self)
        self:ClearFocus()
      end)
      row.delayBox:SetScript("OnEscapePressed", function(self)
        self:SetText(("%.2f"):format(editorImpacts[capturedIdx].delay or 0))
        self:ClearFocus()
      end)
      row.delayBox:SetScript("OnEditFocusLost", commitDelay)

      row.text:SetText(formatSoundBrief(entry.sound))
      row.text:SetPoint("RIGHT", row.playBtn, "LEFT", -4, 0)
      wirePlayStop(row.playBtn, function()
        return entry.sound
      end)
      row.removeBtn:SetScript("OnClick", function()
        table.remove(editorImpacts, capturedIdx)
        edRefreshImpactDisplay()
      end)
      row.stripe:SetShown(i % 2 == 0)
      row:Show()
      yOff = yOff + ROW_HEIGHT
    end

    -- Toggle empty-state visibility
    impactEmptyRow:SetShown(#editorImpacts == 0)
    if #editorImpacts == 0 then
      yOff = ROW_HEIGHT
    end

    local listH = math.max(yOff, ROW_HEIGHT)
    impactSoundListFrame:SetHeight(listH)
    local scrollH = math.min(listH, SOUND_LIST_MAX_H)
    impactSoundScroll:SetHeight(scrollH)

    if impactAddFrame then
      impactAddFrame:ClearAllPoints()
      impactAddFrame:SetPoint("TOPLEFT", impactSoundScroll, "BOTTOMLEFT", 0, -6)
    end

    if edResizeEditor and editorFrame:IsShown() then
      edResizeEditor()
    end
  end

  -- Add form: mirrors Precast's Browse/File input pair so the user can
  -- search the sound database by name. Unique to Impact is the persistent
  -- "Delay" field that combines with whichever input mode commits a sound.
  impactAddFrame = CreateFrame("Frame", nil, impactTab)
  impactAddFrame:SetSize(CONTENT_WIDTH - 40, 24)
  impactAddFrame:SetPoint("TOPLEFT", impactSoundScroll, "BOTTOMLEFT", 0, -6)

  -- Browse/File radio toggle (same widgets the Precast tab uses).
  local impactBrowseRadio = makeRadio(impactTab)
  impactBrowseRadio:SetPoint("TOPLEFT", impactAddFrame, "TOPLEFT", 0, 0)
  impactBrowseRadio.label:SetText(L["Browse"])

  local impactFileRadio = makeRadio(impactTab)
  impactFileRadio:SetPoint("LEFT", impactBrowseRadio.label, "RIGHT", 12, 0)
  impactFileRadio.label:SetText(L["File Path / FID"])

  -- Browse input (search by name, autocomplete) — declared before the
  -- impactAddSound / createSoundInput call below so closures can reference
  -- it; createSoundInput is invoked further down once impactAddSound exists.
  local impactBrowseInput, impactBrowseBox

  -- File input frame (direct FID or path).
  local impactFileFrame = CreateFrame("Frame", nil, impactTab)
  impactFileFrame:SetPoint("TOPLEFT", impactBrowseRadio, "BOTTOMLEFT", 0, -2)
  impactFileFrame:SetSize(CONTENT_WIDTH - 40, 26)

  -- Delay row below the input, always visible regardless of mode.
  local impactDelayLabel = impactTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  impactDelayLabel:SetText(L["Delay:"])

  local impactDelayBox = CreateFrame("EditBox", nil, impactTab, "InputBoxTemplate")
  impactDelayBox:SetSize(60, 22)
  impactDelayBox:SetAutoFocus(false)
  impactDelayBox:SetFontObject("GameFontHighlightSmall")
  impactDelayBox:SetText("1.00")

  local impactDelaySuffix = impactTab:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  impactDelaySuffix:SetText(L["seconds (for travel-time spells like Wrath)"])

  -- Helper: add a sound to the impacts list using the current delay value.
  -- Replace-if-same-sound semantics match /res impact add.
  local function impactAddSound(snd)
    local delay = tonumber(impactDelayBox:GetText())
    if not delay or delay <= 0 then
      Resonance.msg(L["Delay must be a positive number of seconds."])
      return
    end
    if snd == nil or snd == "" then
      Resonance.msg(L["Enter a FileDataID or sound path."])
      return
    end
    local replaced
    for i, entry in ipairs(editorImpacts) do
      if entry.sound == snd then
        entry.delay = delay
        replaced = i
        break
      end
    end
    if not replaced then
      editorImpacts[#editorImpacts + 1] = { delay = delay, sound = snd }
    end
    editorImpactEnabled = true
    impactEnabledCheck:SetChecked(true)
    edRefreshImpactDisplay()
  end

  -- Now that impactAddSound is defined we can wire up the input.
  impactBrowseInput = createSoundInput(impactTab, {
    width = CONTENT_WIDTH - 80,
    placeholder = L["Search spell sounds..."],
    showPlayButton = false,
    debounceKey = "impactBrowse",
    dropdownWidth = CONTENT_WIDTH,
    actionDef = { label = L["+ Add"], icon = "Interface\\Buttons\\UI-PlusButton-UP" },
    onSelected = function(e, w)
      impactAddSound(e.fileDataID)
      w.editbox:SetText("")
      w.dropdown:Hide()
    end,
    getSearchResults = function(q, cb)
      if not hasSpellDB() then
        cb({}, "")
        return
      end
      if tonumber(q) or q:find("\\") or q:find("/") then
        cb({}, "")
        return
      end
      searchDB(Resonance.SpellSounds, q, "impactBrowse", function(results)
        for _, r in ipairs(results) do
          local filename = r.path:match("([^/\\]+)$") or r.path
          local parent = r.path:match("([^/\\]+)[/\\][^/\\]+$")
          r.display = filename
          r.subdisplay = (parent and (parent .. "/  \194\183  ") or "") .. "#" .. r.fileDataID
        end
        appendCustomSounds(results, q)
        cb(results)
      end, Resonance.SpellSoundPrefixes)
    end,
  })
  impactBrowseBox = impactBrowseInput.editbox
  impactBrowseBox:ClearAllPoints()
  impactBrowseBox:SetPoint("TOPLEFT", impactBrowseRadio, "BOTTOMLEFT", 0, -2)
  local impactBrowseDD = impactBrowseInput.dropdown

  local impactBrowseClear = makeClearButton(impactBrowseBox, function()
    impactBrowseDD:Hide()
  end)
  impactBrowseBox:HookScript("OnTextChanged", function(self)
    impactBrowseClear:SetShown(self:GetText() ~= "")
  end)
  impactBrowseBox:HookScript("OnEditFocusGained", function()
    impactBrowseInput.DoSearch()
  end)
  impactBrowseBox:HookScript("OnEditFocusLost", function()
    C_Timer.After(0.2, function()
      if not impactBrowseBox:HasFocus() and not impactBrowseDD:IsMouseOver() then
        impactBrowseDD:Hide()
      end
    end)
  end)

  -- File path / FID input
  local impactFileBox = makeEditBox(impactFileFrame, CONTENT_WIDTH - 200, impactFileFrame, 0, 0, "FID or path")
  impactFileBox:SetPoint("TOPLEFT", impactFileFrame, "TOPLEFT", 0, 0)

  local impactFilePlayBtn =
    makeIconButton(impactFileFrame, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
  wirePlayStop(impactFilePlayBtn, function()
    local v = impactFileBox:GetText()
    return tonumber(v) or (v ~= "" and v or nil)
  end)
  impactFilePlayBtn:SetPoint("LEFT", impactFileBox, "RIGHT", 4, 0)

  -- Width 36 + "Add sound" tooltip matches Cast / Precast File-mode Add.
  local impactFileAddBtn = makeButton(impactFileFrame, L["Add"], 36, function()
    local v = impactFileBox:GetText()
    local snd = tonumber(v) or (v ~= "" and v or nil)
    if snd then
      impactAddSound(snd)
      impactFileBox:SetText("")
    end
  end)
  impactFileAddBtn:SetPoint("LEFT", impactFilePlayBtn, "RIGHT", 2, 0)
  addButtonTooltip(impactFileAddBtn, L["Add sound"])
  impactFileBox:SetScript("OnEnterPressed", function(self)
    local v = self:GetText()
    local snd = tonumber(v) or (v ~= "" and v or nil)
    if snd then
      impactAddSound(snd)
      self:SetText("")
    end
    self:ClearFocus()
  end)

  -- Anchor the always-visible delay row below the input widgets. Kept out
  -- of both Browse and File frames so the user doesn't need to re-enter it
  -- when switching modes.
  impactDelayLabel:SetPoint("TOPLEFT", impactBrowseBox, "BOTTOMLEFT", 0, -28)
  impactDelayBox:SetPoint("LEFT", impactDelayLabel, "RIGHT", 6, 0)
  impactDelaySuffix:SetPoint("LEFT", impactDelayBox, "RIGHT", 6, 0)

  local function impactSetInputMode(isBrowse)
    impactBrowseRadio:SetChecked(isBrowse)
    impactFileRadio:SetChecked(not isBrowse)
    impactBrowseBox:SetShown(isBrowse)
    if impactBrowseBox.clearBtn then
      impactBrowseBox.clearBtn:SetShown(isBrowse and impactBrowseBox:GetText() ~= "")
    end
    impactFileFrame:SetShown(not isBrowse)
    if not isBrowse then
      impactBrowseDD:Hide()
    end
  end
  impactBrowseRadio:SetScript("OnClick", function()
    impactSetInputMode(true)
  end)
  impactFileRadio:SetScript("OnClick", function()
    impactSetInputMode(false)
  end)
  -- Default to Browse when the spell DB is loaded, else File mode.
  impactSetInputMode(hasSpellDB())

  ---------------------------------------------------------------------------
  -- Muted tab content
  ---------------------------------------------------------------------------
  local mutedTab = CreateFrame("Frame", nil, tabContainer)
  mutedTab:SetPoint("TOPLEFT", 8, -6)
  mutedTab:SetPoint("BOTTOMRIGHT", -8, 6)
  mutedTab:Hide()

  local AUTO_MUTE_VISIBLE_ROWS = 8
  local AUTO_MUTE_SCROLL_H = AUTO_MUTE_VISIBLE_ROWS * ROW_HEIGHT

  local autoMuteInfo = mutedTab:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  autoMuteInfo:SetPoint("TOPLEFT", 4, -4)
  autoMuteInfo:SetWidth(CONTENT_WIDTH - 60)
  autoMuteInfo:SetJustifyH("LEFT")
  autoMuteInfo:SetWordWrap(true)

  local autoMuteScroll = CreateFrame("ScrollFrame", "ResonanceAutoMuteScroll", mutedTab, "UIPanelScrollFrameTemplate")
  autoMuteScroll:SetPoint("TOPLEFT", autoMuteInfo, "BOTTOMLEFT", 0, -2)
  autoMuteScroll:SetPoint("BOTTOMRIGHT", mutedTab, "BOTTOMRIGHT", -24, 4)
  autoMuteScroll:SetWidth(CONTENT_WIDTH - 60)

  local autoMuteScrollBar = autoMuteScroll.ScrollBar or _G["ResonanceAutoMuteScrollScrollBar"]

  local autoMuteBg = autoMuteScroll:CreateTexture(nil, "BACKGROUND")
  autoMuteBg:SetAllPoints()
  autoMuteBg:SetColorTexture(0, 0, 0, 0.15)

  local autoMuteContent = CreateFrame("Frame")
  autoMuteScroll:SetScrollChild(autoMuteContent)
  autoMuteContent:SetWidth(CONTENT_WIDTH - 80)
  autoMuteContent:SetHeight(1)

  local autoMuteRows = {}

  refreshAutoMuteSection = function(spellID)
    for _, row in ipairs(autoMuteRows) do
      row:Hide()
    end

    if not spellID then
      autoMuteInfo:SetText("|cff888888" .. L["Enter a spell ID first."] .. "|r")
      autoMuteScroll:Hide()
      return
    end

    local fids = Resonance.getSpellMuteFIDs(spellID)
    if not fids or #fids == 0 then
      autoMuteInfo:SetText("|cff888888" .. L["No auto-mute data for this spell."] .. "|r")
      autoMuteScroll:Hide()
      return
    end

    local entries = {}
    for _, fid in ipairs(fids) do
      entries[#entries + 1] = { type = "sound", fid = fid }
    end

    autoMuteInfo:SetText(L["%d spell sound(s) — uncheck to keep a sound unmuted:"]:format(#fids))
    autoMuteScroll:Show()

    for i, entry in ipairs(entries) do
      local row = autoMuteRows[i]
      if not row then
        row = CreateFrame("Frame", nil, autoMuteContent)
        row:SetSize(CONTENT_WIDTH - 80, ROW_HEIGHT)
        row:SetPoint("TOPLEFT", 0, -(i - 1) * ROW_HEIGHT)

        row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.text:SetPoint("LEFT", 4, 0)
        row.text:SetPoint("RIGHT", row, "RIGHT", -48, 0)
        row.text:SetJustifyH("LEFT")
        row.text:SetWordWrap(false)

        row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
        row.playBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0)

        row.muteBtn = CreateFrame("CheckButton", nil, row, "UICheckButtonTemplate")
        row.muteBtn:SetSize(22, 22)
        row.muteBtn:SetPoint("RIGHT", row.playBtn, "LEFT", -2, 0)
        row.muteBtn:SetHitRectInsets(0, 0, 0, 0)
        row.muteBtn.text:SetText("")
        row.muteBtn:SetScript("OnEnter", function(self)
          GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
          GameTooltip:AddLine(
            self:GetChecked() and L["Muted (click to unmute)"] or L["Not muted (click to mute)"],
            1,
            1,
            1
          )
          GameTooltip:Show()
        end)
        row.muteBtn:SetScript("OnLeave", function()
          GameTooltip:Hide()
        end)

        autoMuteRows[i] = row
      end

      row.text:ClearAllPoints()
      row.text:SetPoint("LEFT", 4, 0)
      row.text:SetPoint("RIGHT", row, "RIGHT", -72, 0)

      if not row.stripe then
        row.stripe = row:CreateTexture(nil, "BACKGROUND")
        row.stripe:SetAllPoints()
      end

      local path = lookupFIDPath(entry.fid)
      if path then
        row.text:SetText(formatSoundDisplay(path, entry.fid))
      else
        row.text:SetText("|cffff8800" .. entry.fid .. "|r")
      end
      local entryFid = entry.fid
      wirePlayStop(row.playBtn, function()
        return entryFid
      end)
      row.playBtn:Show()

      local isExcluded = editorExclusions[entry.fid]
      row.muteBtn:SetChecked(not isExcluded)
      row.muteBtn:SetScript("OnClick", function(self)
        if self:GetChecked() then
          editorExclusions[entry.fid] = nil
        else
          editorExclusions[entry.fid] = true
        end
      end)
      row.muteBtn:Show()

      if i % 2 == 0 then
        row.stripe:SetColorTexture(1, 1, 1, 0.06)
        row.stripe:Show()
      else
        row.stripe:Hide()
      end

      row:Show()
    end

    autoMuteContent:SetHeight(#entries * ROW_HEIGHT)

    if autoMuteScrollBar then
      local visibleH = autoMuteScroll:GetHeight()
      if visibleH and #entries * ROW_HEIGHT > visibleH then
        autoMuteScrollBar:Show()
      else
        autoMuteScrollBar:Hide()
        autoMuteScroll:SetVerticalScroll(0)
      end
    end
  end

  ---------------------------------------------------------------------------
  -- Tab switching
  ---------------------------------------------------------------------------
  local currentTab = "cast"

  local castTabBtn, precastTabBtn, impactTabBtn, mutedTabBtn

  local function selectTab(tabID)
    currentTab = tabID
    castTab:SetShown(tabID == "cast")
    precastTab:SetShown(tabID == "precast")
    impactTab:SetShown(tabID == "impact")
    mutedTab:SetShown(tabID == "muted")
    castTabBtn:SetSelected(tabID == "cast")
    precastTabBtn:SetSelected(tabID == "precast")
    impactTabBtn:SetSelected(tabID == "impact")
    mutedTabBtn:SetSelected(tabID == "muted")
    -- Hide browse dropdowns when switching tabs
    if tabID ~= "cast" then
      edBrowseDD:Hide()
    end
    if tabID ~= "precast" then
      precastBrowseDD:Hide()
    end
  end

  -- Tab order follows the cast lifecycle: precast (bar start) → cast
  -- complete → impact (post-complete, delayed) → muted.
  precastTabBtn = makeEditorTab(tabBar, L["Precast"], function()
    selectTab("precast")
  end)
  precastTabBtn:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 0, 0)

  castTabBtn = makeEditorTab(tabBar, L["Cast Complete"], function()
    selectTab("cast")
  end)
  castTabBtn:SetPoint("LEFT", precastTabBtn, "RIGHT", 2, 0)

  impactTabBtn = makeEditorTab(tabBar, L["Impact"], function()
    selectTab("impact")
  end)
  impactTabBtn:SetPoint("LEFT", castTabBtn, "RIGHT", 2, 0)

  mutedTabBtn = makeEditorTab(tabBar, L["Muted"], function()
    selectTab("muted")
  end)
  mutedTabBtn:SetPoint("LEFT", impactTabBtn, "RIGHT", 2, 0)

  ---------------------------------------------------------------------------
  -- Advanced Options toggle
  ---------------------------------------------------------------------------
  local advancedBtn = CreateFrame("Button", nil, editorFrame)
  advancedBtn:SetHeight(20)
  advancedBtn:SetPoint("TOPLEFT", tabContainer, "BOTTOMLEFT", 0, -4)
  advancedBtn:SetPoint("RIGHT", editorFrame, "RIGHT", -18, 0)
  local advArrow = advancedBtn:CreateTexture(nil, "OVERLAY")
  advArrow:SetSize(14, 14)
  advArrow:SetPoint("LEFT", 0, 0)
  advArrow:SetTexture("Interface\\Buttons\\UI-PlusButton-UP")
  local advLabel = advancedBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  advLabel:SetPoint("LEFT", advArrow, "RIGHT", 4, 0)
  advLabel:SetText(L["Advanced Options"])
  local advBg = advancedBtn:CreateTexture(nil, "BACKGROUND")
  advBg:SetAllPoints()
  advBg:SetColorTexture(0.15, 0.15, 0.15, 0.6)
  advancedBtn:SetScript("OnEnter", function()
    advBg:SetColorTexture(0.22, 0.22, 0.22, 0.6)
  end)
  advancedBtn:SetScript("OnLeave", function()
    advBg:SetColorTexture(0.15, 0.15, 0.15, 0.6)
  end)

  -- Advanced content container
  local advContent = CreateFrame("Frame", nil, editorFrame)
  advContent:SetSize(CONTENT_WIDTH - 20, 1)
  advContent:SetPoint("TOPLEFT", advancedBtn, "BOTTOMLEFT", 0, -4)

  ---------------------------------------------------------------------------
  -- Advanced: Sound mode radios
  ---------------------------------------------------------------------------
  local advSoundModeFrame = CreateFrame("Frame", nil, advContent)
  advSoundModeFrame:SetSize(CONTENT_WIDTH - 20, 24)
  advSoundModeFrame:SetPoint("TOPLEFT", advContent, "TOPLEFT", 0, 0)

  local advSoundModeLabel = advSoundModeFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  advSoundModeLabel:SetPoint("LEFT", advSoundModeFrame, "LEFT", 0, 0)
  advSoundModeLabel:SetText(L["Sound mode:"])

  edSoundModeStandard = makeRadio(advSoundModeFrame)
  edSoundModeStandard:SetPoint("LEFT", advSoundModeLabel, "RIGHT", 6, 0)
  edSoundModeStandard.label:SetText(L["Standard"])
  edSoundModeStandard:SetChecked(true)

  edSoundModeSequence = makeRadio(advSoundModeFrame)
  edSoundModeSequence:SetPoint("LEFT", edSoundModeStandard.label, "RIGHT", 12, 0)
  edSoundModeSequence.label:SetText(L["Sequence (1 per hit)"])

  edSoundModeStandard:SetScript("OnClick", function()
    edSetSoundModeValue("standard")
  end)
  edSoundModeSequence:SetScript("OnClick", function()
    edSetSoundModeValue("sequence")
  end)

  ---------------------------------------------------------------------------
  -- Advanced: Channel override
  ---------------------------------------------------------------------------
  local CHANNEL_OPTIONS = {
    { key = nil, label = L["Default"] },
    { key = "Master", label = MASTER_VOLUME or "Master" },
    { key = "SFX", label = SOUND_VOLUME or "SFX" },
    { key = "Music", label = MUSIC_VOLUME or "Music" },
    { key = "Ambience", label = AMBIENCE_VOLUME or "Ambience" },
    { key = "Dialog", label = DIALOG_VOLUME or "Dialog" },
  }

  local advChannelFrame = CreateFrame("Frame", nil, advContent)
  advChannelFrame:SetSize(CONTENT_WIDTH - 20, 24)
  advChannelFrame:SetPoint("TOPLEFT", advSoundModeFrame, "BOTTOMLEFT", 0, -4)

  local advChannelLabel = advChannelFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  advChannelLabel:SetPoint("LEFT", advChannelFrame, "LEFT", 0, 0)
  advChannelLabel:SetText(L["Channel:"])

  local advChannelBtn = CreateFrame("Button", nil, advChannelFrame, "UIPanelButtonTemplate")
  advChannelBtn:SetPoint("LEFT", advChannelLabel, "RIGHT", 6, 0)
  advChannelBtn:SetSize(90, 22)

  local function edUpdateChannelText()
    for _, opt in ipairs(CHANNEL_OPTIONS) do
      if opt.key == editorChannel then
        advChannelBtn:SetText(opt.label)
        return
      end
    end
    advChannelBtn:SetText(L["Default"])
  end
  edUpdateChannelText()

  advChannelBtn:SetScript("OnClick", function(self)
    MenuUtil.CreateContextMenu(self, function(_, rootDescription)
      for _, opt in ipairs(CHANNEL_OPTIONS) do
        rootDescription:CreateButton(opt.label, function()
          editorChannel = opt.key
          edUpdateChannelText()
        end)
      end
    end)
  end)
  advChannelBtn:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    GameTooltip:AddLine(
      L["Override the sound channel for this spell. Default uses the global channel from the General tab."],
      1,
      1,
      1,
      true
    )
    GameTooltip:Show()
  end)
  advChannelBtn:SetScript("OnLeave", function()
    GameTooltip:Hide()
  end)

  ---------------------------------------------------------------------------
  -- Advanced: Multi-hit window override
  ---------------------------------------------------------------------------
  local advMultiHitFrame = CreateFrame("Frame", nil, advContent)
  advMultiHitFrame:SetSize(CONTENT_WIDTH - 20, 24)
  advMultiHitFrame:SetPoint("TOPLEFT", advChannelFrame, "BOTTOMLEFT", 0, -4)

  local advMultiHitLabel = advMultiHitFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  advMultiHitLabel:SetPoint("LEFT", advMultiHitFrame, "LEFT", 0, 0)
  advMultiHitLabel:SetText(L["Multi-hit window (seconds):"])

  local advMultiHitBox = CreateFrame("EditBox", nil, advMultiHitFrame, "InputBoxTemplate")
  advMultiHitBox:SetSize(60, 22)
  advMultiHitBox:SetPoint("LEFT", advMultiHitLabel, "RIGHT", 6, 0)
  advMultiHitBox:SetAutoFocus(false)
  do
    advMultiHitBox.placeholder = advMultiHitBox:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    advMultiHitBox.placeholder:SetPoint("LEFT", 4, 0)
    advMultiHitBox.placeholder:SetText("e.g. 1.5")
    local function upd(self)
      self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
    end
    advMultiHitBox:SetScript("OnEditFocusGained", function(self)
      self.placeholder:Hide()
    end)
    advMultiHitBox:SetScript("OnEditFocusLost", upd)
    advMultiHitBox:SetScript("OnTextChanged", upd)
    advMultiHitBox:SetScript("OnShow", upd)
  end
  wireNumericEditBox(advMultiHitBox, function(val)
    editorMultiHitWindow = val
  end)

  local advMultiHitClearBtn = makeButton(advMultiHitFrame, L["Clear"], 42, function()
    advMultiHitBox:SetText("")
    editorMultiHitWindow = nil
  end)
  advMultiHitClearBtn:SetPoint("LEFT", advMultiHitBox, "RIGHT", 4, 0)

  advMultiHitFrame:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    GameTooltip:AddLine(
      L["Override the multi-hit sound window for this spell. Leave empty to use the global default from the General tab."],
      1,
      1,
      1,
      true
    )
    GameTooltip:Show()
  end)
  advMultiHitFrame:SetScript("OnLeave", function()
    GameTooltip:Hide()
  end)

  ---------------------------------------------------------------------------
  -- Advanced: Faction-specific sounds
  ---------------------------------------------------------------------------
  local advFactionFrame = CreateFrame("Frame", nil, advContent)
  advFactionFrame:SetSize(CONTENT_WIDTH - 20, 58)
  advFactionFrame:SetPoint("TOPLEFT", advMultiHitFrame, "BOTTOMLEFT", 0, -4)

  local advFactionCheck = CreateFrame("CheckButton", nil, advFactionFrame, "UICheckButtonTemplate")
  advFactionCheck:SetSize(22, 22)
  advFactionCheck:SetPoint("TOPLEFT", 0, 0)
  advFactionCheck.text:SetFontObject("GameFontNormalSmall")
  advFactionCheck.text:SetText(L["Faction-specific sounds"])

  local advFactionDesc = advFactionFrame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  advFactionDesc:SetPoint("TOPLEFT", advFactionCheck, "BOTTOMLEFT", 22, -2)
  advFactionDesc:SetWidth(CONTENT_WIDTH - 40)
  advFactionDesc:SetJustifyH("LEFT")
  advFactionDesc:SetWordWrap(true)
  advFactionDesc:SetText(
    L["Override the replacement sound based on your character's faction. Impact-list faction variants are configured via /res impact add <spellID> <delay> <sound> horde|alliance."]
  )

  -- factionRows[key] = { label, box, play } for key in "hordeCast" /
  -- "hordePrecast" / "allianceCast" / "alliancePrecast". One local instead
  -- of 12 to stay under Lua's 200-local-per-function cap.
  local factionRows = {}

  local function makeFactionRow(key, labelText, setEditorValue)
    local row = {}
    row.label = advFactionFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.label:SetText(labelText)

    row.box = CreateFrame("EditBox", nil, advFactionFrame, "InputBoxTemplate")
    row.box:SetSize(180, 22)
    row.box:SetAutoFocus(false)
    row.box:SetScript("OnEnterPressed", function(self)
      self:ClearFocus()
    end)
    row.box:SetScript("OnEscapePressed", function(self)
      self:ClearFocus()
    end)
    row.box:SetScript("OnTextChanged", function(self)
      local v = self:GetText()
      setEditorValue(v ~= "" and (tonumber(v) or v) or nil)
    end)

    row.play = makeIconButton(advFactionFrame, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
    wirePlayStop(row.play, function()
      local v = row.box:GetText()
      return tonumber(v) or (v ~= "" and v or nil)
    end)
    row.play:SetPoint("LEFT", row.box, "RIGHT", 4, 0)

    factionRows[key] = row
  end

  makeFactionRow("hordeCast", "|cffff2020" .. L["Horde cast:"] .. "|r", function(v)
    editorFaction.hordeSound = v
  end)
  factionRows.hordeCast.label:SetPoint("TOPLEFT", advFactionDesc, "BOTTOMLEFT", 0, -6)
  factionRows.hordeCast.box:SetPoint("LEFT", factionRows.hordeCast.label, "RIGHT", 6, 0)

  makeFactionRow("hordePrecast", "|cffff2020" .. L["Horde precast:"] .. "|r", function(v)
    editorFaction.hordePrecastSound = v
  end)
  factionRows.hordePrecast.label:SetPoint("TOPLEFT", factionRows.hordeCast.label, "BOTTOMLEFT", 0, -6)
  factionRows.hordePrecast.box:SetPoint("LEFT", factionRows.hordePrecast.label, "RIGHT", 6, 0)

  makeFactionRow("allianceCast", "|cff0070dd" .. L["Alliance cast:"] .. "|r", function(v)
    editorFaction.allianceSound = v
  end)
  factionRows.allianceCast.label:SetPoint("TOPLEFT", factionRows.hordePrecast.label, "BOTTOMLEFT", 0, -10)
  factionRows.allianceCast.box:SetPoint("LEFT", factionRows.allianceCast.label, "RIGHT", 6, 0)

  makeFactionRow("alliancePrecast", "|cff0070dd" .. L["Alliance precast:"] .. "|r", function(v)
    editorFaction.alliancePrecastSound = v
  end)
  factionRows.alliancePrecast.label:SetPoint("TOPLEFT", factionRows.allianceCast.label, "BOTTOMLEFT", 0, -6)
  factionRows.alliancePrecast.box:SetPoint("LEFT", factionRows.alliancePrecast.label, "RIGHT", 6, 0)

  local function edSetFactionMode(enabled)
    advFactionCheck:SetChecked(enabled)
    advFactionDesc:SetShown(enabled)
    for _, row in pairs(factionRows) do
      row.label:SetShown(enabled)
      row.box:SetShown(enabled)
      row.play:SetShown(enabled)
    end
    if not enabled then
      editorFaction.hordeSound = nil
      editorFaction.hordePrecastSound = nil
      editorFaction.allianceSound = nil
      editorFaction.alliancePrecastSound = nil
      advFactionFrame:SetHeight(22)
    else
      advFactionFrame:SetHeight(140)
    end
    if edResizeEditor and editorFrame:IsShown() then
      edResizeEditor()
    end
  end
  advFactionCheck:SetScript("OnClick", function(self)
    edSetFactionMode(self:GetChecked())
  end)
  edSetFactionMode(false)

  ---------------------------------------------------------------------------
  -- Advanced section visibility
  ---------------------------------------------------------------------------

  local function edSetAdvancedVisible(show)
    edAdvancedExpanded = show
    advArrow:SetTexture(show and "Interface\\Buttons\\UI-MinusButton-UP" or "Interface\\Buttons\\UI-PlusButton-UP")
    advContent:SetShown(show)
    advSoundModeFrame:SetShown(show)
    advChannelFrame:SetShown(show)
    advMultiHitFrame:SetShown(show)
    advFactionFrame:SetShown(show)
    if edResizeEditor and editorFrame:IsShown() then
      edResizeEditor()
    end
  end
  advancedBtn:SetScript("OnClick", function()
    edSetAdvancedVisible(not edAdvancedExpanded)
  end)

  ---------------------------------------------------------------------------
  -- Mute-only handler (hides tabs, shows only auto-mute data)
  ---------------------------------------------------------------------------
  local function edSetMuteOnly(muteOnly)
    edMuteOnlyCheck:SetChecked(muteOnly)
    tabBar:SetShown(not muteOnly)
    tabContainer:SetShown(not muteOnly)
    advancedBtn:SetShown(not muteOnly)
    if muteOnly then
      advContent:Hide()
      edBrowseDD:Hide()
      precastBrowseDD:Hide()
      -- Show auto-mute section inline (below mute-only checkbox)
      mutedTab:SetParent(editorFrame)
      mutedTab:ClearAllPoints()
      mutedTab:SetPoint("TOPLEFT", edMuteOnlyCheck, "BOTTOMLEFT", 0, -8)
      mutedTab:SetPoint("BOTTOMRIGHT", editorFrame, "BOTTOMRIGHT", -18, 46)
      mutedTab:Show()
    else
      -- Restore muted tab into tab container
      mutedTab:SetParent(tabContainer)
      mutedTab:ClearAllPoints()
      mutedTab:SetAllPoints()
      mutedTab:SetShown(currentTab == "muted")
      selectTab(currentTab)
    end
    if edAdvancedExpanded and not muteOnly then
      edSetAdvancedVisible(true)
    end
    if edResizeEditor and editorFrame:IsShown() then
      edResizeEditor()
    end
  end
  edMuteOnlyCheck:SetScript("OnClick", function(self)
    local checked = self:GetChecked()
    if checked then
      editorSound = false
    else
      editorSound = nil
    end
    edSetMuteOnly(checked)
    edRefreshSoundList()
  end)

  ---------------------------------------------------------------------------
  -- Editor resize
  ---------------------------------------------------------------------------
  edResizeEditor = function()
    local muteOnly = edMuteOnlyCheck:GetChecked()
    local h = 40 -- title bar
      + 30 -- spell input
      + 20 -- spell preview
      + 26 -- mute-only checkbox

    if muteOnly then
      -- Mute-only: auto-mute section fills remaining space
      local sid = tonumber(edSpellIDBox:GetText())
      local hasMuteData = sid and Resonance.getSpellMuteFIDs(sid)
      if hasMuteData then
        h = h + 12 + AUTO_MUTE_SCROLL_H + 30
      else
        h = h + 50
      end
    else
      -- Dynamic tab content height based on sound list scroll frame
      local scrollH = 0
      if currentTab == "cast" then
        scrollH = castSoundScroll and castSoundScroll:GetHeight() or ROW_HEIGHT
      elseif currentTab == "precast" then
        scrollH = precastSoundScroll and precastSoundScroll:GetHeight() or ROW_HEIGHT
      end
      -- Tab content: enabled(26) + label(16) + scrollFrame + browse(50) + duration(26) + padding(24)
      local tabH = math.max(TAB_CONTENT_MIN_H, 26 + 16 + scrollH + 50 + 26 + 24 + 12)
      tabH = math.min(tabH, TAB_CONTENT_MAX_H)
      tabContainer:SetHeight(tabH)
      h = h
        + 6 -- gap
        + 24 -- tab bar
        + 2 -- gap
        + tabH -- tab content (dynamic)
        + 4 -- gap
        + 24 -- advanced toggle
      if edAdvancedExpanded then
        local advH = 24
          + 4 -- sound mode
          + 24
          + 4 -- channel
          + 24
          + 4 -- multi-hit
          + advFactionFrame:GetHeight()
          + 4 -- faction
        h = h + 4 + advH
      end
    end

    h = h + 40 -- save/cancel buttons
    editorFrame:SetHeight(h)
  end

  ---------------------------------------------------------------------------
  -- Save / Cancel buttons
  ---------------------------------------------------------------------------
  local edCancelBtn = makeButton(editorFrame, L["Cancel"], 60, nil)
  edCancelBtn:SetPoint("BOTTOMRIGHT", editorFrame, "BOTTOMRIGHT", -18, 16)

  local edSaveBtn = makeButton(editorFrame, L["Save"], 60, nil)
  edSaveBtn:SetPoint("RIGHT", edCancelBtn, "LEFT", -4, 0)

  local refreshList -- forward declaration

  local function closeEditor()
    editorFrame:Hide()
    edBrowseDD:Hide()
    precastBrowseDD:Hide()
    edSpellSearchDD:Hide()
  end
  edCloseBtn:SetScript("OnClick", closeEditor)

  ---------------------------------------------------------------------------
  -- openEditor
  ---------------------------------------------------------------------------
  local function openEditor(spellID)
    profile = Resonance.db.profile
    -- Reset all editor state
    editorSound = nil
    editorSoundMode = "standard"
    editorDuration = nil
    editorLoop = nil
    editorTrigger = "cast"
    editorChannel = nil
    editorMultiHitWindow = nil
    editorPrecastSound = nil
    editorPrecastDuration = nil
    editorPrecastLoop = nil
    editorFaction.hordeSound = nil
    editorFaction.hordePrecastSound = nil
    editorFaction.allianceSound = nil
    editorFaction.alliancePrecastSound = nil
    editorCastEnabled = true
    editorPrecastEnabled = false
    wipe(editorImpacts)
    editorImpactEnabled = false
    editorFaction.preservedHordeImpacts = nil
    editorFaction.preservedAllianceImpacts = nil
    editorFaction.preservedHordeExtras = nil
    editorFaction.preservedAllianceExtras = nil
    editorFaction.preservedMuteFIDs = nil
    wipe(editorExclusions)

    -- Reset all input fields
    castBrowseBox:SetText("")
    edBrowseDD:Hide()
    castFileBox:SetText("")
    precastBrowseBox:SetText("")
    precastBrowseDD:Hide()
    precastFileBox:SetText("")
    edSpellSearchDD:Hide()
    castDurBox:SetText("")
    precastDurBox:SetText("")
    castLoopCheck:SetChecked(false)
    precastLoopCheck:SetChecked(false)
    advMultiHitBox:SetText("")
    factionRows.hordeCast.box:SetText("")
    factionRows.hordePrecast.box:SetText("")
    factionRows.allianceCast.box:SetText("")
    factionRows.alliancePrecast.box:SetText("")

    if spellID then
      edSpellIDBox:SetText(tostring(spellID))
      edSpellIDBox:Disable()
      if edSpellIDBox.clearBtn then
        edSpellIDBox.clearBtn:Hide()
      end
      local cfg = profile.spell_config[spellID]
      if cfg then
        -- Deep-copy sound table
        if type(cfg.sound) == "table" then
          editorSound = {}
          for i, s in ipairs(cfg.sound) do
            editorSound[i] = s
          end
          if cfg.sound.sequence then
            editorSound.sequence = true
            editorSoundMode = "sequence"
          elseif cfg.sound.random then
            editorSound.random = {}
            for i, s in ipairs(cfg.sound.random) do
              editorSound.random[i] = s
            end
          end
        else
          editorSound = cfg.sound
        end
        if cfg.muteExclusions then
          for fid in pairs(cfg.muteExclusions) do
            editorExclusions[fid] = true
          end
        end
        if cfg.duration then
          editorDuration = cfg.duration
          castDurBox:SetText(tostring(cfg.duration))
        end
        if cfg.loop then
          editorLoop = cfg.loop
          castLoopCheck:SetChecked(true)
        end
        if cfg.trigger then
          editorTrigger = cfg.trigger
        end
        if cfg.channel then
          editorChannel = cfg.channel
        end
        if cfg.multiHitWindow then
          editorMultiHitWindow = cfg.multiHitWindow
          advMultiHitBox:SetText(tostring(cfg.multiHitWindow))
        end
        if cfg.precastSound then
          editorPrecastSound = cfg.precastSound
        end
        if cfg.precastDuration then
          editorPrecastDuration = cfg.precastDuration
          precastDurBox:SetText(tostring(cfg.precastDuration))
        end
        if cfg.precastLoop then
          editorPrecastLoop = cfg.precastLoop
          precastLoopCheck:SetChecked(true)
        end
        -- Lift legacy cfg.hordeSound / cfg.allianceSound into the nested
        -- bundle form (cfg.horde.sound / cfg.alliance.sound) before reading.
        -- migrateFaction is idempotent and safe to re-run.
        if Resonance.migrateFaction then
          Resonance.migrateFaction(cfg)
        end
        if cfg.horde then
          editorFaction.hordeSound = cfg.horde.sound
          editorFaction.hordePrecastSound = cfg.horde.precastSound
          factionRows.hordeCast.box:SetText(cfg.horde.sound and tostring(cfg.horde.sound) or "")
          factionRows.hordePrecast.box:SetText(cfg.horde.precastSound and tostring(cfg.horde.precastSound) or "")
          -- Non-GUI fields on the bundle (impacts, durations, loops) are
          -- preserved separately below so they survive a GUI save.
          editorFaction.preservedHordeImpacts = cfg.horde.impacts
          editorFaction.preservedHordeExtras = {
            duration = cfg.horde.duration,
            precastDuration = cfg.horde.precastDuration,
            loop = cfg.horde.loop,
            precastLoop = cfg.horde.precastLoop,
          }
        end
        if cfg.alliance then
          editorFaction.allianceSound = cfg.alliance.sound
          editorFaction.alliancePrecastSound = cfg.alliance.precastSound
          factionRows.allianceCast.box:SetText(cfg.alliance.sound and tostring(cfg.alliance.sound) or "")
          factionRows.alliancePrecast.box:SetText(
            cfg.alliance.precastSound and tostring(cfg.alliance.precastSound) or ""
          )
          editorFaction.preservedAllianceImpacts = cfg.alliance.impacts
          editorFaction.preservedAllianceExtras = {
            duration = cfg.alliance.duration,
            precastDuration = cfg.alliance.precastDuration,
            loop = cfg.alliance.loop,
            precastLoop = cfg.alliance.precastLoop,
          }
        end
        -- Preserve cfg.muteFIDs (template-supplied explicit auto-mute FIDs).
        -- Deep-copy so editor-side mutation can never affect the live entry
        -- before save.
        if cfg.muteFIDs then
          local copy = {}
          for i, fid in ipairs(cfg.muteFIDs) do
            copy[i] = fid
          end
          editorFaction.preservedMuteFIDs = copy
        end
        -- Impact list (base): deep-copy so edits in the editor don't touch
        -- the live cfg until the user saves.
        if cfg.impacts then
          for _, entry in ipairs(cfg.impacts) do
            editorImpacts[#editorImpacts + 1] = {
              delay = entry.delay,
              sound = entry.sound,
              duration = entry.duration,
            }
          end
          editorImpactEnabled = #editorImpacts > 0
        end
      end
      local name = Resonance.getSpellName(spellID)
      edTitle:SetText(L["Configure: "] .. (name or "Spell " .. spellID))
    else
      edSpellIDBox:SetText("")
      edSpellIDBox:Enable()
      edTitle:SetText(L["Add New Spell"])
    end

    -- When loading precast-only config, Core stores the precast sound in cfg.sound.
    -- Move it to editorPrecastSound for display in the Precast tab.
    if editorTrigger == "precast" and editorSound and editorSound ~= false then
      editorPrecastSound = editorSound
      editorSound = nil
    end

    -- Derive cast/precast enabled from trigger
    editorCastEnabled = (editorTrigger == "cast" or editorTrigger == "precast_and_cast")
    editorPrecastEnabled = (editorTrigger == "precast" or editorTrigger == "precast_and_cast")
    castEnabledCheck:SetChecked(editorCastEnabled)
    precastEnabledCheck:SetChecked(editorPrecastEnabled)

    edUpdateSpellPreview()
    edRefreshSoundList()
    edRefreshPrecastDisplay()
    edRefreshImpactDisplay()
    impactEnabledCheck:SetChecked(editorImpactEnabled)
    castSetInputMode(hasSpellDB())
    precastSetInputMode(hasSpellDB())
    edUpdateChannelText()
    edSetFactionMode(
      editorFaction.hordeSound ~= nil
        or editorFaction.hordePrecastSound ~= nil
        or editorFaction.allianceSound ~= nil
        or editorFaction.alliancePrecastSound ~= nil
        or editorFaction.preservedHordeImpacts ~= nil
        or editorFaction.preservedAllianceImpacts ~= nil
    )

    -- Handle mute-only state
    local isMuteOnly = editorSound == false
    edSetMuteOnly(isMuteOnly)
    if not isMuteOnly then
      selectTab("cast")
    end

    -- Auto-expand advanced if any advanced option is configured
    local hasAdvanced = editorDuration ~= nil
      or editorLoop
      or editorChannel ~= nil
      or editorMultiHitWindow ~= nil
      or editorFaction.hordeSound ~= nil
      or editorFaction.allianceSound ~= nil
      or edIsSequenceMode()
    edSetAdvancedVisible(hasAdvanced)

    refreshAutoMuteSection(spellID)
    edResizeEditor()

    editorFrame:Show()
  end

  edCancelBtn:SetScript("OnClick", closeEditor)

  ---------------------------------------------------------------------------
  -- Save handler
  ---------------------------------------------------------------------------
  edSaveBtn:SetScript("OnClick", function()
    profile = Resonance.db.profile
    local sid = tonumber(edSpellIDBox:GetText())
    if not sid or sid <= 0 then
      Resonance.msg(L["Enter a valid spell ID."])
      return
    end
    -- Derive trigger from tab enabled states (need this before validation)
    local trigger
    if editorSound == false then
      -- Mute-only: no trigger needed
      trigger = nil
    elseif editorCastEnabled and editorPrecastEnabled then
      trigger = "precast_and_cast"
    elseif editorPrecastEnabled then
      trigger = "precast"
    else
      trigger = nil -- "cast" is the default, saved as nil
    end

    -- When precast-only, Core uses cfg.sound for the precast phase.
    -- Map the precast tab's sound to the main sound field.
    local saveSound = editorSound
    if trigger == "precast" then
      saveSound = editorPrecastSound or editorSound
    end

    -- Validate: must have a sound configured (or be mute-only)
    if saveSound == nil then
      Resonance.msg(L["Select a replacement sound or enable 'Mute only'."])
      return
    end

    local isNew = not profile.spell_config[sid]
    -- Build exclusions table (only save if non-empty)
    local exclusions = nil
    for fid in pairs(editorExclusions) do
      if not exclusions then
        exclusions = {}
      end
      exclusions[fid] = true
    end
    -- Only save precast fields in precast_and_cast mode (precast-only uses the main sound field)
    local precastSound = (trigger == "precast_and_cast") and editorPrecastSound or nil
    local precastDuration = (trigger == "precast_and_cast") and editorPrecastDuration or nil
    local precastLoop = (trigger == "precast_and_cast") and editorPrecastLoop or nil
    -- Impact list (base): only saved when the tab is enabled and non-empty.
    -- Deep-copy so later edits in the editor don't mutate the saved entry.
    local impactsSave = nil
    if editorImpactEnabled and #editorImpacts > 0 then
      impactsSave = {}
      for _, entry in ipairs(editorImpacts) do
        impactsSave[#impactsSave + 1] = {
          delay = entry.delay,
          sound = entry.sound,
          duration = entry.duration,
        }
      end
    end
    -- Build faction bundles: editor-managed fields (sound, precastSound)
    -- combined with slash-command-managed fields (impacts, duration, loop)
    -- that we preserved on open. Bundle is nil if empty so migration /
    -- cleanup stays simple.
    local function buildFactionBundle(sound, precastSound, preservedImpacts, preservedExtras)
      if
        sound == nil
        and precastSound == nil
        and (not preservedImpacts or #preservedImpacts == 0)
        and (not preservedExtras or next(preservedExtras) == nil)
      then
        return nil
      end
      local bundle = {}
      if sound ~= nil then
        bundle.sound = sound
      end
      if precastSound ~= nil then
        bundle.precastSound = precastSound
      end
      if preservedImpacts and #preservedImpacts > 0 then
        bundle.impacts = preservedImpacts
      end
      if preservedExtras then
        if preservedExtras.duration then
          bundle.duration = preservedExtras.duration
        end
        if preservedExtras.precastDuration then
          bundle.precastDuration = preservedExtras.precastDuration
        end
        if preservedExtras.loop then
          bundle.loop = preservedExtras.loop
        end
        if preservedExtras.precastLoop then
          bundle.precastLoop = preservedExtras.precastLoop
        end
      end
      return bundle
    end
    local hordeBundle = buildFactionBundle(
      editorFaction.hordeSound,
      editorFaction.hordePrecastSound,
      editorFaction.preservedHordeImpacts,
      editorFaction.preservedHordeExtras
    )
    local allianceBundle = buildFactionBundle(
      editorFaction.allianceSound,
      editorFaction.alliancePrecastSound,
      editorFaction.preservedAllianceImpacts,
      editorFaction.preservedAllianceExtras
    )
    -- Build the config entry. Legacy cfg.hordeSound / cfg.allianceSound are
    -- deliberately NOT written — migration lifts any old data into the
    -- nested bundle on next load. cfg.muteFIDs is preserved verbatim
    -- because templates populate it and the GUI doesn't edit it.
    local cfgEntry = {
      sound = saveSound,
      muteExclusions = exclusions,
      muteFIDs = editorFaction.preservedMuteFIDs,
      duration = editorDuration,
      loop = editorLoop,
      trigger = trigger,
      channel = editorChannel,
      precastSound = precastSound,
      precastDuration = precastDuration,
      precastLoop = precastLoop,
      multiHitWindow = editorMultiHitWindow,
      impacts = impactsSave,
      horde = hordeBundle,
      alliance = allianceBundle,
    }
    if isNew then
      profile.spell_config[sid] = cfgEntry
      Resonance.applyAutoMutesForSpell(sid)
      Resonance.applyPrecastMutes(sid)
    else
      Resonance.removeAutoMutesForSpell(sid)
      profile.spell_config[sid] = cfgEntry
      Resonance.applyAutoMutesForSpell(sid)
      -- Existing spell may have changed muteExclusions or muteFIDs, which
      -- can shift the early-FID set.  Rebuild precast mutes from scratch.
      if profile.mutePrecastSounds then
        Resonance.clearPrecastMutes()
        Resonance.applyPrecastMutes()
      end
    end
    Resonance.invalidateSpellNameIndex()
    closeEditor()
    refreshList()
  end)

  addBtn:SetScript("OnClick", function()
    openEditor(nil)
  end)

  clearPresetsBtn:SetScript("OnClick", function()
    profile = Resonance.db.profile
    local removed = Resonance:RemovePresetSpells()
    closeEditor()
    refreshList()
    Resonance.msg(L["Cleared %d preset spells."]:format(removed))
  end)

  clearAllSpellsBtn:SetScript("OnClick", function()
    profile = Resonance.db.profile
    for sid in pairs(profile.spell_config or {}) do
      Resonance.removeAutoMutesForSpell(sid)
    end
    wipe(profile.spell_config)
    wipe(profile.preset_spells)
    -- spell_config is now empty, so every precast-muted FID is orphaned.
    if profile.mutePrecastSounds then
      Resonance.clearPrecastMutes()
    end
    Resonance.invalidateSpellNameIndex()
    closeEditor()
    refreshList()
    Resonance.msg(L["Cleared all spell sound configurations."])
  end)

  -- Spell list rendering
  local listHeaders = {} -- reusable class header frames
  local collapsedGroups = {} -- { [groupKey] = true } for collapsed sections
  local collapsedInitialized = false -- set defaults on first render

  local function getOrCreateHeader(idx)
    if listHeaders[idx] then
      return listHeaders[idx]
    end
    local hdr = CreateFrame("Button", nil, listContainer)
    hdr:SetHeight(ROW_HEIGHT)
    local bg = hdr:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.2, 0.2, 0.25, 0.5)
    hdr.bg = bg
    hdr.arrow = hdr:CreateTexture(nil, "OVERLAY")
    hdr.arrow:SetSize(16, 16)
    hdr.arrow:SetPoint("LEFT", 4, 0)
    hdr.text = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    hdr.text:SetPoint("LEFT", hdr.arrow, "RIGHT", 2, 0)
    hdr.text:SetJustifyH("LEFT")
    hdr.count = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hdr.count:SetPoint("LEFT", hdr.text, "RIGHT", 6, 0)
    hdr.count:SetTextColor(0.6, 0.6, 0.6)
    -- Highlight on hover
    hdr.highlight = hdr:CreateTexture(nil, "HIGHLIGHT")
    hdr.highlight:SetAllPoints()
    hdr.highlight:SetColorTexture(1, 1, 1, 0.05)
    listHeaders[idx] = hdr
    return hdr
  end

  local function getOrCreateRow(idx)
    if listRows[idx] then
      return listRows[idx]
    end
    local row = CreateFrame("Frame", nil, listContainer)
    row:SetHeight(ROW_HEIGHT)
    listRows[idx] = row

    row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.nameText:SetPoint("LEFT", 4, 0)
    row.nameText:SetWidth(200)
    row.nameText:SetJustifyH("LEFT")
    row.nameText:SetWordWrap(false)

    row.soundText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.soundText:SetPoint("LEFT", row.nameText, "RIGHT", 8, 0)
    row.soundText:SetPoint("RIGHT", row, "RIGHT", -58, 0)
    row.soundText:SetJustifyH("LEFT")
    row.soundText:SetWordWrap(false)

    row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
    row.playBtn:SetPoint("RIGHT", row, "RIGHT", -36, 0)

    row.editBtn = makeIconButton(row, "Interface\\WorldMap\\GEAR_64GREY", 20, L["Edit"])
    row.editBtn:SetPoint("RIGHT", row, "RIGHT", -18, 0)

    row.delBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 18, L["Delete"])
    row.delBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0)

    return row
  end

  refreshList = function()
    profile = Resonance.db.profile
    -- Keep the class-preset button label / tooltip in sync with the current
    -- preset_spells state. refreshList is called after every mutation that
    -- could change whether the class preset is loaded (apply, clear, remove,
    -- save).
    updateClassPresetBtn()
    for _, row in ipairs(listRows) do
      row:Hide()
    end
    for _, hdr in ipairs(listHeaders) do
      hdr:Hide()
    end

    -- Group spells by source, only showing the player's class and custom spells
    local groups = {} -- source -> { {spellID, cfg}, ... }
    local groupSet = {} -- track which sources exist
    for sid, cfg in pairs(profile.spell_config or {}) do
      if cfg.sound ~= nil then
        local source = profile.preset_spells and profile.preset_spells[sid]
        local key = source or "_custom"
        -- Only show player's own class, saved presets, and custom spells
        if key == playerClass or key == "_custom" or not CLASS_DISPLAY[key] then
          if not groups[key] then
            groups[key] = {}
            groupSet[key] = true
          end
          groups[key][#groups[key] + 1] = { spellID = sid, cfg = cfg }
        end
      end
    end

    -- Sort spells within each group by spell name
    for _, spells in pairs(groups) do
      table.sort(spells, function(a, b)
        local na = Resonance.getSpellName(a.spellID) or ""
        local nb = Resonance.getSpellName(b.spellID) or ""
        return na < nb
      end)
    end

    -- Build ordered list of group keys: player's class first, then saved presets, then custom
    local orderedKeys = {}
    if groupSet[playerClass] then
      orderedKeys[#orderedKeys + 1] = playerClass
      groupSet[playerClass] = nil
    end
    local extras = {}
    for key in pairs(groupSet) do
      if key ~= "_custom" then
        extras[#extras + 1] = key
      end
    end
    table.sort(extras)
    for _, key in ipairs(extras) do
      orderedKeys[#orderedKeys + 1] = key
    end
    if groups["_custom"] then
      orderedKeys[#orderedKeys + 1] = "_custom"
    end

    -- Default: everything collapsed
    if not collapsedInitialized and #orderedKeys > 0 then
      for _, key in ipairs(orderedKeys) do
        collapsedGroups[key] = true
      end
      collapsedInitialized = true
    end

    -- Render grouped list
    local yOff = 0
    local rowIdx = 0
    local hdrIdx = 0
    local totalSpells = 0

    for _, key in ipairs(orderedKeys) do
      local spells = groups[key]
      local collapsed = collapsedGroups[key]

      -- Class/group header
      hdrIdx = hdrIdx + 1
      local hdr = getOrCreateHeader(hdrIdx)
      hdr:ClearAllPoints()
      hdr:SetPoint("TOPLEFT", listContainer, "TOPLEFT", 0, -yOff)
      hdr:SetPoint("RIGHT", listContainer, "RIGHT", 0, 0)

      local displayName = CLASS_DISPLAY[key] or (key == "_custom" and L["Custom"] or key)
      local cc = RAID_CLASS_COLORS and RAID_CLASS_COLORS[key]
      if cc then
        hdr.text:SetText(cc:WrapTextInColorCode(displayName))
      else
        hdr.text:SetText(displayName)
      end
      hdr.arrow:SetTexture(
        collapsed and "Interface\\Buttons\\UI-PlusButton-UP" or "Interface\\Buttons\\UI-MinusButton-UP"
      )
      hdr.count:SetText("(" .. #spells .. ")")
      local hdrKey = key
      hdr:SetScript("OnClick", function()
        collapsedGroups[hdrKey] = not collapsedGroups[hdrKey]
        refreshList()
      end)
      hdr:Show()
      yOff = yOff + ROW_HEIGHT

      -- Spell rows (skip if collapsed)
      if not collapsed then
        for i, entry in ipairs(spells) do
          rowIdx = rowIdx + 1
          totalSpells = totalSpells + 1
          local row = getOrCreateRow(rowIdx)
          row:ClearAllPoints()
          row:SetPoint("TOPLEFT", listContainer, "TOPLEFT", 0, -yOff)
          row:SetPoint("RIGHT", listContainer, "RIGHT", 0, 0)

          if i % 2 == 0 then
            if not row.stripe then
              row.stripe = row:CreateTexture(nil, "BACKGROUND")
              row.stripe:SetAllPoints()
              row.stripe:SetColorTexture(1, 0.82, 0, 0.08)
            end
            row.stripe:Show()
          elseif row.stripe then
            row.stripe:Hide()
          end

          local spellName = Resonance.getSpellName(entry.spellID) or "?"
          row.nameText:SetText(spellName .. " |cff888888(" .. entry.spellID .. ")|r")

          local cfg = entry.cfg
          local sound = cfg.sound
          if sound == false then
            row.soundText:SetText("|cffff8800" .. L["Muted (no replacement)"] .. "|r")
            row.playBtn:SetEnabled(false)
          elseif type(sound) == "table" then
            local parts = {}
            if sound.sequence then
              parts[#parts + 1] = L["sequence: %d sounds"]:format(#sound)
            else
              for _, s in ipairs(sound) do
                parts[#parts + 1] = formatSoundBrief(s)
              end
              if sound.random then
                parts[#parts + 1] = L["+1 random from %d"]:format(#sound.random)
              end
            end
            row.soundText:SetText("|cff00ff00" .. table.concat(parts, ", ") .. "|r")
            row.playBtn:SetEnabled(true)
            local cfgSound = cfg.sound
            wirePlayStop(row.playBtn, function()
              return cfgSound
            end)
          elseif type(sound) == "number" then
            local path = lookupFIDPath(sound)
            if path then
              row.soundText:SetText(formatSoundDisplay(path, sound))
            else
              row.soundText:SetText("|cff00ff00FID:" .. sound .. "|r")
            end
            row.playBtn:SetEnabled(true)
            local cfgSound = cfg.sound
            wirePlayStop(row.playBtn, function()
              return cfgSound
            end)
          else
            local filename = tostring(sound):match("[^/\\]+$") or tostring(sound)
            row.soundText:SetText("|cff00ff00" .. filename .. "|r")
            row.playBtn:SetEnabled(true)
            local cfgSound = cfg.sound
            wirePlayStop(row.playBtn, function()
              return cfgSound
            end)
          end
          -- Append duration and trigger indicators if configured
          local indicators = ""
          if cfg.duration then
            indicators = indicators .. ("%.1fs"):format(cfg.duration)
          end
          if cfg.loop then
            indicators = indicators .. (indicators ~= "" and ", " or "") .. "loop"
          end
          if cfg.trigger == "precast" then
            indicators = indicators .. (indicators ~= "" and ", " or "") .. "precast"
          elseif cfg.trigger == "precast_and_cast" then
            indicators = indicators .. (indicators ~= "" and ", " or "") .. "precast+cast"
          end
          if cfg.channel then
            indicators = indicators .. (indicators ~= "" and ", " or "") .. cfg.channel
          end
          if cfg.multiHitWindow then
            indicators = indicators .. (indicators ~= "" and ", " or "") .. ("%.1fs window"):format(cfg.multiHitWindow)
          end
          if indicators ~= "" then
            local cur = row.soundText:GetText() or ""
            row.soundText:SetText(cur .. " |cffaaaaaa(" .. indicators .. ")|r")
          end
          row.editBtn:SetScript("OnClick", function()
            openEditor(entry.spellID)
          end)
          row.delBtn:SetScript("OnClick", function()
            local prof = Resonance.db.profile
            Resonance.removeAutoMutesForSpell(entry.spellID)
            if prof.preset_spells[entry.spellID] then
              if not prof.removed_template_spells then
                prof.removed_template_spells = {}
              end
              prof.removed_template_spells[entry.spellID] = true
            end
            prof.spell_config[entry.spellID] = nil
            prof.preset_spells[entry.spellID] = nil
            -- Early FIDs are shared across same-school spells, so removing
            -- one may or may not free its FIDs depending on what other
            -- configured spells claim.  Rebuild from scratch.
            if prof.mutePrecastSounds then
              Resonance.clearPrecastMutes()
              Resonance.applyPrecastMutes()
            end
            Resonance.invalidateSpellNameIndex()
            closeEditor()
            refreshList()
          end)

          row:Show()
          yOff = yOff + ROW_HEIGHT
        end
      else
        totalSpells = totalSpells + #spells
      end
    end

    local totalH = math.max(yOff, ROW_HEIGHT)
    listContainer:SetHeight(totalH)
    listEmpty:SetShown(totalSpells == 0)
    recalcContentHeight(2)
  end

  -- Publish to ctx
  ctx.refreshList = refreshList
  ctx.editorFrame = editorFrame
  ctx.edBrowseDD = edBrowseDD
  ctx.edSpellSearchDD = edSpellSearchDD
  ctx.precastBrowseDD = precastBrowseDD
  ctx.closeEditor = closeEditor
end

---------------------------------------------------------------------------
-- Tab 3: Muted Sounds
---------------------------------------------------------------------------
buildTab3_MutedSounds = function(ctx)
  local recalcContentHeight = ctx.recalcContentHeight
  local playerClass = ctx.playerClass
  local profile = Resonance.db.profile

  local muteTab = ctx.mutedSoundsContent

  -- Data unavailable banner
  local muteBanner
  if not ctx.dataLoaded then
    muteBanner = showDataUnavailableBanner(muteTab, select(2, Resonance.loadDataAddon()))
  end

  -- Tab heading
  local muteTabHdr = muteTab:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  muteTabHdr:SetPoint(
    "TOPLEFT",
    muteBanner and muteBanner or muteTab,
    muteBanner and "BOTTOMLEFT" or "TOPLEFT",
    16,
    muteBanner and -12 or -16
  )
  muteTabHdr:SetText(L["Sound Browser"])

  local muteTabDesc = muteTab:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  muteTabDesc:SetPoint("TOPLEFT", muteTabHdr, "BOTTOMLEFT", 0, -6)
  muteTabDesc:SetPoint("RIGHT", muteTab, "RIGHT", -16, 0)
  muteTabDesc:SetJustifyH("LEFT")
  muteTabDesc:SetSpacing(3)
  muteTabDesc:SetText(
    L["Browse and mute individual sounds by type. Search spell sounds, character vocalizations, NPC sounds, or enter a FileDataID directly."]
  )

  local muteSpellRadio = makeRadio(muteTab)
  muteSpellRadio:SetPoint("TOPLEFT", muteTabDesc, "BOTTOMLEFT", 0, -12)
  muteSpellRadio.label:SetText(L["Spell Sounds"])

  local muteCharRadio = makeRadio(muteTab)
  muteCharRadio:SetPoint("LEFT", muteSpellRadio.label, "RIGHT", 10, 0)
  muteCharRadio.label:SetText(L["Character Sounds"])

  local muteNPCRadio = makeRadio(muteTab)
  muteNPCRadio:SetPoint("LEFT", muteCharRadio.label, "RIGHT", 10, 0)
  muteNPCRadio.label:SetText(L["NPC"])

  local muteMountRadio = makeRadio(muteTab)
  muteMountRadio:SetPoint("LEFT", muteNPCRadio.label, "RIGHT", 10, 0)
  muteMountRadio.label:SetText(L["Mount"])

  local muteFidRadio = makeRadio(muteTab)
  muteFidRadio.label:SetText(L["FID"])
  -- NPC mode is retail-only (Creature DB2 isn't populated on wago.tools for TBC).
  -- Mount mode works on TBC via the cross-flavor MountSoundData_TBC bridge.
  if Resonance.IS_RETAIL then
    muteFidRadio:SetPoint("LEFT", muteMountRadio.label, "RIGHT", 10, 0)
  else
    muteNPCRadio:Hide()
    muteMountRadio:ClearAllPoints()
    muteMountRadio:SetPoint("LEFT", muteCharRadio.label, "RIGHT", 10, 0)
    muteFidRadio:SetPoint("LEFT", muteMountRadio.label, "RIGHT", 10, 0)
  end

  local muteMode = "spells"

  local muteSearchFrame = CreateFrame("Frame", nil, muteTab)
  muteSearchFrame:SetPoint("TOPLEFT", muteSpellRadio, "BOTTOMLEFT", 0, -4)
  muteSearchFrame:SetSize(CONTENT_WIDTH, 26)

  local muteSearchBox =
    makeEditBox(muteSearchFrame, CONTENT_WIDTH - 120, muteSearchFrame, 0, 0, L["Search sounds to mute..."])
  muteSearchBox:SetPoint("TOPLEFT", muteSearchFrame, "TOPLEFT", 0, 0)

  local refreshMuteList -- forward declaration

  local myVoxBtn = makeButton(muteTab, L["My Vox"], 60, function()
    muteCharRadio:Click()
    muteSearchBox:SetText(getPlayerVoxKey())
    muteSearchBox:SetFocus()
  end)
  myVoxBtn:SetPoint("LEFT", muteSearchBox, "RIGHT", 24, 0)

  local muteFidFrame = CreateFrame("Frame", nil, muteTab)
  muteFidFrame:SetPoint("TOPLEFT", muteSpellRadio, "BOTTOMLEFT", 0, -4)
  muteFidFrame:SetSize(CONTENT_WIDTH, 26)
  muteFidFrame:Hide()

  local muteFidBox = makeEditBox(muteFidFrame, 120, muteFidFrame, 0, 0, "FileDataID")
  muteFidBox:SetPoint("TOPLEFT", muteFidFrame, "TOPLEFT", 0, 0)
  muteFidBox:SetNumeric(true)

  local muteFidPlayBtn =
    makeIconButton(muteFidFrame, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
  wirePlayStop(muteFidPlayBtn, function()
    return tonumber(muteFidBox:GetText())
  end)
  muteFidPlayBtn:SetPoint("LEFT", muteFidBox, "RIGHT", 4, 0)

  local muteFidAddBtn = makeButton(muteFidFrame, L["+ Mute"], 52, function()
    local fid = tonumber(muteFidBox:GetText())
    if fid then
      Resonance.db.profile.mute_file_data_ids[fid] = true
      MuteSoundFile(fid)
      muteFidBox:SetText("")
      refreshMuteList()
    end
  end)
  muteFidAddBtn:SetPoint("LEFT", muteFidPlayBtn, "RIGHT", 2, 0)

  -- NPC muting helpers
  local npcNameCache
  local function lookupNPCName(npcID)
    if not npcNameCache then
      npcNameCache = {}
      for _, entry in ipairs(Resonance.NPCSoundIndex or {}) do
        local name, id = entry:match("^(.+)#(-?%d+)$")
        if name and id then
          npcNameCache[tonumber(id)] = name
        end
      end
    end
    return npcNameCache[npcID] or tostring(npcID)
  end

  local function isNPCMuted(npcID)
    return Resonance.db.profile.mutedNPCs and Resonance.db.profile.mutedNPCs[npcID]
  end

  local function getNPCFIDCount(npcID)
    local n = 0
    local NPCSoundCSD = Resonance.NPCSoundCSD
    if NPCSoundCSD then
      local NPCRepCSDs = Resonance.NPCRepCSDs
      local csdList = NPCRepCSDs and NPCRepCSDs[npcID]
      if csdList then
        for cs in csdList:gmatch("%d+") do
          local packed = NPCSoundCSD[tonumber(cs)]
          if packed then
            for _ in packed:gmatch("%d+") do
              n = n + 1
            end
          end
        end
      else
        local NPCToCSD = Resonance.NPCToCSD
        local csd = NPCToCSD and NPCToCSD[npcID]
        local packed = csd and NPCSoundCSD[csd]
        if packed then
          for _ in packed:gmatch("%d+") do
            n = n + 1
          end
        end
      end
    end
    local NPCVoiceData = Resonance.NPCVoiceData
    if NPCVoiceData then
      local vo = NPCVoiceData[npcID]
      if vo then
        for _ in vo:gmatch("%d+") do
          n = n + 1
        end
      end
    end
    return n
  end

  local function muteNPC(npcID)
    local p = Resonance.db.profile
    if not p.mutedNPCs then
      p.mutedNPCs = {}
    end
    p.mutedNPCs[npcID] = true
    Resonance.refreshNPCMutes()
    refreshMuteList()
  end

  local function unmuteNPC(npcID)
    local p = Resonance.db.profile
    if p.mutedNPCs then
      p.mutedNPCs[npcID] = nil
    end
    Resonance.refreshNPCMutes()
    refreshMuteList()
  end

  -- Mount muting helpers
  local mountNameCache
  local function lookupMountName(mountID)
    if not mountNameCache then
      mountNameCache = {}
      for _, entry in ipairs(Resonance.MountSoundIndex or {}) do
        local name, id = entry:match("^(.+)#(%d+)$")
        if name and id then
          mountNameCache[tonumber(id)] = name
        end
      end
    end
    return mountNameCache[mountID] or tostring(mountID)
  end

  local function isMountMuted(mountID)
    return Resonance.db.profile.mutedMounts and Resonance.db.profile.mutedMounts[mountID]
  end

  local function getMountFIDCount(mountID)
    local n = 0
    local MountSoundCSD = Resonance.MountSoundCSD
    local MountToCSD = Resonance.MountToCSD
    if MountSoundCSD and MountToCSD then
      local csdRef = MountToCSD[mountID]
      if csdRef then
        local csdStr = type(csdRef) == "number" and tostring(csdRef) or csdRef
        for cs in tostring(csdStr):gmatch("%d+") do
          local packed = MountSoundCSD[tonumber(cs)]
          if packed then
            for _ in packed:gmatch("%d+") do
              n = n + 1
            end
          end
        end
      end
    end
    return n
  end

  local function muteMount(mountID)
    local p = Resonance.db.profile
    if not p.mutedMounts then
      p.mutedMounts = {}
    end
    p.mutedMounts[mountID] = true
    Resonance.refreshMountMutes()
    refreshMuteList()
  end

  local function unmuteMount(mountID)
    local p = Resonance.db.profile
    if p.mutedMounts then
      p.mutedMounts[mountID] = nil
    end
    Resonance.refreshMountMutes()
    refreshMuteList()
  end

  local muteDD
  muteDD = createAutocomplete(muteSearchBox, function(e)
    if e then
      return e.fileDataID
    end
  end, function(e)
    if e then
      if muteMode == "npc" then
        muteNPC(e.fileDataID) -- fileDataID is actually npcID in NPC mode
      elseif muteMode == "mount" then
        muteMount(e.fileDataID) -- fileDataID is actually mountID in mount mode
      else
        Resonance.db.profile.mute_file_data_ids[e.fileDataID] = true
        MuteSoundFile(e.fileDataID)
        refreshMuteList()
      end
    end
  end, { label = L["+ Mute"], icon = "Interface\\Buttons\\UI-GroupLoot-Pass-Down" }, CONTENT_WIDTH)

  function muteDD:onRowRefresh(row, entry)
    if muteMode == "npc" then
      local muted = isNPCMuted(entry.fileDataID)
      row.actionBtn:SetEnabled(not muted)
      row.actionBtn.tooltipText = muted and L["Muted"] or L["+ Mute"]
    elseif muteMode == "mount" then
      local muted = isMountMuted(entry.fileDataID)
      row.actionBtn:SetEnabled(not muted)
      row.actionBtn.tooltipText = muted and L["Muted"] or L["+ Mute"]
    else
      local muted = isFIDMuted(entry.fileDataID)
      row.actionBtn:SetEnabled(not muted)
      row.actionBtn.tooltipText = muted and L["Muted"] or L["+ Mute"]
    end
  end

  local function doMuteSearch()
    if not muteSearchBox:HasFocus() then
      return
    end
    local q = muteSearchBox:GetText()
    if #q < 3 then
      muteDD:SetData({}, "")
      muteDD:Hide()
      return
    end
    if muteMode == "npc" then
      searchDB(Resonance.NPCSoundIndex, q, "muteSearch", function(results)
        for _, r in ipairs(results) do
          local npcID = r.fileDataID
          local count = getNPCFIDCount(npcID)
          local displayName = r.path
          r.display = displayName
          if isNPCMuted(npcID) then
            r.display = "|cff888888" .. displayName .. "|r"
          end
          r.subdisplay = "NPC " .. npcID .. "  \194\183  " .. count .. " " .. L["sounds"]
        end
        muteDD:SetData(results, #results == 0 and L["No matches."] or L["%d results"]:format(#results))
      end, nil, Resonance.NPCSoundL10N)
    elseif muteMode == "mount" then
      searchDB(Resonance.MountSoundIndex, q, "muteSearch", function(results)
        for _, r in ipairs(results) do
          local mountID = r.fileDataID
          local count = getMountFIDCount(mountID)
          local displayName = r.path
          r.display = displayName
          if isMountMuted(mountID) then
            r.display = "|cff888888" .. displayName .. "|r"
          end
          r.subdisplay = "Mount " .. mountID .. "  \194\183  " .. count .. " " .. L["sounds"]
        end
        muteDD:SetData(results, #results == 0 and L["No matches."] or L["%d results"]:format(#results))
      end)
    else
      local searchTarget = (muteMode == "character") and Resonance.CharacterSounds or Resonance.SpellSounds
      local prefixPool = (muteMode == "character") and Resonance.CharacterSoundPrefixes or Resonance.SpellSoundPrefixes
      searchDB(searchTarget, q, "muteSearch", function(results)
        for _, r in ipairs(results) do
          local filename = r.path:match("([^/\\]+)$") or r.path
          local parent = r.path:match("([^/\\]+)[/\\][^/\\]+$")
          r.display = filename
          if isFIDMuted(r.fileDataID) then
            r.display = "|cff888888" .. filename .. "|r"
          end
          r.subdisplay = (parent and (parent .. "/  \194\183  ") or "") .. "#" .. r.fileDataID
        end
        muteDD:SetData(results, #results == 0 and L["No matches."] or L["%d results"]:format(#results))
      end, prefixPool)
    end
  end

  local muteSearchClear = makeClearButton(muteSearchBox, function()
    muteDD:Hide()
  end)

  muteSearchBox:SetScript("OnTextChanged", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
    end
    muteSearchClear:SetShown(self:GetText() ~= "")
    debounce("muteSearch", 0.3, doMuteSearch)
  end)
  muteSearchBox:SetScript("OnEditFocusGained", function(self)
    if self.placeholder then
      self.placeholder:Hide()
    end
    doMuteSearch()
  end)
  muteSearchBox:SetScript("OnEditFocusLost", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "")
    end
    C_Timer.After(0.2, function()
      if not muteSearchBox:HasFocus() and not muteDD:IsMouseOver() then
        muteDD:Hide()
      end
    end)
  end)
  muteSearchBox:SetScript("OnEnterPressed", function(self)
    self:ClearFocus()
  end)
  muteSearchBox:SetScript("OnEscapePressed", function(self)
    muteDD:Hide()
    self:ClearFocus()
  end)

  local function setMuteMode(mode)
    muteMode = mode
    muteSpellRadio:SetChecked(mode == "spells")
    muteCharRadio:SetChecked(mode == "character")
    muteNPCRadio:SetChecked(mode == "npc")
    muteMountRadio:SetChecked(mode == "mount")
    muteFidRadio:SetChecked(mode == "fid")
    muteSearchFrame:SetShown(mode ~= "fid")
    myVoxBtn:SetShown(mode == "character")
    muteFidFrame:SetShown(mode == "fid")
    if mode == "npc" then
      muteSearchBox.placeholder:SetText(L["Search NPC by name..."])
    elseif mode == "mount" then
      muteSearchBox.placeholder:SetText(L["Search mount by name..."])
    else
      muteSearchBox.placeholder:SetText(L["Search sounds to mute..."])
    end
    muteDD:Hide()
  end
  muteSpellRadio:SetScript("OnClick", function()
    setMuteMode("spells")
  end)
  muteCharRadio:SetScript("OnClick", function()
    setMuteMode("character")
  end)
  muteNPCRadio:SetScript("OnClick", function()
    setMuteMode("npc")
  end)
  muteMountRadio:SetScript("OnClick", function()
    setMuteMode("mount")
  end)
  muteFidRadio:SetScript("OnClick", function()
    setMuteMode("fid")
  end)

  -- Muted sounds list
  local clearAllBtn = makeButton(muteTab, L["Clear All Manual"], 120, function()
    local p = Resonance.db.profile
    local manualFids = {}
    for fid, enabled in pairs(p.mute_file_data_ids) do
      if enabled then
        manualFids[#manualFids + 1] = fid
      end
    end
    wipe(p.mute_file_data_ids)
    for _, fid in ipairs(manualFids) do
      if not isFIDMuted(fid) then
        UnmuteSoundFile(fid)
      end
    end
    refreshMuteList()
  end)

  -- Manual section heading (shown above table header when manual mutes exist)
  local manualSectionHdr = CreateFrame("Frame", nil, muteTab)
  manualSectionHdr:SetHeight(ROW_HEIGHT + 4)
  manualSectionHdr:SetPoint("TOPLEFT", muteSearchFrame, "BOTTOMLEFT", 0, -10)
  manualSectionHdr:SetPoint("RIGHT", muteTab, "RIGHT", -4, 0)
  manualSectionHdr:Hide()

  local manualSectionText = manualSectionHdr:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  manualSectionText:SetPoint("LEFT", 0, 0)
  manualSectionText:SetText(L["Manually muted sounds"])

  -- Table header (matching Spell Sounds tab style)
  local muteTableHeader = CreateFrame("Frame", nil, muteTab)
  muteTableHeader:SetHeight(ROW_HEIGHT)
  muteTableHeader:SetPoint("TOPLEFT", muteSearchFrame, "BOTTOMLEFT", 0, -10)
  muteTableHeader:SetPoint("RIGHT", muteTab, "RIGHT", -4, 0)

  local muteHeaderBg = muteTableHeader:CreateTexture(nil, "BACKGROUND")
  muteHeaderBg:SetAllPoints()
  muteHeaderBg:SetColorTexture(0.15, 0.15, 0.18, 0.6)

  local muteHdrSound = muteTableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  muteHdrSound:SetPoint("LEFT", 4, 0)
  muteHdrSound:SetPoint("RIGHT", muteTableHeader, "RIGHT", -88, 0)
  muteHdrSound:SetJustifyH("LEFT")
  muteHdrSound:SetText(L["Sound"])

  local muteHdrActions = muteTableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  muteHdrActions:SetPoint("RIGHT", muteTableHeader, "RIGHT", -2, 0)
  muteHdrActions:SetJustifyH("RIGHT")
  muteHdrActions:SetText(L["Actions"])

  local muteListContainer = CreateFrame("Frame", nil, muteTab)
  muteListContainer:SetPoint("TOPLEFT", muteTableHeader, "BOTTOMLEFT", 0, -2)
  muteListContainer:SetPoint("RIGHT", muteTab, "RIGHT", -4, 0)
  muteListContainer:SetHeight(ROW_HEIGHT)

  local muteListEmpty = muteListContainer:CreateFontString(nil, "OVERLAY", "GameFontDisable")
  muteListEmpty:SetPoint("TOPLEFT", 4, 0)
  muteListEmpty:SetText(L["No sounds muted."])

  local muteListRows = {}
  local muteListHeaders = {}
  local muteCollapsedGroups = {}
  local muteCollapsedInitialized = false

  local function getOrCreateMuteHeader(idx)
    if muteListHeaders[idx] then
      return muteListHeaders[idx]
    end
    local hdr = CreateFrame("Button", nil, muteListContainer)
    hdr:SetHeight(ROW_HEIGHT)
    local bg = hdr:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.2, 0.2, 0.25, 0.5)
    hdr.bg = bg
    hdr.arrow = hdr:CreateTexture(nil, "OVERLAY")
    hdr.arrow:SetSize(16, 16)
    hdr.arrow:SetPoint("LEFT", 4, 0)
    hdr.text = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    hdr.text:SetPoint("LEFT", hdr.arrow, "RIGHT", 2, 0)
    hdr.text:SetJustifyH("LEFT")
    hdr.count = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hdr.count:SetPoint("LEFT", hdr.text, "RIGHT", 6, 0)
    hdr.count:SetTextColor(0.6, 0.6, 0.6)
    hdr.highlight = hdr:CreateTexture(nil, "HIGHLIGHT")
    hdr.highlight:SetAllPoints()
    hdr.highlight:SetColorTexture(1, 1, 1, 0.05)
    muteListHeaders[idx] = hdr
    return hdr
  end

  local function getOrCreateMuteRow(idx)
    local row = muteListRows[idx]
    if not row then
      row = CreateFrame("Frame", nil, muteListContainer)
      row:SetHeight(ROW_HEIGHT)
      muteListRows[idx] = row
      row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
      row.text:SetPoint("LEFT", 8, 0)
      row.text:SetPoint("RIGHT", row, "RIGHT", -44, 0)
      row.text:SetJustifyH("LEFT")
      row.text:SetWordWrap(false)
      row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
      row.playBtn:SetPoint("RIGHT", row, "RIGHT", -20, 0)
      row.removeBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 18, L["Remove"])
      row.removeBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0)
    end
    return row
  end

  refreshMuteList = function()
    profile = Resonance.db.profile
    for _, row in ipairs(muteListRows) do
      row:Hide()
    end
    for _, hdr in ipairs(muteListHeaders) do
      hdr:Hide()
    end
    clearAllBtn:Hide()

    -- Collect all muted FIDs, tracking source
    local fidSet = {}
    for fid, enabled in pairs(profile.mute_file_data_ids or {}) do
      if enabled then
        fidSet[fid] = { source = "manual" }
      end
    end
    -- Build reverse lookup: FID -> { spellIDs, excluded } (only for current class)
    local autoFidInfo = {}
    for sid in pairs(profile.spell_config or {}) do
      if Resonance.shouldAutoMuteSpell(sid) then
        local fids = Resonance.getSpellMuteFIDs(sid)
        if fids then
          local excl = profile.spell_config[sid].muteExclusions
          for _, fid in ipairs(fids) do
            if not autoFidInfo[fid] then
              autoFidInfo[fid] = { spellIDs = {}, excluded = true }
            end
            autoFidInfo[fid].spellIDs[#autoFidInfo[fid].spellIDs + 1] = sid
            if not (excl and excl[fid]) then
              autoFidInfo[fid].excluded = false
            end
          end
        end
      end
    end
    for fid, info in pairs(autoFidInfo) do
      if fidSet[fid] then
        fidSet[fid].source = info.excluded and fidSet[fid].source or "both"
      else
        fidSet[fid] = { source = info.excluded and "auto_excluded" or "auto" }
      end
      fidSet[fid].spellIDs = info.spellIDs
      fidSet[fid].excluded = info.excluded
    end

    -- Split into manual and auto entries
    local manualEntries = {}
    local autoEntries = {}
    for fid, info in pairs(fidSet) do
      local path = lookupFIDPath(fid)
      local sortKey = path and path:lower() or tostring(fid)
      local entry =
        { fid = fid, source = info.source, spellIDs = info.spellIDs, excluded = info.excluded, sortKey = sortKey }
      if info.source == "manual" then
        manualEntries[#manualEntries + 1] = entry
      else
        autoEntries[#autoEntries + 1] = entry
      end
    end
    table.sort(manualEntries, function(a, b)
      return a.sortKey < b.sortKey
    end)

    -- Group auto entries by class (from preset_spells), then by spell within each class
    -- Structure: classKey -> spellID -> { entries }
    local classSpellFids = {} -- classKey -> spellID -> { entries }

    for _, entry in ipairs(autoEntries) do
      if entry.spellIDs then
        -- Find the primary spell (first by preset class, then by name)
        local bestSid, bestClass
        for _, sid in ipairs(entry.spellIDs) do
          local cls = profile.preset_spells and profile.preset_spells[sid]
          if cls then
            bestSid = sid
            bestClass = cls
            break
          end
        end
        if not bestSid then
          bestSid = entry.spellIDs[1]
          bestClass = "_custom"
        end
        if not classSpellFids[bestClass] then
          classSpellFids[bestClass] = {}
        end
        if not classSpellFids[bestClass][bestSid] then
          classSpellFids[bestClass][bestSid] = {}
        end
        local group = classSpellFids[bestClass][bestSid]
        group[#group + 1] = entry
      end
    end

    -- Sort entries within each spell group
    for _, spellMap in pairs(classSpellFids) do
      for _, entries in pairs(spellMap) do
        table.sort(entries, function(a, b)
          return a.sortKey < b.sortKey
        end)
      end
    end

    -- Build ordered class keys: player's class first, then CLASS_ORDER, then extras, then _custom
    local orderedClassKeys = {}
    local classKeySet = {}
    for key in pairs(classSpellFids) do
      classKeySet[key] = true
    end

    if classKeySet[playerClass] then
      orderedClassKeys[#orderedClassKeys + 1] = playerClass
      classKeySet[playerClass] = nil
    end
    for _, cls in ipairs(CLASS_ORDER) do
      if classKeySet[cls] then
        orderedClassKeys[#orderedClassKeys + 1] = cls
        classKeySet[cls] = nil
      end
    end
    local extras = {}
    for key in pairs(classKeySet) do
      if key ~= "_custom" then
        extras[#extras + 1] = key
      end
    end
    table.sort(extras)
    for _, key in ipairs(extras) do
      orderedClassKeys[#orderedClassKeys + 1] = key
    end
    if classSpellFids["_custom"] then
      orderedClassKeys[#orderedClassKeys + 1] = "_custom"
    end

    -- Count FIDs per class
    local classCount = {}
    for cls, spellMap in pairs(classSpellFids) do
      local n = 0
      for _, entries in pairs(spellMap) do
        n = n + #entries
      end
      classCount[cls] = n
    end

    -- Default: everything collapsed
    if not muteCollapsedInitialized and #orderedClassKeys > 0 then
      for _, key in ipairs(orderedClassKeys) do
        muteCollapsedGroups[key] = true
      end
      muteCollapsedInitialized = true
    end

    -- Render
    local yOff = 0
    local rowIdx = 0
    local hdrIdx = 0
    local soundRowIdx = 0

    -- Manual mutes section
    if #manualEntries > 0 then
      -- Show section heading above table header, with Clear All button on the right
      manualSectionHdr:Show()
      clearAllBtn:SetParent(manualSectionHdr)
      clearAllBtn:ClearAllPoints()
      clearAllBtn:SetPoint("RIGHT", manualSectionHdr, "RIGHT", 0, 0)
      clearAllBtn:Show()
      muteTableHeader:ClearAllPoints()
      muteTableHeader:SetPoint("TOPLEFT", manualSectionHdr, "BOTTOMLEFT", 0, -2)
      muteTableHeader:SetPoint("RIGHT", muteTab, "RIGHT", -4, 0)

      for _, entry in ipairs(manualEntries) do
        soundRowIdx = soundRowIdx + 1
        rowIdx = rowIdx + 1
        local row = getOrCreateMuteRow(rowIdx)
        row:SetHeight(ROW_HEIGHT)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 0, -yOff)
        row:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)

        local fid = entry.fid
        row.text:SetFontObject(GameFontHighlightSmall)
        local path = lookupFIDPath(fid)
        local display
        if path then
          display = formatSoundDisplay(path, fid)
        else
          display = "|cffff8800" .. fid .. "|r"
        end
        row.text:SetText(display)
        row.text:SetPoint("LEFT", 8, 0)
        row.text:SetPoint("RIGHT", row, "RIGHT", -44, 0)

        row.playBtn:Show()
        local f = fid
        wirePlayStop(row.playBtn, function()
          return f
        end)
        if row.muteToggle then
          row.muteToggle:Hide()
        end
        row.removeBtn:Show()
        row.removeBtn:SetEnabled(true)
        row.removeBtn:SetScript("OnEnter", function(self)
          GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
          GameTooltip:AddLine(L["Unmute"], 1, 1, 1)
          GameTooltip:Show()
        end)
        row.removeBtn:SetScript("OnLeave", function()
          GameTooltip:Hide()
        end)
        row.removeBtn:SetScript("OnClick", function()
          profile.mute_file_data_ids[fid] = nil
          if not isFIDMuted(fid) then
            UnmuteSoundFile(fid)
          end
          refreshMuteList()
        end)

        if not row.stripe then
          row.stripe = row:CreateTexture(nil, "BACKGROUND")
          row.stripe:SetAllPoints()
          row.stripe:SetColorTexture(1, 1, 1, 0.06)
        end
        row.stripe:SetShown(soundRowIdx % 2 == 0)

        row:Show()
        yOff = yOff + ROW_HEIGHT
      end
    else
      manualSectionHdr:Hide()
      clearAllBtn:Hide()
      muteTableHeader:ClearAllPoints()
      muteTableHeader:SetPoint("TOPLEFT", muteSearchFrame, "BOTTOMLEFT", 0, -10)
      muteTableHeader:SetPoint("RIGHT", muteTab, "RIGHT", -4, 0)
    end

    -- Auto-muted class groups
    if #orderedClassKeys > 0 then
      rowIdx = rowIdx + 1
      local autoLabel = getOrCreateMuteRow(rowIdx)
      autoLabel:SetHeight(ROW_HEIGHT + 4)
      autoLabel:ClearAllPoints()
      autoLabel:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 0, -yOff)
      autoLabel:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)
      autoLabel.text:SetFontObject(GameFontNormal)
      autoLabel.text:SetPoint("LEFT", 0, 0)
      autoLabel.text:SetPoint("RIGHT", autoLabel, "RIGHT", -4, 0)
      autoLabel.text:SetText(L["Auto-muted from spell configurations"])
      autoLabel.playBtn:Hide()
      autoLabel.removeBtn:Hide()
      if autoLabel.muteToggle then
        autoLabel.muteToggle:Hide()
      end
      if autoLabel.stripe then
        autoLabel.stripe:Hide()
      end
      autoLabel:Show()
      yOff = yOff + ROW_HEIGHT + 4
    end

    for _, classKey in ipairs(orderedClassKeys) do
      local spellMap = classSpellFids[classKey]
      local collapsed = muteCollapsedGroups[classKey]

      -- Class header
      hdrIdx = hdrIdx + 1
      local hdr = getOrCreateMuteHeader(hdrIdx)
      hdr:ClearAllPoints()
      hdr:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 0, -yOff)
      hdr:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)

      local displayName = CLASS_DISPLAY[classKey] or (classKey == "_custom" and L["Custom"] or classKey)
      local cc = RAID_CLASS_COLORS and RAID_CLASS_COLORS[classKey]
      if cc then
        hdr.text:SetText(cc:WrapTextInColorCode(displayName))
      else
        hdr.text:SetText(displayName)
      end
      hdr.arrow:SetTexture(
        collapsed and "Interface\\Buttons\\UI-PlusButton-UP" or "Interface\\Buttons\\UI-MinusButton-UP"
      )
      hdr.count:SetText("(" .. (classCount[classKey] or 0) .. ")")
      local hdrKey = classKey
      hdr:SetScript("OnClick", function()
        muteCollapsedGroups[hdrKey] = not muteCollapsedGroups[hdrKey]
        refreshMuteList()
      end)
      hdr:Show()
      yOff = yOff + ROW_HEIGHT

      if not collapsed then
        -- Sort spells within class by name
        local sortedSpells = {}
        for sid in pairs(spellMap) do
          sortedSpells[#sortedSpells + 1] = { sid = sid, name = Resonance.getSpellName(sid) or tostring(sid) }
        end
        table.sort(sortedSpells, function(a, b)
          return a.name < b.name
        end)

        for _, spellInfo in ipairs(sortedSpells) do
          local entries = spellMap[spellInfo.sid]

          -- Spell sub-header
          rowIdx = rowIdx + 1
          local spellRow = getOrCreateMuteRow(rowIdx)
          spellRow:SetHeight(ROW_HEIGHT)
          spellRow:ClearAllPoints()
          spellRow:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 0, -yOff)
          spellRow:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)
          spellRow.text:SetFontObject(GameFontHighlightSmall)
          spellRow.text:SetPoint("LEFT", 8, 0)
          spellRow.text:SetPoint("RIGHT", spellRow, "RIGHT", -4, 0)
          spellRow.text:SetText("|cff66aaff" .. spellInfo.name .. "|r  |cff888888(" .. spellInfo.sid .. ")|r")
          spellRow.playBtn:Hide()
          spellRow.removeBtn:Hide()
          if spellRow.muteToggle then
            spellRow.muteToggle:Hide()
          end
          if spellRow.stripe then
            spellRow.stripe:Hide()
          end
          spellRow:Show()
          yOff = yOff + ROW_HEIGHT

          -- Sound entries for this spell
          for _, entry in ipairs(entries) do
            soundRowIdx = soundRowIdx + 1
            rowIdx = rowIdx + 1
            local row = getOrCreateMuteRow(rowIdx)
            row:SetHeight(ROW_HEIGHT)
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 0, -yOff)
            row:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)

            row.text:SetFontObject(GameFontHighlightSmall)
            local fid = entry.fid
            local display
            local path = lookupFIDPath(fid)
            if path then
              display = formatSoundDisplay(path, fid)
            else
              display = "|cffff8800" .. fid .. "|r"
            end
            if entry.excluded then
              display = "|cff888888[unmuted]|r " .. display
            end
            row.text:SetText(display)
            row.text:SetPoint("LEFT", 16, 0)
            row.text:SetPoint("RIGHT", row, "RIGHT", -114, 0)

            row.playBtn:Show()
            local f = fid
            wirePlayStop(row.playBtn, function()
              return f
            end)

            if not row.muteToggle then
              local mtMinW = math.max(btnTextWidth(L["Mute"]), btnTextWidth(L["Unmute"])) + 12
              row.muteToggle = makeButton(row, L["Unmute"], math.max(60, mtMinW), nil)
              row.muteToggle:SetPoint("RIGHT", row.playBtn, "LEFT", -4, 0)
            end

            row.removeBtn:Hide()
            row.muteToggle:Show()
            row.muteToggle:SetText(entry.excluded and L["Mute"] or L["Unmute"])
            row.muteToggle:SetScript("OnClick", function()
              profile = Resonance.db.profile
              if entry.excluded then
                -- Re-mute: remove this FID from exclusions only for the
                -- spells that actually reference it (avoids cross-preset
                -- side effects from iterating all spell_config entries).
                if entry.spellIDs then
                  for _, sid in ipairs(entry.spellIDs) do
                    local cfg = profile.spell_config[sid]
                    if cfg and cfg.muteExclusions then
                      cfg.muteExclusions[fid] = nil
                    end
                  end
                end
                Resonance.rebuildAutoMutes()
                MuteSoundFile(fid)
                Resonance.msg(L["Re-muted FID %d"]:format(fid))
              else
                -- Unmute: add this FID to exclusions only for the spells
                -- that reference it.
                if entry.spellIDs then
                  for _, sid in ipairs(entry.spellIDs) do
                    local cfg = profile.spell_config[sid]
                    if cfg then
                      if not cfg.muteExclusions then
                        cfg.muteExclusions = {}
                      end
                      cfg.muteExclusions[fid] = true
                    end
                  end
                end
                Resonance.rebuildAutoMutes()
                if not isFIDMuted(fid) then
                  UnmuteSoundFile(fid)
                end
                Resonance.msg(L["Unmuted FID %d"]:format(fid))
              end
              refreshMuteList()
            end)

            -- Striping
            if not row.stripe then
              row.stripe = row:CreateTexture(nil, "BACKGROUND")
              row.stripe:SetAllPoints()
              row.stripe:SetColorTexture(1, 1, 1, 0.06)
            end
            row.stripe:SetShown(soundRowIdx % 2 == 0)

            row:Show()
            yOff = yOff + ROW_HEIGHT
          end
        end
      end
    end

    -- Muted NPCs section
    local mutedNPCs = profile.mutedNPCs
    local npcEntries = {}
    if mutedNPCs and Resonance.NPCToCSD then
      for npcID in pairs(mutedNPCs) do
        local name = lookupNPCName(npcID)
        npcEntries[#npcEntries + 1] = { npcID = npcID, name = name, count = getNPCFIDCount(npcID) }
      end
      table.sort(npcEntries, function(a, b)
        return a.name:lower() < b.name:lower()
      end)
    end

    if #npcEntries > 0 then
      -- NPC section header
      rowIdx = rowIdx + 1
      local npcLabel = getOrCreateMuteRow(rowIdx)
      npcLabel:SetHeight(ROW_HEIGHT + 4)
      npcLabel:ClearAllPoints()
      npcLabel:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 0, -yOff)
      npcLabel:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)
      npcLabel.text:SetFontObject(GameFontNormal)
      npcLabel.text:SetPoint("LEFT", 0, 0)
      npcLabel.text:SetPoint("RIGHT", npcLabel, "RIGHT", -4, 0)
      npcLabel.text:SetText(L["Muted NPCs"])
      npcLabel.playBtn:Hide()
      npcLabel.removeBtn:Hide()
      if npcLabel.muteToggle then
        npcLabel.muteToggle:Hide()
      end
      if npcLabel.stripe then
        npcLabel.stripe:Hide()
      end
      npcLabel:Show()
      yOff = yOff + ROW_HEIGHT + 4

      for ni, npcEntry in ipairs(npcEntries) do
        rowIdx = rowIdx + 1
        local row = getOrCreateMuteRow(rowIdx)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 8, -yOff)
        row:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)
        row.text:SetFontObject(GameFontHighlightSmall)
        row.text:SetText(
          npcEntry.name .. " |cff888888(NPC " .. npcEntry.npcID .. ", " .. npcEntry.count .. " " .. L["sounds"] .. ")|r"
        )
        row.playBtn:Hide()
        row.removeBtn:Show()
        row.removeBtn:SetEnabled(true)
        if row.muteToggle then
          row.muteToggle:Hide()
        end
        local capturedNpcID = npcEntry.npcID
        row.removeBtn:SetScript("OnEnter", function(self)
          GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
          GameTooltip:AddLine(L["Unmute"], 1, 1, 1)
          GameTooltip:Show()
        end)
        row.removeBtn:SetScript("OnLeave", function()
          GameTooltip:Hide()
        end)
        row.removeBtn:SetScript("OnClick", function()
          unmuteNPC(capturedNpcID)
        end)
        if not row.stripe then
          row.stripe = row:CreateTexture(nil, "BACKGROUND")
          row.stripe:SetAllPoints()
          row.stripe:SetColorTexture(1, 1, 1, 0.06)
        end
        row.stripe:SetShown(ni % 2 == 0)
        row:Show()
        yOff = yOff + ROW_HEIGHT
      end
    end

    -- Muted Mounts section
    local mutedMounts = profile.mutedMounts
    local mountEntries = {}
    if mutedMounts and Resonance.MountToCSD then
      for mountID in pairs(mutedMounts) do
        local name = lookupMountName(mountID)
        mountEntries[#mountEntries + 1] = { mountID = mountID, name = name, count = getMountFIDCount(mountID) }
      end
      table.sort(mountEntries, function(a, b)
        return a.name:lower() < b.name:lower()
      end)
    end

    if #mountEntries > 0 then
      -- Mount section header
      rowIdx = rowIdx + 1
      local mountLabel = getOrCreateMuteRow(rowIdx)
      mountLabel:SetHeight(ROW_HEIGHT + 4)
      mountLabel:ClearAllPoints()
      mountLabel:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 0, -yOff)
      mountLabel:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)
      mountLabel.text:SetFontObject(GameFontNormal)
      mountLabel.text:SetPoint("LEFT", 0, 0)
      mountLabel.text:SetPoint("RIGHT", mountLabel, "RIGHT", -4, 0)
      mountLabel.text:SetText(L["Muted Mounts"])
      mountLabel.playBtn:Hide()
      mountLabel.removeBtn:Hide()
      if mountLabel.muteToggle then
        mountLabel.muteToggle:Hide()
      end
      if mountLabel.stripe then
        mountLabel.stripe:Hide()
      end
      mountLabel:Show()
      yOff = yOff + ROW_HEIGHT + 4

      for mi, mountEntry in ipairs(mountEntries) do
        rowIdx = rowIdx + 1
        local row = getOrCreateMuteRow(rowIdx)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", muteListContainer, "TOPLEFT", 8, -yOff)
        row:SetPoint("RIGHT", muteListContainer, "RIGHT", 0, 0)
        row.text:SetFontObject(GameFontHighlightSmall)
        row.text:SetText(
          mountEntry.name
            .. " |cff888888(Mount "
            .. mountEntry.mountID
            .. ", "
            .. mountEntry.count
            .. " "
            .. L["sounds"]
            .. ")|r"
        )
        row.playBtn:Hide()
        row.removeBtn:Show()
        row.removeBtn:SetEnabled(true)
        if row.muteToggle then
          row.muteToggle:Hide()
        end
        local capturedMountID = mountEntry.mountID
        row.removeBtn:SetScript("OnEnter", function(self)
          GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
          GameTooltip:AddLine(L["Unmute"], 1, 1, 1)
          GameTooltip:Show()
        end)
        row.removeBtn:SetScript("OnLeave", function()
          GameTooltip:Hide()
        end)
        row.removeBtn:SetScript("OnClick", function()
          unmuteMount(capturedMountID)
        end)
        if not row.stripe then
          row.stripe = row:CreateTexture(nil, "BACKGROUND")
          row.stripe:SetAllPoints()
          row.stripe:SetColorTexture(1, 1, 1, 0.06)
        end
        row.stripe:SetShown(mi % 2 == 0)
        row:Show()
        yOff = yOff + ROW_HEIGHT
      end
    end

    for i = rowIdx + 1, #muteListRows do
      muteListRows[i]:Hide()
    end
    for i = hdrIdx + 1, #muteListHeaders do
      muteListHeaders[i]:Hide()
    end
    local totalH = math.max(yOff, ROW_HEIGHT)
    muteListContainer:SetHeight(totalH)
    muteListEmpty:SetShown(yOff == 0)
    recalcContentHeight(5)
  end

  setMuteMode("spells")

  -- Publish to ctx
  ctx.refreshMuteList = refreshMuteList
  ctx.muteDD = muteDD
end

---------------------------------------------------------------------------
-- Tab 4: Presets
---------------------------------------------------------------------------
buildTab4_Presets = function(ctx)
  local recalcContentHeight = ctx.recalcContentHeight
  local playerClass = ctx.playerClass
  local profile = Resonance.db.profile

  local presetTab = ctx.presetsContent

  -- Export/Import dialog (shared by Presets tab)
  local eiFrame = CreateFrame("Frame", "ResonanceExportImport", UIParent, "BackdropTemplate")
  eiFrame:SetSize(480, 260)
  eiFrame:SetPoint("CENTER")
  eiFrame:SetFrameStrata("DIALOG")
  eiFrame:SetMovable(true)
  eiFrame:EnableMouse(true)
  eiFrame:SetClampedToScreen(true)
  eiFrame:Hide()

  eiFrame:SetBackdrop({
    bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
    edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
    tile = true,
    tileSize = 32,
    edgeSize = 24,
    insets = { left = 5, right = 5, top = 5, bottom = 5 },
  })
  eiFrame:SetBackdropColor(0.06, 0.06, 0.08, 1)

  local eiTitleBar = CreateFrame("Frame", nil, eiFrame)
  eiTitleBar:SetHeight(28)
  eiTitleBar:SetPoint("TOPLEFT", 6, -6)
  eiTitleBar:SetPoint("TOPRIGHT", -6, -6)
  eiTitleBar:EnableMouse(true)
  eiTitleBar:RegisterForDrag("LeftButton")
  eiTitleBar:SetScript("OnDragStart", function()
    eiFrame:StartMoving()
  end)
  eiTitleBar:SetScript("OnDragStop", function()
    eiFrame:StopMovingOrSizing()
  end)

  local eiTitleBg = eiTitleBar:CreateTexture(nil, "BACKGROUND")
  eiTitleBg:SetAllPoints()
  eiTitleBg:SetColorTexture(0.12, 0.12, 0.16, 0.8)

  local eiTitle = eiTitleBar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  eiTitle:SetPoint("LEFT", 6, 0)

  local eiCloseBtn = CreateFrame("Button", nil, eiFrame, "UIPanelCloseButton")
  eiCloseBtn:SetPoint("TOPRIGHT", eiFrame, "TOPRIGHT", -2, -2)

  tinsert(UISpecialFrames, "ResonanceExportImport")

  local eiScrollFrame = CreateFrame("ScrollFrame", "ResonanceEIScroll", eiFrame, "UIPanelScrollFrameTemplate")
  eiScrollFrame:SetPoint("TOPLEFT", eiTitleBar, "BOTTOMLEFT", 10, -8)
  eiScrollFrame:SetPoint("BOTTOMRIGHT", eiFrame, "BOTTOMRIGHT", -34, 50)

  local eiEditBox = CreateFrame("EditBox", nil, eiScrollFrame)
  eiEditBox:SetMultiLine(true)
  eiEditBox:SetAutoFocus(false)
  eiEditBox:SetFontObject(ChatFontNormal)
  eiEditBox:SetWidth(eiScrollFrame:GetWidth() or 420)
  eiEditBox:SetScript("OnEscapePressed", function(self)
    self:ClearFocus()
  end)
  eiScrollFrame:SetScrollChild(eiEditBox)

  local eiStatus = eiFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  eiStatus:SetPoint("BOTTOMLEFT", eiFrame, "BOTTOMLEFT", 16, 18)
  eiStatus:SetWidth(300)
  eiStatus:SetJustifyH("LEFT")

  local eiMinW = math.max(btnTextWidth(L["Close"]), btnTextWidth(L["Import"])) + 12
  local eiActionBtn = makeButton(eiFrame, L["Close"], math.max(80, eiMinW), nil)
  eiActionBtn:SetPoint("BOTTOMRIGHT", eiFrame, "BOTTOMRIGHT", -16, 14)

  local eiMode = "export"

  local refreshPresetList -- forward declaration

  eiActionBtn:SetScript("OnClick", function()
    if eiMode == "export" then
      eiFrame:Hide()
    else
      -- Import as preset
      local text = eiEditBox:GetText()
      local name, result = Resonance:ImportToPreset(text)
      if not name then
        eiStatus:SetText("|cffff4444" .. (result or "Import failed.") .. "|r")
      else
        -- Handle name conflicts
        local baseName = name
        local idx = 2
        while Resonance.db.profile.saved_presets[name] do
          name = baseName .. " " .. idx
          idx = idx + 1
        end
        Resonance.db.profile.saved_presets[name] = result
        local sc = 0
        for _ in pairs(result.spells or {}) do
          sc = sc + 1
        end
        local mc = 0
        for _ in pairs(result.mutes or {}) do
          mc = mc + 1
        end
        eiStatus:SetText("|cff00ff00" .. L["Imported preset '%s': %d spells, %d mutes."]:format(name, sc, mc) .. "|r")
        if refreshPresetList then
          refreshPresetList()
        end
      end
    end
  end)

  -- Presets tab layout
  local presetBanner
  if not ctx.dataLoaded then
    presetBanner = showDataUnavailableBanner(presetTab, select(2, Resonance.loadDataAddon()))
  end

  local presetTabHdr = presetTab:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  presetTabHdr:SetPoint(
    "TOPLEFT",
    presetBanner and presetBanner or presetTab,
    presetBanner and "BOTTOMLEFT" or "TOPLEFT",
    16,
    presetBanner and -12 or -16
  )
  presetTabHdr:SetText(L["Presets"])

  local presetDesc = presetTab:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  presetDesc:SetPoint("TOPLEFT", presetTabHdr, "BOTTOMLEFT", 0, -6)
  presetDesc:SetWidth(CONTENT_WIDTH)
  presetDesc:SetJustifyH("LEFT")
  presetDesc:SetSpacing(3)
  presetDesc:SetText(
    L["Load built-in class presets or your own saved configurations. Each preset contains spell sound settings and manual mutes."]
  )

  local presetSaveBtn = makeButton(presetTab, L["Save Current Config"], 130, nil)
  presetSaveBtn:SetPoint("TOPLEFT", presetDesc, "BOTTOMLEFT", 0, -12)

  local presetImportBtn = makeButton(presetTab, L["Import"], 54, nil)
  presetImportBtn:SetPoint("LEFT", presetSaveBtn, "RIGHT", 6, 0)

  local presetExportProfileBtn = makeButton(presetTab, L["Export Full Profile"], 130, nil)
  presetExportProfileBtn:SetPoint("LEFT", presetImportBtn, "RIGHT", 6, 0)

  -- Inline save name input (hidden by default)
  local presetSaveFrame = CreateFrame("Frame", nil, presetTab)
  presetSaveFrame:SetPoint("TOPLEFT", presetSaveBtn, "BOTTOMLEFT", 0, -6)
  presetSaveFrame:SetPoint("RIGHT", presetTab, "RIGHT", -16, 0)
  presetSaveFrame:SetHeight(26)
  presetSaveFrame:Hide()

  local presetSaveNameLabel = presetSaveFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  presetSaveNameLabel:SetPoint("LEFT", 0, 0)
  presetSaveNameLabel:SetText(L["Name:"])

  local presetSaveNameBox = makeEditBox(presetSaveFrame, 200, presetSaveNameLabel, 0, 0, L["Preset name..."])
  presetSaveNameBox:ClearAllPoints()
  presetSaveNameBox:SetPoint("LEFT", presetSaveNameLabel, "RIGHT", 6, 0)

  local presetSaveConfirmBtn = makeButton(presetSaveFrame, L["Save"], 50, nil)
  presetSaveConfirmBtn:SetPoint("LEFT", presetSaveNameBox, "RIGHT", 4, 0)

  local presetSaveCancelBtn = makeButton(presetSaveFrame, L["Cancel"], 50, nil)
  presetSaveCancelBtn:SetPoint("LEFT", presetSaveConfirmBtn, "RIGHT", 4, 0)

  -- List anchor (adjusts when save frame is shown)
  local presetListAnchor = CreateFrame("Frame", nil, presetTab)
  presetListAnchor:SetSize(1, 1)
  presetListAnchor:SetPoint("TOPLEFT", presetSaveBtn, "BOTTOMLEFT", 0, -10)

  -- Table header
  local presetTableHeader = CreateFrame("Frame", nil, presetTab)
  presetTableHeader:SetHeight(ROW_HEIGHT)
  presetTableHeader:SetPoint("TOPLEFT", presetListAnchor, "BOTTOMLEFT", 0, 0)
  presetTableHeader:SetPoint("RIGHT", presetTab, "RIGHT", -16, 0)

  local presetHeaderBg = presetTableHeader:CreateTexture(nil, "BACKGROUND")
  presetHeaderBg:SetAllPoints()
  presetHeaderBg:SetColorTexture(0.15, 0.15, 0.18, 0.6)

  local presetHdrName = presetTableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  presetHdrName:SetPoint("LEFT", 4, 0)
  presetHdrName:SetWidth(180)
  presetHdrName:SetJustifyH("LEFT")
  presetHdrName:SetText(L["Preset"])

  local presetHdrInfo = presetTableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  presetHdrInfo:SetPoint("LEFT", presetHdrName, "RIGHT", 8, 0)
  presetHdrInfo:SetWidth(140)
  presetHdrInfo:SetJustifyH("LEFT")
  presetHdrInfo:SetText(L["Contents"])

  local presetHdrActions = presetTableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  presetHdrActions:SetPoint("RIGHT", presetTableHeader, "RIGHT", -2, 0)
  presetHdrActions:SetJustifyH("RIGHT")
  presetHdrActions:SetText(L["Actions"])

  -- Preset list container
  local presetListContainer = CreateFrame("Frame", nil, presetTab)
  presetListContainer:SetPoint("TOPLEFT", presetTableHeader, "BOTTOMLEFT", 0, -2)
  presetListContainer:SetPoint("RIGHT", presetTab, "RIGHT", -16, 0)
  presetListContainer:SetHeight(ROW_HEIGHT)

  local presetListEmpty = presetListContainer:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  presetListEmpty:SetPoint("TOPLEFT", 4, 0)
  presetListEmpty:SetText(L["No presets available."])

  local presetListRows = {}

  -- Remove All Preset Spells button
  local presetRemoveAllBtn = makeButton(presetTab, L["Remove All Preset Spells"], 170, nil)
  presetRemoveAllBtn:SetPoint("TOPLEFT", presetListContainer, "BOTTOMLEFT", 0, -8)
  presetRemoveAllBtn:Hide()

  -- Preset list rendering
  refreshPresetList = function()
    profile = Resonance.db.profile
    for _, row in ipairs(presetListRows) do
      row:Hide()
    end

    local presets = {}

    -- Helper to build a class preset entry
    local function makeClassPresetEntry(classKey)
      local tpl = Resonance.ClassTemplates[classKey]
      local spellCount = #tpl
      local activeCount = 0
      for _, entry in ipairs(tpl) do
        if profile.preset_spells[entry.spellID] == classKey then
          activeCount = activeCount + 1
        end
      end
      return {
        name = CLASS_DISPLAY[classKey] or classKey,
        key = classKey,
        source = "class",
        spellCount = spellCount,
        muteCount = 0,
        activeCount = activeCount,
      }
    end

    -- Player's class preset first
    if Resonance.ClassTemplates and Resonance.ClassTemplates[playerClass] then
      presets[#presets + 1] = makeClassPresetEntry(playerClass)
    end

    -- Saved presets
    local savedNames = {}
    for name in pairs(profile.saved_presets or {}) do
      savedNames[#savedNames + 1] = name
    end
    table.sort(savedNames)
    for _, name in ipairs(savedNames) do
      local preset = profile.saved_presets[name]
      local spellCount = 0
      for _ in pairs(preset.spells or {}) do
        spellCount = spellCount + 1
      end
      local muteCount = 0
      for _ in pairs(preset.mutes or {}) do
        muteCount = muteCount + 1
      end
      local activeCount = 0
      for _, source in pairs(profile.preset_spells) do
        if source == name then
          activeCount = activeCount + 1
        end
      end
      presets[#presets + 1] = {
        name = name,
        key = name,
        source = "saved",
        spellCount = spellCount,
        muteCount = muteCount,
        activeCount = activeCount,
      }
    end

    -- Other class presets (de-emphasized, for alt setup)
    if Resonance.ClassTemplates then
      local otherClasses = {}
      for classKey in pairs(Resonance.ClassTemplates) do
        if classKey ~= playerClass then
          otherClasses[#otherClasses + 1] = classKey
        end
      end
      table.sort(otherClasses, function(a, b)
        return (CLASS_DISPLAY[a] or a) < (CLASS_DISPLAY[b] or b)
      end)
      for _, classKey in ipairs(otherClasses) do
        presets[#presets + 1] = makeClassPresetEntry(classKey)
      end
    end

    -- Check if any preset spells are active (for Remove All button)
    local hasActivePresetSpells = next(profile.preset_spells) ~= nil

    for idx, preset in ipairs(presets) do
      local row = presetListRows[idx]
      if not row then
        row = CreateFrame("Frame", nil, presetListContainer)
        row:SetHeight(ROW_HEIGHT)
        presetListRows[idx] = row

        row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.nameText:SetPoint("LEFT", 4, 0)
        row.nameText:SetWidth(180)
        row.nameText:SetJustifyH("LEFT")
        row.nameText:SetWordWrap(false)

        row.infoBtn = CreateFrame("Button", nil, row)
        row.infoBtn:SetPoint("LEFT", row.nameText, "RIGHT", 8, 0)
        row.infoBtn:SetSize(140, ROW_HEIGHT)
        row.infoBtn:SetHighlightTexture("Interface\\BUTTONS\\WHITE8X8")
        row.infoBtn:GetHighlightTexture():SetAlpha(0.1)
        row.infoBtn:SetScript("OnEnter", function(self)
          if self.spellList and #self.spellList > 0 then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:AddLine(self.presetName or "Preset", 1, 0.82, 0)
            GameTooltip:AddLine(" ")
            for _, spellInfo in ipairs(self.spellList) do
              GameTooltip:AddLine(spellInfo, 1, 1, 1)
            end
            GameTooltip:Show()
          end
        end)
        row.infoBtn:SetScript("OnLeave", function()
          GameTooltip:Hide()
        end)

        row.infoText = row.infoBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.infoText:SetPoint("LEFT", 0, 0)
        row.infoText:SetWidth(140)
        row.infoText:SetJustifyH("LEFT")
        row.infoText:SetWordWrap(false)

        row.applyBtn = makeButton(row, L["Apply"], 50, nil)
        row.removeBtn = makeButton(row, L["Remove"], 65, nil)
        row.exportBtn = makeButton(row, L["Export"], 50, nil)
        row.deleteBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 18, L["Delete preset"])
      end

      row:SetPoint("TOPLEFT", presetListContainer, "TOPLEFT", 0, -(idx - 1) * ROW_HEIGHT)
      row:SetPoint("RIGHT", presetListContainer, "RIGHT", 0, 0)

      -- Stripe
      if idx % 2 == 0 then
        if not row.stripe then
          row.stripe = row:CreateTexture(nil, "BACKGROUND")
          row.stripe:SetAllPoints()
          row.stripe:SetColorTexture(1, 1, 1, 0.06)
        end
        row.stripe:Show()
      elseif row.stripe then
        row.stripe:Hide()
      end

      -- Name (de-emphasize other classes)
      local nameDisplay = preset.name
      if preset.source == "class" then
        if preset.key == playerClass then
          nameDisplay = nameDisplay .. "  |cff888888" .. L["(Your Class)"] .. "|r"
        else
          nameDisplay = "|cff888888" .. nameDisplay .. "  " .. L["(Other Class)"] .. "|r"
        end
      end
      row.nameText:SetText(nameDisplay)

      -- Info
      local info
      if preset.muteCount > 0 then
        info = L["%d spells, %d mutes."]:format(preset.spellCount, preset.muteCount)
      else
        info = L["%d spells"]:format(preset.spellCount)
      end
      if preset.activeCount > 0 then
        info = info .. "  |cff00ff00" .. L["(%d active)"]:format(preset.activeCount) .. "|r"
      end
      row.infoText:SetText(info)

      -- Build spell list for tooltip preview
      local spellList = {}
      if preset.source == "class" then
        local tpl = Resonance.ClassTemplates[preset.key]
        if tpl then
          for _, tplEntry in ipairs(tpl) do
            local sname = Resonance.getSpellName(tplEntry.spellID) or tplEntry.name
            local active = profile.preset_spells[tplEntry.spellID] == preset.key
            local prefix = active and "|cff00ff00" or "|cffcccccc"
            spellList[#spellList + 1] = prefix .. sname .. "|r  |cff888888(" .. tplEntry.spellID .. ")|r"
          end
        end
      else
        local presetData = profile.saved_presets[preset.key]
        if presetData and presetData.spells then
          local sortedSids = {}
          for sid in pairs(presetData.spells) do
            sortedSids[#sortedSids + 1] = sid
          end
          table.sort(sortedSids)
          for _, sid in ipairs(sortedSids) do
            local sname = Resonance.getSpellName(sid) or tostring(sid)
            local active = profile.preset_spells[sid] == preset.key
            local prefix = active and "|cff00ff00" or "|cffcccccc"
            spellList[#spellList + 1] = prefix .. sname .. "|r  |cff888888(" .. sid .. ")|r"
          end
        end
      end
      row.infoBtn.spellList = spellList
      row.infoBtn.presetName = preset.name

      -- Position buttons from right to left
      local rightEdge = -2

      -- Delete button (only for saved presets)
      if preset.source == "saved" then
        row.deleteBtn:ClearAllPoints()
        row.deleteBtn:SetPoint("RIGHT", row, "RIGHT", rightEdge, 0)
        row.deleteBtn:Show()
        row.deleteBtn:SetScript("OnClick", function()
          Resonance:DeleteSavedPreset(preset.key)
          Resonance:RemovePresetSpells(preset.key)
          Resonance.msg(L["Deleted preset '%s'."]:format(preset.name))
          refreshPresetList()
          if ctx.refreshList then
            ctx.refreshList()
          end
        end)
        rightEdge = rightEdge - (row.deleteBtn:GetWidth() + 4)
      else
        row.deleteBtn:Hide()
      end

      -- Export button
      row.exportBtn:ClearAllPoints()
      row.exportBtn:SetPoint("RIGHT", row, "RIGHT", rightEdge, 0)
      row.exportBtn:Show()
      row.exportBtn:SetScript("OnClick", function()
        local data
        if preset.source == "class" then
          data = Resonance:ClassPresetToData(preset.key)
        else
          data = profile.saved_presets[preset.key]
        end
        if data then
          eiMode = "export"
          eiTitle:SetText(L["Export Preset: "] .. preset.name)
          local exportStr = Resonance:ExportPresetData(preset.name, data)
          eiEditBox:SetText(exportStr)
          eiActionBtn:SetText(L["Close"])
          local sc = 0
          for _ in pairs(data.spells or {}) do
            sc = sc + 1
          end
          local mc = 0
          for _ in pairs(data.mutes or {}) do
            mc = mc + 1
          end
          eiStatus:SetText(L["%d spells, %d mutes."]:format(sc, mc))
          eiFrame:Show()
          eiEditBox:SetFocus()
          eiEditBox:HighlightText()
        end
      end)
      rightEdge = rightEdge - (row.exportBtn:GetWidth() + 4)

      -- Remove button (only if preset has active spells)
      if preset.activeCount > 0 then
        row.removeBtn:ClearAllPoints()
        row.removeBtn:SetPoint("RIGHT", row, "RIGHT", rightEdge, 0)
        row.removeBtn:SetText(L["Remove"])
        row.removeBtn:Show()
        row.removeBtn:SetScript("OnClick", function()
          local removed = Resonance:RemovePresetSpells(preset.key)
          Resonance.msg(L["Removed %d preset spells from '%s'."]:format(removed, preset.name))
          refreshPresetList()
          if ctx.refreshList then
            ctx.refreshList()
          end
        end)
        rightEdge = rightEdge - (row.removeBtn:GetWidth() + 4)
      else
        row.removeBtn:Hide()
      end

      -- Apply button (hide if all spells from this preset are already active)
      if preset.activeCount < preset.spellCount then
        row.applyBtn:ClearAllPoints()
        row.applyBtn:SetPoint("RIGHT", row, "RIGHT", rightEdge, 0)
        row.applyBtn:Show()
        row.applyBtn:SetScript("OnClick", function()
          if preset.source == "class" then
            local added, skipped = Resonance:ApplyClassTemplate(preset.key)
            Resonance.msg(L["Preset '%s' applied: %d spells added, %d skipped."]:format(preset.name, added, skipped))
          else
            local added, skipped, addedMutes = Resonance:ApplySavedPreset(preset.key)
            Resonance.msg(
              L["Preset '%s' applied: %d spells, %d mutes added (%d skipped)."]:format(
                preset.name,
                added,
                addedMutes,
                skipped
              )
            )
          end
          refreshPresetList()
          if ctx.refreshList then
            ctx.refreshList()
          end
        end)
      else
        row.applyBtn:Hide()
      end

      row:Show()
    end

    -- Hide extra rows
    for i = #presets + 1, #presetListRows do
      presetListRows[i]:Hide()
    end

    local listH = math.max(#presets * ROW_HEIGHT, ROW_HEIGHT)
    presetListContainer:SetHeight(listH)
    presetListEmpty:SetShown(#presets == 0)

    -- Remove All button
    presetRemoveAllBtn:SetShown(hasActivePresetSpells)

    recalcContentHeight(6)
  end

  -- Save Current Config button
  presetSaveBtn:SetScript("OnClick", function()
    if presetSaveFrame:IsShown() then
      presetSaveFrame:Hide()
      presetListAnchor:ClearAllPoints()
      presetListAnchor:SetPoint("TOPLEFT", presetSaveBtn, "BOTTOMLEFT", 0, -10)
    else
      presetSaveFrame:Show()
      presetSaveNameBox:SetText("")
      presetSaveNameBox:SetFocus()
      presetListAnchor:ClearAllPoints()
      presetListAnchor:SetPoint("TOPLEFT", presetSaveFrame, "BOTTOMLEFT", 0, -6)
    end
    recalcContentHeight(6)
  end)

  presetSaveConfirmBtn:SetScript("OnClick", function()
    local name = presetSaveNameBox:GetText()
    if name == "" then
      Resonance.msg(L["Enter a preset name."])
      return
    end
    if Resonance.db.profile.saved_presets[name] then
      Resonance.msg(L["Preset '%s' already exists. Choose a different name."]:format(name))
      return
    end
    Resonance:SaveCurrentAsPreset(name)
    Resonance.msg(L["Saved current config as preset '%s'."]:format(name))
    presetSaveFrame:Hide()
    presetListAnchor:ClearAllPoints()
    presetListAnchor:SetPoint("TOPLEFT", presetSaveBtn, "BOTTOMLEFT", 0, -10)
    refreshPresetList()
  end)

  presetSaveCancelBtn:SetScript("OnClick", function()
    presetSaveFrame:Hide()
    presetListAnchor:ClearAllPoints()
    presetListAnchor:SetPoint("TOPLEFT", presetSaveBtn, "BOTTOMLEFT", 0, -10)
    recalcContentHeight(6)
  end)

  presetSaveNameBox:SetScript("OnEnterPressed", function()
    presetSaveConfirmBtn:Click()
  end)
  presetSaveNameBox:SetScript("OnEscapePressed", function()
    presetSaveCancelBtn:Click()
  end)

  -- Import button
  presetImportBtn:SetScript("OnClick", function()
    eiMode = "import"
    eiTitle:SetText(L["Import Preset"])
    eiEditBox:SetText("")
    eiActionBtn:SetText(L["Import"])
    eiStatus:SetText(L["Paste a preset string below."])
    eiFrame:Show()
    eiEditBox:SetFocus()
  end)

  -- Export Full Profile button
  presetExportProfileBtn:SetScript("OnClick", function()
    eiMode = "export"
    eiTitle:SetText(L["Export Full Profile"])
    local exportStr = Resonance:ExportConfig("Full Profile")
    eiEditBox:SetText(exportStr)
    eiActionBtn:SetText(L["Close"])
    local sc = 0
    for _ in pairs(profile.spell_config or {}) do
      sc = sc + 1
    end
    local mc = 0
    for _, enabled in pairs(profile.mute_file_data_ids or {}) do
      if enabled then
        mc = mc + 1
      end
    end
    eiStatus:SetText(L["%d spells, %d manual mutes (all classes)."]:format(sc, mc))
    eiFrame:Show()
    eiEditBox:SetFocus()
    eiEditBox:HighlightText()
  end)

  -- Remove All Preset Spells button
  presetRemoveAllBtn:SetScript("OnClick", function()
    local removed = Resonance:RemovePresetSpells()
    Resonance.msg(L["Removed %d preset spells."]:format(removed))
    refreshPresetList()
    if ctx.refreshList then
      ctx.refreshList()
    end
  end)

  -- Publish to ctx
  ctx.refreshPresetList = refreshPresetList
end

---------------------------------------------------------------------------
-- Tab 5: Ambient Sounds
---------------------------------------------------------------------------
buildTab5_Ambient = function(ctx)
  local recalcContentHeight = ctx.recalcContentHeight

  ctx.refreshAmbientTab = (function()
    local ambTab = ctx.ambientContent
    local ASD = Resonance.AmbientSoundData

    -- Data unavailable banner
    local ambBanner
    if not ctx.dataLoaded then
      ambBanner = showDataUnavailableBanner(ambTab, select(2, Resonance.loadDataAddon()))
    end

    if not ASD then
      local noData = ambTab:CreateFontString(nil, "OVERLAY", "GameFontDisable")
      noData:SetPoint(
        "TOPLEFT",
        ambBanner and ambBanner or ambTab,
        ambBanner and "BOTTOMLEFT" or "TOPLEFT",
        16,
        ambBanner and -12 or -16
      )
      noData:SetText(L["No ambient sound data available."])
      return function() end
    end

    local hdr = ambTab:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    hdr:SetPoint(
      "TOPLEFT",
      ambBanner and ambBanner or ambTab,
      ambBanner and "BOTTOMLEFT" or "TOPLEFT",
      16,
      ambBanner and -12 or -16
    )
    hdr:SetText(L["Ambient"])

    local desc = ambTab:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    desc:SetPoint("TOPLEFT", hdr, "BOTTOMLEFT", 0, -6)
    desc:SetPoint("RIGHT", ambTab, "RIGHT", -16, 0)
    desc:SetJustifyH("LEFT")
    desc:SetSpacing(3)
    desc:SetText(
      L["Mute environmental/ambient sounds for specific zones. Useful for silencing annoying drones, beams, or oppressive ambient audio."]
    )

    -- Search box for individual ambient sounds
    local doAmbSearch -- forward declaration
    local ambSearchLabel = ambTab:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    ambSearchLabel:SetPoint("TOPLEFT", desc, "BOTTOMLEFT", 0, -12)
    ambSearchLabel:SetText(L["Search individual sounds:"])

    local ambSearchBox =
      makeEditBox(ambTab, CONTENT_WIDTH - 60, ambSearchLabel, 0, -4, L["e.g. silvermoon, maw, beam, wind..."])
    ambSearchBox:SetPoint("TOPLEFT", ambSearchLabel, "BOTTOMLEFT", 0, -4)

    local ambSearchDD
    ambSearchDD = createAutocomplete(ambSearchBox, function(e)
      if e then
        return e.fileDataID
      end
    end, function(e)
      if e then
        local p = Resonance.db.profile
        local fid = e.fileDataID
        if p.mute_file_data_ids[fid] then
          p.mute_file_data_ids[fid] = nil
          UnmuteSoundFile(fid)
          Resonance.msg(L["Unmuted FID %d"]:format(fid))
        else
          p.mute_file_data_ids[fid] = true
          MuteSoundFile(fid)
          Resonance.msg(L["Re-muted FID %d"]:format(fid))
        end
        -- Refresh dropdown in-place (doAmbSearch has a focus guard that
        -- would skip the refresh when clicking the button steals focus)
        if ambSearchDD.data then
          for _, r in ipairs(ambSearchDD.data) do
            local f = r.fileDataID
            local filename = r.path:match("([^/\\]+)$") or r.path
            if p.mute_file_data_ids[f] then
              r.display = "|cff888888" .. filename .. "|r"
            else
              r.display = filename
            end
          end
          ambSearchDD:SetData(ambSearchDD.data, ambSearchDD.statusText:GetText())
        end
      end
    end, { label = L["Mute"], icon = "Interface\\Buttons\\UI-GroupLoot-Pass-Down" }, CONTENT_WIDTH)
    ctx.allDropdowns[#ctx.allDropdowns + 1] = ambSearchDD

    function ambSearchDD:onRowRefresh(row, entry)
      local muted = Resonance.db.profile.mute_file_data_ids[entry.fileDataID]
      row.actionBtn.tooltipText = muted and L["Unmute"] or L["Mute"]
      -- Visual feedback: red + reduced alpha when muted, bright when not
      if muted then
        row.actionBtn.icon:SetVertexColor(1, 0.3, 0.3)
        row.actionBtn.icon:SetAlpha(0.6)
      else
        row.actionBtn.icon:SetVertexColor(0.8, 0.8, 0.8)
        row.actionBtn.icon:SetAlpha(1)
      end
    end

    -- Build FID → "Expansion > Zone" lookup lazily (cached).
    -- Used for both search matching and display labels.
    local ambFIDZone = {} -- fid (number) → "Expansion > Zone"
    -- Pre-lowercased zone+expansion search strings per FID (built once)
    local ambFIDZoneSearch -- fid (number) → lowercased "Exp > Zone LocalExp LocalZone"
    local function ensureAmbFIDZone()
      if ambFIDZoneSearch then
        return
      end
      ambFIDZoneSearch = {}
      if ASD then
        for exp, zones in pairs(ASD) do
          for zone, packed in pairs(zones) do
            local label = exp .. " > " .. zone
            local localExp = L[exp] or ""
            local localZone = L[zone] or ""
            local searchStr = label
            if localExp ~= exp and localExp ~= "" then
              searchStr = searchStr .. " " .. localExp
            end
            if localZone ~= zone and localZone ~= "" then
              searchStr = searchStr .. " " .. localZone
            end
            local lowerSearch = searchStr:lower()
            for s in packed:gmatch("%d+") do
              local fid = tonumber(s)
              ambFIDZone[fid] = label
              ambFIDZoneSearch[fid] = lowerSearch
            end
          end
        end
      end
    end

    doAmbSearch = function()
      if not ambSearchBox:HasFocus() then
        return
      end
      local q = ambSearchBox:GetText()
      if #q < 3 then
        ambSearchDD:SetData({}, "")
        ambSearchDD:Hide()
        return
      end
      ensureAmbFIDZone()
      searchDB(
        Resonance.AmbientSounds,
        q,
        "ambSearch",
        function(results)
          for _, r in ipairs(results) do
            local fid = r.fileDataID
            local zoneLabel = ambFIDZone[fid]
            local filename = r.path:match("([^/\\]+)$") or r.path
            local parent = r.path:match("([^/\\]+)[/\\][^/\\]+$")
            r.display = filename
            if Resonance.db.profile.mute_file_data_ids[fid] then
              r.display = "|cff888888" .. filename .. "|r"
            end
            local parts = {}
            if zoneLabel then
              parts[#parts + 1] = "|cff66aacc" .. zoneLabel .. "|r"
            end
            if parent then
              parts[#parts + 1] = "|cff888888" .. parent .. "/|r"
            end
            parts[#parts + 1] = "|cff777777#" .. fid .. "|r"
            r.subdisplay = table.concat(parts, "  \194\183  ")
          end
          ambSearchDD:SetData(results, #results == 0 and L["No matches."] or L["%d results"]:format(#results))
        end,
        nil,
        nil,
        function(fid, terms)
          -- Match zone/expansion metadata without concatenating into the path
          local zs = ambFIDZoneSearch[fid]
          if not zs then
            return false
          end
          for _, t in ipairs(terms) do
            if not zs:find(t, 1, true) then
              return false
            end
          end
          return true
        end
      )
    end

    local ambSearchClear = makeClearButton(ambSearchBox, function()
      ambSearchDD:Hide()
    end)

    ambSearchBox:SetScript("OnTextChanged", function(self)
      if self.placeholder then
        self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
      end
      ambSearchClear:SetShown(self:GetText() ~= "")
      debounce("ambSearch", 0.3, doAmbSearch)
    end)
    ambSearchBox:SetScript("OnEditFocusGained", function(self)
      if self.placeholder then
        self.placeholder:Hide()
      end
      doAmbSearch()
    end)
    ambSearchBox:SetScript("OnEditFocusLost", function(self)
      if self.placeholder then
        self.placeholder:SetShown(self:GetText() == "")
      end
      C_Timer.After(0.2, function()
        if not ambSearchBox:HasFocus() and not ambSearchDD:IsMouseOver() then
          ambSearchDD:Hide()
        end
      end)
    end)
    ambSearchBox:SetScript("OnEnterPressed", function(self)
      self:ClearFocus()
    end)
    ambSearchBox:SetScript("OnEscapePressed", function(self)
      ambSearchDD:Hide()
      self:ClearFocus()
    end)

    -- Zone-based bulk muting
    local zoneHeader = ambTab:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    zoneHeader:SetPoint("TOPLEFT", ambSearchBox, "BOTTOMLEFT", 0, -16)
    zoneHeader:SetText(L["Mute by zone:"])

    local EXP_ORDER =
      { "Midnight", "The War Within", "Dragonflight", "Shadowlands", "Battle for Azeroth", "Legion", "General" }
    local allRows = {}
    local expanded = {}

    local function countFIDs(packed)
      local n = 0
      for _ in packed:gmatch("%d+") do
        n = n + 1
      end
      return n
    end

    local ZONE_COL_WIDTH = math.floor((CONTENT_WIDTH - 40) / 2)
    local function buildZoneRow(parent, _anchor, _isFirst, zone, key, fidCount)
      local row = CreateFrame("Frame", nil, parent)
      row:SetSize(ZONE_COL_WIDTH, ROW_HEIGHT)
      local cb = CreateFrame("CheckButton", nil, row, "UICheckButtonTemplate")
      cb:SetPoint("LEFT", 0, 0)
      local t = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
      t:SetPoint("LEFT", cb, "RIGHT", 4, 0)
      t:SetText(zone)
      local b = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
      b:SetPoint("LEFT", t, "RIGHT", 6, 0)
      b:SetText("|cff888888(" .. fidCount .. ")|r")
      cb:SetScript("OnClick", function(self)
        local p = Resonance.db.profile
        if not p.muteAmbientSounds then
          p.muteAmbientSounds = {}
        end
        p.muteAmbientSounds[key] = self:GetChecked() or nil
        Resonance.refreshAmbientMutes()
      end)
      row.cb = cb
      row.key = key
      return row
    end

    local expHeaders = {} -- { ehdr, zoneRows[] } per expansion
    local function relayoutExpansions()
      local yOff = -8
      for _, entry in ipairs(expHeaders) do
        entry.ehdr:ClearAllPoints()
        entry.ehdr:SetPoint("TOPLEFT", zoneHeader, "BOTTOMLEFT", 0, yOff)
        entry.ehdr:SetPoint("RIGHT", ambTab, "RIGHT", -16, 0)
        yOff = yOff - (ROW_HEIGHT + 4) - 2
        if expanded[entry.exp] then
          -- Column-major layout: first half left, second half right
          -- A | C
          -- B | D
          local n = #entry.zoneRows
          local half = math.ceil(n / 2)
          local startY = yOff
          for idx, row in ipairs(entry.zoneRows) do
            row:ClearAllPoints()
            local col, rowInCol
            if idx <= half then
              col = 0
              rowInCol = idx - 1
            else
              col = 1
              rowInCol = idx - half - 1
            end
            local xOff = 20 + col * ZONE_COL_WIDTH
            local rowY = startY - rowInCol * (ROW_HEIGHT + 2)
            row:SetPoint("TOPLEFT", zoneHeader, "BOTTOMLEFT", xOff, rowY)
          end
          yOff = startY - half * (ROW_HEIGHT + 2)
        end
      end
      recalcContentHeight(4)
    end

    for _, exp in ipairs(EXP_ORDER) do
      local data = ASD[exp]
      if data then
        local ehdr = CreateFrame("Button", nil, ambTab)
        ehdr:SetHeight(ROW_HEIGHT + 4)
        ehdr:SetPoint("RIGHT", ambTab, "RIGHT", -16, 0)
        local bg = ehdr:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints()
        bg:SetColorTexture(0.18, 0.18, 0.18, 0.8)
        local arrow = ehdr:CreateTexture(nil, "OVERLAY")
        arrow:SetSize(16, 16)
        arrow:SetPoint("LEFT", 8, 0)
        arrow:SetTexture("Interface\\Buttons\\UI-PlusButton-UP")
        local title = ehdr:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        title:SetPoint("LEFT", arrow, "RIGHT", 4, 0)
        title:SetText(L[exp] or exp)

        local zc, fc = 0, 0
        for _, packed in pairs(data) do
          zc = zc + 1
          fc = fc + countFIDs(packed)
        end
        local info = ehdr:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        info:SetPoint("LEFT", title, "RIGHT", 8, 0)
        info:SetText("(" .. L["%d zones, %d sounds"]:format(zc, fc) .. ")")

        local sorted = {}
        for z in pairs(data) do
          sorted[#sorted + 1] = z
        end
        table.sort(sorted)

        local zoneRows = {}
        for _, z in ipairs(sorted) do
          local row = buildZoneRow(ambTab, ehdr, false, z, exp .. "|" .. z, countFIDs(data[z]))
          row:Hide()
          zoneRows[#zoneRows + 1] = row
          allRows[#allRows + 1] = row
        end

        expanded[exp] = false
        ehdr:SetScript("OnClick", function()
          expanded[exp] = not expanded[exp]
          arrow:SetTexture(
            expanded[exp] and "Interface\\Buttons\\UI-MinusButton-UP" or "Interface\\Buttons\\UI-PlusButton-UP"
          )
          for _, r in ipairs(zoneRows) do
            r:SetShown(expanded[exp])
          end
          relayoutExpansions()
        end)
        ehdr:SetScript("OnEnter", function()
          bg:SetColorTexture(0.25, 0.25, 0.25, 0.8)
        end)
        ehdr:SetScript("OnLeave", function()
          bg:SetColorTexture(0.18, 0.18, 0.18, 0.8)
        end)

        expHeaders[#expHeaders + 1] = { exp = exp, ehdr = ehdr, zoneRows = zoneRows }
      end
    end
    relayoutExpansions()

    return function()
      local p = Resonance.db.profile
      for _, row in ipairs(allRows) do
        row.cb:SetChecked(p.muteAmbientSounds and p.muteAmbientSounds[row.key] or false)
      end
      recalcContentHeight(4)
    end
  end)()
end

---------------------------------------------------------------------------
-- Mount Sounds tab: family checkboxes + drag-and-drop individual muting
---------------------------------------------------------------------------
buildTab_MountSounds = function(buildCtx)
  local recalcContentHeight = buildCtx.recalcContentHeight
  local mountTab = buildCtx.mountSoundsContent

  -- Data unavailable banner
  local mountBanner
  if not buildCtx.dataLoaded then
    mountBanner = showDataUnavailableBanner(mountTab, select(2, Resonance.loadDataAddon()))
  end

  local hdr = mountTab:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  hdr:SetPoint(
    "TOPLEFT",
    mountBanner and mountBanner or mountTab,
    mountBanner and "BOTTOMLEFT" or "TOPLEFT",
    16,
    mountBanner and -12 or -16
  )
  hdr:SetText(L["Mount Sounds"])

  local desc = mountTab:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  desc:SetPoint("TOPLEFT", hdr, "BOTTOMLEFT", 0, -6)
  desc:SetPoint("RIGHT", mountTab, "RIGHT", -16, 0)
  desc:SetJustifyH("LEFT")
  desc:SetSpacing(3)
  desc:SetText(
    L["Mute individual mount sounds or skyriding wind effects. Search for a mount by name and check the box to mute its sounds."]
  )

  -- ── Skyriding wind toggle ─────────────────────────────────────────
  local skyridingCb = CreateFrame("CheckButton", nil, mountTab, "UICheckButtonTemplate")
  skyridingCb:SetPoint("TOPLEFT", desc, "BOTTOMLEFT", 0, -10)
  local skyridingLabel = mountTab:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  skyridingLabel:SetPoint("LEFT", skyridingCb, "RIGHT", 4, 0)
  skyridingLabel:SetText(L["Mute skyriding wind sounds"])
  local skyridingNote = mountTab:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  skyridingNote:SetPoint("LEFT", skyridingLabel, "RIGHT", 8, 0)
  skyridingNote:SetText("|cff888888" .. L["(dive wind, speed rush)"] .. "|r")

  skyridingCb:SetScript("OnClick", function(self)
    Resonance.db.profile.muteSkyriding = self:GetChecked() or false
    Resonance.refreshMountMutes()
  end)

  -- ── Individual mount muting (collected mounts browser) ──────────────
  local indivHeader = mountTab:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  indivHeader:SetPoint("TOPLEFT", skyridingCb, "BOTTOMLEFT", 0, -14)
  indivHeader:SetText(L["Mute individual mounts:"])

  local function isMountMutedLocal(mountID)
    return Resonance.db.profile.mutedMounts and Resonance.db.profile.mutedMounts[mountID]
  end

  local function getMountFIDCountLocal(mountID)
    local n = 0
    local MountSoundCSD = Resonance.MountSoundCSD
    local MountToCSD = Resonance.MountToCSD
    if MountSoundCSD and MountToCSD then
      local csdRef = MountToCSD[mountID]
      if csdRef then
        local csdStr = type(csdRef) == "number" and tostring(csdRef) or csdRef
        for cs in tostring(csdStr):gmatch("%d+") do
          local packed = MountSoundCSD[tonumber(cs)]
          if packed then
            for _ in packed:gmatch("%d+") do
              n = n + 1
            end
          end
        end
      end
    end
    -- Model-embedded sounds
    local MountExtraFIDs = Resonance.MountExtraFIDs
    if MountExtraFIDs then
      local extra = MountExtraFIDs[mountID]
      if extra then
        for _ in extra:gmatch("%d+") do
          n = n + 1
        end
      end
    end
    return n
  end

  local refreshMountList -- forward declaration

  local function toggleMountMute(mountID)
    local p = Resonance.db.profile
    if not p.mutedMounts then
      p.mutedMounts = {}
    end
    if p.mutedMounts[mountID] then
      p.mutedMounts[mountID] = nil
    else
      p.mutedMounts[mountID] = true
    end
    Resonance.refreshMountMutes()
    if refreshMountList then
      refreshMountList()
    end
  end

  -- Filter box
  local mountFilterBox = makeEditBox(mountTab, CONTENT_WIDTH - 60, indivHeader, 0, -4, L["Filter mounts..."])
  mountFilterBox:SetPoint("TOPLEFT", indivHeader, "BOTTOMLEFT", 0, -4)

  local mountFilterClear = makeClearButton(mountFilterBox, function() end)

  -- Container for mount rows
  local mountListContainer = CreateFrame("Frame", nil, mountTab)
  mountListContainer:SetPoint("TOPLEFT", mountFilterBox, "BOTTOMLEFT", 0, -6)
  mountListContainer:SetPoint("RIGHT", mountTab, "RIGHT", -16, 0)
  mountListContainer:SetHeight(1)

  -- Mount row pool
  local MOUNT_ROW_H = 22
  local MOUNTS_PER_PAGE = 20
  local mountRows = {}
  local function getOrCreateMountRow(idx)
    if mountRows[idx] then
      return mountRows[idx]
    end
    local row = CreateFrame("Frame", nil, mountListContainer)
    row:SetHeight(MOUNT_ROW_H)
    local cb = CreateFrame("CheckButton", nil, row, "UICheckButtonTemplate")
    cb:SetPoint("LEFT", 0, 0)
    cb:SetSize(22, 22)
    local icon = row:CreateTexture(nil, "ARTWORK")
    icon:SetSize(18, 18)
    icon:SetPoint("LEFT", cb, "RIGHT", 2, 0)
    local t = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    t:SetPoint("LEFT", icon, "RIGHT", 4, 0)
    t:SetPoint("RIGHT", row, "RIGHT", -4, 0)
    t:SetJustifyH("LEFT")
    local stripe = row:CreateTexture(nil, "BACKGROUND")
    stripe:SetAllPoints()
    stripe:SetColorTexture(1, 1, 1, 0.06)
    row.cb = cb
    row.icon = icon
    row.text = t
    row.stripe = stripe
    mountRows[idx] = row
    return row
  end

  -- Pagination
  local currentPage = 1
  local filteredMounts = {} -- { {mountID, name, icon}, ... }

  local pageInfo = mountTab:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  local prevBtn = makeButton(mountTab, "<", 28, function()
    if currentPage > 1 then
      currentPage = currentPage - 1
      refreshMountList()
    end
  end)
  local nextBtn = makeButton(mountTab, ">", 28, function()
    local totalPages = math.max(1, math.ceil(#filteredMounts / MOUNTS_PER_PAGE))
    if currentPage < totalPages then
      currentPage = currentPage + 1
      refreshMountList()
    end
  end)

  -- Build filtered mount list. Retail uses C_MountJournal (collection-aware,
  -- icons via journal). Classic has no journal, so iterate Resonance.MountSoundIndex
  -- directly — list every TBC mount we have data for, icon from spell texture.
  local MountToCSD = Resonance.MountToCSD
  local MountExtraFIDs = Resonance.MountExtraFIDs
  local function rebuildFilteredMounts()
    filteredMounts = {}
    local filter = (mountFilterBox:GetText() or ""):lower()

    if Resonance.IS_RETAIL then
      local allIDs = C_MountJournal.GetMountIDs()
      for _, mid in ipairs(allIDs) do
        local name, _, iconTexture, _, _, _, _, _, _, _, isCollected, mountID = C_MountJournal.GetMountInfoByID(mid)
        local hasSounds = mountID
          and ((MountToCSD and MountToCSD[mountID]) or (MountExtraFIDs and MountExtraFIDs[mountID]))
        if name and isCollected and hasSounds then
          if filter == "" or name:lower():find(filter, 1, true) then
            filteredMounts[#filteredMounts + 1] = {
              mountID = mountID,
              name = name,
              icon = iconTexture,
            }
          end
        end
      end
    else
      local index = Resonance.MountSoundIndex or {}
      local mountSpells = Resonance.MountSpells or {}
      local getTex = (C_Spell and C_Spell.GetSpellTexture) or _G.GetSpellTexture
      for _, entry in ipairs(index) do
        local name, idStr = entry:match("^(.+)#(%-?%d+)$")
        local mountID = idStr and tonumber(idStr)
        if name and mountID then
          local hasSounds = (MountToCSD and MountToCSD[mountID]) or (MountExtraFIDs and MountExtraFIDs[mountID])
          if hasSounds and (filter == "" or name:lower():find(filter, 1, true)) then
            local icon
            local sid = mountSpells[mountID]
            if sid and getTex then
              icon = getTex(sid)
            end
            filteredMounts[#filteredMounts + 1] = { mountID = mountID, name = name, icon = icon }
          end
        end
      end
    end

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

  local mountListEmpty = mountTab:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  mountListEmpty:SetPoint("TOPLEFT", mountListContainer, "TOPLEFT", 4, -4)
  mountListEmpty:SetPoint("RIGHT", mountTab, "RIGHT", -16, 0)
  mountListEmpty:SetJustifyH("LEFT")
  mountListEmpty:Hide()

  refreshMountList = function()
    rebuildFilteredMounts()
    local totalPages = math.max(1, math.ceil(#filteredMounts / MOUNTS_PER_PAGE))
    if currentPage > totalPages then
      currentPage = totalPages
    end

    local startIdx = (currentPage - 1) * MOUNTS_PER_PAGE + 1
    local endIdx = math.min(startIdx + MOUNTS_PER_PAGE - 1, #filteredMounts)

    local yOff = 0
    local rowIdx = 0
    for i = startIdx, endIdx do
      rowIdx = rowIdx + 1
      local entry = filteredMounts[i]
      local row = getOrCreateMountRow(rowIdx)
      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", mountListContainer, "TOPLEFT", 0, -yOff)
      row:SetPoint("RIGHT", mountListContainer, "RIGHT", 0, 0)
      row.cb:SetChecked(isMountMutedLocal(entry.mountID))
      row.icon:SetTexture(entry.icon)
      local countStr = ""
      local count = getMountFIDCountLocal(entry.mountID)
      if count > 0 then
        countStr = " |cff888888(" .. count .. " " .. L["sounds"] .. ")|r"
      end
      row.text:SetText(entry.name .. countStr)
      local capturedID = entry.mountID
      row.cb:SetScript("OnClick", function()
        toggleMountMute(capturedID)
      end)
      row.stripe:SetShown(rowIdx % 2 == 0)
      row:Show()
      yOff = yOff + MOUNT_ROW_H
    end
    for i = rowIdx + 1, #mountRows do
      mountRows[i]:Hide()
    end
    mountListContainer:SetHeight(math.max(yOff, MOUNT_ROW_H))

    -- No results message
    local filter = (mountFilterBox:GetText() or "")
    if #filteredMounts == 0 and filter ~= "" then
      mountListEmpty:SetText(L['No collected mounts matching "%s" have sound data to mute.']:format(filter))
      mountListEmpty:Show()
    else
      mountListEmpty:Hide()
    end

    -- Pagination controls
    pageInfo:SetText(
      L["Page %d of %d"]:format(currentPage, totalPages)
        .. "  |cff888888("
        .. #filteredMounts
        .. " "
        .. L["mounts"]
        .. ")|r"
    )
    prevBtn:SetEnabled(currentPage > 1)
    nextBtn:SetEnabled(currentPage < totalPages)
    prevBtn:SetShown(#filteredMounts > 0)
    nextBtn:SetShown(#filteredMounts > 0)
    pageInfo:SetShown(#filteredMounts > 0)

    recalcContentHeight(7)
  end

  -- Layout pagination controls below the list
  prevBtn:SetPoint("TOPLEFT", mountListContainer, "BOTTOMLEFT", 0, -6)
  pageInfo:SetPoint("LEFT", prevBtn, "RIGHT", 6, 0)
  nextBtn:SetPoint("LEFT", pageInfo, "RIGHT", 6, 0)

  -- Filter box events
  mountFilterBox:SetScript("OnTextChanged", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "" and not self:HasFocus())
    end
    mountFilterClear:SetShown(self:GetText() ~= "")
    currentPage = 1
    debounce("mountFilter", 0.3, refreshMountList)
  end)
  mountFilterBox:SetScript("OnEditFocusGained", function(self)
    if self.placeholder then
      self.placeholder:Hide()
    end
  end)
  mountFilterBox:SetScript("OnEditFocusLost", function(self)
    if self.placeholder then
      self.placeholder:SetShown(self:GetText() == "")
    end
  end)
  mountFilterBox:SetScript("OnEnterPressed", function(self)
    self:ClearFocus()
  end)
  mountFilterBox:SetScript("OnEscapePressed", function(self)
    self:ClearFocus()
  end)

  -- Refresh callback for profile changes
  buildCtx.refreshMountTab = function()
    local p = Resonance.db.profile
    skyridingCb:SetChecked(p.muteSkyriding or false)
    refreshMountList()
  end

  -- Initial refresh
  buildCtx.refreshMountTab()
end

---------------------------------------------------------------------------
-- Alerts tab: custom UI with dropdown + table
---------------------------------------------------------------------------
buildTab_Alerts = function(buildCtx)
  local content = buildCtx.alertsContent
  local profile = Resonance.db.profile
  local ALERT_EVENTS = Resonance.ALERT_EVENTS
  local ROW_H = 24
  local COL_ENABLED = 30
  local COL_EVENT = 130
  local COL_DURATION = 65
  local COL_ACTIONS = 26

  -- ── Tab heading ────────────────────────────────────────────────────
  local tabHdr = content:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  tabHdr:SetPoint("TOPLEFT", content, "TOPLEFT", 16, -16)
  tabHdr:SetText(L["Alerts"])

  -- ── Disclaimer ───────────────────────────────────────────────────────
  local disclaimer = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  disclaimer:SetPoint("TOPLEFT", tabHdr, "BOTTOMLEFT", 0, -8)
  disclaimer:SetPoint("RIGHT", content, "RIGHT", -16, 0)
  disclaimer:SetJustifyH("LEFT")
  disclaimer:SetWordWrap(true)
  disclaimer:SetText(
    L["Alert sounds play alongside the game's default sounds — they do not mute or replace anything. Use them to draw your attention to important combat events."]
  )

  -- ── "Add Alert" form ─────────────────────────────────────────────────
  -- Label
  local addLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  addLabel:SetPoint("TOPLEFT", disclaimer, "BOTTOMLEFT", 0, -12)
  addLabel:SetText(L["Play a sound when:"])

  -- Dropdown button
  local ddBtn = CreateFrame("Button", nil, content, "UIPanelButtonTemplate")
  ddBtn:SetSize(180, 24)
  ddBtn:SetPoint("LEFT", addLabel, "RIGHT", 8, 0)
  local selectedEventKey = nil

  -- Event description (below dropdown)
  local eventDesc = content:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
  eventDesc:SetPoint("TOPLEFT", addLabel, "BOTTOMLEFT", 0, -6)
  eventDesc:SetPoint("RIGHT", content, "RIGHT", -16, 0)
  eventDesc:SetJustifyH("LEFT")
  eventDesc:SetWordWrap(true)
  eventDesc:SetText("")

  -- Labels row (all aligned on the same baseline)
  local soundLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  soundLabel:SetPoint("TOPLEFT", eventDesc, "BOTTOMLEFT", 0, -8)
  soundLabel:SetText(L["Sound:"])

  local durLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  durLabel:SetPoint("LEFT", soundLabel, "LEFT", 210, 0)
  durLabel:SetText(L["Duration (s):"])

  -- Inputs row (all aligned below the labels)
  local alertSoundInput = createSoundInput(content, {
    width = 200,
    placeholder = L["FileDataID, path, or search"],
    debounceKey = "alertSound",
    -- Test button below offers duration-aware preview; keep the inline play
    -- button hidden here so users have one preview affordance, not two.
    showPlayButton = false,
    getSearchResults = function(q, cb)
      defaultSpellSoundSearch(q, cb, "alertSound")
    end,
  })
  local soundBox = alertSoundInput.editbox
  soundBox:SetPoint("TOPLEFT", soundLabel, "BOTTOMLEFT", -2, -2)

  local durBox = makeEditBox(content, 55, durLabel, -2, -2, "")
  durBox:SetPoint("TOPLEFT", durLabel, "BOTTOMLEFT", -2, -2)

  -- Show a brief tooltip anchored to a button
  local function flashTooltip(btn, text)
    GameTooltip:SetOwner(btn, "ANCHOR_TOP")
    GameTooltip:AddLine(text, 1, 0.3, 0.3)
    GameTooltip:Show()
    C_Timer.After(2, function()
      GameTooltip:Hide()
    end)
  end

  -- Test button (aligned with the edit boxes)
  local testBtn
  testBtn = makeButton(content, L["Test"], 40, function()
    local snd = soundBox:GetText()
    if snd == "" then
      flashTooltip(testBtn, L["Enter a sound first."])
      return
    end
    local n = tonumber(snd)
    local sound = n or snd
    local ok, handle = Resonance.previewSound(sound)
    if ok and handle then
      local dur = tonumber(durBox:GetText())
      if dur and dur > 0 then
        C_Timer.After(dur, function()
          StopSound(handle, 0)
        end)
      end
    end
  end)

  -- Add button
  local addBtn
  local refreshAlertList -- forward declaration

  addBtn = makeButton(content, L["Add"], 40, function()
    if not selectedEventKey and soundBox:GetText() == "" then
      flashTooltip(addBtn, L["Select an event and enter a sound."])
      return
    end
    if not selectedEventKey then
      flashTooltip(addBtn, L["Select an event first."])
      return
    end
    local snd = soundBox:GetText()
    if snd == "" then
      flashTooltip(addBtn, L["Enter a sound first."])
      return
    end
    local n = tonumber(snd)
    local sound = n or snd
    if not profile.alerts then
      profile.alerts = {}
    end
    local cfg = { enabled = true, sound = sound, loop = false }
    local dur = tonumber(durBox:GetText())
    if dur and dur > 0 then
      cfg.duration = dur
    end
    profile.alerts[selectedEventKey] = cfg
    Resonance.refreshAlertEvents()
    -- Reset form
    soundBox:SetText("")
    durBox:SetText("")
    selectedEventKey = nil
    ddBtn:SetText(L["Select event..."])
    eventDesc:SetText("")
    refreshAlertList()
  end)
  addBtn:SetPoint("LEFT", testBtn, "RIGHT", 4, 0)

  -- Reanchor test/add buttons based on which fields are visible
  local function reanchorFormButtons()
    testBtn:ClearAllPoints()
    testBtn:SetPoint("LEFT", durBox, "RIGHT", 8, 0)
  end
  reanchorFormButtons()

  -- Dropdown behavior
  local function updateDropdown()
    local configured = profile.alerts or {}
    ddBtn:SetScript("OnClick", function(self)
      MenuUtil.CreateContextMenu(self, function(_, rootDescription)
        for _, evt in ipairs(ALERT_EVENTS) do
          if not configured[evt.key] then
            local btn = rootDescription:CreateButton(evt.name, function()
              selectedEventKey = evt.key
              ddBtn:SetText(evt.name)
              eventDesc:SetText(evt.desc)
            end)
            btn:SetTooltip(function(tooltip)
              GameTooltip_SetTitle(tooltip, evt.name)
              GameTooltip_AddNormalLine(tooltip, evt.desc)
            end)
          end
        end
        -- If all events are configured, show a disabled note
        local anyAvailable = false
        for _, evt in ipairs(ALERT_EVENTS) do
          if not configured[evt.key] then
            anyAvailable = true
            break
          end
        end
        if not anyAvailable then
          rootDescription:CreateButton(L["All events configured"]):SetEnabled(false)
        end
      end)
    end)
  end

  -- ── Configured alerts table ──────────────────────────────────────────
  local tableHeader = CreateFrame("Frame", nil, content)
  tableHeader:SetHeight(ROW_H)

  -- Header labels
  local hdrEnabled = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  hdrEnabled:SetPoint("LEFT", 4, 0)
  hdrEnabled:SetWidth(COL_ENABLED)
  hdrEnabled:SetText("")

  local hdrEvent = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  hdrEvent:SetPoint("LEFT", hdrEnabled, "RIGHT", 4, 0)
  hdrEvent:SetWidth(COL_EVENT)
  hdrEvent:SetJustifyH("LEFT")
  hdrEvent:SetText(L["Event"])

  local hdrSound = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  hdrSound:SetPoint("LEFT", hdrEvent, "RIGHT", 4, 0)
  hdrSound:SetJustifyH("LEFT")
  hdrSound:SetText(L["Sound"])

  local hdrDuration = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  hdrDuration:SetWidth(COL_DURATION)
  hdrDuration:SetJustifyH("LEFT")
  hdrDuration:SetText(L["Duration"])

  local hdrActions = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  hdrActions:SetWidth(COL_ACTIONS)
  hdrActions:SetText("")

  -- Right-anchor duration and actions
  hdrActions:SetPoint("RIGHT", tableHeader, "RIGHT", -4, 0)
  hdrDuration:SetPoint("RIGHT", hdrActions, "LEFT", -4, 0)
  hdrSound:SetPoint("RIGHT", hdrDuration, "LEFT", -4, 0)

  -- Separator line under header
  local hdrLine = tableHeader:CreateTexture(nil, "ARTWORK")
  hdrLine:SetHeight(1)
  hdrLine:SetPoint("BOTTOMLEFT", 0, 0)
  hdrLine:SetPoint("BOTTOMRIGHT", 0, 0)
  hdrLine:SetColorTexture(0.4, 0.4, 0.4, 0.5)

  -- Alert rows (pre-allocated)
  local alertRows = {}
  local emptyText = content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
  emptyText:SetText(L["No alerts configured. Select an event above and click Add."])
  emptyText:SetJustifyH("LEFT")

  local function getOrCreateAlertRow(idx)
    if alertRows[idx] then
      return alertRows[idx]
    end
    local row = CreateFrame("Frame", nil, content)
    row:SetHeight(ROW_H)
    alertRows[idx] = row

    -- Alternating stripe
    row.stripe = row:CreateTexture(nil, "BACKGROUND")
    row.stripe:SetAllPoints()
    row.stripe:SetColorTexture(1, 0.82, 0, 0.08)
    row.stripe:Hide()

    -- Enabled checkbox
    row.enableCB = CreateFrame("CheckButton", nil, row, "UICheckButtonTemplate")
    row.enableCB:SetSize(22, 22)
    row.enableCB:SetPoint("LEFT", 4, 0)
    row.enableCB:SetScript("OnEnter", function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      GameTooltip:AddLine(L["Enable or disable this alert"], 1, 1, 1)
      GameTooltip:Show()
    end)
    row.enableCB:SetScript("OnLeave", function()
      GameTooltip:Hide()
    end)

    -- Event name
    row.eventText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.eventText:SetPoint("LEFT", row.enableCB, "RIGHT", 4, 0)
    row.eventText:SetWidth(COL_EVENT)
    row.eventText:SetJustifyH("LEFT")
    row.eventText:SetWordWrap(false)

    -- Delete button (rightmost)
    row.delBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 18, L["Delete"])
    row.delBtn:SetPoint("RIGHT", row, "RIGHT", -4, 0)

    -- Play button
    row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
    row.playBtn:SetPoint("RIGHT", row.delBtn, "LEFT", -4, 0)

    -- Duration text
    row.durText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.durText:SetWidth(COL_DURATION)
    row.durText:SetJustifyH("LEFT")
    row.durText:SetPoint("RIGHT", row.playBtn, "LEFT", -4, 0)

    -- Sound text (fills remaining space)
    row.soundText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.soundText:SetPoint("LEFT", row.eventText, "RIGHT", 4, 0)
    row.soundText:SetPoint("RIGHT", row.durText, "LEFT", -4, 0)
    row.soundText:SetJustifyH("LEFT")
    row.soundText:SetWordWrap(false)

    -- Tooltip on event name
    row.eventHit = CreateFrame("Frame", nil, row)
    row.eventHit:SetPoint("TOPLEFT", row.eventText, "TOPLEFT", 0, 0)
    row.eventHit:SetPoint("BOTTOMRIGHT", row.eventText, "BOTTOMRIGHT", 0, 0)
    row.eventHit:SetScript("OnEnter", function(self)
      if row._desc then
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:AddLine(row._name or "", 1, 1, 1)
        GameTooltip:AddLine(row._desc, nil, nil, nil, true)
        GameTooltip:Show()
      end
    end)
    row.eventHit:SetScript("OnLeave", function()
      GameTooltip:Hide()
    end)

    return row
  end

  -- ── Refresh function ─────────────────────────────────────────────────
  refreshAlertList = function()
    profile = Resonance.db.profile
    local alerts = profile.alerts or {}

    -- Collect configured alerts in metadata order
    local ordered = {}
    for _, evt in ipairs(ALERT_EVENTS) do
      if alerts[evt.key] then
        ordered[#ordered + 1] = { key = evt.key, meta = evt, cfg = alerts[evt.key] }
      end
    end

    -- Title + table header: anchored below the edit box row
    local configuredLabel = content._configuredLabel
    if not configuredLabel then
      configuredLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormal")
      content._configuredLabel = configuredLabel
    end
    configuredLabel:ClearAllPoints()
    configuredLabel:SetPoint("TOPLEFT", soundBox, "BOTTOMLEFT", 2, -16)
    configuredLabel:SetText(L["Configured alerts:"])
    configuredLabel:Show()

    tableHeader:ClearAllPoints()
    tableHeader:SetPoint("TOPLEFT", configuredLabel, "BOTTOMLEFT", 0, -4)
    tableHeader:SetPoint("RIGHT", content, "RIGHT", -16, 0)
    tableHeader:Show()

    -- Hide all existing rows
    for _, row in ipairs(alertRows) do
      row:Hide()
    end

    if #ordered == 0 then
      emptyText:ClearAllPoints()
      emptyText:SetPoint("TOPLEFT", tableHeader, "BOTTOMLEFT", 4, -8)
      emptyText:Show()
    else
      emptyText:Hide()
    end

    local yOff = 0
    for i, entry in ipairs(ordered) do
      local row = getOrCreateAlertRow(i)
      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", tableHeader, "BOTTOMLEFT", 0, -yOff)
      row:SetPoint("RIGHT", tableHeader, "RIGHT", 0, 0)

      -- Stripe alternating rows
      if i % 2 == 0 then
        row.stripe:Show()
      else
        row.stripe:Hide()
      end

      -- Event name + tooltip data
      row.eventText:SetText(entry.meta.name)
      row._name = entry.meta.name
      row._desc = entry.meta.desc

      -- Sound display
      local snd = entry.cfg.sound
      local soundDisplay
      if type(snd) == "number" then
        local path = lookupFIDPath(snd)
        if path then
          -- Show short filename + FID
          local filename = path:match("[^/\\]+$") or path
          soundDisplay = filename .. " |cff888888#" .. snd .. "|r"
        else
          soundDisplay = "#" .. snd
        end
      elseif type(snd) == "string" then
        local filename = snd:match("[^/\\]+$") or snd
        soundDisplay = filename
      else
        soundDisplay = "—"
      end
      row.soundText:SetText(soundDisplay or "—")

      -- Duration display
      if entry.cfg.duration then
        row.durText:SetText(("%.1fs"):format(entry.cfg.duration))
      else
        row.durText:SetText("—")
      end

      -- Enabled checkbox
      row.enableCB:SetChecked(entry.cfg.enabled)
      row.enableCB:SetScript("OnClick", function(self)
        entry.cfg.enabled = self:GetChecked()
        Resonance.refreshAlertEvents()
      end)

      -- Play/stop button
      wirePlayStop(row.playBtn, function()
        return entry.cfg.sound
      end)

      -- Delete button
      row.delBtn:SetScript("OnClick", function()
        profile.alerts[entry.key] = nil
        Resonance.refreshAlertEvents()
        refreshAlertList()
      end)

      row:Show()
      yOff = yOff + ROW_H
    end

    -- Update dropdown to exclude configured events
    updateDropdown()
    -- If the currently selected event was just configured, reset selection
    if selectedEventKey and alerts[selectedEventKey] then
      selectedEventKey = nil
      ddBtn:SetText(L["Select event..."])
      eventDesc:SetText("")
    end

    buildCtx.recalcContentHeight(content)
  end

  -- Store refresh callback for re-show
  buildCtx.refreshAlertList = refreshAlertList

  -- Initial render
  ddBtn:SetText(L["Select event..."])
  updateDropdown()
  refreshAlertList()
end

---------------------------------------------------------------------------
-- Custom Sounds tab
---------------------------------------------------------------------------
buildTab_CustomSounds = function(buildCtx)
  local content = buildCtx.customSoundsContent
  local profile = Resonance.db.profile
  local CUSTOM_ROOT = Resonance.CUSTOM_SOUNDS_ROOT
  local ROW_H = 24

  -- Tab heading
  local tabHdr = content:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
  tabHdr:SetPoint("TOPLEFT", content, "TOPLEFT", 16, -16)
  tabHdr:SetText(L["Custom Sounds"])

  -- Instructions
  local instructions = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
  instructions:SetPoint("TOPLEFT", tabHdr, "BOTTOMLEFT", 0, -8)
  instructions:SetPoint("RIGHT", content, "RIGHT", -16, 0)
  instructions:SetJustifyH("LEFT")
  instructions:SetWordWrap(true)
  instructions:SetText(
    L["Place your sound files (.ogg, .mp3) in Interface/AddOns/Resonance_Sounds/ and register them below. Registered sounds appear in all sound search boxes. Reload the UI (/reload) after adding new files to the folder."]
  )

  -- Register a sound section
  local registerLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  registerLabel:SetPoint("TOPLEFT", instructions, "BOTTOMLEFT", 0, -16)
  registerLabel:SetText(L["Register a sound:"])

  -- Filename label + input
  local fileLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  fileLabel:SetPoint("TOPLEFT", registerLabel, "BOTTOMLEFT", 0, -8)
  fileLabel:SetText(L["Filename:"])

  local fileBox = makeEditBox(content, 180, fileLabel, -2, -2, "mysound.ogg")
  fileBox:SetPoint("TOPLEFT", fileLabel, "BOTTOMLEFT", -2, -2)

  -- Name label + input (same row, offset to the right)
  local nameLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  nameLabel:SetPoint("LEFT", fileLabel, "LEFT", 190, 0)
  nameLabel:SetText(L["Display name (optional):"])

  local nameBox = makeEditBox(content, 160, nameLabel, -2, -2, "")
  nameBox:SetPoint("TOPLEFT", nameLabel, "BOTTOMLEFT", -2, -2)

  -- Flash tooltip helper
  local function flashTooltip(btn, text)
    GameTooltip:SetOwner(btn, "ANCHOR_TOP")
    GameTooltip:AddLine(text, 1, 0.3, 0.3)
    GameTooltip:Show()
    C_Timer.After(2, function()
      GameTooltip:Hide()
    end)
  end

  -- Play button
  local playBtn
  playBtn = makeButton(content, L["Test"], 40, function()
    local file = fileBox:GetText()
    if file == "" then
      flashTooltip(playBtn, L["Enter a filename first."])
      return
    end
    local path = CUSTOM_ROOT .. file
    Resonance.previewSound(path)
  end)
  playBtn:SetPoint("LEFT", nameBox, "RIGHT", 8, 0)

  -- Add button
  local addBtn
  local refreshCustomSounds

  addBtn = makeButton(content, L["Add"], 40, function()
    local file = fileBox:GetText()
    if file == "" then
      flashTooltip(addBtn, L["Enter a filename first."])
      return
    end
    -- Check for duplicate filenames
    if not profile.customSounds then
      profile.customSounds = {}
    end
    for _, entry in ipairs(profile.customSounds) do
      if entry.file == file then
        flashTooltip(addBtn, L["This file is already registered."])
        return
      end
    end
    local name = nameBox:GetText()
    if name == "" then
      -- Use filename without extension as display name
      name = file:match("(.+)%..+$") or file
    end
    profile.customSounds[#profile.customSounds + 1] = { name = name, file = file }
    if Resonance.syncCustomSoundsToLSM then
      Resonance.syncCustomSoundsToLSM()
    end
    fileBox:SetText("")
    nameBox:SetText("")
    refreshCustomSounds()
  end)
  addBtn:SetPoint("LEFT", playBtn, "RIGHT", 4, 0)

  -- Registered sounds table
  local tableLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormal")

  local tableHeader = CreateFrame("Frame", nil, content)
  tableHeader:SetHeight(ROW_H)

  -- Column headers
  local COL_NAME = 160
  local COL_FILE = 180

  local hdrName = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  hdrName:SetPoint("LEFT", 4, 0)
  hdrName:SetWidth(COL_NAME)
  hdrName:SetJustifyH("LEFT")
  hdrName:SetText(L["Name"])

  local hdrFile = tableHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  hdrFile:SetPoint("LEFT", hdrName, "RIGHT", 4, 0)
  hdrFile:SetWidth(COL_FILE)
  hdrFile:SetJustifyH("LEFT")
  hdrFile:SetText(L["File"])

  local hdrLine = tableHeader:CreateTexture(nil, "ARTWORK")
  hdrLine:SetHeight(1)
  hdrLine:SetPoint("BOTTOMLEFT", 0, 0)
  hdrLine:SetPoint("BOTTOMRIGHT", 0, 0)
  hdrLine:SetColorTexture(0.4, 0.4, 0.4, 0.5)

  -- Rows
  local rows = {}
  local emptyText = content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
  emptyText:SetText(L["No custom sounds registered."])
  emptyText:SetJustifyH("LEFT")

  local function getOrCreateRow(idx)
    if rows[idx] then
      return rows[idx]
    end
    local row = CreateFrame("Frame", nil, content)
    row:SetHeight(ROW_H)
    rows[idx] = row

    row.stripe = row:CreateTexture(nil, "BACKGROUND")
    row.stripe:SetAllPoints()
    row.stripe:SetColorTexture(1, 0.82, 0, 0.08)
    row.stripe:Hide()

    row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.nameText:SetPoint("LEFT", 4, 0)
    row.nameText:SetWidth(COL_NAME)
    row.nameText:SetJustifyH("LEFT")
    row.nameText:SetWordWrap(false)

    row.fileText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.fileText:SetPoint("LEFT", row.nameText, "RIGHT", 4, 0)
    row.fileText:SetWidth(COL_FILE)
    row.fileText:SetJustifyH("LEFT")
    row.fileText:SetWordWrap(false)
    row.fileText:SetTextColor(0.6, 0.6, 0.6)

    row.delBtn = makeIconButton(row, "Interface\\Buttons\\UI-StopButton", 18, L["Delete"])
    row.delBtn:SetPoint("RIGHT", row, "RIGHT", -4, 0)

    row.playBtn = makeIconButton(row, "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up", 22, L["Play / Stop"])
    row.playBtn:SetPoint("RIGHT", row.delBtn, "LEFT", -4, 0)

    return row
  end

  -- Refresh function
  refreshCustomSounds = function()
    profile = Resonance.db.profile
    local sounds = profile.customSounds or {}

    -- Position table
    tableLabel:ClearAllPoints()
    tableLabel:SetPoint("TOPLEFT", fileBox, "BOTTOMLEFT", 2, -16)
    tableLabel:SetText(L["Registered sounds:"])
    tableLabel:Show()

    tableHeader:ClearAllPoints()
    tableHeader:SetPoint("TOPLEFT", tableLabel, "BOTTOMLEFT", 0, -4)
    tableHeader:SetPoint("RIGHT", content, "RIGHT", -16, 0)
    tableHeader:Show()

    for _, row in ipairs(rows) do
      row:Hide()
    end

    if #sounds == 0 then
      emptyText:ClearAllPoints()
      emptyText:SetPoint("TOPLEFT", tableHeader, "BOTTOMLEFT", 4, -8)
      emptyText:Show()
    else
      emptyText:Hide()
    end

    local yOff = 0
    for i, entry in ipairs(sounds) do
      local row = getOrCreateRow(i)
      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", tableHeader, "BOTTOMLEFT", 0, -yOff)
      row:SetPoint("RIGHT", tableHeader, "RIGHT", 0, 0)

      if i % 2 == 0 then
        row.stripe:Show()
      else
        row.stripe:Hide()
      end

      row.nameText:SetText(entry.name)
      row.fileText:SetText(entry.file)

      wirePlayStop(row.playBtn, function()
        return CUSTOM_ROOT .. entry.file
      end)

      row.delBtn:SetScript("OnClick", function()
        table.remove(profile.customSounds, i)
        refreshCustomSounds()
      end)

      row:Show()
      yOff = yOff + ROW_H
    end

    buildCtx.recalcContentHeight(content)
  end

  buildCtx.refreshCustomSounds = refreshCustomSounds
  refreshCustomSounds()
end

---------------------------------------------------------------------------
-- Subcategory panel infrastructure
---------------------------------------------------------------------------

-- Shared context table used by all builder functions.  Elevated to module
-- scope so that cross-panel references (refreshList, allDropdowns, etc.)
-- survive across subcategory show/hide cycles.
local ctx

-- Create a scroll frame inside a panel and return both the scroll frame
-- and its content child.  This mirrors the old per-tab scroll setup.
local function createScrollableContent(parentPanel)
  local sf = CreateFrame("ScrollFrame", nil, parentPanel, "UIPanelScrollFrameTemplate")
  sf:SetPoint("TOPLEFT", parentPanel, "TOPLEFT", 8, -8)
  sf:SetPoint("BOTTOMRIGHT", parentPanel, "BOTTOMRIGHT", -24, 8)

  -- Auto-hide scrollbar when content fits
  local scrollbarParts = {}
  for j = 1, sf:GetNumChildren() do
    local child = select(j, sf:GetChildren())
    if child:IsObjectType("Slider") then
      scrollbarParts[#scrollbarParts + 1] = child
      for k = 1, child:GetNumChildren() do
        local sub = select(k, child:GetChildren())
        scrollbarParts[#scrollbarParts + 1] = sub
      end
      break
    end
  end
  sf.scrollbarParts = scrollbarParts
  for _, part in ipairs(scrollbarParts) do
    part:Hide()
  end

  local function updateScrollbar(self)
    local yRange = self:GetVerticalScrollRange()
    local show = yRange and yRange > 0
    for _, part in ipairs(self.scrollbarParts) do
      part:SetShown(show)
    end
    if not show then
      self:SetVerticalScroll(0)
    end
  end
  sf:HookScript("OnScrollRangeChanged", updateScrollbar)
  sf:HookScript("OnShow", updateScrollbar)

  local c = CreateFrame("Frame")
  sf:SetScrollChild(c)
  c:SetWidth(CONTENT_WIDTH + 40)
  c:SetHeight(1)
  sf:HookScript("OnSizeChanged", function(self, w)
    if w and w > 0 then
      c:SetWidth(w)
    end
  end)

  return sf, c
end

-- Dynamically resize scroll child to fit actual content.
-- Accepts either a numeric tabIndex (legacy compat -- indexes into
-- ctx.contentFrames) or a content frame directly.
local function recalcContentHeight(contentOrIndex)
  C_Timer.After(0, function()
    local content
    if type(contentOrIndex) == "number" then
      local entry = ctx and ctx.contentFrames and ctx.contentFrames[contentOrIndex]
      content = entry
    else
      content = contentOrIndex
    end
    if not content then
      return
    end
    local top = content:GetTop()
    if not top then
      return
    end
    local lowestBottom = top
    for i = 1, content:GetNumChildren() do
      local child = select(i, content:GetChildren())
      if child:IsShown() then
        local bottom = child:GetBottom()
        if bottom and bottom < lowestBottom then
          lowestBottom = bottom
        end
      end
    end
    content:SetHeight(math.max(top - lowestBottom + 20, 1))
  end)
end

-- Helper to embed an AceConfig table into a subcategory panel.
-- Wraps the panel in a Blizzard scroll frame (same `createScrollableContent`
-- used by Mount Sounds / Spell Sounds tabs) so the scrollbar visually spans
-- the full panel — title, description, and AceConfig widgets all scroll
-- together. AceGUI's own ScrollFrame widget would scrollbar-clip below the
-- title; using the outer Blizzard scroll keeps the visual consistent.
local function buildAceConfigPanel(parentFrame, aceConfigName, title, description)
  local aceGUI = LibStub("AceGUI-3.0")
  local AceConfigDialog = LibStub("AceConfigDialog-3.0")

  local _, content = createScrollableContent(parentFrame)

  local yOff = -16
  if title then
    local hdr = content:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    hdr:SetPoint("TOPLEFT", 16, yOff)
    hdr:SetText(title)
    yOff = yOff - (hdr:GetStringHeight() or 16) - 6
  end
  if description then
    local desc = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    desc:SetPoint("TOPLEFT", 16, yOff)
    desc:SetPoint("RIGHT", content, "RIGHT", -16, 0)
    desc:SetJustifyH("LEFT")
    desc:SetSpacing(3)
    desc:SetText(description)
    yOff = yOff - (desc:GetStringHeight() or 14) - 12
  end

  -- AceGUI SimpleGroup with Flow layout: AceConfig flows widgets vertically.
  -- Height is generous (most panels fit; oversized panels still scroll via
  -- the outer Blizzard scroll frame).
  local CONTAINER_HEIGHT = 2000
  local aceContainer = CreateFrame("Frame", nil, content)
  aceContainer:SetPoint("TOPLEFT", 16, yOff)
  aceContainer:SetPoint("RIGHT", content, "RIGHT", -16, 0)
  aceContainer:SetHeight(CONTAINER_HEIGHT)

  local container = aceGUI:Create("SimpleGroup")
  container:SetLayout("Flow")
  container.frame:SetParent(content)
  container.frame:SetAllPoints(aceContainer)
  container.frame:Show()

  AceConfigDialog:Open(aceConfigName, container)

  -- After AceConfig renders, size the scroll content to fit. We use a generous
  -- baseline (CONTAINER_HEIGHT) since AceGUI SimpleGroup with Flow layout
  -- doesn't auto-grow to fit child widgets — content above CONTAINER_HEIGHT
  -- would clip without this padding, and the outer Blizzard scroll handles
  -- the actual scrolling.
  C_Timer.After(0, function()
    content:SetHeight(-yOff + CONTAINER_HEIGHT + 16)
  end)

  return container
end

-- Build the content for a specific subcategory panel.
-- Called on first OnShow of each panel; `built` flag prevents re-entry.
local function buildSubcategoryContent(name, panel)
  local entry = subcategories[name]
  if not entry or entry.built then
    return
  end
  entry.built = true

  local dataLoaded = Resonance.loadDataAddon()
  ctx.dataLoaded = dataLoaded

  -- AceConfig-based panels
  if name == "General" or name == "Muting" or name == "Profiles" then
    local aceKey = ({
      ["General"] = "Resonance_General",
      ["Muting"] = "Resonance_Muting",
      ["Profiles"] = "Resonance_Profiles",
    })[name]
    local aceTitle = ({
      ["General"] = L["General"],
      ["Muting"] = L["Muting"],
      ["Profiles"] = L["Profiles"],
    })[name]
    local aceDesc = ({
      ["General"] = L["Toggle features and choose the replacement sound channel."],
      ["Muting"] = L["Configure which categories of game sounds to mute: weapon impacts, vocalizations, creature sounds, and profession audio."],
      ["Profiles"] = L["Manage saved profiles. Copy settings between characters or switch configurations."],
    })[name]
    buildAceConfigPanel(panel, aceKey, aceTitle, aceDesc)
    return
  end

  if name == "Alerts" then
    local _, content = createScrollableContent(panel)
    ctx.alertsContent = content
    buildTab_Alerts(ctx)
    return
  end

  if name == "Custom Sounds" then
    local _, content = createScrollableContent(panel)
    ctx.customSoundsContent = content
    buildTab_CustomSounds(ctx)
    return
  end

  -- Custom UI panels need scrollable content areas
  if name == "Spell Sounds" then
    local _, content = createScrollableContent(panel)
    ctx.spellSoundsContent = content
    ctx.contentFrames[2] = content
    buildTab2_SpellSounds(ctx)
    -- Wire editor frame dropdown cleanup
    if ctx.editorFrame then
      ctx.editorFrame:HookScript("OnHide", function()
        if ctx.edBrowseDD then
          ctx.edBrowseDD:Hide()
        end
        if ctx.edSpellSearchDD then
          ctx.edSpellSearchDD:Hide()
        end
        if ctx.precastBrowseDD then
          ctx.precastBrowseDD:Hide()
        end
      end)
    end
    -- Register dropdowns for click-catcher management
    if ctx.edBrowseDD then
      ctx.allDropdowns[#ctx.allDropdowns + 1] = ctx.edBrowseDD
    end
    if ctx.edSpellSearchDD then
      ctx.allDropdowns[#ctx.allDropdowns + 1] = ctx.edSpellSearchDD
    end
    if ctx.precastBrowseDD then
      ctx.allDropdowns[#ctx.allDropdowns + 1] = ctx.precastBrowseDD
    end
    return
  end

  if name == "Sound Browser" then
    local _, content = createScrollableContent(panel)
    ctx.mutedSoundsContent = content
    ctx.contentFrames[5] = content
    buildTab3_MutedSounds(ctx)
    -- Register mute dropdown for click-catcher management
    if ctx.muteDD then
      ctx.allDropdowns[#ctx.allDropdowns + 1] = ctx.muteDD
    end
    return
  end

  if name == "Mount Sounds" then
    local _, content = createScrollableContent(panel)
    ctx.mountSoundsContent = content
    ctx.contentFrames[7] = content
    buildTab_MountSounds(ctx)
    return
  end

  if name == "Ambient" then
    local _, content = createScrollableContent(panel)
    ctx.ambientContent = content
    ctx.contentFrames[4] = content
    buildTab5_Ambient(ctx)
    return
  end

  if name == "Presets" then
    local _, content = createScrollableContent(panel)
    ctx.presetsContent = content
    ctx.contentFrames[6] = content
    buildTab4_Presets(ctx)
    return
  end
end

---------------------------------------------------------------------------
-- SetupOptions (called from Core.lua OnInitialize)
---------------------------------------------------------------------------
function Resonance:SetupOptions()
  registerAllPanels()

  local _, playerClass = UnitClass("player")

  -- Initialize shared context (elevated to module scope)
  ctx = {
    playerClass = playerClass,
    allDropdowns = {},
    recalcContentHeight = recalcContentHeight,
    contentFrames = {}, -- numeric index -> content frame (legacy compat)
  }

  -- Wire OnShow handlers for each subcategory panel to build on first show
  for name, entry in pairs(subcategories) do
    local panelFrame = entry.panel
    panelFrame:SetScript("OnShow", function(self)
      if entry.built then
        -- Re-trigger refresh callbacks on subsequent shows
        if name == "Spell Sounds" then
          invalidateSpellCache()
          startBuildPlayerSpellCache()
          if ctx.refreshList then
            ctx.refreshList()
          end
        elseif name == "Sound Browser" then
          if ctx.refreshMuteList then
            ctx.refreshMuteList()
          end
        elseif name == "Ambient" then
          if ctx.refreshAmbientTab then
            ctx.refreshAmbientTab()
          end
        elseif name == "Mount Sounds" then
          if ctx.refreshMountTab then
            ctx.refreshMountTab()
          end
        elseif name == "Presets" then
          if ctx.refreshPresetList then
            ctx.refreshPresetList()
          end
        elseif name == "Alerts" then
          if ctx.refreshAlertList then
            ctx.refreshAlertList()
          end
        elseif name == "Custom Sounds" then
          if ctx.refreshCustomSounds then
            ctx.refreshCustomSounds()
          end
        end
        return
      end
      local ok, err = pcall(buildSubcategoryContent, name, self)
      if not ok then
        local errStr = tostring(err)
        local errLabel = self:CreateFontString(nil, "OVERLAY", "GameFontRed")
        errLabel:SetPoint("CENTER", 0, 0)
        errLabel:SetWidth(500)
        errLabel:SetText(L["Resonance options failed to load:\n\n"] .. errStr)
        Resonance.msg("|cffff4444" .. L["Options UI error: "] .. errStr .. "|r")
        Resonance.msg("|cffff4444" .. L["Type /res diag for library diagnostics."] .. "|r")
      else
        -- Custom panels need a deferred refresh after the first frame render
        -- so that layout calculations (content height, scroll range) are correct.
        C_Timer.After(0, function()
          if name == "Spell Sounds" then
            invalidateSpellCache()
            startBuildPlayerSpellCache()
            if ctx.refreshList then
              ctx.refreshList()
            end
          elseif name == "Sound Browser" then
            if ctx.refreshMuteList then
              ctx.refreshMuteList()
            end
          elseif name == "Ambient" then
            if ctx.refreshAmbientTab then
              ctx.refreshAmbientTab()
            end
          elseif name == "Mount Sounds" then
            if ctx.refreshMountTab then
              ctx.refreshMountTab()
            end
          elseif name == "Presets" then
            if ctx.refreshPresetList then
              ctx.refreshPresetList()
            end
          elseif name == "Alerts" then
            if ctx.refreshAlertList then
              ctx.refreshAlertList()
            end
          elseif name == "Custom Sounds" then
            if ctx.refreshCustomSounds then
              ctx.refreshCustomSounds()
            end
          end
        end)
      end
    end)
  end

  -- Free spell cache memory when leaving Spell Sounds panel
  if subcategories["Spell Sounds"] then
    subcategories["Spell Sounds"].panel:HookScript("OnHide", function()
      invalidateSpellCache()
    end)
  end

  -- Dropdown management: hide dropdowns when custom panels hide
  for _, sname in ipairs({ "Spell Sounds", "Sound Browser", "Ambient" }) do
    if subcategories[sname] then
      subcategories[sname].panel:HookScript("OnHide", function()
        if ctx and ctx.allDropdowns then
          for _, dd in ipairs(ctx.allDropdowns) do
            dd:Hide()
          end
        end
      end)
    end
  end

  -- Click catcher to close dropdowns when clicking outside
  local clickCatcher = CreateFrame("Button", nil, UIParent)
  clickCatcher:SetAllPoints()
  clickCatcher:SetFrameStrata("FULLSCREEN_DIALOG")
  clickCatcher:SetFrameLevel(0)
  clickCatcher:Hide()
  clickCatcher:RegisterForClicks("AnyUp")
  clickCatcher:SetScript("OnClick", function()
    if ctx and ctx.allDropdowns then
      for _, dd in ipairs(ctx.allDropdowns) do
        dd:Hide()
      end
    end
    clickCatcher:Hide()
  end)

  -- Wire click catcher to all dropdowns that get registered.
  -- Since dropdowns are created lazily when panels build, we hook
  -- into the allDropdowns table via a metatable that auto-wires new entries.
  local realDropdowns = ctx.allDropdowns
  local ddMeta = {}
  ddMeta.__newindex = function(t, k, dd)
    rawset(t, k, dd)
    if dd and type(dd) == "table" and dd.HookScript then
      dd:HookScript("OnShow", function()
        clickCatcher:Show()
      end)
      dd:HookScript("OnHide", function()
        local anyVisible = false
        for _, d in ipairs(realDropdowns) do
          if d:IsShown() then
            anyVisible = true
            break
          end
        end
        if not anyVisible then
          clickCatcher:Hide()
        end
      end)
    end
  end
  setmetatable(realDropdowns, ddMeta)
end

-- Replace AceGUI Frame's translucent parchment-style backdrop (the
-- UI-DialogBox-Background texture, which has alpha in its insets and is
-- topped by a winged DialogBox-Header that doesn't cover the full title
-- area) with a solid black backdrop and a thin clean border. Also hides the
-- dialog-style header textures — without parchment behind them they look
-- out of place — and re-anchors the title text to sit cleanly at the top
-- of the new backdrop.
local function applyOpaquePopupBackdrop(f)
  f.frame:SetBackdrop({
    bgFile = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile = false,
    edgeSize = 16,
    insets = { left = 4, right = 4, top = 4, bottom = 4 },
  })
  f.frame:SetBackdropColor(0, 0, 0, 1)
  f.frame:SetBackdropBorderColor(0.4, 0.4, 0.45, 1)
  if f.titlebg then
    f.titlebg:Hide()
  end
  -- The two side title textures (titlebg_l / titlebg_r) aren't exposed on
  -- the AceGUI widget; find them by their texture path among the frame's
  -- regions and hide.
  for i = 1, f.frame:GetNumRegions() do
    local region = select(i, f.frame:GetRegions())
    if region and region.GetTexture and region:GetTexture() == 131080 then
      region:Hide()
    end
  end
  if f.titletext then
    f.titletext:ClearAllPoints()
    f.titletext:SetPoint("TOP", f.frame, "TOP", 0, -10)
  end
end

-- Open a popup window with the per-material sheathe overrides grid.
-- Uses AceGUI Frame + AceConfigDialog:Open to embed the
-- "Resonance_SheatheAdvanced" options table.
local _sheatheAdvancedFrame
Resonance.openSheatheAdvancedPopup = function()
  if _sheatheAdvancedFrame and _sheatheAdvancedFrame.frame and _sheatheAdvancedFrame.frame:IsShown() then
    _sheatheAdvancedFrame.frame:Raise()
    return
  end
  local aceGUI = LibStub("AceGUI-3.0")
  local AceConfigDialog = LibStub("AceConfigDialog-3.0")
  local f = aceGUI:Create("Frame")
  f:SetTitle(L["Sheathe — Advanced"])
  f:SetStatusText(L["Empty cells fall back to the simple-mode slots."])
  f:SetLayout("Fill")
  f:SetWidth(540)
  f:SetHeight(560)
  f:SetCallback("OnClose", function(widget)
    AceConfigDialog:Close("Resonance_SheatheAdvanced")
    aceGUI:Release(widget)
    _sheatheAdvancedFrame = nil
  end)
  applyOpaquePopupBackdrop(f)
  AceConfigDialog:Open("Resonance_SheatheAdvanced", f)
  _sheatheAdvancedFrame = f
end

-- Refresh any currently-visible custom panel (called from Core.lua on profile change)
Resonance.refreshVisibleOptionPanels = function()
  if not ctx then
    return
  end
  for name, entry in pairs(subcategories) do
    if entry.built and entry.panel:IsVisible() then
      if name == "Spell Sounds" and ctx.refreshList then
        ctx.refreshList()
      end
      if name == "Sound Browser" and ctx.refreshMuteList then
        ctx.refreshMuteList()
      end
      if name == "Presets" and ctx.refreshPresetList then
        ctx.refreshPresetList()
      end
      if name == "Ambient" and ctx.refreshAmbientTab then
        ctx.refreshAmbientTab()
      end
      if name == "Mount Sounds" and ctx.refreshMountTab then
        ctx.refreshMountTab()
      end
    end
  end
end
