/*
 * sabr-client.mjs — dependency-free SABR streaming client (milestone 3).
 * This is the PORT TARGET for includes/hls_worker_v2.js: no googlevideo, no
 * deps, no eval. It drives the VideoPlaybackAbrRequest → UMP loop to pull a
 * full video+audio file using a harvested WEB session (url + ustreamerConfig
 * + poToken). Logic mirrors LuanRT googlevideo's SabrStream core.
 *
 * Validated in node by sabr-client-test.mjs (drains captured.json to exact
 * contentLength, matching the googlevideo-driven sabr-drain-capture.mjs).
 *
 * Wire facts (verified against googlevideo generated protos):
 *   UMP framing: repeat [varint partType, varint partSize, partSize bytes]
 *   part ids: MEDIA_HEADER=20 MEDIA=21 MEDIA_END=22 NEXT_REQUEST_POLICY=35
 *             FORMAT_INITIALIZATION_METADATA=42 SABR_REDIRECT=43
 *             STREAM_PROTECTION_STATUS=58
 *   MediaHeader: headerId=1 itag=3 lmt=4 isInitSeg=8 sequenceNumber=9
 *                durationMs=12 formatId=13 contentLength=14 startMs=11
 *   FormatInitializationMetadata: formatId=2 endSegmentNumber=4
 *                durationUnits=9 durationTimescale=10
 *   FormatId: itag=1 lastModified=2
 */
import { encodeVideoPlaybackAbrRequest } from './sabr-proto.mjs';

const PART = { MEDIA_HEADER: 20, MEDIA: 21, MEDIA_END: 22, NEXT_REQUEST_POLICY: 35, FORMAT_INIT: 42, SABR_REDIRECT: 43, STREAM_PROTECTION: 58 };

const readVarint = (b, p) => { let r = 0, s = 0, x; do { x = b[p++]; r += (x & 0x7f) * Math.pow(2, s); s += 7; } while (x & 0x80); return [r, p]; };

// generic protobuf field walk → [field, wireType, value]
function* fields(buf) {
  let pos = 0;
  while (pos < buf.length) {
    let tag; [tag, pos] = readVarint(buf, pos);
    const f = tag >> 3, wt = tag & 7;
    if (wt === 0) { let v; [v, pos] = readVarint(buf, pos); yield [f, wt, v]; }
    else if (wt === 2) { let len; [len, pos] = readVarint(buf, pos); yield [f, wt, buf.subarray(pos, pos + len)]; pos += len; }
    else if (wt === 1) { yield [f, wt, buf.subarray(pos, pos + 8)]; pos += 8; }
    else if (wt === 5) { yield [f, wt, buf.subarray(pos, pos + 4)]; pos += 4; }
    else return;
  }
}

function decodeFormatId(buf) { const o = {}; for (const [f, , v] of fields(buf)) { if (f === 1) o.itag = v; else if (f === 2) o.lastModified = v; } return o; }
function decodeMediaHeader(buf) {
  const o = {}; for (const [f, , v] of fields(buf)) {
    if (f === 1) o.headerId = v; else if (f === 3) o.itag = v; else if (f === 8) o.isInitSeg = !!v;
    else if (f === 9) o.sequenceNumber = v; else if (f === 11) o.startMs = v; else if (f === 12) o.durationMs = v;
    else if (f === 13) o.formatId = decodeFormatId(v); else if (f === 14) o.contentLength = v;
  }
  if (o.itag === undefined && o.formatId) o.itag = o.formatId.itag;
  return o;
}
function decodeFormatInit(buf) {
  const o = {}; for (const [f, , v] of fields(buf)) {
    if (f === 2) o.formatId = decodeFormatId(v); else if (f === 4) o.endSegmentNumber = v;
    else if (f === 9) o.durationUnits = v; else if (f === 10) o.durationTimescale = v;
  }
  return o;
}

