/* pdf_steg_extract.js -- recover the Mysterion stegId from a signed PDF entirely
 * in JS (no PDFium). Handles the FXX3 watermark image which may be RC4-encrypted
 * (PDF Standard Security Handler, empty user password) and Flate-compressed.
 *
 * Pipeline: locate FXX3 image stream -> (RC4 decrypt if /Encrypt) -> inflate ->
 * steg_lsb LSB recovery of "stegId|creatorId".
 *
 * Depends on: pako (inflate), and MysterionStegLsb (steg_lsb.js) for recovery.
 * MD5 + RC4 are implemented inline (no Node crypto -- browser-safe).
 */
(function () {
  'use strict';

  // ---- MD5 (RFC 1321) -- browser-safe, operates on Uint8Array ----
  function md5(bytes) {
    function rol(x, c) { return (x << c) | (x >>> (32 - c)); }
    const s = [7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21];
    const K = [];
    for (let i = 0; i < 64; i++) K[i] = (Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296)) >>> 0;
    const ml = bytes.length * 8;
    const withOne = new Uint8Array(((bytes.length + 8) >> 6) + 1 << 6);
    withOne.set(bytes); withOne[bytes.length] = 0x80;
    const dv = new DataView(withOne.buffer);
    dv.setUint32(withOne.length - 8, ml >>> 0, true);
    dv.setUint32(withOne.length - 4, Math.floor(ml / 4294967296) >>> 0, true);
    let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
    for (let off = 0; off < withOne.length; off += 64) {
      const M = [];
      for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true);
      let A = a0, B = b0, C = c0, D = d0;
      for (let i = 0; i < 64; i++) {
        let F, g;
        if (i < 16) { F = (B & C) | (~B & D); g = i; }
        else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16; }
        else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16; }
        else { F = C ^ (B | ~D); g = (7 * i) % 16; }
        F = (F + A + K[i] + M[g]) >>> 0;
        A = D; D = C; C = B; B = (B + rol(F, s[i])) >>> 0;
      }
      a0 = (a0 + A) >>> 0; b0 = (b0 + B) >>> 0; c0 = (c0 + C) >>> 0; d0 = (d0 + D) >>> 0;
    }
    const out = new Uint8Array(16); const odv = new DataView(out.buffer);
    odv.setUint32(0, a0, true); odv.setUint32(4, b0, true); odv.setUint32(8, c0, true); odv.setUint32(12, d0, true);
    return out;
  }

  // ---- RC4 ----
  function rc4(key, data) {
    const S = new Uint8Array(256);
    for (let i = 0; i < 256; i++) S[i] = i;
    let j = 0;
    for (let i = 0; i < 256; i++) { j = (j + S[i] + key[i % key.length]) & 0xff; const t = S[i]; S[i] = S[j]; S[j] = t; }
    const out = new Uint8Array(data.length);
    let a = 0, b = 0;
    for (let k = 0; k < data.length; k++) { a = (a + 1) & 0xff; b = (b + S[a]) & 0xff; const t = S[a]; S[a] = S[b]; S[b] = t; out[k] = data[k] ^ S[(S[a] + S[b]) & 0xff]; }
    return out;
  }

  const PAD = new Uint8Array([0x28,0xBF,0x4E,0x5E,0x4E,0x75,0x8A,0x41,0x64,0x00,0x4E,0x56,0xFF,0xFA,0x01,0x08,0x2E,0x2E,0x00,0xB6,0xD0,0x68,0x3E,0x80,0x2F,0x0C,0xA9,0xFE,0x64,0x53,0x69,0x7A]);

  function _concat(arrs) {
    let n = 0; for (const a of arrs) n += a.length;
    const out = new Uint8Array(n); let o = 0;
    for (const a of arrs) { out.set(a, o); o += a.length; }
    return out;
  }
  function _hexToBytes(h) { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); return b; }
  function _latin1(bytes, s, e) { let r = ''; for (let i = s; i < e; i++) r += String.fromCharCode(bytes[i]); return r; }
  // Byte-level substring search -- no full-file string build. ASCII needle.
  function _bytesIndexOf(hay, needleStr, from) {
    from = from || 0;
    const n = needleStr.length;
    const n0 = needleStr.charCodeAt(0);
    const end = hay.length - n;
    for (let i = from; i <= end; i++) {
      if (hay[i] !== n0) continue;
      let ok = true;
      for (let j = 1; j < n; j++) { if (hay[i + j] !== needleStr.charCodeAt(j)) { ok = false; break; } }
      if (ok) return i;
    }
    return -1;
  }

  // Compute the file encryption key (R2/R3, empty user password).
  function _fileKey(O, P, ID0, n, R) {
    const pbuf = new Uint8Array(4); new DataView(pbuf.buffer).setInt32(0, P, true);
    let key = md5(_concat([PAD, O, pbuf, ID0]));
    if (R >= 3) { for (let i = 0; i < 50; i++) key = md5(key.slice(0, n)); }
    return key.slice(0, n);
  }
  function _objectKey(fileKey, num, gen) {
    const ext = new Uint8Array(5);
    ext[0] = num & 0xff; ext[1] = (num >> 8) & 0xff; ext[2] = (num >> 16) & 0xff;
    ext[3] = gen & 0xff; ext[4] = (gen >> 8) & 0xff;
    return md5(_concat([fileKey, ext])).slice(0, Math.min(fileKey.length + 5, 16));
  }

  // Parse the /Encrypt dict + /ID from the PDF. Returns {fileKey, n} or null.
  function _parseEncryption(bytes) {
    const encIdx = _bytesIndexOf(bytes, '/Encrypt', 0);
    if (encIdx < 0) return null;
    const encRegion = _latin1(bytes, encIdx, Math.min(bytes.length, encIdx + 40));
    const encRef = encRegion.match(/\/Encrypt\s+(\d+)\s+\d+\s+R/);
    if (!encRef) return null; // not encrypted
    const encNum = parseInt(encRef[1]);
    // Find "<encNum> 0 obj" by byte search (not preceded by a digit).
    const marker = encNum + ' 0 obj';
    let objStart = -1, scan = 0;
    while (true) {
      const hit = _bytesIndexOf(bytes, marker, scan);
      if (hit < 0) break;
      const prev = hit > 0 ? bytes[hit - 1] : 0x20;
      if (!(prev >= 0x30 && prev <= 0x39)) { objStart = hit; break; }
      scan = hit + 1;
    }
    if (objStart < 0) return null;
    // Region for detection (may span nested << >> for AES /CF).
    const region = _latin1(bytes, objStart, Math.min(bytes.length, objStart + 800));
    const isAES = /\/AESV[23]/.test(region);
    const Rd = parseInt((region.match(/\/R\s+(\d+)/) || [])[1] || '2');
    const Vd = parseInt((region.match(/\/V\s+(\d+)/) || [])[1] || '1');
    if (isAES || Vd >= 4 || Rd >= 5) {
      return { unsupported: true, aes: true, R: Rd, V: Vd };
    }
    // RC4 path: dict bounded at first ">>".
    const closeRel = region.indexOf('>>');
    const dict = closeRel >= 0 ? region.slice(0, closeRel + 2) : region;
    const R = Rd, V = Vd;
    const lenBits = parseInt((dict.match(/\/Length\s+(\d+)/) || [])[1] || (V === 1 ? '40' : '128'));
    const P = parseInt((dict.match(/\/P\s+(-?\d+)/) || [])[1]);
    const O = _parseLiteralBytes(dict, '/O');
    // /ID lives in the trailer as "/ID[<hex><hex>]". A bare "/ID" can appear in
    // binary content, so search for the array form "/ID[" and, to be safe,
    // prefer the LAST occurrence (the trailer is at the end of the file).
    let ID0 = new Uint8Array(0);
    let idSearch = 0, idPos = -1;
    while (true) {
      const h = _bytesIndexOf(bytes, '/ID[', idSearch);
      if (h < 0) break;
      idPos = h; idSearch = h + 1;
    }
    if (idPos >= 0) {
      const idRegion = _latin1(bytes, idPos, Math.min(bytes.length, idPos + 100));
      const idm = idRegion.match(/\/ID\s*\[\s*<([0-9A-Fa-f]+)>/);
      if (idm) ID0 = _hexToBytes(idm[1]);
    }
    const n = lenBits / 8;
    const fileKey = _fileKey(O, P, ID0, n, R);
    return { fileKey, n, R, V };
  }
  // Parse a PDF literal string value for a given key, e.g. /O(...). Returns the
  // raw bytes with escapes resolved. Works on the latin1 dict text.
  function _parseLiteralBytes(dictText, key) {
    const ki = dictText.indexOf(key);
    if (ki < 0) return null;
    let i = ki + key.length;
    while (i < dictText.length && dictText[i] !== '(' && dictText[i] !== '<') i++;
    if (dictText[i] === '<') {
      // hex string
      let j = i + 1, hex = '';
      while (j < dictText.length && dictText[j] !== '>') { const c = dictText[j]; if (/[0-9A-Fa-f]/.test(c)) hex += c; j++; }
      if (hex.length % 2) hex += '0';
      const out = new Uint8Array(hex.length / 2);
      for (let k = 0; k < out.length; k++) out[k] = parseInt(hex.substr(k * 2, 2), 16);
      return out;
    }
    // literal string ( ... ) with balanced parens + escapes
    i++; // skip (
    const bytes = [];
    let depth = 1;
    while (i < dictText.length) {
      const c = dictText[i];
      if (c === '\\') {
        const n = dictText[i + 1];
        if (n === 'n') { bytes.push(10); i += 2; }
        else if (n === 'r') { bytes.push(13); i += 2; }
        else if (n === 't') { bytes.push(9); i += 2; }
        else if (n === 'b') { bytes.push(8); i += 2; }
        else if (n === 'f') { bytes.push(12); i += 2; }
        else if (n === '(') { bytes.push(40); i += 2; }
        else if (n === ')') { bytes.push(41); i += 2; }
        else if (n === '\\') { bytes.push(92); i += 2; }
        else if (n >= '0' && n <= '7') {
          let oct = ''; let k = i + 1;
          while (k < dictText.length && oct.length < 3 && dictText[k] >= '0' && dictText[k] <= '7') { oct += dictText[k]; k++; }
          bytes.push(parseInt(oct, 8) & 0xff); i = k;
        } else { bytes.push(n.charCodeAt(0)); i += 2; }
      } else if (c === '(') { depth++; bytes.push(40); i++; }
      else if (c === ')') { depth--; if (depth === 0) { i++; break; } bytes.push(41); i++; }
      else { bytes.push(c.charCodeAt(0) & 0xff); i++; }
    }
    return new Uint8Array(bytes);
  }

  // Find the FXX3 image object's raw (possibly encrypted) stream + its obj num.
  function _findFxx3Stream(bytes) {
    // Locate "/FXX3 <n> <g> R" by byte search; fall back to largest image object.
    let objNum = null;
    const refIdx = _bytesIndexOf(bytes, '/FXX3', 0);
    if (refIdx >= 0) {
      // parse the number after "/FXX3 "
      let i = refIdx + 5;
      while (i < bytes.length && (bytes[i] === 0x20 || bytes[i] === 0x0d || bytes[i] === 0x0a)) i++;
      let num = 0, any = false;
      while (i < bytes.length && bytes[i] >= 0x30 && bytes[i] <= 0x39) { num = num * 10 + (bytes[i] - 0x30); i++; any = true; }
      if (any) objNum = num;
    }
    if (objNum == null) return null;
    // Find "<objNum> 0 obj" at byte level.
    const marker = objNum + ' 0 obj';
    let objStart = -1, scan = 0;
    while (true) {
      const hit = _bytesIndexOf(bytes, marker, scan);
      if (hit < 0) break;
      // ensure not preceded by a digit (avoid 161 matching 61)
      const prev = hit > 0 ? bytes[hit - 1] : 0x20;
      if (!(prev >= 0x30 && prev <= 0x39)) { objStart = hit; break; }
      scan = hit + 1;
    }
    if (objStart < 0) return null;
    // Dict text is small -- latin1 just the region up to "stream".
    const streamKw = _bytesIndexOf(bytes, 'stream', objStart);
    if (streamKw < 0) return null;
    const dictText = _latin1(bytes, objStart, streamKw);
    const length = parseInt((dictText.match(/\/Length\s+(\d+)/) || [])[1]);
    const width = parseInt((dictText.match(/\/Width\s+(\d+)/) || [])[1] || '0');
    const height = parseInt((dictText.match(/\/Height\s+(\d+)/) || [])[1] || '0');
    // stream data starts after "stream" + EOL.
    let ds = streamKw + 6;
    if (bytes[ds] === 0x0d && bytes[ds + 1] === 0x0a) ds += 2; else if (bytes[ds] === 0x0a) ds += 1;
    const stream = bytes.slice(ds, ds + length);
    return { objNum, gen: 0, stream, width, height };
  }
  function _indexOf(bytes, kw, from) {
    const t = _latin1(bytes, from, Math.min(bytes.length, from + 2000000));
    const i = t.indexOf(kw);
    return i < 0 ? -1 : from + i;
  }

  // Main entry: returns {stegId, creatorId} or null.
  // Returns one of:
  //   { stegId, creatorId }            -- recovered
  //   null                             -- no watermark / unreadable
  //   { passwordProtected: true, aes } -- encrypted in a way we can't open here
  function recoverStegIdFromPdf(pdfBytes, inflate) {
    const found = _findFxx3Stream(pdfBytes);
    if (!found) return null;
    let raw = found.stream;
    const enc = _parseEncryption(pdfBytes);
    if (enc && enc.unsupported) {
      // Encrypted with AES / non-empty-password scheme we don't implement here.
      return { passwordProtected: true, aes: !!enc.aes };
    }
    if (enc) {
      const okey = _objectKey(enc.fileKey, found.objNum, found.gen);
      raw = rc4(okey, raw);
    }
    let rgb;
    try { rgb = inflate(raw); } catch (e) { return null; }
    return globalThis.MysterionStegLsb.recoverStegId(rgb, found.width, found.height, 3);
  }

    // Async variant: [inflateAsync] returns a Promise<Uint8Array>. Lets the
  // extension use the browser-native DecompressionStream (no pako bundle).
  // [password] optional; needed for AES/R6 (non-empty-password) PDFs.
  async function recoverStegIdFromPdfAsync(pdfBytes, inflateAsync, password) {
    const found = _findFxx3Stream(pdfBytes);
    if (!found) return null;
    let raw = found.stream;
    const enc = _parseEncryption(pdfBytes);
    if (enc && enc.unsupported) {
      // AES / non-empty-password. If no password provided, signal it.
      if (!password) return { passwordProtected: true, aes: !!enc.aes };
      // AES-256/R6 decryption path (WebCrypto) -- handled by caller-provided hook.
      if (typeof globalThis.MysterionPdfAes !== 'undefined') {
        try {
          raw = await globalThis.MysterionPdfAes.decryptStream(pdfBytes, found, password);
        } catch (e) { return { passwordProtected: true, aes: true, wrongPassword: true }; }
      } else {
        return { passwordProtected: true, aes: !!enc.aes };
      }
    } else if (enc) {
      const okey = _objectKey(enc.fileKey, found.objNum, found.gen);
      raw = rc4(okey, raw);
    }
    let rgb;
    try { rgb = await inflateAsync(raw); } catch (e) { return null; }
    return globalThis.MysterionStegLsb.recoverStegId(rgb, found.width, found.height, 3);
  }

globalThis.MysterionPdfSteg = { recoverStegIdFromPdf, recoverStegIdFromPdfAsync, _fileKey, _objectKey, rc4, md5 };
})();
