(() => {
  const btn = document.getElementById("btn");
  const status = document.getElementById("status");
  const log = document.getElementById("log");

  let mediaRecorder = null;
  let recordingChunks = [];
  let isRecording = false;
  let recordingCancel = false;
  let isBusy = false;

  function setBtnIdle() { btn.classList.remove("active"); btn.innerHTML = "&#x1f399;"; btn.disabled = false; }
  function setBtnActive() { btn.classList.add("active"); btn.innerHTML = "&#x25a0;"; }

  function startRecording() {
    if (isRecording || isBusy) return;
    isRecording = true;
    recordingCancel = false;
    recordingChunks = [];
    status.textContent = "recording... tap to stop";
    setBtnActive();

    navigator.mediaDevices.getUserMedia({
      audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
    })
      .then((stream) => {
        if (recordingCancel) {
          stream.getTracks().forEach(t => t.stop());
          return;
        }
        const mr = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
        mediaRecorder = mr;
        mr.ondataavailable = (e) => { if (e.data.size > 0) recordingChunks.push(e.data); };
        mr.onstop = () => {
          stream.getTracks().forEach(t => t.stop());
          processRecording();
        };
        mr.start(100);
      })
      .catch((err) => {
        status.textContent = "mic error: " + err.message;
        isRecording = false;
        setBtnIdle();
      });
  }

  function stopRecording() {
    if (!isRecording) return;
    isRecording = false;
    if (!mediaRecorder) {
      recordingCancel = true;
      setBtnIdle();
      return;
    }
    status.textContent = "stopping...";
    mediaRecorder.stop();
    mediaRecorder = null;
  }

  function processRecording() {
    status.textContent = "processing...";
    const blob = new Blob(recordingChunks, { type: "audio/webm" });
    if (blob.size < 200) { status.textContent = "too short"; setBtnIdle(); return; }

    const ac = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
    blob.arrayBuffer().then((buf) => ac.decodeAudioData(buf))
      .then((audioBuffer) => {
        const srcRate = audioBuffer.sampleRate;
        const srcChan = audioBuffer.numberOfChannels;
        const srcLen = audioBuffer.length;
        const ratio = 16000 / srcRate;
        const dstLen = Math.floor(srcLen * ratio);

        const srcData = new Float32Array(srcLen);
        for (let i = 0; i < srcLen; i++) {
          let s = 0;
          for (let c = 0; c < srcChan; c++) s += audioBuffer.getChannelData(c)[i];
          srcData[i] = s / srcChan;
        }

        const dstData = new Int16Array(dstLen);
        for (let i = 0; i < dstLen; i++) {
          const srcIdx = i / ratio;
          const lo = Math.floor(srcIdx);
          const hi = Math.min(lo + 1, srcLen - 1);
          const frac = srcIdx - lo;
          const sample = srcData[lo] + (srcData[hi] - srcData[lo]) * frac;
          const clamped = Math.max(-1, Math.min(1, sample));
          dstData[i] = clamped < 0 ? clamped * 32768 : clamped * 32767;
        }

        ac.close();
        sendPCM(new Uint8Array(dstData.buffer));
      })
      .catch((err) => {
        status.textContent = "decode error: " + err.message;
        setBtnIdle();
      });
  }

  function testFetch() {
    fetch("/api/ping").then(r => r.text()).then(t => {
      if (t === "pong") appendLog("server reachable", "");
      else appendLog("unexpected ping: " + t, "error");
    }).catch(e => appendLog("fetch failed: " + e.message, "error"));
  }

  function sendPCM(pcm) {
    isBusy = true;
    btn.disabled = true;
    status.textContent = "sending...";
    appendLog("sending " + pcm.length + " bytes", "");

    fetch("/api/ptt", { method: "POST", body: pcm })
      .then(async (res) => {
        if (!res.ok) {
          const txt = await res.text();
          throw new Error(txt);
        }
        const replyText = res.headers.get("X-Reply-Text");
        if (replyText) {
          const txt = decodeURIComponent(replyText);
          appendLog(txt, "reply");
        }
        const audioData = await res.arrayBuffer();
        if (audioData.byteLength > 0) {
          playPCM(new Uint8Array(audioData));
        } else {
          status.textContent = "no reply audio";
          setBtnIdle();
          isBusy = false;
        }
      })
      .catch((err) => {
        status.textContent = "error: " + err.message;
        setBtnIdle();
        isBusy = false;
      });
  }

  function playPCM(pcm) {
    const sampleRate = 16000;
    const bitsPerSample = 16;
    const channels = 1;
    const dataLen = pcm.length;
    const headerLen = 44;
    const wav = new Uint8Array(headerLen + dataLen);

    const dv = (i, v) => { wav[i] = v & 255; wav[i+1] = (v>>8) & 255; wav[i+2] = (v>>16) & 255; wav[i+3] = (v>>24) & 255; };
    const sv = (i, v) => { wav[i] = v & 255; wav[i+1] = (v>>8) & 255; };
    wav[0] = 0x52; wav[1] = 0x49; wav[2] = 0x46; wav[3] = 0x46;
    dv(4, 36 + dataLen);
    wav[8] = 0x57; wav[9] = 0x41; wav[10] = 0x56; wav[11] = 0x45;
    wav[12] = 0x66; wav[13] = 0x6d; wav[14] = 0x74; wav[15] = 0x20;
    dv(16, 16);
    sv(20, 1);
    sv(22, channels);
    dv(24, sampleRate);
    dv(28, sampleRate * channels * bitsPerSample / 8);
    sv(32, channels * bitsPerSample / 8);
    sv(34, bitsPerSample);
    wav[36] = 0x64; wav[37] = 0x61; wav[38] = 0x74; wav[39] = 0x61;
    dv(40, dataLen);
    wav.set(pcm, 44);

    const blob = new Blob([wav], { type: "audio/wav" });
    const audio = new Audio();
    audio.src = URL.createObjectURL(blob);
    status.textContent = "playing...";
    audio.onended = () => { status.textContent = "ready"; setBtnIdle(); isBusy = false; };
    audio.play().catch(() => { isBusy = false; setBtnIdle(); });
  }

  function appendLog(msg, cls) {
    const el = document.createElement("div");
    el.className = cls;
    el.textContent = msg;
    log.appendChild(el);
    log.scrollTop = log.scrollHeight;
  }

  btn.addEventListener("click", () => {
    if (isBusy) return;
    if (isRecording) { stopRecording(); }
    else { startRecording(); }
  });

  // ---- ntfy WS subscribe: proactive nudges land in-app -------------------
  // The PWA connects straight to ntfy's WebSocket (mavweb serves only the URL,
  // token included). ntfy streams one JSON object per frame; we care about
  // event:"message". Reconnects with backoff — ntfy drops idle sockets and the
  // phone sleeps. A missed nudge while disconnected is non-loss: sev>=3 also
  // hit the native ntfy push, this is the in-app mirror, not the only channel.
  function subscribeNtfy() {
    fetch("/api/ntfy").then((r) => (r.status === 204 ? "" : r.text())).then((url) => {
      if (!url) return; // not configured
      if ("Notification" in window && Notification.permission === "default") {
        Notification.requestPermission();
      }
      connectNtfy(url, 1000);
    }).catch(() => {}); // no ntfy config endpoint → stay voice-only
  }

  function connectNtfy(url, backoff) {
    let ws;
    try { ws = new WebSocket(url); } catch (e) { scheduleReconnect(url, backoff); return; }
    ws.onopen = () => { backoff = 1000; appendLog("nudges connected", ""); };
    ws.onmessage = (ev) => {
      let m;
      try { m = JSON.parse(ev.data); } catch (e) { return; }
      if (m.event !== "message") return; // skip open/keepalive/poll_request
      const text = (m.title ? m.title + ": " : "") + (m.message || "");
      appendLog(text, "reply");
      if ("Notification" in window && Notification.permission === "granted") {
        new Notification(m.title || "maven", { body: m.message || "" });
      }
    };
    ws.onclose = () => scheduleReconnect(url, backoff);
    ws.onerror = () => { try { ws.close(); } catch (e) {} };
  }

  function scheduleReconnect(url, backoff) {
    const next = Math.min(backoff * 2, 30000); // cap at 30s
    setTimeout(() => connectNtfy(url, next), backoff);
  }

  // ---- presence: page heartbeat -----------------------------------------
  // A surface you have open + alive is a weak presence signal (τ=4min). Ping
  // every 30s; the fact's fresh timestamp is what the scorer reads. Fire-and-
  // forget — a dropped ping just decays, non-loss. Disabled server-side (503)
  // when mavweb has no -core; we ignore the failure and stop pinging isn't
  // needed (the scorer just never sees the key).
  function heartbeat() {
    fetch("/api/signal?key=page_heartbeat", { method: "POST" }).catch(() => {});
  }
  heartbeat();
  setInterval(heartbeat, 30000);

  status.textContent = "ready";
  testFetch();
  subscribeNtfy();
})();
