// Service worker:
//  - opens the side panel from the toolbar button
//  - right-click "Look up in El Eje" on selected text
//  - answers match/getLyrics requests from the YouTube overlay content script
import * as db from "./db.js";
import * as learning from "./learning.js";
import * as confirms from "./confirms.js";
import * as ytmap from "./ytmap.js";
import * as feedback from "./feedback.js";
import * as fpMatcher from "./fp/matcher.js";

// Toolbar click opens the side panel (persists across restarts).
if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) {
  chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
}

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: "eleje-lookup",
    title: 'Look up “%s” in El Eje',
    contexts: ["selection"],
  });
  fpMatcher.prefetch();   // warm the R2 fingerprint cache so first detect works offline + fast
});

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== "eleje-lookup" || !info.selectionText) return;
  await chrome.storage.session.set({ pendingQuery: info.selectionText.trim() });
  if (tab && chrome.sidePanel && chrome.sidePanel.open) {
    try { await chrome.sidePanel.open({ tabId: tab.id }); } catch (e) { /* needs a gesture on some versions */ }
  }
});

// Dev feedback mirror: when a local dev endpoint is configured (eleje_dev_endpoint), push the
// feedback files to it whenever they change, debounced. Inert in normal use (endpoint unset).
let _fbTimer = null;
chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== "local") return;
  if (changes.eleje_dev_endpoint || changes.eleje_share_optout || feedback.FEEDBACK_KEYS.some((k) => changes[k])) {
    clearTimeout(_fbTimer);
    _fbTimer = setTimeout(() => { feedback.pushAll(); feedback.submitProd(); }, 800);
  }
});

// Dev convenience: if the local feedback sink answers, enable mirroring AUTOMATICALLY — no
// console flag needed. The sink runs only on the developer's machine (launchd), and public
// builds don't ship the localhost host permission, so this never activates for normal users.
// Setting the endpoint fires the onChanged listener above, which mirrors all existing feedback.
async function autoDetectDevSink() {
  try {
    if ((await chrome.storage.local.get("eleje_dev_endpoint")).eleje_dev_endpoint) return;
    const r = await fetch("http://localhost:8787/", { method: "GET" });
    if (r.ok) await chrome.storage.local.set({ eleje_dev_endpoint: "http://localhost:8787" });
  } catch { /* sink not running — stay off */ }
}
autoDetectDevSink();
feedback.submitProd();   // flush any pending customer feedback to prod on startup (on by default, opt-out)

// ---- On-device audio fingerprint detection (offscreen capture) ------------
// Tab audio can only be held by an offscreen document in MV3; the service worker
// brokers between the side panel (which mints the tabCapture streamId under a
// user gesture) and the offscreen doc (which captures + matches on-device).
const OFFSCREEN_PATH = "src/offscreen.html";

async function hasOffscreen() {
  if (chrome.offscreen && chrome.offscreen.hasDocument) {
    try { return await chrome.offscreen.hasDocument(); } catch { /* fall through */ }
  }
  if (chrome.runtime.getContexts) {
    const ctxs = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
    return !!(ctxs && ctxs.length);
  }
  return false;
}

let _creatingOffscreen = null;
async function ensureOffscreen() {
  if (await hasOffscreen()) return;
  if (!_creatingOffscreen) {
    _creatingOffscreen = chrome.offscreen.createDocument({
      url: OFFSCREEN_PATH,
      // USER_MEDIA = capture the stream; AUDIO_PLAYBACK = route it back to the
      // speakers so the captured tab stays audible (capture mutes it otherwise).
      reasons: ["USER_MEDIA", "AUDIO_PLAYBACK"],
      justification: "Capture a few seconds of tab audio to fingerprint and identify the tango playing.",
    }).catch((e) => {
      if (!String(e && e.message).includes("single offscreen")) throw e;   // tolerate a create race
    }).finally(() => { _creatingOffscreen = null; });
  }
  await _creatingOffscreen;
}

// The panel supplies a tabCapture streamId (tab audio) or asks for mic; the
// offscreen doc captures, fingerprints and matches. Returns the gate verdict.
async function handleFpDetect(msg) {
  await ensureOffscreen();
  const payload = {
    target: "offscreen", type: "fpRunDetect",
    source: msg.source || "tab", streamId: msg.streamId, seconds: msg.seconds || 12,
  };
  try {
    return await chrome.runtime.sendMessage(payload);
  } catch (e) {
    // Rare: the offscreen doc's listener wasn't ready the instant it loaded.
    await new Promise((r) => setTimeout(r, 200));
    return chrome.runtime.sendMessage(payload);
  }
}

