// Offscreen document — the only MV3 context that can hold a MediaStream.
// Captures ~12 s of tab (or mic) audio on demand, decodes/resamples to mono
// 8 kHz, fingerprints it (fp/engine.js), and matches it against the on-device
// slim landmark index (fp/matcher.js). Returns a gate verdict to the caller.
//
// The heavy index (~37 MB) is loaded lazily inside the matcher and cached for
// the life of this document, so repeat detections are fast. Created on demand by
// the service worker (background.js) and left alive to keep that cache warm.
import { fingerprint, resample } from "./fp/engine.js";
import * as matcher from "./fp/matcher.js";

// Multi-speed search (see fp/client.js) — old tango transfers are digitized a few
// % off the library copy, which breaks the native-speed landmark match.
const SPEED_RATIOS = Array.from({ length: 25 }, (_, i) => 1 + (i - 12) * 0.005);   // 0.94 … 1.06
const VERDICT_RANK = { accept: 2, suggest: 1, abstain: 0 };

async function matchMultiSpeed(samples) {
  const idx = await matcher.loadIndex();
  let best = null, bestRank = -1, hashes = 0;
  for (const r of SPEED_RATIOS) {
    const fp = fingerprint(r === 1 ? samples : resample(samples, r));
    if (!fp.codes.length) continue;
    hashes = Math.max(hashes, fp.codes.length);
    const v = matcher.decide(matcher.rank(idx, fp), idx.gate);
    const rk = (VERDICT_RANK[v.verdict] || 0) * 1e6 + (v.score || 0);
    if (rk > bestRank) { bestRank = rk; best = { ...v, speed: +r.toFixed(4) }; }
  }
  return best ? { hashes, ...best } : { verdict: "abstain", score: 0, candidates: [] };
}

function pickMime() {
  for (const m of ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus"]) {
    if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(m)) return m;
  }
  return "";
}

async function getStream(source, streamId) {
  if (source === "mic") {
    return navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  }
  // Tab audio via the streamId minted by chrome.tabCapture.getMediaStreamId.
  return navigator.mediaDevices.getUserMedia({
    audio: { mandatory: { chromeMediaSource: "tab", chromeMediaSourceId: streamId } },
    video: false,
  });
}

// Record `seconds` of the stream and return mono 8 kHz Float32 samples.
async function captureSamples(source, streamId, seconds) {
  const stream = await getStream(source, streamId);
  if (!stream.getAudioTracks || stream.getAudioTracks().length === 0) {
    stream.getTracks().forEach((t) => t.stop());
    throw new Error(source === "mic" ? "microphone has no audio track" : "this tab has no audio to capture");
  }

  // Keep tab audio audible while we capture (tab capture mutes it otherwise). Needs
  // the offscreen doc's AUDIO_PLAYBACK reason + a resumed context to actually play.
  let routeCtx = null, routeNode = null;
  if (source !== "mic") {
    try {
      routeCtx = new AudioContext();
      if (routeCtx.state === "suspended") await routeCtx.resume().catch(() => {});
      routeNode = routeCtx.createMediaStreamSource(stream);
      routeNode.connect(routeCtx.destination);
    } catch { /* if routing fails the tab briefly mutes; capture still works */ }
  }

  const mime = pickMime();
  const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
  const chunks = [];
  rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
  const stopped = new Promise((res) => { rec.onstop = res; });
  rec.start(1000);   // 1 s timeslice → chunks accrue progressively (robust to stop timing)
  await new Promise((r) => setTimeout(r, Math.round(seconds * 1000)));
  try { rec.stop(); } catch { /* already stopped */ }
  await stopped;

  // Release the capture so the tab returns to normal.
  stream.getTracks().forEach((t) => t.stop());
  if (routeNode) try { routeNode.disconnect(); } catch {}
  if (routeCtx) try { await routeCtx.close(); } catch {}

  if (!chunks.length) throw new Error("no audio captured — the tab may be silent or muted");
  const blob = new Blob(chunks, { type: chunks[0].type || mime || "audio/webm" });
  const arr = await blob.arrayBuffer();
  const samples = await decodeTo8kMono(arr);
  // All-silence guard: a flat signal can't be fingerprinted — say so clearly.
  let peak = 0;
  for (let i = 0; i < samples.length; i += 97) { const a = Math.abs(samples[i]); if (a > peak) peak = a; }
  if (peak < 1e-4) throw new Error("captured audio was silent — check the tab isn't muted and audio is playing");
  return samples;
}

async function decodeTo8kMono(arrayBuffer) {
  const tmp = new AudioContext();
  let buf;
  try { buf = await tmp.decodeAudioData(arrayBuffer); }
  finally { try { await tmp.close(); } catch {} }
  const frames = Math.max(1, Math.ceil(buf.duration * 8000));
  const off = new OfflineAudioContext(1, frames, 8000);
  const src = off.createBufferSource();
  src.buffer = buf;
  src.connect(off.destination);     // multi-channel → 1-channel destination downmixes to mono
  src.start();
  const rendered = await off.startRendering();
  return rendered.getChannelData(0);
}

async function runDetect({ source = "tab", streamId, seconds = 12 }) {
  const samples = await captureSamples(source, streamId, seconds);
  const result = await matchMultiSpeed(samples);
  return { ok: true, samples: samples.length, ...result };
}

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (!msg || msg.target !== "offscreen") return false;
  if (msg.type === "fpRunDetect") {
    runDetect(msg)
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String(e && e.message || e) }));
    return true;   // async
  }
  return false;
});
