-- Sleek RP - Warlock Pets (Events.lua)
-- Dispatcher principal + points d'extension simples.

local ADDON_NAME, SRPWL = ...
if not SRPWL then return end

-- === Env (détection Classic / capacités API) =================================
-- Nécessite que "Core/Env.lua" soit chargé avant.
local Env = SRPWL.Env or {
  hasMountJournal = (C_MountJournal and type(C_MountJournal.GetMountIDs) == "function") or false,
  interface = select(4, GetBuildInfo()),
  log = function() end,
}

local f = CreateFrame("Frame")
local DB = _G.SleekRP_WLP_DB or (_G.SleekRP_WLP_DB or {})
_G.SleekRP_WLP_DB = DB
DB.seen_regions = DB.seen_regions or {}
DB.dialogue_rate = DB.dialogue_rate or 1.0  -- 0.25..3.0 recommandé

-- ==== Points d’extension (append-only) =======================================
SRPWL._onChatEmote   = SRPWL._onChatEmote   or {}
SRPWL._onRegionPing  = SRPWL._onRegionPing  or {}
SRPWL._onGroupHealth = SRPWL._onGroupHealth or {}

function SRPWL.AddOnChatEmote(fn)   if type(fn)=="function" then table.insert(SRPWL._onChatEmote, fn) end end
function SRPWL.AddOnRegionPing(fn)  if type(fn)=="function" then table.insert(SRPWL._onRegionPing, fn) end end
function SRPWL.AddOnGroupHealth(fn) if type(fn)=="function" then table.insert(SRPWL._onGroupHealth, fn) end end

local function safe_fire(list, ...)
  for _,fn in ipairs(list) do
    local ok, err = pcall(fn, ...)
    if not ok and DB.debug then print("|cff88fSleekRP|r callback error:", err) end
  end
end

-- === États internes ==========================================================
local activeBuffs = {}
local lastMoney = 0
local lastKeyAt = {}
local wasInGroup = IsInGroup() or IsInRaid()
local wasMounted = (IsMounted and IsMounted()) or false
local lastKillAt = 0
local mapHooked = false
local fishingActive = false
local lastFishingAt = 0
local recentMountCastAt = 0
local LOCALE = (GetLocale and GetLocale()) or "enUS"
local IS_RETAIL = (_G.WOW_PROJECT_ID and _G.WOW_PROJECT_MAINLINE and _G.WOW_PROJECT_ID == _G.WOW_PROJECT_MAINLINE) and true or false
local IN_INSTANCE = false
local PLAYER_GUID = nil

-- === Helpers ================================================================
local function IsDebug() return DB and DB.debug == true end
local function log(...) if IsDebug() then print("|cff88fSleekRP:|r", ...) end end
local function UnitAlive(u) return UnitExists(u) and not UnitIsDeadOrGhost(u) end
local function stripAccents(s)
  if not s then return "" end
  s = s:gsub("[àáâãäå]", "a"):gsub("ç", "c")
       :gsub("[èéêë]", "e"):gsub("[ìíîï]", "i")
       :gsub("[òóôöõ]", "o"):gsub("[ùúûü]", "u")
       :gsub("ÿ", "y"):gsub("œ", "oe"):gsub("æ", "ae")
       :gsub("À","A"):gsub("Â","A"):gsub("Ä","A"):gsub("É","E"):gsub("È","E"):gsub("Ê","E"):gsub("Ë","E")
       :gsub("Î","I"):gsub("Ï","I"):gsub("Ô","O"):gsub("Ö","O"):gsub("Ù","U"):gsub("Û","U"):gsub("Ü","U")
       :gsub("Œ","OE"):gsub("Æ","AE"):gsub("’","'")
  return s
end
local function norm(s)
  if not s or s == "" then return "" end
  s = stripAccents(s)
  s = s:gsub("[^%w%s%-']", "")
  return string.lower(s)
end
local function compactKeyBase(s)
  s = stripAccents(s or "")
  s = s:gsub("[^%w]", "")
  return s
end
local function trim(s) return (s and s:gsub("^%s+",""):gsub("%s+$","") or "") end

-- --- Rate helpers -----------------------------------------------------------
local function GetRate()
  local r = (SRPWL and SRPWL.GetDialogueRate and SRPWL.GetDialogueRate()) or DB.dialogue_rate or 1.0
  r = tonumber(r) or 1.0
  if r < 0.1 then r = 0.1 end
  if r > 3.0 then r = 3.0 end
  return r
end
local function EffectiveCooldown(base)
  local rate = GetRate()
  local cd = (tonumber(base) or 1.0) / rate
  if cd < 0.5 then cd = 0.5 end
  return cd
end
local function throttle(key, window, scaleWithRate)
  local t = GetTime()
  window = tonumber(window) or 1.5
  if scaleWithRate then window = EffectiveCooldown(window) end
  local last = lastKeyAt[key] or 0
  if (t - last) < window then return true end
  lastKeyAt[key] = t
  return false
end

-- ===== Emission =============================================================
local function SpeakNow(key)
  if not key then return end
  if UnitExists("pet") then
    if SRPWL and SRPWL.TrySpeak then SRPWL.TrySpeak(key) end
  else
    f._pending = f._pending or {}
    f._pending[key] = GetTime()
    if IsDebug() then log("Queue event (no pet):", key) end
  end
end
local function FlushPending()
  if not f._pending then return end
  local keys = {}
  for k,_ in pairs(f._pending) do table.insert(keys, k) end
  table.sort(keys)
  f._pending = nil
  for _,k in ipairs(keys) do
    C_Timer.After(0.05, function() SpeakNow(k) end)
  end
end

