/* Weather Squatch — broadcast: fetch, audio playback, mouth-sync, captions timing.
   - fetchBroadcast() hits the real same-origin endpoint, falling back to the
     offline mock so the full state machine is playable here. 404s are surfaced.
   - Broadcast drives playback in one of three modes:
       audio : real mp3 (base64) → AnalyserNode RMS → mouth   (production path)
       tts   : SpeechSynthesis voice + procedural mouth + boundary-synced captions
       timed : silent virtual clock + procedural mouth + time-synced captions
   It exposes mouthValue (0..1, smoothed downstream), revealedCount, and onended. */

async function fetchBroadcast(zip) {
  const mockWithLatency = async () => {
    await new Promise((r) => setTimeout(r, 2600 + Math.random() * 1800)); // simulate live generation
    return window.mockBroadcast(zip); // may throw its own 404 (e.g. 99999)
  };
  let res;
  try {
    res = await fetch(`${window.BROADCAST_ENDPOINT}?zip=${encodeURIComponent(zip)}`, { headers: { Accept: "application/json" } });
  } catch (e) {
    if (window.WS_FORCE_REAL) throw e;        // strict mode: surface network error
    return mockWithLatency();                 // endpoint unreachable → offline mock
  }
  const ct = (res.headers.get("content-type") || "").toLowerCase();
  const isJson = ct.includes("application/json");
  // Genuine "place not found" only when the API itself answers 404 with JSON.
  if (res.status === 404 && isJson) { const e = new Error("ZIP not found"); e.status = 404; throw e; }
  if (res.ok && isJson) {
    const data = await res.json();
    if (data && data.script) return data;
  }
  // Endpoint absent (dev server text/html 404) or unexpected response → mock.
  if (window.WS_FORCE_REAL) { const e = new Error("HTTP " + res.status); e.status = res.status; throw e; }
  return mockWithLatency();
}

function getAudioCtx() {
  if (!window.__wsAudioCtx) {
    const AC = window.AudioContext || window.webkitAudioContext;
    if (AC) window.__wsAudioCtx = new AC();
  }
  const ctx = window.__wsAudioCtx;
  if (ctx && ctx.state === "suspended") ctx.resume().catch(() => {});
  return ctx;
}

function b64ToBlob(b64, type = "audio/mpeg") {
  const clean = b64.includes(",") ? b64.split(",").pop() : b64;
  const bin = atob(clean);
  const u8 = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
  return new Blob([u8], { type });
}

class Broadcast {
  constructor({ script, audioBase64, useTTS = true }) {
    this.script = script || "";
    this.words = this.script.split(/\s+/).filter(Boolean);
    this.audioBase64 = audioBase64 || null;
    this.useTTS = useTTS;
    this.revealedCount = 0;
    this._mouth = 0;
    this.duration = Math.max(4, this.words.length * 0.34);
    this.onended = null;
    this._ended = false;
    this._startedAt = 0;
    this.mode = this.audioBase64 ? "audio" : (useTTS && "speechSynthesis" in window ? "tts" : "timed");
    this._raf = null;
  }

  async prepare() {
    if (this.mode !== "audio") return;
    const ctx = getAudioCtx();
    this.audio = new Audio();
    this.audio.crossOrigin = "anonymous";
    this.audio.src = URL.createObjectURL(b64ToBlob(this.audioBase64));
    this.audio.preload = "auto";
    await new Promise((res) => {
      this.audio.addEventListener("loadedmetadata", () => { this.duration = this.audio.duration || this.duration; res(); }, { once: true });
      this.audio.addEventListener("error", res, { once: true });
      setTimeout(res, 1500);
    });
    if (ctx) {
      this.analyser = ctx.createAnalyser();
      this.analyser.fftSize = 512;
      this._buf = new Uint8Array(this.analyser.fftSize);
      try {
        this.srcNode = ctx.createMediaElementSource(this.audio);
        this.srcNode.connect(this.analyser);
        this.analyser.connect(ctx.destination);
      } catch (e) { this.analyser = null; }
    }
  }

  start() {
    this._startedAt = performance.now();
    this._ended = false;
    if (this.mode === "audio") {
      this.audio.addEventListener("ended", () => this._finish(), { once: true });
      this.audio.play().catch(() => { this.mode = "timed"; this._loopTimed(); });
    } else if (this.mode === "tts") {
      this._startTTS();
    } else {
      this._loopTimed();
    }
    this._tick();
  }