// ---- Matching service for the YouTube overlay -----------------------------
function slim(s) {
  return { id: s.id, t: s.t, o: s.o, s: s.s, y: s.y, g: s.g, k: s.k, b: s.b, l: s.l };
}

async function handleMatch(title, url, context, music) {
  await db.init();
  // Fast lookup: if this page was previously confirmed, that wins over title-
  // guessing (no-op when the caller doesn't pass a url).
  if (url) {
    const conf = await confirms.getConfirm(url);
    if (conf && conf.notTango) return { candidates: [], confident: false, notTango: true, lyrics: null, align: null };
    const song = conf && db.getSong(conf.songId);
    if (song) {
      // Confirmed track wins; keep the title matches as alternates so the overlay's
      // "pick a version" still works if the confirmation was wrong.
      const alts = db.matchTitle(title, 5, context)
        .map(c => ({ ...slim(c.song), score: Math.round(c.score) }))
        .filter(c => c.id !== song.id);
      return {
        confident: true, confirmed: true,
        candidates: [{ ...slim(song), score: 999 }, ...alts],
        lyrics: song.l ? await db.getLyrics(song.id) : null,
        align: song.l ? await db.getAlignment(song.id) : null,
      };
    }
  }
  // YouTube's OWN detected song ("Music in this video" / "Provided to YouTube by …"). It's
  // YouTube's Content-ID match, so it names the actual recording — tried BEFORE guessing
  // from the noisy video title. We still require a CONFIDENT catalog match (the detected
  // song+artist must resolve to one of ours), else fall through to title / fingerprint.
  if (music && music.song) {
    const dq = [music.song, music.artist, music.year].filter(Boolean).join(" ");
    const dtop = db.matchTitle(dq, 5)[0];
    if (dtop && db.isConfident(dtop)) {
      const alts = db.matchTitle(title, 5, context)
        .map(c => ({ ...slim(c.song), score: Math.round(c.score) }))
        .filter(c => c.id !== dtop.song.id);
      return {
        confident: true, detected: true,
        candidates: [{ ...slim(dtop.song), score: 997 }, ...alts],
        lyrics: dtop.song.l ? await db.getLyrics(dtop.song.id) : null,
        align: dtop.song.l ? await db.getAlignment(dtop.song.id) : null,
      };
    }
  }
  if (url) {
    // Bundled grown map: a known YouTube URL → its track (textually confirmed
    // during fingerprint growth), so the overlay recognises it instantly even
    // when the title is ambiguous. Personal confirmations above still win.
    const mgid = await ytmap.lookup(url);
    const msong = mgid && db.getSong(mgid);
    if (msong) {
      const alts = db.matchTitle(title, 5, context)
        .map(c => ({ ...slim(c.song), score: Math.round(c.score) }))
        .filter(c => c.id !== msong.id);
      return {
        confident: true, mapped: true,
        candidates: [{ ...slim(msong), score: 998 }, ...alts],
        lyrics: msong.l ? await db.getLyrics(msong.id) : null,
        align: msong.l ? await db.getAlignment(msong.id) : null,
      };
    }
  }
  const cands = db.matchTitle(title, 5, context);
  if (!cands.length) return { candidates: [], confident: false, lyrics: null, align: null };
  const top = cands[0];
  // Abstain on weak guesses rather than mislabel: a non-confident top whose own title
  // is only partly present is more likely a wrong same-label/junk-title artifact (or a
  // song absent from the library) than a real match. The overlay then stays quiet and
  // can fall back to sound-detect.
  if (!db.isShowable(top)) return { candidates: [], confident: false, lyrics: null, align: null };
  return {
    confident: db.isConfident(top),
    candidates: cands.map(c => ({ ...slim(c.song), score: Math.round(c.score) })),
    lyrics: top.song.l ? await db.getLyrics(top.song.id) : null,
    align: top.song.l ? await db.getAlignment(top.song.id) : null,
  };
}

// Active tab's URL/title, via its content script (avoids the "tabs" permission).
async function activePageInfo() {
  try {
    const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
    const tab = tabs && tabs[0];
    if (!tab || tab.id == null) return {};
    try {
      const info = await chrome.tabs.sendMessage(tab.id, { type: "elejePageInfo" });
      return info || { url: tab.url || "" };
    } catch {
      return { url: tab.url || "" };   // content script not present (chrome://, store…)
    }
  } catch {
    return {};
  }
}