local DELAYED_EVENTS = {
  Emote_Roar = 1.5, Emote_Laugh = 1.5, Emote_Yes = 1.5, Emote_No = 1.5,
  Emote_Cheer = 1.5, Emote_Cry = 1.5, Emote_Whistle = 1.5,
  MapOpened = 1.5, QuestCompleted = 1.5, Emote_Salute = 1.5,
  Emote_Flirt = 4.0,
}
local function Emit(key)
  local d = DELAYED_EVENTS[key]
  if d then
    local delay = (d == true) and 1.5 or tonumber(d) or 1.5
    C_Timer.After(delay, function() SpeakNow(key) end)
  else
    SpeakNow(key)
  end
end
function TrySpeak(key)
  if not key then return end
  Emit(key)
end

-- ===== Safe Spell Info ======================================================
local function SpellInfo(spellID)
  if C_Spell and C_Spell.GetSpellInfo then
    return C_Spell.GetSpellInfo(spellID)
  end
  if GetSpellInfo then
    local name, _, _, castTime = GetSpellInfo(spellID)
    if name then return { name = name, castTime = castTime } end
  end
  return nil
end
local function SpellName(spellID)
  if C_Spell and C_Spell.GetSpellName then
    local n = C_Spell.GetSpellName(spellID)
    if n and n ~= "" then return n end
  end
  if GetSpellInfo then
    local n = GetSpellInfo(spellID)
    if n and n ~= "" then return n end
  end
  return nil
end

-- ===== Data lazy-binding (KW & ZONE_ALIAS viennent de Data_Tokens.lua) ======
local function getKW()
  if SRPWL.KW then return SRPWL.KW end
  -- Fallback minimal si Data_Tokens.lua n’est pas chargé (pour éviter les erreurs).
  return {
    hearthstone={"hearth"}, healthstone={"healthstone"}, soulstone={"soulstone"},
    summon_rit={"ritual of summoning"}, gateway={"demonic gateway"}, potion={" potion"},
    sum_demon={"summon"},
    emote_bow={"bow"}, emote_kneel={"kneel"}, emote_laugh={"laugh"},
    emote_thank={"thank"}, emote_wink={"wink"}, emote_roar={"roar"}, emote_cry={"cry"},
    emote_beg={"beg"}, emote_pat={" pat "}, emote_hug={"hug"}, emote_rude={"rude"},
    emote_sit={"sit "}, emote_sleep={"sleep"}, emote_yes={" nod"}, emote_no={" no."},
    emote_cheer={"cheer"}, emote_whistle={"whistle"}, emote_point={" point "},
    emote_shrug={"shrug"}, emote_clap={"clap"}, emote_salute={"salute"},
    emote_kiss={"kiss"}, emote_pet={" pet "}, emote_dance={"dance"},
    emote_hi={"wave","hello"}, emote_love={"love"}, emote_flirt={"flirt"},
    emote_agree={"agree"}, emote_trade={"trade"},
  }
end

local _EMOTE_ORDER = nil
local function getEmoteOrder()
  if _EMOTE_ORDER then return _EMOTE_ORDER end
  local KW = getKW()
  _EMOTE_ORDER = {
    { key="Emote_Bow",      kw=KW.emote_bow     },
    { key="Emote_Kneel",    kw=KW.emote_kneel   },
    { key="Emote_Laugh",    kw=KW.emote_laugh   },
    { key="Emote_Thank",    kw=KW.emote_thank   },
    { key="Emote_Wink",     kw=KW.emote_wink    },
    { key="Emote_Roar",     kw=KW.emote_roar    },
    { key="Emote_Cry",      kw=KW.emote_cry     },
    { key="Emote_Beg",      kw=KW.emote_beg     },
    { key="Emote_Pat",      kw=KW.emote_pat     },
    { key="Emote_Hug",      kw=KW.emote_hug     },
    { key="Emote_Rude",     kw=KW.emote_rude    },
    { key="Emote_Sit",      kw=KW.emote_sit     },
    { key="Emote_Sleep",    kw=KW.emote_sleep   },
    { key="Emote_Yes",      kw=KW.emote_yes     },
    { key="Emote_No",       kw=KW.emote_no      },
    { key="Emote_Cheer",    kw=KW.emote_cheer   },
    { key="Emote_Whistle",  kw=KW.emote_whistle },
    { key="Emote_Point",    kw=KW.emote_point   },
    { key="Emote_Shrug",    kw=KW.emote_shrug   },
    { key="Emote_Clap",     kw=KW.emote_clap    },
    { key="Emote_Salute",   kw=KW.emote_salute  },
    { key="Emote_WaveKiss", kw=KW.emote_kiss    },
    { key="Emote_Flirt",    kw=KW.emote_flirt   },
    { key="Emote_Pet",      kw=KW.emote_pet     },
    { key="Emote_Dance",    kw=KW.emote_dance   },
    { key="Emote_Hi",       kw=KW.emote_hi      },
    { key="Emote_Love",     kw=KW.emote_love    },
    { key="Emote_Agree",    kw=KW.emote_agree   },
    { key="Emote_Surprised", kw=KW.emote_surprised },
    { key="Emote_Facepalm",  kw=KW.emote_facepalm  },
    { key="Emote_Victory",   kw=KW.emote_victory   },
    { key="Emote_Work",      kw=KW.emote_work      },
    { key="Trade",          kw=KW.emote_trade   },
  }
  return _EMOTE_ORDER
end

-- ===== Zones (alias FR/EN) ===================================================
local function tryKeyFromName(name)
  if not name or name == "" then return nil end
  local aliasTbl = SRPWL.ZONE_ALIAS
  if aliasTbl then
    local alias = aliasTbl[norm(name):gsub("[%s%-']", "")]
    if alias then return alias end
  end
  local compact = compactKeyBase(name)
  if compact ~= "" and SRPWL.HasEventKey then
    local candidate = "Zone_" .. compact
    if SRPWL.HasEventKey(candidate) then return candidate end
  end
  return nil
