/**
 * First-party YouTube ad pruner — runs in the PAGE (MAIN) world at
 * document_start with "world": "MAIN" so it executes SYNCHRONOUSLY before
 * YouTube's first inline script sets ytInitialPlayerResponse. A statically
 * declared content script can't reach the MAIN world synchronously at the
 * initial page load; the service worker registers this file directly via
 * chrome.scripting (see src/prune-register.ts), and only while the first-party
 * ad-blocking setting is on — so registration itself is the gate, no in-script
 * flag check needed, and the script is simply absent when off.
 *
 * It strips ad slots out of YouTube player responses (uBO "json-prune") and
 * removes ad segments from HLS/DASH/VAST manifests, so most ads never start.
 * It ONLY removes ad data from responses YouTube already sent — it never
 * manipulates outbound requests. (An earlier build also rewrote the outbound
 * /youtubei/v1/player request to solicit an ad-free variant; a behavioral A/B
 * proved it redundant — json-prune alone empties the player's ad schedule —
 * and it was removed for a cleaner Web Store posture. See the spoof-code
 * archive in project memory + git history if it ever needs restoring.)
 *
 * This file is intentionally plain ES5-ish JS with no imports: it's copied
 * verbatim from public/ and injected as-is into the page.
 */
;(function () {
  'use strict'
  if (window.__skipSenseiPruned) return
  window.__skipSenseiPruned = true

  var AD_KEYS = ['adPlacements', 'adSlots', 'playerAds', 'adBreakHeartbeatParams']
  var AD_SLOT_KEYS = { adPlacements: 1, adSlots: 1 }
  // Dedupe schedule-posting per videoId. YouTube refetches a video's player
  // response many times (seeks, quality changes, navigation), re-serving the
  // SAME ad breaks — we hand the loader each video's schedule only once.
  var scheduledVideos = Object.create(null)

  function videoIdOf(obj) {
    try {
      var d =
        (obj && obj.videoDetails) ||
        (obj && obj.playerResponse && obj.playerResponse.videoDetails)
      return (d && d.videoId) || ''
    } catch (e) {
      return ''
    }
  }

  // Read each ad break's scheduled position (ms into the content) from
  // adPlacements BEFORE we strip it. Pre-roll = 0, mid-rolls = offset ms,
  // post-roll = -1. This is the break SCHEDULE — how many breaks would play
  // and where — which is all the response tells us. It does NOT say how many
  // individual ads a break would contain (that comes from the ad request we
  // block), so a "break" is the countable unit, not the creative.
  function extractOffsets(target) {
    var out = []
    try {
      var aps = target.adPlacements
      if (!Array.isArray(aps)) return out
      for (var i = 0; i < aps.length; i++) {
        var r = aps[i] && aps[i].adPlacementRenderer
        var cfg = r && r.config && r.config.adPlacementConfig
        var off = cfg && cfg.adTimeOffset && cfg.adTimeOffset.offsetStartMilliseconds
        if (off === undefined || off === null) continue
        var ms = parseInt(off, 10)
        if (!isNaN(ms)) out.push(ms)
      }
    } catch (e) {}
    return out
  }

  // Strip the ad keys from one object; return true if any ad SLOT key was
  // present (so we know the response actually carried ads).
  function stripKeys(target) {
    var hadSlots = false
    for (var i = 0; i < AD_KEYS.length; i++) {
      var key = AD_KEYS[i]
      if (key in target) {
        try {
          if (AD_SLOT_KEYS[key]) hadSlots = true
          delete target[key]
        } catch (e) {}
      }
    }
    return hadSlots
  }

  function postSchedule(vid, offsets) {
    try {
      window.postMessage(
        {
          source: 'skip-sensei',
          type: 'ad-schedule',
          videoId: vid,
          offsets: offsets,
        },
        '*',
      )
    } catch (e) {}
  }

  function prune(obj) {
    if (!obj || typeof obj !== 'object') return obj
    // Capture the break schedule BEFORE stripping — the loader counts each
    // break only when playback actually reaches it, so a partial watch counts
    // only the breaks you'd have hit, not the whole video's total.
    var offsets = extractOffsets(obj)
    var hadSlots = stripKeys(obj)
    if (obj.playerResponse && typeof obj.playerResponse === 'object') {
      offsets = offsets.concat(extractOffsets(obj.playerResponse))
      if (stripKeys(obj.playerResponse)) hadSlots = true
    }
    if (hadSlots) {
      var vid = videoIdOf(obj)
      if (vid && !scheduledVideos[vid]) {
        scheduledVideos[vid] = 1
        // Slots present but no readable offsets (rare shape) → treat as a
        // single break at start so it isn't silently uncounted.
        postSchedule(vid, offsets.length ? offsets : [0])
      }
    }
    return obj
  }

  // ---------------------------------------------------------------------------
  // 0) Native-function cloaking (CRITICAL — set up first; every native-function
  //    replacement below is routed through cloak() so that integrity checks of
  //    the form `fn.toString().includes('[native code]')` still see the
  //    original native source and don't detect our tampering.
  //
  //    Implementation: patch Function.prototype.toString ONCE. A WeakMap maps
  //    each replacement function -> the ORIGINAL native it stands in for; when
  //    toString is invoked on a known replacement we return the ORIGINAL's
  //    toString output instead. The patched toString cloaks itself too.
  // ---------------------------------------------------------------------------
  var nativeToString = Function.prototype.toString
  var cloakMap
  try {
    cloakMap = new WeakMap()
  } catch (e) {
    cloakMap = null
  }

  function cloak(replacementFn, originalFn) {
    try {
      if (cloakMap && typeof replacementFn === 'function' && typeof originalFn === 'function') {
        cloakMap.set(replacementFn, originalFn)
      }
    } catch (e) {}
    return replacementFn
  }

  try {
    var patchedToString = function toString() {
      try {
        if (cloakMap) {
          var orig = cloakMap.get(this)
          if (orig) return nativeToString.call(orig)
        }
      } catch (e) {}
      return nativeToString.apply(this, arguments)
    }
    Function.prototype.toString = patchedToString
    // Cloak the cloaker itself so Function.prototype.toString.toString()
    // still reports native code.
    cloak(patchedToString, nativeToString)
  } catch (e) {}

  // 1) Initial watch-page payload: an inline script assigns
  //    window.ytInitialPlayerResponse. Trap the assignment.
  var stored
  try {
    Object.defineProperty(window, 'ytInitialPlayerResponse', {
      configurable: true,
      get: function () {
        return stored
      },
      set: function (value) {
        stored = prune(value)
      },
    })
  } catch (e) {}

  // 2) SPA navigations / autoplay: the player response arrives via
  //    fetch().json().
  try {
    var originalJson = Response.prototype.json
    Response.prototype.json = cloak(function () {
      return originalJson.apply(this, arguments).then(function (data) {
        return prune(data)
      })
    }, originalJson)
  } catch (e) {}

  // 3) Belt and braces for anything parsed out-of-band.
  var originalParse = JSON.parse
  try {
    JSON.parse = cloak(function () {
      return prune(originalParse.apply(this, arguments))
    }, originalParse)
  } catch (e) {}


  // ---------------------------------------------------------------------------
  // 5) HLS ad-segment pruning (m3u-prune) &
  // 6) DASH/VAST XML pruning (xml-prune).
  //    Both operate on the response-TEXT path. We wrap Response.prototype.text
  //    and shadow the per-instance XHR responseText getter, then dispatch on
  //    the body shape: HLS playlists (#EXTM3U) go to m3uPrune, XML bodies
  //    (<?xml / <MPD / <VAST) go to xmlPrune. Both are deliberately
  //    conservative — if no ad markers are recognized the body is returned
  //    byte-for-byte unchanged, so non-ad media is never corrupted.
  // ---------------------------------------------------------------------------

  // Match "ad"/"ads" only as a whole token (bounded by non-letters or ends),
  // NOT as a substring — so "broadcast", "adaptation", "download", "loading",
  // "header" etc. are never mistaken for ads. This is what keeps the DASH/HLS
  // pruners from deleting legitimate media.
  function hasAdToken(s) {
    return /(^|[^a-z])ads?([^a-z]|$)/i.test(String(s))
  }

  // 5) HLS playlist ad stripping.
  function m3uPrune(text) {
    try {
      var lines = text.split('\n')
      var out = []
      var changed = false
      var inAdCue = false
      for (var i = 0; i < lines.length; i++) {
        var line = lines[i]
        var t = line.trim()

        // #EXT-X-CUE-OUT ... #EXT-X-CUE-IN brackets a server-stitched ad span;
        // drop the markers and every #EXTINF segment line between them.
        if (/^#EXT-X-CUE-OUT/i.test(t)) {
          inAdCue = true
          changed = true
          continue
        }
        if (/^#EXT-X-CUE-IN/i.test(t)) {
          inAdCue = false
          changed = true
          continue
        }
        if (inAdCue) {
          changed = true
          continue
        }

        // #EXT-X-DATERANGE lines explicitly tagged as ads: a stitched-ad /
        // ad-token CLASS, or an SCTE-35 splice. (Dropping a DATERANGE line
        // removes only the metadata tag, not media segments.)
        if (/^#EXT-X-DATERANGE/i.test(t)) {
          var cls = t.match(/CLASS="([^"]*)"/i)
          if (/twitch-stitched-ad|scte-?35/i.test(t) || (cls && hasAdToken(cls[1]))) {
            changed = true
            continue
          }
        }

        out.push(line)
      }
      // Unbalanced CUE-OUT — no closing CUE-IN in this playlist body (common in
      // live/DVR refreshes where the pair spans two refreshes). We'd have
      // dropped real trailing segments, so bail and return the playlist
      // unchanged rather than corrupt it.
      if (inAdCue) return text
      if (changed) {
        notify()
        return out.join('\n')
      }
    } catch (e) {}
    return text
  }

  // 6) DASH / VAST XML ad stripping.
  function xmlPrune(text) {
    try {
      if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') {
        return text
      }
      var doc = new DOMParser().parseFromString(text, 'application/xml')
      // Bail out on any parse error — never risk corrupting non-ad XML.
      if (!doc || doc.getElementsByTagName('parsererror').length) return text
      var root = doc.documentElement
      if (!root) return text
      var name = (root.nodeName || '').toUpperCase()

      // VAST: an <Ad>-bearing wrapper is entirely ad payload; replace it with
      // an empty <VAST> so no ad is served.
      if (name === 'VAST') {
        var ads = doc.getElementsByTagName('Ad')
        if (ads && ads.length) {
          var version = root.getAttribute('version') || '4.0'
          notify()
          return '<VAST version="' + version + '"/>'
        }
        return text
      }

      // DASH MPD: remove <Period> elements that are ad periods — id is an "ad"
      // TOKEN, or a SupplementalProperty carries an SCTE-35 / ad-token signal.
      // Token matching (not substring) is critical: "ad" is a substring of
      // "broadcast", "adaptation-set-switching" (a real, non-ad DASH scheme),
      // "download", etc., and substring matching would delete live content.
      if (name === 'MPD') {
        var periods = root.getElementsByTagName('Period')
        var toRemove = []
        for (var p = 0; p < periods.length; p++) {
          var period = periods[p]
          var isAd = false
          if (hasAdToken(period.getAttribute('id') || '')) isAd = true
          if (!isAd) {
            var supps = period.getElementsByTagName('SupplementalProperty')
            for (var s = 0; s < supps.length; s++) {
              var scheme = supps[s].getAttribute('schemeIdUri') || ''
              var val = supps[s].getAttribute('value') || ''
              if (
                /scte-?35/i.test(scheme) ||
                hasAdToken(scheme) ||
                hasAdToken(val)
              ) {
                isAd = true
                break
              }
            }
          }
          if (isAd) toRemove.push(period)
        }
        // Never strip EVERY period — a manifest that looks entirely like ads is
        // almost certainly a false match; leave it intact rather than break all
        // playback.
        if (toRemove.length && toRemove.length < periods.length) {
          for (var r = 0; r < toRemove.length; r++) {
            if (toRemove[r].parentNode) {
              toRemove[r].parentNode.removeChild(toRemove[r])
            }
          }
          notify()
          return new XMLSerializer().serializeToString(doc)
        }
        return text
      }
    } catch (e) {}
    return text
  }

  // Dispatch a response-text body to the right pruner by shape.
  function processResponseText(text) {
    if (typeof text !== 'string' || !text) return text
    try {
      var head = text.slice(0, 256)
      if (/^\s*#EXTM3U/.test(head)) return m3uPrune(text)
      if (/^\s*<\?xml/.test(head) || head.indexOf('<MPD') !== -1 || head.indexOf('<VAST') !== -1) {
        return xmlPrune(text)
      }
    } catch (e) {}
    return text
  }

  // 5+6a) Response.prototype.text()
  try {
    var originalText = Response.prototype.text
    Response.prototype.text = cloak(function () {
      return originalText.apply(this, arguments).then(function (txt) {
        return processResponseText(txt)
      })
    }, originalText)
  } catch (e) {}

  // XHR send — a single override handling BOTH (4) the outbound player-request
  // body spoof and (5+6) shadowing the per-instance responseText getter so a
  // completed HLS/XML response reads back pruned. Kept as one wrapper so the
  // cloak() mapping points straight at the true native send (no intermediate
  // wrapper leaks its JS source through toString). The native responseText
  // getter is reached via the prototype descriptor; if it throws (e.g. a
  // responseType mismatch) that error propagates naturally.
  try {
    var nativeXHRSend = XMLHttpRequest.prototype.send
    var xhrTextDesc = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText')
    var nativeResponseTextGet =
      xhrTextDesc && typeof xhrTextDesc.get === 'function' ? xhrTextDesc.get : null

    XMLHttpRequest.prototype.send = cloak(function () {
      try {
        // (5+6) Shadow responseText so completed HLS/XML bodies read pruned.
        if (nativeResponseTextGet && !this.__ssTextShadowed) {
          this.__ssTextShadowed = true
          var xhr = this
          Object.defineProperty(xhr, 'responseText', {
            configurable: true,
            get: function () {
              var raw = nativeResponseTextGet.call(xhr)
              if (xhr.readyState === 4) {
                try {
                  return processResponseText(raw)
                } catch (e) {}
              }
              return raw
            },
          })
        }
      } catch (e) {}
      return nativeXHRSend.apply(this, arguments)
    }, nativeXHRSend)
  } catch (e) {}
})()