// Relay a detect-coordination message from the side panel to the active tab's content
// script (page metadata + player seek for the fingerprint capture). Empty object if none.
async function forwardToActiveTab(type) {
  try {
    const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
    const tab = tabs && tabs[0];
    if (!tab || tab.id == null) return {};
    return (await chrome.tabs.sendMessage(tab.id, { type })) || {};
  } catch { return {}; }
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || !msg.type) return false;
  if (msg.type === "match") { handleMatch(msg.title, msg.url, msg.context, msg.music).then(sendResponse); return true; }
  if (msg.type === "pageInfo") { activePageInfo().then(sendResponse); return true; }
  // Side-panel detect coordination → active tab's content script.
  if (msg.type === "detectInfo") { forwardToActiveTab("elejeDetectInfo").then(sendResponse); return true; }
  if (msg.type === "seekIntro") { forwardToActiveTab("elejeSeekIntro").then(sendResponse); return true; }
  if (msg.type === "restorePlayback") { forwardToActiveTab("elejeRestorePlayback").then(sendResponse); return true; }
  if (msg.type === "getLyrics") {
    db.init().then(() => db.getLyrics(msg.id)).then(ly => sendResponse({ lyrics: ly }));
    return true;
  }
  if (msg.type === "getAlignment") {
    db.init().then(() => db.getAlignment(msg.id)).then(a => sendResponse({ align: a }));
    return true;
  }
  // ---- Hover-to-learn (content script on all sites) ----
  if (msg.type === "learningTerms") {
    learning.init().then(() => sendResponse({ entries: learning.allEntries() }));
    return true;
  }
  if (msg.type === "artistInfo") {
    db.init().then(() => sendResponse(db.byArtist(msg.name)));
    return true;
  }
  if (msg.type === "openPanel") {
    (async () => {
      await chrome.storage.session.set({ pendingQuery: msg.query || "" });
      if (sender.tab && chrome.sidePanel && chrome.sidePanel.open) {
        try { await chrome.sidePanel.open({ tabId: sender.tab.id }); } catch (e) { /* gesture */ }
      }
      sendResponse({ ok: true });
    })();
    return true;
  }
  // ---- Audio fingerprint detection ----
  if (msg.type === "fpDetect") {
    handleFpDetect(msg)
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String(e && e.message || e) }));
    return true;
  }
  // On-page "Detect tango" button: open the panel and tell it to detect.
  if (msg.type === "fpOpenPanelDetect") {
    (async () => {
      await chrome.storage.session.set({ pendingDetect: true });
      if (sender.tab && chrome.sidePanel && chrome.sidePanel.open) {
        try { await chrome.sidePanel.open({ tabId: sender.tab.id }); } catch (e) { /* gesture */ }
      }
      sendResponse({ ok: true });
    })();
    return true;
  }
  // ---- Assign a track to the page: confirm / search / undo (from the overlay) ----
  if (msg.type === "saveConfirm") {
    confirms.saveConfirm(msg.url, msg.song, msg.source || "overlay").then((rec) => sendResponse({ ok: !!rec, rec }));
    return true;
  }
  if (msg.type === "getConfirm") {
    confirms.getConfirm(msg.url).then((confirm) => sendResponse({ confirm }));
    return true;
  }
  if (msg.type === "removeConfirm") {
    confirms.removeConfirm(msg.url).then((ok) => sendResponse({ ok }));
    return true;
  }
  if (msg.type === "markNotTango") {
    confirms.saveNotTango(msg.url).then((rec) => sendResponse({ ok: !!rec }));
    return true;
  }
  if (msg.type === "search") {
    db.init().then(() => sendResponse({ results: db.search(msg.q || "", msg.limit || 8).map(slim) }));
    return true;
  }
  // Other recordings of the same song (same folded title, other orchestras/years) —
  // the overlay's "grabaciones" list, mirroring the side panel's renderRecs.
  if (msg.type === "otherRecordings") {
    db.init().then(() => {
      const s = db.getSong(msg.id);
      sendResponse({ recordings: s ? db.otherRecordings(s).map(slim) : [] });
    });
    return true;
  }
  // ---- Dev feedback: a misdetection / undetected report from the overlay ----
  if (msg.type === "reportMisdetect") {
    feedback.reportMisdetect(msg.rec).then((ok) => sendResponse({ ok }));
    return true;
  }
  return false;
});