end
local function getRegionKey()
  local id = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
  local info = id and C_Map.GetMapInfo and C_Map.GetMapInfo(id)
  local guard = 0
  if info and info.name then
    local k = tryKeyFromName(info.name); if k then return k end
  end
  while info and info.parentMapID and guard < 10 do
    guard = guard + 1
    info = C_Map.GetMapInfo(info.parentMapID)
    if info and info.name then
      local k2 = tryKeyFromName(info.name); if k2 then return k2 end
    end
  end
  local regionName = (GetRealZoneText and GetRealZoneText()) or (GetZoneText and GetZoneText()) or ""
  local k3 = tryKeyFromName(regionName)
  if k3 then return k3 end
  return (regionName ~= "") and ("Zone_" .. compactKeyBase(regionName)) or nil
end

-- ===== Montures ==============================================================
local MOUNT_SPELLIDS, MOUNT_NAMES_NORM = {}, {}
local function buildMountSpellSet()
  wipe(MOUNT_SPELLIDS); wipe(MOUNT_NAMES_NORM)
  if Env.hasMountJournal and C_MountJournal and C_MountJournal.GetMountIDs then
    local ids = C_MountJournal.GetMountIDs()
    if ids then
      for _, mid in ipairs(ids) do
        local name, spellID = C_MountJournal.GetMountInfoByID(mid)
        if name and name ~= "" then MOUNT_NAMES_NORM[norm(name)] = true end
        if spellID then MOUNT_SPELLIDS[spellID] = true end
      end
    end
  end
end
local function isMountBySpell(spellID, name)
  if spellID and MOUNT_SPELLIDS[spellID] then return true end
  if name and MOUNT_NAMES_NORM[norm(name)] then return true end
  return false
end
local function flagRecentMountCast() recentMountCastAt = GetTime() end
local function isWithinRecentMountWindow()
  return (GetTime() - (recentMountCastAt or 0)) < 6.0
end

-- ===== Pêche ================================================================
local FISHING_IDS = { [131474]=true, [131476]=true, [7620]=true, [7731]=true, [7732]=true, [18248]=true, [33095]=true }
local FISH_TOKENS = { "fish", "pech", "peche" }
local function isFishingName(n)
  n = norm(n or ""); for _,k in ipairs(FISH_TOKENS) do if n:find(k, 1, true) then return true end end
  return false
end
local function verifyFishingChannelSoon()
  C_Timer.After(0.05, function()
    local name = UnitChannelInfo and UnitChannelInfo("player")
    if name and isFishingName(name) then
      fishingActive = true
      if not throttle("FishingStarted_uc", 1.0) then TrySpeak("FishingStarted") end
    end
  end)
end

-- ===== Region ticker (3–4 min) + hook =======================================
f._regionTimer = nil
f._currentRegionKey = nil
local function cancelRegionTimer() if f._regionTimer and f._regionTimer.Cancel then f._regionTimer:Cancel() end f._regionTimer = nil end
local function scheduleRegionPing(regionKey)
  cancelRegionTimer()
  if not regionKey then return end
  local function nextDelay() return EffectiveCooldown(180 + math.random(0, 60)) end
  local function ping()
    if getRegionKey() == regionKey then
      log("Region ping ->", regionKey, "(rate=", GetRate(), ")")
      TrySpeak(regionKey)                       -- relance un dialogue de zone (random côté Core)
      safe_fire(SRPWL._onRegionPing, regionKey) -- <<< hook minimaliste
      f._regionTimer = C_Timer.NewTimer(nextDelay(), ping)
    end
  end
  f._regionTimer = C_Timer.NewTimer(nextDelay(), ping)
end

-- ===== Raid buffs & Eat/Drink ===============================================
SRPWL.BUFF = SRPWL.BUFF or {}
local SRPWL_BUFF = SRPWL.BUFF
SRPWL_BUFF.durationThreshold = SRPWL_BUFF.durationThreshold or 300

local RAID_CANONICAL_IDS = {1459,23028,21562,21564,14752,27681,976,27683,1126,21849,6673,20217,19742,19740,20911,381748}
local RAID_NAME_FALLBACKS = {
  "Arcane Intellect","Arcane Brilliance","Power Word: Fortitude","Prayer of Fortitude",
  "Mark of the Wild","Gift of the Wild","Battle Shout",
  "Blessing of Kings","Blessing of Wisdom","Blessing of Might","Blessing of Sanctuary",
  "Divine Spirit","Prayer of Spirit","Shadow Protection","Prayer of Shadow Protection",
  "Blessing of the Bronze",
  "Intelligence des arcanes","Brillance des arcanes","Mot de pouvoir : Robustesse","Prière de Robustesse",
  "Marque du fauve","Don du fauve","Cri de guerre","Bénédiction des rois","Bénédiction de sagesse",
  "Bénédiction de puissance","Bénédiction de sanctuaire","Esprit divin","Prière d'esprit",
  "Protection contre l’Ombre","Prière de Protection contre l’Ombre","Protection contre l'Ombre","Bénédiction du bronze",
}
local RAID_NAMESET = nil
local function buildRaidNameSet()
  if RAID_NAMESET then return RAID_NAMESET end
  RAID_NAMESET = {}
  for _, id in ipairs(RAID_CANONICAL_IDS) do
    local info = SpellInfo(id); local name = info and info.name
    if name and name ~= "" then RAID_NAMESET[norm(name)] = true end
  end
  for _, n in ipairs(RAID_NAME_FALLBACKS) do RAID_NAMESET[norm(n)] = true end
  return RAID_NAMESET
