// ─────────────────────────────────────────────────────────────
// NATIONAL VA LOANS — VIDEO AUDIO
// Narration clips plus a music bed that ducks under the voice.
//
// Mobile browsers stutter if several <audio> elements are seeked on
// every animation frame (the scene re-renders at display rate, and
// each seek flushes the decoder). This player decodes each file once
// and lets the audio thread play it. The picture can drop frames
// without chopping the sound. Playback is resynced only when the
// timeline actually jumps (scrub, skip, loop). Sound starts with the
// picture. If a browser holds the audio context until a tap, the first
// click inside the player resumes it without changing the Mute label.
// ─────────────────────────────────────────────────────────────
const audioBytes = new Map();
const audioBuffers = new Map();
let audioCtx = null;

function getAudioCtx() {
  const AC = window.AudioContext || window.webkitAudioContext;
  if (!AC) return null;
  if (audioCtx && audioCtx.state !== 'closed') return audioCtx;
  try {
    audioCtx = new AC({ latencyHint: 'playback' });
  } catch (err) {
    audioCtx = new AC();
  }
  return audioCtx;
}

function loadBytes(src) {
  let pending = audioBytes.get(src);
  if (!pending) {
    pending = fetch(src).then((res) => {
      if (!res.ok) throw new Error('audio ' + res.status);
      return res.arrayBuffer();
    });
    pending.catch(() => { audioBytes.delete(src); });
    audioBytes.set(src, pending);
  }
  return pending;
}

function loadBuffer(src) {
  let pending = audioBuffers.get(src);
  if (pending) return pending;
  const ctx = getAudioCtx();
  if (!ctx) return Promise.reject(new Error('no audio'));
  pending = loadBytes(src).then((bytes) => ctx.decodeAudioData(bytes.slice(0)));
  pending.catch(() => { audioBuffers.delete(src); });
  audioBuffers.set(src, pending);
  return pending;
}

function warmAudio(cues, music) {
  if (!getAudioCtx()) return;
  cues.forEach((cue) => { loadBuffer(cue.src).catch(() => {}); });
  if (music && music.src) loadBuffer(music.src).catch(() => {});
}

function unlockAudio() {
  const ctx = getAudioCtx();
  if (!ctx) return null;
  if (ctx.state !== 'running') {
    const resumed = ctx.resume();
    if (resumed && resumed.catch) resumed.catch(() => {});
  }
  try {
    const silence = ctx.createBuffer(1, 1, ctx.sampleRate);
    const ping = ctx.createBufferSource();
    ping.buffer = silence;
    ping.connect(ctx.destination);
    ping.start(0);
  } catch (err) {}
  return ctx;
}

function stopNode(ref) {
  const node = ref.current;
  ref.current = null;
  if (!node) return;
  try { node.onended = null; node.stop(); } catch (err) {}
  try { node.disconnect(); } catch (err) {}
}

function cueAt(cues, t) {
  for (let i = 0; i < cues.length; i++) {
    const cue = cues[i];
    if (t >= cue.at && t < cue.at + cue.len) return cue;
  }
  return null;
}

function cueKey(cue) {
  return cue ? cue.src + '@' + cue.at : '';
}

