#!/usr/bin/env node
/*
 * innertube-probe.mjs — standalone diagnostic that imitates the InnerTube
 * /youtubei/v1/player requests the WebExtension makes, plus the googlevideo
 * segment fetch, so we can observe live YouTube responses (status codes,
 * playabilityStatus, 403s, SABR-only signals) WITHOUT touching addon code.
 *
 * Mirrors includes/common.js: ANDROID_VR + iOS clients (the two the addon
 * uses), the player request body/headers, and the `&range=` segment fetch
 * with the addon's DNR header behaviour (Referer set, Origin/Cookie absent,
 * per-client User-Agent). Also probes candidate FALLBACK clients so we can
 * discover a working replacement when the current ones 403.
 *
 * Usage:
 *   node tools/innertube-probe.mjs <videoId|watchURL> [--clients=a,b] [--no-seg]
 *   node tools/innertube-probe.mjs dQw4w9WgXcQ
 *   node tools/innertube-probe.mjs https://www.youtube.com/watch?v=VIDEOID
 *
 * Notes / caveats (important when reading results):
 *  - Node has NO browser cookies by default. The addon's player POST runs in the
 *    YT page and DOES carry login/visitor cookies; without --cookies this probe
 *    is anonymous (like incognito). Pass --cookies=<file> to authenticate (see
 *    below) so cookie-gated cases (age-restricted, members-only) can be probed.
 *    Segment fetches in the addon are cookie-stripped via DNR, so the segment leg
 *    matches; only the player leg differs for cookie-gated cases.
 *  - --cookies=<file>: a Netscape cookies.txt (e.g. "Get cookies.txt LOCALLY")
 *    OR a file with one raw "Cookie:" request header line. We derive the
 *    SAPISIDHASH Authorization header from the SAPISID cookie so InnerTube
 *    actually treats the request as logged-in (Cookie alone is not enough).
 *  - visitorData is scraped from the watch page (same as addon tier-3 fallback).
 */

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';

