/**
 * scripts/test_tv_voz.js — node scripts/test_tv_voz.js
 *
 * Pruebas de la voz del televisor CONTRA EL CÓDIGO REAL: se extrae el bloque
 * de anuncios de display_global.php y se ejecuta con un sintetizador simulado.
 * Cubre los tres fallos intermitentes diagnosticados:
 *   1. recolector de basura llevándose la locución a mitad de frase
 *   2. motor atascado en pausa tras un cancel() (silencio total sin error)
 *   3. voz que nunca arranca (bloqueo o voz rota) dejando el cartel pegado
 */
const fs = require('fs');

let fallos = 0;
function ok(desc, cond) {
  console.log((cond ? '  ok    ' : '  FALLA ') + desc);
  if (!cond) fallos++;
}

// ── Extraer el código real ──────────────────────────────────────────────
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/display_global.php', 'utf8')
  .replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
const js  = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1];
const ini = js.indexOf('const VOCES_PREFERIDAS');
const fin = js.indexOf('/* ── Fullscreen ── */');
const cuerpo = 'let sonidoActivo=true,_vozES=null;function playBeep(){}'
             + js.slice(ini, fin);

ok('el código extraído contiene el anclaje anti-recolector', cuerpo.includes('_uttAncladas'));
ok('resume() se llama antes de cada speak()',
   /resume\(\);\s*\n\s*window\.speechSynthesis\.speak\(utt\)/.test(cuerpo));

ok('el despertador SSE está conectado (los llamados despiertan a la pestaña oculta)',
   js.includes("sse_turno.php?area=recepcion") && js.includes("addEventListener('cola_update'"));

// ── Armazón de simulación ───────────────────────────────────────────────
function escenario({ bloqueado = false, msArranque = 20, caida = false, atascoUnaVez = false } = {}) {
  let t = 0; const cola = [];
  const st = (fn, ms) => cola.push({ t: t + ms, fn });
  class Utt {
    constructor(x) { this.texto = x; this.l = {}; }
    addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
    emit(e, arg) { (this.l[e] || []).forEach(f => f(arg || {})); }
  }
  let resumeAntesDeSpeak = false, resumePendiente = false;
  let activa = null, totalSpeaks = 0;
  const synth = {
    speaking: false, pending: false,
    pause() {},
    // Como Chrome de verdad: cancelar una locución activa le dispara
    // 'error' con interrupted. Fue exactamente el comportamiento que el
    // simulador viejo no imitaba, y por eso no cazó el doble anuncio.
    cancel() {
      synth.speaking = synth.pending = false;
      const u = activa; activa = null;
      if (u) u.emit('error', { error: 'interrupted' });
    },
    resume() { resumePendiente = true; },
    getVoices: () => [{ name: 'Sabina', lang: 'es-MX', localService: true }],
    speak(u) {
      resumeAntesDeSpeak = resumePendiente; resumePendiente = false;
      totalSpeaks++;
      if (bloqueado || caida) return;   // descartado en silencio: ni pending queda
      if (atascoUnaVez && totalSpeaks === 1) {
        // El modo verificado en vivo (23/08): el motor JURA hablar
        // (speaking=true) pero no suena y 'start' no llega jamás.
        activa = u; synth.speaking = true;
        return;
      }
      activa = u;
      synth.pending = true;
      st(() => { if (activa !== u) return; synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque);
      st(() => { if (activa !== u) return; synth.speaking = false; activa = null; u.emit('end'); }, msArranque + 4000);
    },
  };
  const win = { speechSynthesis: synth };
  const beacons = [];
  const sandbox = new Function(
    'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
    'localStorage', 'navigator', 'Blob', 'Date', 'document',
    cuerpo + '; return { anunciarTurno, _uttAncladas };'
  );
  const fakeDate = { now: () => t };
  fakeDate.prototype = Date.prototype;
  const api = sandbox(
    win, st, () => {}, Utt,
    { getItem: () => '[]', setItem: () => {} },
    { sendBeacon: (u, b) => beacons.push(u) },
    function Blob() {},
    Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
    { addEventListener: () => {}, hidden: false }
  );
  const correr = (hastaMs) => {
    let n = 0;
    while (cola.length && n++ < 500) {
      cola.sort((a, b) => a.t - b.t);
      const e = cola.shift();
      if (hastaMs !== undefined && e.t > hastaMs) { cola.unshift(e); break; }
      t = e.t; e.fn();
    }
  };
  return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t,
           speaks: () => totalSpeaks, hablando: () => activa };
}

