// OrbitCosmo YouTube layer — pure, dependency-free logic for reading YouTube
// watch pages: video identity (the same-video exclusion), the
// youtube.com/redirect unwrap, chapter-line mining, transcript-segment
// joining, and the transcript capture guard.
//
// The bright line (TODO.md Phase 4) holds everywhere here: these helpers
// parse strings the page already rendered — no URL in this file is ever
// fetched, and YouTube's API is never touched. The DOM walking lives in
// content.js; this file exists so the rules it applies stay unit-testable
// (tools/test-youtube.js) — content.js itself has more than one reason to
// change (keywords, refs, the observer lifecycle) and was getting crowded.
//
// Kept free of any chrome/* access; the manifest loads it into the content-
// script isolated world ahead of content.js (globals never reach the host
// page — isolated worlds don't share scope with it).

(function (root) {
  "use strict";

  // The video id a URL points at, or "" for anything that isn't a video
  // page. watch?v=<id> and youtu.be/<id> are the same video reached two
  // ways; shorts/live/embed paths carry the id too. Query noise around the
  // id — t=/start= timestamps, list=/index= playlist context — deliberately
  // doesn't matter: identity is the id alone. Every path applies the same
  // minimum shape (real ids are 11 url-safe chars; {6,} tolerates drift) —
  // a degenerate v= ("ab", whitespace) must not become an identity.
  const ID_RE = /^[\w-]{6,}$/;
  function videoIdOf(url) {
    let u;
    try {
      u = new URL(url);
    } catch {
      return "";
    }
    const host = u.hostname.replace(/^(www|m|music)\./i, "").toLowerCase();
    if (host === "youtu.be") {
      const seg = u.pathname.split("/")[1] || "";
      return ID_RE.test(seg) ? seg : "";
    }
    if (host === "youtube.com") {
      if (u.pathname === "/watch") {
        const v = u.searchParams.get("v") || "";
        return ID_RE.test(v) ? v : "";
      }
      const m = /^\/(?:shorts|live|embed)\/([\w-]{6,})/.exec(u.pathname);
      if (m) return m[1];
    }
    return "";
  }

  // Same video, any wrapper: a watch page's chapter/timestamp links (t=),
  // playlist variants (list=/index=) and youtu.be short links all carry the
  // page's own video id — they're self, never a reference. Non-video URLs
  // have no id and are never "same".
  function isSameVideo(candidateUrl, pageUrl) {
    const id = videoIdOf(pageUrl);
    return !!id && videoIdOf(candidateUrl) === id;
  }

  // External links in YouTube descriptions are wrapped in the
  // youtube.com/redirect?…&q=<target> interstitial. The target is right
  // there in the rendered href — unwrapping it is string parsing, not a
  // request. Returns the target for a redirect link, the input untouched
  // for anything else (a /redirect path on some other host is NOT unwrapped
  // — only YouTube's own interstitial is trusted to mean "the real link is
  // q="), and "" for a redirect with no usable target (the interstitial
  // itself references nothing — callers drop falsy).
  function unwrapYouTubeRedirect(url) {
    let u;
    try {
      u = new URL(url);
    } catch {
      return url;
    }
    const host = u.hostname.replace(/^(www|m)\./i, "").toLowerCase();
    if (host !== "youtube.com" || u.pathname !== "/redirect") return url;
    let q = u.searchParams.get("q") || "";
    // searchParams already decoded once; some wrappers double-encode the
    // target, so one more decode recovers "https%3A%2F%2F…".
    if (q && !/^https?:\/\//i.test(q)) {
      try {
        q = decodeURIComponent(q);
      } catch {
        // keep q as-is; the test below rejects it
      }
    }
    return /^https?:\/\//i.test(q) ? q : "";
  }

  // Chapter lines in a description — "0:00 Intro", "1:23:45 - The war ends"
  // — yield their titles. The fallback when the chapter rail isn't rendered;
  // lines without a leading timestamp are ordinary prose and stay out.
  const CHAPTER_LINE_RE =
    /^\s*[-–—•*([]?\s*(?:\d{1,2}:)?\d{1,2}:\d{2}[\])]?\s*[-–—:.]?\s*(\S.*)$/;
  const CHAPTERS_MAX = 40;

  function chapterTitlesFromText(text) {
    const out = [];
    for (const line of String(text || "").split("\n")) {
      if (out.length >= CHAPTERS_MAX) break;
      const m = CHAPTER_LINE_RE.exec(line);
      if (m) out.push(m[1].replace(/\s+/g, " ").trim().slice(0, 80));
    }
    return out;
  }

  // Transcript segment texts → one string. Strips the leading timestamp a
  // segment carries when the whole renderer's text was taken (the fallback
  // path when YouTube's .segment-text class shifted) and collapses
  // whitespace.
  function joinTranscriptSegments(texts) {
    return (texts || [])
      .map((t) =>
        String(t || "")
          .replace(/^\s*(?:\d{1,2}:)?\d{1,2}:\d{2}\s*/, "")
          .replace(/\s+/g, " ")
          .trim()
      )
      .filter(Boolean)
      .join(" ");
  }

  // The observer's send guard: capture only when segments actually rendered
  // AND the page still shows the video the observer started on. YouTube
  // hops between videos without page loads, so without the id comparison a
  // SPA hop could attribute one video's transcript to another — the one
  // failure mode the once-per-load model must never allow.
  function shouldCaptureTranscript(state) {
    if (!state || !state.segCount) return false;
    const startId = videoIdOf(state.startHref);
    return !!startId && videoIdOf(state.nowHref) === startId;
  }

  const api = {
    CHAPTERS_MAX,
    videoIdOf,
    isSameVideo,
    unwrapYouTubeRedirect,
    chapterTitlesFromText,
    joinTranscriptSegments,
    shouldCaptureTranscript,
  };

  // Dual export: CommonJS for Node tests, global attach for the content
  // script's isolated world.
  if (typeof module !== "undefined" && module.exports) {
    module.exports = api;
  } else {
    Object.assign(root, api);
  }
})(typeof globalThis !== "undefined" ? globalThis : self);