// ---- client table (VR + iOS match includes/common.js verbatim) -------------
const CLIENTS = {
  ANDROID_VR: {
    header: '28',
    client: {
      clientName: 'ANDROID_VR', clientVersion: '1.65.10',
      deviceMake: 'Oculus', deviceModel: 'Quest 3',
      osName: 'Android', osVersion: '12L', androidSdkVersion: 32,
      userAgent: 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
      platform: 'MOBILE', clientFormFactor: 'SMALL_FORM_FACTOR',
      hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  IOS: {
    header: '5',
    client: {
      clientName: 'iOS', clientVersion: '21.26.4',
      deviceMake: 'Apple', deviceModel: 'iPhone16,2',
      osName: 'iOS', osVersion: '18.3.2.22D82',
      userAgent: 'com.google.ios.youtube/21.26.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X)',
      platform: 'MOBILE', clientFormFactor: 'UNKNOWN_FORM_FACTOR',
      hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  // visionos (header 101): Apple Vision Pro — PoT-free, plain URLs, no decipher.
  // Now the addon's PRIMARY client (yt-dlp default since PR #17261).
  VISIONOS: {
    header: '101',
    client: {
      clientName: 'VISIONOS', clientVersion: '1.02',
      deviceMake: 'Apple', deviceModel: 'RealityDevice17,1',
      osName: 'visionOS', osVersion: '26.5.23O471',
      userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15',
      hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  // ---- fallback candidates (diagnostic only; not in addon today) ----------
  ANDROID: {
    header: '3',
    client: {
      clientName: 'ANDROID', clientVersion: '20.10.38',
      deviceMake: 'Google', deviceModel: 'Pixel 8',
      osName: 'Android', osVersion: '14', androidSdkVersion: 34,
      userAgent: 'com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US; Pixel 8) gzip',
      platform: 'MOBILE', hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  TVHTML5: {
    header: '85',
    client: {
      clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', clientVersion: '2.0',
      userAgent: 'Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15',
      platform: 'TV', clientScreen: 'EMBED', hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  MWEB: {
    header: '2',
    client: {
      clientName: 'MWEB', clientVersion: '2.20250101.00.00',
      userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
      platform: 'MOBILE', hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  WEB: {
    header: '1',
    client: {
      clientName: 'WEB', 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',
      platform: 'DESKTOP', hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  // web_safari: clientName WEB (header 1) but a Safari UA — YT serves HLS to
  // Safari, so this is the WebPO client most likely to expose hlsManifestUrl.
  WEB_SAFARI: {
    header: '1',
    client: {
      clientName: 'WEB', clientVersion: '2.20250101.00.00',
      userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
      platform: 'DESKTOP', hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  },
  // web_creator (header 62): the client yt-dlp uses for age-VERIFICATION with
  // logged-in cookies. Requires a Player PoToken for its formats.
  WEB_CREATOR: {
    header: '62',
    client: {
      clientName: 'WEB_CREATOR', clientVersion: '1.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',
      platform: 'DESKTOP', hl: 'en', gl: 'US', timeZone: 'UTC', utcOffsetMinutes: 0
    }
  }
};

const H264_VIDEO_ITAGS = new Set([160, 133, 134, 135, 136, 137, 298, 299]);
const AAC_AUDIO_ITAGS = new Set([139, 140]);

// ---- 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];
  })
);

// ---- optional auth: --cookies=<file> --> Cookie + SAPISIDHASH headers --------
// Cookie alone does NOT authenticate InnerTube; YT web auth needs the
// SAPISIDHASH Authorization header derived from the SAPISID cookie. Build both.
let AUTH_HEADERS = {};
if (flags.cookies) {
  const raw = readFileSync(flags.cookies, 'utf8');
  const jar = {};
  if (/\t/.test(raw)) {
    // Netscape cookies.txt: domain \t flag \t path \t secure \t expiry \t name \t value
    for (const line of raw.split('\n')) {
      if (!line || line.startsWith('#')) continue;
      const c = line.split('\t');
      if (c.length >= 7) jar[c[5].trim()] = c[6].trim();
    }
  } else {
    // raw "Cookie:" header line
    for (const kv of raw.replace(/^Cookie:/i, '').trim().split(/;\s*/)) {
      const i = kv.indexOf('=');
      if (i > 0) jar[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
    }
  }
  const origin = 'https://www.youtube.com';
  const ts = Math.floor(Date.now() / 1000);
  const sha1 = (s) => createHash('sha1').update(s).digest('hex');
  const auth = [];
  if (jar.SAPISID) auth.push(`SAPISIDHASH ${ts}_${sha1(`${ts} ${jar.SAPISID} ${origin}`)}`);
  if (jar['__Secure-1PAPISID']) auth.push(`SAPISID1PHASH ${ts}_${sha1(`${ts} ${jar['__Secure-1PAPISID']} ${origin}`)}`);
  if (jar['__Secure-3PAPISID']) auth.push(`SAPISID3PHASH ${ts}_${sha1(`${ts} ${jar['__Secure-3PAPISID']} ${origin}`)}`);
  AUTH_HEADERS = {
    'Cookie': Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '),
    'Origin': origin,
    'X-Origin': origin,
    'X-Goog-AuthUser': '0'
  };
  if (auth.length) AUTH_HEADERS['Authorization'] = auth.join(' ');
  console.error(`[probe] AUTHENTICATED: ${Object.keys(jar).length} cookies; SAPISIDHASH=${jar.SAPISID ? 'yes' : 'MISSING (login will fail)'}`);
}

if (!positional[0]) {
  console.error('usage: node tools/innertube-probe.mjs <videoId|watchURL> [--clients=ANDROID_VR,IOS,WEB_SAFARI,WEB_CREATOR,...] [--no-seg] [--cookies=<cookies.txt|header.txt>]');
  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 SELECTED = flags.clients
  ? String(flags.clients).split(',').map(s => s.trim().toUpperCase())
  : Object.keys(CLIENTS);
const DO_SEG = !flags['no-seg'];
const POT = flags.pot ? String(flags.pot) : '';        // GVS PO token to append (&pot=) — verify fix / reproduce
const VISITOR = flags.visitor ? String(flags.visitor) : ''; // override visitorData (paste a bucketed user's value)

// ---- visitorData scrape (mirror addon tier-3 HTML 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();
    const vd = (html.match(/"VISITOR_DATA":\s*"([^"]+)"/) || [])[1] || '';
    const sts = Number((html.match(/"STS":\s*(\d+)/) || [])[1]) || 0;
    return { visitorData: vd, signatureTimestamp: sts, watchStatus: resp.status };
  } catch (e) {
    return { visitorData: '', signatureTimestamp: 0, watchStatus: 'ERR ' + e.message };
  }
}

// ---- player request (mirror fetchInnertubePlayer) --------------------------
async function fetchPlayer(name, videoId, sts, visitorData) {
  const def = CLIENTS[name];
  const body = {
    videoId, racyCheckOk: true, contentCheckOk: true,
    playbackContext: { contentPlaybackContext: { signatureTimestamp: sts || 20563, vis: 0, splay: false, lactMilliseconds: '-1' } },
    context: {
      client: { ...def.client, visitorData: visitorData || '' },
      user: { enableSafetyMode: false, lockedSafetyMode: false },
      request: { useSsl: true, internalExperimentFlags: [] }
    }
  };
  const url = 'https://www.youtube.com/youtubei/v1/player?prettyPrint=false&alt=json';
  const t0 = Date.now();
  const resp = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Youtube-Client-Name': def.header,
      'X-Youtube-Client-Version': def.client.clientVersion,
      'X-Goog-Visitor-Id': visitorData || '',
      // addon sets UA via DNR for iOS; we set it directly for every client
      'User-Agent': def.client.userAgent,
      // empty when anonymous; Cookie + SAPISIDHASH when --cookies is passed
      ...AUTH_HEADERS
    },
    body: JSON.stringify(body)
  });
  const ms = Date.now() - t0;
  if (!resp.ok) return { ok: false, status: resp.status, ms, text: (await resp.text()).slice(0, 300) };
  return { ok: true, status: resp.status, ms, json: await resp.json() };
}

// ---- segment fetch (mirror fetchToOpfs &range=, DNR header behaviour) ------
async function probeSegment(name, fmt, useHttpRange, start = 0, end = 1048575) {
  const ua = CLIENTS[name].client.userAgent;
  const headers = {
    // DNR: Origin removed (node omits anyway), Referer set, Cookie absent
    'Referer': 'https://www.youtube.com/',
    'User-Agent': ua
  };
  let url = fmt.url;
  if (POT && !/[?&]pot=/.test(url)) url += `&pot=${encodeURIComponent(POT)}`;
  if (useHttpRange) headers['Range'] = `bytes=${start}-${end}`;
  else url = `${url}&range=${start}-${end}`;
  try {
    const t0 = Date.now();
    const resp = await fetch(url, { headers });
    const ms = Date.now() - t0;
    const buf = await resp.arrayBuffer();
    return { status: resp.status, ms, bytes: buf.byteLength, ctype: resp.headers.get('content-type') || '' };
  } catch (e) {
    return { status: 'ERR', err: e.message };
  }
}

// Drain ALL chunks sequentially (mirror fetchToOpfs 10MB loop) — catches a
// 403 that only appears deep into the file / after N chunks, not on chunk 0.
async function drainFormat(name, fmt, useHttpRange) {
  const CHUNK = 10 * 1048576;
  const total = parseInt(fmt.contentLength || '0', 10);
  if (!total) return { drained: false, why: 'no contentLength' };
  let offset = 0, chunks = 0, got = 0;
  while (offset < total) {
    const end = Math.min(offset + CHUNK - 1, total - 1);
    const r = await probeSegment(name, fmt, useHttpRange, offset, end);
    chunks++;
    if (r.status !== 200 && r.status !== 206) {
      return { drained: false, failAt: offset, chunk: chunks, status: r.status, total };
    }
    got += r.bytes;
    offset = end + 1;
  }
  return { drained: true, chunks, got, total };
}

// ---- analyse one player response -------------------------------------------
function summarize(json) {
  const ps = json.playabilityStatus || {};
  const sd = json.streamingData || {};
  const adaptive = sd.adaptiveFormats || [];
  const progressive = sd.formats || [];
  const all = [...progressive, ...adaptive];
  const withUrl = all.filter(f => f.url);
  const withCipher = all.filter(f => f.signatureCipher || f.cipher);
  const h264 = adaptive.filter(f => H264_VIDEO_ITAGS.has(f.itag) && f.url);
  const aac = adaptive.filter(f => AAC_AUDIO_ITAGS.has(f.itag) && f.url);
  const prog18 = progressive.find(f => f.itag === 18 && f.url);
  return {
    status: ps.status, reason: ps.reason || ps.errorScreen?.playerErrorMessageRenderer?.reason?.simpleText,
    title: json.videoDetails?.title, isLive: json.videoDetails?.isLiveContent,
    nFormats: all.length, nUrl: withUrl.length, nCipher: withCipher.length,
    sabr: !!sd.serverAbrStreamingUrl, hls: !!sd.hlsManifestUrl, dash: !!sd.dashManifestUrl,
    h264, aac, prog18,
    itagsWithUrl: withUrl.map(f => f.itag).sort((a, b) => a - b),
    adaptiveAll: adaptive, progAll: progressive
  };
}

// Full per-format dump (--formats): reveals DRC duplicates, audioTracks, itag 18
// presence, url-vs-SABR, so we can see EXACTLY what a client offers.
function dumpFormats(s) {
  const codec = (f) => ((f.mimeType || '').match(/codecs="([^"]+)"/)?.[1] || '').split('.')[0];
  const kind = (f) => (f.mimeType || '').startsWith('audio') ? 'A' : 'V';
  const row = (f) => {
    const drc = f.isDrc ? ' DRC' : '';
    const track = f.audioTrack ? ` track="${f.audioTrack.displayName || f.audioTrack.id || '?'}"${f.audioTrack.audioIsDefault ? '*' : ''}` : '';
    const dims = f.width ? `${f.width}x${f.height}@${f.fps || '?'}` : (f.audioSampleRate ? `${Math.round((f.bitrate||0)/1000)}k/${f.audioSampleRate}Hz` : '');
    return `    [${kind(f)}] itag ${String(f.itag).padStart(3)} ${(codec(f)||'?').padEnd(5)} ${dims.padEnd(14)} url=${f.url ? 'Y' : '.'} clen=${f.contentLength || '-'}${drc}${track}`;
  };
  console.log(`  --- adaptive (${s.adaptiveAll.length}) ---`);
  for (const f of s.adaptiveAll.slice().sort((a, b) => a.itag - b.itag)) console.log(row(f));
  if (s.progAll.length) {
    console.log(`  --- progressive (${s.progAll.length}) ---`);
    for (const f of s.progAll.slice().sort((a, b) => a.itag - b.itag)) console.log(row(f));
  }
  // Flag itag dups (same itag appearing >1 = DRC / multi-track collision risk)
  const seen = {};
  for (const f of s.adaptiveAll) (seen[f.itag] = seen[f.itag] || []).push(f);
  const dups = Object.entries(seen).filter(([, v]) => v.length > 1);
  if (dups.length) console.log(`  ⚠ DUPLICATE itags: ${dups.map(([it, v]) => `${it}×${v.length}`).join(', ')} (itag-keyed maps will collide)`);
}

// ---- main ------------------------------------------------------------------
(async () => {
  console.log(`\n=== innertube-probe  video=${VIDEO_ID}  clients=[${SELECTED.join(', ')}] ===`);
  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)' : ''}  STS=${cfg.signatureTimestamp || '(default 20563)'}  pot=${POT ? POT.slice(0, 12) + '…' : '(none)'}\n`);

  for (const name of SELECTED) {
    if (!CLIENTS[name]) { console.log(`-- ${name}: UNKNOWN CLIENT, skip`); continue; }
    console.log(`── ${name} (x-youtube-client-name=${CLIENTS[name].header}) ──`);
    let pr;
    try { pr = await fetchPlayer(name, VIDEO_ID, cfg.signatureTimestamp, cfg.visitorData); }
    catch (e) { console.log(`  player POST threw: ${e.message}\n`); continue; }

    if (!pr.ok) { console.log(`  player POST: HTTP ${pr.status} (${pr.ms}ms)  body=${pr.text}\n`); continue; }

    const s = summarize(pr.json);
    console.log(`  player POST: HTTP ${pr.status} (${pr.ms}ms)`);
    console.log(`  playabilityStatus: ${s.status}${s.reason ? '  reason="' + s.reason + '"' : ''}`);
    console.log(`  title: ${s.title || '(n/a)'}  isLive=${s.isLive}`);
    console.log(`  formats: total=${s.nFormats} withUrl=${s.nUrl} withCipher=${s.nCipher}  SABR=${s.sabr} HLS=${s.hls} DASH=${s.dash}`);
    console.log(`  itags(url): [${s.itagsWithUrl.join(',')}]`);
    if (s.nCipher > 0 && s.nUrl === 0) console.log(`  ⚠ all formats signatureCipher (needs decipher) — unusable by addon as-is`);
    if (s.sabr && s.nUrl === 0) console.log(`  ⚠ SABR-only response (no plain videoplayback URLs) — PO-token/SABR enforcement likely`);
    if (flags.formats) dumpFormats(s);

    if (!DO_SEG) { console.log(''); continue; }

    // segment test: prefer adaptive 137 (video) + 140 (audio), else prog 18
    const useHttpRange = name === 'IOS'; // iOS URLs honour HTTP Range, not &range=
    const targets = [];
    const v = s.h264.find(f => f.itag === 137) || s.h264[0];
    const a = s.aac.find(f => f.itag === 140) || s.aac[0];
    if (v) targets.push(['video itag ' + v.itag, v]);
    if (a) targets.push(['audio itag ' + a.itag, a]);
    if (!targets.length && s.prog18) targets.push(['progressive itag 18', s.prog18]);
    if (!targets.length) { console.log(`  segment test: no usable plain-URL format to probe\n`); continue; }

    for (const [label, fmt] of targets) {
      // chunk 0
      const r0 = await probeSegment(name, fmt, useHttpRange, 0, 1048575);
      const v0 = r0.status === 200 || r0.status === 206 ? 'OK' : (r0.status === 403 ? '*** 403 ***' : 'unexpected');
      console.log(`  segment ${label} [${useHttpRange ? 'HTTP Range' : '&range='}] chunk0: HTTP ${r0.status} ${v0}  bytes=${r0.bytes ?? '-'} ctype=${r0.ctype ?? r0.err ?? ''}`);

      // deep range (mid-file) — late-offset 403s
      const clen = parseInt(fmt.contentLength || '0', 10);
      if (clen > 2 * 1048576) {
        const mid = Math.floor(clen / 2);
        const rm = await probeSegment(name, fmt, useHttpRange, mid, Math.min(mid + 1048575, clen - 1));
        const vm = rm.status === 200 || rm.status === 206 ? 'OK' : (rm.status === 403 ? '*** 403 ***' : 'unexpected');
        console.log(`  segment ${label} mid@${mid}: HTTP ${rm.status} ${vm}  bytes=${rm.bytes ?? '-'}`);
      }

      // full drain (opt-in: slow) — catches cumulative/throttle 403
      if (flags.drain) {
        const d = await drainFormat(name, fmt, useHttpRange);
        if (d.drained) console.log(`  segment ${label} DRAIN: OK all ${d.chunks} chunks (${d.got}/${d.total} bytes)`);
        else console.log(`  segment ${label} DRAIN: *** FAIL *** ${d.why || `HTTP ${d.status} at offset ${d.failAt} (chunk ${d.chunk}/${Math.ceil(d.total / (10*1048576))})`}`);
      }
    }
    console.log('');
  }
  console.log('=== done ===\n');
})();
