-- Resonance
-- Goal:
--   1) Keep SFX ON (so you still hear footsteps/world audio)
--   2) Mute specific modern spell sounds by FileDataID
--   3) Play your chosen "old school" sounds on your own casts (UNIT_SPELLCAST_SUCCEEDED)
--   Works with any class/spec.
--
-- Sound file setup:
--   Put extracted sound files (Vanilla/TBC/Wrath) into:
--     Interface\AddOns\Resonance_Sounds\vanilla\   (preferred — survives addon-manager updates)
--     Interface\AddOns\Resonance\sounds\vanilla\   (legacy — wiped by WoWUp/CurseForge on update)
--   Named after the spell, e.g.:
--     Mortal Strike.wav
--     Execute.ogg
--     Shield Slam.wav
--   The addon will prefer a file override by spell name (from your local files),
--   over any in-client fallback FileDataID.
--
-- Fallback strategy:
--   - If you do NOT provide a local file override, we can optionally play a SoundKit/FileDataID
--     that exists in the retail client (if you map one).
--   - If neither exists, we play sounds/fallback/generic.wav
--
-- Note on MuteSoundFile:
--   Mutes are not guaranteed to persist after a full client restart, so we re-apply on login.
--   API: MuteSoundFile(fileDataID), UnmuteSoundFile(fileDataID)

Resonance = LibStub("AceAddon-3.0"):NewAddon("Resonance", "AceConsole-3.0", "AceEvent-3.0")