// ── 1. Anclaje: la locución queda referenciada mientras habla ───────────
{
  const e = escenario();
  e.api.anunciarTurno('A01', 'Consultorio 2', null, () => {});
  e.correr(800);   // ya se llamó a speak()
  ok('durante el habla la locución está anclada (no la puede recoger el GC)',
     e.api._uttAncladas.size === 1);
  e.correr();      // hasta el final
  ok('al terminar se suelta el anclaje (sin fuga de memoria)',
     e.api._uttAncladas.size === 0);
}

// ── 2. resume() antes de speak: motor atascado en pausa ─────────────────
{
  const e = escenario();
  e.api.anunciarTurno('A02', 'Consultorio 1', null, () => {});
  e.correr();
  ok('speak() siempre va precedido de resume() (destranca el motor pausado)',
     e.resumeUsado());
}

// ── 3. Audio bloqueado: avisa para que el cartel no quede pegado ────────
{
  const e = escenario({ bloqueado: true });
  let aviso = null;
  e.api.anunciarTurno('A03', 'Consultorio 3', null, () => { aviso = e.t(); });
  e.correr();
  ok('con el audio bloqueado igual avisa y el cartel se puede cerrar',
     aviso !== null && aviso < 10000);
  ok('el anclaje también se suelta cuando no llegó a hablar',
     e.api._uttAncladas.size === 0);
}

// ── 4. Voz lenta (remota): no se corta ──────────────────────────────────
{
  const e = escenario({ msArranque: 2800 });
  let termino = false;
  e.api.anunciarTurno('A04', 'Consultorio 4', 'maría gómez', () => { termino = true; });
  e.correr();
  ok('una voz que tarda 2,8 s en arrancar habla completa y termina', termino);
}

// ── 5. La bitácora registra start y end ─────────────────────────────────
{
  const e = escenario();
  e.api.anunciarTurno('A05', 'Consultorio 5', null, () => {});
  e.correr();
  ok('la bitácora mandó al menos 2 eventos (start y end) al servidor',
     e.beacons.length >= 2);
}

// ── 5b. La voz no arranca: reintenta UNA vez, sin doble anuncio ─────────
// El defecto que esto vigila: cancel() dispara 'error' en la locución vieja,
// y sin el token de generación ese handler lanzaba OTRO reintento: dos voces
// superpuestas diciendo lo mismo, de forma intermitente.
{
  // Voz que queda pendiente pero jamás arranca (pending eterno no: el sim
  // bloqueado ni encola — usamos arranque infinito quitando la cola de start)
  // Modo real de Chrome: el speak se descarta EN SILENCIO (ni speaking ni
  // pending). Ese es el caso que el vigilante reintenta. El otro modo —voz
  // eternamente en pending— se tolera a propósito: es indistinguible de una
  // voz remota lenta, y cortarla fue justo el defecto del entrecortado.
  const e = escenario({ caida: true });
  let avisos = 0;
  e.api.anunciarTurno('C01', 'Consultorio 1', null, () => { avisos++; });
  e.correr(30000);
  ok('voz descartada en silencio: exactamente 2 speak (original + 1 reintento), no más',
     e.speaks() === 2);
  ok('y el cartel recibe exactamente UN aviso de cierre (vía «mudo»)', avisos === 1);
}

// ── 5c. Un anuncio nuevo que cancela al anterior no lo resucita ─────────
{
  const e = escenario({ msArranque: 20 });
  let avisosA = 0;
  e.api.anunciarTurno('D01', 'Consultorio 1', null, () => { avisosA++; });
  e.correr(1000);                    // D01 está hablando
  const speaksAntes = e.speaks();
  e.api.anunciarTurno('D02', 'Consultorio 2', null, () => {});
  e.correr();
  ok('cancelar al anterior con un anuncio nuevo no dispara reintentos fantasma del viejo',
     e.speaks() === speaksAntes + 1);
}

