// audio.jsx
// Procedural SFX engine using Web Audio API. Opt-in.
//
// Sounds:
//   - transformer:  low-end hum (intro), saw + filtered noise
//   - woosh:        electric transition sweep
//   - blip:         UI click / Y-N choice
//   - hover:        subtle tick
//   - ambient:      slow evolving drone (background)

(function () {
  let ctx = null;
  let masterGain = null;
  let ambientPlaying = false;
  let enabled = false;
  const sampleEls = {};               // cached one-shot <audio> elements (cells / imafraid)
  const MASTER_CEIL = 0.55;          // masterGain at volume = 1.0
  let masterVol = (() => {
    try { const v = parseFloat(localStorage.getItem('dys_volume')); return isNaN(v) ? 0.8 : v; }
    catch { return 0.8; }
  })();

  function ensureCtx() {
    if (ctx) return ctx;
    const AudioCtx = window.AudioContext || window.webkitAudioContext;
    if (!AudioCtx) return null;
    ctx = new AudioCtx();
    masterGain = ctx.createGain();
    masterGain.gain.value = MASTER_CEIL * masterVol;
    masterGain.connect(ctx.destination);
    return ctx;
  }

  function envelope(gainNode, atk, sus, rel, peak = 1) {
    const now = ctx.currentTime;
    gainNode.gain.cancelScheduledValues(now);
    gainNode.gain.setValueAtTime(0, now);
    gainNode.gain.linearRampToValueAtTime(peak, now + atk);
    gainNode.gain.setValueAtTime(peak, now + atk + sus);
    gainNode.gain.exponentialRampToValueAtTime(0.0001, now + atk + sus + rel);
  }

  function noiseBuffer(durSec) {
    const len = Math.floor(ctx.sampleRate * durSec);
    const buf = ctx.createBuffer(1, len, ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
    return buf;
  }

  // ============================================================
  // SFX — one-shot sounds
  // ============================================================

  function transformer() {
    if (!enabled || !ensureCtx()) return;
    const dur = 2.8;
    // Low saw — the hum
    const osc = ctx.createOscillator();
    osc.type = 'sawtooth';
    osc.frequency.setValueAtTime(50, ctx.currentTime);
    osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + 0.6);
    osc.frequency.exponentialRampToValueAtTime(55, ctx.currentTime + dur);

    // Slight detuned saw — beats with first
    const osc2 = ctx.createOscillator();
    osc2.type = 'sawtooth';
    osc2.frequency.setValueAtTime(50.4, ctx.currentTime);
    osc2.frequency.exponentialRampToValueAtTime(80.6, ctx.currentTime + 0.6);
    osc2.frequency.exponentialRampToValueAtTime(55.4, ctx.currentTime + dur);

    const filter = ctx.createBiquadFilter();
    filter.type = 'lowpass';
    filter.frequency.value = 280;
    filter.Q.value = 6;

    // Noise crackle on top
    const noise = ctx.createBufferSource();
    noise.buffer = noiseBuffer(dur);
    const noiseGain = ctx.createGain();
    noiseGain.gain.value = 0.04;
    const noiseFilter = ctx.createBiquadFilter();
    noiseFilter.type = 'bandpass';
    noiseFilter.frequency.value = 900;
    noiseFilter.Q.value = 3;
    noise.connect(noiseFilter);
    noiseFilter.connect(noiseGain);

    const sumGain = ctx.createGain();
    osc.connect(filter);
    osc2.connect(filter);
    filter.connect(sumGain);
    noiseGain.connect(sumGain);

    sumGain.connect(masterGain);

    envelope(sumGain, 0.35, dur - 1.1, 0.7, 0.45);
    osc.start();
    osc2.start();
    noise.start();
    osc.stop(ctx.currentTime + dur + 0.1);
    osc2.stop(ctx.currentTime + dur + 0.1);
    noise.stop(ctx.currentTime + dur + 0.1);
  }

  function woosh() {
    if (!enabled || !ensureCtx()) return;
    const dur = 0.55;
    // Filtered noise sweep
    const noise = ctx.createBufferSource();
    noise.buffer = noiseBuffer(dur);
    const filter = ctx.createBiquadFilter();
    filter.type = 'bandpass';
    filter.frequency.setValueAtTime(220, ctx.currentTime);
    filter.frequency.exponentialRampToValueAtTime(3800, ctx.currentTime + dur * 0.7);
    filter.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + dur);
    filter.Q.value = 8;

    const gain = ctx.createGain();
    noise.connect(filter);
    filter.connect(gain);
    gain.connect(masterGain);
    envelope(gain, 0.04, 0.18, 0.35, 0.35);
    noise.start();
    noise.stop(ctx.currentTime + dur + 0.05);
  }

  function blip(freq = 880) {
    if (!enabled || !ensureCtx()) return;
    const dur = 0.08;
    const osc = ctx.createOscillator();
    osc.type = 'square';
    osc.frequency.setValueAtTime(freq, ctx.currentTime);
    osc.frequency.exponentialRampToValueAtTime(freq * 0.5, ctx.currentTime + dur);

    const filter = ctx.createBiquadFilter();
    filter.type = 'lowpass';
    filter.frequency.value = 2400;

    const gain = ctx.createGain();
    osc.connect(filter);
    filter.connect(gain);
    gain.connect(masterGain);
    envelope(gain, 0.005, 0.02, 0.06, 0.18);
    osc.start();
    osc.stop(ctx.currentTime + dur + 0.02);
  }

  function tick() {
    if (!enabled || !ensureCtx()) return;
    const osc = ctx.createOscillator();
    osc.type = 'triangle';
    osc.frequency.value = 1800;
    const gain = ctx.createGain();
    osc.connect(gain);
    gain.connect(masterGain);
    envelope(gain, 0.002, 0.005, 0.025, 0.08);
    osc.start();
    osc.stop(ctx.currentTime + 0.05);
  }

  // ============================================================
  // AMBIENT — slow evolving drone
  // ============================================================

  // Recorded ambient bed (SDB_ambient), decoded into an AudioBuffer and looped
  // with an EQUAL-POWER CROSSFADE: each pass fades its tail out while the next
  // pass fades its head in over the same window. Because head and tail are the
  // same material, the seam dissolves — sounds like an infinite loop even
  // though the source file isn't loop-prepared. No re-export needed.
  const AMBIENT_PEAK = 0.5;     // ceiling for the bed inside the mix
  const AMBIENT_FADE = 4;       // first fade-in (seconds)
  const XFADE = 2.2;            // crossfade window at the loop seam (seconds)

  let ambientBuffer = null;     // decoded AudioBuffer (cached)
  let ambientBus = null;        // GainNode: all ambient passes sum here → analyser → masterGain
  let analyser = null;          // taps the ambient bed to drive the waveform
  let ambientLoading = null;    // in-flight decode promise
  let xfTimer = null;           // schedules the next crossfade pass

  function ensureAnalyser() {
    if (analyser || !ensureCtx()) return analyser;
    analyser = ctx.createAnalyser();
    analyser.fftSize = 1024;
    analyser.smoothingTimeConstant = 0.82;
    return analyser;
  }

  async function loadAmbientBuffer() {
    if (ambientBuffer) return ambientBuffer;
    if (ambientLoading) return ambientLoading;
    ambientLoading = (async () => {
      const tryFetch = async (url) => {
        const res = await fetch(url);
        const arr = await res.arrayBuffer();
        return await ctx.decodeAudioData(arr);
      };
      // iOS/Safari can't decode WebM/Opus via decodeAudioData — go straight to
      // mp3 there so we don't waste a fetch+decode that's guaranteed to fail
      // (and risk leaving the bed silent). Everywhere else WebM is smaller, so
      // prefer it and keep mp3 as the fallback.
      let probe;
      try { probe = document.createElement('audio'); } catch { probe = null; }
      const webmOk = probe && probe.canPlayType && probe.canPlayType('audio/webm');
      const order = webmOk
        ? [['ambientWebm', 'assets/audio/ambient.webm'], ['ambientMp3', 'assets/audio/ambient.mp3']]
        : [['ambientMp3', 'assets/audio/ambient.mp3'], ['ambientWebm', 'assets/audio/ambient.webm']];
      for (const [id, path] of order) {
        try { ambientBuffer = await tryFetch(window.__res(id, path)); break; }
        catch { /* try next source */ }
      }
      return ambientBuffer;
    })();
    return ambientLoading;
  }

  // Schedule one playback pass starting at audio-clock time `startAt`, with an
  // equal-power fade-in head and a fade-out tail, then queue the next pass to
  // begin XFADE before this one ends.
  function schedulePass(startAt, firstFade) {
    if (!ambientPlaying || !ambientBuffer) return;
    const dur = ambientBuffer.duration;
    const src = ctx.createBufferSource();
    src.buffer = ambientBuffer;
    const g = ctx.createGain();
    src.connect(g);
    g.connect(ambientBus);

    const fadeIn = firstFade ? AMBIENT_FADE : XFADE;
    // equal-power-ish fade in
    g.gain.setValueAtTime(0.0001, startAt);
    g.gain.linearRampToValueAtTime(1, startAt + fadeIn);
    // hold, then fade out the tail
    const tailStart = startAt + dur - XFADE;
    g.gain.setValueAtTime(1, Math.max(startAt + fadeIn, tailStart));
    g.gain.linearRampToValueAtTime(0.0001, startAt + dur);

    src.start(startAt);
    src.stop(startAt + dur + 0.05);

    // next pass overlaps the tail
    const nextAt = startAt + dur - XFADE;
    const delayMs = Math.max(0, (nextAt - ctx.currentTime - 0.4) * 1000);
    clearTimeout(xfTimer);
    xfTimer = setTimeout(() => schedulePass(nextAt, false), delayMs);
  }

  function startAmbient() {
    if (!enabled || !ensureCtx() || ambientPlaying) return;
    ambientPlaying = true;

    // Build the ambient bus: passes → ambientBus → analyser(tap) + → masterGain
    ambientBus = ctx.createGain();
    ambientBus.gain.value = AMBIENT_PEAK;
    ambientBus.connect(masterGain);
    ensureAnalyser();
    if (analyser) ambientBus.connect(analyser); // analyser is a sink-tap, no further routing

    loadAmbientBuffer().then(() => {
      if (!ambientPlaying) return;
      schedulePass(ctx.currentTime + 0.05, true);
    }).catch(() => { ambientPlaying = false; });
  }

  function stopAmbient() {
    if (!ambientPlaying) return;
    ambientPlaying = false;
    clearTimeout(xfTimer);
    if (ambientBus) {
      const t = ctx.currentTime;
      try {
        ambientBus.gain.cancelScheduledValues(t);
        ambientBus.gain.setValueAtTime(ambientBus.gain.value, t);
        ambientBus.gain.linearRampToValueAtTime(0.0001, t + 1.0);
      } catch {}
      const bus = ambientBus;
      setTimeout(() => { try { bus.disconnect(); } catch {} }, 1200);
    }
    ambientBus = null;
  }

  // RMS level 0..1 from the ambient analyser — drives the ghost waveform.
  let levelBuf = null;
  function getLevel() {
    if (!analyser) return 0;
    if (!levelBuf) levelBuf = new Uint8Array(analyser.fftSize);
    analyser.getByteTimeDomainData(levelBuf);
    let sum = 0;
    for (let i = 0; i < levelBuf.length; i++) {
      const v = (levelBuf[i] - 128) / 128;
      sum += v * v;
    }
    const rms = Math.sqrt(sum / levelBuf.length);
    return Math.min(1, rms * 3.2); // scale up — ambient is quiet
  }

  // iOS/Safari unlock. WebAudio stays suspended on touch devices until the
  // context is resumed inside a *reliable* user gesture — and crucially,
  // resume() is ASYNC, so the context is still 'suspended' on the line right
  // after you call it. The previous version checked state synchronously, always
  // saw 'suspended', and demanded another gesture — which is exactly why audio
  // only woke up after a few taps on iOS. Now we (a) resume + play a silent
  // buffer on every gesture, and (b) listen for the context's own
  // 'statechange' to tell us when it has actually reached 'running', then stop.
  let unlockArmed = false;
  function kickUnlock() {
    const c = ensureCtx();
    if (!c) return;
    if (c.state === 'suspended') { try { c.resume(); } catch {} }
    try {
      const buf = c.createBuffer(1, 1, 22050);
      const s = c.createBufferSource();
      s.buffer = buf;
      s.connect(c.destination);
      s.start(0);
    } catch {}
  }
  function primeUnlock() {
    const c = ensureCtx();
    if (!c) return;
    kickUnlock();                       // immediate attempt (enough on Android/desktop)
    if (c.state === 'running' || unlockArmed) return;
    unlockArmed = true;
    const evs = ['touchend', 'pointerup', 'mousedown', 'click', 'keydown'];
    const onGesture = () => { if (c.state !== 'running') kickUnlock(); };
    const finish = () => {
      evs.forEach((ev) => window.removeEventListener(ev, onGesture, true));
      unlockArmed = false;
    };
    c.addEventListener('statechange', function sc() {
      if (c.state === 'running') { c.removeEventListener('statechange', sc); finish(); }
    });
    evs.forEach((ev) => window.addEventListener(ev, onGesture, { capture: true }));
  }

  // ============================================================
  // PUBLIC API
  // ============================================================
  const Audio = {
    enable() {
      const c = ensureCtx();
      if (!c) return;
      // ALWAYS (re)attempt the unlock — even if logically already enabled — so a
      // reliable gesture (e.g. the intro ENTER click) can finish an unlock that
      // an earlier flaky pointerdown started but failed to complete on iOS.
      primeUnlock();
      if (enabled) return;
      enabled = true;
      startAmbient();
    },
    disable() {
      if (!enabled) return;
      enabled = false;
      stopAmbient();
    },
    toggle() {
      if (enabled) Audio.disable();
      else Audio.enable();
      return enabled;
    },
    isEnabled: () => enabled,
    // Master volume 0..1 — persisted, works on every page even before audio is on.
    setVolume(v) {
      masterVol = Math.max(0, Math.min(1, v));
      try { localStorage.setItem('dys_volume', String(masterVol)); } catch {}
      if (masterGain) {
        const t = ctx.currentTime;
        masterGain.gain.cancelScheduledValues(t);
        masterGain.gain.setTargetAtTime(MASTER_CEIL * masterVol, t, 0.05);
      }
    },
    getVolume: () => masterVol,
    // One-shot sample player for easter-egg voice clips (cells / imafraid).
    // Uses an HTMLAudioElement (decodes on iOS where the procedural bed needs
    // unlocking) so a deliberate trigger — copy click, Ctrl+C — always pays off.
    // Independent of the ambient toggle, but silent at volume 0 so a muted
    // visitor stays muted. Cached per-name; overlapping triggers restart it.
    playSample(name) {
      if (masterVol <= 0) return;
      try {
        if (!sampleEls[name]) {
          const el = document.createElement('audio');
          el.preload = 'auto';
          const webm = window.__res ? window.__res(name + 'Webm', 'assets/audio/' + name + '.webm') : 'assets/audio/' + name + '.webm';
          const mp3 = window.__res ? window.__res(name + 'Mp3', 'assets/audio/' + name + '.mp3') : 'assets/audio/' + name + '.mp3';
          const canWebm = el.canPlayType && el.canPlayType('audio/webm');
          el.src = canWebm ? webm : mp3;
          sampleEls[name] = el;
        }
        const a = sampleEls[name];
        a.volume = Math.max(0, Math.min(1, masterVol));
        a.currentTime = 0;
        const p = a.play();
        if (p && p.catch) p.catch(() => {});
      } catch {}
    },
    // RMS level 0..1 from the ambient bed — drives the ghost waveform.
    getLevel,
    isAmbient: () => ambientPlaying,
    // Suspend/restore the ambient bed WITHOUT flipping `enabled` — lets the
    // Story (2226) page run its score standalone, then bring ambient back on
    // exit, while the global SFX toggle state stays in sync.
    suspendAmbient() { if (ambientPlaying) stopAmbient(); },
    resumeAmbient() { if (enabled && !ambientPlaying) startAmbient(); },
    transformer, woosh, blip, tick,
  };

  window.Audio = Audio;
})();