  _startTTS() {
    const u = new SpeechSynthesisUtterance(this.script);
    u.rate = 0.96; u.pitch = 0.8; u.volume = 1;
    // Prefer a deep/male-ish voice if available.
    const voices = window.speechSynthesis.getVoices() || [];
    const pick = voices.find(v => /male|daniel|fred|alex|david|google uk english male/i.test(v.name)) || voices.find(v => /en[-_]/i.test(v.lang));
    if (pick) u.voice = pick;
    this._lastBoundary = performance.now();
    u.onboundary = (e) => {
      if (e.name === "word" || e.charIndex != null) {
        // Map charIndex → word count.
        const upto = this.script.slice(0, e.charIndex).split(/\s+/).filter(Boolean).length;
        this.revealedCount = Math.max(this.revealedCount, upto);
        this._lastBoundary = performance.now();
        this._pulse = 1;
      }
    };
    u.onend = () => this._finish();
    u.onerror = () => { this.mode = "timed"; this._loopTimed(); };
    // Fallback: if no boundary events fire shortly, switch to timed reveal.
    this._ttsFallbackCheck = setTimeout(() => {
      if (this.revealedCount === 0) { this._ttsTimed = true; }
    }, 1200);
    try { window.speechSynthesis.cancel(); window.speechSynthesis.speak(u); }
    catch (e) { this.mode = "timed"; this._loopTimed(); }
  }

  _loopTimed() { /* nothing extra; _tick handles reveal + mouth */ }

  _tick() {
    const frame = () => {
      if (this._ended) return;
      const now = performance.now();
      const elapsed = (now - this._startedAt) / 1000;

      if (this.mode === "audio") {
        const t = this.audio ? this.audio.currentTime : elapsed;
        this.revealedCount = Math.min(this.words.length, Math.round((t / this.duration) * this.words.length));
        this._mouth = this._readAnalyser();
      } else if (this.mode === "tts") {
        if (this._ttsTimed) {
          this.revealedCount = Math.min(this.words.length, Math.floor((elapsed / this.duration) * this.words.length));
        }
        // Procedural mouth while the synth speaks.
        const since = (now - (this._lastBoundary || now)) / 1000;
        const env = Math.max(0, 1 - since * 2.2);          // decay after each word
        this._mouth = (0.25 + 0.75 * env) * (0.55 + 0.45 * Math.abs(Math.sin(now / 70)));
        if (this._pulse) { this._mouth = Math.max(this._mouth, 0.85); this._pulse = 0; }
        if (this.revealedCount >= this.words.length && !window.speechSynthesis.speaking) this._finish();
      } else { // timed
        this.revealedCount = Math.min(this.words.length, Math.floor((elapsed / this.duration) * this.words.length));
        this._mouth = this._proceduralMouth(elapsed);
        if (elapsed >= this.duration) this._finish();
      }
      this._raf = requestAnimationFrame(frame);
    };
    this._raf = requestAnimationFrame(frame);
  }

  _readAnalyser() {
    if (!this.analyser) return 0.0;
    this.analyser.getByteTimeDomainData(this._buf);
    let sum = 0;
    for (let i = 0; i < this._buf.length; i++) { const v = (this._buf[i] - 128) / 128; sum += v * v; }
    const rms = Math.sqrt(sum / this._buf.length);
    return Math.min(1, rms * 3.2);                          // normalize loudness → mouth
  }

  // Word-gated open/close hump with jitter, synced to the virtual clock.
  _proceduralMouth(t) {
    const wlen = this.duration / Math.max(1, this.words.length);
    const local = (t % wlen) / wlen;                        // 0..1 within current word
    const idx = Math.floor(t / wlen);
    const isPause = idx < this.words.length && /[.,;:!?]$/.test(this.words[idx] || "");
    const hump = Math.sin(local * Math.PI);                 // open then close per word
    const jitter = 0.5 + 0.5 * Math.sin(t * 33.7);
    let m = hump * (0.55 + 0.45 * jitter);
    if (isPause) m *= 0.35;
    return Math.max(0, Math.min(1, m));
  }

  get mouthValue() { return this._mouth; }
  get progress() { return Math.min(1, this.revealedCount / Math.max(1, this.words.length)); }

  _finish() {
    if (this._ended) return;
    this._ended = true;
    this.revealedCount = this.words.length;
    if (this._raf) cancelAnimationFrame(this._raf);
    if (this.onended) this.onended();
  }

  stop() {
    this._ended = true;
    if (this._raf) cancelAnimationFrame(this._raf);
    if (this.audio) { try { this.audio.pause(); } catch (e) {} }
    if (this.mode === "tts") { try { window.speechSynthesis.cancel(); } catch (e) {} }
  }
}

Object.assign(window, { fetchBroadcast, Broadcast, getAudioCtx });