// ── 5d. MOTOR ATASCADO: speaking sin 'start' → romperlo y rehablar ──────
// Reproducido en vivo el 23/08: primer llamado tras el ocio, el motor queda
// en speaking=true sin audio. El vigilante debe detectarlo (una locución que
// de verdad suena dispara 'start' de inmediato), romperlo y rehablar — una
// sola vez, sin dobles.
{
  const e = escenario({ atascoUnaVez: true });
  let avisos = 0;
  e.api.anunciarTurno('F01', 'Consultorio 1', null, () => { avisos++; });
  e.correr();
  ok('el atasco se detecta y el reintento habla: exactamente 2 speak',
     e.speaks() === 2);
  ok('el llamado termina avisando UNA vez (el reintento habló completo)',
     avisos === 1);
  ok('sin locuciones ancladas al final (sin fugas tras el atasco)',
     e.api._uttAncladas.size === 0);
}

// ── 5e. Keepalive mudo: el motor recibe trabajo inaudible en el ocio ────
// pause/resume no mantiene despierto al motor (verificado en vivo); hablar a
// volumen cero sí. Este caso vigila que el keepalive exista y sea inaudible.
{
  const intervalos = [];
  let t = 0; const cola = [];
  const st = (fn, ms) => cola.push({ t: t + ms, fn });
  class Utt { constructor(x) { this.texto = x; this.l = {}; } addEventListener() {} }
  let hablado = null;
  const synth = { speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
                  getVoices: () => [], speak(u) { hablado = u; } };
  new Function('window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
    'localStorage', 'navigator', 'Blob', 'Date', 'document',
    cuerpo + ';')(
    { speechSynthesis: synth }, st, (fn, ms) => intervalos.push({ fn, ms }), Utt,
    { getItem: () => '[]', setItem: () => {} }, { sendBeacon: () => {} },
    function Blob() {},
    Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => 0 }),
    { addEventListener: () => {}, hidden: false });
  const ka = intervalos.find(i => i.ms === 25000);
  ok('existe el keepalive de 25 s', !!ka);
  if (ka) ka.fn();
  ok('en el ocio habla una letra a VOLUMEN CERO (inaudible, pero trabajo real)',
     hablado !== null && hablado.volume === 0);
  ok('con el motor ocupado el keepalive no interfiere', (() => {
     hablado = null; synth.speaking = true; if (ka) ka.fn(); return hablado === null;
  })());
}

// ── 6. El camino feliz: habla bien ──────────────────────────────────────
// Qué dice exactamente, y con qué voz. Un anuncio que "funciona" pero
// pronuncia mal el nombre o elige la voz equivocada también es un fallo.
{
  // Instrumentación directa: escenario propio con captura de la locución
  let t = 0; const cola = [];
  const st = (fn, ms) => cola.push({ t: t + ms, fn });
  class Utt {
    constructor(x) { this.texto = x; this.l = {}; }
    addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
    emit(e, a) { (this.l[e] || []).forEach(f => f(a || {})); }
  }
  let capturada = null;
  const synth = {
    speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
    getVoices: () => [
      { name: 'Microsoft Sabina', lang: 'es-MX', localService: true },
      { name: 'Google español de Colombia', lang: 'es-CO', localService: false },
    ],
    speak(u) {
      capturada = u;
      st(() => u.emit('start'), 10);
      st(() => u.emit('end'), 4000);
    },
  };
  const api = new Function(
    'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
    'localStorage', 'navigator', 'Blob', 'Date', 'document',
    cuerpo + '; return { anunciarTurno };'
  )({ speechSynthesis: synth }, st, () => {}, Utt,
    { getItem: () => '[]', setItem: () => {} },
    { sendBeacon: () => {} }, function Blob() {},
    Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
    { addEventListener: () => {}, hidden: false });

  api.anunciarTurno('A01', 'Consultorio 2', 'maría fernanda gómez', () => {});
  let n = 0;
  while (cola.length && n++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }

  ok('el texto deletrea el código y termina con el destino',
     capturada && capturada.texto === 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2');
  ok('los nombres con tilde salen bien capitalizados (María, no MaríA)',
     capturada && capturada.texto.includes('María Fernanda Gómez'));
  ok('elige la voz LOCAL (Sabina) aunque la remota es-CO esté disponible',
     capturada && capturada.voice && capturada.voice.name === 'Microsoft Sabina');
  ok('sin nombre de paciente igual habla: solo código y destino', (() => {
     capturada = null;
     api.anunciarTurno('B02', 'Recepción 1', null, () => {});
     let m = 0;
     while (cola.length && m++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
     return capturada && capturada.texto === 'Turno B 0 2, pase a Recepción 1';
  })());
}

console.log();
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
process.exit(fallos === 0 ? 0 : 1);