end
local function isEatingOrDrinkingBuff(name)
  local stems = {
    "eat","food","well fed","feast","banquet","picnic","refreshment",
    "drink","grog","milk","water","nourri","nourrit","nourriture","repas",
    "restaure","régén","regen","régénère","regener","biscuit","gateau","gâteau",
    "riz","pain","rago","ragou","ragoût","ragout","bois","boisson","lait","eau",
    tostring(LOCALE):match("^fr") and "the " or nil, tostring(LOCALE):match("^fr") and "thé" or nil,
  }
  local n = norm(name or "")
  for _,k in ipairs(stems) do if k and n:find(k, 1, true) then return true end end
  return false
end
local function forEachBuff(unit, cb)
  if AuraUtil and AuraUtil.ForEachAura then
    AuraUtil.ForEachAura(unit, "HELPFUL", nil, cb)
  else
    local i = 1
    while true do
      local name, _, _, _, duration, expirationTime, _, _, _, spellId = UnitBuff(unit, i)
      if not name then break end
      cb({name=name, spellId=spellId, duration=duration, expirationTime=expirationTime})
      i = i + 1
    end
  end
end
local function scanBuffsAndDetect(unit)
  if unit ~= "player" then return end
  local foundEatDrink = false
  local seen = {}
  forEachBuff("player", function(a)
    local id = a.spellId or 0
    seen[id] = true
    if isEatingOrDrinkingBuff(a.name) then foundEatDrink = true end
    if not activeBuffs[id] then
      if (function(auraName, dur)
        local names = buildRaidNameSet()
        local n = norm(auraName or ""); local th = tonumber(SRPWL_BUFF.durationThreshold) or 300
        if not names[n] then return false end
        if not dur or dur == 0 then return true end
        return dur >= th
      end)(a.name, a.duration) then
        activeBuffs[id] = true
        if not throttle("BuffReceived", 0.5) then TrySpeak("BuffReceived") end
      end
    end
  end)
  for id in pairs(activeBuffs) do if not seen[id] then activeBuffs[id] = nil end end
  if foundEatDrink and not isWithinRecentMountWindow() then
    if not throttle("EatingDrinking", 3.0) then TrySpeak("EatingDrinking") end
  end
end

-- ===== Keywords util =========================================================
local function matchAny(s, list)
  s = norm(s or "")
  for _, k in ipairs(list or {}) do
    local kk = norm(k)
    if kk ~= "" and s:find(kk, 1, true) then return true end
  end
  return false
end

-- ===== Soulstone & “Major” ===================================================
local SOULSTONE_IDS = { [20707]=true }
local MAJOR_THROTTLE_WINDOW = 40.0
local RETAIL_MAJOR_IDS = { [116858]=true,[17962]=true,[17877]=true,[152108]=true,[29722]=true,[5740]=true,[30108]=true,[324536]=true,[48181]=true,[27243]=true,[265187]=true,[264178]=true,[18540]=true,[342601]=true,[6353]=true,[6789]=true,[5484]=true, }
local CLASSIC_MAJOR_IDS = { [686]=true,[5676]=true,[172]=true,[6353]=true,[17962]=true,[17877]=true,[5740]=true,[27243]=true,[30108]=true,[48181]=true,[18540]=true,[342601]=true,[6789]=true,[5484]=true, }
local RETAIL_MAJOR_TOKENS = {
  "chaos bolt","conflagrate","shadowburn","cataclysm","incinerate","rain of fire",
  "unstable affliction","malefic rapture","haunt","seed of corruption",
  "summon demonic tyrant","ritual of doom","soul fire","death coil","mortal coil","howl of terror",
  "trait du chaos","conflagration","brulure de l'ombre","brûlure de l'ombre","cataclysme","incineration","incinération","pluie de feu",
  "affliction instable","extase malefique","extase maléfique","hanter","graine de corruption",
  "invocation d'un tyran demoniaque","invocation d un tyran demoniaque","rituel funeste","feu de l'ame","feu de l ame","voile mortel","hurlement de terreur",
}
local CLASSIC_MAJOR_TOKENS = {
  "shadow bolt","searing pain","immolate","conflagrate","shadowburn","rain of fire",
  "seed of corruption","unstable affliction","haunt","ritual of doom","soul fire","death coil","howl of terror",
  "trait de l'ombre","douleur brulante","douleur brûlante","immolation","conflagration","brulure de l'ombre","brûlure de l'ombre","pluie de feu",
  "graine de corruption","affliction instable","hanter","rituel funeste","feu de l'ame","feu de l ame","voile mortel","hurlement de terreur",
}
local function isInIdSet(spellID, set) return (spellID and set[spellID]) and true or false end
local function hasAnyToken(name, tokens)
  local n = norm(name or ""); for _,t in ipairs(tokens) do if n:find(norm(t), 1, true) then return true end end
  return false
end
local function isMajorRetail(spellID, name) return isInIdSet(spellID, RETAIL_MAJOR_IDS) or hasAnyToken(name, RETAIL_MAJOR_TOKENS) end
local function isMajorClassic(spellID, name) return isInIdSet(spellID, CLASSIC_MAJOR_IDS) or hasAnyToken(name, CLASSIC_MAJOR_TOKENS) end
local function isMajorStart(spellID, name) if IS_RETAIL then return isMajorRetail(spellID, name) else return isMajorClassic(spellID, name) end end
local function isMajorSucc(spellID, name)  if IS_RETAIL then return isMajorRetail(spellID, name) else return isMajorClassic(spellID, name) end end
local function FireSpellCastMajor() if not throttle("SpellCastMajor", MAJOR_THROTTLE_WINDOW, false) then TrySpeak("SpellCastMajor") end end

-- ===== Suivi groupe: mort & PV bas ==========================================
local LOWHP_THRESHOLD = 0.25
local lastLowHPAt, announcedDead = {}, {}
local function ForEachGroupUnit(fn)
  if IsInRaid() then
    local n = GetNumGroupMembers() or 0
    for i=1,n do fn("raid"..i) end
  elseif IsInGroup() then
    local n = GetNumSubgroupMembers() or 0
    for i=1,n do fn("party"..i) end
  end
