// Panel-side audio detection: capture → fingerprint → match, all in the side
// panel (a VISIBLE extension page).
//
// Why here and not the offscreen document: a side panel can hold a MediaStream
// just like any page, and — crucially — it can show the microphone permission
// prompt. getUserMedia for the mic from an invisible offscreen doc just fails
// ("Permission dismissed"), which is the bug users hit. Tab audio works the same
// way: mint a tabCapture streamId under the button's user gesture, then capture
// it right here. The heavy index (~37 MB) loads lazily in matcher.js and is
// cached for the panel's lifetime.
import { fingerprint, resample } from "./engine.js";
import * as matcher from "./matcher.js";

const SR = 8000;

// Multi-speed search: fingerprint the query at a ladder of resample ratios and
// keep the best verdict. Old tango transfers (and their YouTube re-uploads) are
// routinely digitized a few % off the library copy — measured on real YouTube
// audio: La cumparsita +5.5%, Sans Souci +3.75%, Verdemar +3% — which kills the
// native-speed landmark match. ±6% in 0.5% steps recovers all of them (tested
// 8/12 → 12/12 vs single-speed). ~15 ms/fingerprint × 25 ≈ 0.4 s, after the
// 12 s capture, so the cost is invisible.
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 };

export async function hasTabCapture() {
  if (!chrome.permissions) return true;
  try { return await chrome.permissions.contains({ permissions: ["tabCapture"] }); }
  catch { return false; }
}

// Must be called from a user gesture.
export async function requestTabCapture() {
  if (!chrome.permissions) return true;
  try { return await chrome.permissions.request({ permissions: ["tabCapture"] }); }
  catch { return false; }
}

async function activeTab() {
  const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
  return tabs && tabs[0];
}

function getMediaStreamId(targetTabId) {
  return new Promise((resolve, reject) => {
    try {
      const p = chrome.tabCapture.getMediaStreamId({ targetTabId }, (id) => {
        const err = chrome.runtime.lastError;
        if (err) reject(new Error(err.message)); else resolve(id);
      });
      if (p && typeof p.then === "function") p.then(resolve, reject);   // promise-style builds
    } catch (e) { reject(e); }
  });
}

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 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 * SR));
  const off = new OfflineAudioContext(1, frames, SR);
  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);
}

// Start capturing a stream into 1 s chunks (no stop). `keepAudible` re-routes the
// captured tab audio to the speakers (tab capture mutes the tab otherwise). Returns
// a handle the staged matcher harvests from progressively, then tears down.
function startCapture(stream, keepAudible) {
  if (!stream.getAudioTracks || stream.getAudioTracks().length === 0) {
    stream.getTracks().forEach((t) => t.stop());
    throw new Error("no audio track in the captured stream");
  }
  let routeCtx = null, routeNode = null;
  if (keepAudible) {
    try {
      routeCtx = new AudioContext();
      if (routeCtx.state === "suspended") 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); };
  rec.start(1000);   // 1 s timeslice → chunks accrue progressively
  return { stream, rec, chunks, routeCtx, routeNode, mime };
}

// Decode everything captured so far → mono 8 kHz Float32, WITHOUT stopping the
// recorder. Returns null if nothing decodable yet (a partial container early on)
// or the audio is silent — the caller just waits for the next checkpoint.
async function harvest(h) {
  try { h.rec.requestData(); } catch { /* not all builds support it; 1 s chunks cover us */ }
  await new Promise((r) => setTimeout(r, 120));   // let a flushed chunk land
  if (!h.chunks.length) return null;
  const blob = new Blob(h.chunks.slice(), { type: h.chunks[0].type || h.mime || "audio/webm" });
  let samples;
  try { samples = await decodeTo8kMono(await blob.arrayBuffer()); }
  catch { return null; }   // container not yet self-contained enough to decode — skip
  let peak = 0;
  for (let i = 0; i < samples.length; i += 97) { const a = Math.abs(samples[i]); if (a > peak) peak = a; }
  return peak < 1e-4 ? null : samples;
}

async function teardown(h) {
  try { h.rec.stop(); } catch {}
  h.stream.getTracks().forEach((t) => t.stop());
  if (h.routeNode) try { h.routeNode.disconnect(); } catch {}
  if (h.routeCtx) try { await h.routeCtx.close(); } catch {}
}

