// El Eje YouTube overlay: detect the tango playing from the video title, match it
// against the bundled library (via the service worker), and show a follow-along
// Spanish + English lyrics panel. No fake karaoke sync — every upload's timing
// differs — just a clean, scrollable panel with a "wrong one? pick a version" fix.
(() => {
  "use strict";
  if (window.__elejeOverlayLoaded) return;
  window.__elejeOverlayLoaded = true;

  let host, shadow, root;
  let lastKey = "";
  let dismissedKey = "";
  let current = null;        // chosen candidate
  let candidates = [];
  let collapsed = false;
  let sync = null;           // active LyricSync controller (null when static)
  let syncMod = null;        // cached lyric_sync.js module
  let authorMod = null;      // cached author.js module
  let fillGen = 0;           // generation guard for the async fillLyrics
  let authoring = false;     // timing-authoring mode active
  let authorSession = null;  // TimingSession while authoring
  let authorGroups = null;   // the lyric line-groups being timed
  let onAuthorKey = null;    // window keydown handler (Space/Backspace) while authoring
  let ytProbe = null;        // {title, author, description, music} from the MAIN-world probe

  // A page-context probe reads YouTube's embedded ytInitialPlayerResponse / ytInitialData
  // (title, FULL description, detected "Music in this video") and postMessages it here — far
  // more reliable than scraping the collapsed/lazy description DOM. DOM fallbacks still apply.
  function injectProbe() {
    try {
      const s = document.createElement("script");
      s.src = chrome.runtime.getURL("src/yt_probe.js");
      s.addEventListener("load", () => s.remove());
      (document.head || document.documentElement).appendChild(s);
    } catch (e) { /* CSP / unavailable — DOM fallbacks cover it */ }
  }
  window.addEventListener("message", (e) => {
    if (e.source === window && e.data && e.data.__elejeYT === 1) ytProbe = e.data;
  });
  injectProbe();

  // Side-panel "Detect tango" coordination. The panel (a separate context) captures the
  // tab audio, but it can't read this page's metadata or control the player — so it asks
  // us. (a) elejeDetectInfo: hand back the page's detected song + title + description so the
  // panel can identify the track from METADATA first, no capture needed. (b) elejeSeekIntro:
  // the sound index covers each track's first ~40s, but the user is usually mid-song, so a
  // live capture never overlaps it — seek to the intro before the panel captures (only when
  // actually past the window, to avoid disrupting playback unnecessarily). (c) restore after.
  let savedPlayback = null;
  chrome.runtime.onMessage.addListener((msg, _s, send) => {
    if (!msg || !msg.type) return;
    if (msg.type === "elejeDetectInfo") {
      send({ url: location.href, title: getMatchQuery(), music: getDetectedMusic(), description: getVideoDescription() });
      return true;
    }
    if (msg.type === "elejeSeekIntro") {
      const v = findMedia();
      if (v && v.currentTime > 35) {                 // mid-song → jump to the intro
        savedPlayback = { t: v.currentTime, paused: v.paused };
        try { v.currentTime = 2; if (v.paused) v.play(); } catch (e) { /* */ }
        send({ seeked: true });
      } else send({ seeked: false });                // already near the start → capture in place
      return true;
    }
    if (msg.type === "elejeRestorePlayback") {
      const v = findMedia();
      if (savedPlayback && v) {
        try { v.currentTime = savedPlayback.t; if (savedPlayback.paused) v.pause(); } catch (e) { /* */ }
      }
      savedPlayback = null;
      send({ ok: true });
      return true;
    }
  });

  const STYLE = `
    :host { all: initial; }
    .wrap { position: fixed; right: 16px; bottom: 84px; z-index: 2147483000;
      width: 360px; max-width: calc(100vw - 32px); font: 13px/1.5 -apple-system,
      BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; color: #f5f5f5; }
    .pill { display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
      background: #f24257; color: #ffffff; border: 1px solid #f24257; border-radius: 999px;
      padding: 8px 14px; box-shadow: 0 6px 22px rgba(0,0,0,.45); font-weight: 600; }
    .pill .dot { color: #f24257; }
    .pill.muted { background: #161616; color: #cfcfcf; border-color: #262626; }
    .card { background: #141414; border: 1px solid #262626; border-radius: 12px;
      box-shadow: 0 10px 30px rgba(0,0,0,.5); overflow: hidden; }
    .hd { display: flex; align-items: flex-start; gap: 8px; padding: 11px 12px 9px;
      background: linear-gradient(180deg,#161616,#141414); border-bottom: 1px solid #262626; }
    .hd .brand { color: #f24257; font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
      font-weight: 700; margin-bottom: 2px; }
    .hd .t { font-weight: 700; font-size: 15px; }
    .hd .by { color: #cfcfcf; font-size: 12px; }
    .hd .x { margin-left: auto; display: flex; gap: 6px; }
    .hd button { background: none; border: none; color: #cfcfcf; cursor: pointer;
      font-size: 15px; padding: 0 3px; line-height: 1; }
    .hd button:hover { color: #f5f5f5; }
    .facts { display: flex; flex-wrap: wrap; gap: 5px; padding: 8px 12px 0; }
    .fact { background: #161616; border: 1px solid #262626; border-radius: 6px;
      padding: 2px 7px; font-size: 11px; }
    .fact b { color: #f24257; }
    .body { max-height: 46vh; overflow-y: auto; padding: 10px 12px 12px; }
    .lyr { display: flex; gap: 14px; }
    .col { flex: 1 1 0; min-width: 0; }
    .col h4 { margin: 0 0 4px; font-size: 10px; color: #8a8a8a; text-transform: uppercase; letter-spacing: .08em; }
    .col pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: inherit; font-size: 12.5px; }
    .col.es pre { color: #f5f5f5; }
    .col.en pre { color: #cfcfcf; }
    .note { color: #cfcfcf; font-size: 12px; padding: 4px 0; }
    .alts { border-top: 1px solid #262626; }
    .alts summary { cursor: pointer; color: #f24257; padding: 9px 12px; list-style: none; font-size: 12px; }
    .alts summary::-webkit-details-marker { display: none; }
    .alt { padding: 7px 12px; cursor: pointer; border-top: 1px solid #161616; }
    .alt:hover { background: #161616; }
    .alt .s { color: #cfcfcf; font-size: 11px; }
    .foot { padding: 8px 12px; border-top: 1px solid #262626; }
    .foot a { color: #f24257; text-decoration: none; font-size: 12px; }
    .foot a:hover { text-decoration: underline; }
    /* Contributors (composer/lyricist credit) — mirrors the side panel's .contrib. */
    .contrib { color: #9a9a9a; font-size: 11.5px; margin-top: 5px; white-space: pre-wrap; }
    .contrib:empty { display: none; }
    /* Grabaciones: other recordings of the same song, like the panel's renderRecs. */
    .recs { border-top: 1px solid #262626; }
    .recs .section-h { padding: 9px 12px 4px; margin: 0; }
    .recs ul { list-style: none; margin: 0; padding: 0 0 6px; max-height: 30vh; overflow-y: auto; }
    .recs li { padding: 6px 12px; border-top: 1px solid #161616; font-size: 12px; }   /* display-only */
    .recs li:first-child { border-top: none; }
    .recs li .r-sub { display: block; color: #9a9a9a; font-size: 11px; margin-top: 1px; }
    .assign { border-top: 1px solid #262626; padding: 8px 12px; }
    .asg-row { display: flex; gap: 8px; flex-wrap: wrap; }
    .asg-btn { flex: 1 1 auto; white-space: nowrap; background: #f24257; color: #fff; border: none; border-radius: 6px; padding: 7px 8px; font-size: 12px; cursor: pointer; }
    .asg-btn.ghost { background: transparent; color: #cfcfcf; border: 1px solid #333; }
    .asg-btn:hover { filter: brightness(1.08); }
    .asg-done { color: #4ec77f; font-size: 12px; }
    .asg-link { background: none; border: none; color: #f24257; cursor: pointer; font-size: 12px; padding: 0 0 0 6px; }
    .asg-search { margin-top: 8px; }
    .asg-search input { width: 100%; box-sizing: border-box; background: #161616; border: 1px solid #333; color: #f5f5f5; border-radius: 6px; padding: 7px 9px; font-size: 12px; }
    .asg-res { max-height: 180px; overflow-y: auto; margin-top: 6px; }
    .asg-hit { padding: 6px 8px; cursor: pointer; border-radius: 5px; font-size: 12px; }
    .asg-hit:hover { background: #161616; }
    .asg-hit .s { color: #9a9a9a; }
    .asg-empty { color: #9a9a9a; font-size: 12px; padding: 6px 8px; }
    @media (max-width: 520px) { .lyr { flex-direction: column; } }
    /* Interleaved lyrics: each Spanish line with its English translation right below.
       In synced mode (media playing) lines dim and the current one lights up coral. */
    .sbadge { display: inline-block; color: #f24257; font-size: 10px; font-weight: 700;
      letter-spacing: .08em; text-transform: uppercase; margin-bottom: 8px; }
    .lyrics-flow .lg { padding: 3px 0; }
    .lyrics-flow .lg .es { font-size: 13px; color: #f5f5f5; }
    .lyrics-flow .lg .en { font-size: 11.5px; color: #9a9a9a; margin-top: 1px; }
    .lyrics-flow.synced .lg { opacity: .45; transition: opacity .25s ease, color .25s ease; }
    .lyrics-flow.synced .lg.cur { opacity: 1; }
    .lyrics-flow.synced .lg.cur .es { color: #f24257; font-weight: 600; }
    .lyrics-flow.synced .lg.cur .en { color: #cfcfcf; }
    @media (prefers-reduced-motion: reduce) { .lyrics-flow.synced .lg { transition: none; } }
    /* Timing authoring tool */
    .au-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding-bottom: 8px; }
    .au-mark { background: #1c1c1c; color: #f5f5f5; border: 1px solid #333; border-radius: 6px;
      padding: 4px 8px; font-size: 11px; cursor: pointer; }
    .au-mark:hover { border-color: #f24257; }
    .au-chip { font-size: 11px; color: #f24257; min-width: 30px; }
    .au-prog { margin-left: auto; font-size: 11px; color: #8a8a8a; }
    .au-tip { font-size: 11px; color: #8a8a8a; padding: 0 0 8px; }
    .au-tip b { color: #f24257; }
    .au-lines { max-height: 30vh; overflow-y: auto; }
    .au-ln { display: flex; gap: 8px; align-items: baseline; padding: 3px 4px; border-radius: 5px; cursor: pointer; }
    .au-ln:hover { background: #1a1a1a; }
    .au-ln.cur { box-shadow: inset 2px 0 0 #f24257; background: #1d1416; }
    .au-ln .au-t { font-size: 10.5px; color: #6a6a6a; min-width: 42px; font-variant-numeric: tabular-nums; }
    .au-ln.done .au-t { color: #f24257; }
    .au-ln .au-x { font-size: 12.5px; color: #cfcfcf; }
    .au-ln.cur .au-x { color: #f5f5f5; }
    .au-foot { display: flex; gap: 6px; align-items: center; padding-top: 9px; margin-top: 6px;
      border-top: 1px solid #262626; flex-wrap: wrap; }
    .au-tap { flex: 1 1 auto; background: #f24257; color: #fff; border: none; border-radius: 7px;
      padding: 9px; font-weight: 700; font-size: 13px; cursor: pointer; letter-spacing: .03em; }
    .au-tap:hover { background: #ff5a6e; }
    .au-btn { background: #1c1c1c; color: #cfcfcf; border: 1px solid #333; border-radius: 6px;
      padding: 8px 10px; font-size: 12px; cursor: pointer; }
    .au-btn:hover { color: #f5f5f5; border-color: #555; }
    .au-btn.primary { background: #161616; color: #f24257; border-color: #f24257; }
    .au-saved { color: #4ec77f; font-size: 11px; padding-top: 6px; }
    /* Docked timing panel: full-height on the right edge (like the app) so you watch the
       video on the left and time on the right. Applied to .wrap while authoring. */
    .wrap.dock { right: 0; top: 0; bottom: 0; width: 420px; max-width: 92vw; }
    .wrap.dock .card { height: 100vh; border-radius: 0; border-top: 0; border-bottom: 0; border-right: 0;
      display: flex; flex-direction: column; box-shadow: -10px 0 34px rgba(0,0,0,.55); }
    .wrap.dock .facts, .wrap.dock .alts, .wrap.dock .foot, .wrap.dock .lyr-bar { display: none; }   /* maximize editor/timing room */
    .wrap.dock .body { flex: 1 1 auto; min-height: 0; max-height: none; display: flex; flex-direction: column; }
    .wrap.dock .author { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; width: 100%; }
    .wrap.dock .au-lines { flex: 1 1 auto; min-height: 0; max-height: none; }
    .wrap.dock .au-ln { padding: 5px 6px; }
    .wrap.dock .au-ln .au-x { font-size: 14px; }
    .wrap.dock .le-rows { flex: 1 1 auto; min-height: 0; max-height: none; }
    /* Lyrics actions bar + the in-overlay lyrics editor (shadow DOM has no panel.css). */
    .lyr-bar { display: flex; gap: 6px; padding: 8px 12px 0; }
    .lyr-bar button { background: #161616; color: #f24257; border: 1px solid #f24257;
      border-radius: 6px; padding: 4px 11px; font-size: 12px; font-weight: 600; cursor: pointer; }
    .lyr-bar button:hover { background: #1c1c1c; }
    .section-h { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: #8a8a8a; margin: 0 0 8px; }
    .section-h .time-btn { float: right; text-transform: none; letter-spacing: 0; background: #161616;
      color: #f24257; border: 1px solid #f24257; border-radius: 6px; padding: 3px 9px; font-size: 11px; cursor: pointer; }
    .le-tip { font-size: 11px; color: #8a8a8a; padding: 0 0 8px; } .le-tip b { color: #f24257; }
    .le-rows { display: flex; flex-direction: column; gap: 6px; max-height: 34vh; overflow-y: auto; }
    .le-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 5px; align-items: start; }
    .le-row textarea { width: 100%; resize: none; overflow: hidden; min-height: 28px; background: #0e0e0e;
      color: #f5f5f5; border: 1px solid #333; border-radius: 6px; padding: 5px 7px; font: inherit; font-size: 12.5px; line-height: 1.35; }
    .le-row .le-en { color: #cfcfcf; }
    .le-row textarea:focus { border-color: #f24257; outline: none; }
    .le-ctl { display: flex; flex-direction: column; gap: 3px; }
    .le-ctl button { background: #161616; border: 1px solid #333; border-radius: 5px; color: #8a8a8a;
      cursor: pointer; width: 24px; height: 24px; font-size: 12px; line-height: 1; }
    .le-ctl button:hover:not(:disabled) { color: #f5f5f5; border-color: #555; }
    .le-ctl button:disabled { opacity: .35; cursor: default; }
    .le-foot { display: flex; gap: 6px; align-items: center; padding-top: 10px; flex-wrap: wrap; }
    .le-foot .btn { background: #f24257; color: #fff; border: 0; border-radius: 7px; padding: 8px 12px;
      font-weight: 700; font-size: 13px; cursor: pointer; }
    .le-foot .pt-mini { background: #1c1c1c; color: #cfcfcf; border: 1px solid #333; border-radius: 6px;
      padding: 6px 10px; font-size: 12px; cursor: pointer; }
    .le-by-view { color: #cfcfcf; font-size: 12px; line-height: 1.45; white-space: pre-wrap;
      background: #141414; border: 1px solid #333; border-radius: 6px; padding: 7px 9px; }
    .le-by-empty { color: #8a8a8a; font-style: italic; }
    .le-grab-h { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: #8a8a8a; margin: 8px 0 4px; }
    .le-grab { margin: 8px 0 2px; }
    .le-grab-add { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
    .le-grab-o, .le-grab-s { flex: 1 1 130px; min-width: 0; background: #0e0e0e; color: #f5f5f5;
      border: 1px solid #333; border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 12px; }
    .le-grab-o:focus, .le-grab-s:focus { border-color: #f24257; outline: none; }
    .le-grab-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
    .le-grab-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #cfcfcf;
      background: #161616; border: 1px solid #333; border-radius: 6px; padding: 4px 6px 4px 9px; }
    .le-grab-item span { flex: 1 1 auto; min-width: 0; }
    .le-grab-del { margin-left: auto; background: none; border: 0; color: #8a8a8a; cursor: pointer; font-size: 15px; line-height: 1; }
    .le-grab-del:hover { color: #f24257; }
    .pt-saved { color: #4ec77f; font-size: 11px; }
  `;

  function ensure() {
    if (host) return;
    host = document.createElement("div");
    host.id = "eleje-overlay-host";
    shadow = host.attachShadow({ mode: "open" });
    const style = document.createElement("style");
    style.textContent = STYLE;
    root = document.createElement("div");
    root.className = "wrap";
    shadow.append(style, root);
    document.documentElement.appendChild(host);
  }

  function esc(s) {
    return (s == null ? "" : String(s)).replace(/&/g, "&amp;").replace(/</g, "&lt;")
      .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  }
  const send = (m) => chrome.runtime.sendMessage(m).catch(() => null);

  function stopSync() { if (sync) { try { sync.stop(); } catch (e) { /* noop */ } sync = null; } }

  // Format seconds as m:ss.t for the authoring chips.
  function fmtTime(s) {
    if (!Number.isFinite(s)) return "—";
    const m = Math.floor(s / 60), r = s - m * 60;
    return m + ":" + (r < 10 ? "0" : "") + r.toFixed(1);
  }

  // Re-center the scroll container on `target`. Plain assignment (not
  // scrollTo({behavior:"smooth"}), which is a no-op in some engines) so the current
  // line reliably lands in view on every engine.
  function centerScroll(el, target) { el.scrollTop = Math.max(0, target); }

  function hide() { stopSync(); endAuthorMode(); setPlayerBrand(false); if (root) root.innerHTML = ""; current = null; candidates = []; }

  // The media playing on this page. YouTube can have several <video> elements (main
  // player, inline previews, ads) — prefer the main player, then any that's actually
  // playing/has progressed, so timing never reads a parked-at-0 element.
  function findMedia() {
    const main = document.querySelector("video.html5-main-video")
      || document.querySelector(".html5-video-player video");
    if (main) return main;
    const media = [...document.querySelectorAll("video, audio")];
    return media.find(m => !m.paused && m.currentTime > 0)
      || media.find(m => m.currentTime > 0)
      || media.find(m => Number.isFinite(m.duration) && m.duration > 0)
      || media[0] || null;
  }

  // The warp/sync module is a web-accessible ES module; load it lazily, once.
  async function loadSyncModule() {
    if (syncMod) return syncMod;
    try {
      const getURL = typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.getURL;
      const url = getURL ? chrome.runtime.getURL("src/lyric_sync.js") : null;
      if (!url) return null;
      syncMod = await import(url);
    } catch (e) { syncMod = null; }
    return syncMod;
  }

  async function loadAuthorModule() {
    if (authorMod) return authorMod;
    try {
      const getURL = typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.getURL;
      const url = getURL ? chrome.runtime.getURL("src/author.js") : null;
      if (!url) return null;
      authorMod = await import(url);
    } catch (e) { authorMod = null; }
    return authorMod;
  }

  let editMod = null;       // cached lyrics_edit.js module
  async function loadEditModule() {
    if (editMod) return editMod;
    try {
      const getURL = typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.getURL;
      const url = getURL ? chrome.runtime.getURL("src/lyrics_edit.js") : null;
      if (!url) return null;
      editMod = await import(url);
    } catch (e) { editMod = null; }
    return editMod;
  }

  // Tear down the authoring keyboard hook (called whenever the card is replaced/hidden).
  function endAuthorMode() {
    authoring = false;
    authorSession = null;
    authorGroups = null;
    if (root) root.classList.remove("dock");
    if (onAuthorKey) { window.removeEventListener("keydown", onAuthorKey, true); onAuthorKey = null; }
  }

  function byline(s) { return [s.o, s.s, s.y].filter(Boolean).map(esc).join(" · "); }

  function renderPill(confident) {
    stopSync();
    endAuthorMode();
    const label = current ? esc(current.t) : "no match";
    root.innerHTML =
      `<div class="pill ${confident ? "" : "muted"}" id="elejePill">
         <span class="dot">♪</span> El&nbsp;Eje · ${label}</div>`;
    root.querySelector("#elejePill").addEventListener("click", () => { collapsed = false; renderCard(); });
  }

  // Flag the current match as wrong/missing for the dev feedback loop (mirrored to the local
  // sink). Captures the video + what El Eje picked + an optional correction the user types.
  function reportWrong() {
    const note = prompt(
      "Report this detection. What SHOULD it be?  e.g. \"Verdemar — Di Sarli\", or \"not a tango\".\n" +
      "Leave blank to just flag the current match as wrong.", "");
    if (note === null) return;                       // cancelled
    send({ type: "reportMisdetect", rec: {
      url: location.href,
      videoId: (location.search.match(/[?&]v=([^&]+)/) || [])[1] || "",
      videoTitle: getVideoTitle(),
      detected: current ? { id: current.id, t: current.t, o: current.o, s: current.s, y: current.y } : null,
      verdict: note.trim() ? "correction" : "wrong",
      note: note.trim() || null,
    } });
    const b = root.querySelector("#elejeReport");
    if (b) { b.textContent = "✓"; b.title = "Reported — thanks"; }
  }

  // "Assign the right tango to this page." Confirms a track for the current page: it persists
  // (background confirms store → wins on the next load via handleMatch) and is mirrored to the
  // dev feedback sink as `eleje_confirms`. Accept the current match, or search the library for
  // the correct one when detection is wrong/missing.
  async function renderAssign() {
    const slot = root && root.querySelector("#elejeAssign");
    if (!slot) return;
    const url = location.href;
    const conf = await send({ type: "getConfirm", url });
    if (conf && conf.confirm && current && conf.confirm.songId === current.id) {
      slot.innerHTML = `<div class="asg-done">✓ Saved as the track for this page<button class="asg-link" id="asgUndo">Undo</button></div>`;
      slot.querySelector("#asgUndo").addEventListener("click", async () => {
        await send({ type: "removeConfirm", url });
        renderAssign();
      });
      return;
    }
    slot.innerHTML =
      `<div class="asg-row">
         <button class="asg-btn" id="asgYes">✓ This is the track</button>
         <button class="asg-btn ghost" id="asgNo">Not this — search</button>
         <button class="asg-btn ghost" id="asgNot">Not a tango</button>
       </div>
       <div class="asg-search" id="asgSearch" hidden>
         <input id="asgQ" type="search" placeholder="Search the library for the right tango…" autocomplete="off" spellcheck="false">
         <div class="asg-res" id="asgRes"></div>
       </div>`;
    slot.querySelector("#asgYes").addEventListener("click", async () => {
      if (!current) return;
      await send({ type: "saveConfirm", url, song: { id: current.id, t: current.t, o: current.o, s: current.s, y: current.y, l: current.l } });
      renderAssign();
    });
    const box = slot.querySelector("#asgSearch");
    slot.querySelector("#asgNo").addEventListener("click", () => {
      box.hidden = !box.hidden;
      if (!box.hidden) slot.querySelector("#asgQ").focus();
    });
    slot.querySelector("#asgNot").addEventListener("click", async () => {
      const u = location.href;
      send({ type: "reportMisdetect", rec: {
        url: u, videoId: (location.search.match(/[?&]v=([^&]+)/) || [])[1] || "",
        videoTitle: getVideoTitle(),
        detected: current ? { id: current.id, t: current.t, o: current.o } : null,
        verdict: "not_tango",
      } });
      await send({ type: "markNotTango", url: u });   // persists: the overlay stays quiet here on reload
      dismissedKey = lastKey; hide();
    });
    const input = slot.querySelector("#asgQ");
    input.addEventListener("keydown", (e) => e.stopPropagation());   // don't let Space pause the video
    let timer = null;
    input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => assignSearch(input.value), 200); });
  }

  async function assignSearch(q) {
    const res = root && root.querySelector("#asgRes");
    if (!res) return;
    if (!q || q.trim().length < 2) { res.innerHTML = ""; return; }
    const r = await send({ type: "search", q, limit: 8 });
    const list = (r && r.results) || [];
    if (!list.length) { res.innerHTML = `<div class="asg-empty">No match in the library.</div>`; return; }
    res.innerHTML = list.map(s =>
      `<div class="asg-hit" data-id="${esc(s.id)}">${esc(s.t)}<span class="s"> — ${esc([s.o, s.y].filter(Boolean).join(" · "))}</span></div>`).join("");
    res.querySelectorAll(".asg-hit").forEach(el =>
      el.addEventListener("click", () => assignPick(el.dataset.id, list)));
  }

  async function assignPick(id, list) {
    const song = list.find(s => s.id === id);
    if (!song) return;
    const url = location.href;
    // Record what changed (auto-detected → user-assigned) for the feedback loop, then confirm.
    send({ type: "reportMisdetect", rec: {
      url, videoId: (location.search.match(/[?&]v=([^&]+)/) || [])[1] || "",
      videoTitle: getVideoTitle(),
      detected: current ? { id: current.id, t: current.t, o: current.o } : null,
      assigned: { id: song.id, t: song.t, o: song.o, y: song.y },
      verdict: "reassigned",
    } });
    await send({ type: "saveConfirm", url, song });
    candidates = [song, ...candidates.filter((c) => c.id !== song.id)];
    current = song;
    if (current.__lyrics === undefined) {
      const [lr, ar] = await Promise.all([send({ type: "getLyrics", id }), send({ type: "getAlignment", id })]);
      current.__lyrics = (lr && lr.lyrics) || null;
      current.__align = (ar && ar.align) || null;
    }
    renderCard();
  }

  function renderCard() {
    if (!current) return;
    const facts = [current.g && `<span class="fact">${esc(current.g)}</span>`,
      current.k && `<span class="fact"><b>Key</b> ${esc(current.k)}</span>`,
      current.b && `<span class="fact"><b>BPM</b> ${esc(current.b)}</span>`].filter(Boolean).join("");
    const yt = encodeURIComponent([current.o, current.t, current.s].filter(Boolean).join(" "));
    root.innerHTML = `
      <div class="card">
        <div class="hd">
          <div>
            <div class="brand">El Eje</div>
            <div class="t">${esc(current.t)}</div>
            <div class="by">${byline(current)}</div>
            <div class="contrib" id="elejeContrib"></div>
          </div>
          <div class="x">
            <button id="elejeReport" title="Report a wrong or missing detection">✗</button>
            <button id="elejeMin" title="Collapse">–</button>
            <button id="elejeClose" title="Hide for this video">✕</button>
          </div>
        </div>
        ${facts ? `<div class="facts">${facts}</div>` : ""}
        <div class="lyr-bar">
          <button id="elejeEditLyrics" title="Edit lyric lines or add a grabación to the credits">✎ Lyrics &amp; credits</button>
          ${current.l ? `<button id="elejeAuthor" title="Tap along to time each line">♪ Timing</button>` : ""}
        </div>
        <div class="body" id="elejeBody"><div class="note">Loading lyrics…</div></div>
        ${candidates.length > 1 ? `
        <details class="alts">
          <summary>Not this recording? Pick a version (${candidates.length - 1})</summary>
          ${candidates.map(c => c.id === current.id ? "" :
            `<div class="alt" data-id="${esc(c.id)}">${esc(c.t)}
               <div class="s">${byline(c)}${c.l ? " · lyrics" : ""}</div></div>`).join("")}
        </details>` : ""}
        <div class="assign" id="elejeAssign"></div>
        <div class="recs" id="elejeRecs"></div>
        <div class="foot"><a href="https://music.youtube.com/search?q=${yt}" target="_blank" rel="noopener">Search this recording ▸</a></div>
      </div>`;
    root.querySelector("#elejeMin").addEventListener("click", () => { collapsed = true; renderPill(true); });
    root.querySelector("#elejeClose").addEventListener("click", () => { dismissedKey = lastKey; hide(); });
    root.querySelector("#elejeReport")?.addEventListener("click", reportWrong);
    root.querySelector("#elejeAuthor")?.addEventListener("click", enterAuthor);
    root.querySelector("#elejeEditLyrics")?.addEventListener("click", enterLyricsEditOverlay);
    root.querySelectorAll(".alt").forEach(el =>
      el.addEventListener("click", () => pick(el.dataset.id)));
    renderAssign();
    renderRecs();
    fillLyrics(current.__lyrics, current.__align);
  }

  // Grabaciones — other recordings of THIS song (same title, other orchestras/years),
  // mirroring the side panel's "Other recordings of …" list. Async-filled; clicking a
  // row switches the card to that recording (loads its lyrics).
  async function renderRecs() {
    const slot = root && root.querySelector("#elejeRecs");
    if (!slot || !current) return;
    const recGen = current.id;
    const r = await send({ type: "otherRecordings", id: current.id });
    if (!root || !current || current.id !== recGen) return;   // user switched away
    const list = (r && r.recordings) || [];
    if (!list.length) { slot.innerHTML = ""; return; }
    // DISPLAY-ONLY: the grabaciones list is the currently-playing track's other recordings —
    // NOT clickable. A stray tap would switch the card to a different recording and risk
    // mis-confirming the playing video, accumulating noise in the crowd data.
    slot.innerHTML =
      `<div class="section-h">Grabaciones de “${esc(current.t)}” (${list.length})</div>
       <ul>${list.map(o =>
         `<li>${esc(o.o || "Unknown")}${o.s ? " · " + esc(o.s) : ""}
            <span class="r-sub">${[o.y, o.g].filter(Boolean).map(esc).join(" · ")}${o.l ? " · lyrics" : ""}</span></li>`).join("")}
       </ul>`;
  }

  // Interleaved lyric lines: Spanish with its English translation right below.
  // `groups` = [{i, es, en}] (dot-only / blank lines already dropped). `badge` is the
  // sync label (e.g. "♪ synced") or null for the static view; when set, the dim/
  // highlight affordance is on. Returns the rendered .lg elements in order.
  function renderFlow(body, groups, badge) {
    const rows = groups.map((g, pos) =>
      `<div class="lg" data-pos="${pos}">
         <div class="es">${esc(g.es)}</div>
         ${g.en ? `<div class="en">${esc(g.en)}</div>` : ""}
       </div>`).join("");
    body.innerHTML =
      `${badge ? `<div class="sbadge">${esc(badge)}</div>` : ""}
       <div class="lyrics-flow${badge ? " synced" : ""}">${rows}</div>`;
    return body.querySelectorAll(".lyrics-flow .lg");
  }

  // Ultimate fallback if the sync/lyrics module can't load: the old two-column panel.
  function staticTwoColumn(body, ly) {
    body.innerHTML = `<div class="lyr">
      <div class="col es"><h4>Español</h4><pre>${esc(ly.es)}</pre></div>
      <div class="col en"><h4>English</h4><pre>${esc(ly.en)}</pre></div></div>`;
  }

  async function fillLyrics(ly, align) {
    const gen = ++fillGen;     // a later render (fast re-pick) supersedes this one
    stopSync();
    const body = root.querySelector("#elejeBody");
    if (!body) return;
    const mod = await loadSyncModule();
    const emod = await loadEditModule();
    if (gen !== fillGen) return;
    // Prefer the user's saved lyric edit (fixed wording / added lines) for THIS song,
    // so edits show + become timeable here too — mirrors the side panel.
    let edited = false;
    if (emod && current) {
      try {
        const e = current.id ? await emod.loadLyricEdit(current.id) : null;
        // Prefer the edited lyrics AND the edited Grabado/credits (apply even a credits-only edit).
        if (e && (e.es || e.en || e.by)) {
          ly = { es: e.es, en: e.en, by: (e.by != null ? e.by : (ly && ly.by)) };
          edited = true;
        }
      } catch (e2) { /* ignore a bad stored edit */ }
    }
    if (gen !== fillGen) return;

    // Reflect edit state on the always-visible "✎ Lyrics & credits" bar button.
    const eb = root.querySelector("#elejeEditLyrics");
    const hasLyrics = !!(ly && (ly.es || ly.en));
    if (eb) eb.textContent = edited ? "✎ Lyrics & credits ✱" : hasLyrics ? "✎ Lyrics & credits" : "✎ Add lyrics & credits";
    const cb = root.querySelector("#elejeContrib");   // contributors (composer/lyricist)
    if (cb) cb.textContent = ly && ly.by ? ly.by : "";

    if (!hasLyrics) {
      body.innerHTML = `<div class="note">No lyrics in the El Eje library — tap “✎ Add lyrics &amp; credits” to add them.</div>`;
      return;
    }
    const groups = mod ? mod.lyricGroups(ly) : null;
    if (!groups || !groups.length) { staticTwoColumn(body, ly); return; }
    // Try timed sync against the page's media; otherwise show the interleaved lyrics static.
    let ok = false;
    try { ok = await renderSynced(body, ly, align, groups, mod, gen); } catch (e) { /* static */ }
    if (gen !== fillGen) return;
    if (!ok) renderFlow(body, groups, null);
  }

  // Edit/add lyrics right in the overlay (docked for room). On done, re-render — which
  // now prefers the saved edit, so the new lines show and become timeable.
  async function enterLyricsEditOverlay() {
    if (!current) return;
    const emod = await loadEditModule();
    const body = root.querySelector("#elejeBody");
    if (!emod || !body) return;
    stopSync();
    endAuthorMode();
    let base = current.__lyrics || { es: "", en: "" };
    try {
      const e = await emod.loadLyricEdit(current.id);
      // Preload the saved edit INCLUDING the Grabado/credits + structured grabaciones, falling
      // back to the bundled snapshot's values — else opening the editor would blank `by` and
      // Save would wipe existing credits. Apply a credits-only edit too (guard includes e.by).
      if (e && (e.es || e.en || e.by != null)) {
        base = {
          es: e.es != null ? e.es : base.es,
          en: e.en != null ? e.en : base.en,
          by: e.by != null ? e.by : base.by,
          byBase: e.byBase,
          grabaciones: e.grabaciones,
        };
      }
    } catch (e2) { /* ignore */ }
    if (root) root.classList.add("dock");   // full-height editor, like the timing panel
    emod.mountLyricsEditor({
      slot: body, song: current, ly: base,
      onDone: () => { if (root) root.classList.remove("dock"); renderCard(); },
    });
  }

  // Per-group times from a contribution whose times are ALREADY in this video's clock
  // (the user timed THIS upload) — no warp. null if too few lines map.
  function exactGroupTimes(c, groups) {
    if (!c || !Array.isArray(c.lines) || !c.lines.length) return null;
    const m = new Map();
    for (const l of c.lines) if (Number.isFinite(l.t)) m.set(l.i, l.t);
    const times = groups.map(g => (m.has(g.i) ? m.get(g.i) : null));
    return times.filter(x => x != null).length >= 2 ? times : null;
  }

  // Drive the current-line highlight + auto-scroll off the media clock. Timing source,
  // best first: (1) your OWN timing for THIS upload (no warp); (2) your timing from
  // ANOTHER upload of the same song, warped to this video's duration; (3) the bundled
  // app alignment, warped. Returns false (caller shows static) when none apply.
  async function renderSynced(body, ly, align, groups, mod, gen) {
    const media = findMedia();
    if (!media) return false;
    const hasAlign = align && Array.isArray(align.t) && align.t.length === ly.es.split("\n").length;
    const amod = await loadAuthorModule();
    if (gen !== fillGen) return false;
    const videoId = new URLSearchParams(location.search).get("v");

    // (1) exact, this upload — doesn't need duration, so it can render immediately.
    let exact = null;
    if (amod && videoId) {
      try { exact = exactGroupTimes(await amod.loadContribution(videoId), groups); } catch (e) { /* ignore */ }
    }
    if (gen !== fillGen) return false;
    // (2) is a transfer possible? — your timing for this song on a different upload.
    let transferSrc = null;
    if (amod && !exact && current && current.id) {
      try { transferSrc = amod.pickSongContribution(await amod.allContributions(), current.id, videoId || undefined); }
      catch (e) { /* ignore */ }
    }
    if (gen !== fillGen) return false;
    if (!exact && !transferSrc && !hasAlign) return false;   // nothing to sync

    const build = () => {
      if (gen !== fillGen) return false;
      const durOK = Number.isFinite(media.duration) && media.duration > 0;
      let groupTimes = null, badge = "♪ synced";
      if (exact) { groupTimes = exact; badge = "♪ your timing"; }
      else if (transferSrc && durOK) {
        const byEs = amod.transferredLineTimes(transferSrc, media.duration);   // {esIndex: sec}
        const t = byEs ? groups.map(g => (g.i in byEs ? byEs[g.i] : null)) : null;
        if (t && t.filter(x => x != null).length >= 2) { groupTimes = t; badge = "♪ your timing (adapted)"; }
      }
      if (!groupTimes && hasAlign && durOK) {                // bundled alignment, warped
        const warp = mod.makeWarp({ appEnd: align.d != null ? align.d : null, onlineEnd: media.duration });
        if (warp) { groupTimes = groups.map(g => mod.mapLineTimes(align.t, warp)[g.i]); badge = "♪ synced"; }
      }
      if (!groupTimes) return false;
      const els = renderFlow(body, groups, badge);
      let prev = -1;
      const setActive = (pos) => {
        if (prev >= 0 && els[prev]) els[prev].classList.remove("cur");
        prev = pos;
        const e = els[pos];
        if (!e) return;
        e.classList.add("cur");
        // Center the current line within the scrollable body.
        const top = body.scrollTop + (e.getBoundingClientRect().top - body.getBoundingClientRect().top)
          - body.clientHeight / 2 + e.clientHeight / 2;
        centerScroll(body, top);
      };
      sync = new mod.LyricSync(media, groupTimes, setActive).start();
      return true;
    };

    // Exact renders now; transfer/align need duration — wait briefly if it's not ready.
    if (exact || (Number.isFinite(media.duration) && media.duration > 0)) return build();
    return await new Promise((resolve) => {
      let settled = false;
      const finish = (v) => { if (settled) return; settled = true; cleanup(); resolve(v); };
      const onMeta = () => finish(build());
      const cleanup = () => {
        media.removeEventListener("loadedmetadata", onMeta);
        media.removeEventListener("durationchange", onMeta);
      };
      media.addEventListener("loadedmetadata", onMeta);
      media.addEventListener("durationchange", onMeta);
      setTimeout(() => finish(false), 4000);
    });
  }

  // ---- Timing authoring: tap along to capture per-line timestamps -----------
  async function enterAuthor() {
    if (!current) return;
    const [mod, amod] = await Promise.all([loadSyncModule(), loadAuthorModule()]);
    const body = root.querySelector("#elejeBody");
    if (!body) return;
    if (!mod || !amod || !current.__lyrics) {
      body.innerHTML = `<div class="note">Timing needs the lyrics module — try reloading the extension.</div>`;
      return;
    }
    const groups = mod.lyricGroups(current.__lyrics);
    if (!groups.length) { body.innerHTML = `<div class="note">No lyrics to time for this recording.</div>`; return; }
    authorGroups = groups;
    const videoId = new URLSearchParams(location.search).get("v") || "";
    let prior = null;
    try {
      const stored = await amod.loadContribution(videoId);
      if (stored) {
        const posByEs = new Map(groups.map((g, pos) => [g.i, pos]));   // original es index → group position
        prior = {
          startSec: stored.startSec, endSec: stored.endSec,
          lines: (stored.lines || []).map(l => ({ i: posByEs.get(l.i), t: l.t })).filter(l => Number.isInteger(l.i)),
        };
      }
    } catch (e) { /* ignore a bad stored record */ }
    authorSession = new amod.TimingSession(groups.length, prior);
    authoring = true;
    if (root) root.classList.add("dock");   // expand to the full-height right-side timing panel
    onAuthorKey = (e) => {
      if (!authoring) return;
      if (e.code === "Space") { e.preventDefault(); e.stopPropagation(); authorStampNext(); }
      else if (e.code === "Backspace") { e.preventDefault(); e.stopPropagation(); authorSession.undo(); auRefresh(); }
    };
    window.addEventListener("keydown", onAuthorKey, true);   // capture, so YouTube doesn't pause on Space
    renderAuthor();
  }

  function renderAuthor() {
    const body = root.querySelector("#elejeBody");
    if (!body || !authorSession || !authorGroups) return;
    const rows = authorGroups.map((g, pos) =>
      `<div class="au-ln" data-pos="${pos}"><span class="au-t" data-pos="${pos}"></span><span class="au-x">${esc(g.es)}</span></div>`).join("");
    body.innerHTML = `<div class="author">
      <div class="au-bar">
        <button class="au-mark" id="auStart">▸ start</button><span class="au-chip" id="auStartChip">—</span>
        <span class="au-prog" id="auProg"></span>
        <span class="au-chip" id="auEndChip">—</span><button class="au-mark" id="auEnd">end ◂</button>
      </div>
      <div class="au-tip">Play the video; press <b>Space</b> (or tap a line) as each line begins. <b>⌫</b> undoes.</div>
      <div class="au-lines" id="auLines">${rows}</div>
      <div class="au-saved" id="auSaved" style="display:none"></div>
      <div class="au-foot">
        <button class="au-tap" id="auTap">TAP the next line ⌴</button>
        <button class="au-btn" id="auUndo" title="Undo last">↺</button>
        <button class="au-btn primary" id="auSave">✓ Save</button>
        <button class="au-btn" id="auExit">Exit</button>
      </div></div>`;
    const m = () => findMedia();
    body.querySelector("#auTap").addEventListener("click", authorStampNext);
    body.querySelector("#auUndo").addEventListener("click", () => { authorSession.undo(); auRefresh(); });
    body.querySelector("#auSave").addEventListener("click", authorSave);
    body.querySelector("#auExit").addEventListener("click", exitAuthor);
    body.querySelector("#auStart").addEventListener("click", () => { const v = m(); if (v) { authorSession.markStart(v.currentTime); auRefresh(); } });
    body.querySelector("#auEnd").addEventListener("click", () => { const v = m(); if (v) { authorSession.markEnd(v.currentTime); auRefresh(); } });
    body.querySelectorAll(".au-ln").forEach(el =>
      el.addEventListener("click", () => { const v = m(); if (v) { authorSession.stampLine(+el.dataset.pos, v.currentTime); auRefresh(); } }));
    auRefresh();
  }

  function auRefresh() {
    const body = root.querySelector("#elejeBody");
    if (!body || !authorSession) return;
    const s = authorSession;
    body.querySelectorAll(".au-ln").forEach((el) => {
      const pos = +el.dataset.pos, t = s.times[pos];
      el.classList.toggle("done", t != null);
      el.classList.toggle("cur", pos === s.cursor);
      el.querySelector(".au-t").textContent = t != null ? fmtTime(t) : (pos === s.cursor ? "▸" : "");
    });
    const set = (id, txt) => { const e = body.querySelector(id); if (e) e.textContent = txt; };
    set("#auProg", s.stampedCount() + "/" + s.n);
    set("#auStartChip", s.start != null ? fmtTime(s.start) : "—");
    set("#auEndChip", s.end != null ? fmtTime(s.end) : "—");
    const curEl = body.querySelector(".au-ln.cur"), lines = body.querySelector("#auLines");
    if (curEl && lines) {
      const top = lines.scrollTop + (curEl.getBoundingClientRect().top - lines.getBoundingClientRect().top) - lines.clientHeight / 2;
      lines.scrollTop = Math.max(0, top);
    }
  }

  function authorStampNext() {
    const v = findMedia();
    if (!v || !authorSession) return;
    authorSession.stampNext(v.currentTime);
    auRefresh();
  }

  async function authorSave() {
    const amod = await loadAuthorModule();
    if (!amod || !authorSession || !authorGroups) return;
    const v = findMedia();
    const videoId = new URLSearchParams(location.search).get("v") || "";
    const c = authorSession.build({
      videoId,
      url: videoId ? "https://www.youtube.com/watch?v=" + videoId : location.href,
      videoTitle: getVideoTitle(),
      song: current,
      onlineDuration: v ? v.duration : null,
      lineText: authorGroups.map(g => g.es),
    });
    // session indexes by GROUP position; store the ORIGINAL es-line index so the owner
    // can fold it straight onto the app's lyrics.
    c.lines = c.lines.map(l => ({ i: authorGroups[l.i] ? authorGroups[l.i].i : l.i, text: l.text, t: l.t }));
    const ok = await amod.saveContribution(c);
    const el = root.querySelector("#auSaved");
    if (el) {
      el.style.display = "";
      el.textContent = ok
        ? `Saved ✓ ${c.lines.length} lines · start ${fmtTime(c.startSec)} · end ${fmtTime(c.endSec)}`
        : "Couldn’t save (storage unavailable).";
    }
  }

  function exitAuthor() { endAuthorMode(); renderCard(); }

  // The El Eje action-bar button on YouTube, inserted to the LEFT of like/dislike.
  // Re-checked each poll tick so it survives YouTube's SPA re-renders.
  function ensureYouTubeButton() {
    if (location.pathname !== "/watch") return;
    if (document.getElementById("eleje-yt-btn")) return;
    // The like/dislike action row — YouTube renames/relocates this container across layouts,
    // which is why the button sometimes failed to appear. Try old + current + segmented-button
    // layouts, then fall back to the actions/menu wrapper so we always land somewhere visible.
    const row = document.querySelector("ytd-watch-metadata #top-level-buttons-computed")
      || document.querySelector("ytd-menu-renderer #top-level-buttons-computed")
      || document.querySelector("#top-level-buttons-computed")
      || document.querySelector("ytd-watch-metadata #actions-inner #menu")
      || document.querySelector("ytd-watch-metadata #actions-inner")
      || document.querySelector("ytd-watch-metadata #menu ytd-menu-renderer")
      || document.querySelector("#menu.ytd-watch-metadata");
    if (!row) return;
    const btn = document.createElement("button");
    btn.id = "eleje-yt-btn";
    btn.type = "button";
    btn.title = "El Eje — lyrics & timing";
    btn.style.cssText = "display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 14px;" +
      "margin-right:8px;border:none;border-radius:18px;cursor:pointer;flex-shrink:0;white-space:nowrap;" +
      "background:var(--yt-spec-badge-chip-background,rgba(255,255,255,.1));" +
      "color:var(--yt-spec-text-primary,#f1f1f1);font:500 14px/1 Roboto,Arial,sans-serif;";
    btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" style="display:block;flex:none">
        <circle cx="8" cy="8" r="4.6" fill="none" stroke="#f24257" stroke-width="1.6"/>
        <line x1="8" y1="2.3" x2="8" y2="13.7" stroke="#f24257" stroke-width="1.7" stroke-linecap="round"/>
      </svg><span>El Eje</span>`;
    btn.addEventListener("click", openCard);
    row.insertBefore(btn, row.firstChild);
  }

  function openCard() {
    collapsed = false;
    ensure();
    if (current) renderCard();
    else { const q = getMatchQuery(); if (q) runMatch(q, true); }
  }

  async function pick(id) {
    const c = candidates.find(x => x.id === id);
    if (!c) return;
    current = c;
    if (current.__lyrics === undefined) {
      const [lr, ar] = await Promise.all([
        send({ type: "getLyrics", id }),
        send({ type: "getAlignment", id }),
      ]);
      current.__lyrics = (lr && lr.lyrics) || null;
      current.__align = (ar && ar.align) || null;
    }
    collapsed = false;
    renderCard();
  }

  async function runMatch(title, userInitiated) {
    endAuthorMode();              // a new video ends any in-progress timing session
    // Pass the page URL (a previously-confirmed track for THIS video wins over title-
    // guessing), YouTube's own DETECTED song (its Content-ID match — tried first, before
    // title-guessing), and the DESCRIPTION (dance uploads name the orchestra there).
    const resp = await send({
      type: "match", title, url: location.href,
      music: getDetectedMusic(), context: getVideoDescription(),
    });
    if (resp && resp.notTango) { if (userInitiated) renderNotTango(); else hide(); return; }
    if (!resp || !resp.candidates || !resp.candidates.length) {
      // No title/page-detail match. Stay quiet on the passive poll; but when the user actually
      // invoked El Eje, fall back to identifying the track by its SOUND (the requested default).
      if (userInitiated) offerSoundDetect(); else hide();
      return;
    }
    candidates = resp.candidates;
    current = candidates[0];
    current.__lyrics = resp.lyrics || null;
    current.__align = resp.align || null;
    setPlayerBrand(true);       // brand the YouTube scrubber only while a track is matched
    ensure();
    if (collapsed || !resp.confident) renderPill(resp.confident);
    else renderCard();
    // Tell the side panel what's playing so it can auto-show this track's lyrics.
    if (resp.confident && current) {
      try {
        chrome.storage.local.set({ eleje_nowplaying: {
          songId: current.id, t: current.t, o: current.o, s: current.s,
          url: location.href, at: Date.now(),
        } });
      } catch (e) { /* storage may be unavailable in some contexts */ }
    }
  }

  function getVideoTitle() {
    const sels = [
      "h1.ytd-watch-metadata yt-formatted-string",
      "h1.ytd-watch-metadata",
      "#title h1 yt-formatted-string",
      "h1.title.ytd-video-primary-info-renderer",
    ];
    for (const s of sels) {
      const el = document.querySelector(s);
      const t = el && el.textContent && el.textContent.trim();
      if (t) return t;
    }
    const dt = document.title.replace(/\s*-\s*YouTube\s*$/i, "").trim();
    return dt && dt.toLowerCase() !== "youtube" ? dt : "";
  }

  // The uploading channel/artist. On YouTube "Topic"/Art-Track pages the video title is
  // just the SONG name and the orchestra is the channel ("Miguel Caló - Topic"), so we
  // read it and fold it into the match — without it a bare title like "Oigo tu voz"
  // can't pick the right orchestra among many versions.
  function getVideoChannel() {
    const sels = [
      "ytd-video-owner-renderer #channel-name #text a",
      "ytd-video-owner-renderer #channel-name a",
      "#owner #channel-name a",
      "#upload-info #channel-name a",
      "ytd-channel-name#channel-name a",
    ];
    for (const s of sels) {
      const el = document.querySelector(s);
      const t = el && el.textContent && el.textContent.trim();
      if (t) return t.replace(/\s*[-–—]\s*(Topic|Tema)\s*$/i, "").trim();
    }
    return "";
  }

  // The video DESCRIPTION (truncated) — dance/performance uploads usually name the
  // ORCHESTRA here, not in the title, so the matcher uses it to pick the right version.
  function getVideoDescription() {
    // Combine BOTH sources, keep the more complete one — the probe's description
    // (ytInitialPlayerResponse.shortDescription) is the full, always-present text, but fall
    // back to / prefer the rendered DOM if it somehow has more, so nothing is lost.
    const probe = (ytProbe && ytProbe.description) || "";
    let dom = "";
    const sels = [
      "ytd-watch-metadata #description-inline-expander",
      "ytd-text-inline-expander#description-inline-expander",
      "#description ytd-text-inline-expander",
      "#description.ytd-watch-metadata",
      "ytd-expandable-video-description-body-renderer",
    ];
    for (const sel of sels) {
      const el = document.querySelector(sel);
      const t = el && el.textContent && el.textContent.trim();
      if (t) { dom = t; break; }
    }
    return (probe.length >= dom.length ? probe : dom).slice(0, 1500);
  }

  // The string handed to the title matcher: video title + channel (the channel carries
  // the orchestra on Art-Track pages). Falls back to just the title.
  function getMatchQuery() {
    const t = getVideoTitle();
    if (!t) return "";
    const ch = getVideoChannel();
    return ch ? t + " " + ch : t;
  }

  // YouTube's OWN auto-detected song — its Content-ID match, almost always the actual
  // recording, so it's the strongest title-side signal. Gathered from EVERY source and
  // MERGED field-by-field so no detection is lost whether it lives in ytInitialData, the
  // auto-generated description, or only the rendered DOM. Returns {song, artist, album?,
  // year?} or null.
  function getDetectedMusic() {
    const txt = el => (el && el.textContent || "").replace(/\s+/g, " ").trim();
    const firstName = s => (s || "").split(/\s*[·•|]\s*/)[0].trim();   // "Artist · Album" → Artist
    const sources = [];
    // (1) ytInitialData structured "Music in this video" (via the MAIN-world probe).
    if (ytProbe && ytProbe.music) sources.push(ytProbe.music);
    // (2) Auto-generated "Provided to YouTube by …" block in the (probe or DOM) description.
    const desc = getVideoDescription();
    const pm = desc.match(/Provided to YouTube by[^\n]*[\r\n]+\s*(.+?)\s+[·•]\s+(.+?)\s*[\r\n]/i);
    if (pm) sources.push({ song: pm[1].trim(), artist: firstName(pm[2]) });
    // (2b) Human-written dance/performance descriptions (030tango, festivals, …) routinely
    //   name the recording explicitly, e.g.  …dance "Mozo guapo" by Ricardo Tanturi, sung by
    //   Alberto Castillo, at…  — pull the quoted SONG + the "by ORCHESTRA[, sung by SINGER]".
    const dm = desc.match(/["“”«]([^"“”«»\n]{2,70})["”»][^\S\n]+by[^\S\n]+([^,.\n;(]{2,50}?)(?:[^\S\n]*,?[^\S\n]*sung by[^\S\n]+([^,.\n;(]{2,50}))?[,.\n;(]/i);
    if (dm) sources.push({ song: dm[1].trim(), artist: (dm[2].trim() + (dm[3] ? " " + dm[3].trim() : "")).trim() });
    // (3) DOM structured "Music in this video" attribute cards.
    for (const vm of document.querySelectorAll("yt-video-attribute-view-model, ytd-video-attribute-view-model")) {
      const song = txt(vm.querySelector(".yt-video-attribute-view-model__title, [class*='__title'], #title"));
      const artist = firstName(txt(vm.querySelector(".yt-video-attribute-view-model__subtitle, [class*='__subtitle'], #subtitle")));
      if (song && song.length <= 120) { sources.push({ song, artist }); break; }
    }
    // (4) DOM Song / Artist metadata rows.
    let rsong = "", rartist = "", ralbum = "";
    for (const row of document.querySelectorAll("ytd-metadata-row-renderer, ytd-info-row-renderer")) {
      const label = txt(row.querySelector("#title, .title, .metadata-row-title")).toLowerCase();
      const value = txt(row.querySelector("#content, .content, .metadata-row-content, a"));
      if (!value) continue;
      if (!rsong && /\b(song|canci|t[ií]tulo|titre|lied|brano)\b/.test(label)) rsong = value;
      else if (!rartist && /\b(artist|artista|interpret|m[uú]sic)\b/.test(label)) rartist = value;
      else if (!ralbum && /\b(album|álbum|disco)\b/.test(label)) ralbum = value;
    }
    if (rsong) sources.push({ song: rsong, artist: rartist, album: ralbum });
    // MERGE: fill each field from the first source that supplies it — ytInitialData and the
    // DOM complement each other, so we never drop a song/artist one of them caught.
    const out = {};
    for (const m of sources) for (const k of ["song", "artist", "album"]) if (m[k] && !out[k]) out[k] = m[k];
    const ym = desc.match(/Released on:\s*(\d{4})/i) || desc.match(/[℗©]\s*(\d{4})/);
    if (ym) out.year = ym[1];
    return out.song ? out : null;
  }

  // Brand YouTube's own progress bar — coral played-track + an El Eje scrubber mark (white ring, black
  // centre and a coral axis line through it ("el eje" = the axis) — but ONLY while a tango is
  // matched (gated by runMatch/hide). The <style> must live in the PAGE (light DOM), not the
  // overlay's shadow root, to reach YouTube's player; it's a contain-fitted background slightly
  // larger than the default dot, and YouTube's own show/hide + hover-grow still apply.
  const SCRUBBER_SVG =
    "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E" +
    "%3Ccircle cx='8' cy='8' r='6.2' fill='%23000000' stroke='%23ffffff' stroke-width='1.5'/%3E" +
    "%3Cline x1='8' y1='2.7' x2='8' y2='13.3' stroke='%23f24257' stroke-width='1.9' stroke-linecap='round'/%3E" +
    "%3C/svg%3E";
  function setPlayerBrand(on) {
    const existing = document.getElementById("eleje-player-style");
    if (!on) { if (existing) existing.remove(); return; }
    if (existing) return;
    const s = document.createElement("style");
    s.id = "eleje-player-style";
    s.textContent = ".ytp-scrubber-button{" +
      "background:url(\"" + SCRUBBER_SVG + "\") center/contain no-repeat !important;" +
      "box-shadow:none !important;width:16px !important;height:16px !important;margin:-1.5px 0 0 -1.5px !important}" +
      ".ytp-play-progress{background-color:#f24257 !important}";   // tint the played track coral
    (document.head || document.documentElement).appendChild(s);
  }

  // No title/page-detail match, and the user asked for El Eje → offer to identify the track by
  // its SOUND. It's one tap (not silent) because Chrome only allows tab-audio capture from a
  // fresh user gesture; the tap opens the panel and runs detection there.
  // The page was marked "not a tango" — show a quiet pill with an Undo (only when the user
  // explicitly reopens El Eje; the passive poll just stays hidden, which is the point).
  function renderNotTango() {
    stopSync(); endAuthorMode(); setPlayerBrand(false);
    current = null; candidates = [];
    ensure();
    root.innerHTML =
      `<div class="pill muted"><span class="dot">✗</span> El&nbsp;Eje · not a tango ` +
      `<button class="asg-link" id="ntUndo">Undo</button></div>`;
    root.querySelector("#ntUndo").addEventListener("click", async () => {
      await send({ type: "removeConfirm", url: location.href });
      lastKey = "";                                   // force a fresh match
      const q = getMatchQuery();
      if (q) runMatch(q, true);
    });
  }

  function offerSoundDetect() {
    stopSync(); endAuthorMode(); setPlayerBrand(false);
    current = null; candidates = [];
    ensure();
    root.innerHTML =
      `<div class="pill muted" id="elejeDetectPill" title="Identify this track by its sound">` +
      `<span class="dot">♪</span> El&nbsp;Eje · detect by sound ▸</div>`;
    root.querySelector("#elejeDetectPill").addEventListener("click", () => send({ type: "fpOpenPanelDetect" }));
  }

  // Poll: handles SPA navigation + late-loading title/channel without fragile event coupling.
  setInterval(() => {
    if (location.pathname !== "/watch") { if (lastKey) { hide(); lastKey = ""; } return; }
    ensureYouTubeButton();        // (re)insert the action-bar button if YouTube re-rendered
    const q = getMatchQuery();    // title + channel (re-matches when the channel loads in)
    if (!q) return;
    // The page-context probe lands a beat after load; fold its state into the key so the
    // overlay RE-MATCHES (now with YouTube's detected song) once it arrives, instead of
    // sticking with the first title-only guess.
    const probeTag = ytProbe ? (ytProbe.music && ytProbe.music.song ? "m" : "d") : "";
    const key = location.search.split("&")[0] + "|" + q + "|" + probeTag;  // video id + query + probe
    if (key === lastKey) return;
    lastKey = key;
    collapsed = false;
    if (key === dismissedKey) return;
    runMatch(q);
  }, 1000);
})();