end
local function CheckUnitHealth(unit)
  if not UnitExists(unit) or UnitIsUnit(unit, "player") then return end
  local guid = UnitGUID(unit); if not guid then return end
  local hp, maxhp = UnitHealth(unit), UnitHealthMax(unit)
  if (maxhp or 0) <= 0 then return end
  local pct = hp / maxhp
  if UnitIsDeadOrGhost(unit) then
    if not announcedDead[guid] then
      announcedDead[guid] = true
      if not throttle("PartyMemberDead", 5.0, true) then TrySpeak("PartyMemberDead") end
      safe_fire(SRPWL._onGroupHealth, unit, {dead=true})
    end
    return
  else
    announcedDead[guid] = nil
  end
  if pct <= LOWHP_THRESHOLD then
    local t = GetTime()
    if (t - (lastLowHPAt[guid] or 0)) > EffectiveCooldown(20) then
      lastLowHPAt[guid] = t
      if not throttle("PartyMemberLow", 5.0, true) then TrySpeak("PartyMemberLow") end
      safe_fire(SRPWL._onGroupHealth, unit, {low=true, pct=pct})
    end
  end
end
local function ScanGroupHealth() ForEachGroupUnit(CheckUnitHealth) end

-- ===== Group wipe ============================================================
local function IsFullGroupDead()
  if IsInRaid() then
    local n = GetNumGroupMembers()
    if n and n > 0 then for i=1,n do if UnitAlive("raid"..i) then return false end end; return true end
  elseif IsInGroup() then
    local n = GetNumSubgroupMembers()
    for i=1,n do if UnitAlive("party"..i) then return false end end
    if UnitAlive("player") then return false end
    return n > 0
  end
  return false
end

-- ===== Instance state ========================================================
local function UpdateInstanceState()
  local inInst = IsInInstance and IsInInstance()
  if inInst ~= IN_INSTANCE then
    IN_INSTANCE = inInst
    if IN_INSTANCE then
      if not throttle("InstanceEntered", 10.0, true) then TrySpeak("InstanceEntered") end
    end
  end
end

