// OrbitCosmo content script
// Extracts a lightweight summary of the page (title, description, keywords)
// plus the references it renders (scholarly meta tags, bibliography strings,
// in-body content links) and sends it to the background service worker.
// Runs once per page load — with one exception: on YouTube watch pages a
// transcript observer (watchForTranscript, below) may re-send once when the
// user opens the transcript panel and its text becomes rendered DOM.
// Reads the DOM only — never requests anything. The pure YouTube rules live
// in youtube.js (loaded ahead of this file by the manifest).

(function () {
  const STOPWORDS = new Set([
    "the","a","an","and","or","but","if","then","else","when","at","by","for","with",
    "about","against","between","into","through","during","before","after","above",
    "below","to","from","up","down","in","out","on","off","over","under","again",
    "further","once","here","there","all","any","both","each","few","more","most",
    "other","some","such","no","nor","not","only","own","same","so","than","too",
    "very","can","will","just","of","is","are","was","were","be","been","being","have",
    "has","had","do","does","did","this","that","these","those","i","you","he","she",
    "it","we","they","them","his","her","its","our","your","their","what","which","who",
    "whom","whose","how","why","as","that's","what's","also","may","new","one","two",
    "use","using","used","get","got","like","make","made","see","way","well","said"
  ]);

  function getMeta(name) {
    const el =
      document.querySelector(`meta[name="${name}"]`) ||
      document.querySelector(`meta[property="${name}"]`);
    return el ? (el.getAttribute("content") || "").trim() : "";
  }

  function topKeywords(text, limit) {
    // Break text into maximal runs of content words. A run ends at a stopword OR
    // at punctuation, so a phrase ("world war", "soviet union") never spans a
    // stopword ("end of the war" → "war", not "end war") or a sentence boundary
    // ("the Soviet Union. Germany…" → never "union germany"). Splitting on
    // punctuation but not spaces keeps multi-word phrases intact.
    const runs = [];
    for (const seg of text.toLowerCase().split(/[^a-z0-9'\- ]+/)) {
      let run = [];
      for (const w of seg.match(/[a-z][a-z0-9'-]{2,}/g) || []) {
        if (STOPWORDS.has(w)) {
          if (run.length) { runs.push(run); run = []; }
        } else {
          run.push(w);
        }
      }
      if (run.length) runs.push(run);
    }

    // Count single words plus the 2- and 3-word phrases inside each run.
    const uni = Object.create(null);
    const phrase = Object.create(null);
    for (const r of runs) {
      for (let i = 0; i < r.length; i++) {
        uni[r[i]] = (uni[r[i]] || 0) + 1;
        // Skip degenerate phrases made of one repeated word ("germany germany").
        if (i + 1 < r.length && r[i] !== r[i + 1]) {
          const bg = r[i] + " " + r[i + 1];
          phrase[bg] = (phrase[bg] || 0) + 1;
        }
        if (i + 2 < r.length && !(r[i] === r[i + 1] && r[i] === r[i + 2])) {
          const tg = r[i] + " " + r[i + 1] + " " + r[i + 2];
          phrase[tg] = (phrase[tg] || 0) + 1;
        }
      }
    }

    // A phrase only counts if it *recurs* (>= 2) — otherwise it's an incidental
    // adjacency, not a real term. Take the strongest phrases first and mark
    // their words as covered so they don't also show up on their own.
    const chosen = [];
    const covered = new Set();
    for (const p of Object.keys(phrase).filter((p) => phrase[p] >= 2)
      .sort((a, b) => phrase[b] - phrase[a])) {
      chosen.push({ term: p, count: phrase[p] });
      for (const w of p.split(" ")) covered.add(w);
      if (chosen.length >= limit) break;
    }

    // Fill the rest with the strongest standalone words not already in a phrase.
    for (const w of Object.keys(uni).filter((w) => !covered.has(w))
      .sort((a, b) => uni[b] - uni[a])) {
      if (chosen.length >= limit) break;
      chosen.push({ term: w, count: uni[w] });
    }

    // Final order by frequency; phrases naturally rank by their own recurrence.
    return chosen
      .sort((a, b) => b.count - a.count)
      .slice(0, limit)
      .map((c) => c.term);
  }

  // The pure YouTube rules (youtube.js) load ahead of this file per the
  // manifest — but a load failure must degrade to plain capture, never cost
  // the page payload (an unguarded ReferenceError would reach the outer
  // try and silently drop EVERY page's visit). One guard for every call
  // site: the fallbacks are behavior-neutral — unwrap is identity, nothing
  // is ever "same video", no chapters, no transcript. typeof on the last-
  // attached helper covers them all (youtube.js attaches its api in one
  // Object.assign — all or nothing).
  const YT = typeof shouldCaptureTranscript === "function"
    ? { videoIdOf, isSameVideo, unwrapYouTubeRedirect,
        chapterTitlesFromText, joinTranscriptSegments, shouldCaptureTranscript }
    : {
        videoIdOf: () => "",
        isSameVideo: () => false,
        unwrapYouTubeRedirect: (u) => u,
        chapterTitlesFromText: () => [],
        joinTranscriptSegments: () => "",
        shouldCaptureTranscript: () => false,
      };

  // Page chrome that in-body reference links must never come from — nav
  // bars, footers, sidebars, "related posts" rails.
  const CHROME_SELECTOR =
    'nav, header, footer, aside, [role="navigation"], [role="banner"], [role="contentinfo"]';

  // ISBNs, witnessed but not yet consumed (decided 2026-07-15): matched over
  // the FULL rendered body text — before the content cap, so the snapshot
  // witnesses them now and a future replay can use them retroactively. An
  // "ISBN" prefix is required (a bare 13-digit run in prose is a barcode, a
  // price, an id — not a book), the checksum must verify, and everything
  // normalizes to hyphen-less ISBN-13 (valid ISBN-10s converted). No ghost,
  // no edge, no queue entry is ever minted from these — there's no canonical
  // ISBN URL a user would actually visit, so nothing to merge on.
  const ISBN_MAX = 20;
  // The prefix is required RIGHT THERE — the capture starts within a few
  // chars of "ISBN". The run is captured loosely (it may swallow trailing
  // digits like a year); extractIsbns validates prefix-first below instead
  // of trusting the match boundary.
  const ISBN_RE = /\bISBN(?:-?1[03])?\s*:?\s*([0-9][0-9Xx -]{8,20})/gi;

  function isbn10Check(digits) {
    let sum = 0;
    for (let i = 0; i < 10; i++) {
      sum += (10 - i) * (digits[i] === "X" ? 10 : Number(digits[i]));
    }
    return sum % 11 === 0;
  }
  function isbn13CheckDigit(first12) {
    let sum = 0;
    for (let i = 0; i < 12; i++) sum += Number(first12[i]) * (i % 2 ? 3 : 1);
    return String((10 - (sum % 10)) % 10);
  }

  function extractIsbns(text) {
    const out = [];
    const seen = new Set();
    for (const m of String(text || "").matchAll(ISBN_RE)) {
      if (out.length >= ISBN_MAX) break;
      const digits = m[1].replace(/[ -]/g, "").toUpperCase();
      // ISBN-13 first (its 978/979 prefix is unambiguous), then ISBN-10 —
      // each read off the FRONT of the run, so trailing junk digits the
      // loose capture swallowed can't fail a valid ISBN.
      const first13 = digits.slice(0, 13);
      const first10 = digits.slice(0, 10);
      let isbn13 = "";
      if (/^97[89]\d{10}$/.test(first13)) {
        // A 978/979-shaped 13-digit run is ISBN-13 or NOTHING: falling back
        // to its first 10 digits would fabricate a phantom ISBN-10 (~1 in
        // 11 passes mod-11) — and a false witness persists forever.
        if (first13[12] === isbn13CheckDigit(first13)) isbn13 = first13;
      } else if (/^\d{9}[\dX]$/.test(first10) && isbn10Check(first10)) {
        const core = "978" + first10.slice(0, 9);
        isbn13 = core + isbn13CheckDigit(core);
      }
      if (!isbn13 || seen.has(isbn13)) continue;
      seen.add(isbn13);
      out.push(isbn13);
    }
    return out;
  }

  // What this page references, straight from the rendered DOM (the
  // references projection in references.js mines it later — plus a pattern
  // pass over `content`, which is why old snapshots without `refs` still
  // yield references on replay):
  //   meta      — the page's OWN scholarly identity (Highwire / Google
  //               Scholar tags), used to exclude self-references
  //   citations — raw citation_reference strings (publisher bibliographies)
  //   anchors   — cross-page in-body links with substantive anchor text
  //               (often the best title for a page never visited)
  //   isbns     — checksum-validated ISBN-13s (witnessed only, see above)
  function extractRefs(bodyText) {
    // Field caps: a hostile meta tag must not bloat the snapshot or make the
    // 24KB shed loop below re-stringify a multi-MB string per iteration.
    const meta = {
      doi: getMeta("citation_doi").slice(0, 512),
      arxiv: getMeta("citation_arxiv_id").slice(0, 512),
      title: getMeta("citation_title").slice(0, 512),
    };

    const citations = [];
    for (const el of document.querySelectorAll('meta[name="citation_reference"]')) {
      if (citations.length >= 50) break;
      const v = (el.getAttribute("content") || "").trim();
      if (v) citations.push(v.slice(0, 500)); // a real bibliography entry fits
    }

    const anchors = [];
    const seen = new Set();
    const pageHref = location.href.split("#")[0];

    // One rule set for both passes below. `skipChrome` lets the targeted
    // YouTube pass keep description links that generic chrome selectors
    // might swallow; everything else (text/length/cap/dedupe) is identical.
    function addAnchor(a, skipChrome) {
      if (anchors.length >= 80) return;
      // External YouTube description links are wrapped in
      // youtube.com/redirect?…&q=<target> — unwrap to the real target
      // (string parsing of the rendered href, not a request; "" = an
      // interstitial with no usable target, dropped). Non-redirect hrefs
      // pass through untouched. Property, not attribute: already absolute.
      const href = YT.unwrapYouTubeRedirect(a.href);
      if (!href || !/^https?:/i.test(href)) return;
      if (href.split("#")[0] === pageHref) return; // same-page / hash-only
      // Same-video links are self, never references: chapter/timestamp
      // links (t=/start=) and playlist variants (list=/index=) all point at
      // this watch page's own video id — compare on the id, not the URL
      // (youtube.js). Inert off YouTube: no page video id, no exclusion.
      if (YT.isSameVideo(href, pageHref)) return;
      if (!skipChrome && a.closest(CHROME_SELECTOR)) return;
      if (seen.has(href)) return;
      const text = (a.textContent || "").replace(/\s+/g, " ").trim();
      if (text.length < 8) return; // "here", "PDF", pagination digits
      seen.add(href); // first occurrence's text wins
      anchors.push({ href, text: text.slice(0, 140) });
    }

    // Targeted YouTube pass first: on a watch page the generic body pass
    // below mostly misses the app shell, and description links are the
    // yield — running them first also ranks them ahead when references.js
    // truncates at REFS_PER_PAGE (anchors keep captured order). Best-effort
    // selectors: if YouTube's DOM shifted, this finds nothing and degrades
    // silently to the generic pass.
    if (YT.videoIdOf(location.href)) {
      for (const a of document.querySelectorAll(
        "#description a[href], ytd-text-inline-expander a[href]"
      )) {
        addAnchor(a, true);
      }
    }
    for (const a of document.querySelectorAll("a[href]")) {
      if (anchors.length >= 80) break;
      addAnchor(a, false);
    }

    // Keep the snapshot lean: if serialized refs would exceed ~24KB, shed
    // anchors first (the bulkiest, most redundant source), then citations.
    // isbns never shed — 20 thirteen-char strings are noise in the budget.
    const refs = { meta, citations, anchors, isbns: extractIsbns(bodyText) };
    while (anchors.length && JSON.stringify(refs).length > 24576) anchors.pop();
    while (citations.length && JSON.stringify(refs).length > 24576) citations.pop();
    return refs;
  }

  // Chapter titles describe what the video COVERS — corpus, not citations:
  // they fold into keywords/content the way headings do (the same-video
  // chapter LINKS are excluded from refs.anchors above). The chapter rail's
  // renderer first; description timestamp lines ("12:40 The war ends") as
  // the fallback when the rail isn't rendered. Best-effort selectors — a
  // shifted DOM yields "" silently. ". " joins keep topKeywords from
  // building phrases across two chapter titles (punctuation ends a run).
  function extractChapterText() {
    const titles = [];
    for (const el of document.querySelectorAll(
      "ytd-macro-markers-list-item-renderer h4"
    )) {
      if (titles.length >= 40) break;
      const t = (el.textContent || "").replace(/\s+/g, " ").trim();
      if (t) titles.push(t.slice(0, 80));
    }
    if (!titles.length) {
      const desc = document.querySelector("#description, ytd-text-inline-expander");
      if (desc) titles.push(...YT.chapterTitlesFromText(desc.innerText || ""));
    }
    return titles.join(". ");
  }

  // The `content` field: parts joined in priority order, cleaned, capped —
  // whatever doesn't fit the cap simply isn't part of the page's memory.
  const CONTENT_CAP = 2500;
  function buildContent(parts, cap) {
    return parts
      .filter(Boolean)
      .join("  ")
      .replace(/\s+/g, " ")
      .trim()
      .slice(0, cap);
  }

  // `transcript` is joined transcript text on the observer's re-send
  // (YouTube watch pages only), otherwise absent.
  function extract(transcript) {
    const title = (document.title || "").trim();
    const description =
      getMeta("description") || getMeta("og:description") || "";

    // Pull text from the most meaningful elements first.
    const headingText = Array.from(
      document.querySelectorAll("h1, h2, h3")
    )
      .map((h) => h.textContent || "")
      .join(" ");

    // Chapter titles, on video pages — best-effort like refs below.
    let chapterText = "";
    if (YT.videoIdOf(location.href)) {
      try {
        chapterText = extractChapterText();
      } catch (e) {
        // keep ""
      }
    }

    const bodyText = (document.body ? document.body.innerText : "") || "";

    // Weight headings + title + description more heavily by repeating them;
    // chapter titles weigh like headings (they're the video's headings).
    const corpus = [
      title, title, title,
      description, description,
      headingText, headingText,
      chapterText, chapterText,
      transcript || "",
      bodyText.slice(0, 20000),
    ].join(" ");

    const keywords = topKeywords(corpus, 12);

    // A cleaned, capped excerpt of the page's real text — used for search.
    // Transcript text joins after title/headings/chapters and ahead of the
    // generic body soup, under the same cap — and the embedding input
    // follows content (migrate.js embedInputText), so the cap keeps what
    // the page's vector learns honest too.
    const content = buildContent(
      [title, headingText, chapterText, transcript || "", bodyText],
      CONTENT_CAP
    );

    // Best-effort by design: a refs failure must never cost the visit itself.
    let refs = { meta: { doi: "", arxiv: "", title: "" }, citations: [], anchors: [], isbns: [] };
    try {
      refs = extractRefs(bodyText);
    } catch (e) {
      // keep the empty shape
    }

    return {
      url: location.href,
      title,
      description: description.slice(0, 300),
      keywords,
      content,
      refs,
      // Additive, only ever present as true: this capture included rendered
      // transcript text (pipeline.js carries it latest-wins on the snapshot).
      ...(transcript ? { transcriptCaptured: true } : {}),
      favicon:
        (document.querySelector('link[rel~="icon"]') || {}).href ||
        location.origin + "/favicon.ico",
      ts: Date.now(),
    };
  }

  // --- YouTube transcript capture ---------------------------------------------
  // The transcript only becomes DOM when the user opens YouTube's transcript
  // panel — so on watch pages a lightweight observer waits for the segments
  // and re-extracts THEN (still reading rendered DOM, never fetching). One
  // send per page LOAD: the first sighting of a segment arms a single
  // delayed capture (letting the rest of the list render), then the observer
  // disconnects for good — reopening the panel on the same load doesn't
  // re-send (the transcript is already in the snapshot); no loops. The
  // re-sent payload flows through the normal pipeline: transcript text
  // changes `content`, so the contentHash differs → dirty → the existing
  // re-embed path; nothing worker-side is transcript-aware.
  //
  // YouTube hops between videos without page loads and this script runs once
  // per load — SPA navigation stays out of scope here (the worker's tab
  // events still record those visits). The one thing the observer must
  // guarantee: never attribute one video's transcript to another —
  // shouldCaptureTranscript (youtube.js) compares the video id at capture
  // time against the id this script loaded on, and a hop drops the capture.
  function watchForTranscript() {
    const startHref = location.href;
    if (!YT.videoIdOf(startHref)) return; // not a watch page — nothing to observe
    let armed = false;
    const obs = new MutationObserver(() => {
      if (armed) return;
      if (!document.querySelector("ytd-transcript-segment-renderer")) return;
      armed = true;
      setTimeout(() => {
        obs.disconnect(); // one shot — capture or not, this observer is done
        try {
          // .segment-text is the text sans timestamp; fall back to the whole
          // renderer (joinTranscriptSegments strips leading timestamps).
          const els = document.querySelectorAll(
            "ytd-transcript-segment-renderer .segment-text"
          );
          const src = els.length
            ? els
            : document.querySelectorAll("ytd-transcript-segment-renderer");
          const transcript = YT.joinTranscriptSegments(
            Array.from(src, (el) => el.textContent || "")
          );
          if (!transcript) return;
          if (!YT.shouldCaptureTranscript({
            segCount: src.length, startHref, nowHref: location.href,
          })) return;
          // Pin the re-send to the URL this load's visit was recorded under:
          // clicking a chapter link before opening the panel mutates
          // location.href (&t=…), and extract()'s live URL would attach the
          // transcript + flag to a near-duplicate t= node. Same video either
          // way (the guard above), so startHref is the truthful key. The
          // re-send does count one extra visit on that node — accepted:
          // opening the transcript IS a real interaction with the page.
          const payload = extract(transcript);
          payload.url = startHref;
          chrome.runtime.sendMessage({ type: "orbit:page", payload });
        } catch (e) {
          // Fail silently — never break the host page.
        }
      }, 1200);
    });
    obs.observe(document.documentElement, { childList: true, subtree: true });
  }

  // Under Node (unit tests) expose the pure extractors and stop — there's no
  // page to scrape. In the browser `module` is undefined, so this is skipped.
  if (typeof module !== "undefined" && module.exports) {
    module.exports = { topKeywords, buildContent, CONTENT_CAP, extractIsbns, ISBN_MAX };
    return;
  }

  // Avoid noise from blank/internal pages.
  if (!location.href.startsWith("http")) return;

  try {
    const payload = extract();
    chrome.runtime.sendMessage({ type: "orbit:page", payload });
    watchForTranscript();
  } catch (e) {
    // Fail silently — never break the host page.
  }
})();
