#!/usr/bin/env node
/*
 * sabr-probe.mjs — node-only PoC for the SABR download path (Phase 1 of
 * project_random_403_potoken_sabr). Validates whether YouTube's
 * Server-Adaptive Bitrate (SABR) endpoint can pull a FULL file for videos
 * whose direct adaptiveFormat URLs are 1 MB-capped (made-for-kids / forced
 * SABR), e.g. BRfjeH366EY. Run this BEFORE wiring SABR into the addon.
 *
 * What it does:
 *   1. scrape watch page → visitorData (same as innertube-probe.mjs)
 *   2. POST /youtubei/v1/player (iOS or ANDROID) → serverAbrStreamingUrl,
 *      videoPlaybackUstreamerConfig, adaptiveFormats
 *   3. drive googlevideo's SabrStream (the LuanRT reference the memory cites)
 *      to POST the VideoPlaybackAbrRequest protobuf + parse the UMP response
 *   4. drain the video + audio ReadableStreams and report bytes vs
 *      contentLength — did SABR lift the 1 MB cap?
 *
 * googlevideo is a NODE-ONLY dep. It is NOT bundled in the shipped add-on;
 * the add-on port will be hand-rolled (no eval, no deps) once this PoC
 * confirms the approach + the pot requirement. package.json is gitignored
 * by repo convention, so install it explicitly before running:
 *     cd tools && npm i googlevideo
 *
 * Usage:
 *   node tools/sabr-probe.mjs <videoId|watchURL> [--client=IOS|ANDROID]
 *        [--video=<itag>] [--audio=<itag>] [--pot=<TOKEN>] [--visitor=<VD>]
 *
 * Without --pot the run reveals whether SABR demands attestation
 * (streamProtectionStatus=3) — the Phase 0 prerequisite.
 */

import { SabrStream } from 'googlevideo/sabr-stream';
import { buildSabrFormat, EnabledTrackTypes } from 'googlevideo/utils';

// ---- client table (iOS + ANDROID match includes/common.js / innertube-probe) ----
const CLIENTS = {
  IOS: {
    header: 5,
    clientVersion: '20.11.6',
    userAgent: 'com.google.ios.youtube/20.11.6 (iPhone10,4; U; CPU iOS 16_7_7 like Mac OS X)',
    client: { clientName: 'iOS', clientVersion: '20.11.6', deviceMake: 'Apple', deviceModel: 'iPhone10,4', osName: 'iOS', osVersion: '16.7.7.20H330', hl: 'en', gl: 'US' },
    clientInfo: { clientName: 5, clientVersion: '20.11.6', deviceMake: 'Apple', deviceModel: 'iPhone10,4', osName: 'iOS', osVersion: '16.7.7.20H330', osVersion_acceptLanguage: 'en-US' }
  },
  ANDROID: {
    header: 3,
    clientVersion: '20.10.38',
    userAgent: 'com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US; Pixel 8) gzip',
    client: { clientName: 'ANDROID', clientVersion: '20.10.38', deviceMake: 'Google', deviceModel: 'Pixel 8', osName: 'Android', osVersion: '14', androidSdkVersion: 34, hl: 'en', gl: 'US' },
    clientInfo: { clientName: 3, clientVersion: '20.10.38', deviceMake: 'Google', deviceModel: 'Pixel 8', osName: 'Android', osVersion: '14', androidSdkVersion: 34 }
  },
  // WEB: the client that mints the web pot — use to test if pot is client-bound.
  WEB: {
    header: 1,
    clientVersion: '2.20250101.00.00',
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
    client: { clientName: 'WEB', clientVersion: '2.20250101.00.00', osName: 'Windows', osVersion: '10.0', hl: 'en', gl: 'US' },
    clientInfo: { clientName: 1, clientVersion: '2.20250101.00.00', osName: 'Windows', osVersion: '10.0' }
  }
};

const SP_STATUS = { 1: 'OK', 2: 'ATTESTATION_PENDING', 3: 'ATTESTATION_REQUIRED' };