---------------------------------------------------------------------------
-- Flavor detection + capability profile.
--
-- Per-flavor TOCs select the right data files at load time.  At runtime,
-- behavior differences are described by `Resonance.Flavor` — a single table
-- of capability flags loaded once at addon init, keyed off WOW_PROJECT_ID.
--
-- To add a new WoW flavor (e.g. Wrath Anniversary):
--   1. Add a profile entry in FLAVOR_PROFILES below (a new key, plus the
--      flavor's project ID and capability flags).
--   2. Ship the matching .toc files (Resonance_Wrath.toc + the data sub-addon)
--      and add them to build.py's ADDON_INCLUDES / DATA_INCLUDES lists.
--   3. Generate the data files via tools/spell_sounds.py --build wrath.
-- No code changes outside FLAVOR_PROFILES should be needed unless the new
-- flavor requires brand-new behavior not yet expressed by a capability flag.
--
-- The IS_* booleans are kept as aliases for legacy call sites; new code
-- should query `Resonance.Flavor.X` instead so the IS_* set can shrink over
-- time as the capability table absorbs more decisions.
---------------------------------------------------------------------------
-- All flavor setup wrapped in a do/end block so the helper locals don't
-- count toward Core.lua's main-chunk 200-local cap.  Only the resulting
-- Resonance.Flavor table + IS_* aliases escape into the outer scope.
do
  local PROJECT = WOW_PROJECT_ID or 1
  local PROJECT_MAINLINE = WOW_PROJECT_MAINLINE or 1
  local PROJECT_VANILLA = WOW_PROJECT_CLASSIC or 2
  local PROJECT_TBC = WOW_PROJECT_BURNING_CRUSADE_CLASSIC or 5
  local PROJECT_WRATH = WOW_PROJECT_WRATH_CLASSIC or 11
  local PROJECT_CATA = WOW_PROJECT_CATACLYSM_CLASSIC or 14
  local PROJECT_MISTS = WOW_PROJECT_MISTS_CLASSIC or 19

  -- Default values for every capability key.  A profile below only needs to
  -- override what differs from these defaults.
  local FLAVOR_DEFAULTS = {
    id = "unknown",
    displayName = "Unknown",
    projectID = nil,
    -- "persistent": MuteSoundFile is left in place for configured spells.
    -- "transient": mute only during the cast window (UNIT_SPELLCAST_SENT to
    -- +5s) — used on Classic where many spells share generic FIDs and
    -- persistent muting silences too much.
    muteMode = "persistent",
    -- Opt-in persistent mute of cast-initiation FIDs.  Only meaningful when
    -- muteMode = "transient" (transient can't catch the first cast after
    -- /reload because the engine plays cast-start audio synchronously
    -- inside the C call before any Lua hook runs).
    supportsPrecastMute = false,
    supportsMountTab = true,
    -- Skyriding is a retail-only flight system, so the "mute skyriding wind
    -- sounds" toggle is hidden on every Classic flavor (Vanilla/TBC/MoP). Only
    -- the retail profile opts in. Default false also keeps it hidden for any
    -- future Classic flavor that falls through to these defaults.
    supportsSkyriding = false,
    -- Sound browser NPC mode requires the Creature DB2 table which isn't
    -- populated on wago.tools for wow_anniversary / wow_classic_era.
    supportsNPCBrowser = true,
    -- Class Presets target retail spells/specs and don't apply on Classic.
    supportsClassPresets = true,
    ambientScope = "full",
    -- COMBAT_LOG_EVENT_UNFILTERED is restricted on retail 12.x, fully open
    -- on Classic.  Drives real-time impact-sound timing for travel spells.
    registersCLEUForImpacts = false,
    registersSpellCastSent = false,
    unavailableProfessions = {},
    modernSoundFIDCap = nil,
  }

  local FLAVOR_PROFILES = {
    retail = {
      id = "retail",
      displayName = "Retail",
      projectID = PROJECT_MAINLINE,
      supportsSkyriding = true,
    },
    tbc = {
      id = "tbc",
      displayName = "TBC Anniversary",
      projectID = PROJECT_TBC,
      muteMode = "transient",
      supportsPrecastMute = true,
      supportsNPCBrowser = false,
      supportsClassPresets = false,
      ambientScope = "general",
      registersCLEUForImpacts = true,
      registersSpellCastSent = true,
      unavailableProfessions = { Inscription = true },
    },
    vanilla = {
      id = "vanilla",
      displayName = "Vanilla Anniversary",
      projectID = PROJECT_VANILLA,
      muteMode = "transient",
      supportsPrecastMute = true,
      supportsNPCBrowser = false,
      supportsClassPresets = false,
      ambientScope = "general",
      registersCLEUForImpacts = true,
      registersSpellCastSent = true,
      unavailableProfessions = { Inscription = true, Jewelcrafting = true },
      modernSoundFIDCap = 1000000,
    },
    mists = {
      id = "mists",
      displayName = "MoP Classic",
      projectID = PROJECT_MISTS,
      muteMode = "transient",
      supportsPrecastMute = true,
      supportsNPCBrowser = false,
      supportsClassPresets = false,
      -- MoP Classic has every profession. NPC browser stays off (no MoP NPC
      -- sound data). Ambient uses the "general" cross-flavor subset like the
      -- other Classic flavors. Mount data ships (MountSoundData_Mists), so the
      -- mount tab falls through to the default (enabled); the skyriding toggle
      -- stays hidden via supportsSkyriding defaulting to false.
      ambientScope = "general",
      registersCLEUForImpacts = true,
      registersSpellCastSent = true,
    },
  }

  local function resolveFlavor()
    local out = {}
    for k, v in pairs(FLAVOR_DEFAULTS) do
      out[k] = v
    end
    for _, profile in pairs(FLAVOR_PROFILES) do
      if profile.projectID == PROJECT then
        for k, v in pairs(profile) do
          out[k] = v
        end
        return out
      end
    end
    -- Unknown flavor (e.g. Wrath Anniversary not yet added).  Fall back to
    -- retail-style defaults so the addon at least loads.
    return out
  end

  Resonance.Flavor = resolveFlavor()

  -- Legacy IS_* aliases.  New code should query Resonance.Flavor.X instead.
  Resonance.IS_RETAIL = PROJECT == PROJECT_MAINLINE
  Resonance.IS_VANILLA = PROJECT == PROJECT_VANILLA
  Resonance.IS_TBC = PROJECT == PROJECT_TBC
  Resonance.IS_WRATH = PROJECT == PROJECT_WRATH
  Resonance.IS_CATA = PROJECT == PROJECT_CATA
  Resonance.IS_MISTS = PROJECT == PROJECT_MISTS
  Resonance.IS_CLASSIC = not Resonance.IS_RETAIL
end

-- Localize Lua built-ins to avoid per-call _G hash lookups.
-- Standard WoW addon practice; each access through _G costs ~20-50 ns
-- which adds up on rapid-fire spell casts and bulk mute operations.
local type = type
local tostring = tostring
local tonumber = tonumber
local pairs = pairs
local ipairs = ipairs
local select = select
local pcall = pcall
local wipe = wipe
local math = math
local string = string
local table = table
local bit = bit

local L = Resonance_L
local ADDON_ROOT = "Interface\\AddOns\\Resonance\\"
local CUSTOM_SOUNDS_ROOT = "Interface\\AddOns\\Resonance_Sounds\\"

local ADDON_VERSION = C_AddOns and C_AddOns.GetAddOnMetadata("Resonance", "Version") or "unknown"
if ADDON_VERSION:find("@") then
  ADDON_VERSION = "dev"
end

-- Capture data globals into addon namespace, then release them from _G.
-- Core-addon data files load before Core.lua (see .toc order) so these are
-- guaranteed set.  Data-addon globals (SpellMuteData, SpellSounds, etc.) may
-- not exist yet if Resonance_Data hasn't been loaded (LoadOnDemand).
Resonance.ClassTemplates = Resonance_ClassTemplates
Resonance_ClassTemplates = nil
-- Per-class spell-sound FIDs for the "mute all classes except mine" option.
-- Ships in the main addon (not LoadOnDemand) so it's available at login.
Resonance.ClassSpellSoundFIDs = Resonance_ClassSpellSoundFIDs
Resonance_ClassSpellSoundFIDs = nil
Resonance.ProfessionSoundData = Resonance_ProfessionSoundData
Resonance_ProfessionSoundData = nil
Resonance.ProfessionCategories = Resonance_ProfessionCategories
Resonance_ProfessionCategories = nil
Resonance.EffectMuteData = Resonance_EffectMuteData
Resonance_EffectMuteData = nil

-- Capture globals from Resonance_Data (may not exist yet if LoadOnDemand).
-- Called at file load and again from loadDataAddon() after late loading.
local function captureDataAddonGlobals()
  if Resonance_SpellMuteData then
    Resonance.SpellMuteData = Resonance_SpellMuteData
    Resonance_SpellMuteData = nil
  end
  if Resonance_SpellEarlyFIDs then
    Resonance.SpellEarlyFIDs = Resonance_SpellEarlyFIDs
    Resonance_SpellEarlyFIDs = nil
  end
  if Resonance_RaceCSD then
    Resonance.RaceCSD = Resonance_RaceCSD
    Resonance_RaceCSD = nil
  end
  if Resonance_VoxFIDs then
    Resonance.VoxFIDs = Resonance_VoxFIDs
    Resonance_VoxFIDs = nil
  end
  if Resonance_WeaponImpactFIDs then
    Resonance.WeaponImpactFIDs = Resonance_WeaponImpactFIDs
    Resonance_WeaponImpactFIDs = nil
  end
  if Resonance_ExcludedFIDs then
    Resonance.ExcludedFIDs = Resonance_ExcludedFIDs
    Resonance_ExcludedFIDs = nil
  end
  if Resonance_AmbientSoundData then
    Resonance.AmbientSoundData = Resonance_AmbientSoundData
    Resonance_AmbientSoundData = nil
  end
  if Resonance_AmbientSounds then
    Resonance.AmbientSounds = Resonance_AmbientSounds
    Resonance_AmbientSounds = nil
  end
  if Resonance_SpellSounds then
    Resonance.SpellSounds = Resonance_SpellSounds
    Resonance_SpellSounds = nil
  end
  if Resonance_SpellSoundPrefixes then
    Resonance.SpellSoundPrefixes = Resonance_SpellSoundPrefixes
    Resonance_SpellSoundPrefixes = nil
  end
  if Resonance_CharacterSounds then
    Resonance.CharacterSounds = Resonance_CharacterSounds
    Resonance_CharacterSounds = nil
  end
  if Resonance_CharacterSoundPrefixes then
    Resonance.CharacterSoundPrefixes = Resonance_CharacterSoundPrefixes
    Resonance_CharacterSoundPrefixes = nil
  end
  if Resonance_NPCSoundL10N then
    Resonance.NPCSoundL10N = Resonance_NPCSoundL10N
    Resonance_NPCSoundL10N = nil
  end
  if Resonance_CreatureVoxData then
    Resonance.CreatureVoxData = Resonance_CreatureVoxData
    Resonance_CreatureVoxData = nil
  end
  if Resonance_CreatureVoxCategories then
    Resonance.CreatureVoxCategories = Resonance_CreatureVoxCategories
    Resonance_CreatureVoxCategories = nil
  end
  if Resonance_CreatureVoxExcludedFIDs then
    Resonance.CreatureVoxExcludedFIDs = Resonance_CreatureVoxExcludedFIDs
    Resonance_CreatureVoxExcludedFIDs = nil
  end
  if Resonance_NPCSoundIndex then
    Resonance.NPCSoundIndex = Resonance_NPCSoundIndex
    Resonance_NPCSoundIndex = nil
  end
  if Resonance_NPCToCSD then
    Resonance.NPCToCSD = Resonance_NPCToCSD
    Resonance_NPCToCSD = nil
  end
  if Resonance_NPCSoundCSD then
    Resonance.NPCSoundCSD = Resonance_NPCSoundCSD
    Resonance_NPCSoundCSD = nil
  end
  if Resonance_NPCVoiceData then
    Resonance.NPCVoiceData = Resonance_NPCVoiceData
    Resonance_NPCVoiceData = nil
  end
  if Resonance_NPCRepCSDs then
    Resonance.NPCRepCSDs = Resonance_NPCRepCSDs
    Resonance_NPCRepCSDs = nil
  end
  if Resonance_MountSoundIndex then
    Resonance.MountSoundIndex = Resonance_MountSoundIndex
    Resonance_MountSoundIndex = nil
  end
  if Resonance_MountToCSD then
    Resonance.MountToCSD = Resonance_MountToCSD
    Resonance_MountToCSD = nil
  end
  if Resonance_MountSoundCSD then
    Resonance.MountSoundCSD = Resonance_MountSoundCSD
    Resonance_MountSoundCSD = nil
  end
  if Resonance_MountSpells then
    Resonance.MountSpells = Resonance_MountSpells
    Resonance_MountSpells = nil
  end
  if Resonance_SpellToMount then
    Resonance.SpellToMount = Resonance_SpellToMount
    Resonance_SpellToMount = nil
  end
  if Resonance_MountExtraFIDs then
    Resonance.MountExtraFIDs = Resonance_MountExtraFIDs
    Resonance_MountExtraFIDs = nil
  end
  if Resonance_MountSkyridingFIDs then
    Resonance.MountSkyridingFIDs = Resonance_MountSkyridingFIDs
    Resonance_MountSkyridingFIDs = nil
  end
  if Resonance_SheatheMuteData then
    Resonance.SheatheMuteData = Resonance_SheatheMuteData
    Resonance_SheatheMuteData = nil
  end
end
captureDataAddonGlobals()

-- Localize frequently-called WoW API functions.  Global lookups are
-- measurably slower in Lua 5.1's interpreter and these are called on
-- every spell cast and during bulk mute operations.
local MuteSoundFile = MuteSoundFile
local UnmuteSoundFile = UnmuteSoundFile
local PlaySoundFile = PlaySoundFile
local IsPlayerSpell = IsPlayerSpell
local UnitRace = UnitRace
local UnitSex = UnitSex
local UnitClass = UnitClass
local StopSound = StopSound
local GetTime = GetTime
local C_Timer = C_Timer

local lastVoxRaceGender -- cached to detect actual race/gender changes

-- Local aliases for hot-path access (core-addon data, always available)
local ClassTemplates = Resonance.ClassTemplates
local ProfessionSoundData = Resonance.ProfessionSoundData
local ProfessionCategories = Resonance.ProfessionCategories

-- Data-addon tables (SpellMuteData, RaceCSD, VoxFIDs, WeaponImpactFIDs,
-- AmbientSoundData, SpellSounds, CharacterSounds, AmbientSounds,
-- CreatureVoxData, NPCSoundData) live on Resonance.X and are accessed
-- there so that loadDataAddon() updates are
-- visible without re-capturing locals.

-- WoW's MuteSoundFile is refcounted; this ceiling covers the worst case of
-- stale mute state accumulated across reloads/multiple spells sharing FIDs.
local MAX_MUTE_DEPTH = 20

local db -- shortcut to self.db.profile, set in OnInitialize
local autoMutedFIDs = {} -- runtime-only refcounted mute table (not saved)
local voxMutedFIDs = {} -- runtime-only: FIDs muted by global vox toggle
local weaponMutedFIDs = {} -- runtime-only: FIDs muted by weapon impact toggle
local creatureMutedFIDs = {} -- runtime-only: FIDs muted by creature vox toggle
local autoShotMutedFIDs = {} -- runtime-only: FIDs muted by classic auto-shot toggle
local professionMutedFIDs = {} -- runtime-only: FIDs muted by profession sound toggle
local npcMutedFIDs = {} -- runtime-only: FIDs muted by NPC sound muting
local alertHandles = {} -- { [alertKey] = soundHandle } for debouncing alert sounds
local lastLoCInterrupt = nil -- timestamp set by LOSS_OF_CONTROL_ADDED on SCHOOL_INTERRUPT
local pendingInterrupt = nil -- { time, spellID } set by UNIT_SPELLCAST_INTERRUPTED awaiting LoC confirmation
local ambientMutedFIDs = {} -- runtime-only: FIDs muted by ambient sound toggles
local mountMutedFIDs = {} -- runtime-only: FIDs muted by mount sound muting
local sheatheMutedFIDs = {} -- runtime-only: FIDs muted by sheathe-replacement toggle
-- Stored as a Resonance field (not a file-scope local) because the main chunk
-- is at Lua's 200-local cap.  Holds FIDs muted by curated effect-mute toggles.
Resonance._effectMutedFIDs = {}
-- Holds FIDs muted by the "mute all classes except mine" option (same 200-local
-- reason). Populated from Resonance.ClassSpellSoundFIDs.
Resonance._soloClassMutedFIDs = {}
local transientMutedFIDs = {} -- runtime-only: { [fid] = C_Timer ticker } for cast-window mutes (Classic)
local precastMutedFIDs = {} -- runtime-only: FIDs muted by aggressive precast mute toggle (Classic)
local TRANSIENT_MUTE_DURATION = 5.0 -- seconds; covers cast bar + projectile travel + impact
local lastCastSoundTime = {} -- { [spellID] = GetTime() } for debouncing rapid duplicates
local lastCastSoundCfgTime = {} -- { [cfg] = GetTime() } for debouncing multi-hit spells (same cfg, different spellIDs)
-- Precast uses its own tracking tables so the precast debounce never suppresses
-- (nor is suppressed by) the matching cast-complete sound under the
-- precast_and_cast trigger, where both phases fire within the same window by
-- design. Stored as Resonance fields rather than file-scope locals because the
-- main chunk is already at Lua's 200-local cap.
Resonance._lastPrecastSoundTime = {} -- { [spellID] = GetTime() } debouncing rapid duplicate precasts
Resonance._lastPrecastSoundCfgTime = {} -- { [cfg] = GetTime() } debouncing multi-hit precasts (same cfg, different spellIDs)
local CAST_SOUND_DEBOUNCE = 0.5 -- seconds; min GCD is 0.75s so legitimate recasts are unaffected
local sequenceState = {} -- { [sequenceTable] = { idx = <number> } } for sequence sound mode
local activeChannelSpellID = nil -- spellID of the spell currently being channeled
local activeChannelCfg = nil -- resolved spell_config for the channeled spell

-- Alert event metadata: drives the Alerts UI dropdown and event handler registration
Resonance.ALERT_EVENTS = {
  {
    key = "interrupt",
    name = "Interrupt",
    desc = "Play a sound when your cast is interrupted by an enemy kick or counterspell.",
    order = 1,
  },
  {
    key = "lossOfControl",
    name = "Loss of Control",
    desc = "Play a sound when you are stunned, feared, silenced, or otherwise lose control of your character.",
    order = 2,
  },
  {
    key = "death",
    name = "Death",
    desc = "Play a sound when you die.",
    order = 3,
  },
}
-- Build lookup by key for fast access
Resonance.ALERT_EVENTS_BY_KEY = {}
for _, evt in ipairs(Resonance.ALERT_EVENTS) do
  Resonance.ALERT_EVENTS_BY_KEY[evt.key] = evt
end

-- Returns true if a FID is held muted by manual mutes or any runtime layer
-- OTHER than the specified skip table.  Used when clearing one layer to avoid
-- unmuting sounds still needed elsewhere.
local function isMutedElsewhere(fid, skip)
  if db.mute_file_data_ids[fid] then
    return true
  end
  if autoMutedFIDs[fid] and autoMutedFIDs[fid] > 0 and autoMutedFIDs ~= skip then
    return true
  end
  if voxMutedFIDs[fid] and voxMutedFIDs ~= skip then
    return true
  end
  if weaponMutedFIDs[fid] and weaponMutedFIDs ~= skip then
    return true
  end
  if creatureMutedFIDs[fid] and creatureMutedFIDs ~= skip then
    return true
  end
  if autoShotMutedFIDs[fid] and autoShotMutedFIDs ~= skip then
    return true
  end
  if professionMutedFIDs[fid] and professionMutedFIDs ~= skip then
    return true
  end
  if npcMutedFIDs[fid] and npcMutedFIDs ~= skip then
    return true
  end
  if ambientMutedFIDs[fid] and ambientMutedFIDs ~= skip then
    return true
  end
  if mountMutedFIDs[fid] and mountMutedFIDs ~= skip then
    return true
  end
  if sheatheMutedFIDs[fid] and sheatheMutedFIDs ~= skip then
    return true
  end
  if transientMutedFIDs[fid] and transientMutedFIDs ~= skip then
    return true
  end
  if precastMutedFIDs[fid] and precastMutedFIDs ~= skip then
    return true
  end
  local effectMuted = Resonance._effectMutedFIDs
  if effectMuted[fid] and effectMuted ~= skip then
    return true
  end
  local soloMuted = Resonance._soloClassMutedFIDs
  if soloMuted[fid] and soloMuted ~= skip then
    return true
  end
  return false
end

---------------------------------------------------------------------------
-- Classic auto-shot data
---------------------------------------------------------------------------
local AUTO_SHOT_SPELL_ID = 75

-- Modern auto-shot FIDs to mute (bow + gun combined).
-- Bow: bowpullback, bowrelease, bow_cast_oneshot (TWW)
-- Gun: blunderbuss, shoot_cast_oneshot (TWW), shoot_cast_oneshot alt (TWW)
local AUTO_SHOT_MUTE_FIDS = {
  -- spell_hu_bowpullback_01-08
  925291,
  925293,
  925295,
  925297,
  925299,
  925301,
  925303,
  925305,
  -- spell_hu_bowrelease_01-05
  922086,
  922088,
  922090,
  922092,
  922094,
  -- bow_cast_oneshot (TWW)
  5913903,
  5913905,
  5913907,
  5913909,
  5913911,
  5913913,
  5913915,
  -- spell_hu_blunderbuss_weaponfire_01-06
  921248,
  921250,
  921252,
  921254,
  921256,
  921258,
  -- shoot_cast_oneshot (TWW)
  5923734,
  5923860,
  5923862,
  5923864,
  5923866,
  -- shoot_cast_oneshot alt (TWW)
  6256085,
  6256087,
  6256089,
  6256091,
  6256093,
  6256095,
  6256097,
  6256099,
  6256101,
  6256103,
  6256105,
  6256107,
  6256109,
}

-- Classic replacement sounds (random pool per weapon type)
local CLASSIC_BOW_FIDS = { 567674, 567673, 567682 } -- bowrelease 1-3
local CLASSIC_GUN_FIDS = { 567721, 567718, 567722 } -- gunfire 1-3
-- Classic-era WoW grouped bows + crossbows under the same non-metal material
-- and reused bow-release SoundKits for both. We expose a separate pool so the
-- "force-crossbow" override is wired up; future patches can substitute distinct
-- crossbow FIDs here without touching the engine.
local CLASSIC_CROSSBOW_FIDS = { 567674, 567673, 567682 }

local RANGED_OVERRIDE_VALID = {
  auto = true,
  forceBow = true,
  forceGun = true,
  forceCrossbow = true,
}

-- Name-based index: lowercase spell name -> spell_config entry.
-- Handles variant spell IDs (e.g., Balance Moonfire 155625 vs base 8921)
-- that share sounds with a configured spell but have a different ID.
-- Rebuilt lazily on first access after any spell_config change.
local spellNameIndex = {}
local spellNameIndexDirty = true

---------------------------------------------------------------------------
-- Defaults (AceDB profile)
---------------------------------------------------------------------------
local defaults = {
  profile = {
    enabled = true,
    debug = false,
    soundChannel = "Master",
    mute_file_data_ids = {},
    local_file_overrides_by_spell_name = {},
    spell_to_play_file_data_id = {},
    spell_config = {},
    preset_spells = {}, -- { [spellID] = presetName } tracks which spells came from presets
    removed_template_spells = {}, -- { [spellID] = true } prevents re-adding deleted template spells
    saved_presets = {}, -- { [name] = { spells = {[sid]={sound,muteExclusions}}, mutes = {[fid]=true} } }
    muteVocalizations = "off",
    muteWeaponImpacts = false,
    mutePrecastSounds = false, -- aggressive persistent mute for Classic cast-initiation FIDs
    muteCreatureVox = {}, -- { ["Beast"] = true, ["Demon"] = true, ... }
    muteProfessionSounds = {}, -- { ["Blacksmithing"] = true, ... }
    muteAmbientSounds = {}, -- { ["Midnight|Silvermoon City"] = true, ... }
    muteEffects = {}, -- { ["dh_devour.loop"] = true, ... } curated effect-mute toggles
    multiHitWindow = 1.5, -- seconds; suppress duplicate sounds from multi-hit spells
    classicAutoShot = false,
    rangedSoundOverride = "auto", -- auto | forceBow | forceGun | forceCrossbow
    rangedSoundOverrideAdvanced = false, -- gates whether forceCrossbow is exposed in UI
    replaceSheathe = false, -- play replacement sound on sheath state transitions
    sheatheReplacements = {}, -- { meleeDraw, meleeSheathe, rangedDraw, rangedSheathe } (sound keys; nil = no replacement for that slot, Blizzard's default plays)
    sheatheReplacementsAdvanced = false, -- derived UI/convenience flag; runtime fallback is per-cell on sheatheReplacementsByMaterial
    sheatheReplacementsByMaterial = {}, -- [material][state] sound key overrides; falls back to sheatheReplacements on empty cell
    muteSheatheBySlot = {}, -- per-simple-slot mute opt-in: { meleeDraw, meleeSheathe, rangedDraw, rangedSheathe } -> bool
    muteSheatheByMaterial = {}, -- per-cell mute opt-in: [material][state] -> bool (advanced popup)
    mutedNPCs = {}, -- { [npcID] = true } NPCs whose sounds are muted
    mutedMounts = {}, -- { [mountID] = true } mounts whose sounds are muted
    muteSkyriding = false, -- mute skyriding wind/speed/ability sounds
    soloClassMute = false, -- aggressively mute every class's spell sounds except the player's own
    alerts = {},
    customSounds = {}, -- { { name = "My Sound", file = "mysound.ogg" }, ... }
    minimap = { hide = false },
    _lastAutoMutedFIDs = {}, -- persisted snapshot for stale-mute cleanup across /reload
    _lastCreatureMutedFIDs = {}, -- same for creature vox mutes
    _lastProfessionMutedFIDs = {}, -- same for profession sound mutes
    _lastAmbientMutedFIDs = {}, -- same for ambient sound mutes
    _lastNPCMutedFIDs = {}, -- same for NPC sound mutes
    _lastMountMutedFIDs = {}, -- same for mount sound mutes
    _lastVoxMutedFIDs = {}, -- same for vocalization mutes
    _lastWeaponMutedFIDs = {}, -- same for weapon impact mutes
    _lastSheatheMutedFIDs = {}, -- same for sheathe / draw mutes
    _lastTransientFIDs = {}, -- Classic transient cast-window mutes; drained at OnEnable
    _lastPrecastFIDs = {}, -- Aggressive precast mutes; balances MuteSoundFile calls across /reload
  },
}

---------------------------------------------------------------------------
-- Helpers
---------------------------------------------------------------------------
local function msg(s)
  DEFAULT_CHAT_FRAME:AddMessage("|cff00d1ff[Resonance]|r " .. tostring(s))
end

-- Resolve the spell name API once; C_Spell.GetSpellName is always
-- available on retail 10.x+ and never changes during the session.
local _GetSpellName = (C_Spell and C_Spell.GetSpellName) or GetSpellInfo
local function getSpellName(spellID)
  return _GetSpellName(spellID)
end

local function invalidateSpellNameIndex()
  spellNameIndexDirty = true
end

-- Convert any legacy singular impact fields (impactSound/impactDelay/
-- impactDuration) into the list form (cfg.impacts = { { delay, sound, ... } }).
-- Harmless no-op when already migrated.
local function migrateImpacts(cfg)
  if cfg and cfg.impactSound and not cfg.impacts then
    cfg.impacts = {
      { sound = cfg.impactSound, delay = cfg.impactDelay or 1.0, duration = cfg.impactDuration },
    }
    cfg.impactSound = nil
    cfg.impactDelay = nil
    cfg.impactDuration = nil
  end
end

-- Schedule impact sounds for a cast. Called from UNIT_SPELLCAST_SUCCEEDED
-- BEFORE the cast-sound debounce / multi-hit-window checks so that rapid
-- recasts (spam Wrath) still fire impacts even when the cast sound itself
-- gets deduplicated. Forward-declared and assigned below (after
-- playSoundCollection is defined).
local scheduleImpactsForCast

-- Faction overrides: cfg.horde / cfg.alliance are bundles shaped like the
-- base cfg (fields: sound, precastSound, impacts, duration, precastDuration,
-- loop, precastLoop). Each field falls back independently to the base cfg
-- when not set on the faction bundle.
--
-- Legacy (pre-bundle) fields were cfg.hordeSound / cfg.allianceSound, which
-- only varied the main cast sound. migrateFaction lifts those into the new
-- nested form; resolveFactionField falls back to them if migration somehow
-- did not run (e.g. a saved-vars import from an older version).
local function migrateFaction(cfg)
  if not cfg then
    return
  end
  -- Merge legacy hordeSound/allianceSound into the bundle. Defensive about
  -- mixed states (bundle exists but has no .sound): copy into the bundle's
  -- sound slot instead of dropping the legacy value.
  if cfg.hordeSound then
    if not cfg.horde then
      cfg.horde = { sound = cfg.hordeSound }
    elseif cfg.horde.sound == nil then
      cfg.horde.sound = cfg.hordeSound
    end
    cfg.hordeSound = nil
  end
  if cfg.allianceSound then
    if not cfg.alliance then
      cfg.alliance = { sound = cfg.allianceSound }
    elseif cfg.alliance.sound == nil then
      cfg.alliance.sound = cfg.allianceSound
    end
    cfg.allianceSound = nil
  end
end

-- Per-field faction resolution: returns the faction bundle's value if set,
-- the legacy field for backward compat on the 'sound' field only, or the
-- base cfg value. Caller is responsible for migration before relying on
-- this for writes; reads work regardless.
local function resolveFactionField(cfg, fieldName)
  if not cfg then
    return nil
  end
  local faction = UnitFactionGroup("player")
  local bundle
  if faction == "Horde" then
    bundle = cfg.horde
  elseif faction == "Alliance" then
    bundle = cfg.alliance
  end
  if bundle and bundle[fieldName] ~= nil then
    return bundle[fieldName]
  end
  -- Legacy fallback: only cfg.hordeSound / cfg.allianceSound existed, and
  -- both overrode cfg.sound. No legacy equivalents for precastSound etc.
  if fieldName == "sound" then
    if faction == "Horde" and cfg.hordeSound ~= nil then
      return cfg.hordeSound
    elseif faction == "Alliance" and cfg.allianceSound ~= nil then
      return cfg.allianceSound
    end
  end
  return cfg[fieldName]
end

-- Strip an optional trailing "horde"/"alliance" token from a slash-command
-- argument string. Returns (argsWithoutFaction, factionName-or-nil). The
-- trailing token must be whitespace-separated; paths containing "horde" as
-- an inner segment are unaffected. Edge case: a file literally named
-- "horde" / "alliance" as the last token would be misparsed — documented.
local function parseFactionSuffix(args)
  if not args or args == "" then
    return args, nil
  end
  local prefix, last = args:match("^(.-)%s+(%S+)%s*$")
  if prefix and last then
    local lower = last:lower()
    if lower == "horde" or lower == "alliance" then
      return prefix, lower
    end
  end
  return args, nil
end

-- Resolve the write-target cfg bundle for a faction name ("horde"/"alliance"/
-- nil). Auto-creates the bundle on demand.
local function getFactionBundle(cfg, faction)
  if not faction then
    return cfg
  end
  cfg[faction] = cfg[faction] or {}
  return cfg[faction]
end

-- Read-only variant: returns the bundle if it exists, else nil. Never
-- creates.
local function peekFactionBundle(cfg, faction)
  if not cfg then
    return nil
  end
  if not faction then
    return cfg
  end
  return cfg[faction]
end

local function factionLabel(faction)
  if faction == "horde" then
    return "Horde "
  elseif faction == "alliance" then
    return "Alliance "
  end
  return ""
end

-- Remove a faction bundle if it has no remaining fields. Harmless no-op
-- when the bundle is absent or still populated.
local function pruneEmptyFactionBundle(cfg, faction)
  if not faction or not cfg then
    return
  end
  local b = cfg[faction]
  if b and next(b) == nil then
    cfg[faction] = nil
  end
end

-- Remove a spell_config entry if every field is empty/nil. Called after
-- any slash command that might leave behind a stub (e.g. /res cast clear
-- on a spell with only cast sound).
local function dropEmptyCfgIfUnused(sid)
  if not db or not db.spell_config then
    return
  end
  local cfg = db.spell_config[sid]
  if not cfg then
    return
  end
  if
    cfg.sound ~= nil
    or cfg.precastSound
    or cfg.impacts
    or cfg.horde
    or cfg.alliance
    or cfg.hordeSound
    or cfg.allianceSound
    or cfg.trigger
    or cfg.duration
    or cfg.precastDuration
    or cfg.loop
    or cfg.precastLoop
    or cfg.channel
    or cfg.multiHitWindow
    or cfg.muteExclusions
  then
    return
  end
  db.spell_config[sid] = nil
end

-- Shared dispatcher for /res cast and /res precast. Both work on a single
-- sound field (scalar or list with sequence/random mode flag), plus a
-- duration and loop field. `label` is "cast" or "precast" (used for user
-- messages). `fields` names the three cfg subfields to write to.
local function handlePhaseSoundCommand(rest, label, fields)
  local sub, rest2 = rest:match("^(%S+)%s*(.*)$")
  sub = (sub or ""):lower()
  local args, faction = parseFactionSuffix(rest2 or "")
  local fLabel = factionLabel(faction)
  local soundField = fields.sound
  local durField = fields.duration
  local loopField = fields.loop

  local function usage(line)
    msg(L["Usage: /res %s %s"]:format(label, line))
  end

  if sub == "add" then
    local sid, snd = args:match("^(%d+)%s+(.+)$")
    sid = sid and tonumber(sid)
    if not sid or not snd or snd == "" then
      usage("add <spellID> <path|fid> [horde|alliance]")
      return
    end
    local parsed = tonumber(snd) or snd
    local cfg = db.spell_config[sid]
    if not cfg then
      cfg = {}
      db.spell_config[sid] = cfg
    end
    migrateFaction(cfg)
    local bundle = getFactionBundle(cfg, faction)
    local current = bundle[soundField]
    if current == nil then
      bundle[soundField] = parsed
    elseif type(current) == "table" then
      current[#current + 1] = parsed
    else
      bundle[soundField] = { current, parsed }
    end
    invalidateSpellNameIndex()
    msg(L["Added %s%s sound for spellID %d: %s"]:format(fLabel, label, sid, tostring(parsed)))
  elseif sub == "list" or sub == "show" then
    local sid = tonumber(args:match("^(%d+)") or "")
    if not sid then
      usage("list <spellID> [horde|alliance]")
      return
    end
    local cfg = db.spell_config[sid]
    if cfg then
      migrateFaction(cfg)
    end
    local bundle = peekFactionBundle(cfg, faction)
    local val = bundle and bundle[soundField]
    if val == nil then
      msg(L["No %s%s sound for spellID %d."]:format(fLabel, label, sid))
      return
    end
    local name = getSpellName(sid) or ""
    msg(L["%s%s sounds for %s (spellID %d):"]:format(fLabel, label, name ~= "" and name or "<?>", sid))
    if type(val) == "table" then
      for i, s in ipairs(val) do
        msg(L["  [%d] %s"]:format(i, tostring(s)))
      end
      if val.random then
        for i, s in ipairs(val.random) do
          msg(L["  random[%d] %s"]:format(i, tostring(s)))
        end
      end
      local mode = val.sequence and "sequence" or (val.random and "random" or "standard")
      msg(L["  mode: %s"]:format(mode))
    else
      msg(L["  [scalar] %s"]:format(tostring(val)))
    end
    if bundle[durField] then
      msg(L["  duration: %.2fs"]:format(bundle[durField]))
    end
    if bundle[loopField] then
      local lv = bundle[loopField]
      msg(L["  loop: %s"]:format(type(lv) == "number" and ("%d iterations"):format(lv) or "on"))
    end
  elseif sub == "remove" then
    local sid, idx = args:match("^(%d+)%s+(%d+)$")
    sid = sid and tonumber(sid)
    idx = idx and tonumber(idx)
    if not sid or not idx then
      usage("remove <spellID> <index> [horde|alliance]")
      return
    end
    local cfg = db.spell_config[sid]
    local bundle = peekFactionBundle(cfg, faction)
    local val = bundle and bundle[soundField]
    if val == nil then
      msg(L["No %s%s sound for spellID %d."]:format(fLabel, label, sid))
      return
    end
    if type(val) ~= "table" then
      if idx ~= 1 then
        msg(L["Index out of range."])
        return
      end
      bundle[soundField] = nil
    else
      if not val[idx] then
        msg(L["Index out of range."])
        return
      end
      table.remove(val, idx)
      if #val == 0 and not val.random then
        bundle[soundField] = nil
      elseif #val == 1 and not val.sequence and not val.random then
        bundle[soundField] = val[1]
      end
    end
    pruneEmptyFactionBundle(cfg, faction)
    dropEmptyCfgIfUnused(sid)
    invalidateSpellNameIndex()
    msg(L["Removed %s%s sound #%d from spellID %d."]:format(fLabel, label, idx, sid))
  elseif sub == "clear" then
    local sid = tonumber(args:match("^(%d+)") or "")
    if not sid then
      usage("clear <spellID> [horde|alliance]")
      return
    end
    local cfg = db.spell_config[sid]
    if cfg then
      local bundle = peekFactionBundle(cfg, faction)
      if bundle then
        bundle[soundField] = nil
        bundle[durField] = nil
        bundle[loopField] = nil
      end
      -- Also drop legacy hordeSound/allianceSound if clearing faction cast.
      if faction == "horde" and soundField == "sound" then
        cfg.hordeSound = nil
      end
      if faction == "alliance" and soundField == "sound" then
        cfg.allianceSound = nil
      end
      pruneEmptyFactionBundle(cfg, faction)
      dropEmptyCfgIfUnused(sid)
    end
    invalidateSpellNameIndex()
    msg(L["Cleared %s%s sound for spellID %d."]:format(fLabel, label, sid))
  elseif sub == "mode" then
    local sid, mode = args:match("^(%d+)%s+(%S+)$")
    sid = sid and tonumber(sid)
    mode = mode and mode:lower()
    if not sid or not (mode == "standard" or mode == "sequence" or mode == "random") then
      usage("mode <spellID> standard|sequence|random [horde|alliance]")
      return
    end
    local cfg = db.spell_config[sid]
    if not cfg then
      msg(L["No spell config for spellID %d. Add a sound first."]:format(sid))
      return
    end
    migrateFaction(cfg)
    local bundle = peekFactionBundle(cfg, faction)
    local val = bundle and bundle[soundField]
    if val == nil then
      msg(L["No %s%s sound to set mode on."]:format(fLabel, label))
      return
    end
    if type(val) ~= "table" then
      val = { val }
      bundle[soundField] = val
    end
    val.sequence = nil
    if mode == "sequence" then
      val.sequence = true
      val.random = nil
    elseif mode == "random" then
      val.random = val.random or {}
      for i = #val, 1, -1 do
        val.random[#val.random + 1] = val[i]
        val[i] = nil
      end
    else
      val.random = nil
    end
    msg(L["Set %s%s mode for spellID %d: %s"]:format(fLabel, label, sid, mode))
  elseif sub == "loop" then
    local sid, valStr = args:match("^(%d+)%s+(%S+)$")
    sid = sid and tonumber(sid)
    if not sid or not valStr then
      usage("loop <spellID> on|off|<N> [horde|alliance]")
      return
    end
    local cfg = db.spell_config[sid]
    if not cfg then
      cfg = {}
      db.spell_config[sid] = cfg
    end
    migrateFaction(cfg)
    local bundle = getFactionBundle(cfg, faction)
    local lower = valStr:lower()
    if lower == "off" or lower == "0" or lower == "false" then
      bundle[loopField] = nil
      pruneEmptyFactionBundle(cfg, faction)
      dropEmptyCfgIfUnused(sid)
      msg(L["Disabled %s%s loop for spellID %d."]:format(fLabel, label, sid))
    elseif lower == "on" or lower == "true" then
      bundle[loopField] = true
      msg(L["Enabled %s%s loop for spellID %d."]:format(fLabel, label, sid))
    else
      local n = tonumber(valStr)
      if not n or n <= 0 then
        usage("loop <spellID> on|off|<N> [horde|alliance]")
        return
      end
      bundle[loopField] = n
      msg(L["Set %s%s loop for spellID %d: %d iterations"]:format(fLabel, label, sid, n))
    end
  elseif sub == "duration" then
    local sid, valStr = args:match("^(%d+)%s+(%S+)$")
    sid = sid and tonumber(sid)
    if not sid or not valStr then
      usage("duration <spellID> <seconds|off> [horde|alliance]")
      return
    end
    local cfg = db.spell_config[sid]
    if not cfg then
      cfg = {}
      db.spell_config[sid] = cfg
    end
    migrateFaction(cfg)
    local bundle = getFactionBundle(cfg, faction)
    local lower = valStr:lower()
    if lower == "off" or lower == "0" or lower == "clear" then
      bundle[durField] = nil
      pruneEmptyFactionBundle(cfg, faction)
      dropEmptyCfgIfUnused(sid)
      msg(L["Cleared %s%s duration for spellID %d."]:format(fLabel, label, sid))
    else
      local n = tonumber(valStr)
      if not n or n <= 0 then
        usage("duration <spellID> <seconds|off> [horde|alliance]")
        return
      end
      bundle[durField] = n
      msg(L["Set %s%s duration for spellID %d: %.2fs"]:format(fLabel, label, sid, n))
    end
  else
    msg(L["Usage: /res %s <add|list|remove|clear|mode|loop|duration> <spellID> [...]"]:format(label))
  end
end

local function getSpellNameIndex()
  if not spellNameIndexDirty then
    return spellNameIndex
  end
  wipe(spellNameIndex)
  for sid, cfg in pairs(db and db.spell_config or {}) do
    if cfg.sound then
      local name = _GetSpellName(sid)
      if name and name ~= "" then
        spellNameIndex[name:lower()] = cfg
      end
    end
  end
  spellNameIndexDirty = false
  return spellNameIndex
end

local function sanitizeFilename(name)
  name = name:gsub('[\\/:*?"<>|]', "")
  name = name:gsub("%s+$", "")
  return name
end

local function normalizePath(p)
  if not p or p == "" then
    return nil
  end
  p = p:gsub("/", "\\")
  -- Pass through any path that is already absolute to the WoW client's
  -- virtual filesystem. "Interface\..." covers addon and UI assets;
  -- "Sound\..." covers the client's built-in sound tree (used by many
  -- LibSharedMedia entries). Anything else is treated as relative to
  -- the Resonance addon root.
  local lower = p:lower()
  if lower:match("^interface\\") or lower:match("^sound\\") then
    return p
  end
  return ADDON_ROOT .. p
end

-- Pre-compute constant path prefixes (avoids per-cast concatenation).
-- External base lives in the Resonance_Sounds/ sibling addon so that
-- addon managers (WoWUp, CurseForge, etc.) don't wipe user-extracted
-- files when they replace the Resonance/ folder on update.
local VANILLA_SOUND_BASE_EXTERNAL = CUSTOM_SOUNDS_ROOT .. "vanilla\\"
local VANILLA_SOUND_BASE_BUNDLED = ADDON_ROOT .. "sounds\\vanilla\\"

-- ChatCommand subcommand handlers extracted to file scope.
-- Lua caps each function at 60 upvalues; keeping these out of ChatCommand
-- prevents hitting that cap as the command palette grows.

local function handleImpactCommand(rest)
  local sub, rest2 = rest:match("^(%S+)%s*(.*)$")
  sub = (sub or ""):lower()
  local args, faction = parseFactionSuffix(rest2 or "")
  local fLabel = factionLabel(faction)

  if sub == "add" then
    local sid, delay, snd = args:match("^(%d+)%s+(%S+)%s+(.+)$")
    sid = sid and tonumber(sid)
    delay = delay and tonumber(delay)
    if not sid or not delay or delay <= 0 or not snd or snd == "" then
      msg(L["Usage: /res impact add <spellID> <delay> <path|fid> [horde|alliance]"])
      return
    end
    local parsed = tonumber(snd) or snd
    local cfg = db.spell_config[sid]
    if not cfg then
      cfg = {}
      db.spell_config[sid] = cfg
    end
    migrateImpacts(cfg)
    migrateFaction(cfg)
    local bundle = getFactionBundle(cfg, faction)
    migrateImpacts(bundle)
    bundle.impacts = bundle.impacts or {}
    local replaced
    for i, entry in ipairs(bundle.impacts) do
      if entry.sound == parsed then
        entry.delay = delay
        replaced = i
        break
      end
    end
    if replaced then
      msg(
        ("Updated %simpact [%d] for spellID %d: delay=%.2fs, sound=%s"):format(
          fLabel,
          replaced,
          sid,
          delay,
          tostring(parsed)
        )
      )
    else
      bundle.impacts[#bundle.impacts + 1] = { delay = delay, sound = parsed }
      msg(
        ("Added %simpact [%d] for spellID %d: delay=%.2fs, sound=%s"):format(
          fLabel,
          #bundle.impacts,
          sid,
          delay,
          tostring(parsed)
        )
      )
    end
    invalidateSpellNameIndex()
  elseif sub == "list" or sub == "show" then
    local sid = tonumber(args:match("^(%d+)") or "")
    if not sid then
      msg(L["Usage: /res impact list <spellID> [horde|alliance]"])
      return
    end
    local cfg = db.spell_config[sid]
    if cfg then
      migrateImpacts(cfg)
      migrateFaction(cfg)
    end
    local bundle = peekFactionBundle(cfg, faction)
    if bundle then
      migrateImpacts(bundle)
    end
    local impacts = bundle and bundle.impacts
    if not impacts or #impacts == 0 then
      msg(L["No %simpact sounds for spellID %d."]:format(fLabel, sid))
      return
    end
    local name = getSpellName(sid) or ""
    msg(L["%sImpact sounds for %s (spellID %d):"]:format(fLabel, name ~= "" and name or "<?>", sid))
    for i, entry in ipairs(impacts) do
      msg(L["  [%d] delay=%.2fs  sound=%s"]:format(i, entry.delay or 0, tostring(entry.sound)))
    end
  elseif sub == "remove" then
    local sid, idx = args:match("^(%d+)%s+(%d+)$")
    sid = sid and tonumber(sid)
    idx = idx and tonumber(idx)
    if not sid or not idx then
      msg(L["Usage: /res impact remove <spellID> <index> [horde|alliance]"])
      return
    end
    local cfg = db.spell_config[sid]
    if cfg then
      migrateImpacts(cfg)
      migrateFaction(cfg)
    end
    local bundle = peekFactionBundle(cfg, faction)
    if bundle then
      migrateImpacts(bundle)
    end
    if not bundle or not bundle.impacts or not bundle.impacts[idx] then
      msg(L["No %simpact sound #%d for spellID %d."]:format(fLabel, idx, sid))
      return
    end
    table.remove(bundle.impacts, idx)
    if #bundle.impacts == 0 then
      bundle.impacts = nil
    end
    pruneEmptyFactionBundle(cfg, faction)
    dropEmptyCfgIfUnused(sid)
    msg(L["Removed %simpact [%d] from spellID %d."]:format(fLabel, idx, sid))
  elseif sub == "clear" or sub == "off" then
    local sid = tonumber(args:match("^(%d+)") or "")
    if not sid then
      msg(L["Usage: /res impact clear <spellID> [horde|alliance]"])
      return
    end
    local cfg = db.spell_config[sid]
    if cfg then
      local bundle = peekFactionBundle(cfg, faction)
      if bundle then
        bundle.impacts = nil
        bundle.impactSound = nil
        bundle.impactDelay = nil
        bundle.impactDuration = nil
      end
      pruneEmptyFactionBundle(cfg, faction)
      dropEmptyCfgIfUnused(sid)
    end
    msg(L["Cleared %simpact sounds for spellID %d."]:format(fLabel, sid))
  else
    msg(L["Usage: /res impact <add|list|remove|clear> ..."])
    msg(L["  /res impact add <spellID> <delay> <path|fid> [horde|alliance]"])
    msg(L["  /res impact list <spellID> [horde|alliance]"])
    msg(L["  /res impact remove <spellID> <index> [horde|alliance]"])
    msg(L["  /res impact clear <spellID> [horde|alliance]"])
  end
end

local function handleTriggerCommand(rest)
  local sid, trig = rest:match("^(%d+)%s+(%S+)$")
  sid = sid and tonumber(sid)
  trig = trig and trig:lower()
  local trigMap = {
    cast = "cast",
    precast = "precast",
    both = "precast_and_cast",
    precast_and_cast = "precast_and_cast",
  }
  local resolved = trig and trigMap[trig]
  if not sid or not resolved then
    msg(L["Usage: /res trigger <spellID> cast|precast|both"])
    return
  end
  local cfg = db.spell_config[sid]
  if not cfg then
    cfg = {}
    db.spell_config[sid] = cfg
  end
  cfg.trigger = resolved
  msg(L["Set trigger for spellID %d: %s"]:format(sid, resolved))
end

local function handleChannelCommand(rest)
  local sid, ch = rest:match("^(%d+)%s+(%S+)$")
  sid = sid and tonumber(sid)
  if not sid or not ch then
    msg(L["Usage: /res channel <spellID> Master|SFX|Dialog|Music|Ambience|off"])
    return
  end
  local cfg = db.spell_config[sid]
  if not cfg then
    cfg = {}
    db.spell_config[sid] = cfg
  end
  local lower = ch:lower()
  if lower == "off" or lower == "clear" then
    cfg.channel = nil
    dropEmptyCfgIfUnused(sid)
    msg(L["Cleared channel override for spellID %d."]:format(sid))
  else
    local canonical = ({
      master = "Master",
      sfx = "SFX",
      dialog = "Dialog",
      music = "Music",
      ambience = "Ambience",
    })[lower]
    if not canonical then
      msg(L["Usage: /res channel <spellID> Master|SFX|Dialog|Music|Ambience|off"])
      return
    end
    cfg.channel = canonical
    msg(L["Set channel for spellID %d: %s"]:format(sid, canonical))
  end
end

local function handleMultihitCommand(rest)
  local sid, val = rest:match("^(%d+)%s+(%S+)$")
  sid = sid and tonumber(sid)
  if not sid or not val then
    msg(L["Usage: /res multihit <spellID> <seconds|off>"])
    return
  end
  local cfg = db.spell_config[sid]
  if not cfg then
    cfg = {}
    db.spell_config[sid] = cfg
  end
  local lower = val:lower()
  if lower == "off" or lower == "0" or lower == "clear" then
    cfg.multiHitWindow = nil
    dropEmptyCfgIfUnused(sid)
    msg(L["Cleared multi-hit window for spellID %d."]:format(sid))
  else
    local n = tonumber(val)
    if not n or n <= 0 then
      msg(L["Usage: /res multihit <spellID> <seconds|off>"])
      return
    end
    cfg.multiHitWindow = n
    msg(L["Set multi-hit window for spellID %d: %.2fs"]:format(sid, n))
  end
end

local function handleUnmuteCommand(rest)
  local sub, rest2 = rest:match("^(%S+)%s*(.*)$")
  sub = (sub or ""):lower()
  if sub == "add" then
    local sid, fid = (rest2 or ""):match("^(%d+)%s+(%d+)$")
    sid = sid and tonumber(sid)
    fid = fid and tonumber(fid)
    if not sid or not fid then
      msg(L["Usage: /res unmute add <spellID> <fileDataID>"])
      return
    end
    local cfg = db.spell_config[sid]
    if not cfg then
      cfg = {}
      db.spell_config[sid] = cfg
    end
    cfg.muteExclusions = cfg.muteExclusions or {}
    cfg.muteExclusions[fid] = true
    msg(L["Added unmute for spellID %d: FID %d"]:format(sid, fid))
  elseif sub == "list" or sub == "show" then
    local sid = tonumber((rest2 or ""):match("^(%d+)") or "")
    if not sid then
      msg(L["Usage: /res unmute list <spellID>"])
      return
    end
    local cfg = db.spell_config[sid]
    local ex = cfg and cfg.muteExclusions
    if not ex or next(ex) == nil then
      msg(L["No unmute entries for spellID %d."]:format(sid))
      return
    end
    local name = getSpellName(sid) or ""
    msg(L["Unmute list for %s (spellID %d):"]:format(name ~= "" and name or "<?>", sid))
    for fid in pairs(ex) do
      msg(L["  FID %d"]:format(fid))
    end
  elseif sub == "remove" then
    local sid, fid = (rest2 or ""):match("^(%d+)%s+(%d+)$")
    sid = sid and tonumber(sid)
    fid = fid and tonumber(fid)
    if not sid or not fid then
      msg(L["Usage: /res unmute remove <spellID> <fileDataID>"])
      return
    end
    local cfg = db.spell_config[sid]
    if cfg and cfg.muteExclusions then
      cfg.muteExclusions[fid] = nil
      if next(cfg.muteExclusions) == nil then
        cfg.muteExclusions = nil
      end
      dropEmptyCfgIfUnused(sid)
    end
    msg(L["Removed unmute for spellID %d: FID %d"]:format(sid, fid))
  elseif sub == "clear" then
    local sid = tonumber((rest2 or ""):match("^(%d+)") or "")
    if not sid then
      msg(L["Usage: /res unmute clear <spellID>"])
      return
    end
    local cfg = db.spell_config[sid]
    if cfg then
      cfg.muteExclusions = nil
      dropEmptyCfgIfUnused(sid)
    end
    msg(L["Cleared unmute entries for spellID %d."]:format(sid))
  else
    msg(L["Usage: /res unmute <add|list|remove|clear> <spellID> [<fileDataID>]"])
  end
end

local function handleSpellCommand(rest)
  local sub, rest2 = rest:match("^(%S+)%s*(.*)$")
  sub = (sub or ""):lower()
  if sub == "show" or sub == "dump" then
    local sid = tonumber((rest2 or ""):match("^(%d+)") or "")
    if not sid then
      msg(L["Usage: /res spell show <spellID>"])
      return
    end
    local cfg = db.spell_config[sid]
    if not cfg then
      msg(L["No spell config for spellID %d."]:format(sid))
      return
    end
    migrateImpacts(cfg)
    migrateFaction(cfg)
    local name = getSpellName(sid) or ""
    msg(L["Spell config for %s (spellID %d):"]:format(name ~= "" and name or "<?>", sid))
    local function dumpBundle(prefix, b)
      if not b then
        return
      end
      if b.sound ~= nil then
        msg(L["  %ssound = %s"]:format(prefix, tostring(b.sound)))
      end
      if b.precastSound ~= nil then
        msg(L["  %sprecastSound = %s"]:format(prefix, tostring(b.precastSound)))
      end
      if b.duration then
        msg(L["  %sduration = %.2fs"]:format(prefix, b.duration))
      end
      if b.precastDuration then
        msg(L["  %sprecastDuration = %.2fs"]:format(prefix, b.precastDuration))
      end
      if b.loop then
        msg(L["  %sloop = %s"]:format(prefix, tostring(b.loop)))
      end
      if b.precastLoop then
        msg(L["  %sprecastLoop = %s"]:format(prefix, tostring(b.precastLoop)))
      end
      if b.impacts and #b.impacts > 0 then
        msg(L["  %simpacts: %d entries"]:format(prefix, #b.impacts))
        for i, e in ipairs(b.impacts) do
          msg(L["    [%d] delay=%.2fs sound=%s"]:format(i, e.delay or 0, tostring(e.sound)))
        end
      end
    end
    dumpBundle("", cfg)
    if cfg.trigger then
      msg(L["  trigger = %s"]:format(cfg.trigger))
    end
    if cfg.channel then
      msg(L["  channel = %s"]:format(cfg.channel))
    end
    if cfg.multiHitWindow then
      msg(L["  multiHitWindow = %.2fs"]:format(cfg.multiHitWindow))
    end
    if cfg.muteExclusions then
      local fids = {}
      for fid in pairs(cfg.muteExclusions) do
        fids[#fids + 1] = fid
      end
      msg(L["  muteExclusions (%d): %s"]:format(#fids, table.concat(fids, ", ")))
    end
    if cfg.horde then
      msg(L["  [Horde overrides]"])
      dumpBundle("    ", cfg.horde)
    end
    if cfg.alliance then
      msg(L["  [Alliance overrides]"])
      dumpBundle("    ", cfg.alliance)
    end
  elseif sub == "remove" or sub == "delete" then
    local sid = tonumber((rest2 or ""):match("^(%d+)") or "")
    if not sid then
      msg(L["Usage: /res spell remove <spellID>"])
      return
    end
    db.spell_config[sid] = nil
    invalidateSpellNameIndex()
    msg(L["Removed spell config for spellID %d."]:format(sid))
  else
    msg(L["Usage: /res spell <show|remove> <spellID>"])
  end
end

local function resolveLocalFileForSpellName(spellName)
  if not spellName or spellName == "" then
    return nil
  end

  local mapped = db.local_file_overrides_by_spell_name and db.local_file_overrides_by_spell_name[spellName]
  if mapped then
    return { normalizePath(mapped) }
  end

  local name = sanitizeFilename(spellName)
  return {
    VANILLA_SOUND_BASE_EXTERNAL .. name .. ".wav",
    VANILLA_SOUND_BASE_EXTERNAL .. name .. ".ogg",
    VANILLA_SOUND_BASE_BUNDLED .. name .. ".wav",
    VANILLA_SOUND_BASE_BUNDLED .. name .. ".ogg",
  }
end

local activeSpellChannel = nil -- per-spell channel override, set during playResolvedSound

local function getChannel()
  return activeSpellChannel or (db and db.soundChannel) or "Master"
end

-- Resolve a user-entered sound value (whatever's stored in a Resonance config
-- input) into something PlaySoundFile can actually play. Handles four cases
-- in order of likelihood:
--   1. number             → already a FileDataID
--   2. numeric string     → FileDataID typed by the user (AceConfig stores
--                            input values as strings, so "567473" is common)
--   3. LSM-registered name → resolved via LibSharedMedia HashTable; this
--                            covers the user's own custom-sound entries
--                            (synced to LSM at load) and any LSM entries
--                            shared by other addons.
--   4. addon-relative or absolute path
-- Returns nil if `value` is empty / unresolvable.
--
-- Uses `Resonance.getLSM` (table access, resolved at call time) instead of
-- the local `getLSM` because `previewSound` is declared above the latter.
local function resolveSoundValue(value)
  if value == nil or value == "" then
    return nil
  end
  if type(value) == "number" then
    return value
  end
  local s = tostring(value)
  local n = tonumber(s)
  if n then
    return n
  end
  local getLSMFn = Resonance.getLSM
  local lsm = getLSMFn and getLSMFn()
  if lsm and lsm.HashTable then
    local ok, lsmTable = pcall(lsm.HashTable, lsm, "sound")
    if ok and type(lsmTable) == "table" then
      local fetched = lsmTable[s]
      if fetched ~= nil and fetched ~= "" then
        return fetched
      end
    end
  end
  return normalizePath(s)
end
Resonance.resolveSoundValue = resolveSoundValue

local function previewSound(value)
  local resolved = resolveSoundValue(value)
  if not resolved then
    return false
  end
  return PlaySoundFile(resolved, getChannel())
end

-- LibSharedMedia-3.0 is an optional integration. If present, we register
-- the user's Resonance custom sounds with LSM so other addons (WeakAuras,
-- BigWigs, etc.) can see them, and we surface LSM-registered sounds from
-- other addons in our own search dropdowns. The addon functions identically
-- whether or not LSM is available — the library is never a hard dependency.
local LSM_LIB
local function getLSM()
  if LSM_LIB ~= nil then
    return LSM_LIB or nil
  end
  local ok, lib = pcall(function()
    return LibStub and LibStub("LibSharedMedia-3.0", true)
  end)
  LSM_LIB = (ok and lib) or false
  return LSM_LIB or nil
end

-- Register all entries in db.customSounds with LSM under MediaType "sound".
-- LSM has no unregister API, but :Register is idempotent for the same
-- (type, name, path) triple and will refuse to overwrite a different
-- addon's registration — both properties make this safe to call freely.
local function syncCustomSoundsToLSM()
  local lsm = getLSM()
  if not lsm or not lsm.Register then
    return
  end
  local custom = db and db.customSounds
  if not custom then
    return
  end
  for _, entry in ipairs(custom) do
    if entry and entry.name and entry.file and entry.name ~= "" and entry.file ~= "" then
      local path = CUSTOM_SOUNDS_ROOT .. entry.file
      pcall(lsm.Register, lsm, "sound", entry.name, path)
    end
  end
end

local function clearAlertState()
  lastLoCInterrupt = nil
  pendingInterrupt = nil
  for key, handle in pairs(alertHandles) do
    StopSound(handle)
    alertHandles[key] = nil
  end
end

local function refreshAlertEvents()
  local alerts = db.alerts or {}
  -- Interrupt needs UNIT_SPELLCAST_INTERRUPTED
  if alerts.interrupt and alerts.interrupt.enabled then
    Resonance:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED")
  else
    pcall(function()
      Resonance:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
    end)
  end
  -- LOSS_OF_CONTROL_ADDED is shared by interrupt + lossOfControl
  local needLoC = (alerts.interrupt and alerts.interrupt.enabled)
    or (alerts.lossOfControl and alerts.lossOfControl.enabled)
  if needLoC then
    Resonance:RegisterEvent("LOSS_OF_CONTROL_ADDED")
  else
    pcall(function()
      Resonance:UnregisterEvent("LOSS_OF_CONTROL_ADDED")
    end)
  end
  -- Death
  if alerts.death and alerts.death.enabled then
    Resonance:RegisterEvent("PLAYER_DEAD")
  else
    pcall(function()
      Resonance:UnregisterEvent("PLAYER_DEAD")
    end)
  end
end
Resonance.refreshAlertEvents = refreshAlertEvents
Resonance.clearAlertState = clearAlertState

local function clearAlertEvents()
  pcall(function()
    Resonance:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
  end)
  pcall(function()
    Resonance:UnregisterEvent("LOSS_OF_CONTROL_ADDED")
  end)
  pcall(function()
    Resonance:UnregisterEvent("PLAYER_DEAD")
  end)
end

-- Ref-counted tracker for FIDs with in-flight playback.
-- Prevents the first timer from re-muting a FID that still has active sounds
-- (e.g., rapid casts of the same spell like Whirlwind in a proc window).
local activePlaybackFIDs = {}

local function scheduleStopSound(handle, duration)
  if duration and handle then
    C_Timer.After(duration, function()
      StopSound(handle, 0)
    end)
  end
end

local function playAlertSound(key, debugMsg)
  local alerts = db.alerts
  if not alerts then
    return
  end
  local cfg = alerts[key]
  if not cfg or not cfg.sound then
    return
  end
  if db.debug and debugMsg then
    msg(debugMsg)
  end
  if alertHandles[key] then
    StopSound(alertHandles[key])
    alertHandles[key] = nil
  end
  local ok, handle = previewSound(cfg.sound)
  if ok then
    alertHandles[key] = handle
    if cfg.duration then
      scheduleStopSound(handle, cfg.duration)
    end
  end
end

-- Precast sound handles currently playing. Populated during a precast
-- playback pass via the capturePrecastHandles flag; stopped on
-- UNIT_SPELLCAST_STOP (fires for success/fail/interrupt). This ensures the
-- precast sound ends when the cast bar ends, independent of the sound's
-- natural length — users don't need to hand-tune cfg.precastDuration.
local activePrecastHandles = {}
local capturePrecastHandles = false
-- Track whether the currently active loop ticker is playing a precast
-- sound, so UNIT_SPELLCAST_STOP only cancels loops that belong to the
-- cast that just ended (not a cast-complete loop that just started).
local activeLoopIsPrecast = false

local function playOneSoundWithUnmute(snd, dbg, duration)
  local isNum = type(snd) == "number"
  local isMuted = isNum
    and (
      (db.mute_file_data_ids and db.mute_file_data_ids[snd])
      or autoMutedFIDs[snd]
      or voxMutedFIDs[snd]
      or weaponMutedFIDs[snd]
      or creatureMutedFIDs[snd]
      or autoShotMutedFIDs[snd]
      or professionMutedFIDs[snd]
      or npcMutedFIDs[snd]
      or ambientMutedFIDs[snd]
      or transientMutedFIDs[snd]
      or precastMutedFIDs[snd]
    )
  if dbg then
    msg(
      ("  Playing: %s (muted: %s, duration: %s)"):format(
        tostring(snd),
        tostring(isMuted and true or false),
        duration and ("%.2fs"):format(duration) or "full"
      )
    )
  end
  local handle
  if isMuted then
    local fid = snd
    local ch = getChannel()
    for _ = 1, MAX_MUTE_DEPTH do
      UnmuteSoundFile(fid)
    end
    activePlaybackFIDs[fid] = (activePlaybackFIDs[fid] or 0) + 1
    _, handle = PlaySoundFile(fid, ch)
    -- Re-mute symmetrically (same count as unmutes) to restore WoW's internal
    -- refcount, but only after the sound buffer has had time to start playing.
    C_Timer.After(0.5, function()
      local count = (activePlaybackFIDs[fid] or 1) - 1
      if count > 0 then
        activePlaybackFIDs[fid] = count
        return
      end
      activePlaybackFIDs[fid] = nil
      -- Re-mute once per active source to restore the correct refcount.
      -- The MAX_MUTE_DEPTH unmutes above guaranteed clearing to 0;
      -- re-muting per source restores only the depth the addon originally set,
      -- preventing refcount inflation that would leave sounds permanently muted.
      if autoMutedFIDs[fid] and autoMutedFIDs[fid] > 0 then
        MuteSoundFile(fid)
      end
      if db.mute_file_data_ids and db.mute_file_data_ids[fid] then
        MuteSoundFile(fid)
      end
      if weaponMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if voxMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if creatureMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if autoShotMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if professionMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if npcMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if ambientMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if transientMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
      if precastMutedFIDs[fid] then
        MuteSoundFile(fid)
      end
    end)
  elseif isNum then
    _, handle = PlaySoundFile(snd, getChannel())
  else
    _, handle = previewSound(snd)
  end
  scheduleStopSound(handle, duration)
  -- If this playback is part of a precast pass, record the handle so
  -- UNIT_SPELLCAST_STOP can stop it when the cast bar ends.
  if capturePrecastHandles and handle then
    activePrecastHandles[#activePrecastHandles + 1] = handle
  end
  return handle
end

-- Active sound loop state: cancelled on next cast or when iterations exhausted.
local activeLoop = nil -- { ticker = C_Timer ticker, cancel = function }

local function cancelActiveLoop()
  if activeLoop then
    if activeLoop.ticker then
      activeLoop.ticker:Cancel()
    end
    activeLoop = nil
  end
end

local function playSoundCollection(soundData, dbg, duration)
  if not soundData then
    return
  end
  if type(soundData) == "table" then
    -- Sequence mode: play one sound per hit, advancing through the list.
    -- Format: {snd1, snd2, ..., sequence = true}
    -- Stop the previous sound so hits produce distinct sounds even when
    -- they fire simultaneously (e.g. Rampage's 5 hits in <1 second).
    if soundData.sequence and #soundData > 0 then
      local count = #soundData
      local state = sequenceState[soundData]
      if not state then
        state = { idx = 0 }
        sequenceState[soundData] = state
      end
      state.idx = (state.idx % count) + 1
      local pick = soundData[state.idx]
      if dbg then
        msg(L["  Sequence [%d/%d]: picked %s, channel: %s"]:format(state.idx, count, tostring(pick), getChannel()))
      end
      playOneSoundWithUnmute(pick, dbg, duration)
      return
    end
    if dbg then
      msg(
        ("  spell_config: %d fixed sound(s)%s%s, channel: %s"):format(
          #soundData,
          soundData.random and (", random pool: " .. #soundData.random) or "",
          soundData.sequence and " (sequence EMPTY)" or "",
          getChannel()
        )
      )
    end
    for _, snd in ipairs(soundData) do
      playOneSoundWithUnmute(snd, dbg, duration)
    end
    if soundData.random and #soundData.random > 0 then
      local pick = soundData.random[math.random(1, #soundData.random)]
      if dbg then
        msg(L["  Random pool (%d): picked %s"]:format(#soundData.random, tostring(pick)))
      end
      playOneSoundWithUnmute(pick, dbg, duration)
    end
  else
    if dbg then
      msg(L["  spell_config: 1 sound, channel: %s"]:format(getChannel()))
    end
    playOneSoundWithUnmute(soundData, dbg, duration)
  end
end

-- Play a sound collection with optional looping.
-- loop: nil/false = no loop, true = loop until next cast, number = max iterations
-- Requires duration to know when to re-trigger.
local function playSoundCollectionWithLoop(soundData, dbg, duration, loop)
  cancelActiveLoop()
  playSoundCollection(soundData, dbg, duration)
  if not loop or not duration or duration <= 0 then
    return
  end
  local maxIter = (type(loop) == "number") and loop or 999
  local iter = 0
  local loopChannel = activeSpellChannel -- capture for ticker iterations
  local ticker
  ticker = C_Timer.NewTicker(duration, function()
    iter = iter + 1
    if iter >= maxIter then
      cancelActiveLoop()
      return
    end
    activeSpellChannel = loopChannel
    playSoundCollection(soundData, dbg, duration)
    activeSpellChannel = nil
  end)
  activeLoop = { ticker = ticker }
end

-- Assigned here (after playSoundCollection etc.) rather than above to keep
-- the forward-declared upvalue reachable. See the forward declaration for
-- why this is called before debounce in UNIT_SPELLCAST_SUCCEEDED.
-- CLEU-driven impact sound dispatch (Classic flavors only). Retail uses
-- fixed-delay C_Timer.After because 12.0.x removed addon access to
-- SPELL_DAMAGE / CombatLogGetCurrentEventInfo. On Classic, COMBAT_LOG_EVENT_UNFILTERED
-- is fully open, so we wait for the actual impact event instead of guessing.
local pendingImpacts = {} -- spellID -> { impacts, channel, dbg, expiresAt }
local CLASSIC_IMPACT_WINDOW = 5 -- seconds; covers the slowest projectile we'd reasonably expect

local CLASSIC_IMPACT_EVENTS = {
  SPELL_DAMAGE = true,
  SPELL_HEAL = true,
  SPELL_MISSED = true,
  SPELL_HEAL_ABSORBED = true,
  SPELL_DISPEL = true,
  RANGE_DAMAGE = true,
  RANGE_MISSED = true,
  -- SPELL_AURA_APPLIED covers buff/debuff-only spells (e.g. Hex, Fear) that
  -- have no SPELL_DAMAGE event.
  SPELL_AURA_APPLIED = true,
  -- Intentionally NOT SPELL_PERIODIC_*: those would refire impact every dot/hot tick.
}

function scheduleImpactsForCast(cfg, dbg, spellID)
  if not cfg then
    return
  end
  migrateImpacts(cfg)
  migrateFaction(cfg)
  if cfg.horde then
    migrateImpacts(cfg.horde)
  end
  if cfg.alliance then
    migrateImpacts(cfg.alliance)
  end
  local impacts = resolveFactionField(cfg, "impacts")
  if not impacts or #impacts == 0 then
    return
  end

  if Resonance.Flavor.registersCLEUForImpacts and spellID then
    -- Wait for the real impact via combat log. Configured per-entry delays
    -- are ignored when CLEU is available — actual impact timing replaces them.
    pendingImpacts[spellID] = {
      impacts = impacts,
      channel = cfg.channel,
      dbg = dbg,
      expiresAt = GetTime() + CLASSIC_IMPACT_WINDOW,
    }
    if dbg then
      msg(L["  Impact: waiting for combat log (Classic)"])
    end
    return
  end

  -- Retail: fire each entry on its configured fixed delay.
  for _, entry in ipairs(impacts) do
    if entry.sound and entry.delay and entry.delay > 0 then
      local eSnd = entry.sound
      local eDelay = entry.delay
      local eDur = entry.duration
      local eChan = cfg.channel
      local eDbg = dbg
      C_Timer.After(eDelay, function()
        activeSpellChannel = eChan
        if eDbg then
          msg(L["  Impact sound fired (delay=%.2fs)"]:format(eDelay))
        end
        playSoundCollection(eSnd, eDbg, eDur)
        activeSpellChannel = nil
      end)
    end
  end
end

local function fireClassicImpact(spellID)
  local pending = pendingImpacts[spellID]
  if not pending then
    return
  end
  pendingImpacts[spellID] = nil
  if GetTime() > pending.expiresAt then
    return
  end
  for _, entry in ipairs(pending.impacts) do
    if entry.sound then
      activeSpellChannel = pending.channel
      if pending.dbg then
        msg(L["  Impact sound fired (CLEU)"])
      end
      playSoundCollection(entry.sound, pending.dbg, entry.duration)
      activeSpellChannel = nil
    end
  end
end

-- luacheck: globals CombatLogGetCurrentEventInfo
function Resonance:COMBAT_LOG_EVENT_UNFILTERED()
  if not next(pendingImpacts) then
    return -- hot path early-out: nothing pending, ignore the event entirely
  end
  local _, eventType, _, sourceGUID, _, _, _, _, _, _, _, spellID = CombatLogGetCurrentEventInfo()
  if not spellID then
    return
  end
  if not CLASSIC_IMPACT_EVENTS[eventType] then
    return
  end
  if sourceGUID ~= UnitGUID("player") then
    return
  end
  fireClassicImpact(spellID)
end

local function playResolvedSound(spellID, spellName, cfg, dbg, phase)
  -- Hot-path callers pass cfg and dbg to avoid redundant lookups;
  -- other callers (e.g., /res testspell) can omit them.
  -- phase: nil/"cast" = cast complete (default), "precast" = cast bar start
  if dbg == nil then
    dbg = db.debug
  end
  phase = phase or "cast"

  -- 0) spell_config: unified per-spell configuration
  if cfg == nil then
    cfg = db.spell_config and db.spell_config[spellID]
  end

  -- 0b) Name-based fallback for variant spell IDs: when a spec-specific
  -- override (e.g., Balance Moonfire 155625) fires without its own
  -- spell_config entry, match the configured base spell by name.
  if cfg == nil then
    if spellName == nil then
      spellName = _GetSpellName(spellID) or ""
    end
    if spellName ~= "" then
      cfg = getSpellNameIndex()[spellName:lower()]
      if dbg and cfg then
        msg(('  Variant matched by name: "%s"'):format(spellName))
      end
    end
  end

  -- Cancel any active sound loop from a previous cast
  cancelActiveLoop()

  -- Impacts are scheduled in UNIT_SPELLCAST_SUCCEEDED, before the main-
  -- sound debounce / multi-hit-window checks, so a rapid-recast that gets
  -- debounced on the cast sound still fires its impact. Here we only need
  -- to check whether impacts exist so the impact-only branch below can
  -- short-circuit the vanilla-name fallback path.
  migrateImpacts(cfg)
  migrateFaction(cfg)
  if cfg and cfg.horde then
    migrateImpacts(cfg.horde)
  end
  if cfg and cfg.alliance then
    migrateImpacts(cfg.alliance)
  end
  local impacts = phase == "cast" and resolveFactionField(cfg, "impacts") or nil
  local impactScheduled = impacts and #impacts > 0 or false

  -- Resolve the main cast sound (with faction variant fallback). Used for
  -- the mute-only check and the main playback branch below.
  local resolvedSound = resolveFactionField(cfg, "sound")

  if cfg and cfg.sound == false then
    -- Mute only — original sounds are auto-muted, no replacement plays.
    -- cfg.sound == false is explicit base mute; faction variants do not
    -- override this intent.
    if dbg then
      msg(L["  spell_config: mute only (no replacement)"])
    end
    return true
  elseif cfg and resolvedSound then
    -- Determine which phase this config triggers on:
    --   "cast"             = cast complete only (default)
    --   "precast"          = cast bar start only
    --   "precast_and_cast" = precast plays precastSound, cast plays sound
    local trigger = cfg.trigger or "cast"
    if trigger == "precast_and_cast" then
      -- Two-phase setup: precastSound on start, sound on complete
      if phase == "precast" then
        local psnd = resolveFactionField(cfg, "precastSound")
        local pdur = resolveFactionField(cfg, "precastDuration")
        local ploop = resolveFactionField(cfg, "precastLoop")
        activeSpellChannel = cfg.channel
        if psnd then
          -- Reset handle list for this cast; UNIT_SPELLCAST_STOP will
          -- stop whatever we capture here when the cast bar ends.
          for i = #activePrecastHandles, 1, -1 do
            activePrecastHandles[i] = nil
          end
          capturePrecastHandles = true
          activeLoopIsPrecast = true
          playSoundCollectionWithLoop(psnd, dbg, pdur, ploop)
          capturePrecastHandles = false
        elseif dbg then
          msg(L["  No precastSound configured for precast phase."])
        end
        activeSpellChannel = nil
        return true
      end
      -- phase == "cast": fall through to play main sound below
    elseif trigger == "precast" then
      if phase ~= "precast" then
        if dbg then
          msg(L["  spell_config: trigger=precast, skipping cast phase"])
        end
        return true
      end
      -- Precast-only: play here and register the handles (and mark the loop as
      -- precast) so UNIT_SPELLCAST_STOP cuts the sound when the cast bar ends —
      -- player cancel, enemy interrupt, or completion. Without this the sound
      -- plays to its natural end even after the cast is interrupted. Mirrors
      -- the precast_and_cast precast branch above.
      activeSpellChannel = cfg.channel
      for i = #activePrecastHandles, 1, -1 do
        activePrecastHandles[i] = nil
      end
      capturePrecastHandles = true
      activeLoopIsPrecast = true
      playSoundCollectionWithLoop(
        resolvedSound,
        dbg,
        resolveFactionField(cfg, "duration"),
        resolveFactionField(cfg, "loop")
      )
      capturePrecastHandles = false
      activeSpellChannel = nil
      return true
    else -- trigger == "cast" (default)
      if phase ~= "cast" then
        if dbg then
          msg(L["  spell_config: trigger=cast, skipping precast phase"])
        end
        return true
      end
    end

    activeSpellChannel = cfg.channel
    -- Cast-complete loops should survive the subsequent UNIT_SPELLCAST_STOP
    -- (that event fires milliseconds after SUCCEEDED). Mark the current loop
    -- as non-precast so the stop handler leaves it alone.
    activeLoopIsPrecast = false
    playSoundCollectionWithLoop(
      resolvedSound,
      dbg,
      resolveFactionField(cfg, "duration"),
      resolveFactionField(cfg, "loop")
    )
    activeSpellChannel = nil
    -- User explicitly configured this sound; don't fall through
    -- (PlaySoundFile may return nil for valid FileDataIDs)
    return true
  elseif impactScheduled then
    -- Impact-only config: no main sound, but the impact is already
    -- queued. Don't fall through to the vanilla-name fallback paths.
    return true
  elseif dbg then
    msg(L["  No spell_config sound for this spell."])
  end

  -- Fallback paths only apply to the "cast" phase
  if phase ~= "cast" then
    return false
  end

  -- Fallback paths need spellName; resolve lazily (skipped on the
  -- common path above where cfg.sound existed and returned early).
  if spellName == nil then
    spellName = _GetSpellName(spellID) or ""
  end

  -- 1) Prefer local extracted "vanilla" file by spell name.
  -- Tries Resonance_Sounds/vanilla/ first (survives addon-manager
  -- updates), then the bundled Resonance/sounds/vanilla/ location.
  local ch = getChannel()
  local candidates = resolveLocalFileForSpellName(spellName)
  if candidates then
    for i = 1, #candidates do
      local path = candidates[i]
      if path then
        local ok = PlaySoundFile(path, ch)
        if ok then
          return true
        end
      end
    end
  end

  -- 2) Fallback to an in-client FileDataID mapping (user-provided)
  local fid = db.spell_to_play_file_data_id and db.spell_to_play_file_data_id[spellID]
  if fid then
    local ok = PlaySoundFile(fid, ch)
    if ok then
      return true
    end
  end

  return false
end

-- shouldTriggerForSpell is inlined into UNIT_SPELLCAST_SUCCEEDED for
-- the hot path to eliminate function call overhead and allow the
-- spell_config lookup to be passed through to playResolvedSound.

---------------------------------------------------------------------------
-- Global vocalization mute
---------------------------------------------------------------------------
local function getPlayerCSDEntry()
  local raceCSD = Resonance.RaceCSD
  if not raceCSD or not Resonance.VoxFIDs then
    return nil
  end
  local raceID = select(3, UnitRace("player"))
  local sex = (UnitSex("player") == 3) and "1" or "0"
  local csdID = raceCSD[tostring(raceID) .. ":" .. sex]
  if not csdID then
    return nil
  end
  return Resonance.VoxFIDs[csdID]
end

local function getVoxMode()
  local v = db.muteVocalizations
  if v == true then
    return "mine"
  end
  if v == false or v == "off" then
    return "off"
  end
  return v -- "mine" or "all"
end

local function muteVoxEntry(voxEntry)
  local count = 0
  for _, fids in pairs(voxEntry) do
    for _, fid in ipairs(fids) do
      if not voxMutedFIDs[fid] then
        voxMutedFIDs[fid] = true
        MuteSoundFile(fid)
        count = count + 1
      end
    end
  end
  return count
end

local function applyVoxMutes()
  local mode = getVoxMode()
  if mode == "off" then
    return
  end
  -- Cache race/gender key so UNIT_MODEL_CHANGED can detect real changes
  if mode == "mine" then
    local _, raceKey = UnitRace("player")
    local gender = UnitSex("player")
    lastVoxRaceGender = (raceKey or "") .. (gender or "")
  end
  local count = 0
  local hasData = Resonance.VoxFIDs and Resonance.RaceCSD
  if hasData then
    if mode == "mine" then
      local voxEntry = getPlayerCSDEntry()
      if voxEntry then
        count = muteVoxEntry(voxEntry)
      end
    elseif mode == "all" then
      for _, voxEntry in pairs(Resonance.VoxFIDs) do
        count = count + muteVoxEntry(voxEntry)
      end
    end
    -- Persist snapshot
    local snapshot = {}
    for fid in pairs(voxMutedFIDs) do
      snapshot[fid] = true
    end
    db._lastVoxMutedFIDs = snapshot
  else
    -- Data addon not loaded — re-apply from snapshot
    local snapshot = db._lastVoxMutedFIDs
    if snapshot then
      for fid in pairs(snapshot) do
        if not voxMutedFIDs[fid] then
          voxMutedFIDs[fid] = true
          MuteSoundFile(fid)
          count = count + 1
        end
      end
    end
  end
  if count > 0 then
    local label = mode == "all" and L["all races"] or L["your race/gender"]
    msg(L["Muted %d vocalization sounds (%s)."]:format(count, label))
  end
end

local function clearVoxMutes()
  local count = 0
  for fid in pairs(voxMutedFIDs) do
    if not isMutedElsewhere(fid, voxMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  if db and db._lastVoxMutedFIDs then
    for fid in pairs(db._lastVoxMutedFIDs) do
      if not voxMutedFIDs[fid] and not isMutedElsewhere(fid, voxMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(db._lastVoxMutedFIDs)
  end
  wipe(voxMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d vocalization mutes."]:format(count))
  end
end

local function refreshVoxMutes()
  clearVoxMutes()
  if db.enabled then
    applyVoxMutes()
  end
end

---------------------------------------------------------------------------
-- Global weapon impact mute
---------------------------------------------------------------------------
local function applyWeaponMutes()
  local count = 0
  if Resonance.WeaponImpactFIDs then
    for _, fid in ipairs(Resonance.WeaponImpactFIDs) do
      if not weaponMutedFIDs[fid] then
        weaponMutedFIDs[fid] = true
        MuteSoundFile(fid)
        count = count + 1
      end
    end
    -- Persist snapshot
    local snapshot = {}
    for fid in pairs(weaponMutedFIDs) do
      snapshot[fid] = true
    end
    db._lastWeaponMutedFIDs = snapshot
  else
    -- Data addon not loaded — re-apply from snapshot
    local snapshot = db._lastWeaponMutedFIDs
    if snapshot then
      for fid in pairs(snapshot) do
        if not weaponMutedFIDs[fid] then
          weaponMutedFIDs[fid] = true
          MuteSoundFile(fid)
          count = count + 1
        end
      end
    end
  end
  if count > 0 then
    msg(L["Muted %d weapon impact sounds."]:format(count))
  end
end

local function clearWeaponMutes()
  local count = 0
  -- Fully unmute every FID (MAX_MUTE_DEPTH clears any accumulated refcount)
  for fid in pairs(weaponMutedFIDs) do
    if not isMutedElsewhere(fid, weaponMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  if db and db._lastWeaponMutedFIDs then
    for fid in pairs(db._lastWeaponMutedFIDs) do
      if not weaponMutedFIDs[fid] and not isMutedElsewhere(fid, weaponMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(db._lastWeaponMutedFIDs)
  end
  wipe(weaponMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d weapon impact mutes."]:format(count))
  end
end

---------------------------------------------------------------------------
-- Creature vocalization mute
---------------------------------------------------------------------------
local function hasAnyCreatureVoxEnabled()
  if not db.muteCreatureVox then
    return false
  end
  return next(db.muteCreatureVox) ~= nil
end

local function applyCreatureVoxMutes()
  if not db.muteCreatureVox then
    return
  end
  local cvData = Resonance.CreatureVoxData
  local count = 0
  if cvData then
    for cat, enabled in pairs(db.muteCreatureVox) do
      if enabled then
        local packed = cvData[cat]
        if packed then
          for s in packed:gmatch("%d+") do
            local fid = tonumber(s)
            if
              fid
              and not creatureMutedFIDs[fid]
              and not autoShotMutedFIDs[fid]
              and not professionMutedFIDs[fid]
              and not npcMutedFIDs[fid]
              and not mountMutedFIDs[fid]
              and not ambientMutedFIDs[fid]
            then
              creatureMutedFIDs[fid] = true
              MuteSoundFile(fid)
              count = count + 1
            end
          end
        end
      end
    end
    -- Unmute stale creature vox FIDs from previous session that are no longer
    -- in CreatureVoxData (e.g. FIDs removed to avoid collateral spell muting).
    -- MuteSoundFile persists across /reload but creatureMutedFIDs does not;
    -- each reload with creature vox enabled added another MuteSoundFile call,
    -- so we must call UnmuteSoundFile MAX_MUTE_DEPTH times to drain the
    -- engine's internal refcount.
    local stale = db._lastCreatureMutedFIDs
    if stale then
      for fid in pairs(stale) do
        if not creatureMutedFIDs[fid] and not isMutedElsewhere(fid, creatureMutedFIDs) then
          for _ = 1, MAX_MUTE_DEPTH do
            UnmuteSoundFile(fid)
          end
        end
      end
    end
    -- Unconditionally unmute FIDs that were removed from CreatureVoxData to
    -- avoid collateral spell muting.  Clears stale MuteSoundFile state from
    -- sessions before _lastCreatureMutedFIDs tracking was added.
    local cvExcluded = Resonance.CreatureVoxExcludedFIDs
    if cvExcluded then
      for s in cvExcluded:gmatch("%d+") do
        local fid = tonumber(s)
        if fid and not creatureMutedFIDs[fid] and not isMutedElsewhere(fid, creatureMutedFIDs) then
          for _ = 1, MAX_MUTE_DEPTH do
            UnmuteSoundFile(fid)
          end
        end
      end
    end
    -- Persist snapshot for next session's stale cleanup
    local snapshot = {}
    for fid in pairs(creatureMutedFIDs) do
      snapshot[fid] = true
    end
    db._lastCreatureMutedFIDs = snapshot
  else
    -- Data addon not loaded — re-apply from snapshot
    local snapshot = db._lastCreatureMutedFIDs
    if snapshot then
      for fid in pairs(snapshot) do
        if not creatureMutedFIDs[fid] then
          creatureMutedFIDs[fid] = true
          MuteSoundFile(fid)
          count = count + 1
        end
      end
    end
  end
  if count > 0 then
    msg(L["Muted %d creature vocalization sounds."]:format(count))
  end
end

local function clearCreatureVoxMutes()
  local count = 0
  for fid in pairs(creatureMutedFIDs) do
    if not isMutedElsewhere(fid, creatureMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  -- Also unmute stale FIDs from previous session snapshot — drain refcount
  if db and db._lastCreatureMutedFIDs then
    for fid in pairs(db._lastCreatureMutedFIDs) do
      if not creatureMutedFIDs[fid] and not isMutedElsewhere(fid, creatureMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(db._lastCreatureMutedFIDs)
  end
  wipe(creatureMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d creature vocalization mutes."]:format(count))
  end
end

local function refreshCreatureVoxMutes()
  clearCreatureVoxMutes()
  if db.enabled and hasAnyCreatureVoxEnabled() then
    applyCreatureVoxMutes()
  end
end

---------------------------------------------------------------------------
-- Profession sound mute
---------------------------------------------------------------------------
local function hasAnyProfessionMuteEnabled()
  if not db.muteProfessionSounds then
    return false
  end
  return next(db.muteProfessionSounds) ~= nil
end

local function applyProfessionMutes()
  if not ProfessionSoundData or not db.muteProfessionSounds then
    return
  end
  local count = 0
  for cat, enabled in pairs(db.muteProfessionSounds) do
    if enabled then
      local packed = ProfessionSoundData[cat]
      if packed then
        for s in packed:gmatch("%d+") do
          local fid = tonumber(s)
          if fid and not professionMutedFIDs[fid] then
            professionMutedFIDs[fid] = true
            MuteSoundFile(fid)
            count = count + 1
          end
        end
      end
    end
  end
  -- Unmute stale FIDs from previous session
  local stale = db._lastProfessionMutedFIDs
  if stale then
    for fid in pairs(stale) do
      if not professionMutedFIDs[fid] and not isMutedElsewhere(fid, professionMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
  end
  -- Persist snapshot for next session
  local snapshot = {}
  for fid in pairs(professionMutedFIDs) do
    snapshot[fid] = true
  end
  db._lastProfessionMutedFIDs = snapshot
  if count > 0 then
    msg(L["Muted %d profession sounds."]:format(count))
  end
end

local function clearProfessionMutes()
  local count = 0
  for fid in pairs(professionMutedFIDs) do
    if not isMutedElsewhere(fid, professionMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  if db and db._lastProfessionMutedFIDs then
    for fid in pairs(db._lastProfessionMutedFIDs) do
      if not professionMutedFIDs[fid] and not isMutedElsewhere(fid, professionMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(db._lastProfessionMutedFIDs)
  end
  wipe(professionMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d profession sound mutes."]:format(count))
  end
end

local function refreshProfessionMutes()
  clearProfessionMutes()
  if db.enabled and hasAnyProfessionMuteEnabled() then
    applyProfessionMutes()
  end
end

---------------------------------------------------------------------------
-- Ambient sound mute
---------------------------------------------------------------------------
local function hasAnyAmbientMuteEnabled()
  if not db.muteAmbientSounds then
    return false
  end
  for _, v in pairs(db.muteAmbientSounds) do
    if v then
      return true
    end
  end
  return false
end

local function applyAmbientMutes()
  local ambientData = Resonance.AmbientSoundData
  if not ambientData or not db.muteAmbientSounds then
    return
  end
  local count = 0
  for key, enabled in pairs(db.muteAmbientSounds) do
    if enabled then
      local exp, zone = key:match("^(.+)|(.+)$")
      if exp and zone and ambientData[exp] and ambientData[exp][zone] then
        local packed = ambientData[exp][zone]
        for s in packed:gmatch("%d+") do
          local fid = tonumber(s)
          if fid and not ambientMutedFIDs[fid] then
            ambientMutedFIDs[fid] = true
            MuteSoundFile(fid)
            count = count + 1
          end
        end
      end
    end
  end
  -- Unmute stale ambient FIDs from previous session
  local stale = db._lastAmbientMutedFIDs
  if stale then
    for fid in pairs(stale) do
      if not ambientMutedFIDs[fid] and not isMutedElsewhere(fid, ambientMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
  end
  -- Persist snapshot for next session
  local snapshot = {}
  for fid in pairs(ambientMutedFIDs) do
    snapshot[fid] = true
  end
  db._lastAmbientMutedFIDs = snapshot
  if count > 0 then
    msg(L["Muted %d ambient sounds."]:format(count))
  end
end

local function clearAmbientMutes()
  local count = 0
  for fid in pairs(ambientMutedFIDs) do
    if not isMutedElsewhere(fid, ambientMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  if db and db._lastAmbientMutedFIDs then
    for fid in pairs(db._lastAmbientMutedFIDs) do
      if not ambientMutedFIDs[fid] and not isMutedElsewhere(fid, ambientMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(db._lastAmbientMutedFIDs)
  end
  wipe(ambientMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d ambient sound mutes."]:format(count))
  end
end

local function refreshAmbientMutes()
  clearAmbientMutes()
  if db.enabled and hasAnyAmbientMuteEnabled() then
    applyAmbientMutes()
  end
end

---------------------------------------------------------------------------
-- Curated effect mutes (e.g. another player's Demon Hunter Devour aura loop).
--
-- Mirrors the ambient mute lifecycle (apply/clear/refresh + stale-FID cleanup
-- across reloads).  Wrapped in a do/end block so its helper locals don't count
-- toward Core.lua's main-chunk 200-local cap; only the Resonance.* methods and
-- the shared Resonance._effectMutedFIDs table escape the block.
---------------------------------------------------------------------------
do
  local effectMutedFIDs = Resonance._effectMutedFIDs

  -- "<groupKey>.<variantKey>" — the key under which a variant's toggle lives
  -- in db.muteEffects.
  local function variantKey(group, variant)
    return group.key .. "." .. variant.key
  end

  function Resonance.hasAnyEffectMuteEnabled()
    if not (Resonance.EffectMuteData and db.muteEffects) then
      return false
    end
    for _, enabled in pairs(db.muteEffects) do
      if enabled then
        return true
      end
    end
    return false
  end

  function Resonance.applyEffectMutes()
    local data = Resonance.EffectMuteData
    if not data or not db.muteEffects then
      return
    end
    local count = 0
    for _, group in ipairs(data) do
      for _, variant in ipairs(group.variants) do
        if db.muteEffects[variantKey(group, variant)] then
          for s in variant.fids:gmatch("%d+") do
            local fid = tonumber(s)
            if fid and not effectMutedFIDs[fid] then
              effectMutedFIDs[fid] = true
              MuteSoundFile(fid)
              count = count + 1
            end
          end
        end
      end
    end
    -- Unmute stale effect FIDs from a previous session (toggles since turned off).
    local stale = db._lastEffectMutedFIDs
    if stale then
      for fid in pairs(stale) do
        if not effectMutedFIDs[fid] and not isMutedElsewhere(fid, effectMutedFIDs) then
          for _ = 1, MAX_MUTE_DEPTH do
            UnmuteSoundFile(fid)
          end
        end
      end
    end
    -- Persist snapshot for next session.
    local snapshot = {}
    for fid in pairs(effectMutedFIDs) do
      snapshot[fid] = true
    end
    db._lastEffectMutedFIDs = snapshot
    if count > 0 then
      msg(L["Muted %d effect sounds."]:format(count))
    end
  end

  function Resonance.clearEffectMutes()
    local count = 0
    for fid in pairs(effectMutedFIDs) do
      if not isMutedElsewhere(fid, effectMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
      count = count + 1
    end
    if db and db._lastEffectMutedFIDs then
      for fid in pairs(db._lastEffectMutedFIDs) do
        if not effectMutedFIDs[fid] and not isMutedElsewhere(fid, effectMutedFIDs) then
          for _ = 1, MAX_MUTE_DEPTH do
            UnmuteSoundFile(fid)
          end
        end
      end
      wipe(db._lastEffectMutedFIDs)
    end
    wipe(effectMutedFIDs)
    if count > 0 then
      msg(L["Cleared %d effect sound mutes."]:format(count))
    end
  end

  function Resonance.refreshEffectMutes()
    Resonance.clearEffectMutes()
    if db.enabled and Resonance.hasAnyEffectMuteEnabled() then
      Resonance.applyEffectMutes()
    end
  end
end

---------------------------------------------------------------------------
-- Classic auto-shot mute
---------------------------------------------------------------------------
local function applyAutoShotMutes()
  local count = 0
  for _, fid in ipairs(AUTO_SHOT_MUTE_FIDS) do
    if not autoShotMutedFIDs[fid] then
      autoShotMutedFIDs[fid] = true
      MuteSoundFile(fid)
      count = count + 1
    end
  end
  if count > 0 then
    msg(L["Muted %d auto-shot sounds."]:format(count))
  end
end

local function clearAutoShotMutes()
  local count = 0
  for fid in pairs(autoShotMutedFIDs) do
    if not isMutedElsewhere(fid, autoShotMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  wipe(autoShotMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d auto-shot mutes."]:format(count))
  end
end

-- Detect equipped ranged weapon type from main hand (slot 16).
-- Returns "bow" (bows + crossbows) or "gun", or nil if unknown.
local C_Item_GetItemInfoInstant = C_Item and C_Item.GetItemInfoInstant
local function getEquippedRangedType()
  local itemID = GetInventoryItemID("player", 16)
  if not itemID or not C_Item_GetItemInfoInstant then
    return nil
  end
  local _, _, _, _, _, classID, subclassID = C_Item_GetItemInfoInstant(itemID)
  if classID ~= 2 then
    return nil
  end -- not a weapon
  if subclassID == 2 or subclassID == 18 then
    return "bow"
  end -- Bows / Crossbows
  if subclassID == 3 then
    return "gun"
  end -- Guns
  return nil
end

---------------------------------------------------------------------------
-- Sheathe / draw sound replacement
---------------------------------------------------------------------------
-- All module-internal state and helpers live inside the `do ... end` block
-- so their local slots are released when the block ends. Lua's main-chunk
-- has a 200-local limit and Core.lua is large; this collapses ~21 module
-- locals to a single `Sheathe` namespace table that the rest of Core.lua
-- references.
local Sheathe = {}
do
  -- Subclass → material best-effort heuristic. Per spec this may not exactly
  -- match Blizzard's per-model classification; used only for advanced-mode
  -- per-material slot routing.
  local SUBCLASS_TO_MATERIAL = {
    [0] = "metal",
    [1] = "metal",
    [2] = "nonmetal",
    [3] = "metal",
    [4] = "metal",
    [5] = "metal",
    [6] = "metal",
    [7] = "metal",
    [8] = "metal",
    [9] = "metal",
    [10] = "nonmetal",
    [13] = "metal",
    [14] = "misc",
    [15] = "metal",
    [16] = "liquid",
    [17] = "metal",
    [18] = "nonmetal",
    [19] = "nonmetal",
    [20] = "liquid",
  }

  -- Per-slot opt-in: an empty slot means "do not replace this transition".
  -- That lets a user customize a single direction (e.g. melee sheathe only)
  -- and leave Blizzard's default sounds alone for the others. We deliberately
  -- do not bundle defaults — a "replacement that sounds identical to the
  -- original" would defeat the per-slot opt-in by re-introducing audio on
  -- every transition the user left blank.

  local sheathTimerGeneration = 0
  local sheatheCheckPending = false
  local cachedSheathState
  local sheatheHookInstalled = false
  local sheatheFeatureSupported -- nil = unchecked
  -- Forward-declared so the polling loop and ToggleSheath hook (defined below)
  -- can reference them; both are assigned alongside applySheatheMutes further down.
  local hasAnySheatheMute

  local function checkSheatheFeatureSupported()
    if sheatheFeatureSupported ~= nil then
      return sheatheFeatureSupported
    end
    if type(GetSheathState) ~= "function" or type(ToggleSheath) ~= "function" then
      sheatheFeatureSupported = false
      if db and db.debug then
        msg(L["Sheathe replacement: API missing on this flavor; feature disabled."])
      end
      return false
    end
    sheatheFeatureSupported = true
    return true
  end

  local function getMaterialForSlot(slotIndex)
    local itemID = GetInventoryItemID("player", slotIndex)
    if not itemID or not C_Item_GetItemInfoInstant then
      return nil
    end
    local _, _, _, _, _, classID, subclassID = C_Item_GetItemInfoInstant(itemID)
    if classID ~= 2 then
      return nil
    end
    return SUBCLASS_TO_MATERIAL[subclassID]
  end

  local function getSheatheReplacementSoundKey(slot)
    local material
    if slot == "rangedDraw" or slot == "rangedSheathe" then
      material = getMaterialForSlot(18) or getMaterialForSlot(16)
    else
      material = getMaterialForSlot(16)
    end
    local state = (slot == "meleeDraw" or slot == "rangedDraw") and "draw" or "sheathe"
    if material and db.sheatheReplacementsByMaterial then
      local matSlot = db.sheatheReplacementsByMaterial[material]
      if matSlot and matSlot[state] then
        return matSlot[state]
      end
    end
    if db.sheatheReplacements and db.sheatheReplacements[slot] then
      return db.sheatheReplacements[slot]
    end
    -- Empty slot → no replacement. Caller treats nil as "don't dispatch".
    return nil
  end

  local function playSheatheSound(slot)
    local raw = getSheatheReplacementSoundKey(slot)
    if not raw then
      return
    end
    -- Resolve the stored value (FID number, numeric string, LSM name, or
    -- path) into something PlaySoundFile can actually play. AceConfig stores
    -- editbox input as a string, so "567473" must be coerced to FID 567473
    -- — a recurring class of bug if we don't go through the resolver.
    local sound = resolveSoundValue(raw)
    if not sound then
      return
    end
    local ch = getChannel()
    if type(sound) == "number" and sheatheMutedFIDs[sound] then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(sound)
      end
      PlaySoundFile(sound, ch)
      C_Timer.After(0.5, function()
        if sheatheMutedFIDs[sound] then
          MuteSoundFile(sound)
        end
      end)
    else
      PlaySoundFile(sound, ch)
    end
  end

  local function dispatchSheatheTransition(prev, curr)
    if not db.replaceSheathe then
      return
    end
    -- 1=none/sheathed, 2=melee drawn, 3=ranged drawn (verified vs
    -- Blizzard_SharedXMLBase/ModelSceneUtil.lua:38).
    if prev == 1 and curr == 2 then
      playSheatheSound("meleeDraw")
    elseif prev == 2 and curr == 1 then
      playSheatheSound("meleeSheathe")
    elseif prev == 1 and curr == 3 then
      playSheatheSound("rangedDraw")
    elseif prev == 3 and curr == 1 then
      playSheatheSound("rangedSheathe")
    elseif prev == 2 and curr == 3 then
      playSheatheSound("meleeSheathe")
      playSheatheSound("rangedDraw")
    elseif prev == 3 and curr == 2 then
      playSheatheSound("rangedSheathe")
      playSheatheSound("meleeDraw")
    end
  end

  -- Polling for sheath state changes. The ToggleSheath hook fires
  -- immediately on Z press, but GetSheathState() returns the old value
  -- until the sheathe animation completes (~250-500ms depending on weapon
  -- type and class). A single fixed-delay check is either too early (sees
  -- stale state, mis-attributes the transition — the "draw/sheathe
  -- swapped" bug) or too late (audible lag).
  --
  -- Instead we poll: sample GetSheathState every POLL_INTERVAL, fire the
  -- dispatch on the first iteration that detects a real change, and stop.
  -- POLL_MAX_ATTEMPTS gives ~600ms total window — enough for any sheathe
  -- animation. Sound fires as soon as state actually settles, so latency
  -- matches the visual rather than waiting for a fixed worst-case delay.
  -- False triggers (UNIT_MODEL_CHANGED from transmog/mount) just poll until
  -- timeout with no dispatch — cheap and harmless.
  local POLL_INTERVAL = 0.05
  local POLL_MAX_ATTEMPTS = 12

  local function pollForSheathChange(generation, attempt)
    if generation ~= sheathTimerGeneration then
      return
    end
    if not (db and (db.replaceSheathe or hasAnySheatheMute())) then
      return
    end
    local curr = GetSheathState()
    if curr == 1 or curr == 2 or curr == 3 then
      local prev = cachedSheathState
      if prev ~= nil and prev ~= curr then
        cachedSheathState = curr
        dispatchSheatheTransition(prev, curr)
        return -- detected and dispatched; stop polling
      end
    end
    if attempt < POLL_MAX_ATTEMPTS then
      C_Timer.After(POLL_INTERVAL, function()
        pollForSheathChange(generation, attempt + 1)
      end)
    end
  end

  local function scheduleSheathCheck()
    if sheatheCheckPending then
      return
    end
    if not checkSheatheFeatureSupported() then
      return
    end
    sheatheCheckPending = true
    local generation = sheathTimerGeneration
    C_Timer.After(0, function()
      sheatheCheckPending = false
      pollForSheathChange(generation, 0)
    end)
  end

  -- Cache current sheath state without dispatching. Required so the user's
  -- first sheathe action after login isn't eaten by the no-dispatch-on-nil rule.
  local function seedSheathState()
    if not checkSheatheFeatureSupported() then
      return
    end
    local curr = GetSheathState()
    if curr == 1 or curr == 2 or curr == 3 then
      cachedSheathState = curr
    end
  end

  -- Mute opt-ins live in two places — both are pure unions:
  --   db.muteSheatheBySlot[slot] = true: simple-mode per-slot checkbox. Mutes
  --     every {material, state} pair that the slot might dispatch on. Slot →
  --     materials mapping below; "draw" / "sheathe" picks the state.
  --   db.muteSheatheByMaterial[material][state] = true: advanced-popup per-cell
  --     checkbox. Mutes that exact FID directly.
  -- An "any mute enabled" check is needed by event-gating code (the polling
  -- loop, the ToggleSheath hook) — both opt-in surfaces feed it.
  local SLOT_MUTE_MATERIALS = {
    meleeDraw = { state = "draw", materials = { "metal", "nonmetal", "liquid", "plate" } },
    meleeSheathe = { state = "sheathe", materials = { "metal", "nonmetal", "liquid", "plate" } },
    rangedDraw = { state = "draw", materials = { "metal", "nonmetal", "misc" } },
    rangedSheathe = { state = "sheathe", materials = { "metal", "nonmetal", "misc" } },
  }

  hasAnySheatheMute = function()
    if db then
      if db.muteSheatheBySlot then
        for _, v in pairs(db.muteSheatheBySlot) do
          if v then
            return true
          end
        end
      end
      if db.muteSheatheByMaterial then
        for _, byState in pairs(db.muteSheatheByMaterial) do
          for _, v in pairs(byState) do
            if v then
              return true
            end
          end
        end
      end
    end
    return false
  end
  Sheathe.hasAnyMute = hasAnySheatheMute

  local function applySheatheMutes()
    local data = Resonance.SheatheMuteData
    if not data then
      local snapshot = db._lastSheatheMutedFIDs
      if snapshot then
        for fid in pairs(snapshot) do
          if not sheatheMutedFIDs[fid] then
            sheatheMutedFIDs[fid] = true
            MuteSoundFile(fid)
          end
        end
      end
      return
    end
    -- Gather the union of per-slot and per-cell mute selections, keyed by FID.
    local toMute = {}
    local bySlot = db.muteSheatheBySlot
    if bySlot then
      for slot, on in pairs(bySlot) do
        if on then
          local mapping = SLOT_MUTE_MATERIALS[slot]
          if mapping then
            for _, material in ipairs(mapping.materials) do
              local byState = data[material]
              local fid = byState and byState[mapping.state]
              if fid then
                toMute[fid] = true
              end
            end
          end
        end
      end
    end
    local byMat = db.muteSheatheByMaterial
    if byMat then
      for material, byState in pairs(byMat) do
        local dataByState = data[material]
        if dataByState then
          for state, on in pairs(byState) do
            if on then
              local fid = dataByState[state]
              if fid then
                toMute[fid] = true
              end
            end
          end
        end
      end
    end
    local count = 0
    local snapshot = {}
    for fid in pairs(toMute) do
      if not sheatheMutedFIDs[fid] then
        sheatheMutedFIDs[fid] = true
        MuteSoundFile(fid)
        count = count + 1
      end
      snapshot[fid] = true
    end
    db._lastSheatheMutedFIDs = snapshot
    if count > 0 and db.debug then
      msg(L["Muted %d sheathe sounds."]:format(count))
    end
  end

  local function clearSheatheMutes()
    local count = 0
    for fid in pairs(sheatheMutedFIDs) do
      if not isMutedElsewhere(fid, sheatheMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
      count = count + 1
    end
    if db and db._lastSheatheMutedFIDs then
      for fid in pairs(db._lastSheatheMutedFIDs) do
        if not sheatheMutedFIDs[fid] and not isMutedElsewhere(fid, sheatheMutedFIDs) then
          for _ = 1, MAX_MUTE_DEPTH do
            UnmuteSoundFile(fid)
          end
        end
      end
      wipe(db._lastSheatheMutedFIDs)
    end
    wipe(sheatheMutedFIDs)
    if count > 0 and db and db.debug then
      msg(L["Cleared %d sheathe mutes."]:format(count))
    end
  end

  local sheatheFrame = CreateFrame("Frame")
  sheatheFrame:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_ENTERING_WORLD" then
      seedSheathState()
    end
    scheduleSheathCheck()
  end)

  local SHEATHE_EVENTS = {
    "PLAYER_ENTERING_WORLD",
    "PLAYER_REGEN_DISABLED",
    "PLAYER_REGEN_ENABLED",
    "START_AUTOREPEAT_SPELL",
    "STOP_AUTOREPEAT_SPELL",
    "PLAYER_EQUIPMENT_CHANGED",
    "UPDATE_SHAPESHIFT_FORM",
  }

  local function enableSheatheEvents()
    if not checkSheatheFeatureSupported() then
      return
    end
    for _, evt in ipairs(SHEATHE_EVENTS) do
      sheatheFrame:RegisterEvent(evt)
    end
    seedSheathState()
  end

  local function disableSheatheEvents()
    sheatheFrame:UnregisterAllEvents()
    sheathTimerGeneration = sheathTimerGeneration + 1
    sheatheCheckPending = false
  end

  -- Install the ToggleSheath secure hook exactly once at addon load. The hook
  -- callback gates on feature-enabled state internally — secure hooks cannot
  -- be removed for the session, so toggling the feature off makes the callback
  -- inert rather than uninstalling.
  local function installSheatheHook()
    if sheatheHookInstalled then
      return
    end
    if not checkSheatheFeatureSupported() then
      return
    end
    hooksecurefunc("ToggleSheath", function()
      if db and (db.replaceSheathe or hasAnySheatheMute()) then
        scheduleSheathCheck()
      end
    end)
    sheatheHookInstalled = true
  end

  Sheathe.checkFeatureSupported = checkSheatheFeatureSupported
  Sheathe.applyMutes = applySheatheMutes
  Sheathe.clearMutes = clearSheatheMutes
  Sheathe.enableEvents = enableSheatheEvents
  Sheathe.disableEvents = disableSheatheEvents
  Sheathe.installHook = installSheatheHook
end

---------------------------------------------------------------------------
-- NPC sound mute
---------------------------------------------------------------------------
local function hasAnyNPCMuteEnabled()
  if not db.mutedNPCs then
    return false
  end
  return next(db.mutedNPCs) ~= nil
end

local function applyNPCMutes()
  if not db.mutedNPCs then
    return
  end
  local npcSoundCSD = Resonance.NPCSoundCSD
  local npcRepCSDs = Resonance.NPCRepCSDs
  local npcToCSD = Resonance.NPCToCSD
  local npcVoiceData = Resonance.NPCVoiceData
  local count = 0
  if npcSoundCSD or npcVoiceData then
    for npcID in pairs(db.mutedNPCs) do
      -- CSD combat sounds — check multi-CSD table first, fall back to single CSD
      if npcSoundCSD then
        local csdList = npcRepCSDs and npcRepCSDs[npcID]
        if csdList then
          -- Multi-CSD: NPC has different sound profiles across story phases
          for cs in csdList:gmatch("%d+") do
            local packed = npcSoundCSD[tonumber(cs)]
            if packed then
              for s in packed:gmatch("%d+") do
                local fid = tonumber(s)
                if fid and not npcMutedFIDs[fid] then
                  npcMutedFIDs[fid] = true
                  MuteSoundFile(fid)
                  count = count + 1
                end
              end
            end
          end
        elseif npcToCSD then
          local csd = npcToCSD[npcID]
          local packed = csd and npcSoundCSD[csd]
          if packed then
            for s in packed:gmatch("%d+") do
              local fid = tonumber(s)
              if fid and not npcMutedFIDs[fid] then
                npcMutedFIDs[fid] = true
                MuteSoundFile(fid)
                count = count + 1
              end
            end
          end
        end
      end
      -- VO dialogue sounds (from listfile, separate from CSD)
      if npcVoiceData then
        local vo = npcVoiceData[npcID]
        if vo then
          for s in vo:gmatch("%d+") do
            local fid = tonumber(s)
            if fid and not npcMutedFIDs[fid] then
              npcMutedFIDs[fid] = true
              MuteSoundFile(fid)
              count = count + 1
            end
          end
        end
      end
    end
    -- Persist snapshot for next session
    local snapshot = {}
    for fid in pairs(npcMutedFIDs) do
      snapshot[fid] = true
    end
    db._lastNPCMutedFIDs = snapshot
  else
    -- Data addon not loaded — re-apply from snapshot
    local snapshot = db._lastNPCMutedFIDs
    if snapshot then
      for fid in pairs(snapshot) do
        if not npcMutedFIDs[fid] then
          npcMutedFIDs[fid] = true
          MuteSoundFile(fid)
          count = count + 1
        end
      end
    end
  end
  if count > 0 then
    msg(L["Muted %d NPC sounds."]:format(count))
  end
end

local function clearNPCMutes()
  local count = 0
  for fid in pairs(npcMutedFIDs) do
    if not isMutedElsewhere(fid, npcMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  -- Also unmute stale FIDs from previous session snapshot
  if db and db._lastNPCMutedFIDs then
    for fid in pairs(db._lastNPCMutedFIDs) do
      if not npcMutedFIDs[fid] and not isMutedElsewhere(fid, npcMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(db._lastNPCMutedFIDs)
  end
  wipe(npcMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d NPC sound mutes."]:format(count))
  end
end

local function refreshNPCMutes()
  clearNPCMutes()
  if db.enabled and hasAnyNPCMuteEnabled() then
    applyNPCMutes()
  end
end

-- Expose for Options.lua
Resonance.refreshNPCMutes = refreshNPCMutes

---------------------------------------------------------------------------
-- Mount sound mute
---------------------------------------------------------------------------
local function hasAnyMountMuteEnabled()
  if db.mutedMounts and next(db.mutedMounts) then
    return true
  end
  if db.muteSkyriding then
    return true
  end
  return false
end

local function applyMountMutes()
  local mountSoundCSD = Resonance.MountSoundCSD
  local mountToCSD = Resonance.MountToCSD
  local mountExtraFIDs = Resonance.MountExtraFIDs
  local skyridingFIDs = Resonance.MountSkyridingFIDs
  local count = 0
  if mountSoundCSD or mountExtraFIDs then
    -- Individual mount mutes
    if db.mutedMounts then
      for mountID in pairs(db.mutedMounts) do
        -- CSD sounds
        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 s in packed:gmatch("%d+") do
                  local fid = tonumber(s)
                  if fid and not mountMutedFIDs[fid] then
                    mountMutedFIDs[fid] = true
                    MuteSoundFile(fid)
                    count = count + 1
                  end
                end
              end
            end
          end
        end
        -- Model-embedded sounds (walk, run, jump, fly from M2 animations)
        if mountExtraFIDs then
          local extra = mountExtraFIDs[mountID]
          if extra then
            for s in extra:gmatch("%d+") do
              local fid = tonumber(s)
              if fid and not mountMutedFIDs[fid] then
                mountMutedFIDs[fid] = true
                MuteSoundFile(fid)
                count = count + 1
              end
            end
          end
        end
      end
    end
    -- Mute skyriding wind/speed sounds (separate toggle)
    if skyridingFIDs and db.muteSkyriding then
      for s in skyridingFIDs:gmatch("%d+") do
        local fid = tonumber(s)
        if fid and not mountMutedFIDs[fid] then
          mountMutedFIDs[fid] = true
          MuteSoundFile(fid)
          count = count + 1
        end
      end
    end
    -- Persist snapshot for next session
    local snapshot = {}
    for fid in pairs(mountMutedFIDs) do
      snapshot[fid] = true
    end
    db._lastMountMutedFIDs = snapshot
  else
    -- Data addon not loaded — re-apply from snapshot
    local snapshot = db._lastMountMutedFIDs
    if snapshot then
      for fid in pairs(snapshot) do
        if not mountMutedFIDs[fid] then
          mountMutedFIDs[fid] = true
          MuteSoundFile(fid)
          count = count + 1
        end
      end
    end
  end
  if count > 0 then
    msg(L["Muted %d mount sounds."]:format(count))
  end
end

local function clearMountMutes()
  local count = 0
  for fid in pairs(mountMutedFIDs) do
    if not isMutedElsewhere(fid, mountMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
    count = count + 1
  end
  -- Also unmute stale FIDs from previous session snapshot
  if db and db._lastMountMutedFIDs then
    for fid in pairs(db._lastMountMutedFIDs) do
      if not mountMutedFIDs[fid] and not isMutedElsewhere(fid, mountMutedFIDs) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(db._lastMountMutedFIDs)
  end
  wipe(mountMutedFIDs)
  if count > 0 then
    msg(L["Cleared %d mount sound mutes."]:format(count))
  end
end

local function refreshMountMutes()
  clearMountMutes()
  if db.enabled and hasAnyMountMuteEnabled() then
    applyMountMutes()
  end
end

-- Expose for Options.lua
Resonance.refreshMountMutes = refreshMountMutes

---------------------------------------------------------------------------
-- "Mute all classes except mine": mutes the union of every class's spell-sound
-- FIDs except the player's own. WoW's MuteSoundFile is per-file and global (no
-- per-caster control), so this silences other players' rotations but NOT other
-- players of your own class, and aggressively mutes sounds shared with your
-- class. Data is Resonance.ClassSpellSoundFIDs (always-loaded main-addon file).
---------------------------------------------------------------------------
-- Defined as Resonance.* fields rather than file-scope locals: the main chunk
-- is at Lua's 200-local cap.
function Resonance.applySoloClassMute()
  local data = Resonance.ClassSpellSoundFIDs
  if not data then
    return
  end
  local _, myClass = UnitClass("player")
  local soloMuted = Resonance._soloClassMutedFIDs
  local count = 0
  for class, fidStr in pairs(data) do
    if class ~= myClass then
      for s in fidStr:gmatch("%d+") do
        local fid = tonumber(s)
        if fid and not soloMuted[fid] then
          soloMuted[fid] = true
          MuteSoundFile(fid)
          count = count + 1
        end
      end
    end
  end
  -- Snapshot for balancing orphaned MuteSoundFile calls on a future session.
  -- Stored account-GLOBAL (not per-profile) so logging into a different
  -- character, whose own class mutes a different set, can still undo this one
  -- regardless of which profile each character uses.
  local snapshot = {}
  for fid in pairs(soloMuted) do
    snapshot[fid] = true
  end
  Resonance.db.global._lastSoloClassMutedFIDs = snapshot
  if count > 0 then
    msg(L["Muted %d sounds from other classes."]:format(count))
  end
end

function Resonance.clearSoloClassMute()
  local soloMuted = Resonance._soloClassMutedFIDs
  for fid in pairs(soloMuted) do
    if not isMutedElsewhere(fid, soloMuted) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
    end
  end
  -- Unmute stale FIDs from the previous session's account-global snapshot.
  -- This is what lets a character of a different class undo the prior
  -- character's mute set on login (the snapshot is shared across profiles).
  local lastSolo = Resonance.db and Resonance.db.global._lastSoloClassMutedFIDs
  if lastSolo then
    for fid in pairs(lastSolo) do
      if not soloMuted[fid] and not isMutedElsewhere(fid, soloMuted) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
    wipe(lastSolo)
  end
  wipe(soloMuted)
end

function Resonance.refreshSoloClassMute()
  Resonance.clearSoloClassMute()
  if db.enabled and db.soloClassMute then
    Resonance.applySoloClassMute()
  end
end

---------------------------------------------------------------------------
-- Spell FID resolution: combines explicit muteFIDs (from spell_config,
-- for spells whose FIDs were excluded from SpellMuteData as over-shared)
-- with SpellMuteData entries (comma-separated strings parsed on demand).
--
-- The SpellMuteData[sid] string parse is cached per-spell — UNIT_SPELLCAST_SENT
-- fires on every cast attempt, and a Mage spamming Fireball would otherwise
-- re-gmatch the same string several times per second.  SpellMuteData is loaded
-- once via the data addon and never mutates at runtime, so the cache lives
-- as long as the data addon does.
---------------------------------------------------------------------------
local _smf_fids = {}
local _smf_seen = {}
local _smf_parsed = {} -- sid -> array of FIDs (parsed once from SpellMuteData)

local function _parseSpellMuteFIDs(sid)
  local val = Resonance.SpellMuteData and Resonance.SpellMuteData[sid]
  if not val then
    _smf_parsed[sid] = false
    return nil
  end
  local list = {}
  for s in val:gmatch("%d+") do
    list[#list + 1] = tonumber(s)
  end
  _smf_parsed[sid] = list
  return list
end

local function getSpellMuteFIDs(sid)
  local cfg = db and db.spell_config and db.spell_config[sid]
  local explicit = cfg and cfg.muteFIDs
  local cached = _smf_parsed[sid]
  if cached == nil then
    cached = _parseSpellMuteFIDs(sid) or false
  end
  local parsed = cached or nil
  if not explicit and not parsed then
    return nil
  end
  wipe(_smf_fids)
  wipe(_smf_seen)
  local fids, seen = _smf_fids, _smf_seen
  if explicit then
    for _, fid in ipairs(explicit) do
      if not seen[fid] then
        fids[#fids + 1] = fid
        seen[fid] = true
      end
    end
  end
  if parsed then
    for _, fid in ipairs(parsed) do
      if not seen[fid] then
        fids[#fids + 1] = fid
        seen[fid] = true
      end
    end
  end
  return #fids > 0 and fids or nil
end

-- Invalidate the parse cache when the data addon (re)loads.
Resonance.invalidateSpellMuteFIDCache = function()
  wipe(_smf_parsed)
end

-- Returns the cast-initiation FIDs (cast_start / channel_start / precast)
-- for a spell, parsed from the data-addon's Resonance.SpellEarlyFIDs table.
-- Used by the aggressive precast mute toggle to persistently mute the
-- specific FIDs that fire during the synchronous engine call before
-- UNIT_SPELLCAST_SENT — eliminates the first-cast leak.  Returns nil
-- when the spell has no early FIDs or the data addon hasn't loaded.
-- Reuses _smf_fids / _smf_seen scratch tables (Lua main-chunk has a
-- 200-local cap; this function is never called from inside
-- getSpellMuteFIDs, so the shared scratch is safe).  Defined directly on
-- the Resonance namespace (rather than as a local) to avoid pushing the
-- main chunk's active-local count past Lua's 200-slot ceiling.
function Resonance._getEarlyFIDsForSpell(sid)
  local val = Resonance.SpellEarlyFIDs and Resonance.SpellEarlyFIDs[sid]
  if not val then
    return nil
  end
  wipe(_smf_fids)
  wipe(_smf_seen)
  local fids, seen = _smf_fids, _smf_seen
  for s in val:gmatch("%d+") do
    local fid = tonumber(s)
    if fid and not seen[fid] then
      fids[#fids + 1] = fid
      seen[fid] = true
    end
  end
  return #fids > 0 and fids or nil
end

---------------------------------------------------------------------------
-- Auto-mute management (runtime refcounts in autoMutedFIDs)
---------------------------------------------------------------------------
local function addAutoMuteFIDs(fids)
  if not fids then
    return
  end
  for _, fid in ipairs(fids) do
    autoMutedFIDs[fid] = (autoMutedFIDs[fid] or 0) + 1
  end
end

local function removeAutoMuteFIDs(fids)
  if not fids then
    return
  end
  for _, fid in ipairs(fids) do
    local count = (autoMutedFIDs[fid] or 0) - 1
    if count <= 0 then
      autoMutedFIDs[fid] = nil
      if not isMutedElsewhere(fid) then
        UnmuteSoundFile(fid)
      end
    else
      autoMutedFIDs[fid] = count
    end
  end
end

local function getExclusions(spellID)
  local cfg = db.spell_config and db.spell_config[spellID]
  return cfg and cfg.muteExclusions
end

local _filtered = {}

local function filterExclusions(fids, exclusions)
  if not exclusions then
    return fids
  end
  wipe(_filtered)
  local filtered = _filtered
  for _, fid in ipairs(fids) do
    if not exclusions[fid] then
      filtered[#filtered + 1] = fid
    end
  end
  return filtered
end

-- Cached at file scope — the player's class token never changes during a session.
local _, playerClassToken = UnitClass("player")

-- Should we auto-mute for this spell? Only mute FIDs for spells that belong
-- to the player's own class (or custom / saved-preset spells). Spells from
-- other class templates would just silence those sounds for nearby players
-- with no benefit since UNIT_SPELLCAST_SUCCEEDED only fires for "player".
local function shouldAutoMuteSpell(spellID)
  local source = db.preset_spells and db.preset_spells[spellID]
  if not source then
    return true
  end -- custom spell, always mute
  if not (ClassTemplates and ClassTemplates[source]) then
    return true
  end -- saved preset (not a class key)
  return source == playerClassToken
end

local _deferSnapshot = false

local function persistAutoMutedSnapshot()
  -- Save current auto-muted FIDs so stale mutes can be cleaned on next /reload.
  -- MuteSoundFile persists across /reload but autoMutedFIDs (runtime table) does not.
  if _deferSnapshot then
    return
  end
  if not db then
    return
  end
  local snapshot = {}
  for fid, rc in pairs(autoMutedFIDs) do
    if rc > 0 then
      snapshot[fid] = true
    end
  end
  db._lastAutoMutedFIDs = snapshot
end

local function reapplyAutoMutesFromSnapshot()
  -- Fast path for login: re-mute from saved snapshot without SpellMuteData.
  -- Full rebuild happens when Resonance_Data is loaded (Options open).
  local snapshot = db and db._lastAutoMutedFIDs
  if not snapshot then
    return
  end
  local count = 0
  for fid in pairs(snapshot) do
    if not autoMutedFIDs[fid] or autoMutedFIDs[fid] <= 0 then
      autoMutedFIDs[fid] = (autoMutedFIDs[fid] or 0) + 1
      MuteSoundFile(fid)
      count = count + 1
    end
  end
  if count > 0 and db.debug then
    msg(L["Re-applied %d auto-mutes from snapshot."]:format(count))
  end
end

local function rebuildAutoMutes()
  -- Unmute FIDs from the PREVIOUS session that are no longer in the current
  -- auto-mute set.  MuteSoundFile calls persist across /reload, so without
  -- this step, FIDs removed from SpellMuteData (e.g. over-shared generic
  -- sounds) would stay muted until a full client restart.
  local stale = db and db._lastAutoMutedFIDs or {}
  for fid, refcount in pairs(autoMutedFIDs) do
    if refcount > 0 and not isMutedElsewhere(fid, autoMutedFIDs) then
      UnmuteSoundFile(fid)
    end
  end
  wipe(autoMutedFIDs)
  -- Flavors with muteMode = "transient" don't apply persistent auto-mute —
  -- but still drain the stale snapshot from any prior persistent-mute version.
  if Resonance.Flavor.muteMode == "persistent" then
    for sid, _ in pairs(db.spell_config or {}) do
      if shouldAutoMuteSpell(sid) then
        local fids = getSpellMuteFIDs(sid)
        if fids then
          addAutoMuteFIDs(filterExclusions(fids, getExclusions(sid)))
        end
      end
    end
  end
  -- Unmute stale FIDs from the previous session snapshot — drain refcount
  for fid in pairs(stale) do
    if not autoMutedFIDs[fid] or autoMutedFIDs[fid] <= 0 then
      if not isMutedElsewhere(fid) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
  end
  -- Unconditionally unmute over-shared FIDs that were stripped from
  -- SpellMuteData.  These generic sounds (precast, whoosh, etc.) were
  -- muted by earlier data versions and the stale MuteSoundFile state
  -- persists across /reload even though the FIDs are no longer tracked.
  local excludedFIDs = Resonance.ExcludedFIDs
  if excludedFIDs then
    for s in excludedFIDs:gmatch("%d+") do
      local fid = tonumber(s)
      if fid and not db.mute_file_data_ids[fid] then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
  end
  persistAutoMutedSnapshot()
end

local _newFIDs = {}

-- On Classic, we don't apply persistent global mutes — see transient mute path
-- below.  Reason: Classic spell visuals reuse generic sounds (e.g. one "magic
-- cast" FID is shared by ~2800 spells), so persistent muting silences too much.
-- Instead we mute the FIDs only during the cast window, scoped via UNIT_SPELLCAST_SENT.
local function applyAutoMutesForSpell(spellID)
  if Resonance.Flavor.muteMode ~= "persistent" then
    return
  end
  if not shouldAutoMuteSpell(spellID) then
    return
  end
  local fids = getSpellMuteFIDs(spellID)
  if not fids then
    return
  end
  fids = filterExclusions(fids, getExclusions(spellID))
  -- Only call MuteSoundFile for FIDs not already muted (avoid inflating WoW's internal refcount)
  wipe(_newFIDs)
  local newFIDs = _newFIDs
  for _, fid in ipairs(fids) do
    if not autoMutedFIDs[fid] or autoMutedFIDs[fid] <= 0 then
      if not db.mute_file_data_ids[fid] then
        newFIDs[#newFIDs + 1] = fid
      end
    end
  end
  addAutoMuteFIDs(fids)
  if db.enabled then
    for _, fid in ipairs(newFIDs) do
      MuteSoundFile(fid)
    end
  end
  persistAutoMutedSnapshot()
end

local function removeAutoMutesForSpell(spellID)
  if Resonance.Flavor.muteMode ~= "persistent" then
    return
  end
  local fids = getSpellMuteFIDs(spellID)
  if not fids then
    return
  end
  removeAutoMuteFIDs(filterExclusions(fids, getExclusions(spellID)))
  persistAutoMutedSnapshot()
end

---------------------------------------------------------------------------
-- Transient auto-mute (Classic).  MuteSoundFile is global, so muting the
-- shared "magic cast" FIDs persistently would silence them for thousands of
-- unrelated spells.  Instead we mute on UNIT_SPELLCAST_SENT and unmute after
-- TRANSIENT_MUTE_DURATION — collateral is scoped to the cast window.
--
-- Persistence: each muted FID is recorded in db._lastTransientFIDs so an
-- abrupt /reload during a cast doesn't leave the FID muted across sessions.
-- OnEnable drains this snapshot to balance any orphaned MuteSoundFile calls.
---------------------------------------------------------------------------
-- Namespaced (rather than local) to keep Core.lua's main chunk under Lua's
-- 200-active-local cap.  Inner call sites use `Resonance.X` rather than `X`.
function Resonance.persistTransientFID(fid, add)
  if not db then
    return
  end
  db._lastTransientFIDs = db._lastTransientFIDs or {}
  if add then
    db._lastTransientFIDs[fid] = true
  else
    db._lastTransientFIDs[fid] = nil
  end
end

function Resonance.transientMuteFID(fid)
  local existing = transientMutedFIDs[fid]
  if existing then
    existing:Cancel()
  else
    -- Gate MuteSoundFile on the /reload-surviving snapshot, not the in-memory
    -- ticker.  After a /reload mid-cast the in-memory ticker is gone but the
    -- engine still holds our +1 (recorded in db._lastTransientFIDs) — calling
    -- MuteSoundFile again would inflate the refcount beyond what the timer's
    -- single UnmuteSoundFile can balance.
    if not (db._lastTransientFIDs and db._lastTransientFIDs[fid]) then
      MuteSoundFile(fid)
      Resonance.persistTransientFID(fid, true)
    end
  end
  transientMutedFIDs[fid] = C_Timer.NewTimer(TRANSIENT_MUTE_DURATION, function()
    transientMutedFIDs[fid] = nil
    -- Always balance the MuteSoundFile we issued.  Other layers track their
    -- own +1 independently; gating on isMutedElsewhere here would orphan
    -- transient's contribution to WoW's refcount.
    UnmuteSoundFile(fid)
    Resonance.persistTransientFID(fid, false)
  end)
end

function Resonance.applyTransientMutesForCast(spellID)
  if Resonance.Flavor.muteMode ~= "transient" or not db.enabled then
    return
  end
  if not db.spell_config or not db.spell_config[spellID] then
    return
  end
  if not shouldAutoMuteSpell(spellID) then
    return
  end
  local fids = getSpellMuteFIDs(spellID)
  if not fids then
    return
  end
  fids = filterExclusions(fids, getExclusions(spellID))
  for _, fid in ipairs(fids) do
    Resonance.transientMuteFID(fid)
  end
end

-- Drains db._lastTransientFIDs at OnEnable: balances MuteSoundFile calls from
-- the prior session that didn't reach their unmute timer (e.g. /reload mid-cast).
function Resonance.drainStaleTransientMutes()
  if not db or not db._lastTransientFIDs then
    return
  end
  for fid in pairs(db._lastTransientFIDs) do
    UnmuteSoundFile(fid)
  end
  wipe(db._lastTransientFIDs)
end

function Resonance.cancelAllTransientMutes()
  for fid, timer in pairs(transientMutedFIDs) do
    timer:Cancel()
    -- Always balance the MuteSoundFile we issued — see transientMuteFID timer.
    UnmuteSoundFile(fid)
    Resonance.persistTransientFID(fid, false)
  end
  wipe(transientMutedFIDs)
end

-- Workaround for Classic first-cast leak: WoW's audio system silently defers
-- MuteSoundFile for FIDs it hasn't seen yet, so the FIRST cast of a configured
-- spell plays through despite the mute applied at UNIT_SPELLCAST_SENT.
-- The fix is to actually LOAD each FID (force the engine to register it)
-- while muted, then immediately stop and unmute.  PlaySoundFile triggers the
-- asset load; the surrounding MuteSoundFile keeps it silent during that
-- millisecond.  Subsequent MuteSoundFile calls then apply instantly.
function Resonance.warmUpFID(fid)
  MuteSoundFile(fid)
  local ok, handle = PlaySoundFile(fid, "Master")
  if ok and handle then
    StopSound(handle)
  end
  UnmuteSoundFile(fid)
end

function Resonance.warmUpTransientFIDs()
  if not db or not db.spell_config then
    return
  end
  local seen = {}
  for sid in pairs(db.spell_config) do
    if shouldAutoMuteSpell(sid) then
      local fids = getSpellMuteFIDs(sid)
      if fids then
        for _, fid in ipairs(filterExclusions(fids, getExclusions(sid))) do
          if not seen[fid] then
            seen[fid] = true
            Resonance.warmUpFID(fid)
          end
        end
      end
    end
  end
end

---------------------------------------------------------------------------
-- Aggressive precast mute (Classic, opt-in).
--
-- The transient mute path can't catch the first cast of a configured spell
-- after /reload: WoW plays cast-start audio synchronously inside the engine's
-- cast-initiation C call, which runs before UNIT_SPELLCAST_SENT (and any
-- other Lua hook).  This toggle persistently mutes the specific subset of
-- FIDs that fire at cast initiation (StartEvent in {1,3,5}: cast_start,
-- channel_start, precast) for every configured spell.
--
-- Tradeoff (surfaced in the toggle's tooltip): cast-start FIDs are shared
-- across many spells of the same school, so muting Fireball's cast-start
-- also silences other fire spells' cast-start audio.
---------------------------------------------------------------------------
-- Apply persistent mutes for cast-initiation FIDs across all configured
-- spells.  When called with `oneSid`, only that spell is processed
-- (used by GUI add/edit sites so they don't need to call the bulk
-- variant — which would still work, just at higher cost on big configs).
-- Removal goes through clearPrecastMutes + applyPrecastMutes (rebuild
-- from scratch) to handle the shared-FID case correctly: early FIDs are
-- shared across same-school spells (Fireball + Pyroblast etc.), so
-- single-spell removal would need to scan other configs for ownership.
-- applyPrecastMutes / clearPrecastMutes are defined directly on the
-- Resonance namespace (rather than as locals) to keep the main chunk's
-- active-local count under Lua's 200-slot ceiling.  Use the local
-- aliases (applyPrecastMutes, clearPrecastMutes) at call sites for
-- syntactic parity with the other mute layers.
-- Mute a single early FID: gate MuteSoundFile on the /reload-surviving
-- snapshot so we don't double-mute across reloads.  Always update the
-- in-memory table so isMutedElsewhere / playOneSoundWithUnmute see current
-- state.  Returns 1 if a fresh MuteSoundFile was issued, 0 if it was already
-- muted from a prior session.
local function _mutePrecastFID(fid)
  precastMutedFIDs[fid] = true
  db._lastPrecastFIDs = db._lastPrecastFIDs or {}
  if db._lastPrecastFIDs[fid] then
    return 0
  end
  db._lastPrecastFIDs[fid] = true
  MuteSoundFile(fid)
  return 1
end

function Resonance.applyPrecastMutes(oneSid)
  if not db or not db.spell_config then
    return
  end
  if oneSid and (not db.mutePrecastSounds or not Resonance.Flavor.supportsPrecastMute) then
    return
  end
  local count = 0
  if oneSid then
    if shouldAutoMuteSpell(oneSid) then
      local fids = Resonance._getEarlyFIDsForSpell(oneSid)
      if fids then
        local exclusions = getExclusions(oneSid)
        for _, fid in ipairs(fids) do
          if not exclusions or not exclusions[fid] then
            count = count + _mutePrecastFID(fid)
          end
        end
      end
    end
  else
    for sid in pairs(db.spell_config) do
      if shouldAutoMuteSpell(sid) then
        local fids = Resonance._getEarlyFIDsForSpell(sid)
        if fids then
          local exclusions = getExclusions(sid)
          for _, fid in ipairs(fids) do
            if not exclusions or not exclusions[fid] then
              count = count + _mutePrecastFID(fid)
            end
          end
        end
      end
    end
  end
  if count > 0 and db.debug then
    msg(L["Applied %d precast mutes."]:format(count))
  end
end

-- Drains the snapshot via exactly one UnmuteSoundFile per FID — symmetric with
-- the MuteSoundFile in _mutePrecastFID.  Other layers are tracked independently
-- so we don't gate on isMutedElsewhere here (gating would orphan our +1).
function Resonance.clearPrecastMutes()
  local snapshot = db and db._lastPrecastFIDs
  local count = 0
  if snapshot then
    for fid in pairs(snapshot) do
      UnmuteSoundFile(fid)
      count = count + 1
    end
    wipe(snapshot)
  end
  wipe(precastMutedFIDs)
  if count > 0 and db and db.debug then
    msg(L["Cleared %d precast mutes."]:format(count))
  end
end

local function applyMutes()
  local count = 0
  for fid, enabled in pairs(db.mute_file_data_ids or {}) do
    if enabled then
      MuteSoundFile(fid)
      count = count + 1
    end
  end
  for fid, refcount in pairs(autoMutedFIDs) do
    if refcount > 0 and not db.mute_file_data_ids[fid] then
      MuteSoundFile(fid)
      count = count + 1
    end
  end
  if count > 0 then
    msg(L["Applied %d sound mutes."]:format(count))
  end
end

local function clearMutes()
  local count = 0
  for fid, enabled in pairs(db.mute_file_data_ids or {}) do
    if
      enabled
      and not voxMutedFIDs[fid]
      and not weaponMutedFIDs[fid]
      and not creatureMutedFIDs[fid]
      and not autoShotMutedFIDs[fid]
      and not professionMutedFIDs[fid]
      and not npcMutedFIDs[fid]
      and not mountMutedFIDs[fid]
      and not ambientMutedFIDs[fid]
    then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
      count = count + 1
    end
  end
  for fid, refcount in pairs(autoMutedFIDs) do
    if refcount > 0 and not isMutedElsewhere(fid, autoMutedFIDs) then
      for _ = 1, MAX_MUTE_DEPTH do
        UnmuteSoundFile(fid)
      end
      count = count + 1
    end
  end
  if count > 0 then
    msg(L["Cleared %d sound mutes."]:format(count))
  end
end

---------------------------------------------------------------------------
-- Base64 encode/decode
---------------------------------------------------------------------------
local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local b64lookup = {}
for i = 1, 64 do
  b64lookup[b64chars:byte(i)] = i - 1
end

local function b64encode(data)
  local out = {}
  for i = 1, #data, 3 do
    local a, b, c = data:byte(i, i + 2)
    b = b or 0
    c = c or 0
    local n = a * 65536 + b * 256 + c
    local remaining = #data - i + 1
    out[#out + 1] = b64chars:sub(bit.rshift(n, 18) + 1, bit.rshift(n, 18) + 1)
    out[#out + 1] = b64chars:sub(bit.band(bit.rshift(n, 12), 63) + 1, bit.band(bit.rshift(n, 12), 63) + 1)
    out[#out + 1] = remaining > 1
        and b64chars:sub(bit.band(bit.rshift(n, 6), 63) + 1, bit.band(bit.rshift(n, 6), 63) + 1)
      or "="
    out[#out + 1] = remaining > 2 and b64chars:sub(bit.band(n, 63) + 1, bit.band(n, 63) + 1) or "="
  end
  return table.concat(out)
end

local function b64decode(data)
  data = data:gsub("[^A-Za-z0-9+/=]", "")
  local out = {}
  for i = 1, #data, 4 do
    local a = b64lookup[data:byte(i)] or 0
    local b = b64lookup[data:byte(i + 1)] or 0
    local c = b64lookup[data:byte(i + 2)] or 0
    local d = b64lookup[data:byte(i + 3)] or 0
    local n = a * 262144 + b * 4096 + c * 64 + d
    out[#out + 1] = string.char(bit.band(bit.rshift(n, 16), 255))
    if data:sub(i + 2, i + 2) ~= "=" then
      out[#out + 1] = string.char(bit.band(bit.rshift(n, 8), 255))
    end
    if data:sub(i + 3, i + 3) ~= "=" then
      out[#out + 1] = string.char(bit.band(n, 255))
    end
  end
  return table.concat(out)
end

---------------------------------------------------------------------------
-- Export / Import
---------------------------------------------------------------------------
local function encodeSoundValue(sound)
  if type(sound) == "table" then
    local parts = {}
    for _, s in ipairs(sound) do
      parts[#parts + 1] = tostring(s)
    end
    if sound.random then
      local rParts = {}
      for _, s in ipairs(sound.random) do
        rParts[#rParts + 1] = tostring(s)
      end
      parts[#parts + 1] = "R" .. table.concat(rParts, "|")
    end
    if sound.sequence then
      parts[#parts + 1] = "SEQ"
    end
    return table.concat(parts, ",")
  elseif type(sound) == "number" then
    return tostring(sound)
  else
    return '"' .. tostring(sound) .. '"'
  end
end

local function decodeSoundValue(val)
  if not val or val == "" then
    return nil
  end
  local quoted = val:match('^"(.*)"$')
  if quoted then
    return quoted
  end
  if val:find(",") then
    local sound = {}
    for part in val:gmatch("[^,]+") do
      if part == "SEQ" then
        sound.sequence = true
      else
        local rPool = part:match("^R(.+)$")
        if rPool then
          sound.random = {}
          for fidStr in rPool:gmatch("(%d+)") do
            sound.random[#sound.random + 1] = tonumber(fidStr)
          end
        else
          local n = tonumber(part)
          if n then
            sound[#sound + 1] = n
          end
        end
      end
    end
    if sound.random and #sound.random == 0 then
      sound.random = nil
    end
    if #sound == 1 and not sound.random and not sound.sequence then
      sound = sound[1]
    end
    if #sound == 0 and not sound.random and not sound.sequence then
      sound = nil
    end
    return sound
  end
  return tonumber(val)
end

local function encodePresetData(name, spells, mutes)
  local lines = { "V1" }
  if name and name ~= "" then
    lines[#lines + 1] = "N" .. name
  end
  for sid, cfg in pairs(spells or {}) do
    if cfg.sound == false then
      lines[#lines + 1] = "S" .. sid .. "=!M"
    elseif cfg.sound then
      lines[#lines + 1] = "S" .. sid .. "=" .. encodeSoundValue(cfg.sound)
    else
      lines[#lines + 1] = "S" .. sid .. "="
    end
    if cfg.muteExclusions then
      local excl = {}
      for fid in pairs(cfg.muteExclusions) do
        excl[#excl + 1] = tostring(fid)
      end
      if #excl > 0 then
        lines[#lines + 1] = "X" .. sid .. ":" .. table.concat(excl, ",")
      end
    end
    if cfg.muteFIDs then
      local parts = {}
      for _, fid in ipairs(cfg.muteFIDs) do
        parts[#parts + 1] = tostring(fid)
      end
      if #parts > 0 then
        lines[#lines + 1] = "F" .. sid .. ":" .. table.concat(parts, ",")
      end
    end
    if cfg.duration then
      lines[#lines + 1] = "D" .. sid .. ":" .. tostring(cfg.duration)
    end
    if cfg.loop then
      lines[#lines + 1] = "L" .. sid .. ":" .. tostring(cfg.loop)
    end
    if cfg.trigger and cfg.trigger ~= "cast" then
      lines[#lines + 1] = "T" .. sid .. ":" .. cfg.trigger
    end
    if cfg.precastSound then
      lines[#lines + 1] = "P" .. sid .. "=" .. encodeSoundValue(cfg.precastSound)
    end
    if cfg.precastDuration then
      lines[#lines + 1] = "Q" .. sid .. ":" .. tostring(cfg.precastDuration)
    end
  end
  for fid in pairs(mutes or {}) do
    lines[#lines + 1] = "M" .. fid
  end
  local text = table.concat(lines, "\n")
  return "!Resonance!" .. b64encode(text)
end

local function decodePresetString(str)
  if not str or str == "" then
    return nil, L["Empty import string."]
  end
  str = str:match("^%s*(.-)%s*$")
  if str:sub(1, 11) ~= "!Resonance!" then
    return nil, L["Invalid format: missing !Resonance! prefix."]
  end
  local encoded = str:sub(12)
  local ok, decoded = pcall(b64decode, encoded)
  if not ok or not decoded or decoded == "" then
    return nil, L["Failed to decode import string."]
  end
  local lines = {}
  for line in decoded:gmatch("[^\n]+") do
    lines[#lines + 1] = line
  end
  if #lines == 0 or (lines[1] ~= "V1" and lines[1] ~= "V2") then
    return nil, L["Unknown format version."]
  end
  local name = nil
  local spells = {}
  local mutes = {}
  for i = 2, #lines do
    local line = lines[i]
    local tag = line:sub(1, 1)
    if tag == "N" then
      name = line:sub(2)
    elseif tag == "S" then
      local sid, val = line:match("^S(%d+)=(.*)")
      sid = tonumber(sid)
      if sid then
        local sound
        if val == "!M" then
          sound = false
        elseif val and val ~= "" then
          sound = decodeSoundValue(val)
        end
        spells[sid] = { sound = sound }
      end
    elseif tag == "X" then
      local sid, fidList = line:match("^X(%d+):(.+)$")
      sid = tonumber(sid)
      if sid and fidList and spells[sid] then
        local exclusions = spells[sid].muteExclusions or {}
        for fidStr in fidList:gmatch("(%d+)") do
          exclusions[tonumber(fidStr)] = true
        end
        spells[sid].muteExclusions = exclusions
      end
    elseif tag == "F" then
      local sid, fidList = line:match("^F(%d+):(.+)$")
      sid = tonumber(sid)
      if sid and fidList and spells[sid] then
        local mfids = {}
        for fidStr in fidList:gmatch("(%d+)") do
          mfids[#mfids + 1] = tonumber(fidStr)
        end
        if #mfids > 0 then
          spells[sid].muteFIDs = mfids
        end
      end
    elseif tag == "D" then
      local sid, dur = line:match("^D(%d+):(.+)$")
      sid = tonumber(sid)
      dur = tonumber(dur)
      if sid and dur and spells[sid] then
        spells[sid].duration = dur
      end
    elseif tag == "L" then
      local sid, val = line:match("^L(%d+):(.+)$")
      sid = tonumber(sid)
      if sid and val and spells[sid] then
        if val == "true" then
          spells[sid].loop = true
        else
          local n = tonumber(val)
          if n then
            spells[sid].loop = n
          end
        end
      end
    elseif tag == "T" then
      local sid, trig = line:match("^T(%d+):(.+)$")
      sid = tonumber(sid)
      if sid and trig and spells[sid] then
        if trig == "cast" or trig == "precast" or trig == "precast_and_cast" then
          spells[sid].trigger = trig
        end
      end
    elseif tag == "P" then
      local sid, val = line:match("^P(%d+)=(.*)")
      sid = tonumber(sid)
      if sid and val and spells[sid] then
        spells[sid].precastSound = decodeSoundValue(val)
      end
    elseif tag == "Q" then
      local sid, dur = line:match("^Q(%d+):(.+)$")
      sid = tonumber(sid)
      dur = tonumber(dur)
      if sid and dur and spells[sid] then
        spells[sid].precastDuration = dur
      end
    elseif tag == "M" then
      local fid = tonumber(line:sub(2))
      if fid then
        mutes[fid] = true
      end
    end
  end
  return name, { spells = spells, mutes = mutes }
end

function Resonance:ExportConfig(name)
  local spells = {}
  for sid, cfg in pairs(db.spell_config or {}) do
    local entry = { sound = cfg.sound }
    if cfg.muteExclusions then
      entry.muteExclusions = {}
      for fid in pairs(cfg.muteExclusions) do
        entry.muteExclusions[fid] = true
      end
    end
    if cfg.muteFIDs then
      entry.muteFIDs = cfg.muteFIDs
    end
    if cfg.duration then
      entry.duration = cfg.duration
    end
    if cfg.loop then
      entry.loop = cfg.loop
    end
    if cfg.trigger and cfg.trigger ~= "cast" then
      entry.trigger = cfg.trigger
    end
    if cfg.precastSound then
      entry.precastSound = cfg.precastSound
    end
    if cfg.precastDuration then
      entry.precastDuration = cfg.precastDuration
    end
    if cfg.multiHitWindow then
      entry.multiHitWindow = cfg.multiHitWindow
    end
    if cfg.hordeSound then
      entry.hordeSound = cfg.hordeSound
    end
    if cfg.allianceSound then
      entry.allianceSound = cfg.allianceSound
    end
    if cfg.precastLoop then
      entry.precastLoop = cfg.precastLoop
    end
    spells[sid] = entry
  end
  local mutes = {}
  for fid, enabled in pairs(db.mute_file_data_ids or {}) do
    if enabled then
      mutes[fid] = true
    end
  end
  return encodePresetData(name, spells, mutes)
end

function Resonance:ImportConfig(str)
  local name, result = decodePresetString(str)
  if not name and type(result) == "string" then
    return nil, result
  end
  if not result or type(result) ~= "table" then
    return nil, L["Failed to parse import string."]
  end
  local added, skipped, addedMutes = 0, 0, 0
  _deferSnapshot = true
  for sid, cfg in pairs(result.spells or {}) do
    if db.spell_config[sid] then
      skipped = skipped + 1
    else
      db.spell_config[sid] = {
        sound = cfg.sound,
        muteExclusions = cfg.muteExclusions,
        muteFIDs = cfg.muteFIDs,
        duration = cfg.duration,
        loop = cfg.loop,
        trigger = cfg.trigger,
        precastSound = cfg.precastSound,
        precastDuration = cfg.precastDuration,
        precastLoop = cfg.precastLoop,
        multiHitWindow = cfg.multiHitWindow,
        hordeSound = cfg.hordeSound,
        allianceSound = cfg.allianceSound,
      }
      applyAutoMutesForSpell(sid)
      Resonance.applyPrecastMutes(sid)
      added = added + 1
    end
  end
  _deferSnapshot = false
  for fid in pairs(result.mutes or {}) do
    if not db.mute_file_data_ids[fid] then
      db.mute_file_data_ids[fid] = true
      MuteSoundFile(fid)
      addedMutes = addedMutes + 1
    end
  end
  if added > 0 then
    persistAutoMutedSnapshot()
    invalidateSpellNameIndex()
  end
  return added, skipped, addedMutes
end

---------------------------------------------------------------------------
-- Preset management
---------------------------------------------------------------------------
function Resonance:ImportToPreset(str)
  local name, result = decodePresetString(str)
  if not name and type(result) == "string" then
    return nil, result
  end
  if not result or type(result) ~= "table" then
    return nil, L["Failed to parse import string."]
  end
  if not name or name == "" then
    local idx = 1
    repeat
      name = "Imported Preset" .. (idx > 1 and (" " .. idx) or "")
      idx = idx + 1
    until not db.saved_presets[name]
  end
  return name, result
end

function Resonance:SaveCurrentAsPreset(name)
  if not name or name == "" then
    return false
  end
  local preset = { spells = {}, mutes = {} }
  for sid, cfg in pairs(db.spell_config or {}) do
    local entry = { sound = cfg.sound }
    if cfg.muteExclusions then
      entry.muteExclusions = {}
      for fid in pairs(cfg.muteExclusions) do
        entry.muteExclusions[fid] = true
      end
    end
    if cfg.muteFIDs then
      entry.muteFIDs = cfg.muteFIDs
    end
    if cfg.duration then
      entry.duration = cfg.duration
    end
    if cfg.loop then
      entry.loop = cfg.loop
    end
    if cfg.trigger and cfg.trigger ~= "cast" then
      entry.trigger = cfg.trigger
    end
    if cfg.precastSound then
      entry.precastSound = cfg.precastSound
    end
    if cfg.precastDuration then
      entry.precastDuration = cfg.precastDuration
    end
    if cfg.precastLoop then
      entry.precastLoop = cfg.precastLoop
    end
    if cfg.multiHitWindow then
      entry.multiHitWindow = cfg.multiHitWindow
    end
    if cfg.hordeSound then
      entry.hordeSound = cfg.hordeSound
    end
    if cfg.allianceSound then
      entry.allianceSound = cfg.allianceSound
    end
    preset.spells[sid] = entry
  end
  for fid, enabled in pairs(db.mute_file_data_ids or {}) do
    if enabled then
      preset.mutes[fid] = true
    end
  end
  db.saved_presets[name] = preset
  return true
end

function Resonance:ApplySavedPreset(name)
  local preset = db.saved_presets[name]
  if not preset then
    return 0, 0, 0
  end
  local added, skipped, addedMutes = 0, 0, 0
  _deferSnapshot = true
  for sid, cfg in pairs(preset.spells or {}) do
    if db.spell_config[sid] then
      skipped = skipped + 1
    else
      local newCfg = { sound = cfg.sound }
      if cfg.muteExclusions then
        newCfg.muteExclusions = {}
        for fid in pairs(cfg.muteExclusions) do
          newCfg.muteExclusions[fid] = true
        end
      end
      if cfg.muteFIDs then
        newCfg.muteFIDs = cfg.muteFIDs
      end
      if cfg.duration then
        newCfg.duration = cfg.duration
      end
      if cfg.loop then
        newCfg.loop = cfg.loop
      end
      if cfg.trigger and cfg.trigger ~= "cast" then
        newCfg.trigger = cfg.trigger
      end
      if cfg.precastSound then
        newCfg.precastSound = cfg.precastSound
      end
      if cfg.precastDuration then
        newCfg.precastDuration = cfg.precastDuration
      end
      if cfg.precastLoop then
        newCfg.precastLoop = cfg.precastLoop
      end
      if cfg.multiHitWindow then
        newCfg.multiHitWindow = cfg.multiHitWindow
      end
      if cfg.hordeSound then
        newCfg.hordeSound = cfg.hordeSound
      end
      if cfg.allianceSound then
        newCfg.allianceSound = cfg.allianceSound
      end
      db.spell_config[sid] = newCfg
      db.preset_spells[sid] = name
      applyAutoMutesForSpell(sid)
      Resonance.applyPrecastMutes(sid)
      added = added + 1
    end
  end
  _deferSnapshot = false
  for fid in pairs(preset.mutes or {}) do
    if not db.mute_file_data_ids[fid] then
      db.mute_file_data_ids[fid] = true
      MuteSoundFile(fid)
      addedMutes = addedMutes + 1
    end
  end
  if added > 0 then
    persistAutoMutedSnapshot()
    invalidateSpellNameIndex()
  end
  return added, skipped, addedMutes
end

function Resonance:DeleteSavedPreset(name)
  if not name then
    return
  end
  db.saved_presets[name] = nil
end

function Resonance:ExportPresetData(name, presetData)
  return encodePresetData(name, presetData.spells, presetData.mutes)
end

function Resonance:ClassPresetToData(classKey)
  local template = ClassTemplates and ClassTemplates[classKey]
  if not template then
    return nil
  end
  local data = { spells = {}, mutes = {} }
  for _, entry in ipairs(template) do
    local spell = { sound = entry.sound }
    if entry.muteExclusions then
      spell.muteExclusions = {}
      for _, fid in ipairs(entry.muteExclusions) do
        spell.muteExclusions[fid] = true
      end
    end
    if entry.muteFIDs then
      spell.muteFIDs = { unpack(entry.muteFIDs) }
    end
    data.spells[entry.spellID] = spell
  end
  return data
end

---------------------------------------------------------------------------
-- Public API (for Options.lua)
---------------------------------------------------------------------------
Resonance.MAX_MUTE_DEPTH = MAX_MUTE_DEPTH
Resonance.ADDON_ROOT = ADDON_ROOT
Resonance.CUSTOM_SOUNDS_ROOT = CUSTOM_SOUNDS_ROOT
Resonance.msg = msg
Resonance.getSpellName = getSpellName
Resonance.normalizePath = normalizePath
Resonance.previewSound = previewSound
Resonance.syncCustomSoundsToLSM = syncCustomSoundsToLSM
Resonance.getLSM = getLSM
Resonance.applyMutes = applyMutes
Resonance.clearMutes = clearMutes
Resonance.applyAutoMutesForSpell = applyAutoMutesForSpell
Resonance.removeAutoMutesForSpell = removeAutoMutesForSpell
-- Resonance.applyPrecastMutes / clearPrecastMutes / _getEarlyFIDsForSpell
-- are namespaced at definition (avoids Lua main-chunk local-slot pressure).
Resonance.precastMutedFIDs = precastMutedFIDs
Resonance.rebuildAutoMutes = rebuildAutoMutes
Resonance.shouldAutoMuteSpell = shouldAutoMuteSpell
Resonance.getSpellMuteFIDs = getSpellMuteFIDs
Resonance.autoMutedFIDs = autoMutedFIDs
Resonance.voxMutedFIDs = voxMutedFIDs
Resonance.weaponMutedFIDs = weaponMutedFIDs
Resonance.creatureMutedFIDs = creatureMutedFIDs
Resonance.autoShotMutedFIDs = autoShotMutedFIDs
Resonance.professionMutedFIDs = professionMutedFIDs
Resonance.npcMutedFIDs = npcMutedFIDs
Resonance.ambientMutedFIDs = ambientMutedFIDs
Resonance.refreshAmbientMutes = refreshAmbientMutes
Resonance.invalidateSpellNameIndex = invalidateSpellNameIndex
-- Migrate legacy cfg.hordeSound / cfg.allianceSound into cfg.horde.sound /
-- cfg.alliance.sound (Phase C GUI writes the bundle; old data needs lifting
-- before the editor opens).
Resonance.migrateFaction = migrateFaction
Resonance.migrateImpacts = migrateImpacts
-- Resonance.refreshPresetsFromTemplates is assigned below, after the
-- function definition (exposes it to Options.lua's "Refresh Class Presets"
-- button so users can re-apply templates without relogging).

---------------------------------------------------------------------------
-- LoadOnDemand data addon loader
---------------------------------------------------------------------------
local dataLoaded = false

local function loadDataAddon()
  if dataLoaded then
    return true
  end
  local loaded, reason = C_AddOns.LoadAddOn("Resonance_Data")
  if loaded then
    captureDataAddonGlobals()
    -- Full rebuild with real data (replaces snapshot-based mutes)
    if Resonance.SpellMuteData then
      rebuildAutoMutes()
    end
    -- Rebuild data-dependent mute layers now that real tables are available
    if Resonance.CreatureVoxData and hasAnyCreatureVoxEnabled() then
      clearCreatureVoxMutes()
      applyCreatureVoxMutes()
    end
    if Resonance.NPCSoundCSD and hasAnyNPCMuteEnabled() then
      clearNPCMutes()
      applyNPCMutes()
    end
    if Resonance.MountSoundCSD and hasAnyMountMuteEnabled() then
      clearMountMutes()
      applyMountMutes()
    end
    if Resonance.VoxFIDs and getVoxMode() ~= "off" then
      clearVoxMutes()
      applyVoxMutes()
    end
    if Resonance.WeaponImpactFIDs and db.muteWeaponImpacts then
      clearWeaponMutes()
      applyWeaponMutes()
    end
    dataLoaded = true
    return true
  end
  return false, reason
end

Resonance.loadDataAddon = loadDataAddon

function Resonance:ApplyClassTemplate(classKey)
  local template = ClassTemplates and ClassTemplates[classKey]
  if not template then
    return 0, 0
  end
  local added, skipped = 0, 0
  -- Clear removal markers so the template's spells can be re-added
  if db.removed_template_spells then
    for _, entry in ipairs(template) do
      db.removed_template_spells[entry.spellID] = nil
    end
  end
  _deferSnapshot = true
  for _, entry in ipairs(template) do
    local sid = entry.spellID
    if db.spell_config[sid] then
      skipped = skipped + 1
    else
      local cfg = { sound = entry.sound }
      if entry.muteExclusions then
        cfg.muteExclusions = {}
        for _, fid in ipairs(entry.muteExclusions) do
          cfg.muteExclusions[fid] = true
        end
      end
      if entry.muteFIDs then
        cfg.muteFIDs = { unpack(entry.muteFIDs) }
      end
      if entry.duration then
        cfg.duration = entry.duration
      end
      if entry.loop then
        cfg.loop = entry.loop
      end
      if entry.trigger then
        cfg.trigger = entry.trigger
      end
      if entry.precastSound then
        cfg.precastSound = entry.precastSound
      end
      if entry.precastDuration then
        cfg.precastDuration = entry.precastDuration
      end
      if entry.precastLoop then
        cfg.precastLoop = entry.precastLoop
      end
      if entry.multiHitWindow then
        cfg.multiHitWindow = entry.multiHitWindow
      end
      if entry.hordeSound then
        cfg.hordeSound = entry.hordeSound
      end
      if entry.allianceSound then
        cfg.allianceSound = entry.allianceSound
      end
      db.spell_config[sid] = cfg
      db.preset_spells[sid] = classKey
      applyAutoMutesForSpell(sid)
      Resonance.applyPrecastMutes(sid)
      added = added + 1
    end
  end
  _deferSnapshot = false
  if added > 0 then
    persistAutoMutedSnapshot()
    invalidateSpellNameIndex()
  end
  return added, skipped
end

-- Refresh preset spells to match current template values (runs on load)
-- Updates sound and muteExclusions from template; preserves user's additional unmutes
-- Also auto-adds new template spells for classes the user has already loaded
local function refreshPresetsFromTemplates()
  if not ClassTemplates then
    return
  end
  local updated, added = 0, 0
  _deferSnapshot = true

  -- Collect which class templates the user has loaded
  local loadedClasses = {}
  for _, source in pairs(db.preset_spells) do
    loadedClasses[source] = true
  end

  -- Update existing preset spells
  for sid, source in pairs(db.preset_spells) do
    local template = ClassTemplates[source]
    if template then
      for _, entry in ipairs(template) do
        if entry.spellID == sid then
          local cfg = db.spell_config[sid]
          if cfg then
            -- Update mute data from template (template is source of truth),
            -- but preserve user-customized sound (the user may have changed it).
            if entry.muteExclusions then
              cfg.muteExclusions = {}
              for _, fid in ipairs(entry.muteExclusions) do
                cfg.muteExclusions[fid] = true
              end
            else
              cfg.muteExclusions = nil
            end
            if entry.muteFIDs then
              cfg.muteFIDs = { unpack(entry.muteFIDs) }
            else
              cfg.muteFIDs = nil
            end
            -- Propagate shape fields from template when the user hasn't set
            -- their own value. Nil-check (not truthy check) so cfg.sound =
            -- false (explicit mute-only) is preserved. Fields that post-date
            -- the user's last Apply/Refresh (e.g. precastSound / trigger /
            -- impacts added in a later template version) reach them here
            -- without wiping anything they customised via the GUI.
            if cfg.multiHitWindow == nil then
              cfg.multiHitWindow = entry.multiHitWindow
            end
            if cfg.trigger == nil and entry.trigger then
              cfg.trigger = entry.trigger
            end
            if cfg.precastSound == nil and entry.precastSound then
              cfg.precastSound = entry.precastSound
            end
            if cfg.precastDuration == nil and entry.precastDuration then
              cfg.precastDuration = entry.precastDuration
            end
            if cfg.precastLoop == nil and entry.precastLoop then
              cfg.precastLoop = entry.precastLoop
            end
            if cfg.duration == nil and entry.duration then
              cfg.duration = entry.duration
            end
            if cfg.loop == nil and entry.loop then
              cfg.loop = entry.loop
            end
            if cfg.impacts == nil and entry.impacts then
              cfg.impacts = {}
              for i, imp in ipairs(entry.impacts) do
                cfg.impacts[i] = { delay = imp.delay, sound = imp.sound, duration = imp.duration }
              end
            end
            updated = updated + 1
          end
          break
        end
      end
    end
  end

  -- Auto-add new template spells for classes already loaded
  for classKey in pairs(loadedClasses) do
    local template = ClassTemplates[classKey]
    if template then
      for _, entry in ipairs(template) do
        local sid = entry.spellID
        if not db.spell_config[sid] and not (db.removed_template_spells and db.removed_template_spells[sid]) then
          local cfg = { sound = entry.sound }
          if entry.muteExclusions then
            cfg.muteExclusions = {}
            for _, fid in ipairs(entry.muteExclusions) do
              cfg.muteExclusions[fid] = true
            end
          end
          if entry.muteFIDs then
            cfg.muteFIDs = { unpack(entry.muteFIDs) }
          end
          if entry.duration then
            cfg.duration = entry.duration
          end
          if entry.loop then
            cfg.loop = entry.loop
          end
          if entry.trigger then
            cfg.trigger = entry.trigger
          end
          if entry.precastSound then
            cfg.precastSound = entry.precastSound
          end
          if entry.precastDuration then
            cfg.precastDuration = entry.precastDuration
          end
          if entry.multiHitWindow then
            cfg.multiHitWindow = entry.multiHitWindow
          end
          if entry.hordeSound then
            cfg.hordeSound = entry.hordeSound
          end
          if entry.allianceSound then
            cfg.allianceSound = entry.allianceSound
          end
          if entry.precastLoop then
            cfg.precastLoop = entry.precastLoop
          end
          db.spell_config[sid] = cfg
          db.preset_spells[sid] = classKey
          added = added + 1
        end
      end
    end
  end

  _deferSnapshot = false
  if updated > 0 or added > 0 then
    invalidateSpellNameIndex()
    -- Only do a full rebuild if SpellMuteData is available (data addon loaded).
    -- Otherwise defer to OnEnable's snapshot reapply or later loadDataAddon().
    if Resonance.SpellMuteData then
      rebuildAutoMutes()
      persistAutoMutedSnapshot()
    else
      -- SpellMuteData isn't loaded yet (LoadOnDemand). The snapshot on disk
      -- reflects the OLD auto-mute set and would stick around via
      -- reapplyAutoMutesFromSnapshot on login. Any new cfg.muteFIDs we just
      -- wrote would stay unapplied until the user opens Options (which
      -- triggers loadDataAddon → rebuildAutoMutes). Invalidate the snapshot
      -- so OnEnable's branch takes loadDataAddon instead and the rebuild
      -- runs eagerly this session.
      db._lastAutoMutedFIDs = nil
    end
    if added > 0 then
      msg(L["%d new template spell(s) auto-added."]:format(added))
    end
  end
end
-- Exposed so the Spell Sounds tab's Refresh Class Presets button can
-- update already-applied presets without relogging.
Resonance.refreshPresetsFromTemplates = refreshPresetsFromTemplates

function Resonance:RemovePresetSpells(presetName)
  local removed = 0
  local toRemove = {}
  for sid, source in pairs(db.preset_spells) do
    if not presetName or source == presetName then
      toRemove[#toRemove + 1] = sid
    end
  end
  _deferSnapshot = true
  for _, sid in ipairs(toRemove) do
    removeAutoMutesForSpell(sid)
    db.spell_config[sid] = nil
    db.preset_spells[sid] = nil
    removed = removed + 1
  end
  -- Recompute precast mutes from the remaining configs.  removePrecastMutes-
  -- ForSpell can't run AFTER spell_config[sid] = nil (it scans the config
  -- to find FIDs still claimed by other spells), and running it BEFORE means
  -- N^2 work for class-wide preset removal.  Just rebuild from scratch.
  if db.mutePrecastSounds and Resonance.Flavor.supportsPrecastMute then
    Resonance.clearPrecastMutes()
    Resonance.applyPrecastMutes()
  end
  _deferSnapshot = false
  if removed > 0 then
    persistAutoMutedSnapshot()
    invalidateSpellNameIndex()
  end
  return removed
end

---------------------------------------------------------------------------
-- AceConfig options tables (one per subcategory panel)
---------------------------------------------------------------------------
local function getGeneralOptions()
  return {
    type = "group",
    name = L["General"],
    args = {
      enabled = {
        type = "toggle",
        name = L["Enable Resonance"],
        desc = L["Toggle the addon on/off. When disabled, all sound mutes are removed and no custom spell sounds play."],
        order = 1,
        width = "full",
        get = function()
          return db.enabled
        end,
        set = function(_, v)
          if v then
            Resonance.enableEverything()
          else
            Resonance.disableEverything()
          end
        end,
      },
      debug = {
        type = "toggle",
        name = L["Debug mode (print casts to chat)"],
        desc = L["Print spell cast details (name, ID) to chat on each cast. Useful for finding spell IDs to configure."],
        order = 2,
        width = "full",
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.debug
        end,
        set = function(_, v)
          db.debug = v
        end,
      },
      showMinimap = {
        type = "toggle",
        name = L["Show minimap button"],
        desc = L["Show a minimap button. Left-click opens options, right-click toggles addon on/off, drag to reposition."],
        order = 3,
        width = "full",
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return not db.minimap.hide
        end,
        set = function(_, v)
          Resonance.toggleMinimapButton(v)
        end,
      },
      multiHitWindow = {
        type = "range",
        name = L["Multi-hit sound window"],
        desc = L["Time window (in seconds) during which a multi-hit spell won't replay its replacement sound. Prevents abilities like Rampage from playing the sound on every hit. Set to 0 to disable. Per-spell overrides take priority."],
        order = 4,
        min = 0,
        max = 5,
        step = 0.1,
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.multiHitWindow
        end,
        set = function(_, v)
          db.multiHitWindow = v
        end,
      },
      soundChannel = {
        type = "select",
        name = L["Replacement sound channel"],
        desc = L["Which audio channel to play replacement spell sounds on. Use 'Master' to always hear them regardless of other volume sliders."],
        order = 5,
        disabled = function()
          return not db.enabled
        end,
        values = {
          Master = MASTER_VOLUME or "Master",
          SFX = SOUND_VOLUME or "SFX",
          Music = MUSIC_VOLUME or "Music",
          Ambience = AMBIENCE_VOLUME or "Ambience",
          Dialog = DIALOG_VOLUME or "Dialog",
        },
        sorting = { "Master", "SFX", "Music", "Ambience", "Dialog" },
        get = function()
          return db.soundChannel
        end,
        set = function(_, v)
          db.soundChannel = v
        end,
      },
      mutePrecastSounds = {
        type = "toggle",
        name = L["Aggressive cast-start mute (opt-in)"],
        desc = L["Fixes the first-cast audio leak: by default this flavor mutes spells only during the cast window, so the very first cast in a session briefly plays the default cast-start sound before the mute applies.\n\n|cffff8080Trade-off:|r WoW shares cast-start sound files across same-school spells. Enabling this also silences the default cast-start audio of unrelated spells sharing those file IDs (e.g. enabling for Fireball will also affect Pyroblast and Scorch)."],
        order = 6,
        width = "full",
        hidden = function()
          return not Resonance.Flavor.supportsPrecastMute
        end,
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.mutePrecastSounds
        end,
        set = function(_, v)
          db.mutePrecastSounds = v
          if v then
            if db.enabled then
              Resonance.applyPrecastMutes()
              msg(L["Aggressive cast-start mute enabled."])
            end
          else
            Resonance.clearPrecastMutes()
            msg(L["Aggressive cast-start mute disabled."])
          end
        end,
      },
    },
  }
end

local function getMutingOptions()
  local opts = {
    type = "group",
    name = L["Muting"],
    args = {
      otherPlayersHeader = {
        type = "header",
        name = L["Other Players"],
        order = 0.1,
        hidden = function()
          return not Resonance.ClassSpellSoundFIDs
        end,
      },
      soloClassMute = {
        type = "toggle",
        name = L["Mute all classes except mine"],
        desc = L["Aggressively mute the spell sounds of every class except your own, so you stop hearing other players' rotations."],
        order = 0.2,
        width = "full",
        hidden = function()
          return not Resonance.ClassSpellSoundFIDs
        end,
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.soloClassMute
        end,
        set = function(_, v)
          db.soloClassMute = v
          Resonance.refreshSoloClassMute()
        end,
      },
      soloClassMuteNote = {
        type = "description",
        name = L["|cffffd200Heads up:|r WoW mutes sounds per file and globally, so it can't tell who is casting. You'll still hear other players of your OWN class, and because some abilities share sound files across classes, a few of your own sounds may be muted too. This is intentionally aggressive."],
        order = 0.3,
        fontSize = "medium",
        hidden = function()
          return not Resonance.ClassSpellSoundFIDs
        end,
      },
      weaponCombatHeader = {
        type = "header",
        name = L["Weapon & Combat"],
        order = 1,
      },
      muteWeaponImpacts = {
        type = "toggle",
        name = L["Mute weapon impact sounds"],
        desc = L["Mute all weapon impact and swing sounds (the melee hit thwack/clang). Applies globally regardless of weapon type. Note: replacing with classic sounds is not possible — auto-attacks do not fire detectable addon events."],
        order = 2,
        width = "full",
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.muteWeaponImpacts
        end,
        set = function(_, v)
          db.muteWeaponImpacts = v
          if v then
            if db.enabled then
              applyWeaponMutes()
            end
          else
            clearWeaponMutes()
          end
        end,
      },
      classicAutoShot = {
        type = "toggle",
        name = L["Classic auto-shot sounds (Hunter)"],
        desc = L["Replace modern bow and gun auto-shot sounds with classic ones. Automatically detects your equipped weapon type (bow/crossbow or gun)."],
        order = 3,
        width = "full",
        hidden = function()
          return playerClassToken ~= "HUNTER"
        end,
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.classicAutoShot
        end,
        set = function(_, v)
          db.classicAutoShot = v
          if v then
            if db.enabled then
              applyAutoShotMutes()
            end
          else
            clearAutoShotMutes()
          end
        end,
      },
      rangedSoundOverride = {
        type = "select",
        name = L["Auto Shot sound source"],
        desc = L["Override which classic sound plays for Auto Shot. 'Auto' uses your equipped weapon type."],
        order = 4,
        width = "full",
        hidden = function()
          return playerClassToken ~= "HUNTER"
        end,
        disabled = function()
          return not db.enabled or not db.classicAutoShot
        end,
        values = function()
          local vals = {
            auto = L["Auto (use equipped weapon)"],
            forceBow = L["Always bow"],
            forceGun = L["Always gun"],
          }
          -- Render the crossbow option when advanced is on, OR when the
          -- effective value is already forceCrossbow (e.g., from saved
          -- variables) so the dropdown can display its current state.
          if db.rangedSoundOverrideAdvanced or db.rangedSoundOverride == "forceCrossbow" then
            vals.forceCrossbow = L["Always crossbow"]
          end
          return vals
        end,
        sorting = function()
          if db.rangedSoundOverrideAdvanced or db.rangedSoundOverride == "forceCrossbow" then
            return { "auto", "forceBow", "forceGun", "forceCrossbow" }
          end
          return { "auto", "forceBow", "forceGun" }
        end,
        get = function()
          return db.rangedSoundOverride or "auto"
        end,
        set = function(_, v)
          db.rangedSoundOverride = RANGED_OVERRIDE_VALID[v] and v or "auto"
        end,
      },
      rangedSoundOverrideAdvanced = {
        type = "toggle",
        name = L["Show crossbow option (advanced)"],
        desc = L["Adds 'Always crossbow' to the Auto Shot sound source dropdown."],
        order = 5,
        width = "full",
        hidden = function()
          return playerClassToken ~= "HUNTER"
        end,
        disabled = function()
          return not db.enabled or not db.classicAutoShot
        end,
        get = function()
          return db.rangedSoundOverrideAdvanced or false
        end,
        set = function(_, v)
          db.rangedSoundOverrideAdvanced = v or false
        end,
      },
      sheatheHeader = {
        type = "header",
        name = L["Weapon Sheathe"],
        order = 6,
        hidden = function()
          return not Sheathe.checkFeatureSupported()
        end,
      },
      replaceSheathe = {
        type = "toggle",
        name = L["Play replacement sounds on sheath"],
        desc = L["Play a chosen sound on each sheathe state transition (draw / sheathe). Configure the four slots below; defaults are bundled."],
        order = 7,
        -- 1.4 + 0.6 advanced button = full row.
        width = 1.4,
        hidden = function()
          return not Sheathe.checkFeatureSupported()
        end,
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.replaceSheathe
        end,
        set = function(_, v)
          db.replaceSheathe = v or false
          if v then
            if db.enabled then
              Sheathe.enableEvents()
            end
          else
            Sheathe.disableEvents()
          end
        end,
      },
      sheatheReplaceAdvancedBtn = {
        type = "execute",
        name = L["Advanced..."],
        desc = L["Open per-material sheathe slot overrides in a separate window."],
        order = 7.5,
        width = 0.6,
        hidden = function()
          return not Sheathe.checkFeatureSupported() or not db.replaceSheathe
        end,
        disabled = function()
          return not db.enabled or not db.replaceSheathe
        end,
        func = function()
          if Resonance.openSheatheAdvancedPopup then
            Resonance.openSheatheAdvancedPopup()
          end
        end,
      },
      sheatheReplaceHelp = {
        type = "description",
        name = L["|cffffd100Tip:|r each slot below is independent. Fill the input to play your sound; tick the Mute box to silence Blizzard's original. Both are optional. Leave them blank to keep Blizzard's default."],
        order = 8,
        fontSize = "medium",
        width = "full",
        hidden = function()
          return not Sheathe.checkFeatureSupported() or not db.replaceSheathe
        end,
      },
      -- Each slot is an inline group titled with the slot's name, holding
      -- a Mute toggle (width 0.4) and the sound input (width 1.6) in a single
      -- row. Inline groups give us a stable table-like layout: AceConfig's
      -- flat flow was packing the [Mute][Input] pairs into uneven rows because
      -- the Resonance_SoundEditBox dialog control's play button isn't counted
      -- in its declared width.
      sheatheSlotMeleeDraw = (function()
        local function slotHidden()
          return not Sheathe.checkFeatureSupported() or not db.replaceSheathe
        end
        local function slotDisabled()
          return not db.enabled or not db.replaceSheathe
        end
        return {
          type = "group",
          inline = true,
          name = L["Melee draw"],
          order = 9.0,
          hidden = slotHidden,
          args = {
            mute = {
              type = "toggle",
              name = L["Mute"],
              desc = L["Mute Blizzard's original sound for this transition."],
              order = 1,
              width = 0.4,
              disabled = slotDisabled,
              get = function()
                return db.muteSheatheBySlot and db.muteSheatheBySlot.meleeDraw or false
              end,
              set = function(_, v)
                if not db.muteSheatheBySlot then
                  db.muteSheatheBySlot = {}
                end
                db.muteSheatheBySlot.meleeDraw = v or nil
                Sheathe.clearMutes()
                if db.enabled and Sheathe.hasAnyMute() then
                  Sheathe.applyMutes()
                end
              end,
            },
            input = {
              type = "input",
              name = "",
              desc = L["Sound played when drawing a melee weapon. Leave blank for the bundled default."],
              dialogControl = "Resonance_SoundEditBox",
              order = 2,
              width = 1.6,
              disabled = slotDisabled,
              get = function()
                return db.sheatheReplacements and db.sheatheReplacements.meleeDraw or ""
              end,
              set = function(_, v)
                if not db.sheatheReplacements then
                  db.sheatheReplacements = {}
                end
                db.sheatheReplacements.meleeDraw = (v ~= "" and v) or nil
              end,
            },
          },
        }
      end)(),
      sheatheSlotMeleeSheathe = (function()
        local function slotHidden()
          return not Sheathe.checkFeatureSupported() or not db.replaceSheathe
        end
        local function slotDisabled()
          return not db.enabled or not db.replaceSheathe
        end
        return {
          type = "group",
          inline = true,
          name = L["Melee sheathe"],
          order = 9.1,
          hidden = slotHidden,
          args = {
            mute = {
              type = "toggle",
              name = L["Mute"],
              desc = L["Mute Blizzard's original sound for this transition."],
              order = 1,
              width = 0.4,
              disabled = slotDisabled,
              get = function()
                return db.muteSheatheBySlot and db.muteSheatheBySlot.meleeSheathe or false
              end,
              set = function(_, v)
                if not db.muteSheatheBySlot then
                  db.muteSheatheBySlot = {}
                end
                db.muteSheatheBySlot.meleeSheathe = v or nil
                Sheathe.clearMutes()
                if db.enabled and Sheathe.hasAnyMute() then
                  Sheathe.applyMutes()
                end
              end,
            },
            input = {
              type = "input",
              name = "",
              desc = L["Sound played when sheathing a melee weapon. Leave blank for the bundled default."],
              dialogControl = "Resonance_SoundEditBox",
              order = 2,
              width = 1.6,
              disabled = slotDisabled,
              get = function()
                return db.sheatheReplacements and db.sheatheReplacements.meleeSheathe or ""
              end,
              set = function(_, v)
                if not db.sheatheReplacements then
                  db.sheatheReplacements = {}
                end
                db.sheatheReplacements.meleeSheathe = (v ~= "" and v) or nil
              end,
            },
          },
        }
      end)(),
      sheatheSlotRangedDraw = (function()
        local function slotHidden()
          return not Sheathe.checkFeatureSupported() or not db.replaceSheathe
        end
        local function slotDisabled()
          return not db.enabled or not db.replaceSheathe
        end
        return {
          type = "group",
          inline = true,
          name = L["Ranged draw"],
          order = 9.2,
          hidden = slotHidden,
          args = {
            mute = {
              type = "toggle",
              name = L["Mute"],
              desc = L["Mute Blizzard's original sound for this transition."],
              order = 1,
              width = 0.4,
              disabled = slotDisabled,
              get = function()
                return db.muteSheatheBySlot and db.muteSheatheBySlot.rangedDraw or false
              end,
              set = function(_, v)
                if not db.muteSheatheBySlot then
                  db.muteSheatheBySlot = {}
                end
                db.muteSheatheBySlot.rangedDraw = v or nil
                Sheathe.clearMutes()
                if db.enabled and Sheathe.hasAnyMute() then
                  Sheathe.applyMutes()
                end
              end,
            },
            input = {
              type = "input",
              name = "",
              desc = L["Sound played when drawing a ranged weapon. Leave blank for the bundled default."],
              dialogControl = "Resonance_SoundEditBox",
              order = 2,
              width = 1.6,
              disabled = slotDisabled,
              get = function()
                return db.sheatheReplacements and db.sheatheReplacements.rangedDraw or ""
              end,
              set = function(_, v)
                if not db.sheatheReplacements then
                  db.sheatheReplacements = {}
                end
                db.sheatheReplacements.rangedDraw = (v ~= "" and v) or nil
              end,
            },
          },
        }
      end)(),
      sheatheSlotRangedSheathe = (function()
        local function slotHidden()
          return not Sheathe.checkFeatureSupported() or not db.replaceSheathe
        end
        local function slotDisabled()
          return not db.enabled or not db.replaceSheathe
        end
        return {
          type = "group",
          inline = true,
          name = L["Ranged sheathe"],
          order = 9.3,
          hidden = slotHidden,
          args = {
            mute = {
              type = "toggle",
              name = L["Mute"],
              desc = L["Mute Blizzard's original sound for this transition."],
              order = 1,
              width = 0.4,
              disabled = slotDisabled,
              get = function()
                return db.muteSheatheBySlot and db.muteSheatheBySlot.rangedSheathe or false
              end,
              set = function(_, v)
                if not db.muteSheatheBySlot then
                  db.muteSheatheBySlot = {}
                end
                db.muteSheatheBySlot.rangedSheathe = v or nil
                Sheathe.clearMutes()
                if db.enabled and Sheathe.hasAnyMute() then
                  Sheathe.applyMutes()
                end
              end,
            },
            input = {
              type = "input",
              name = "",
              desc = L["Sound played when sheathing a ranged weapon. Leave blank for the bundled default."],
              dialogControl = "Resonance_SoundEditBox",
              order = 2,
              width = 1.6,
              disabled = slotDisabled,
              get = function()
                return db.sheatheReplacements and db.sheatheReplacements.rangedSheathe or ""
              end,
              set = function(_, v)
                if not db.sheatheReplacements then
                  db.sheatheReplacements = {}
                end
                db.sheatheReplacements.rangedSheathe = (v ~= "" and v) or nil
              end,
            },
          },
        }
      end)(),
      characterVoxHeader = {
        type = "header",
        name = L["Character Vocalizations"],
        order = 10,
      },
      muteVocalizations = {
        type = "select",
        name = L["Mute character vocalizations"],
        desc = L["Mute combat grunts, shouts, and exertion sounds. 'Mine' mutes your own race/gender, 'All races' mutes every race/gender in the game."],
        order = 11,
        disabled = function()
          return not db.enabled
        end,
        values = {
          off = L["Off"],
          mine = L["Mine"],
          all = L["All races"],
        },
        sorting = { "off", "mine", "all" },
        get = function()
          return getVoxMode()
        end,
        set = function(_, v)
          db.muteVocalizations = v
          refreshVoxMutes()
        end,
      },
      creatureVoxHeader = {
        type = "header",
        name = L["Creature Vocalizations"],
        order = 20,
      },
      creatureVoxDesc = {
        type = "description",
        fontSize = "medium",
        name = L["Significantly reduces monster attack grunts, injury, death, and aggro sounds by creature category. Coverage varies — some creatures may still be heard."],
        order = 21,
      },
    },
  }

  if Resonance.CreatureVoxCategories then
    for i, cat in ipairs(Resonance.CreatureVoxCategories) do
      opts.args["creatureVox_" .. cat] = {
        type = "toggle",
        name = L[cat] or cat,
        order = 22 + i,
        disabled = function()
          return not db.enabled
        end,
        get = function()
          return db.muteCreatureVox and db.muteCreatureVox[cat] or false
        end,
        set = function(_, v)
          if not db.muteCreatureVox then
            db.muteCreatureVox = {}
          end
          db.muteCreatureVox[cat] = v or nil
          refreshCreatureVoxMutes()
        end,
      }
    end
  end

  opts.args.professionsHeader = {
    type = "header",
    name = L["Professions"],
    order = 50,
  }
  opts.args.professionDesc = {
    type = "description",
    fontSize = "medium",
    name = L["Mute crafting, gathering, and other profession-related sounds by profession."],
    order = 51,
  }
  opts.args.sharedSoundsNote = {
    type = "description",
    fontSize = "medium",
    name = "\n|cffaaaaaa"
      .. L["Note: Some sounds are shared across multiple spells and effects. Muting a sound for one feature (e.g. a profession) will also silence it wherever else it plays in the game."]
      .. "|r",
    order = 100,
  }

  -- Curated effect mutes (e.g. another player's Demon Hunter Devour aura loop).
  -- Built dynamically from Resonance.EffectMuteData (retail-only; the data file
  -- ships in the retail TOC). Display labels are keyed by English string so the
  -- L metatable falls back to English for untranslated locales.
  if Resonance.EffectMuteData and next(Resonance.EffectMuteData) then
    local EFFECT_LABELS = {
      dh_devour = {
        name = L["Demon Hunter: Devour (Devourer)"],
        variants = {
          loop = L["Constant aura loop (the hum)"],
          oneshot = L["Aura start (one-shot)"],
          precast = L["Pre-cast wind-up loop"],
          channel = L["Channel loop"],
          devouringvoid = L["Devouring Void (impacts)"],
        },
      },
    }
    opts.args.effectMutesHeader = {
      type = "header",
      name = L["Spell Effects"],
      order = 12,
    }
    opts.args.effectMutesDesc = {
      type = "description",
      fontSize = "medium",
      name = L["Silence loud, looping ability sounds from nearby players. Muted globally, so it works no matter who casts."],
      order = 13,
    }
    local groupOrder = 14
    for _, group in ipairs(Resonance.EffectMuteData) do
      local gkey = group.key
      local labels = EFFECT_LABELS[gkey] or {}
      local groupName = labels.name or gkey
      opts.args["effect_" .. gkey .. "_label"] = {
        type = "description",
        fontSize = "medium",
        name = "|cffffd100" .. groupName .. "|r",
        order = groupOrder,
      }
      opts.args["effect_" .. gkey .. "_recommended"] = {
        type = "execute",
        name = L["Mute recommended"],
        desc = L["Enable just the recommended mutes for this effect (the worst offenders)."],
        order = groupOrder + 0.1,
        width = 0.9,
        disabled = function()
          return not db.enabled
        end,
        func = function()
          db.muteEffects = db.muteEffects or {}
          for _, v in ipairs(group.variants) do
            if v.recommended then
              db.muteEffects[gkey .. "." .. v.key] = true
            end
          end
          Resonance.refreshEffectMutes()
        end,
      }
      opts.args["effect_" .. gkey .. "_all"] = {
        type = "execute",
        name = L["Mute all"],
        desc = L["Enable every mute for this effect."],
        order = groupOrder + 0.2,
        width = 0.7,
        disabled = function()
          return not db.enabled
        end,
        func = function()
          db.muteEffects = db.muteEffects or {}
          for _, v in ipairs(group.variants) do
            db.muteEffects[gkey .. "." .. v.key] = true
          end
          Resonance.refreshEffectMutes()
        end,
      }
      opts.args["effect_" .. gkey .. "_clear"] = {
        type = "execute",
        name = L["Clear"],
        desc = L["Disable every mute for this effect."],
        order = groupOrder + 0.3,
        width = 0.7,
        disabled = function()
          return not db.enabled
        end,
        func = function()
          if db.muteEffects then
            for _, v in ipairs(group.variants) do
              db.muteEffects[gkey .. "." .. v.key] = nil
            end
          end
          Resonance.refreshEffectMutes()
        end,
      }
      local variantLabels = labels.variants or {}
      for vi, variant in ipairs(group.variants) do
        local fullKey = gkey .. "." .. variant.key
        local vname = variantLabels[variant.key] or variant.key
        if variant.recommended then
          vname = vname .. " " .. L["(recommended)"]
        end
        opts.args["effect_" .. gkey .. "_" .. variant.key] = {
          type = "toggle",
          name = vname,
          order = groupOrder + 0.4 + vi / 100,
          width = "full",
          disabled = function()
            return not db.enabled
          end,
          get = function()
            return db.muteEffects and db.muteEffects[fullKey] or false
          end,
          set = function(_, val)
            db.muteEffects = db.muteEffects or {}
            db.muteEffects[fullKey] = val or nil
            Resonance.refreshEffectMutes()
          end,
        }
      end
      groupOrder = groupOrder + 1
    end
  end

  -- Professions absent on this flavor (data-driven via Resonance.Flavor).
  local UNAVAILABLE_PROFESSIONS = Resonance.Flavor.unavailableProfessions or {}

  if Resonance.ProfessionCategories then
    for i, prof in ipairs(Resonance.ProfessionCategories) do
      if not UNAVAILABLE_PROFESSIONS[prof] then
        opts.args["profession_" .. prof] = {
          type = "toggle",
          name = L[prof] or prof,
          order = 52 + i,
          disabled = function()
            return not db.enabled
          end,
          get = function()
            return db.muteProfessionSounds and db.muteProfessionSounds[prof] or false
          end,
          set = function(_, v)
            if not db.muteProfessionSounds then
              db.muteProfessionSounds = {}
            end
            db.muteProfessionSounds[prof] = v or nil
            refreshProfessionMutes()
          end,
        }
      end
    end
  end

  return opts
end

-- AceConfig table for the per-material sheathe advanced popup. Renders a
-- 5×2 grid of sound pickers; cells default to empty (fallback to simple-mode
-- slot at runtime). `sheatheReplacementsAdvanced` is recomputed from the
-- per-cell content here — it's UI-only convenience state.
--
-- Wrapped in a `do ... end` so the two getter functions (and their
-- locals) don't permanently consume slots in Core.lua's main-chunk
-- 200-local-variable budget. Exposed via `Resonance.aceConfigTables`
-- since the registration code below needs to reference them by name.
Resonance.aceConfigTables = Resonance.aceConfigTables or {}
do
  local function getSheatheAdvancedOptions()
    local opts = {
      type = "group",
      name = L["Sheathe — Advanced"],
      args = {
        desc = {
          type = "description",
          name = L["Per-material sheathe / draw overrides. Empty cells fall back to the simple-mode slots; tick Mute to silence Blizzard's original for that cell."],
          order = 1,
          fontSize = "medium",
          width = "full",
        },
        resetBtn = {
          type = "execute",
          name = L["Reset advanced"],
          desc = L["Clear all per-material overrides and per-cell mutes. Simple-mode slots are unchanged."],
          order = 2,
          confirm = function()
            return L["Clear all per-material sheathe overrides?"]
          end,
          func = function()
            db.sheatheReplacementsByMaterial = {}
            db.sheatheReplacementsAdvanced = false
            db.muteSheatheByMaterial = {}
            Sheathe.clearMutes()
            if db.enabled and Sheathe.hasAnyMute() then
              Sheathe.applyMutes()
            end
          end,
        },
      },
    }
    local materialLabels = {
      metal = L["Metal (swords, axes, maces, daggers)"],
      nonmetal = L["Non-metal (staves, wands, bows, crossbows)"],
      liquid = L["Liquid / misc (potions, off-hands)"],
      plate = L["Plate (shields, plate off-hands)"],
      misc = L["Miscellaneous"],
    }
    -- Each [material x state] cell is an inline group titled with the state
    -- ("Draw" / "Sheathe"), holding a Mute toggle (width 0.4) and the sound
    -- input (width 1.6) on a single row. This gives a stable table-like
    -- layout under each material header instead of AceConfig's flat flow,
    -- which packs Mute/Input pairs unevenly because the Resonance_SoundEditBox
    -- play button isn't counted in its declared width.
    local order = 10
    for _, material in ipairs({ "metal", "nonmetal", "liquid", "plate", "misc" }) do
      opts.args[material .. "Header"] = {
        type = "header",
        name = materialLabels[material] or material,
        order = order,
      }
      order = order + 1
      for _, state in ipairs({ "draw", "sheathe" }) do
        local label = state == "draw" and L["Draw"] or L["Sheathe"]
        local mat, st = material, state
        opts.args[mat .. "_" .. st .. "_slot"] = {
          type = "group",
          inline = true,
          name = label,
          order = order,
          args = {
            mute = {
              type = "toggle",
              name = L["Mute"],
              desc = L["Mute Blizzard's original sound for this transition."],
              order = 1,
              width = 0.4,
              get = function()
                local m = db.muteSheatheByMaterial and db.muteSheatheByMaterial[mat]
                return (m and m[st]) or false
              end,
              set = function(_, v)
                if not db.muteSheatheByMaterial then
                  db.muteSheatheByMaterial = {}
                end
                if not db.muteSheatheByMaterial[mat] then
                  db.muteSheatheByMaterial[mat] = {}
                end
                db.muteSheatheByMaterial[mat][st] = v or nil
                Sheathe.clearMutes()
                if db.enabled and Sheathe.hasAnyMute() then
                  Sheathe.applyMutes()
                end
              end,
            },
            input = {
              type = "input",
              name = "",
              dialogControl = "Resonance_SoundEditBox",
              order = 2,
              width = 1.6,
              get = function()
                local m = db.sheatheReplacementsByMaterial and db.sheatheReplacementsByMaterial[mat]
                return (m and m[st]) or ""
              end,
              set = function(_, v)
                if not db.sheatheReplacementsByMaterial then
                  db.sheatheReplacementsByMaterial = {}
                end
                if not db.sheatheReplacementsByMaterial[mat] then
                  db.sheatheReplacementsByMaterial[mat] = {}
                end
                db.sheatheReplacementsByMaterial[mat][st] = (v ~= "" and v) or nil
                -- Recompute the advanced flag from per-cell content (source of truth).
                local anySet = false
                for _, byState in pairs(db.sheatheReplacementsByMaterial) do
                  for _, val in pairs(byState) do
                    if val and val ~= "" then
                      anySet = true
                      break
                    end
                  end
                  if anySet then
                    break
                  end
                end
                db.sheatheReplacementsAdvanced = anySet
              end,
            },
          },
        }
        order = order + 1
      end
    end
    return opts
  end

  Resonance.aceConfigTables.sheatheAdvanced = getSheatheAdvancedOptions
end

---------------------------------------------------------------------------
-- Minimap button (LibDBIcon)
---------------------------------------------------------------------------
local LDB = LibStub("LibDataBroker-1.1")
local LDBIcon = LibStub("LibDBIcon-1.0")

-- Retail-only FileDataID; Classic flavors use the bundled TGA path.
local LDB_ICON = Resonance.IS_RETAIL and 6383545 or "Interface\\AddOns\\Resonance\\assets\\icon.tga"
local ldbObj = LDB:NewDataObject("Resonance", {
  type = "launcher",
  icon = LDB_ICON,
  OnClick = function(_, button)
    if button == "RightButton" then
      if db.enabled then
        Resonance.disableEverything()
      else
        Resonance.enableEverything()
      end
      msg(db.enabled and L["Enabled."] or L["Disabled."])
    else
      if Resonance.openOptions then
        Resonance.openOptions()
      end
    end
  end,
  OnTooltipShow = function(tt)
    tt:AddLine("Resonance |cff888888" .. ADDON_VERSION .. "|r")
    tt:AddLine(L["Left-click: Open options"], 1, 1, 1)
    tt:AddLine(L["Right-click: Toggle on/off"], 1, 1, 1)
    tt:AddLine(L["Drag: Move button"], 0.7, 0.7, 0.7)
  end,
})

Resonance.toggleMinimapButton = function(show)
  db.minimap.hide = not show
  if show then
    LDBIcon:Show("Resonance")
  else
    LDBIcon:Hide("Resonance")
  end
end

---------------------------------------------------------------------------
-- Migration from CastSoundsDB
---------------------------------------------------------------------------
local function migrateFromCastSounds()
  if not CastSoundsDB then
    return
  end

  -- Build AceDB-compatible structure
  if not ResonanceDB then
    ResonanceDB = {}
  end
  if not ResonanceDB.profiles then
    ResonanceDB.profiles = {}
  end
  if not ResonanceDB.profiles["Default"] then
    ResonanceDB.profiles["Default"] = {}
  end

  local dest = ResonanceDB.profiles["Default"]
  local src = CastSoundsDB

  -- Copy known keys
  local simpleKeys = { "enabled", "debug", "soundChannel" }
  for _, k in ipairs(simpleKeys) do
    if src[k] ~= nil then
      dest[k] = src[k]
    end
  end

  local tableKeys = {
    "mute_file_data_ids",
    "local_file_overrides_by_spell_name",
    "spell_to_play_file_data_id",
    "spell_config",
    "minimap",
  }
  for _, k in ipairs(tableKeys) do
    if src[k] then
      dest[k] = src[k]
    end
  end

  -- Don't copy auto_muted_fids — it's runtime-only now

  msg(L["Migrated settings from CastSoundsDB. You can disable the old CastSounds addon."])
  CastSoundsDB = nil
end

---------------------------------------------------------------------------
-- AceAddon lifecycle
---------------------------------------------------------------------------
function Resonance:OnInitialize()
  -- Migrate old saved variables before AceDB:New
  migrateFromCastSounds()

  -- Create AceDB
  self.db = LibStub("AceDB-3.0"):New("ResonanceDB", defaults, true)
  db = self.db.profile

  -- Migrate flat alert keys to unified alerts table (v2.1)
  if db.interruptAlert ~= nil or db.interruptAlertSound ~= nil then
    if not db.alerts then
      db.alerts = {}
    end
    db.alerts.interrupt = {
      enabled = db.interruptAlert or false,
      sound = db.interruptAlertSound,
      duration = db.interruptAlertDuration,
      loop = false,
    }
    db.interruptAlert = nil
    db.interruptAlertSound = nil
    db.interruptAlertDuration = nil
  end
  if db.lossOfControlAlert ~= nil or db.lossOfControlAlertSound ~= nil then
    if not db.alerts then
      db.alerts = {}
    end
    db.alerts.lossOfControl = {
      enabled = db.lossOfControlAlert or false,
      sound = db.lossOfControlAlertSound,
      duration = db.lossOfControlAlertDuration,
      loop = false,
    }
    db.lossOfControlAlert = nil
    db.lossOfControlAlertSound = nil
    db.lossOfControlAlertDuration = nil
  end

  -- Sanity-check rangedSoundOverride. Coerce invalid/nil values to "auto".
  -- Logged once via debug channel, not a hard error — savedvariable
  -- hand-edits or a future-version downgrade could leave a stale value.
  if db.rangedSoundOverride ~= nil and not RANGED_OVERRIDE_VALID[db.rangedSoundOverride] then
    if db.debug then
      msg(L["Invalid rangedSoundOverride value, coerced to 'auto'"])
    end
    db.rangedSoundOverride = "auto"
  end

  -- One-time migration: strip profession sound FIDs from creature vox
  -- snapshots.  Previous versions included mining/crafting sounds in the
  -- Humanoid creature vox data; these stale snapshot entries would keep
  -- profession sounds muted even after the data fix.
  if not db._creatureVoxProfFixApplied then
    local profFIDs = {
      [565539] = 1,
      [565568] = 1,
      [565570] = 1,
      [565577] = 1,
      [565642] = 1,
      [565703] = 1,
      [565850] = 1,
      [565877] = 1,
      [565908] = 1,
      [565944] = 1,
      [566336] = 1,
      [566365] = 1,
      [566369] = 1,
      [566670] = 1,
      [566721] = 1,
      [566989] = 1,
      [566998] = 1,
      [567063] = 1,
      [567081] = 1,
      [567101] = 1,
      [567585] = 1,
      [567587] = 1,
      [567590] = 1,
      [567592] = 1,
      [567593] = 1,
      [567594] = 1,
      [567597] = 1,
      [567600] = 1,
      [567603] = 1,
      [567605] = 1,
      [569559] = 1,
      [569792] = 1,
      [569794] = 1,
      [569799] = 1,
      [569801] = 1,
      [569811] = 1,
      [569814] = 1,
      [569821] = 1,
      [569822] = 1,
      [937258] = 1,
      [937260] = 1,
      [937262] = 1,
      [937264] = 1,
      [937266] = 1,
      [937268] = 1,
      [937270] = 1,
      [937272] = 1,
      [937274] = 1,
      [937276] = 1,
      [971155] = 1,
      [971157] = 1,
      [971159] = 1,
      [1717093] = 1,
      [1717094] = 1,
      [1717095] = 1,
      [1717096] = 1,
      [1717097] = 1,
      [2066564] = 1,
      [2066565] = 1,
      [2066566] = 1,
      [2066568] = 1,
      [2066569] = 1,
      [2066570] = 1,
    }
    local snapshot = db._lastCreatureMutedFIDs
    if snapshot then
      for fid in pairs(profFIDs) do
        if snapshot[fid] then
          snapshot[fid] = nil
          for _ = 1, MAX_MUTE_DEPTH do
            UnmuteSoundFile(fid)
          end
        end
      end
    end
    db._creatureVoxProfFixApplied = true
  end

  -- Profile change callbacks
  local function onProfileChanged()
    cancelActiveLoop()
    clearAutoShotMutes()
    clearNPCMutes()
    clearMountMutes()
    clearAmbientMutes()
    Resonance.clearEffectMutes()
    clearProfessionMutes()
    clearCreatureVoxMutes()
    clearWeaponMutes()
    clearVoxMutes()
    clearMutes()
    Sheathe.clearMutes()
    Sheathe.disableEvents() -- bumps generation token; in-flight callbacks no-op
    clearAlertEvents()
    clearAlertState()
    db = self.db.profile
    -- Ensure tables exist (AceDB "Copy From" may not create default tables)
    if not db.mute_file_data_ids then
      db.mute_file_data_ids = {}
    end
    if not db.spell_config then
      db.spell_config = {}
    end
    if not db.preset_spells then
      db.preset_spells = {}
    end
    if not db.spell_to_play_file_data_id then
      db.spell_to_play_file_data_id = {}
    end
    if not db.alerts then
      db.alerts = {}
    end
    if not db.customSounds then
      db.customSounds = {}
    end
    if not db.removed_template_spells then
      db.removed_template_spells = {}
    end
    syncCustomSoundsToLSM()
    wipe(autoMutedFIDs)
    wipe(sequenceState)
    if Resonance.SpellMuteData then
      rebuildAutoMutes()
    else
      reapplyAutoMutesFromSnapshot()
    end
    if db.enabled then
      applyMutes()
    end
    if getVoxMode() ~= "off" then
      applyVoxMutes()
    end
    if db.muteWeaponImpacts then
      applyWeaponMutes()
    end
    if hasAnyCreatureVoxEnabled() then
      applyCreatureVoxMutes()
    end
    if hasAnyProfessionMuteEnabled() then
      applyProfessionMutes()
    end
    if hasAnyNPCMuteEnabled() then
      applyNPCMutes()
    end
    if hasAnyMountMuteEnabled() then
      applyMountMutes()
    end
    if hasAnyAmbientMuteEnabled() then
      applyAmbientMutes()
    end
    if Resonance.hasAnyEffectMuteEnabled() then
      Resonance.applyEffectMutes()
    end
    if db.classicAutoShot then
      applyAutoShotMutes()
    end
    if Sheathe.hasAnyMute() then
      Sheathe.applyMutes()
    end
    if db.replaceSheathe then
      Sheathe.enableEvents()
    end
    refreshAlertEvents()
    if Resonance.refreshVisibleOptionPanels then
      Resonance.refreshVisibleOptionPanels()
    end
  end
  self.db.RegisterCallback(self, "OnProfileChanged", onProfileChanged)
  self.db.RegisterCallback(self, "OnProfileCopied", onProfileChanged)
  self.db.RegisterCallback(self, "OnProfileReset", onProfileChanged)

  -- Register AceConfig tables (one per subcategory panel)
  local AceConfig = LibStub("AceConfig-3.0")
  AceConfig:RegisterOptionsTable("Resonance_General", getGeneralOptions)
  AceConfig:RegisterOptionsTable("Resonance_Muting", getMutingOptions)
  AceConfig:RegisterOptionsTable("Resonance_SheatheAdvanced", Resonance.aceConfigTables.sheatheAdvanced)
  AceConfig:RegisterOptionsTable("Resonance_Profiles", LibStub("AceDBOptions-3.0"):GetOptionsTable(self.db))

  -- Slash commands
  self:RegisterChatCommand("res", "ChatCommand")
  self:RegisterChatCommand("resonance", "ChatCommand")

  -- Minimap button
  LDBIcon:Register("Resonance", ldbObj, db.minimap)

  -- Options panel (deferred to Options.lua)
  if self.SetupOptions then
    self:SetupOptions()
  end
end

function Resonance:OnEnable()
  self:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
  self:RegisterEvent("UNIT_SPELLCAST_START")
  self:RegisterEvent("UNIT_SPELLCAST_STOP")
  self:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START")
  self:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
  self:RegisterEvent("UNIT_MODEL_CHANGED")
  -- CLEU drives real-time impact-sound timing where the API allows it.
  -- Retail 12.x restricts addon access to combat events, so we keep the
  -- fixed-delay scheduler there.
  if Resonance.Flavor.registersCLEUForImpacts then
    self:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
  end
  -- Transient mute on cast send: the earliest client-side hook before the
  -- engine queues the cast audio.  See applyTransientMutesForCast.
  if Resonance.Flavor.registersSpellCastSent then
    self:RegisterEvent("UNIT_SPELLCAST_SENT")
    Resonance.drainStaleTransientMutes()
  end
  refreshAlertEvents()
  refreshPresetsFromTemplates()
  syncCustomSoundsToLSM()
  -- Use snapshot at login (fast, no SpellMuteData needed).
  -- Full rebuild deferred to when Resonance_Data loads.
  -- Classic: skip the snapshot fast-path because reapplyAutoMutesFromSnapshot
  -- would persistently re-mute prior-session FIDs that should now be transient.
  -- Force the data addon to load so rebuildAutoMutes drains the stale snapshot.
  local hasSnapshot = db._lastAutoMutedFIDs and next(db._lastAutoMutedFIDs) ~= nil
  if Resonance.SpellMuteData then
    rebuildAutoMutes()
  elseif hasSnapshot and Resonance.Flavor.muteMode == "persistent" then
    reapplyAutoMutesFromSnapshot()
  else
    -- No snapshot or empty snapshot (first install, or lost due to prior error).
    -- Eagerly load Resonance_Data so auto-mutes can be built immediately.
    loadDataAddon()
  end
  -- Warm up the audio system for each configured spell's FIDs on Classic.
  -- Without this, the very first cast of a configured spell in a session
  -- leaks through — WoW defers MuteSoundFile for FIDs not yet known to the
  -- audio engine, and UNIT_SPELLCAST_SENT fires too late for cold FIDs.
  -- Cycling MuteSoundFile/UnmuteSoundFile registers the FID without leaving
  -- it persistently muted, so the next MuteSoundFile (at cast time) sticks.
  if Resonance.Flavor.muteMode == "transient" and Resonance.SpellMuteData then
    Resonance.warmUpTransientFIDs()
  end
  if Resonance.Flavor.supportsPrecastMute then
    if db.enabled and db.mutePrecastSounds and Resonance.SpellEarlyFIDs then
      Resonance.applyPrecastMutes()
    elseif db._lastPrecastFIDs and next(db._lastPrecastFIDs) then
      -- Toggle was on previously, off now (or addon disabled).  Drain the
      -- stale snapshot so the engine refcount returns to zero across reloads.
      Resonance.clearPrecastMutes()
    end
  end
  if db.enabled then
    applyMutes()
  end
  if getVoxMode() ~= "off" then
    applyVoxMutes()
  end
  if db.muteWeaponImpacts then
    applyWeaponMutes()
  end
  if db.classicAutoShot then
    applyAutoShotMutes()
  end
  if Sheathe.hasAnyMute() then
    Sheathe.applyMutes()
  end
  if db.replaceSheathe then
    Sheathe.enableEvents()
  end
  Sheathe.installHook()
  if hasAnyProfessionMuteEnabled() then
    applyProfessionMutes()
  end
  if hasAnyNPCMuteEnabled() then
    applyNPCMutes()
  end
  if hasAnyMountMuteEnabled() then
    applyMountMutes()
  end
  Resonance.refreshSoloClassMute()
  if hasAnyAmbientMuteEnabled() then
    applyAmbientMutes()
  end
  if Resonance.hasAnyEffectMuteEnabled() then
    Resonance.applyEffectMutes()
  end
  if hasAnyCreatureVoxEnabled() then
    applyCreatureVoxMutes()
  elseif Resonance.CreatureVoxExcludedFIDs then
    -- Creature vox is off but stale MuteSoundFile state from previous
    -- sessions (before FIDs were excluded) still needs clearing.
    for s in Resonance.CreatureVoxExcludedFIDs:gmatch("%d+") do
      local fid = tonumber(s)
      if fid and not isMutedElsewhere(fid) then
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
    end
  end

  -- Clear stale snapshot mutes from previous sessions for disabled features.
  -- If a feature was disabled between sessions, its snapshot FIDs are still
  -- muted in WoW's internal state. Drain them now.
  local snapshotKeys = {
    { flag = "muteWeaponImpacts", key = "_lastWeaponMutedFIDs" },
    {
      flag = nil,
      flagFn = function()
        return getVoxMode() ~= "off"
      end,
      key = "_lastVoxMutedFIDs",
    },
    { flag = nil, flagFn = hasAnyCreatureVoxEnabled, key = "_lastCreatureMutedFIDs" },
    { flag = nil, flagFn = hasAnyProfessionMuteEnabled, key = "_lastProfessionMutedFIDs" },
    { flag = nil, flagFn = hasAnyNPCMuteEnabled, key = "_lastNPCMutedFIDs" },
    { flag = nil, flagFn = hasAnyAmbientMuteEnabled, key = "_lastAmbientMutedFIDs" },
  }
  for _, entry in ipairs(snapshotKeys) do
    local active = entry.flag and db[entry.flag] or (entry.flagFn and entry.flagFn())
    if not active and db[entry.key] then
      for fid in pairs(db[entry.key]) do
        for _ = 1, MAX_MUTE_DEPTH do
          UnmuteSoundFile(fid)
        end
      end
      wipe(db[entry.key])
    end
  end

  msg(L["Loaded. Type /res or go to Esc > Options > Addons > Resonance."])
end

---------------------------------------------------------------------------
-- Event handler
---------------------------------------------------------------------------

local function resolveSpellConfig(spellID)
  local spell_config = db.spell_config
  local cfg = spell_config and spell_config[spellID]
  if not cfg then
    local name = _GetSpellName(spellID)
    if name and name ~= "" then
      cfg = getSpellNameIndex()[name:lower()]
    end
  end
  return cfg
end

function Resonance:UNIT_MODEL_CHANGED(_, unit)
  if unit ~= "player" then
    return
  end
  if getVoxMode() ~= "mine" then
    return
  end
  -- UNIT_MODEL_CHANGED fires very frequently (gear, mounts, effects).
  -- Only refresh vox mutes if the player's race/gender actually changed
  -- (barbershop, Orb of Deception, etc.), not on every model update.
  local _, raceKey = UnitRace("player")
  local gender = UnitSex("player")
  local key = (raceKey or "") .. (gender or "")
  if key == lastVoxRaceGender then
    return
  end
  lastVoxRaceGender = key
  refreshVoxMutes()
end

function Resonance:LOSS_OF_CONTROL_ADDED()
  local alerts = db.alerts
  if not alerts then
    return
  end
  local schoolInterrupt = false
  local otherLoC = nil
  for i = 1, C_LossOfControl.GetActiveLossOfControlDataCount() do
    local data = C_LossOfControl.GetActiveLossOfControlData(i)
    if data then
      if data.locType == "SCHOOL_INTERRUPT" then
        schoolInterrupt = true
      elseif not otherLoC then
        otherLoC = data.locType
      end
    end
  end
  -- Interrupt alert: correlate SCHOOL_INTERRUPT with UNIT_SPELLCAST_INTERRUPTED
  local intCfg = alerts.interrupt
  if schoolInterrupt and intCfg and intCfg.enabled then
    if pendingInterrupt and GetTime() - pendingInterrupt.time <= 1 then
      local spellID = pendingInterrupt.spellID
      pendingInterrupt = nil
      local name = _GetSpellName(spellID) or ""
      playAlertSound("interrupt", ("Interrupted: %s (spellID %d)"):format(name ~= "" and name or "<?>", spellID))
    else
      lastLoCInterrupt = GetTime()
    end
  end
  -- Loss of control alert: any non-SCHOOL_INTERRUPT LoC
  local locCfg = alerts.lossOfControl
  if otherLoC and locCfg and locCfg.enabled then
    playAlertSound("lossOfControl", ("Loss of control: %s"):format(otherLoC))
  end
end

function Resonance:UNIT_SPELLCAST_INTERRUPTED(_, unit, _, spellID)
  if unit ~= "player" then
    return
  end
  local alerts = db.alerts
  if not alerts then
    return
  end
  local cfg = alerts.interrupt
  if not db.enabled or not cfg or not cfg.enabled or not spellID then
    return
  end
  -- If LOSS_OF_CONTROL_ADDED already confirmed a school interrupt, play now
  if lastLoCInterrupt and GetTime() - lastLoCInterrupt <= 1 then
    lastLoCInterrupt = nil
    local name = _GetSpellName(spellID) or ""
    playAlertSound("interrupt", ("Interrupted: %s (spellID %d)"):format(name ~= "" and name or "<?>", spellID))
  else
    -- Cache for LOSS_OF_CONTROL_ADDED to pick up (it may fire after)
    pendingInterrupt = { time = GetTime(), spellID = spellID }
  end
end

function Resonance:PLAYER_DEAD()
  local alerts = db.alerts
  if not alerts then
    return
  end
  local cfg = alerts.death
  if not db.enabled or not cfg or not cfg.enabled then
    return
  end
  playAlertSound("death", "Player died")
end

-- UNIT_SPELLCAST_SENT fires the instant the client sends the cast packet —
-- earlier than UNIT_SPELLCAST_START.  Used on Classic to mute the spell's
-- default FIDs before the engine plays them.  Retail uses persistent mutes
-- (FIDs are unique per spell) so this is Classic-only.
function Resonance:UNIT_SPELLCAST_SENT(_, unit, _, _, spellID)
  if unit ~= "player" or not spellID then
    return
  end
  Resonance.applyTransientMutesForCast(spellID)
end

function Resonance:UNIT_SPELLCAST_START(_, unit, _, spellID)
  if unit ~= "player" then
    return
  end
  if not db.enabled or not spellID then
    return
  end

  local cfg = resolveSpellConfig(spellID)
  if not cfg then
    return
  end
  local trigger = cfg.trigger
  if trigger ~= "precast" and trigger ~= "precast_and_cast" then
    return
  end

  -- Debounce precast the same way UNIT_SPELLCAST_SUCCEEDED debounces cast
  -- sounds (see that handler): rapid recasts of the same spell, and multi-hit
  -- sub-spell IDs that resolve to the same config, would otherwise spam the
  -- precast sound. Sequence sounds bypass the debounce so each hit advances
  -- the index, matching the cast-complete behaviour.
  local now = GetTime()
  local precastByID = self._lastPrecastSoundTime
  local precastByCfg = self._lastPrecastSoundCfgTime
  local isSequence = type(cfg.sound) == "table" and cfg.sound.sequence and #cfg.sound > 0
  if not isSequence then
    local lastPlayed = precastByID[spellID]
    if lastPlayed and (now - lastPlayed) < CAST_SOUND_DEBOUNCE then
      if db.debug then
        msg(("  Debounced precast: spellID %d (%.2fs ago)"):format(spellID, now - lastPlayed))
      end
      return
    end
    local window = cfg.multiHitWindow or db.multiHitWindow
    if window and window > 0 then
      local lastCfgPlayed = precastByCfg[cfg]
      if lastCfgPlayed and (now - lastCfgPlayed) < window then
        if db.debug then
          msg(
            ("  Debounced precast (multi-hit): spellID %d, same config played %.2fs ago (window %.1fs)"):format(
              spellID,
              now - lastCfgPlayed,
              window
            )
          )
        end
        return
      end
    end
  end

  local dbg = db.debug
  local spellName
  if dbg then
    spellName = _GetSpellName(spellID) or ""
    msg(L["Precast: %s (spellID %d)"]:format(spellName ~= "" and spellName or "<?>", spellID))
  end

  if playResolvedSound(spellID, spellName, cfg, dbg, "precast") then
    precastByID[spellID] = now
    precastByCfg[cfg] = now
  end
end

-- UNIT_SPELLCAST_STOP fires once the cast bar leaves the screen — covers
-- successful completion, player cancel, enemy interrupt, and failure (e.g.
-- movement breaking the cast). Stopping precast sound handles here means
-- the sound ends exactly when the cast bar does, regardless of haste / gear
-- / buffs that shortened the cast — no need to hand-tune precastDuration.
function Resonance:UNIT_SPELLCAST_STOP(_, unit)
  if unit ~= "player" then
    return
  end
  local n = #activePrecastHandles
  if n == 0 and not activeLoopIsPrecast then
    return
  end
  for i = 1, n do
    StopSound(activePrecastHandles[i])
    activePrecastHandles[i] = nil
  end
  if activeLoopIsPrecast then
    cancelActiveLoop()
    activeLoopIsPrecast = false
  end
end

function Resonance:UNIT_SPELLCAST_CHANNEL_START(_, unit, _, spellID)
  if unit ~= "player" then
    return
  end
  if not db.enabled or not spellID then
    return
  end

  -- Refresh transient mute on Classic for long channels (>5s like Drain Soul,
  -- Tranquility): UNIT_SPELLCAST_SENT only fires once at cast start, so the
  -- 5s timer would expire mid-channel without this re-trigger.
  Resonance.applyTransientMutesForCast(spellID)

  local cfg = resolveSpellConfig(spellID)
  activeChannelSpellID = spellID
  activeChannelCfg = cfg -- may be nil; used to suppress name-matched sub-spells

  if not cfg and not IsPlayerSpell(spellID) then
    return
  end

  local dbg = db.debug
  local spellName
  if dbg then
    spellName = _GetSpellName(spellID) or ""
    msg(L["Channel: %s (spellID %d)"]:format(spellName ~= "" and spellName or "<?>", spellID))
  end

  playResolvedSound(spellID, spellName, cfg, dbg, "cast")
end

function Resonance:UNIT_SPELLCAST_CHANNEL_STOP(_, unit)
  if unit ~= "player" then
    return
  end
  activeChannelSpellID = nil
  activeChannelCfg = nil
end

function Resonance:UNIT_SPELLCAST_SUCCEEDED(_, unit, _, spellID)
  if unit ~= "player" then
    return
  end
  if not db.enabled or not spellID then
    return
  end

  -- Classic auto-shot: intercept spell 75 before the normal path
  if spellID == AUTO_SHOT_SPELL_ID and db.classicAutoShot and playerClassToken == "HUNTER" then
    local override = db.rangedSoundOverride or "auto"
    local pool, sourceLabel
    if override == "forceBow" then
      pool, sourceLabel = CLASSIC_BOW_FIDS, "bow"
    elseif override == "forceGun" then
      pool, sourceLabel = CLASSIC_GUN_FIDS, "gun"
    elseif override == "forceCrossbow" then
      pool = CLASSIC_CROSSBOW_FIDS
      sourceLabel = "crossbow"
      if not pool or #pool == 0 then
        pool, sourceLabel = CLASSIC_BOW_FIDS, "bow"
        if db.debug then
          msg(L["Auto Shot: forceCrossbow pool empty, falling back to bow"])
        end
      end
    else
      local weaponType = getEquippedRangedType()
      pool = weaponType == "gun" and CLASSIC_GUN_FIDS or CLASSIC_BOW_FIDS
      sourceLabel = weaponType or "bow"
    end
    local pick = pool[math.random(1, #pool)]
    if db.debug then
      msg(L["Auto Shot: playing classic %s sound %d"]:format(sourceLabel, pick))
    end
    PlaySoundFile(pick, db.soundChannel or "Master")
    return
  end

  -- Resolve config with name-based fallback for spell variants,
  -- then gate on IsPlayerSpell for unconfigured spells.
  local cfg = resolveSpellConfig(spellID)
  if not cfg and not IsPlayerSpell(spellID) then
    return
  end

  -- Suppress channel ticks: channeled spells (e.g. Tranquility) fire
  -- UNIT_SPELLCAST_SUCCEEDED per tick.  The sound was already played by
  -- UNIT_SPELLCAST_CHANNEL_START.  Check both exact spell ID and resolved
  -- config identity — tick sub-spells often use a different ID but match
  -- the same spell_config via name fallback (e.g., Tranquility tick 157982
  -- matching configured Tranquility 740 by name).
  if activeChannelSpellID then
    if spellID == activeChannelSpellID or (activeChannelCfg and cfg == activeChannelCfg) then
      if db.debug then
        msg(L["  Suppressed channel tick: spellID %d"]:format(spellID))
      end
      return
    end
  end

  -- Impact sounds: scheduled once per cast, BEFORE the debounce checks
  -- below. The debounces are intended to deduplicate multi-hit cast sounds
  -- (e.g. Rampage firing the swing sound 5 times per cast) but must not
  -- suppress impacts on legitimate rapid recasts like spam-Wrath under
  -- heavy haste, where the GCD can be shorter than the multi-hit window.
  if cfg then
    scheduleImpactsForCast(cfg, db.debug, spellID)
  end

  -- Debounce: brief guard against rapid duplicate events for the same spell.
  -- The 0.5 s window is well below the minimum GCD (0.75 s) so legitimate
  -- rapid recasts of the same spell are unaffected.
  -- Check both by exact spellID (same spell fired twice) and by resolved cfg
  -- identity (multi-hit spells like Rampage use different sub-spell IDs that
  -- all resolve to the same config via name fallback).
  -- Sequence sounds bypass both debounce layers so each hit advances the index.
  local now = GetTime()
  local isSequence = cfg and type(cfg.sound) == "table" and cfg.sound.sequence and #cfg.sound > 0
  if not isSequence then
    local lastPlayed = lastCastSoundTime[spellID]
    if lastPlayed and (now - lastPlayed) < CAST_SOUND_DEBOUNCE then
      if db.debug then
        msg(L["  Debounced: spellID %d (%.2fs ago)"]:format(spellID, now - lastPlayed))
      end
      return
    end
  end
  if cfg then
    local window = cfg.multiHitWindow or db.multiHitWindow
    if window and window > 0 then
      local lastCfgPlayed = lastCastSoundCfgTime[cfg]
      if lastCfgPlayed and (now - lastCfgPlayed) < window then
        if isSequence then
          -- Sequence: allow through, playSoundCollection will advance the index
          if db.debug then
            msg(
              ("  Sequence hit: spellID %d, same config %.2fs ago (window %.1fs)"):format(
                spellID,
                now - lastCfgPlayed,
                window
              )
            )
          end
        else
          if db.debug then
            msg(
              ("  Debounced (multi-hit): spellID %d, same config played %.2fs ago (window %.1fs)"):format(
                spellID,
                now - lastCfgPlayed,
                window
              )
            )
          end
          return
        end
      end
    end
  end

  -- Defer GetSpellName: for the common case (configured sound), the name
  -- is never used, so skip the C API call entirely unless debug is on or
  -- we fall through to the vanilla-file / FID-mapping fallback paths.
  local dbg = db.debug
  local spellName
  if dbg then
    spellName = _GetSpellName(spellID) or ""
    msg(L["Cast: %s (spellID %d)"]:format(spellName ~= "" and spellName or "<?>", spellID))
  end

  if playResolvedSound(spellID, spellName, cfg, dbg, "cast") then
    lastCastSoundTime[spellID] = now
    if cfg then
      lastCastSoundCfgTime[cfg] = now
    end
  end
end

-- Pre-existing slash command handlers, extracted to file scope for the
-- same upvalue-cap reason as the new commands above.

-- enableEverything / disableEverything are the canonical lifecycle helpers,
-- shared by /res on, /res off, and the LDB minimap right-click toggle.
-- Disabling does NOT wipe the user's saved preference toggles
-- (db.muteWeaponImpacts, db.muteCreatureVox, mutedNPCs, mutedMounts,
-- muteAmbientSounds, etc.) — re-enabling reads those tables and reapplies
-- the mutes.  Wiping them would silently destroy a user's curated muting
-- config every time they toggle the addon off to A/B compare audio.
function Resonance.enableEverything()
  db.enabled = true
  applyMutes()
  if getVoxMode() ~= "off" then
    applyVoxMutes()
  end
  if db.muteWeaponImpacts then
    applyWeaponMutes()
  end
  if hasAnyCreatureVoxEnabled() then
    applyCreatureVoxMutes()
  end
  if hasAnyProfessionMuteEnabled() then
    applyProfessionMutes()
  end
  if hasAnyNPCMuteEnabled() then
    applyNPCMutes()
  end
  if hasAnyMountMuteEnabled() then
    applyMountMutes()
  end
  Resonance.refreshSoloClassMute()
  if hasAnyAmbientMuteEnabled() then
    applyAmbientMutes()
  end
  if Resonance.hasAnyEffectMuteEnabled() then
    Resonance.applyEffectMutes()
  end
  if db.classicAutoShot then
    applyAutoShotMutes()
  end
  if Sheathe.hasAnyMute() then
    Sheathe.applyMutes()
  end
  if db.replaceSheathe then
    Sheathe.enableEvents()
  end
  if db.mutePrecastSounds and Resonance.Flavor.supportsPrecastMute then
    Resonance.applyPrecastMutes()
  end
  refreshAlertEvents()
end

function Resonance.disableEverything()
  cancelActiveLoop()
  db.enabled = false
  clearAutoShotMutes()
  clearAlertEvents()
  clearAlertState()
  clearNPCMutes()
  clearMountMutes()
  Resonance.clearSoloClassMute()
  clearAmbientMutes()
  Resonance.clearEffectMutes()
  clearProfessionMutes()
  clearVoxMutes()
  clearWeaponMutes()
  clearCreatureVoxMutes()
  clearMutes()
  Sheathe.clearMutes()
  Sheathe.disableEvents() -- bumps generation token; ToggleSheath hook stays installed but inert
  Resonance.cancelAllTransientMutes()
  Resonance.clearPrecastMutes()
end

local function handleOnCommand()
  Resonance.enableEverything()
  msg(L["Enabled."])
end

local function handleOffCommand()
  Resonance.disableEverything()
  msg(L["Disabled."])
end

local function handleTestspellCommand(rest)
  local sid = tonumber(rest)
  if not sid then
    msg(L["Usage: /res testspell <spellID>"])
    return
  end
  local name = getSpellName(sid) or ""
  msg(L["Testing sound for: %s (spellID %d)"]:format(name ~= "" and name or "<?>", sid))
  playResolvedSound(sid, name)
end

local function handleDumpCommand(rest)
  local sid = tonumber(rest)
  if not sid then
    msg(L["Usage: /res dump <spellID>"])
    return
  end
  local cfg = db.spell_config and db.spell_config[sid]
  if not cfg then
    msg(L["No spell_config for spellID %d"]:format(sid))
    return
  end
  local snd = cfg.sound
  if snd == false then
    msg(L["  sound = false (mute only)"])
  elseif type(snd) == "table" then
    local mode = snd.sequence and "sequence" or (snd.random and "fixed+random" or "fixed")
    msg(
      ("  sound = table (%s): %d entries%s"):format(mode, #snd, snd.random and (", " .. #snd.random .. " random") or "")
    )
    for i, s in ipairs(snd) do
      msg(L["    [%d] = %s"]:format(i, tostring(s)))
    end
    if snd.random then
      for i, s in ipairs(snd.random) do
        msg(L["    random[%d] = %s"]:format(i, tostring(s)))
      end
    end
  else
    msg(L["  sound = %s (%s)"]:format(tostring(snd), type(snd)))
  end
  msg(L["  multiHitWindow = %s"]:format(tostring(cfg.multiHitWindow)))
end

local function handleCheckfidCommand(rest)
  local fid = tonumber(rest)
  if not fid then
    msg(L["Usage: /res checkfid <fileDataID>"])
    return
  end
  msg(L["=== Checking FID %d ==="]:format(fid))
  local sources = {}
  if db.mute_file_data_ids and db.mute_file_data_ids[fid] then
    sources[#sources + 1] = L["Manual mute"]
  end
  if autoMutedFIDs[fid] and autoMutedFIDs[fid] > 0 then
    sources[#sources + 1] = L["Auto-muted by spell config (refcount=%d)"]:format(autoMutedFIDs[fid])
  end
  if voxMutedFIDs[fid] then
    sources[#sources + 1] = L["Vocalization mute"]
  end
  if weaponMutedFIDs[fid] then
    sources[#sources + 1] = L["Weapon impact mute"]
  end
  if creatureMutedFIDs[fid] then
    sources[#sources + 1] = L["Creature vocalization mute"]
  end
  if autoShotMutedFIDs[fid] then
    sources[#sources + 1] = L["Auto-shot mute"]
  end
  if professionMutedFIDs[fid] then
    sources[#sources + 1] = L["Profession mute"]
  end
  if npcMutedFIDs[fid] then
    sources[#sources + 1] = L["NPC mute"]
  end
  if ambientMutedFIDs[fid] then
    sources[#sources + 1] = L["Ambient mute"]
  end
  if Resonance._effectMutedFIDs[fid] then
    sources[#sources + 1] = L["Effect mute"]
  end
  if precastMutedFIDs[fid] then
    sources[#sources + 1] = L["Aggressive precast mute"]
  end
  if transientMutedFIDs[fid] then
    sources[#sources + 1] = L["Transient cast-window mute"]
  end
  if db._lastWeaponMutedFIDs and db._lastWeaponMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale weapon snapshot"]
  end
  if db._lastVoxMutedFIDs and db._lastVoxMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale vox snapshot"]
  end
  if db._lastCreatureMutedFIDs and db._lastCreatureMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale creature snapshot"]
  end
  if db._lastProfessionMutedFIDs and db._lastProfessionMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale profession snapshot"]
  end
  if db._lastNPCMutedFIDs and db._lastNPCMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale NPC snapshot"]
  end
  if db._lastAmbientMutedFIDs and db._lastAmbientMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale ambient snapshot"]
  end
  if db._lastEffectMutedFIDs and db._lastEffectMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale effect snapshot"]
  end
  if db._lastAutoMutedFIDs and db._lastAutoMutedFIDs[fid] then
    sources[#sources + 1] = L["Stale auto-mute snapshot"]
  end
  if #sources == 0 then
    msg(L["  Not muted by Resonance."])
  else
    for _, s in ipairs(sources) do
      msg(L["  MUTED by: %s"]:format(s))
    end
  end
  msg(L["  Force-unmuting and playing..."])
  for _ = 1, MAX_MUTE_DEPTH do
    UnmuteSoundFile(fid)
  end
  local ok = PlaySoundFile(fid, "Master")
  msg(L["  PlaySoundFile result: %s"]:format(tostring(ok)))
end

local function handleDiagCommand()
  msg(L["=== Resonance Diagnostics ==="])
  local libs = {
    "LibStub",
    "CallbackHandler-1.0",
    "AceAddon-3.0",
    "AceDB-3.0",
    "AceDBOptions-3.0",
    "AceEvent-3.0",
    "AceConsole-3.0",
    "AceConfigRegistry-3.0",
    "AceConfigCmd-3.0",
    "AceConfigDialog-3.0",
    "AceConfig-3.0",
    "AceGUI-3.0",
    "LibDataBroker-1.1",
    "LibDBIcon-1.0",
  }
  for _, libName in ipairs(libs) do
    if libName == "LibStub" then
      local ok = _G.LibStub ~= nil
      msg(L["  %s: %s"]:format(libName, ok and "|cff00ff00OK|r" or "|cffff0000MISSING|r"))
    else
      local lib, minor = LibStub:GetLibrary(libName, true)
      if lib then
        msg(L["  %s: |cff00ff00v%s|r"]:format(libName, tostring(minor or "?")))
      else
        msg(L["  %s: |cffff0000NOT LOADED|r"]:format(libName))
      end
    end
  end
  local aceGUI = LibStub:GetLibrary("AceGUI-3.0", true)
  if aceGUI then
    local widgets = { "SimpleGroup", "Button", "CheckBox", "Dropdown", "Label", "Slider", "EditBox", "Heading" }
    msg(L["  AceGUI widgets:"])
    for _, wtype in ipairs(widgets) do
      local ok, w = pcall(function()
        return aceGUI:Create(wtype)
      end)
      if ok and w then
        aceGUI:Release(w)
        msg(L["    %s: |cff00ff00OK|r"]:format(wtype))
      else
        msg(L["    %s: |cffff0000FAILED|r %s"]:format(wtype, ok and "" or tostring(w)))
      end
    end
  end
  msg(
    "  Settings API: "
      .. (Settings and Settings.RegisterCanvasLayoutCategory and "|cff00ff00OK|r" or "|cffff0000MISSING|r")
  )
  msg(
    "  Spell mute data: "
      .. (Resonance.SpellMuteData and "|cff00ff00loaded (string-packed)|r" or "|cffff0000NOT LOADED|r")
  )
  msg("  Vox data: " .. (Resonance.VoxFIDs and "|cff00ff00loaded|r" or "|cffffff00not loaded|r"))
  msg("  Creature vox data: " .. (Resonance.CreatureVoxData and "|cff00ff00loaded|r" or "|cffffff00not loaded|r"))
  if Resonance.CreatureVoxCategories then
    local enabledCats = {}
    for _, cat in ipairs(Resonance.CreatureVoxCategories) do
      if db.muteCreatureVox and db.muteCreatureVox[cat] then
        enabledCats[#enabledCats + 1] = cat
      end
    end
    local n = 0
    for _ in pairs(creatureMutedFIDs) do
      n = n + 1
    end
    msg(
      ("  Creature vox muted: %d FIDs, %d categories (%s)"):format(
        n,
        #enabledCats,
        #enabledCats > 0 and table.concat(enabledCats, ", ") or "none"
      )
    )
  end
  msg("  Profession sound data: " .. (ProfessionSoundData and "|cff00ff00loaded|r" or "|cffffff00not loaded|r"))
  if ProfessionCategories then
    local enabledProfs = {}
    for _, prof in ipairs(ProfessionCategories) do
      if db.muteProfessionSounds and db.muteProfessionSounds[prof] then
        enabledProfs[#enabledProfs + 1] = prof
      end
    end
    local n = 0
    for _ in pairs(professionMutedFIDs) do
      n = n + 1
    end
    msg(
      ("  Profession muted: %d FIDs, %d professions (%s)"):format(
        n,
        #enabledProfs,
        #enabledProfs > 0 and table.concat(enabledProfs, ", ") or "none"
      )
    )
  end
  msg(L["=== End Diagnostics ==="])
end

---------------------------------------------------------------------------
-- Slash command handler
---------------------------------------------------------------------------
function Resonance:ChatCommand(input)
  input = input or ""
  local cmd, rest = input:match("^(%S+)%s*(.-)$")
  cmd = cmd and cmd:lower() or ""

  if cmd == "options" or cmd == "config" or cmd == "settings" or cmd == "" then
    if Resonance.openOptions then
      Resonance.openOptions()
    else
      msg(L["Options panel not loaded."])
    end
    return
  elseif cmd == "on" then
    handleOnCommand()
  elseif cmd == "off" then
    handleOffCommand()
  elseif cmd == "debug" then
    rest = (rest or ""):lower()
    db.debug = (rest == "on" or rest == "1" or rest == "true")
    msg("Debug: " .. (db.debug and L["Enabled."] or L["Disabled."]))
  elseif cmd == "applymutes" then
    applyMutes()
  elseif cmd == "clearmutes" then
    clearMutes()
  elseif cmd == "muteadd" then
    local fid = tonumber(rest)
    if not fid then
      msg(L["Usage: /res muteadd <fileDataID>"])
      return
    end
    db.mute_file_data_ids[fid] = true
    MuteSoundFile(fid)
    msg(L["Muted fileDataID %d."]:format(fid))
  elseif cmd == "mutedel" then
    local fid = tonumber(rest)
    if not fid then
      msg(L["Usage: /res mutedel <fileDataID>"])
      return
    end
    db.mute_file_data_ids[fid] = nil
    if not isMutedElsewhere(fid) then
      UnmuteSoundFile(fid)
    end
    msg(L["Unmuted fileDataID %d."]:format(fid))
  elseif cmd == "mutelist" then
    msg(L["Muted fileDataIDs:"])
    local n = 0
    for fid, enabled in pairs(db.mute_file_data_ids or {}) do
      if enabled then
        msg(L["  %d"]:format(fid))
        n = n + 1
      end
    end
    if n == 0 then
      msg(L["  (none)"])
    end
  elseif cmd == "map" then
    local sid, fid = rest:match("^(%d+)%s+(%d+)$")
    sid = sid and tonumber(sid)
    fid = fid and tonumber(fid)
    if not sid or not fid then
      msg(L["Usage: /res map <spellID> <fileDataID>"])
      return
    end
    db.spell_to_play_file_data_id[sid] = fid
    msg(L["Mapped spellID %d -> fileDataID %d."]:format(sid, fid))
  elseif cmd == "unmap" then
    local sid = tonumber(rest)
    if not sid then
      msg(L["Usage: /res unmap <spellID>"])
      return
    end
    db.spell_to_play_file_data_id[sid] = nil
    msg(L["Unmapped spellID %d."]:format(sid))
  elseif cmd == "override" then
    local name, path = rest:match('^"(.-)"%s+(.+)$')
    if not name then
      name, path = rest:match("^(.-)%s+(.+)$")
    end
    if not name or not path then
      msg(L['Usage: /res override "Mortal Strike" sounds/vanilla/Mortal Strike.wav'])
      return
    end
    db.local_file_overrides_by_spell_name[name] = path
    msg(L["Override for '%s' -> %s"]:format(name, path))
  elseif cmd == "clearoverride" then
    local name = rest
    if name and name:sub(1, 1) == '"' then
      name = name:match('^"(.-)"$') or name
    end
    if not name or name == "" then
      msg(L['Usage: /res clearoverride "Mortal Strike"'])
      return
    end
    db.local_file_overrides_by_spell_name[name] = nil
    msg(L["Cleared override for '%s'"]:format(name))
  elseif cmd == "cast" then
    handlePhaseSoundCommand(rest, "cast", { sound = "sound", duration = "duration", loop = "loop" })
  elseif cmd == "precast" then
    handlePhaseSoundCommand(
      rest,
      "precast",
      { sound = "precastSound", duration = "precastDuration", loop = "precastLoop" }
    )
  elseif cmd == "trigger" then
    handleTriggerCommand(rest)
  elseif cmd == "channel" then
    handleChannelCommand(rest)
  elseif cmd == "multihit" then
    handleMultihitCommand(rest)
  elseif cmd == "unmute" then
    handleUnmuteCommand(rest)
  elseif cmd == "spell" then
    handleSpellCommand(rest)
  elseif cmd == "impact" then
    handleImpactCommand(rest)
  elseif cmd == "testspell" then
    handleTestspellCommand(rest)
  elseif cmd == "dump" then
    handleDumpCommand(rest)
  elseif cmd == "checkfid" then
    handleCheckfidCommand(rest)
  elseif cmd == "diag" then
    handleDiagCommand()
  elseif cmd == "sfx" then
    rest = (rest or ""):lower()
    if rest == "off" then
      SetCVar("Sound_EnableSFX", 0)
      msg(L["SFX disabled (you may lose footsteps)."])
    else
      SetCVar("Sound_EnableSFX", 1)
      msg(L["SFX enabled."])
    end
  else
    msg(L["Commands:"])
    msg(L["  /res                     Open settings panel"])
    msg(L["  /res on|off"])
    msg(L["  /res debug on|off"])
    msg(L["  /res testspell <spellID>"])
    msg(L["  /res muteadd <fileDataID>   /res mutedel <fileDataID>   /res mutelist"])
    msg(L["  /res map <spellID> <fileDataID>   /res unmap <spellID>"])
    msg(L["  /res cast    add|list|remove|clear|mode|loop|duration <spellID> [...] [horde|alliance]"])
    msg(L["  /res precast add|list|remove|clear|mode|loop|duration <spellID> [...] [horde|alliance]"])
    msg(L["  /res impact  add|list|remove|clear <spellID> [...] [horde|alliance]"])
    msg(L["  /res trigger  <spellID> cast|precast|both"])
    msg(L["  /res channel  <spellID> Master|SFX|Dialog|Music|Ambience|off"])
    msg(L["  /res multihit <spellID> <seconds|off>"])
    msg(L["  /res unmute   add|list|remove|clear <spellID> [<fileDataID>]"])
    msg(L["  /res spell    show|remove <spellID>"])
    msg('  /res override "Spell Name" <path>   /res clearoverride "Spell Name"')
    msg(L["  /res applymutes  /res clearmutes"])
    msg(L["  /res sfx on|off"])
  end
end