-- ===== Handler principal =====================================================
local function OnEvent(self, event, ...)
  if event == "PLAYER_LOGIN" then
    activeBuffs = {}
    lastMoney = GetMoney() or 0
    wasInGroup = IsInGroup() or IsInRaid()
    wasMounted = (IsMounted and IsMounted()) or false
    buildRaidNameSet(); buildMountSpellSet()
    fishingActive, lastFishingAt = false, 0
    recentMountCastAt = 0
    PLAYER_GUID = UnitGUID("player")
    UpdateInstanceState()

    -- hook DoEmote: SIT / OK / AGREE / TRADE (+ NO + PAT)
    if not f._hookedEmote and type(DoEmote) == "function" then
      f._hookedEmote = true
      hooksecurefunc("DoEmote", function(token)
        token = token and tostring(token):upper() or ""
        if     token == "SIT"   then if not throttle("Emote_Sit_Hook",   2.0) then TrySpeak("Emote_Sit")   end
        elseif token == "OK" or token == "AGREE"
                                then if not throttle("Emote_Agree_Hook", 2.0) then TrySpeak("Emote_Agree") end
        elseif token == "TRADE" then if not throttle("Trade_Hook",       2.0) then TrySpeak("Trade")       end
        elseif token == "NO"    then if not throttle("Emote_No_Hook",    2.0) then TrySpeak("Emote_No")    end
        elseif token == "PAT"   then if not throttle("Emote_Pat_Hook",   2.0) then TrySpeak("Emote_Pat")   end
        end
      end)
    end

    -- Hook carte → MapOpened
    if not mapHooked and WorldMapFrame and WorldMapFrame:IsObjectType("Frame") then
      mapHooked = true
      WorldMapFrame:HookScript("OnShow", function()
        if not throttle("MapOpened", 1.0) then Emit("MapOpened") end
      end)
    end

    -- Région init + ticker
    local rkey = getRegionKey()
    if rkey then
      f._currentRegionKey = rkey
      if not DB.seen_regions[rkey] then DB.seen_regions[rkey] = true end
      scheduleRegionPing(rkey)
    end

  elseif event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
    UpdateInstanceState()

  elseif event == "COMPANION_UPDATE" or event == "NEW_MOUNT_ADDED" or event == "MOUNT_JOURNAL_USABILITY_CHANGED" then
    -- Ces events n’existent pleinement que si le journal de montures est dispo (MoP/retail-like).
    if Env.hasMountJournal then buildMountSpellSet() end

  elseif event == "PLAYER_MOUNT_DISPLAY_CHANGED" then
    local nowMounted = (IsMounted and IsMounted()) or false
    if nowMounted and not wasMounted then TrySpeak("MountSummoned") end
    wasMounted = nowMounted

  elseif event == "PLAYER_LEVEL_UP" then
    TrySpeak("LevelUp")

  -- ===== Vendor & Gold =====
  elseif event == "MERCHANT_SHOW" then
    TrySpeak("UsingVendor"); if CanMerchantRepair and CanMerchantRepair() then TrySpeak("RepairingGear") end
  elseif event == "PLAYER_MONEY" then
    local m = GetMoney() or 0
    if m > lastMoney then if not throttle("Event_GoldReceived", 0.4) then TrySpeak("Event_GoldReceived") end end
    lastMoney = m

  -- ===== Bank / Mail / AH / Transmog / Trainer / Taxi =====
  elseif event == "BANKFRAME_OPENED" or event == "GUILDBANKFRAME_OPENED" then TrySpeak("BankUsed")
  elseif event == "MAIL_SHOW" then TrySpeak("MailboxUsed")
  elseif event == "AUCTION_HOUSE_SHOW" then TrySpeak("AuctionHouseUsed")
  elseif event == "TRANSMOGRIFY_OPEN" then TrySpeak("TransmogUsed")
  elseif event == "TRAINER_SHOW" then TrySpeak("TrainingClass")
  elseif event == "TAXIMAP_OPENED" then f._taxiOpen = true
  elseif event == "TAXIMAP_CLOSED" then
    f._taxiOpen = false
    C_Timer.After(0.2, function() if UnitOnTaxi and UnitOnTaxi("player") then TrySpeak("FlightPathTaken") end end)

  -- ===== Rested (Inn) =====
  elseif event == "PLAYER_UPDATE_RESTING" then
    if IsResting() and not throttle("InnResting", 2.0) then TrySpeak("InnResting") end

  -- ===== Boss end / wipe =====
  elseif event == "ENCOUNTER_END" then
    local _, _, _, _, endStatus = ...
    if endStatus == 1 then TrySpeak("BossKill") else Emit("RaidWipe") end

  -- ===== Zones + découvertes =====
  elseif event == "ZONE_CHANGED" or event == "ZONE_CHANGED_NEW_AREA" or event == "ZONE_CHANGED_INDOORS" then
    local rkey = getRegionKey()
    if rkey and rkey ~= f._currentRegionKey then
      if not DB.seen_regions[rkey] then DB.seen_regions[rkey] = true; TrySpeak("ZoneDiscovered") end
      TrySpeak(rkey)
      f._currentRegionKey = rkey
      scheduleRegionPing(rkey)
    end

  -- ===== Emotes (FR/EN, joueur uniquement) + hook ============================
  elseif event == "CHAT_MSG_TEXT_EMOTE" or event == "CHAT_MSG_EMOTE" then
    local msg, sender = ...
    local function playerMatchesSender(s)
      local myName, myRealm = UnitName("player"), GetRealmName()
      if not s or s=="" then return false end
      return (s==myName) or (myRealm and s==(myName.."-"..myRealm))
    end
    local function isSelfEmote(m,s)
      if playerMatchesSender(s) then return true end
      local l = norm(trim(m or "")); return l:find("^you%s") or l:find("^vous%s") or l:find("^vous%p")
    end
    if not isSelfEmote(msg, sender) then return end
    local l = norm(msg or "")
    for _, e in ipairs(getEmoteOrder()) do
      if matchAny(l, e.kw) then
        Emit(e.key)
        safe_fire(SRPWL._onChatEmote, {msg=msg, sender=sender, key=e.key}) -- <<< hook
        return
      end
    end
    TrySpeak("Emote_Any")
    safe_fire(SRPWL._onChatEmote, {msg=msg, sender=sender, key="Emote_Any"})

  -- ===== Combat log: kills + Soulstone + CC + morts groupe ===================
  elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
    local _, subEvent, _, srcGUID, _, _, _, destGUID, _, _, _, spellId, spellName = CombatLogGetCurrentEventInfo()
    if (subEvent == "PARTY_KILL" or subEvent == "UNIT_DIED") and srcGUID == UnitGUID("player") then lastKillAt = GetTime() end
    if (subEvent == "SPELL_CAST_SUCCESS" or subEvent == "SPELL_AURA_APPLIED") and srcGUID == UnitGUID("player") then
      local n = norm(spellName or "")
      if SOULSTONE_IDS[spellId] or matchAny(n, getKW().soulstone or {}) then
        if not throttle("SoulstoneApplied", 2.0) then log("Soulstone via CLEU ->", spellId, spellName or "?"); TrySpeak("SoulstoneApplied") end
      end
    end
    -- CC sur le joueur
    local CC = { STUNNED={408,1833,853,5211,46968,30283,24394,9005,20549,6770}, SHEEPED={118,12824,12825,12826,28271,28272,61305,61721,61780,161372,277792}, SILENCED={15487,1330,47476,31935,2139}, TRAPED={3355,203337,162480}, FEARED={5782,8122,5484,5246} }
    if destGUID and PLAYER_GUID and destGUID == PLAYER_GUID and subEvent == "SPELL_AURA_APPLIED" then
      local keyMap = {}; for k, ids in pairs(CC) do for _,id in ipairs(ids) do keyMap[id]=k end end
      local mapKey = keyMap[spellId]
      local keyToSpeak = (mapKey=="STUNNED" and "Stunned") or (mapKey=="SHEEPED" and "Sheeped") or (mapKey=="SILENCED" and "Silenced") or (mapKey=="TRAPED" and "Traped") or (mapKey=="FEARED" and "Feared") or nil
      if keyToSpeak and not throttle(keyToSpeak, 4.0, false) then TrySpeak(keyToSpeak) end
    end
    -- Mort d'un membre du groupe via CLEU
    if subEvent == "UNIT_DIED" and destGUID then
      if not announcedDead[destGUID] then
        local found=false; ForEachGroupUnit(function(u) if UnitGUID(u) == destGUID then found = true end end)
        if found then
          announcedDead[destGUID] = true
          if not throttle("PartyMemberDead", 5.0, true) then TrySpeak("PartyMemberDead") end
          -- hook group health déjà déclenché via CheckUnitHealth lors du prochain tick
        end
      end
    end

  elseif event == "LOOT_OPENED" then
    if not IN_INSTANCE and (fishingActive or (GetTime() - (lastFishingAt or 0)) < 8) then
      if not throttle("FishingCatch", 1.0) then TrySpeak("FishingCatch") end
      fishingActive = false
      return
    end
    local sinceKill = GetTime() - (lastKillAt or 0)
    if sinceKill < 10 then TrySpeak("OpeningCorpseLoot") else TrySpeak("GatheringLootChest") end

  -- ===== Casts (canal & normal) =============================================
  elseif event == "UNIT_SPELLCAST_CHANNEL_START" then
    local unit, _, spellID = ...
    if unit == "player" then
      local info = SpellInfo(spellID); local name = (info and info.name) or SpellName(spellID) or ""
      local n = norm(name)
      if isMountBySpell(spellID, name) then flagRecentMountCast(); return end
      if FISHING_IDS[spellID] or isFishingName(n) then
        fishingActive = true; if not throttle("FishingStarted", 1.0) then TrySpeak("FishingStarted") end; verifyFishingChannelSoon(); return
      end
      if not isWithinRecentMountWindow() then
        local CH_EAT={"mang","nourrit","food","feast","banqu","repas"}
        local CH_DRINK={"boi","drink","grog","lait","eau"}; if tostring(LOCALE):match("^fr") then table.insert(CH_DRINK,"the "); table.insert(CH_DRINK,"thé") end
        local function anyTok(t) for _,k in ipairs(t) do if n:find(k,1,true) then return true end end end
        if anyTok(CH_EAT) or anyTok(CH_DRINK) then if not throttle("EatingDrinking_cast", 2.0) then TrySpeak("EatingDrinking") end end
      end
    end
  elseif event == "UNIT_SPELLCAST_CHANNEL_UPDATE" then
    if ... == "player" then verifyFishingChannelSoon() end
  elseif event == "UNIT_SPELLCAST_CHANNEL_STOP" then
    local unit = ...
    if unit == "player" then fishingActive = false; lastFishingAt = GetTime() end
  elseif event == "UNIT_SPELLCAST_START" or event == "UNIT_SPELLCAST_SUCCEEDED" then
    local unit, _, spellID = ...
    if unit ~= "player" then return end
    local info = SpellInfo(spellID); local spellName = info and info.name or SpellName(spellID) or ""
    local n = norm(spellName)
    if isMountBySpell(spellID, spellName) then flagRecentMountCast(); return end
    if FISHING_IDS[spellID] or isFishingName(n) then
      if event == "UNIT_SPELLCAST_SUCCEEDED" then lastFishingAt = GetTime() else verifyFishingChannelSoon(); if not throttle("FishingStarted_start", 1.0) then TrySpeak("FishingStarted") end end
      return
    end
    if event == "UNIT_SPELLCAST_SUCCEEDED" and not isWithinRecentMountWindow() then
      local stems = {"eat","food","well fed","feast","banquet","picnic","refreshment","drink","grog","milk","water","nourri","nourrit","nourriture","repas","restaure","régén","regen","régénère","regener","biscuit","gateau","gâteau","riz","pain","rago","ragou","ragoût","ragout","bois","boisson","lait","eau"}; local nn=norm(spellName)
      for _,k in ipairs(stems) do if nn:find(k,1,true) then if not throttle("EatingDrinking_item", 2.0) then TrySpeak("EatingDrinking") end; break end end
    end
    if n:find("mining", 1, true) or n:find("minage", 1, true) then if not throttle("GatheringMine", 1.0) then TrySpeak("GatheringMine") end; return end
    if n:find("herb", 1, true) or n:find("herbor", 1, true) then if not throttle("GatheringHerb", 1.0) then TrySpeak("GatheringHerb") end; return end
    if n:find("skinn", 1, true) or n:find("depec", 1, true) or n:find("dépeç", 1, true) then if not throttle("GatheringSkin", 1.0) then TrySpeak("GatheringSkin") end; return end
    if n:find("cuis", 1, true) or n:find("cook", 1, true) then if not throttle("CookingStarted", 2.0) then TrySpeak("CookingStarted") end end

    local KW = getKW()
    local function any(t) return matchAny(n, t or {}) end
    if any(KW.hearthstone) then if not throttle("hearth", 1.5) then TrySpeak("HearthstoneUsed") end; return end
    if any(KW.healthstone) then if not throttle("healthstone", 1.5) then TrySpeak("HealthstoneUsed") end; return end
    if any(KW.soulstone)  then if not throttle("soulstone", 1.5)  then TrySpeak("SoulstoneApplied") end; return end
    if any(KW.summon_rit) then if not throttle("summon_ritual", 1.5) then TrySpeak("SummonStoneUsed") end; return end
    if any(KW.gateway)    then if not throttle("gateway", 1.5)    then TrySpeak("PortalUsed") end; return end
    if any(KW.potion)     then if not throttle("potion", 1.5)     then TrySpeak("PotionUsed") end; return end
    if any(KW.sum_demon)  then local gate=(event=="UNIT_SPELLCAST_START") and "sum_demon_start" or "sum_demon_succeeded"; if not throttle(gate, 1.0) then TrySpeak("DemonSummoned"); TrySpeak("PetResummoned") end; return end

    if event == "UNIT_SPELLCAST_START" then if isMajorStart(spellID, spellName) then FireSpellCastMajor(); return end
    else                                   if isMajorSucc (spellID, spellName) then FireSpellCastMajor(); return end
    end

  -- ===== Group / Raid ========================================================
  elseif event == "PARTY_INVITE_REQUEST" or event == "GROUP_INVITE_CONFIRMATION" then
    TrySpeak("GroupInvite")
  elseif event == "GROUP_ROSTER_UPDATE" then
    local inGroup = IsInGroup() or IsInRaid()
    if inGroup and not wasInGroup then TrySpeak("GroupJoined")
    elseif (not inGroup) and wasInGroup then TrySpeak("GroupLeft") end
    wasInGroup = inGroup
    C_Timer.After(0.2, ScanGroupHealth)
  elseif event == "READY_CHECK" then
    TrySpeak("ReadyCheck")

  -- ===== Craft/Quest/LFD =====================================================
  elseif event == "TRADE_SKILL_SHOW" then
    TrySpeak("CraftingStarted")
  elseif event == "CHAT_MSG_SYSTEM" then
    local msg = ...
    local l = norm(msg or "")
    if l:find("you create", 1, true) or l:find("vous cr", 1, true) then
      if not throttle("CraftingCompleted", 1.0) then TrySpeak("CraftingCompleted") end
    end
  elseif event == "QUEST_TURNED_IN" then
    Emit("QuestCompleted")
  elseif event == "LFG_QUEUE_STATUS_UPDATE" then
    if not throttle("DungeonQueueJoin", 180, true) then
      TrySpeak("DungeonQueueJoin")
      if IsDebug() and not UnitExists("pet") then log("LFG_QUEUE_STATUS_UPDATE fired but no pet exists at that instant.") end
    end

  -- ===== Auras ===============================================================
  elseif event == "UNIT_AURA" then
    local unit = ...; scanBuffsAndDetect(unit)

  -- ===== Trade (échange joueur) =============================================
  elseif event == "TRADE_SHOW" then
    if not throttle("Trade_Open", 1.0) then TrySpeak("Trade") end

  -- ===== Mort du joueur ======================================================
  elseif event == "PLAYER_DEAD" then
    C_Timer.After(1.0, function() if IsFullGroupDead() then Emit("RaidWipe") end end)

  -- ===== Pet back (flush file d'attente) ====================================
  elseif event == "UNIT_PET" then
    local unit = ...; if unit == "player" then C_Timer.After(0.3, FlushPending) end

  -- ===== Santé groupe en continu ============================================
  elseif event == "UNIT_HEALTH" then
    local unit = ...
    if unit and (unit:match("^party%d") or unit:match("^raid%d")) then CheckUnitHealth(unit) end
  end
end

-- ===== Register events =======================================================
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PLAYER_ENTERING_WORLD")

-- Montures: retail/MoP seulement
if Env.hasMountJournal then
  f:RegisterEvent("MOUNT_JOURNAL_USABILITY_CHANGED")
  f:RegisterEvent("COMPANION_UPDATE")
  f:RegisterEvent("NEW_MOUNT_ADDED")
end

f:RegisterEvent("PLAYER_MOUNT_DISPLAY_CHANGED")
f:RegisterEvent("PLAYER_LEVEL_UP")

f:RegisterEvent("MERCHANT_SHOW")
f:RegisterEvent("PLAYER_MONEY")
f:RegisterEvent("BANKFRAME_OPENED")
f:RegisterEvent("GUILDBANKFRAME_OPENED")
f:RegisterEvent("MAIL_SHOW")
f:RegisterEvent("AUCTION_HOUSE_SHOW")

-- Transmog: seulement si API présente (évite Era/BCC stricts)
if C_Transmog or WardrobeCollectionFrame or WardrobeFrame then
  pcall(function() f:RegisterEvent("TRANSMOGRIFY_OPEN") end)
end

f:RegisterEvent("TRAINER_SHOW")

f:RegisterEvent("PLAYER_UPDATE_RESTING")
f:RegisterEvent("ENCOUNTER_END")

f:RegisterEvent("ZONE_CHANGED")
f:RegisterEvent("ZONE_CHANGED_NEW_AREA")
f:RegisterEvent("ZONE_CHANGED_INDOORS")

f:RegisterEvent("CHAT_MSG_TEXT_EMOTE")
f:RegisterEvent("CHAT_MSG_EMOTE")
f:RegisterEvent("CHAT_MSG_LOOT")
f:RegisterEvent("CHAT_MSG_SYSTEM")

f:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
f:RegisterEvent("LOOT_OPENED")

f:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START")
f:RegisterEvent("UNIT_SPELLCAST_CHANNEL_UPDATE")
f:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
f:RegisterEvent("UNIT_SPELLCAST_START")
f:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")

f:RegisterEvent("PARTY_INVITE_REQUEST")
f:RegisterEvent("GROUP_INVITE_CONFIRMATION")
f:RegisterEvent("GROUP_ROSTER_UPDATE")
f:RegisterEvent("READY_CHECK")

f:RegisterEvent("TRADE_SKILL_SHOW")
f:RegisterEvent("QUEST_TURNED_IN")

-- LFG: évite Era strict — on enregistre seulement si le client l’a
if LFDQueueFrame or LFGDungeonReadyPopup or PVEFrame or C_LFGList then
  pcall(function() f:RegisterEvent("LFG_QUEUE_STATUS_UPDATE") end)
end

f:RegisterEvent("UNIT_AURA")
f:RegisterEvent("TRADE_SHOW")
f:RegisterEvent("PLAYER_DEAD")
f:RegisterEvent("UNIT_PET")

-- Santé groupe (Retail/Classic sûrs)
f:RegisterEvent("UNIT_HEALTH")

f:SetScript("OnEvent", OnEvent)

-- ===== Patch côté Core: préserver la clé de pet active ======================
do
  local _CoreTrySpeak = SRPWL.TrySpeak
  SRPWL.TrySpeak = function(eventKey)
    if SRPWL and SRPWL.GetPet and _CoreTrySpeak then
      local detected = (SRPWL.DetectActivePetKey and SRPWL.DetectActivePetKey()) or nil
      if detected and SRPWL.GetPet(detected) then
        -- Core gère PET_KEY_ACTIVE en interne
      end
    end
    _CoreTrySpeak(eventKey)
  end
end

-- ===== Hook public de test pour l'UI Options ================================
function SRPWL.TestRandomUtterance()
  local keys = { "imp", "voidwalker", "succubus", "sayaad", "felhunter", "felguard" }
  local k = keys[ math.random(#keys) ]
  local samples = {
    "Awaiting orders...","Shadows heed my call.","Your will, my command.",
    "The fel is restless.","I hunger for battle.","Another soul to tease..."
  }
  local line = samples[ math.random(#samples) ]
  local actions = { "Preview", "Test", "Demo" }
  local act = actions[ math.random(#actions) ]
  if SRPWL and SRPWL.ShowTalking then
    SRPWL.ShowTalking(k, nil, act, line, nil, (DB.talking_duration or 4.5))
  end
end