// ---- args ------------------------------------------------------------------
const args = process.argv.slice(2);
const positional = args.filter(a => !a.startsWith('--'));
const flags = Object.fromEntries(args.filter(a => a.startsWith('--')).map(a => {
  const [k, v] = a.replace(/^--/, '').split('=');
  return [k, v === undefined ? true : v];
}));

if (!positional[0]) {
  console.error('usage: node tools/sabr-probe.mjs <videoId|watchURL> [--client=IOS|ANDROID] [--video=itag] [--audio=itag] [--pot=TOKEN] [--visitor=VD]');
  process.exit(2);
}

function parseVideoId(s) {
  const m = s.match(/[?&]v=([\w-]{11})/) || s.match(/youtu\.be\/([\w-]{11})/);
  if (m) return m[1];
  if (/^[\w-]{11}$/.test(s)) return s;
  throw new Error(`cannot parse videoId from "${s}"`);
}

const VIDEO_ID = parseVideoId(positional[0]);
const CLIENT = String(flags.client || 'IOS').toUpperCase();
const POT = flags.pot ? String(flags.pot) : '';
const VISITOR = flags.visitor ? String(flags.visitor) : '';
const WANT_VIDEO = flags.video ? Number(flags.video) : 134;
const WANT_AUDIO = flags.audio ? Number(flags.audio) : 140;

if (!CLIENTS[CLIENT]) { console.error(`unknown --client=${CLIENT} (IOS|ANDROID)`); process.exit(2); }

// ---- visitorData scrape ----------------------------------------------------
async function scrapeYtCfg(videoId) {
  try {
    const resp = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
      headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', 'Accept-Language': 'en-US,en;q=0.9' }
    });
    const html = await resp.text();
    return { visitorData: (html.match(/"VISITOR_DATA":\s*"([^"]+)"/) || [])[1] || '', watchStatus: resp.status };
  } catch (e) { return { visitorData: '', watchStatus: 'ERR ' + e.message }; }
}