function Soundtrack({ cues = [], music, total }) {
  const { actualTime: t, playing, setPlaying } = useTimeline();
  const [on, setOn] = React.useState(true);
  const [slot, setSlot] = React.useState(null);
  // Bumps when the audio context actually starts running, so the engine
  // starts the soundtrack then — not while the browser still has it blocked.
  const [readyGen, setReadyGen] = React.useState(0);
  const onRef = React.useRef(false);
  const playingRef = React.useRef(playing);
  const tRef = React.useRef(t);
  const cuesRef = React.useRef(cues);
  const musicRef = React.useRef(music);
  const totalRef = React.useRef(total);
  onRef.current = on;
  playingRef.current = playing;
  tRef.current = t;
  cuesRef.current = cues;
  musicRef.current = music;
  totalRef.current = total;

  const musicGain = React.useRef(null);
  const voiceGain = React.useRef(null);
  const musicNode = React.useRef(null);
  const voiceNode = React.useRef(null);
  const engine = React.useRef({
    started: false,
    lastT: 0,
    lastNow: 0,
    cue: '',
    gain: -1,
    musicGen: 0,
    voiceGen: 0,
    seekTimer: 0,
  });

  React.useEffect(() => {
    setSlot(document.getElementById('vairrrl-sound-slot'));
    const srcs = cues.map((cue) => cue.src);
    if (music && music.src) srcs.push(music.src);
    srcs.forEach((src) => { loadBytes(src).catch(() => {}); });
    const ctx = unlockAudio();
    warmAudio(cues, music);
    const kick = () => {
      const live = getAudioCtx();
      if (live && live.state === 'running') setReadyGen((n) => n + 1);
    };
    if (ctx) ctx.addEventListener('statechange', kick);
    const resumed = ctx && ctx.resume();
    if (resumed && resumed.then) resumed.then(kick, () => {});
    kick();
    window.__nvlAudio = {
      isRunning: () => {
        const live = getAudioCtx();
        return !!(live && live.state === 'running');
      },
      unlock: () => {
        unlockAudio();
        const live = getAudioCtx();
        const done = () => { if (live && live.state === 'running') setReadyGen((n) => n + 1); };
        const pending = live && live.resume();
        if (pending && pending.then) pending.then(done, done);
        else done();
      },
    };
    const resumeIfBlocked = () => {
      if (!onRef.current || !window.__nvlAudio) return;
      // Record this before resume(), so the click that follows a blocked
      // autoplay keeps the picture playing instead of pausing it.
      window.__nvlAudio.wasBlocked = !window.__nvlAudio.isRunning();
      window.__nvlAudio.unlock();
    };
    window.addEventListener('pointerdown', resumeIfBlocked);
    window.addEventListener('keydown', resumeIfBlocked);
    return () => {
      window.removeEventListener('pointerdown', resumeIfBlocked);
      window.removeEventListener('keydown', resumeIfBlocked);
      if (ctx) ctx.removeEventListener('statechange', kick);
      if (window.__nvlAudio) delete window.__nvlAudio;
      const st = engine.current;
      if (st.seekTimer) clearTimeout(st.seekTimer);
      stopNode(musicNode);
      stopNode(voiceNode);
    };
  }, []);

  const arm = () => {
    if (onRef.current) return;
    unlockAudio();
    warmAudio(cuesRef.current, musicRef.current);
  };
  // The button is portaled into the control bar, but in React the click still
  // bubbles to the picture, which toggles play/pause. Stop it here so turning
  // sound on does not pause the video (and then immediately cut the audio).
  const keepPlaying = (event) => {
    event.stopPropagation();
  };

  React.useEffect(() => {
    const st = engine.current;
    const ctx = audioCtx;
    const now = performance.now();

    const ensureGains = () => {
      if (!ctx) return false;
      if (!musicGain.current) {
        const node = ctx.createGain();
        node.gain.value = 0;
        node.connect(ctx.destination);
        musicGain.current = node;
      }
      if (!voiceGain.current) {
        const node = ctx.createGain();
        node.gain.value = 1;
        node.connect(ctx.destination);
        voiceGain.current = node;
      }
      return true;
    };

    const startMusic = (time) => {
      const bed = musicRef.current;
      if (!bed || !ctx) return;
      st.musicGen += 1;
      const gen = st.musicGen;
      stopNode(musicNode);
      loadBuffer(bed.src).then((buffer) => {
        if (gen !== st.musicGen || !onRef.current || !playingRef.current) return;
        const src = ctx.createBufferSource();
        src.buffer = buffer;
        const rate = bed.loop ? 1 : (totalRef.current > 0 ? bed.len / totalRef.current : 1);
        src.playbackRate.value = rate;
        src.loop = !!bed.loop;
        src.connect(musicGain.current);
        const dur = buffer.duration || 0.01;
        let off = bed.loop ? time : time * rate;
        off = ((off % dur) + dur) % dur;
        if (off >= dur - 0.01) off = 0;
        try { src.start(0, off); } catch (err) { return; }
        musicNode.current = src;
      }).catch(() => {});
    };

    const startVoice = (cue, time) => {
      st.voiceGen += 1;
      const gen = st.voiceGen;
      const key = cueKey(cue);
      st.cue = key;
      stopNode(voiceNode);
      if (!cue || !ctx) return;
      const offset = Math.max(0, time - cue.at);
      loadBuffer(cue.src).then((buffer) => {
        if (gen !== st.voiceGen || !onRef.current || !playingRef.current) return;
        if (st.cue !== key) return;
        const dur = buffer.duration || 0;
        if (offset >= dur - 0.03) return;
        const src = ctx.createBufferSource();
        src.buffer = buffer;
        src.connect(voiceGain.current);
        const remain = Math.min(dur - offset, Math.max(0.05, cue.len - offset));
        try { src.start(0, offset, remain); } catch (err) { return; }
        voiceNode.current = src;
      }).catch(() => {});
    };

    const restartAt = (time) => {
      if (!ensureGains()) return;
      const cue = cueAt(cuesRef.current, time);
      startMusic(time);
      startVoice(cue, time);
    };

    const applyGain = (time) => {
      const bed = musicRef.current;
      if (!bed || !musicGain.current) return;
      const voiced = cuesRef.current.some((cue) => time >= cue.at - 0.2 && time <= cue.at + cue.len + 0.2);
      const fade = Math.min(clamp(time / 1, 0, 1), clamp((totalRef.current - time) / 3, 0, 1));
      const target = Math.round((voiced ? bed.duck : bed.full) * fade * 40) / 40;
      if (target === st.gain) return;
      st.gain = target;
      musicGain.current.gain.setTargetAtTime(target, ctx.currentTime, 0.06);
    };

    if (!on || !playing || !ctx) {
      if (st.seekTimer) { clearTimeout(st.seekTimer); st.seekTimer = 0; }
      if (st.started) {
        st.musicGen += 1;
        st.voiceGen += 1;
        stopNode(musicNode);
        stopNode(voiceNode);
      }
      st.started = false;
      st.cue = '';
      st.gain = -1;
      st.lastT = t;
      st.lastNow = now;
      return;
    }

    // A source started while the context is suspended stays silent even
    // after the browser later allows audio. Wait, then start at the
    // current picture time. readyGen retriggers this once it is running.
    void readyGen;
    if (ctx.state !== 'running') {
      if (!st.resumePending) {
        st.resumePending = true;
        const pending = ctx.resume();
        const finish = () => {
          st.resumePending = false;
          if (getAudioCtx()?.state === 'running') setReadyGen((n) => n + 1);
        };
        if (pending && pending.then) pending.then(finish, finish);
        else finish();
      }
      return;
    }
    ensureGains();
    applyGain(t);

    if (!st.started) {
      st.started = true;
      st.lastT = t;
      st.lastNow = now;
      restartAt(t);
      return;
    }

    const frameError = (t - st.lastT) - ((now - st.lastNow) / 1000);
    st.lastT = t;
    st.lastNow = now;
    // A hitch moves the picture and the audio clock together. A scrub,
    // skip, or loop wrap moves only the picture, and that is the only
    // time we restart the buffers.
    if (Math.abs(frameError) > 0.35) {
      if (st.seekTimer) clearTimeout(st.seekTimer);
      st.seekTimer = setTimeout(() => {
        st.seekTimer = 0;
        if (!onRef.current || !playingRef.current) return;
        restartAt(tRef.current);
      }, 90);
      return;
    }

    const cue = cueAt(cues, t);
    const key = cueKey(cue);
    if (key !== st.cue) startVoice(cue, t);
  });

  if (!cues.length && !music) return null;

  return slot ? ReactDOM.createPortal(
    <button
      type="button"
      onPointerDown={(event) => { keepPlaying(event); arm(); }}
      onTouchStart={(event) => { keepPlaying(event); arm(); }}
      onClick={(event) => {
        keepPlaying(event);
        const next = !on;
        if (next) arm();
        setOn(next);
        if (next && !playing) setPlaying(true);
      }}
      title={on ? 'Turn sound off' : 'Turn sound on'}
      style={{
        display: 'flex', alignItems: 'center', gap: 6,
        height: 28, padding: '0 10px', borderRadius: 999, cursor: 'pointer',
        background: on ? 'rgba(255,255,255,0.12)' : '#0B6FFC',
        color: '#fff',
        border: '1px solid rgba(255,255,255,0.18)',
        fontFamily: 'Inter, system-ui, sans-serif', fontSize: 12, fontWeight: 600,
        whiteSpace: 'nowrap', touchAction: 'manipulation',
      }}>
      <span style={{ width: 7, height: 7, borderRadius: 4, background: on ? '#1EC677' : '#fff', flexShrink: 0 }} />
      {on ? 'Mute' : 'Sound'}
    </button>,
    slot
  ) : null;
}

Object.assign(window, { Soundtrack });