// UMP varint — leading-bit length prefix (NOT protobuf varint). The first
// byte's value selects total length; data bits are assembled little-endian.
// (Verified against googlevideo UmpReader.readVarInt.)
function umpVarint(b, p) {
  const f = b[p];
  const n = f < 128 ? 1 : f < 192 ? 2 : f < 224 ? 3 : f < 240 ? 4 : 5;
  let v;
  if (n === 1) v = f;
  else if (n === 2) v = (f & 0x3f) + 64 * b[p + 1];
  else if (n === 3) v = (f & 0x1f) + 32 * (b[p + 1] + 256 * b[p + 2]);
  else if (n === 4) v = (f & 0x0f) + 16 * (b[p + 1] + 256 * (b[p + 2] + 256 * b[p + 3]));
  else v = b[p + 1] + 256 * (b[p + 2] + 256 * (b[p + 3] + 256 * b[p + 4]));
  return [v, p + n];
}

// UMP parse → [{type, data:Uint8Array}]  (framing uses UMP varints)
function parseUmp(buf) {
  const parts = []; let pos = 0;
  while (pos < buf.length) {
    let type, size; [type, pos] = umpVarint(buf, pos); if (pos > buf.length) break;
    [size, pos] = umpVarint(buf, pos);
    parts.push({ type, data: buf.subarray(pos, pos + size) }); pos += size;
  }
  return parts;
}

export const base64ToU8 = (b64) => Uint8Array.from(atob(b64.replace(/-/g, '+').replace(/_/g, '/').padEnd(b64.length + (4 - b64.length % 4) % 4, '=')), c => c.charCodeAt(0));

/*
 * Drive the SABR loop. Writes finished segment bytes via onChunk('video'|'audio', Uint8Array).
 * Returns { video: bytes, audio: bytes } totals. Throws on attestation failure / stall.
 */