// ---- player POST (mirror fetchInnertubePlayer) -----------------------------
async function fetchPlayer(name, videoId, visitorData) {
  const def = CLIENTS[name];
  const body = {
    videoId, racyCheckOk: true, contentCheckOk: true,
    context: { client: { ...def.client, visitorData: visitorData || '' }, user: {}, request: { useSsl: true } }
  };
  const resp = await fetch('https://www.youtube.com/youtubei/v1/player?prettyPrint=false&alt=json', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Youtube-Client-Name': String(def.header),
      'X-Youtube-Client-Version': def.clientVersion,
      'X-Goog-Visitor-Id': visitorData || '',
      'User-Agent': def.userAgent
    },
    body: JSON.stringify(body)
  });
  if (!resp.ok) throw new Error(`player HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
  return resp.json();
}

// ---- googlevideo fetch wrapper: mirror DNR (Referer set, Origin/Cookie
//      absent — node omits them anyway; set the client UA on the segment POST).
function makeFetch(name) {
  const ua = CLIENTS[name].userAgent;
  return (url, init = {}) => fetch(url, { ...init, headers: { ...(init.headers || {}), 'Referer': 'https://www.youtube.com/', 'User-Agent': ua } });
}

async function drain(stream) {
  const reader = stream.getReader();
  let bytes = 0, chunks = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    bytes += value.byteLength; chunks++;
  }
  return { bytes, chunks };
}

// ---- main ------------------------------------------------------------------
(async () => {
  console.log(`\n=== sabr-probe  video=${VIDEO_ID}  client=${CLIENT}  pot=${POT ? POT.slice(0, 12) + '…' : '(none)'} ===`);
  const cfg = await scrapeYtCfg(VIDEO_ID);
  if (VISITOR) cfg.visitorData = VISITOR;
  console.log(`watch page: HTTP ${cfg.watchStatus}  visitorData=${cfg.visitorData ? cfg.visitorData.slice(0, 18) + '…' : '(none)'}${VISITOR ? ' (override)' : ''}`);

  const player = await fetchPlayer(CLIENT, VIDEO_ID, cfg.visitorData);
  const ps = player.playabilityStatus || {};
  const sd = player.streamingData || {};
  const ustreamerConfig = player.playerConfig?.mediaCommonConfig?.mediaUstreamerRequestConfig?.videoPlaybackUstreamerConfig;
  console.log(`playabilityStatus: ${ps.status}${ps.reason ? '  reason="' + ps.reason + '"' : ''}`);
  console.log(`serverAbrStreamingUrl: ${sd.serverAbrStreamingUrl ? 'present' : 'MISSING'}   ustreamerConfig: ${ustreamerConfig ? 'present (len ' + ustreamerConfig.length + ')' : 'MISSING'}`);

  if (!sd.serverAbrStreamingUrl || !ustreamerConfig) {
    console.log('\n*** cannot run SABR: missing serverAbrStreamingUrl or ustreamerConfig ***\n');
    process.exit(1);
  }

  const adaptive = sd.adaptiveFormats || [];
  const formats = adaptive.map(buildSabrFormat);
  const vWanted = adaptive.find(f => f.itag === WANT_VIDEO) || adaptive.find(f => (f.mimeType || '').startsWith('video/'));
  const aWanted = adaptive.find(f => f.itag === WANT_AUDIO) || adaptive.find(f => (f.mimeType || '').startsWith('audio/'));
  console.log(`formats: ${formats.length}  → video itag ${vWanted?.itag} (clen ${vWanted?.contentLength})  audio itag ${aWanted?.itag} (clen ${aWanted?.contentLength})`);

  const stream = new SabrStream({
    fetch: makeFetch(CLIENT),
    serverAbrStreamingUrl: sd.serverAbrStreamingUrl,
    videoPlaybackUstreamerConfig: ustreamerConfig,
    clientInfo: CLIENTS[CLIENT].clientInfo,
    poToken: POT || undefined,
    formats
  });

  let lastProtection = null;
  stream.on('streamProtectionStatusUpdate', (s) => {
    lastProtection = s.status;
    console.log(`  » streamProtectionStatus = ${s.status} (${SP_STATUS[s.status] || '?'})${s.status === 3 ? '  *** pot REQUIRED ***' : ''}`);
  });
  stream.on('formatInitialization', (f) => {
    const m = f.formatInitializationMetadata;
    console.log(`  » formatInit itag=${m.formatId?.itag} durationMs=${m.durationMs} totalSegments=${m.totalSegments}`);
  });
  stream.on('reloadPlayerResponse', () => console.log('  » server asked to RELOAD player (session invalid)'));

  console.log('\n--- starting SABR stream ---');
  try {
    const { videoStream, audioStream, selectedFormats } = await stream.start({
      videoFormat: vWanted?.itag,
      audioFormat: aWanted?.itag,
      enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO
    });
    const [v, a] = await Promise.all([drain(videoStream), drain(audioStream)]);
    const vClen = Number(selectedFormats.videoFormat?.contentLength || vWanted?.contentLength || 0);
    const aClen = Number(selectedFormats.audioFormat?.contentLength || aWanted?.contentLength || 0);
    console.log('\n=== RESULT ===');
    console.log(`video itag ${selectedFormats.videoFormat?.itag}: ${v.bytes} bytes in ${v.chunks} chunks  (clen ${vClen}) ${vClen && v.bytes >= vClen * 0.99 ? '✓ FULL' : '✗ SHORT'}`);
    console.log(`audio itag ${selectedFormats.audioFormat?.itag}: ${a.bytes} bytes in ${a.chunks} chunks  (clen ${aClen}) ${aClen && a.bytes >= aClen * 0.99 ? '✓ FULL' : '✗ SHORT'}`);
    const ok = vClen && v.bytes >= vClen * 0.99 && aClen && a.bytes >= aClen * 0.99;
    console.log(`\nSABR full-file pull: ${ok ? '✓ WORKS — cap lifted' : '✗ FAILED — short read'}  (protection=${lastProtection ?? 'none'}${POT ? '' : ', no pot supplied'})`);
  } catch (e) {
    console.log(`\n*** SABR start/drain threw: ${e.message} ***`);
    console.log(`protection=${lastProtection ?? 'none'}${POT ? '' : ' — likely needs a pot (run again with --pot=...)'}`);
    process.exitCode = 1;
  }
  console.log('=== done ===\n');
})();