// Staged capture with early-exit: match after `minSeconds`, and only keep listening
// (re-matching the FULL accumulated audio at +12 s steps up to `maxSeconds`) while the
// verdict is short of `accept`. Easy tracks return in ~min; thin transfers/old masters
// — which need more audio to clear the gate (measured: Di Sarli "Julián Centeya" scores
// 5–14 at 12 s but ~40 → top match by ~36 s) — get the extra time instead of a false
// abstain. No precision cost: the gate is unchanged, just fed more hashes.
async function captureAndMatch(stream, keepAudible, opts = {}) {
  const minSeconds = opts.minSeconds || 12;
  const maxSeconds = Math.max(minSeconds, opts.maxSeconds || minSeconds);
  const onProgress = opts.onProgress || (() => {});
  const cancelled = opts.isCancelled || (() => false);
  const marks = [];
  for (let t = minSeconds; t < maxSeconds; t += 12) marks.push(t);
  marks.push(maxSeconds);
  const h = startCapture(stream, keepAudible);
  const rank = (v) => (VERDICT_RANK[v.verdict] || 0) * 1e6 + (v.score || 0);
  let best = null, elapsed = 0;
  try {
    for (const mark of marks) {
      await new Promise((r) => setTimeout(r, Math.max(0, (mark - elapsed) * 1000)));
      elapsed = mark;
      if (cancelled()) break;
      const samples = await harvest(h);
      if (samples) {
        const v = await matchPCM(samples);
        if (!best || rank(v) > rank(best)) best = v;
        onProgress({ elapsed, verdict: best.verdict, score: best.score, maxSeconds });
        if (best.verdict === "accept") break;   // good enough — don't keep the user waiting
      } else {
        onProgress({ elapsed, verdict: best ? best.verdict : "listening", score: best ? best.score : 0, maxSeconds });
      }
    }
  } finally { await teardown(h); }
  if (!best) return { ok: true, verdict: "abstain", score: 0, candidates: [] };
  return best;
}

async function matchPCM(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) }; }
  }
  if (!best) return { ok: true, verdict: "abstain", score: 0, candidates: [] };
  return { ok: true, hashes, ...best };
}

// Normalize the call: detectX(12) (legacy) or detectX({minSeconds,maxSeconds,onProgress,isCancelled}).
function captureOpts(arg) {
  const o = typeof arg === "number" ? { minSeconds: arg } : (arg || {});
  return { minSeconds: o.minSeconds || 12, maxSeconds: o.maxSeconds || 34,
           onProgress: o.onProgress, isCancelled: o.isCancelled };
}

// Detect the tango playing in the active tab. Call from a user gesture.
export async function detectTab(arg = {}) {
  const opts = captureOpts(arg);
  const granted = await requestTabCapture();
  if (!granted) return { ok: false, error: "permission-denied" };
  // After granting an optional permission the API namespace may not appear until
  // the page is reopened — tell the user instead of throwing cryptically.
  if (!chrome.tabCapture || !chrome.tabCapture.getMediaStreamId) return { ok: false, error: "reopen-needed" };
  const tab = await activeTab();
  if (!tab || tab.id == null) return { ok: false, error: "no-active-tab" };

  let streamId;
  try { streamId = await getMediaStreamId(tab.id); }
  catch (e) { return { ok: false, error: "capture-failed", detail: String(e && e.message || e) }; }

  let stream;
  try {
    stream = await navigator.mediaDevices.getUserMedia({
      audio: { mandatory: { chromeMediaSource: "tab", chromeMediaSourceId: streamId } },
      video: false,
    });
  } catch (e) {
    // The side panel couldn't hold the tab stream — fall back to the offscreen
    // document (it captures + matches and returns a verdict). No progressive UI on
    // this rare path, so give it a single longer window instead of staging.
    return detectTabViaOffscreen(tab.id, Math.min(opts.maxSeconds, 20), e);
  }
  try {
    return await captureAndMatch(stream, true, opts);
  } catch (e) {
    return { ok: false, error: "capture-failed", detail: String(e && e.message || e) };
  }
}

// Fallback path: a fresh streamId handed to the service worker → offscreen doc.
async function detectTabViaOffscreen(tabId, seconds, primaryErr) {
  const detail = String(primaryErr && primaryErr.message || primaryErr || "");
  try {
    const streamId = await getMediaStreamId(tabId);
    const r = await chrome.runtime.sendMessage({ type: "fpDetect", source: "tab", streamId, seconds });
    if (r && r.ok !== false) return r;
    return { ok: false, error: (r && r.error) || "capture-failed", detail: (r && r.detail) || detail };
  } catch {
    return { ok: false, error: "capture-failed", detail };
  }
}

// Detect via the microphone (e.g. audio from a phone/speaker in the room).
export async function detectMic(arg = {}) {
  const opts = captureOpts(arg);
  let stream;
  try {
    stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  } catch (e) {
    const name = (e && e.name) || "";
    const code = name === "NotAllowedError" ? "mic-denied"
      : name === "NotFoundError" ? "mic-none" : "mic-failed";
    return { ok: false, error: code, detail: String(e && e.message || e) };
  }
  try {
    return await captureAndMatch(stream, false, opts);
  } catch (e) {
    return { ok: false, error: "mic-failed", detail: String(e && e.message || e) };
  }
}