export async function driveSabr(cfg) {
  const { serverAbrStreamingUrl, ustreamerConfig, poToken, clientInfo, video, audio, fetchFn, onChunk, onProgress } = cfg;
  const ustreamerBytes = ustreamerConfig instanceof Uint8Array ? ustreamerConfig : base64ToU8(ustreamerConfig);
  const potBytes = poToken ? base64ToU8(poToken) : undefined;

  // per-itag state
  const fmtState = new Map(); // itag -> { totalDurationMs, downloadedDurationMs, segs:Set, lastHeaders:[], bytes }
  const ensure = (itag) => { if (!fmtState.has(itag)) fmtState.set(itag, { totalDurationMs: 0, downloadedDurationMs: 0, segs: new Set(), lastHeaders: [], bytes: 0 }); return fmtState.get(itag); };
  ensure(video.itag); ensure(audio.itag);
  const kind = (itag) => itag === audio.itag ? 'audio' : 'video';

  let url = serverAbrStreamingUrl;
  let cachedBufferedRanges = undefined;
  let inited = false;
  let totalBytes = 0, lastTotal = -1, stalls = 0, iters = 0;

  const buildBufferedRanges = () => {
    const ranges = [];
    for (const [itag, st] of fmtState) {
      if (!st.lastHeaders.length) continue;
      const durationMs = st.lastHeaders.reduce((s, h) => s + (Number(h.durationMs) || 0), 0);
      ranges.push({
        formatId: { itag, lastModified: itag === video.itag ? video.lastModified : audio.lastModified },
        startTimeMs: String(st.lastHeaders[0].startMs || 0),
        durationMs: String(durationMs),
        startSegmentIndex: st.lastHeaders[0].sequenceNumber || 1,
        endSegmentIndex: st.lastHeaders[st.lastHeaders.length - 1].sequenceNumber || 1
      });
      st.lastHeaders = [];
    }
    return ranges;
  };

  while (iters++ < 2000) {
    const videoSt = fmtState.get(video.itag);
    const totalDurationMs = videoSt.totalDurationMs;
    if (totalDurationMs > 0 && videoSt.downloadedDurationMs >= totalDurationMs) break; // complete

    if (cachedBufferedRanges === undefined) cachedBufferedRanges = buildBufferedRanges();

    const req = {
      clientAbrState: { playerTimeMs: String(videoSt.downloadedDurationMs) },
      selectedFormatIds: inited ? [{ itag: video.itag, lastModified: video.lastModified }, { itag: audio.itag, lastModified: audio.lastModified }] : [],
      bufferedRanges: cachedBufferedRanges,
      videoPlaybackUstreamerConfig: ustreamerBytes,
      preferredAudioFormatIds: [{ itag: audio.itag, lastModified: audio.lastModified }],
      preferredVideoFormatIds: [{ itag: video.itag, lastModified: video.lastModified }],
      streamerContext: { clientInfo, poToken: potBytes }
    };
    const body = encodeVideoPlaybackAbrRequest(req);

    const resp = await fetchFn(url, { method: 'POST', body });
    if (resp.status !== 200) throw new Error(`SABR POST HTTP ${resp.status}`);
    const buf = new Uint8Array(await resp.arrayBuffer());
    const parts = parseUmp(buf);
    if (globalThis.process?.env?.SABR_DEBUG) console.error(`[dbg] iter${iters} reqBody=${body.length}B resp=${buf.length}B parts=${parts.map(p => p.type + ':' + p.data.length).join(',')}`);

    const queue = new Map(); // headerId -> {itag, seq, durationMs, contentLength, chunks:[], got}
    let protection = null, sawMedia = false;
    for (const { type, data } of parts) {
      if (type === PART.FORMAT_INIT) {
        const fi = decodeFormatInit(data);
        const itag = fi.formatId?.itag; if (itag === undefined) continue;
        const st = ensure(itag);
        if (fi.durationUnits && fi.durationTimescale) st.totalDurationMs = Math.ceil((Number(fi.durationUnits) / Number(fi.durationTimescale)) * 1000);
        inited = true;
      } else if (type === PART.MEDIA_HEADER) {
        const mh = decodeMediaHeader(data);
        const seg = mh.isInitSeg ? 0 : (mh.sequenceNumber || 0);
        const st = ensure(mh.itag);
        if (st.segs.has(seg)) continue; // dedupe
        queue.set(mh.headerId || 0, { itag: mh.itag, seg, durationMs: mh.durationMs || 0, startMs: mh.startMs, sequenceNumber: mh.sequenceNumber, contentLength: Number(mh.contentLength) || 0, chunks: [], got: 0 });
      } else if (type === PART.MEDIA) {
        const headerId = data[0];
        const s = queue.get(headerId); if (!s) continue;
        const chunk = data.subarray(1);
        s.chunks.push(chunk); s.got += chunk.length; sawMedia = true;
      } else if (type === PART.MEDIA_END) {
        const headerId = data[0];
        const s = queue.get(headerId); if (!s) continue;
        if (s.contentLength && s.got !== s.contentLength) { queue.delete(headerId); continue; } // drop short
        const st = ensure(s.itag);
        for (const c of s.chunks) { if (onChunk) onChunk(kind(s.itag), c); st.bytes += c.length; totalBytes += c.length; }
        st.segs.add(s.seg);
        st.downloadedDurationMs += Number(s.durationMs) || 0;
        st.lastHeaders.push({ startMs: s.startMs, durationMs: s.durationMs, sequenceNumber: s.sequenceNumber });
        queue.delete(headerId);
      } else if (type === PART.STREAM_PROTECTION) {
        for (const [f, , v] of fields(data)) if (f === 1) protection = v;
      } else if (type === PART.SABR_REDIRECT) {
        for (const [f, , v] of fields(data)) if (f === 1) url = new TextDecoder().decode(v);
      }
    }

    if (onProgress) onProgress(totalBytes);
    if (!sawMedia && protection !== null && protection >= 2) throw new Error(`SABR attestation failed (streamProtectionStatus=${protection}) — pot invalid/stale`);

    cachedBufferedRanges = undefined; // rebuilt next iter from this request's lastHeaders
    if (totalBytes === lastTotal) { if (++stalls >= 3) throw new Error(`SABR stalled at ${totalBytes} bytes`); } else { stalls = 0; lastTotal = totalBytes; }
  }

  return { video: fmtState.get(video.itag).bytes, audio: fmtState.get(audio.itag).bytes };
}
