// ============================================================
//  Mysterion Verify — Chrome Background Script (v5.6.16)
//  Content-provenance verification: image, video, and PDF.
//  Video cascade Layers 0-6 (Layer 6 = pHash keyframe fallback via
//  MP4Box demuxer, attributes re-encoded/trimmed video to its creator).
//  Range-request + software-decode fallbacks for large/HW-decode cases.
// Firefox MV2: blake3.js is loaded via manifest background.scripts — no importScripts needed
// Chrome MV3: importScripts loads blake3.js into the service worker
if (typeof importScripts === 'function') {
  importScripts('blake3.js');
  // HOTFIX 10.1: MP4Box demuxer required by Layer 6 pHash fallback (Chrome MV3 SW)
  importScripts('mp4box.all.js');
  // PDF verify (Tier-1, pure-JS): steg recovery + tamper localization helpers.
  // steg_lsb.js -> globalThis.MysterionStegLsb; pdf_localization.js -> MysterionPdfLoc;
  // pdf_steg_extract.js -> globalThis.MysterionPdfSteg. All use DecompressionStream
  // (browser-native), no pako bundle. Loaded into the MV3 service worker here since
  // Chrome has no manifest background.scripts array (that's MV2/Firefox only).
  importScripts('pdf_localization.js');
  importScripts('steg_lsb.js');
  importScripts('pdf_steg_extract.js');
  // patch2: v2 native loader (keyed DCT-SS recovery). IIFE + dynamic
  // import() internally, so importScripts-safe. Firefox loads it via
  // manifest background.scripts instead.
  // patch19: load the native glue at TOP LEVEL (SW-install time). MV3
  // forbids importScripts after install, so it cannot be done lazily.
  importScripts('mysterion_native_global.js');
  importScripts('mysterion_native_loader.js');
}
// Initialize BLAKE3 WASM at service worker startup
blake3Init().catch(e => console.warn('[Mysterion] BLAKE3 WASM init failed, will use SHA-256 fallback:', e));

//  v2.0.0: WASM-accelerated extraction & hashing:
//          - MysterionWASM: 18KB WASM module for LSB/DCT/pHash/Hamming
//          - WASM tried first, JS fallback preserved for all paths
//  v1.6.0: GPU & computation optimizations:
//          - Precomputed _COS32 cosine table for _computeImagePHash
//            (replaces inline Math.cos() — same approach as content.js)
//          - Precomputed _COS8 / _SCALE8 tables for _dct8 / _dct2d8
//            (accelerates DCT extraction for steg_id recovery)
//          - OffscreenCanvas used for image processing (Chrome MV3 SW)
//          - CPU fallback preserved for all paths
//  v1.5.1: Video hover speed fix — pHash-first, container fetch in background.
//          Image speed fix — fast DCT at offset(0,0) before brute-force.
//          Skip full 64-offset DCT scan; pHash API handles edge cases.
//  v1.5.0: Bumped to match content script v1.5.0.
//  v1.1.1: Fixed MV3 service worker compatibility:
//          - onMessage returns true for async keepalive
//          - Removed document.createElement fallback (no DOM in SW)
//          - OffscreenCanvas used directly (available in Chrome SW)
//  v1.1.0: Video container metadata extraction — fetches the
//          MP4 file, reads MYSTERION_STEG_ID from the udta/meta
//          atoms, verifies via /crypto/verify (same as images).
//          Falls back to pHash for re-encoded videos (YouTube).
//  v1.0.0: Client-side LSB extraction — image bytes NEVER
//          sent to the platform. Extension fetches the image,
//          extracts steg_id locally, sends only metadata.
//  Chrome MV3: service worker, uses chrome.* API
// ============================================================

// ══════════════════════════════════════════════════════════════════════════════
//  Mysterion WASM Loader — v5.4.0
//  Shared WASM integration layer for content.js and background.js.
//  Loads mysterion.wasm, exposes high-level functions with JS fallback.
//
//  Usage:
//    const wasm = new MysterionWASM();
//    await wasm.init();  // call once
//    if (wasm.available) { ... use wasm functions ... }
// ══════════════════════════════════════════════════════════════════════════════

class MysterionWASM {
  constructor() {
    this._instance = null;
    this._memory = null;
    this._inputPtr = 0;
    this._outputPtr = 0;
    this._hashPtr = 0;
    this._grayPtr = 0;
    this._available = false;
  }

  get available() { return this._available; }

  async init() {
    try {
      // Determine the WASM file URL based on context
      const wasmUrl = this._getWasmUrl();
      if (!wasmUrl) {
        console.warn('[Mysterion WASM] Could not determine WASM URL');
        return;
      }

      const response = await fetch(wasmUrl);
      if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
      const bytes = await response.arrayBuffer();

      const { instance } = await WebAssembly.instantiate(bytes, {
        env: {} // no imports needed — pure computation
      });

      this._instance = instance;
      this._memory = instance.exports.memory;

      // Get buffer pointers
      this._inputPtr = instance.exports.get_input_ptr();
      this._outputPtr = instance.exports.get_output_ptr();
      this._hashPtr = instance.exports.get_hash_ptr();
      this._grayPtr = instance.exports.get_gray_ptr();

      // Initialize cosine tables
      instance.exports.init();

      this._available = true;
      console.log('[Mysterion WASM] Initialized successfully (18KB module)');
    } catch (e) {
      console.warn('[Mysterion WASM] Init failed, using JS fallback:', e.message);
      this._available = false;
    }
  }

  _getWasmUrl() {
    // In browser extension context, use runtime.getURL
    if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL) {
      return browser.runtime.getURL('mysterion.wasm');
    }
    if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getURL) {
      return chrome.runtime.getURL('mysterion.wasm');
    }
    // Fallback: relative path
    return './mysterion.wasm';
  }

  // ── pHash from 32×32 grayscale Float64Array ──────────────────────────────
  computePHash32(gray1024) {
    if (!this._available) return null;
    try {
      // Write grayscale data into WASM GRAY_BUF (1024 f64 values = 8192 bytes)
      const grayView = new Float64Array(this._memory.buffer, this._grayPtr, 1024);
      grayView.set(gray1024);

      const ok = this._instance.exports.compute_phash_32();
      if (ok !== 1) return null;

      // Read 16-char hex hash from HASH_BUF
      const hashBytes = new Uint8Array(this._memory.buffer, this._hashPtr, 16);
      return String.fromCharCode(...hashBytes);
    } catch (e) {
      console.warn('[Mysterion WASM] computePHash32 failed:', e.message);
      return null;
    }
  }

  // ── LSB extraction from RGBA pixel data ──────────────────────────────────
  extractLSB(pixels, width, height) {
    if (!this._available) return null;
    try {
      const byteLen = width * height * 4;
      if (byteLen > 4 * 1024 * 1024) return null; // exceeds INPUT_BUF

      // Write pixel data into WASM INPUT_BUF
      const inputView = new Uint8Array(this._memory.buffer, this._inputPtr, byteLen);
      inputView.set(pixels.subarray ? pixels.subarray(0, byteLen) : pixels.slice(0, byteLen));

      const payLen = this._instance.exports.extract_lsb(width, height);
      if (payLen === 0) return null;

      // Read payload from OUTPUT_BUF
      const payload = new Uint8Array(this._memory.buffer, this._outputPtr, payLen);
      return new Uint8Array(payload); // copy out before buffer might move
    } catch (e) {
      console.warn('[Mysterion WASM] extractLSB failed:', e.message);
      return null;
    }
  }

  // ── DCT extraction from RGBA pixel data ──────────────────────────────────
  extractDCT(pixels, width, height) {
    if (!this._available) return null;
    try {
      const byteLen = width * height * 4;
      if (byteLen > 4 * 1024 * 1024) return null; // exceeds INPUT_BUF

      const inputView = new Uint8Array(this._memory.buffer, this._inputPtr, byteLen);
      inputView.set(pixels.subarray ? pixels.subarray(0, byteLen) : pixels.slice(0, byteLen));

      const payLen = this._instance.exports.extract_dct(width, height);
      if (payLen === 0) return null;

      const payload = new Uint8Array(this._memory.buffer, this._outputPtr, payLen);
      return new Uint8Array(payload);
    } catch (e) {
      console.warn('[Mysterion WASM] extractDCT failed:', e.message);
      return null;
    }
  }

  // ── Image pHash from RGBA pixel data (resize + grayscale + DCT) ─────────
  computeImagePHash(pixels, width, height) {
    if (!this._available) return null;
    try {
      const byteLen = width * height * 4;
      if (byteLen > 4 * 1024 * 1024) return null; // exceeds INPUT_BUF

      const inputView = new Uint8Array(this._memory.buffer, this._inputPtr, byteLen);
      inputView.set(pixels.subarray ? pixels.subarray(0, byteLen) : pixels.slice(0, byteLen));

      const ok = this._instance.exports.compute_image_phash(width, height);
      if (ok !== 1) return null;

      const hashBytes = new Uint8Array(this._memory.buffer, this._hashPtr, 16);
      return String.fromCharCode(...hashBytes);
    } catch (e) {
      console.warn('[Mysterion WASM] computeImagePHash failed:', e.message);
      return null;
    }
  }


  // ── SEI NAL scan (WASM stub — ready for next WASM module build) ────────
  scanSEINAL(buffer) {
    // Future: call this._instance.exports.scan_sei_nal() when WASM exports it
    // For now, JS _extractSEINALFromMP4() handles all SEI scanning
    return null;
  }

  // ── Audio LSB extraction (WASM stub — ready for next WASM module build) ─
  extractAudioLSB(pcmSamples) {
    // Future: call this._instance.exports.extract_audio_lsb() when WASM exports it
    // For now, JS _extractAudioLSBFromMP4() handles all audio LSB extraction
    return null;
  }

  // ── Hamming distance ─────────────────────────────────────────────────────
  hammingHex(hashA, hashB) {
    if (!this._available) return null;
    if (!hashA || !hashB || hashA.length !== 16 || hashB.length !== 16) return null;
    try {
      // Write hashA to HASH_BUF, hashB to INPUT_BUF[0..16]
      const hashView = new Uint8Array(this._memory.buffer, this._hashPtr, 16);
      const inputView = new Uint8Array(this._memory.buffer, this._inputPtr, 16);
      for (let i = 0; i < 16; i++) {
        hashView[i] = hashA.charCodeAt(i);
        inputView[i] = hashB.charCodeAt(i);
      }
      return this._instance.exports.hamming_hex();
    } catch (e) {
      return null;
    }
  }
}

// ── WASM engine global instance ──────────────────────────────────────────────
let _wasmEngine = null;
(async () => {
  // patch26: legacy 18KB mysterion.wasm engine retired — single
  // engine policy. Watermark verdicts come only from the native
  // wasm-bindgen module (MysterionNativeV2); pHash/hamming/LSB
  // self-ID use the pure-JS fallbacks. Leaving _wasmEngine null
  // makes every _wasmEngine call fall through to JS.
  _wasmEngine = null;  // was: new MysterionWASM()
  if (_wasmEngine) await _wasmEngine.init();
})();

const API_URL = 'https://mysterion.io';

const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const _HEX64_RE = /^[0-9a-f]{64}$/i;
// v5.5.93-4: canonical steg_id wire format is 32-hex lowercase per
// routers/sign.py:_STEG_ID_RE. The Dart signer's LSB payload uses the 32-hex
// form (initResp.stegId). Accept either 32-hex or legacy dashed UUID and
// normalize to canonical 32-hex lowercase before any server round-trip.
const _HEX32_RE = /^[0-9a-f]{32}$/i;
function _normalizeStegId(s) {
  if (!s || typeof s !== 'string') return null;
  if (_HEX32_RE.test(s)) return s.toLowerCase();
  if (_UUID_RE.test(s))  return s.replace(/-/g, '').toLowerCase();
  return null;
}

// ── Maintenance detection ────────────────────────────────────────────────────
// When backends are down, Traefik's errors middleware routes to the lighttpd
// maintenance container which returns {"error":"maintenance","status":503}.
// This helper checks a fetch Response for that pattern and sends a clean
// maintenance message to the content script instead of a raw error.
async function _checkMaintenance(response, mediaUrl, tabId, videoId) {
  if (response.status === 503) {
    try {
      const body = await response.clone().json();
      if (body && body.error === 'maintenance') {
        chrome.tabs.sendMessage(tabId, {
          type: 'VERIFY_MAINTENANCE',
          imageUrl: mediaUrl,
          videoId: videoId || null,
          message: body.message || 'Platform maintenance in progress.',
        });
        return true;
      }
    } catch (_) { /* not JSON — fall through to normal error handling */ }
  }
  return false;
}

  // ── Provenance result mapping ───────────────────────────────────────────
  function _provenanceResult(qp) {
    if (qp === 'original')            return 'verified';
    if (qp === 'later_signing')       return 'altered';        // re-signed copy
    if (qp === 'unsigned_derivative') return 'probable_match'; // robot icon
    return 'unknown';
  }
  function _provenanceDetail(qp, creatorName) {
    const who = creatorName || 'unknown creator';
    if (qp === 'original')            return `Verified original by ${who}`;
    if (qp === 'later_signing')       return `Later re-signing — original by ${who}`;
    if (qp === 'unsigned_derivative') return `Unknown Origin — resembles original by ${who}`;
    return 'No Mysterion signature found';
  }


chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: 'mysterion-verify', title: 'Verify with Mysterion',
    contexts: ['image', 'video', 'audio']
  });
});

// ── Raw PNG Decoder (v5.5.55) ─────────────────────────────────────────────
// Decodes PNG into raw RGBA pixels WITHOUT browser color management.
// Preserves exact LSB values needed for steganographic extraction.
// Handles 8-bit RGB (type 2) and RGBA (type 6), non-interlaced only.

function _paethPredictor(a, b, c) {
  const p = a + b - c;
  const pa = Math.abs(p - a);
  const pb = Math.abs(p - b);
  const pc = Math.abs(p - c);
  if (pa <= pb && pa <= pc) return a;
  if (pb <= pc) return b;
  return c;
}

async function _decodePngRaw(buffer) {
  try {
    const view = new DataView(buffer);
    const u8 = new Uint8Array(buffer);
    if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;

    let offset = 8;
    let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0;
    const idatChunks = [];
    let totalIdatLen = 0;

    while (offset + 8 <= u8.length) {
      const chunkLen  = view.getUint32(offset);
      const chunkType = view.getUint32(offset + 4);
      if (chunkType === 0x49484452) {
        width = view.getUint32(offset + 8); height = view.getUint32(offset + 12);
        bitDepth = u8[offset + 16]; colorType = u8[offset + 17]; interlace = u8[offset + 20];
      } else if (chunkType === 0x49444154) {
        idatChunks.push(u8.subarray(offset + 8, offset + 8 + chunkLen));
        totalIdatLen += chunkLen;
      } else if (chunkType === 0x49454E44) { break; }
      offset += 12 + chunkLen;
    }

    if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6) || interlace !== 0) return null;
    if (totalIdatLen === 0 || width === 0 || height === 0) return null;

    const channels = colorType === 6 ? 4 : 3;
    const bpp = channels;
    const compressed = new Uint8Array(totalIdatLen);
    let pos = 0;
    for (const chunk of idatChunks) { compressed.set(chunk, pos); pos += chunk.length; }

    const ds = new DecompressionStream('deflate');
    const writer = ds.writable.getWriter();
    const reader = ds.readable.getReader();
    writer.write(compressed); writer.close();

    const decompChunks = [];
    let decompLen = 0;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      decompChunks.push(value); decompLen += value.length;
    }
    const rawData = new Uint8Array(decompLen);
    pos = 0;
    for (const chunk of decompChunks) { rawData.set(chunk, pos); pos += chunk.length; }

    const stride = 1 + width * channels;
    if (rawData.length < height * stride) return null;

    const pixels = new Uint8Array(width * height * 4);
    const prev = new Uint8Array(width * channels);
    const curr = new Uint8Array(width * channels);

    for (let y = 0; y < height; y++) {
      const scanOff = y * stride;
      const filterType = rawData[scanOff];
      for (let i = 0; i < width * channels; i++) curr[i] = rawData[scanOff + 1 + i];

      for (let i = 0; i < width * channels; i++) {
        const a = i >= bpp ? curr[i - bpp] : 0;
        const b = prev[i];
        const c = i >= bpp ? prev[i - bpp] : 0;
        switch (filterType) {
          case 0: break;
          case 1: curr[i] = (curr[i] + a) & 0xFF; break;
          case 2: curr[i] = (curr[i] + b) & 0xFF; break;
          case 3: curr[i] = (curr[i] + ((a + b) >>> 1)) & 0xFF; break;
          case 4: curr[i] = (curr[i] + _paethPredictor(a, b, c)) & 0xFF; break;
          default: return null;
        }
      }

      const outOff = y * width * 4;
      if (channels === 4) {
        for (let x = 0; x < width; x++) {
          const si = x * 4, di = outOff + x * 4;
          pixels[di] = curr[si]; pixels[di+1] = curr[si+1];
          pixels[di+2] = curr[si+2]; pixels[di+3] = curr[si+3];
        }
      } else {
        for (let x = 0; x < width; x++) {
          const si = x * 3, di = outOff + x * 4;
          pixels[di] = curr[si]; pixels[di+1] = curr[si+1];
          pixels[di+2] = curr[si+2]; pixels[di+3] = 255;
        }
      }
      prev.set(curr);
    }
    console.log(`[Mysterion] Raw PNG decoded: ${width}×${height}, colorType=${colorType}`);
    return { pixels, width, height };
  } catch (e) {
    console.warn('[Mysterion] Raw PNG decode failed:', e.message);
    return null;
  }
}

// ── LSB extraction ──────────────────────────────────────────
// Matches steg_lsb.dart exactly. Returns payload Uint8Array or null.
const _MYST = [0x4D, 0x59, 0x53, 0x54]; // "MYST"

function _bitsToBytes(bits, offset, end) {
  const n = Math.floor((end - offset) / 8);
  const out = new Uint8Array(n);
  for (let i = 0; i < n; i++) {
    const b = offset + i * 8;
    out[i] = (bits[b]   << 7) | (bits[b+1] << 6) | (bits[b+2] << 5) |
             (bits[b+3] << 4) | (bits[b+4] << 3) | (bits[b+5] << 2) |
             (bits[b+6] << 1) |  bits[b+7];
  }
  return out;
}

function _extractLsb(pixels, width, height) {
  const HEADER_BITS  = 64; // 8 bytes: 4 magic + 4 length
  const totalPixels  = width * height;
  const hdrPixels    = Math.ceil(HEADER_BITS / 3);
  if (totalPixels < hdrPixels) return null;

  // Read header bits
  const hBits = new Uint8Array(hdrPixels * 3);
  for (let p = 0; p < hdrPixels; p++) {
    const base = p * 4;
    hBits[p*3]   = pixels[base]   & 1; // R
    hBits[p*3+1] = pixels[base+1] & 1; // G
    hBits[p*3+2] = pixels[base+2] & 1; // B
  }
  const header = _bitsToBytes(hBits, 0, HEADER_BITS);

  // Check magic
  if (header[0] !== _MYST[0] || header[1] !== _MYST[1] ||
      header[2] !== _MYST[2] || header[3] !== _MYST[3]) return null;

  // Payload length (big-endian uint32)
  const payLen = ((header[4] << 24) >>> 0) | (header[5] << 16) |
                  (header[6] << 8) | header[7];
  if (payLen <= 0 || payLen > 1_000_000) return null;

  const totalBits       = HEADER_BITS + payLen * 8;
  const totalPxNeeded   = Math.ceil(totalBits / 3);
  if (totalPxNeeded > totalPixels) return null;

  // Read all bits
  const bits = new Uint8Array(totalPxNeeded * 3);
  for (let p = 0; p < totalPxNeeded; p++) {
    const base = p * 4;
    bits[p*3]   = pixels[base]   & 1;
    bits[p*3+1] = pixels[base+1] & 1;
    bits[p*3+2] = pixels[base+2] & 1;
  }
  return _bitsToBytes(bits, HEADER_BITS, totalBits);
}

// BLAKE3 hash — uses WASM module loaded from blake3.js
async function _sha256hex(buffer) {
  // blake3hex() is defined in blake3.js (loaded via importScripts)
  // Falls back to SHA-256 if BLAKE3 WASM not initialized
  if (typeof blake3hex === 'function') {
    try {
      await blake3Init();
      return blake3hex(buffer);
    } catch (e) {
      console.warn('[Mysterion] BLAKE3 hash failed, falling back to SHA-256:', e.message);
    }
  }
  // Fallback: SHA-256
  const hash = await crypto.subtle.digest('SHA-256', buffer);
  return Array.from(new Uint8Array(hash))
    .map(b => b.toString(16).padStart(2, '0')).join('');
}

// ── Response mapper ─────────────────────────────────────────
function mapResponse(data) {
  let result = 'unknown';
  const isVideo = (data.content_type || '').startsWith('video') ||
    (data.original_record?.content_type || '').startsWith('video');

  if (data.verified === true)                       result = 'verified';
  else if (data.verified === false && data.steg_id) result = 'altered';
  else                                              result = 'unknown';

  let method = null;
  if (data.layers?.length) {
    const authLayer = data.layers.find(l =>
      l.passed && ['DCT','LSB','ML-DSA-87','Keyframe DCT',
                   'Keyframe LSB','SEI NAL'].includes(l.layer)
    );
    if (authLayer) {
      const map = { DCT:'dct', LSB:'lsb', 'ML-DSA-87':'ml-dsa-87',
                    'SEI NAL':'sei-nal',
                    'Keyframe DCT':'keyframe-dct', 'Keyframe LSB':'keyframe-lsb' };
      method = map[authLayer.layer] || null;
    }
  }

  let detail = '';
  if (result === 'altered') {
    const sha = data.layers?.find(l => l.layer === 'Post-Sign BLAKE3' || l.layer === 'File Integrity' || l.layer === 'BLAKE3' || l.layer === 'SHA-256');
    detail = sha ? sha.detail : 'Content has been altered';
  } else if (result === 'verified') {
    detail = `Confidence: ${((data.overall_confidence || 0) * 100).toFixed(1)}%`;
  } else {
    detail = 'No Mysterion signature found';
  }

  return {
    result, detail, method,
    steg_id:             data.steg_id            || null,
    creator_id:          data.creator_id          || null,
    creator_name:        data.creator_name        || null,
    signed_at:           data.original_record?.signed_at || null,
    cert_valid:          data.cert_valid          || false,
    verification_method: data.verification_method || 'Mysterion 8-Layer Verification',
    layers:              data.layers              || [],
    phash:               data.phash               || '',
    overall_confidence:  data.overall_confidence  || 0,
    content_type:        isVideo ? 'video' : 'image',
    frame_mismatches:    data.frame_mismatches    || [],
    camera_id:           data.original_record?.camera_id || null,
  };
}

// ── Client-side image verification ──────────────────────────
// NO FILE UPLOADS. ALL PROCESSING OCCURS ON THE CLIENT DEVICE.

// The extension fetches the image, extracts steg_id via LSB
// locally, then sends ONLY {content_hash, steg_id} to the
// platform. No image bytes ever reach the server.

// ══════════════════════════════════════════════════════════════════════════════
//  Precomputed cosine tables for 8×8 DCT (v1.6.0)
//  Eliminates runtime Math.cos() calls from the inner loop of _dct8.
//  _COS8[k*8+n] = cos(π * k * (2n+1) / 16), k,n ∈ [0,8)
// ══════════════════════════════════════════════════════════════════════════════
const _COS8 = (() => {
  const N = 8, t = new Float64Array(N * N);
  for (let k = 0; k < N; k++)
    for (let n = 0; n < N; n++)
      t[k * N + n] = Math.cos(Math.PI * k * (2 * n + 1) / (2 * N));
  return t;
})();
const _SCALE8 = (() => {
  const s = new Float64Array(8);
  s[0] = Math.sqrt(1.0 / 8);
  const sk = Math.sqrt(2.0 / 8);
  for (let k = 1; k < 8; k++) s[k] = sk;
  return s;
})();

// ── 1D ortho-normalized DCT-II (N=8) — with precomputed cosine table ────────
function _dct8(x) {
  const N = 8, out = new Float64Array(N);
  for (let k = 0; k < N; k++) {
    let s = 0;
    const kOff = k * N;
    for (let n = 0; n < N; n++)
      s += x[n] * _COS8[kOff + n];
    out[k] = s * _SCALE8[k];
  }
  return out;
}

// ── 2D 8×8 ortho DCT ─────────────────────────────────────────────────────────
function _dct2d8(block) {
  // Row-wise DCT
  const tmp = Array.from({length:8}, (_,r) => _dct8(block[r]));
  // Column-wise DCT
  const out = Array.from({length:8}, () => new Float64Array(8));
  for (let c = 0; c < 8; c++) {
    const col = new Float64Array(8);
    for (let r = 0; r < 8; r++) col[r] = tmp[r][c];
    const dc = _dct8(col);
    for (let r = 0; r < 8; r++) out[r][c] = dc[r];
  }
  return out;
}


// ── Legacy DCT (steg_dct.py) extraction removed v5.5.81 — use WASM DCT-SS only ──

// ══════════════════════════════════════════════════════════════════════════════
//  Precomputed cosine table for 32×32 image pHash (v1.6.0)
//  Same approach as content.js — eliminates 32768 Math.cos() calls per hash.
//  _COS32[k*32+n] = cos(π * k * (2n+1) / 64), k,n ∈ [0,32)
// ══════════════════════════════════════════════════════════════════════════════
const _COS32 = (() => {
  const N = 32, t = new Float64Array(N * N);
  for (let k = 0; k < N; k++)
    for (let n = 0; n < N; n++)
      t[k * N + n] = Math.cos(Math.PI * (2 * n + 1) * k / (2 * N));
  return t;
})();

// ── Image pHash — matches imagehash.phash() on the platform exactly ────────
// 1. Grayscale + bilinear resize to 32×32 (matches PIL LANCZOS closely)
// 2. UNNORMALIZED 2D DCT (matches scipy.fftpack.dct type-II default)
// 3. Top-left 8×8 — all 64 values including DC
// 4. Median of all 64 values
// 5. Each bit = coefficient > median (including DC)
//
// v1.6.0: Uses precomputed _COS32 table instead of inline Math.cos().
function _computeImagePHash(pixels, width, height) {
  try {
    const N = 32;
    // Bilinear resize to 32×32 grayscale (closer to PIL LANCZOS than nearest)
    const gray = new Float64Array(N * N);
    for (let r = 0; r < N; r++) {
      for (let c = 0; c < N; c++) {
        const srcX = (c + 0.5) * width  / N - 0.5;
        const srcY = (r + 0.5) * height / N - 0.5;
        const x0 = Math.max(0, Math.floor(srcX));
        const y0 = Math.max(0, Math.floor(srcY));
        const x1 = Math.min(width  - 1, x0 + 1);
        const y1 = Math.min(height - 1, y0 + 1);
        const fx = srcX - x0, fy = srcY - y0;
        const i00 = (y0 * width + x0) * 4;
        const i10 = (y0 * width + x1) * 4;
        const i01 = (y1 * width + x0) * 4;
        const i11 = (y1 * width + x1) * 4;
        const g00 = 0.299*pixels[i00] + 0.587*pixels[i00+1] + 0.114*pixels[i00+2];
        const g10 = 0.299*pixels[i10] + 0.587*pixels[i10+1] + 0.114*pixels[i10+2];
        const g01 = 0.299*pixels[i01] + 0.587*pixels[i01+1] + 0.114*pixels[i01+2];
        const g11 = 0.299*pixels[i11] + 0.587*pixels[i11+1] + 0.114*pixels[i11+2];
        gray[r*N+c] = (1-fx)*(1-fy)*g00 + fx*(1-fy)*g10 +
                       (1-fx)*fy*g01     + fx*fy*g11;
      }
    }
    // UNNORMALIZED 2D DCT — scipy.fftpack.dct type-II:
    //   X[k] = 2 * sum_n x[n] * cos(pi*k*(2n+1)/(2N))
    // v1.6.0: Uses precomputed _COS32 table (same values, no Math.cos())
    // Row DCT
    const tmp = new Float64Array(N * N);
    for (let r = 0; r < N; r++) {
      const rOff = r * N;
      for (let k = 0; k < N; k++) {
        let s = 0;
        const kOff = k * N;
        for (let n = 0; n < N; n++)
          s += gray[rOff + n] * _COS32[kOff + n];
        tmp[rOff + k] = 2.0 * s;
      }
    }
    // Col DCT
    const dct2 = new Float64Array(N * N);
    for (let c = 0; c < N; c++) {
      for (let k = 0; k < N; k++) {
        let s = 0;
        const kOff = k * N;
        for (let n = 0; n < N; n++)
          s += tmp[n*N+c] * _COS32[kOff + n];
        dct2[k*N+c] = 2.0 * s;
      }
    }
    // Top-left 8×8 — all 64 coefficients INCLUDING DC at [0][0]
    const top = new Float64Array(64);
    for (let r = 0; r < 8; r++)
      for (let c = 0; c < 8; c++)
        top[r*8+c] = dct2[r*N+c];
    // Median of all 64 values (matches numpy.median on 8×8)
    const sorted = Float64Array.from(top).sort();
    const median = (sorted[31] + sorted[32]) / 2;
    // 64-bit hash: every bit = coefficient > median
    const hashBits = new Uint8Array(64);
    for (let i = 0; i < 64; i++)
      hashBits[i] = top[i] > median ? 1 : 0;
    // Pack all 64 bits → 16-char hex
    let hex = '';
    for (let i = 0; i < 64; i += 4) {
      let nibble = 0;
      for (let j = 0; j < 4; j++) nibble |= (hashBits[i + j] << (3 - j));
      hex += nibble.toString(16);
    }
    return hex;
  } catch (_) { return null; }
}

// ── Hamming distance between two hex hashes ──────────────────────────────────
// v5.5.23: Added to background.js for pHash comparison in verification flow.
function _hammingHex(a, b) {
  if (!a || !b || a.length !== b.length) return 64; // max distance
  let dist = 0;
  for (let i = 0; i < a.length; i++) {
    let xor = parseInt(a[i], 16) ^ parseInt(b[i], 16);
    while (xor) { dist += xor & 1; xor >>= 1; }
  }
  return dist;
}

// ══════════════════════════════════════════════════════════════════════════════
//  MP4 CONTAINER METADATA PARSER
//  Extracts MYSTERION_* keys from moov > udta > meta > keys + ilst atoms.
//  Used to read steg_id from signed videos served as static files.
//  NO video data is sent to the platform — only the extracted steg_id + hash.
// ══════════════════════════════════════════════════════════════════════════════

function _extractMP4Metadata(buffer) {
  const result = {};
  try {
    const view = new DataView(buffer);
    const len = buffer.byteLength;

    function u32(off) { return view.getUint32(off, false); }
    function str(off, n) {
      return new TextDecoder().decode(new Uint8Array(buffer, off, n));
    }

    // Find atom by type within [start, end)
    function findAtom(name, start, end) {
      let off = start;
      while (off + 8 <= end) {
        const size = u32(off);
        if (size < 8) break;
        const type = str(off + 4, 4);
        if (type === name) return { off, size };
        off += size;
      }
      return null;
    }

    // Navigate: moov > udta > meta > (keys + ilst)
    const moov = findAtom('moov', 0, len);
    if (!moov) return result;

    const udta = findAtom('udta', moov.off + 8, moov.off + moov.size);
    if (!udta) return result;

    const meta = findAtom('meta', udta.off + 8, udta.off + udta.size);
    if (!meta) return result;

    // meta atom has 4-byte version/flags after the 8-byte header
    const metaStart = meta.off + 12;
    const metaEnd = meta.off + meta.size;

    // Parse 'keys' atom — list of key names
    const keys = findAtom('keys', metaStart, metaEnd);
    if (!keys) return result;

    const keysCount = u32(keys.off + 12); // 8 header + 4 version/flags
    const keyNames = [];
    let kOff = keys.off + 16;
    for (let i = 0; i < keysCount && kOff + 8 <= keys.off + keys.size; i++) {
      const kSize = u32(kOff);
      if (kSize < 8) break;
      const kName = str(kOff + 8, kSize - 8);
      keyNames.push(kName);
      kOff += kSize;
    }

    // Parse 'ilst' atom — values indexed by 1-based key index
    const ilst = findAtom('ilst', metaStart, metaEnd);
    if (!ilst) return result;

    let iOff = ilst.off + 8;
    const ilstEnd = ilst.off + ilst.size;
    while (iOff + 8 <= ilstEnd) {
      const itemSize = u32(iOff);
      const keyIndex = u32(iOff + 4); // 1-based index into keys
      if (itemSize < 8) break;

      if (keyIndex >= 1 && keyIndex <= keyNames.length) {
        // Find 'data' sub-atom
        const dataAtom = findAtom('data', iOff + 8, iOff + itemSize);
        if (dataAtom) {
          // data atom: 8 header + 4 type + 4 locale = 16 bytes before value
          const valOff = dataAtom.off + 16;
          const valLen = dataAtom.size - 16;
          if (valLen > 0 && valOff + valLen <= len) {
            result[keyNames[keyIndex - 1]] = str(valOff, valLen);
          }
        }
      }
      iOff += itemSize;
    }
  } catch (e) {
    console.warn('[Mysterion] MP4 metadata parse error:', e.message);
  }
  return result;
}


// ── Audio LSB extraction from MP4 ───────────────────────────────
// Matches video_processor.dart embedAudioLSB() format exactly:
//   Header: "MYST" (4 bytes) + payload length (4 bytes BE)
//   Payload: UTF-8 string  (stegId|preSignHash|signature)
//   Encoding: 1 bit per S16LE audio sample (LSB of low byte)
//
// Uses WebCodecs AudioDecoder (available in Chrome MV3 service workers)
// to decode AAC/Opus to raw PCM, then reads LSB bits.

async function _extractAudioLSBFromMP4(buffer) {
  // Step 1: Find the audio track info from the MP4 container
  const audioInfo = _parseMP4AudioTrack(buffer);
  if (!audioInfo) {
    console.log('[Mysterion] No audio track found in MP4');
    return null;
  }

  // Step 2: Decode audio samples to PCM using WebCodecs
  if (typeof AudioDecoder === 'undefined') {
    console.log('[Mysterion] AudioDecoder not available in this environment');
    return null;
  }

  const pcmSamples = [];
  let decodeError = null;
  const MAX_SAMPLES = 50000;
  let sampleCount = 0;
  let resolveDecoded;
  const decodedPromise = new Promise(r => { resolveDecoded = r; });

  try {
    const decoder = new AudioDecoder({
      output(audioData) {
        try {
          if (sampleCount >= MAX_SAMPLES) {
            audioData.close();
            return;
          }
          const frames = audioData.numberOfFrames;
          const f32 = new Float32Array(frames);
          audioData.copyTo(f32, { planeIndex: 0, format: 'f32-planar' });
          audioData.close();

          for (let i = 0; i < f32.length && sampleCount < MAX_SAMPLES; i++) {
            const s16 = Math.max(-32768, Math.min(32767, Math.round(f32[i] * 32768)));
            pcmSamples.push(s16 & 1);
            sampleCount++;
          }
        } catch (e) {
          console.warn('[Mysterion] Audio decode output error:', e.message);
        }
      },
      error(e) {
        decodeError = e;
        resolveDecoded();
      },
    });

    const config = {
      codec: audioInfo.codec,
      sampleRate: audioInfo.sampleRate,
      numberOfChannels: audioInfo.channels,
    };
    if (audioInfo.description) config.description = audioInfo.description;
    decoder.configure(config);

    for (const chunk of audioInfo.chunks) {
      if (sampleCount >= MAX_SAMPLES) break;
      decoder.decode(new EncodedAudioChunk({
        type: chunk.isKey ? 'key' : 'delta',
        timestamp: chunk.timestamp,
        data: new Uint8Array(buffer, chunk.offset, chunk.size),
      }));
    }

    await decoder.flush();
    decoder.close();
    resolveDecoded();
    await decodedPromise;
  } catch (e) {
    console.warn('[Mysterion] AudioDecoder failed:', e.message);
    return null;
  }

  if (decodeError || pcmSamples.length < 64) {
    console.log('[Mysterion] Insufficient audio samples decoded:', pcmSamples.length);
    return null;
  }

  // Step 3: Read LSB bits to extract header and payload
  function readByte(startBit) {
    let val = 0;
    for (let bit = 7; bit >= 0; bit--) {
      const idx = startBit + (7 - bit);
      if (idx >= pcmSamples.length) return -1;
      val = (val << 1) | pcmSamples[idx];
    }
    return val;
  }

  const magic = [readByte(0), readByte(8), readByte(16), readByte(24)];
  if (magic[0] !== 0x4D || magic[1] !== 0x59 || magic[2] !== 0x53 || magic[3] !== 0x54) {
    console.log('[Mysterion] Audio LSB: no MYST magic header found');
    return null;
  }

  const payloadLen = (readByte(32) << 24) | (readByte(40) << 16) | (readByte(48) << 8) | readByte(56);
  if (payloadLen <= 0 || payloadLen > 5000) {
    console.log('[Mysterion] Audio LSB: invalid payload length:', payloadLen);
    return null;
  }

  const neededBits = (8 + payloadLen) * 8;
  if (pcmSamples.length < neededBits) {
    console.log('[Mysterion] Audio LSB: not enough samples:', pcmSamples.length, '<', neededBits);
    return null;
  }

  const payloadBytes = new Uint8Array(payloadLen);
  for (let i = 0; i < payloadLen; i++) {
    payloadBytes[i] = readByte(64 + i * 8);
  }

  const payload = new TextDecoder().decode(payloadBytes);
  console.log('[Mysterion] Audio LSB payload extracted:', payload.substring(0, 50) + '...');
  return payload;
}


// ── MP4 audio track parser ──────────────────────────────────────
function _parseMP4AudioTrack(buffer) {
  try {
    const view = new DataView(buffer);
    const len = buffer.byteLength;

    function u16(off) { return view.getUint16(off, false); }
    function u32(off) { return view.getUint32(off, false); }
    function str(off, n) {
      return String.fromCharCode(...new Uint8Array(buffer, off, n));
    }

    function findAtom(name, start, end) {
      let off = start;
      while (off + 8 <= end) {
        const size = u32(off);
        if (size < 8) break;
        if (str(off + 4, 4) === name) return { off, size };
        off += size;
      }
      return null;
    }

    function findAllAtoms(name, start, end) {
      const results = [];
      let off = start;
      while (off + 8 <= end) {
        const size = u32(off);
        if (size < 8) break;
        if (str(off + 4, 4) === name) results.push({ off, size });
        off += size;
      }
      return results;
    }

    const moov = findAtom('moov', 0, len);
    if (!moov) return null;

    // Find audio trak (handler_type === 'soun')
    const traks = findAllAtoms('trak', moov.off + 8, moov.off + moov.size);
    let audioTrak = null;
    for (const trak of traks) {
      const mdia = findAtom('mdia', trak.off + 8, trak.off + trak.size);
      if (!mdia) continue;
      const hdlr = findAtom('hdlr', mdia.off + 8, mdia.off + mdia.size);
      if (!hdlr) continue;
      const handlerType = str(hdlr.off + 20, 4);
      if (handlerType === 'soun') { audioTrak = trak; break; }
    }
    if (!audioTrak) return null;

    const mdia = findAtom('mdia', audioTrak.off + 8, audioTrak.off + audioTrak.size);
    if (!mdia) return null;

    const mdhd = findAtom('mdhd', mdia.off + 8, mdia.off + mdia.size);
    let timescale = 44100;
    if (mdhd) {
      const ver = view.getUint8(mdhd.off + 8);
      timescale = ver === 1 ? u32(mdhd.off + 28) : u32(mdhd.off + 20);
    }

    const minf = findAtom('minf', mdia.off + 8, mdia.off + mdia.size);
    if (!minf) return null;
    const stbl = findAtom('stbl', minf.off + 8, minf.off + minf.size);
    if (!stbl) return null;

    // Codec info from stsd
    const stsd = findAtom('stsd', stbl.off + 8, stbl.off + stbl.size);
    if (!stsd) return null;

    const entryOff = stsd.off + 16;
    const entrySize = u32(entryOff);
    const entryCodec = str(entryOff + 4, 4);

    let codec = 'mp4a.40.2';
    let sampleRate = timescale;
    let channels = 2;
    let description = null;

    if (entryCodec === 'mp4a') {
      channels = u16(entryOff + 24);
      sampleRate = u16(entryOff + 32);
      if (sampleRate === 0) sampleRate = timescale;

      const esds = findAtom('esds', entryOff + 36, entryOff + entrySize);
      if (esds) {
        const esdsData = new Uint8Array(buffer, esds.off + 12, esds.size - 12);
        const ascIdx = _findESDSTag(esdsData, 0x05);
        if (ascIdx >= 0) {
          const ascLen = esdsData[ascIdx + 1];
          description = new Uint8Array(buffer, esds.off + 12 + ascIdx + 2, ascLen);
          const objType = (description[0] >> 3) & 0x1F;
          codec = `mp4a.40.${objType}`;
        }
      }
    } else if (entryCodec === 'Opus') {
      codec = 'opus';
      const dOps = findAtom('dOps', entryOff + 36, entryOff + entrySize);
      if (dOps) description = new Uint8Array(buffer, dOps.off + 8, dOps.size - 8);
    } else {
      codec = entryCodec.toLowerCase();
    }

    // Sample table parsing
    const chunks = _parseMP4AudioSamples(buffer, stbl, timescale);
    if (!chunks || chunks.length === 0) return null;

    return { codec, sampleRate, channels, description, chunks };
  } catch (e) {
    console.warn('[Mysterion] MP4 audio track parse error:', e.message);
    return null;
  }
}

function _findESDSTag(data, tag) {
  for (let i = 0; i < data.length - 2; i++) {
    if (data[i] === tag) return i;
  }
  return -1;
}

function _parseMP4AudioSamples(buffer, stbl, timescale) {
  const view = new DataView(buffer);
  function u32(off) { return view.getUint32(off, false); }
  function str(off, n) {
    return String.fromCharCode(...new Uint8Array(buffer, off, n));
  }
  function findAtom(name, start, end) {
    let off = start;
    while (off + 8 <= end) {
      const size = u32(off);
      if (size < 8) break;
      if (str(off + 4, 4) === name) return { off, size };
      off += size;
    }
    return null;
  }

  const stblEnd = stbl.off + stbl.size;

  const stsz = findAtom('stsz', stbl.off + 8, stblEnd);
  if (!stsz) return null;
  const defaultSize = u32(stsz.off + 16);
  const sampleCount = u32(stsz.off + 20);
  const sampleSizes = [];
  for (let i = 0; i < sampleCount && i < 10000; i++) {
    sampleSizes.push(defaultSize > 0 ? defaultSize : u32(stsz.off + 24 + i * 4));
  }

  let chunkOffsets = [];
  const stco = findAtom('stco', stbl.off + 8, stblEnd);
  const co64 = findAtom('co64', stbl.off + 8, stblEnd);
  if (stco) {
    const count = u32(stco.off + 12);
    for (let i = 0; i < count; i++) chunkOffsets.push(u32(stco.off + 16 + i * 4));
  } else if (co64) {
    const count = u32(co64.off + 12);
    for (let i = 0; i < count; i++) {
      const hi = u32(co64.off + 16 + i * 8);
      const lo = u32(co64.off + 20 + i * 8);
      chunkOffsets.push(hi * 0x100000000 + lo);
    }
  } else return null;

  const stsc = findAtom('stsc', stbl.off + 8, stblEnd);
  if (!stsc) return null;
  const stscCount = u32(stsc.off + 12);
  const stscEntries = [];
  for (let i = 0; i < stscCount; i++) {
    stscEntries.push({
      firstChunk: u32(stsc.off + 16 + i * 12),
      samplesPerChunk: u32(stsc.off + 20 + i * 12),
      sdi: u32(stsc.off + 24 + i * 12),
    });
  }

  const results = [];
  let sampleIdx = 0;
  let timestamp = 0;
  for (let ci = 0; ci < chunkOffsets.length && sampleIdx < sampleSizes.length; ci++) {
    let spc = stscEntries[0]?.samplesPerChunk || 1;
    for (const entry of stscEntries) {
      if (ci + 1 >= entry.firstChunk) spc = entry.samplesPerChunk;
    }

    let chunkOff = chunkOffsets[ci];
    for (let s = 0; s < spc && sampleIdx < sampleSizes.length; s++) {
      const size = sampleSizes[sampleIdx];
      if (chunkOff + size <= buffer.byteLength) {
        results.push({
          offset: chunkOff,
          size: size,
          timestamp: Math.round(timestamp * 1000000 / timescale),
          isKey: sampleIdx === 0,
        });
      }
      chunkOff += size;
      sampleIdx++;
      timestamp += 1024;
      if (results.length > 200) return results;
    }
  }
  return results;
}



// ══════════════════════════════════════════════════════════════════════════════
//  SEI NAL H.264 BITSTREAM PARSER (v5.5.0)
//  Scans H.264 video track NAL units for SEI type-5 (user_data_unregistered)
//  containing the MYSTERION_SEI_UUID. Extracts steg_id|preSignHash|signature.
//  Signing embeds: 16-byte UUID header + UTF-8 payload in each SEI message.
// ══════════════════════════════════════════════════════════════════════════════

// Mysterion SEI UUID: 16 bytes matching video_processor.dart _seiUuid
const _SEI_UUID = new Uint8Array([
  0x4D, 0x59, 0x53, 0x54, 0x45, 0x52, 0x49, 0x4F,
  0x4E, 0x2D, 0x53, 0x45, 0x49, 0x2D, 0x49, 0x44,
]);

/**
 * Scan an MP4 buffer for H.264 SEI NAL units with Mysterion payload.
 * Returns the UTF-8 payload string (stegId|preSignHash|signature) or null.
 *
 * Strategy: Parse the video track's sample table to iterate NAL units
 * in each sample. SEI NAL type = 6, payload type 5 = user_data_unregistered.
 * Scans up to first 100 samples for speed.
 */
function _extractSEINALFromMP4(buffer) {
  try {
    const view = new DataView(buffer);
    const len = buffer.byteLength;
    const bytes = new Uint8Array(buffer);

    function u32(off) { return view.getUint32(off, false); }
    function str(off, n) {
      return String.fromCharCode(...new Uint8Array(buffer, off, Math.min(n, len - off)));
    }
    function findAtom(name, start, end) {
      let off = start;
      while (off + 8 <= end) {
        const size = u32(off);
        if (size < 8) break;
        if (str(off + 4, 4) === name) return { off, size };
        off += size;
      }
      return null;
    }
    function findAllAtoms(name, start, end) {
      const results = [];
      let off = start;
      while (off + 8 <= end) {
        const size = u32(off);
        if (size < 8) break;
        if (str(off + 4, 4) === name) results.push({ off, size });
        off += size;
      }
      return results;
    }

    // Find video track (handler_type === 'vide')
    const moov = findAtom('moov', 0, len);
    if (!moov) return null;
    const traks = findAllAtoms('trak', moov.off + 8, moov.off + moov.size);
    let videoTrak = null;
    for (const trak of traks) {
      const mdia = findAtom('mdia', trak.off + 8, trak.off + trak.size);
      if (!mdia) continue;
      const hdlr = findAtom('hdlr', mdia.off + 8, mdia.off + mdia.size);
      if (!hdlr) continue;
      if (str(hdlr.off + 20, 4) === 'vide') { videoTrak = trak; break; }
    }
    if (!videoTrak) return null;

    const mdia = findAtom('mdia', videoTrak.off + 8, videoTrak.off + videoTrak.size);
    if (!mdia) return null;
    const minf = findAtom('minf', mdia.off + 8, mdia.off + mdia.size);
    if (!minf) return null;
    const stbl = findAtom('stbl', minf.off + 8, minf.off + minf.size);
    if (!stbl) return null;
    const stblEnd = stbl.off + stbl.size;

    // Determine NAL length size from avcC
    let nalLenSize = 4;
    const stsd = findAtom('stsd', stbl.off + 8, stblEnd);
    if (stsd) {
      const entryOff = stsd.off + 16;
      const entryEnd = entryOff + u32(entryOff);
      const avcC = findAtom('avcC', entryOff + 86, entryEnd);
      if (avcC && avcC.size > 13) {
        nalLenSize = (view.getUint8(avcC.off + 12) & 0x03) + 1;
      }
    }

    // Parse sample table for sample offsets + sizes
    const stsz = findAtom('stsz', stbl.off + 8, stblEnd);
    if (!stsz) return null;
    const defaultSz = u32(stsz.off + 16);
    const sampleCount = u32(stsz.off + 20);
    const sizes = [];
    for (let i = 0; i < sampleCount && i < 500; i++) {
      sizes.push(defaultSz > 0 ? defaultSz : u32(stsz.off + 24 + i * 4));
    }

    let chunkOffsets = [];
    const stco = findAtom('stco', stbl.off + 8, stblEnd);
    const co64 = findAtom('co64', stbl.off + 8, stblEnd);
    if (stco) {
      const cnt = u32(stco.off + 12);
      for (let i = 0; i < cnt; i++) chunkOffsets.push(u32(stco.off + 16 + i * 4));
    } else if (co64) {
      const cnt = u32(co64.off + 12);
      for (let i = 0; i < cnt; i++) {
        chunkOffsets.push(u32(co64.off + 16 + i * 8) * 0x100000000 + u32(co64.off + 20 + i * 8));
      }
    } else return null;

    const stsc = findAtom('stsc', stbl.off + 8, stblEnd);
    if (!stsc) return null;
    const stscN = u32(stsc.off + 12);
    const stscE = [];
    for (let i = 0; i < stscN; i++) {
      stscE.push({ fc: u32(stsc.off + 16 + i * 12), spc: u32(stsc.off + 20 + i * 12) });
    }

    // Build sample offset list
    const sampleOffsets = [];
    let si = 0;
    for (let ci = 0; ci < chunkOffsets.length && si < sizes.length; ci++) {
      let spc = stscE[0]?.spc || 1;
      for (const e of stscE) if (ci + 1 >= e.fc) spc = e.spc;
      let co = chunkOffsets[ci];
      for (let s = 0; s < spc && si < sizes.length; s++) {
        sampleOffsets.push({ offset: co, size: sizes[si] });
        co += sizes[si];
        si++;
      }
    }

    // Scan first 100 samples for SEI NAL units
    const scanLimit = Math.min(sampleOffsets.length, 100);
    for (let i = 0; i < scanLimit; i++) {
      const { offset, size } = sampleOffsets[i];
      if (offset + size > len) continue;
      let pos = offset;
      const sampleEnd = offset + size;

      while (pos + nalLenSize < sampleEnd) {
        let nalLen = 0;
        for (let b = 0; b < nalLenSize; b++) {
          nalLen = (nalLen << 8) | bytes[pos + b];
        }
        pos += nalLenSize;
        if (nalLen <= 0 || pos + nalLen > sampleEnd) break;

        const nalType = bytes[pos] & 0x1F;
        if (nalType === 6) { // SEI
          let seiPos = pos + 1;
          const nalEnd = pos + nalLen;
          while (seiPos + 2 < nalEnd) {
            let payloadType = 0;
            while (seiPos < nalEnd && bytes[seiPos] === 0xFF) {
              payloadType += 255; seiPos++;
            }
            if (seiPos >= nalEnd) break;
            payloadType += bytes[seiPos++];

            let payloadSize = 0;
            while (seiPos < nalEnd && bytes[seiPos] === 0xFF) {
              payloadSize += 255; seiPos++;
            }
            if (seiPos >= nalEnd) break;
            payloadSize += bytes[seiPos++];

            if (payloadType === 5 && payloadSize >= 17 && seiPos + payloadSize <= nalEnd) {
              let uuidMatch = true;
              for (let u = 0; u < 16; u++) {
                if (bytes[seiPos + u] !== _SEI_UUID[u]) { uuidMatch = false; break; }
              }
              if (uuidMatch) {
                const payloadBytes = bytes.slice(seiPos + 16, seiPos + payloadSize);
                const payload = new TextDecoder().decode(payloadBytes);
                console.log('[Mysterion] SEI NAL payload extracted:', payload.substring(0, 60) + '...');
                return payload;
              }
            }
            seiPos += payloadSize;
          }
        }
        pos += nalLen;
      }
    }
    return null;
  } catch (e) {
    console.warn('[Mysterion] SEI NAL parse error:', e.message);
    return null;
  }
}


// ══════════════════════════════════════════════════════════════════════════════
//  KEYFRAME DCT EXTRACTION via WebCodecs VideoDecoder (v5.5.0)
//  Decodes H.264/H.265 keyframes to RGBA pixels using hardware-accelerated
//  VideoDecoder, then runs DCT steg extraction on each decoded frame.
//  Quick scan: first 5 keyframes. Full scan: all keyframes (user-prompted).
// ══════════════════════════════════════════════════════════════════════════════

/**
 * Parse the H.264 video track to find keyframe sample offsets + codec string.
 * Returns { codec, keyframes: [{offset, size, timestamp}], description, width, height } or null.
 */
function _parseMP4VideoKeyframes(buffer) {
  try {
    const view = new DataView(buffer);
    const len = buffer.byteLength;
    function u32(off) { return view.getUint32(off, false); }
    function u16(off) { return view.getUint16(off, false); }
    function str(off, n) {
      return String.fromCharCode(...new Uint8Array(buffer, off, Math.min(n, len - off)));
    }
    function findAtom(name, start, end) {
      let off = start;
      while (off + 8 <= end) {
        const size = u32(off);
        if (size < 8) break;
        if (str(off + 4, 4) === name) return { off, size };
        off += size;
      }
      return null;
    }
    function findAllAtoms(name, start, end) {
      const results = [];
      let off = start;
      while (off + 8 <= end) {
        const size = u32(off);
        if (size < 8) break;
        if (str(off + 4, 4) === name) results.push({ off, size });
        off += size;
      }
      return results;
    }

    const moov = findAtom('moov', 0, len);
    if (!moov) return null;
    const traks = findAllAtoms('trak', moov.off + 8, moov.off + moov.size);
    let videoTrak = null;
    for (const trak of traks) {
      const mdia = findAtom('mdia', trak.off + 8, trak.off + trak.size);
      if (!mdia) continue;
      const hdlr = findAtom('hdlr', mdia.off + 8, mdia.off + mdia.size);
      if (!hdlr) continue;
      if (str(hdlr.off + 20, 4) === 'vide') { videoTrak = trak; break; }
    }
    if (!videoTrak) return null;

    const mdia = findAtom('mdia', videoTrak.off + 8, videoTrak.off + videoTrak.size);
    if (!mdia) return null;

    const mdhd = findAtom('mdhd', mdia.off + 8, mdia.off + mdia.size);
    let timescale = 30000;
    if (mdhd) {
      const ver = view.getUint8(mdhd.off + 8);
      timescale = ver === 1 ? u32(mdhd.off + 28) : u32(mdhd.off + 20);
    }

    const minf = findAtom('minf', mdia.off + 8, mdia.off + mdia.size);
    if (!minf) return null;
    const stbl = findAtom('stbl', minf.off + 8, minf.off + minf.size);
    if (!stbl) return null;
    const stblEnd = stbl.off + stbl.size;

    // Codec string + description from stsd > avc1 > avcC
    const stsd = findAtom('stsd', stbl.off + 8, stblEnd);
    if (!stsd) return null;
    const entryOff = stsd.off + 16;
    const entrySize = u32(entryOff);
    const entryCodec = str(entryOff + 4, 4);
    let codec = 'avc1.640028';
    let description = null;
    const vidWidth = u16(entryOff + 32);
    const vidHeight = u16(entryOff + 34);

    if (entryCodec === 'avc1' || entryCodec === 'avc3') {
      const avcC = findAtom('avcC', entryOff + 86, entryOff + entrySize);
      if (avcC && avcC.size > 13) {
        const profile = view.getUint8(avcC.off + 9);
        const compat = view.getUint8(avcC.off + 10);
        const level = view.getUint8(avcC.off + 11);
        codec = `avc1.${profile.toString(16).padStart(2,'0')}${compat.toString(16).padStart(2,'0')}${level.toString(16).padStart(2,'0')}`;
        description = new Uint8Array(buffer, avcC.off + 8, avcC.size - 8);
      }
    } else if (entryCodec === 'hvc1' || entryCodec === 'hev1') {
      codec = 'hvc1.1.6.L120.B0';
      const hvcC = findAtom('hvcC', entryOff + 86, entryOff + entrySize);
      if (hvcC) description = new Uint8Array(buffer, hvcC.off + 8, hvcC.size - 8);
    }

    // Sample sizes
    const stsz = findAtom('stsz', stbl.off + 8, stblEnd);
    if (!stsz) return null;
    const defaultSz = u32(stsz.off + 16);
    const sampleCount = u32(stsz.off + 20);
    const sizes = [];
    for (let i = 0; i < sampleCount && i < 10000; i++) {
      sizes.push(defaultSz > 0 ? defaultSz : u32(stsz.off + 24 + i * 4));
    }

    // Chunk offsets
    let chunkOffs = [];
    const stco = findAtom('stco', stbl.off + 8, stblEnd);
    const co64 = findAtom('co64', stbl.off + 8, stblEnd);
    if (stco) {
      const cnt = u32(stco.off + 12);
      for (let i = 0; i < cnt; i++) chunkOffs.push(u32(stco.off + 16 + i * 4));
    } else if (co64) {
      const cnt = u32(co64.off + 12);
      for (let i = 0; i < cnt; i++) {
        chunkOffs.push(u32(co64.off + 16 + i * 8) * 0x100000000 + u32(co64.off + 20 + i * 8));
      }
    } else return null;

    const stsc = findAtom('stsc', stbl.off + 8, stblEnd);
    if (!stsc) return null;
    const stscN = u32(stsc.off + 12);
    const stscE = [];
    for (let i = 0; i < stscN; i++) {
      stscE.push({ fc: u32(stsc.off + 16 + i * 12), spc: u32(stsc.off + 20 + i * 12) });
    }

    // Build full sample offset list
    const sampleOffsets = [];
    let sIdx = 0;
    for (let ci = 0; ci < chunkOffs.length && sIdx < sizes.length; ci++) {
      let spc = stscE[0]?.spc || 1;
      for (const e of stscE) if (ci + 1 >= e.fc) spc = e.spc;
      let co = chunkOffs[ci];
      for (let s = 0; s < spc && sIdx < sizes.length; s++) {
        sampleOffsets.push({ offset: co, size: sizes[sIdx] });
        co += sizes[sIdx]; sIdx++;
      }
    }

    // Sync sample table (stss) — identifies keyframes (1-based indices)
    const keyframeIndices = new Set();
    const stss = findAtom('stss', stbl.off + 8, stblEnd);
    if (stss) {
      const cnt = u32(stss.off + 12);
      for (let i = 0; i < cnt; i++) {
        keyframeIndices.add(u32(stss.off + 16 + i * 4) - 1);
      }
    } else {
      for (let i = 0; i < sampleOffsets.length; i++) keyframeIndices.add(i);
    }

    // Decode timing (stts)
    const stts = findAtom('stts', stbl.off + 8, stblEnd);
    const durations = [];
    if (stts) {
      const cnt = u32(stts.off + 12);
      for (let i = 0; i < cnt; i++) {
        const sc = u32(stts.off + 16 + i * 8);
        const dur = u32(stts.off + 20 + i * 8);
        for (let j = 0; j < sc && durations.length < sampleCount; j++) durations.push(dur);
      }
    }

    const keyframes = [];
    let ts = 0;
    for (let i = 0; i < sampleOffsets.length; i++) {
      if (keyframeIndices.has(i)) {
        keyframes.push({
          offset: sampleOffsets[i].offset,
          size: sampleOffsets[i].size,
          timestamp: Math.round(ts * 1000000 / timescale),
        });
      }
      ts += (durations[i] || 1001);
    }

    return { codec, keyframes, description, width: vidWidth, height: vidHeight };
  } catch (e) {
    console.warn('[Mysterion] Video keyframe parse error:', e.message);
    return null;
  }
}


/**
 * Decode video keyframes to RGBA pixels and run DCT extraction on each.
 * @param {ArrayBuffer} buffer - The full MP4 file buffer
 * @param {number} maxKeyframes - Max keyframes to decode (5 for quick, Infinity for full)
 * @returns {Promise<{stegId: string, preSignHash: string}|null>}
 */
// ═══ OFFSCREEN DECODE BRIDGE (MV3: VideoDecoder unavailable in SW) ═══════════
// VideoDecoder/WebCodecs is not exposed to MV3 service workers. Frame-level
// decode (DCT keyframe scan, pHash) runs in an offscreen document instead.
// These helpers create the offscreen doc on demand and proxy decode requests.
let _offscreenCreating = null;
async function _ensureOffscreenDocument() {
  if (!chrome.offscreen) {
    console.warn('[Mysterion] chrome.offscreen API unavailable');
    return false;
  }
  try {
    const has = await chrome.offscreen.hasDocument?.();
    if (has) return true;
  } catch (_) {}
  if (_offscreenCreating) { await _offscreenCreating; return true; }
  try {
    _offscreenCreating = chrome.offscreen.createDocument({
      url: 'offscreen.html',
      reasons: ['BLOBS'],
      justification: 'Decode video keyframes (WebCodecs VideoDecoder) for Mysterion provenance verification.',
    });
    await _offscreenCreating;
    _offscreenCreating = null;
    return true;
  } catch (e) {
    _offscreenCreating = null;
    // "Only a single offscreen document may be created" => already exists, fine.
    if (String(e && e.message || e).includes('single offscreen')) return true;
    console.warn('[Mysterion] createOffscreen failed:', e && e.message);
    return false;
  }
}

async function _offscreenDecode(kind, payload) {
  const ok = await _ensureOffscreenDocument();
  if (!ok) return null;
  try {
    const resp = await chrome.runtime.sendMessage({ target: 'offscreen-decode', kind, ...payload });
    if (resp && resp.ok) return resp.result;
    if (resp && !resp.ok) console.warn('[Mysterion] offscreen', kind, 'error:', resp.error);
    return null;
  } catch (e) {
    console.warn('[Mysterion] offscreen sendMessage failed:', e && e.message);
    return null;
  }
}

async function _extractKeyframeDCT(buffer, maxKeyframes) {
  // MV3: proxy to offscreen document (VideoDecoder lives there).
  return _offscreenDecode('dct', {
    buffer,
    maxKeyframes: (maxKeyframes === Infinity ? null : maxKeyframes),
  });
}


// ══════════════════════════════════════════════════════════════════════════════
//  LAYER 6 — pHash keyframe fallback (HOTFIX 10, v5.5.93 DEBUG-9)
//  When Layers 0-5 fail to recover a steg_id (e.g. re-encoded / trimmed video),
//  decode the first few keyframes, compute pHash, and ask the server if the
//  visual fingerprint matches any signed record. If so, render
//  "Content Altered — Originally by <creator>" with attribution.
// ══════════════════════════════════════════════════════════════════════════════

/**
 * Decode up to `maxKeyframes` keyframes, pHash each, and query /verify/phash.
 * Returns the first positive match, or null.
 * @param {ArrayBuffer} buffer - The full MP4 file buffer
 * @param {string} videoId
 * @returns {Promise<{phash:string, match:object, querier_provenance:string}|null>}
 */
async function _layer6PhashLookup(buffer, videoId) {
  // MV3: proxy to offscreen document (VideoDecoder + MP4Box live there).
  return _offscreenDecode('phash', { buffer, videoId });
}


// ══════════════════════════════════════════════════════════════════════════════
//  BLAKE3 FAST-PATH — instant hash lookup (v5.5.0)
//  Calls /crypto/verify/hash to check if the exact file was previously signed.
//  Returns immediately if found, short-circuiting the full cascade.
// ══════════════════════════════════════════════════════════════════════════════

/**
 * BLAKE3 fast-path: check if this exact file hash is already signed.
 * @param {string} contentHash - BLAKE3 hex string
 * @returns {Promise<{found: boolean, steg_id?: string}|null>}
 */
async function _blake3FastPath(contentHash) {
  try {
    const response = await fetch(`${API_URL}/crypto/verify/hash`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content_hash: contentHash }),
    });
    if (!response.ok) return null;
    const data = await response.json();
    if (data.found) {
      console.log('[Mysterion] BLAKE3 fast-path: exact match found, steg_id=', data.steg_id);
    }
    return data;
  } catch (_) {
    return null;
  }
}


// ── Video verification — full 8-layer cascade (v5.5.93) ────────────────────
// Layer 0: BLAKE3 fast-path (instant DB lookup)
// Layer 1: Container metadata (MP4 atom parse)
// Layer 2: SEI NAL (H.264 bitstream scan)
// Layer 3: Audio LSB (AAC decode → PCM → LSB read)
// Layer 4: Keyframe DCT quick scan (first 5 keyframes)
// Layer 5: Keyframe DCT full scan (all keyframes)
// Layer 6: pHash frame comparison (API fallback)
// Layer 7: Unknown Origin
// NO video data is sent to the platform — only metadata + hashes.

// Max video file size for direct fetch (500 MB)
const _MAX_VIDEO_FETCH_BYTES = 500 * 1024 * 1024;

async function verifyVideoByFetch(videoUrl, videoId, tabId) {
  try {
    console.log('[Mysterion] Starting 8-layer video verification cascade:', videoUrl);

    // HEAD request to check size and content type
    let canFetch = false;
    try {
      const head = await fetch(videoUrl, { method: 'HEAD' });
      const ct = (head.headers.get('content-type') || '').toLowerCase();
      const cl = parseInt(head.headers.get('content-length') || '0', 10);
      if ((ct.includes('video') || ct.includes('mp4') || ct.includes('octet-stream') ||
           videoUrl.match(/\.(mp4|mov|m4v|webm)(\?|$)/i)) &&
          (cl === 0 || cl <= _MAX_VIDEO_FETCH_BYTES)) {
        canFetch = true;
      }
      if (cl > _MAX_VIDEO_FETCH_BYTES) {
        console.log('[Mysterion DEBUG-8] Video too large for direct fetch (500 MB cap):', cl, 'bytes');
      }
    } catch (_) {
      canFetch = true;
    }

    if (!canFetch) return false;

    // HOTFIX 10.2 (DEBUG-11): Firefox fetch+arrayBuffer on ~200 MB HTTP/2
    // responses intermittently throws "Content-Length exceeds body". Retry
    // with up to 2 attempts and fall back to a Range request for the first
    // 24 MB (enough for moov + leading keyframes for Layer 6 pHash).
    async function _fetchVideoBuffer() {
      let lastErr = null;
      for (let attempt = 1; attempt <= 2; attempt++) {
        try {
          const r = await fetch(videoUrl, { cache: 'no-store' });
          if (!r.ok) throw new Error(`HTTP ${r.status}`);
          return { buffer: await r.arrayBuffer(), partial: false };
        } catch (e) {
          lastErr = e;
          console.warn(`[Mysterion] Full fetch attempt ${attempt} failed:`, e.message);
        }
      }
      // Final fallback: first 24 MB via Range — sufficient for Layer 6 pHash.
      try {
        console.log('[Mysterion] Falling back to Range request (first 24 MB) for Layer 6');
        const RANGE_BYTES = 24 * 1024 * 1024 - 1;
        const r = await fetch(videoUrl, {
          cache: 'no-store',
          headers: { 'Range': `bytes=0-${RANGE_BYTES}` },
        });
        if (!r.ok && r.status !== 206) throw new Error(`HTTP ${r.status}`);
        return { buffer: await r.arrayBuffer(), partial: true };
      } catch (e) {
        console.warn('[Mysterion] Range fallback also failed:', e.message);
        throw lastErr || e;
      }
    }

    const { buffer, partial: isPartialFetch } = await _fetchVideoBuffer();
    if (isPartialFetch) {
      console.log('[Mysterion] Using partial video buffer (Range 0-24MB); BLAKE3/full-file layers will be skipped, Layer 6 pHash active');
    }

    // Compute BLAKE3 of the video file (needed by most layers)
    const contentHash = await _sha256hex(buffer);

    // ── Layer 0: BLAKE3 fast-path ──────────────────────────────────────────
    console.log('[Mysterion] Layer 0: BLAKE3 fast-path check...');
    const fastPath = await _blake3FastPath(contentHash);
    if (fastPath && fastPath.found && fastPath.steg_id) {
      console.log('[Mysterion] Layer 0 HIT: exact file match via BLAKE3');
      // v5.5.93-6: /verify/batch — same endpoint the Flutter app uses. Legacy
      // /verify shim strips v5.5.90 evidence tuple fields and returns a
      // response without verdict_terminal / evidence_tuple.
      const response = await fetch(`${API_URL}/crypto/verify/batch`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify([{
          content_hash: contentHash,
          steg_id:      fastPath.steg_id,
          filename:     videoUrl.split('/').pop().split('?')[0],
        }]),
      });
      if (await _checkMaintenance(response, videoUrl, tabId, videoId)) return true;
      if (response.ok) {
        const batch = await response.json();
        const raw   = (batch && batch.results && batch.results[0]) || {};
        const data  = mapResponse(raw);
        _sendVideoResult(data, videoUrl, videoId, tabId, contentHash, 'BLAKE3 Fast-Path');
        return true;
      }
    }

    let stegId = null;
    let preSignHash = null;
    let verifyMethod = null;

    // ── Layer 1: Container metadata ─────────────────────────────────────────
    console.log('[Mysterion] Layer 1: Container metadata extraction...');
    const meta = _extractMP4Metadata(buffer);
    stegId = meta['MYSTERION_STEG_ID'] || null;
    preSignHash = meta['MYSTERION_PRE_HASH'] || null;
    if (stegId && (stegId = _normalizeStegId(stegId))) {
      verifyMethod = 'Container Metadata';
      console.log('[Mysterion] Layer 1 HIT:', stegId);
    }

    // ── Layer 2: SEI NAL extraction ─────────────────────────────────────────
    if (!_normalizeStegId(stegId)) {
      console.log('[Mysterion] Layer 2: SEI NAL H.264 bitstream scan...');
      try {
        const seiPayload = _extractSEINALFromMP4(buffer);
        if (seiPayload) {
          const parts = seiPayload.split('|');
          const _sid = _normalizeStegId(parts[0]);
          if (_sid) {
            stegId = _sid;
            if (parts[1] && _HEX64_RE.test(parts[1])) preSignHash = parts[1];
            verifyMethod = 'SEI NAL Bitstream';
            console.log('[Mysterion] Layer 2 HIT:', stegId);
          }
        }
      } catch (e) {
        console.warn('[Mysterion] Layer 2 error:', e.message);
      }
    }

    // ── Layer 3: Audio LSB extraction ───────────────────────────────────────
    if (!_normalizeStegId(stegId)) {
      console.log('[Mysterion] Layer 3: Audio LSB extraction...');
      try {
        const audioPayload = await _extractAudioLSBFromMP4(buffer);
        if (audioPayload) {
          const parts = audioPayload.split('|');
          const _sid = _normalizeStegId(parts[0]);
          if (_sid) {
            stegId = _sid;
            if (parts[1] && _HEX64_RE.test(parts[1])) preSignHash = parts[1];
            verifyMethod = 'Audio LSB Steganography';
            console.log('[Mysterion] Layer 3 HIT:', stegId);
          }
        }
      } catch (e) {
        console.warn('[Mysterion] Layer 3 error:', e.message);
      }
    }

    // ── Layer 4: Keyframe DCT quick scan (first 5 keyframes) ────────────────
    if (!_normalizeStegId(stegId)) {
      console.log('[Mysterion] Layer 4: Keyframe DCT quick scan (5 keyframes)...');
      try {
        const kfResult = await _extractKeyframeDCT(buffer, 5);
        if (kfResult) {
          stegId = kfResult.stegId;
          preSignHash = kfResult.preSignHash;
          verifyMethod = 'Keyframe DCT (Quick Scan)';
          console.log('[Mysterion] Layer 4 HIT:', stegId);
        }
      } catch (e) {
        console.warn('[Mysterion] Layer 4 error:', e.message);
      }
    }

    // ── Layer 5: Keyframe DCT full scan (all keyframes) ─────────────────────
    if (!_normalizeStegId(stegId)) {
      console.log('[Mysterion] Layer 5: Keyframe DCT full scan (all keyframes)...');
      try {
        const kfResultFull = await _extractKeyframeDCT(buffer, Infinity);
        if (kfResultFull) {
          stegId = kfResultFull.stegId;
          preSignHash = kfResultFull.preSignHash;
          verifyMethod = 'Keyframe DCT (Full Scan)';
          console.log('[Mysterion] Layer 5 HIT:', stegId);
        }
      } catch (e) {
        console.warn('[Mysterion] Layer 5 error:', e.message);
      }
    }

    // ── No steg_id from any layer — fall back to pHash (Layer 6) ────────────
    // HOTFIX 10 (v5.5.93 DEBUG-9): actually invoke Layer 6 pHash lookup here.
    // On match, render "Content Altered — Originally by <creator>" with
    // attribution directly (bypass mapResponse; the verdict table can't help us
    // without a steg_id). On miss, return false so outer flow renders Unknown.
    stegId = _normalizeStegId(stegId);
    if (!stegId) {
      console.log('[Mysterion] All extraction layers exhausted — falling back to pHash (Layer 6)');
      const phashResult = await _layer6PhashLookup(buffer, videoId);
      if (phashResult && phashResult.match) {
        const m   = phashResult.match;
        const qpv = phashResult.querier_provenance;
        // Per user directive: known-origin altered derivatives must render as
        // "altered" with attribution, not "probable_match". Both
        // `unsigned_derivative` and `later_signing` indicate the content is NOT
        // the canonical original — the signing exists but the bytes differ.
        const isAltered = (qpv === 'unsigned_derivative' || qpv === 'later_signing');
        const result    = isAltered ? 'altered' : (qpv === 'original' ? 'verified' : 'unknown');
        const detail    = isAltered
          ? `Altered — Originally by ${m.creator_name || m.creator_id || 'unknown creator'}`
          : (result === 'verified'
              ? `Verified original by ${m.creator_name || m.creator_id || 'unknown creator'}`
              : 'No Mysterion signature found');
        chrome.tabs.sendMessage(tabId, {
          type:               'VERIFY_RESULT',
          imageUrl:           videoUrl,
          result,
          detail,
          stegId:             m.steg_id,
          creatorName:        m.creator_name || m.creator_id || null,
          orgName:            null,
          signedAt:           m.signed_at || null,
          signatureValid:     result === 'verified',
          certValid:          true,
          verificationMethod: 'pHash Visual Fingerprint',
          layers: [
            { layer: 'Keyframe pHash', passed: true,
              detail: `Visual fingerprint ${phashResult.phash} matched signed record`,
              confidence: m.confidence || 0.9 },
          ],
          phash:              phashResult.phash,
          contentType:        'video',
          frameMismatches:    isAltered ? [{ phash: phashResult.phash, hamming: m.hamming_distance || 0 }] : [],
          cameraId:           null,
        });
        chrome.tabs.sendMessage(tabId, {
          type: 'VERIFY_PHASH_RESULT',
          videoId,
          matched: true,
          bestMatch: {
            steg_id:          m.steg_id,
            creator_name:     m.creator_name,
            signed_at:        m.signed_at,
            confidence:       m.confidence || 0.9,
            hamming_distance: m.hamming_distance || 0,
          },
          result,
          stegId:    m.steg_id,
        });
        return true;
      }
      console.log('[Mysterion] Layer 6 miss — Unknown Origin');
      return false;
    }

    // ── Verify with API using extracted steg_id ─────────────────────────────
    // v5.5.93-6: /verify/batch (same as Flutter app). See _verifyWithPlatform.
    const response = await fetch(`${API_URL}/crypto/verify/batch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify([{
        content_hash:      contentHash,
        steg_id:           stegId,
        filename:          videoUrl.split('/').pop().split('?')[0],
        blake3_pre_client: preSignHash,
      }]),
    });
    if (await _checkMaintenance(response, videoUrl, tabId, videoId)) return true;

    if (!response.ok) {
      const text = await response.text().catch(() => `HTTP ${response.status}`);
      throw new Error(`API ${response.status}: ${text.slice(0, 120)}`);
    }

    const batch = await response.json();
    const raw   = (batch && batch.results && batch.results[0]) || {};
    const data  = mapResponse(raw);
    _sendVideoResult(data, videoUrl, videoId, tabId, contentHash, verifyMethod);
    return true;

  } catch (err) {
    console.warn('[Mysterion] Video fetch verification failed:', err.message);
    return false;
  }
}


/** Helper: send video verification result to content script */
function _sendVideoResult(data, videoUrl, videoId, tabId, contentHash, verifyMethod) {
  const resultMsg = {
    type:               'VERIFY_RESULT',
    imageUrl:           videoUrl,
    result:             data.result,
    detail:             data.detail,
    stegId:             data.steg_id,
    creatorName:        data.creator_name || data.creator_id,
    orgName:            null,
    signedAt:           data.signed_at,
    signatureValid:     data.result === 'verified',
    certValid:          data.cert_valid,
    verificationMethod: verifyMethod || data.verification_method,
    layers:             data.layers,
    phash:              data.phash,
    contentType:        'video',
    frameMismatches:    data.frame_mismatches,
    cameraId:           data.camera_id,
  };
  chrome.tabs.sendMessage(tabId, resultMsg);
  // No alteration report here — video alteration is detected by
  // content.js per-frame pHash comparison (steg_id + pHash mismatch).

  chrome.tabs.sendMessage(tabId, {
    type: 'VERIFY_PHASH_RESULT',
    videoId,
    matched: data.result === 'verified' || data.result === 'altered',
    bestMatch: {
      steg_id:      data.steg_id,
      creator_name: data.creator_name,
      signed_at:    data.signed_at,
      confidence:   data.overall_confidence,
      hamming_distance: 0,
    },
    result:    data.result,
    stegId:    data.steg_id,
  });
}


async function verifyMedia(mediaUrl, tabId) {
  try {
    // NO FILE UPLOADS. ALL PROCESSING OCCURS ON THE CLIENT DEVICE.
    
    console.log('[Mysterion DBG] verifyMedia called with mediaUrl:', mediaUrl);
    const imgRes = await fetch(mediaUrl);
    console.log('[Mysterion DBG] fetch response URL:', imgRes.url, 'status:', imgRes.status, 'redirected:', imgRes.redirected);
    const buffer = await imgRes.arrayBuffer();
    console.log('[Mysterion DBG] buffer size:', buffer.byteLength, 'first 16 bytes:', Array.from(new Uint8Array(buffer, 0, 16)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
    const contentHash = await _sha256hex(buffer);
    console.log('[Mysterion DBG] contentHash:', contentHash);

    // ── Layer 0: BLAKE3 fast-path (v5.5.2) ──────────────────────────────────
    // If the exact file hash is already in the DB, short-circuit the cascade.
    // BLAKE3 hit = exact bytes match = verified, no pHash needed.
    const fastPath = await _blake3FastPath(contentHash);
    if (fastPath && fastPath.found && fastPath.steg_id) {
      console.log('[Mysterion] Image BLAKE3 fast-path HIT:', fastPath.steg_id);
      // v5.5.93-DEBUG-7: BLAKE3 exact-byte match IS verification — the stored
      // record exists, was signed with ML-DSA-87 + TSA, and the suspect file
      // is byte-identical to the cert-bound post_sign_hash. Calling /verify/batch
      // without pHash/LSB fields causes the verdict table to collapse to UNKNOWN
      // (Row 1/2 require P=PASS), which mapResponse then misinterprets as altered.
      // The right call here is to render verified directly from the fast-path
      // response, which already carries creator_id + signed_at.
      console.log('[Mysterion DBG] fast-path rendering verified directly, no batch call');
      chrome.tabs.sendMessage(tabId, {
        type:               'VERIFY_RESULT',
        imageUrl:           mediaUrl,
        result:             'verified',
        detail:             'Bytes bit-perfect · origin + identity confirmed',
        stegId:             fastPath.steg_id,
        creatorName:        fastPath.creator_name || fastPath.creator_id || null,
        orgName:            null,
        signedAt:           fastPath.signed_at || null,
        signatureValid:     true,
        certValid:          true,
        verificationMethod: 'BLAKE3 Fast-Path',
        layers: [
          { layer: 'Post-Sign BLAKE3', passed: true,
            detail: 'Post-sign BLAKE3 matches cert-bound hash', confidence: 1.0 },
        ],
        phash:              null,
        contentType:        fastPath.content_type || 'image',
        frameMismatches:    [],
        cameraId:           null,
      });
      return; // BLAKE3 exact match — verified, done
    }


    // ── Raw PNG decode for LSB (v5.5.55) ──────────────────────────────────
    // Browser canvas corrupts LSBs via color management. Decode PNG raw bytes
    // directly to preserve exact pixel values for steganographic extraction.
    // Canvas is still used for pHash (doesn't need LSB precision) and non-PNG.
    let rawPng = await _decodePngRaw(buffer);

    // Canvas decode (used for pHash, DCT, and as LSB fallback for non-PNG)
    const bitmap = await createImageBitmap(new Blob([buffer]), { colorSpaceConversion: 'none' });
    const { width, height } = bitmap;
    const canvas = new OffscreenCanvas(width, height);
    const ctx = canvas.getContext('2d');
    ctx.drawImage(bitmap, 0, 0);
    bitmap.close();
    const pixels = ctx.getImageData(0, 0, width, height).data;

    // Use raw PNG pixels for LSB if available, otherwise canvas pixels
    const lsbPixels = rawPng ? rawPng.pixels : pixels;
    const lsbWidth  = rawPng ? rawPng.width  : width;
    const lsbHeight = rawPng ? rawPng.height : height;

    let preSignHash = null;
    let extractedCreatorId = null;    // v5.5.93-3: C column of the Verdict Table Pathways
    // ── Layer 1: LSB extraction (WASM first, JS fallback) ────────────────
    let stegId = null, stegSource = null;
    let lsbPayload = null;
    if (_wasmEngine) {
      lsbPayload = _wasmEngine.extractLSB(lsbPixels, lsbWidth, lsbHeight);
    }
    if (!lsbPayload) {
      lsbPayload = _extractLsb(lsbPixels, lsbWidth, lsbHeight);
    }
    if (lsbPayload) {
      const parts = new TextDecoder().decode(lsbPayload).split('|');
      const _sid = _normalizeStegId(parts[0]);
      if (_sid) {
        stegId = _sid;
        stegSource = 'lsb';
        if (parts[1] && _UUID_RE.test(parts[1])) extractedCreatorId = parts[1];
        if (parts[2] && _HEX64_RE.test(parts[2])) preSignHash = parts[2];
        console.log('[Mysterion] LSB extracted via', rawPng ? 'raw PNG decoder' : 'canvas fallback');
      }
    }
    rawPng = null; // free memory

    // ── Layer 3: pHash visual fingerprint (WASM first, JS fallback) ──────
    // Always compute pHash — needed for alteration detection.
    // Uses canvas pixels (color management doesn't affect pHash).
    let imgPhash = null;
    if (_wasmEngine) {
      imgPhash = _wasmEngine.computeImagePHash(pixels, width, height);
    }
    if (!imgPhash) {
      imgPhash = _computeImagePHash(pixels, width, height);
    }

    // ── Helpers (v5.5.25) ────────────────────────────────────────────────
    function _phashDist(a, b) {
      if (!a || !b) return -1;
      return _wasmEngine ? _wasmEngine.hammingDistance(a, b) : _hammingHex(a, b);
    }

    async function _fireAlert(alertStegId) {
      let reporterFP = 'anonymous';
      try {
        const raw = [navigator.userAgent || 'sw', 'offscreen',
          Intl.DateTimeFormat().resolvedOptions().timeZone].join('|');
        const fpData = new TextEncoder().encode(raw);
        const fpHash = await crypto.subtle.digest('SHA-256', fpData);
        reporterFP = Array.from(new Uint8Array(fpHash)).map(b => b.toString(16).padStart(2, '0')).join('');
      } catch (_) {}
      // v5.5.108: image flow — always report media_type='image' so the
      // creator app can render an Image badge and route Investigate to
      // the Image Analysis panel (not the video-only Forensic Analysis).
      fetch(`${API_URL}/alerts/alteration-report`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          steg_id: alertStegId,
          source_url: mediaUrl,
          client_rolling_hash: contentHash,
          reporter_fingerprint: reporterFP,
          media_type: 'image',
        }),
      }).then(() => {
        console.log('[Mysterion] Alteration reported for:', alertStegId, '(image)');
      }).catch(() => {});
    }

    function _sendImgResult(opts) {
      chrome.tabs.sendMessage(tabId, {
        type:               'VERIFY_RESULT',
        imageUrl:           mediaUrl,
        result:             opts.result,
        detail:             opts.detail || '',
        stegId:             opts.stegId || null,
        creatorName:        opts.creatorName || null,
        orgName:            null,
        signedAt:           opts.signedAt || null,
        signatureValid:     opts.signatureValid || false,
        certValid:          opts.certValid || false,
        verificationMethod: opts.verificationMethod || null,
        layers:             opts.layers || [],
        phash:              opts.phash || null,
        contentType:        opts.contentType || 'image',
        frameMismatches:    opts.frameMismatches || [],
        cameraId:           opts.cameraId || null,
      });
    }

    function _baseFields(data, source) {
      return {
        stegId:             data.steg_id,
        creatorName:        data.creator_name || data.creator_id,
        signedAt:           data.signed_at,
        certValid:          data.cert_valid,
        verificationMethod: source === 'lsb' ? 'LSB Steganography' : source === 'dct' ? 'DCT Steganography' : null,
        layers:             data.layers,
        phash:              data.phash,
        contentType:        data.content_type,
        frameMismatches:    data.frame_mismatches,
        cameraId:           data.camera_id,
      };
    }

    async function _verifyWithPlatform(sid, psh, lsbOk, dctOk, extractedCid) {
      // v5.5.93-6: route through /crypto/verify/batch — the same endpoint the
      // Flutter app uses. The legacy /crypto/verify shim strips the full
      // Phase-1 evidence tuple (lsb_extracted / extracted_creator_id /
      // blake3_pre_client) before constructing BatchVerifyItem and returns a
      // VerifyResponse schema without verdict_terminal / evidence_tuple, so
      // the extension's Verdict Table Pathways consumer could not fire.
      // /verify/batch accepts the full tuple and returns BatchVerifyItemResult
      // with verdict_terminal + verdict_label + verdict_detail + evidence_tuple
      // populated. Wire contract (routers/verify.py:2325):
      //   request : bare JSON array of BatchVerifyItem (max 100)
      //   response: { results: [BatchVerifyItemResult, ...] }
      //   BatchVerifyItem columns:
      //     content_hash         → B_post
      //     blake3_pre_client    → B_pre
      //     lsb_extracted /
      //       dct_extracted      → S  (S_lsb ∨ S_dct)
      //     extracted_creator_id → C
      //     phash                → P  (server computes Q-adaptive verdict)
      const item = {
        content_hash:         contentHash,
        steg_id:              sid,
        phash:                imgPhash,
        jpeg_quality:         (() => { try { return _estimateJpegQuality(new Uint8Array(buffer)); } catch (_) { return null; } })(),  // patch27_jpeg_quality
        filename:             mediaUrl.split('/').pop(),
        lsb_extracted:        lsbOk,
        dct_extracted:        dctOk,
        extracted_creator_id: extractedCid,
        blake3_pre_client:    psh,
      };
      const response = await fetch(`${API_URL}/crypto/verify/batch`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify([item]),
      });
      if (await _checkMaintenance(response, mediaUrl, tabId)) return null;
      if (!response.ok) {
        const text = await response.text().catch(() => `HTTP ${response.status}`);
        throw new Error(`API ${response.status}: ${text.slice(0, 120)}`);
      }
      const batch = await response.json();
      const raw   = (batch && batch.results && batch.results[0]) || {};
      console.log('[Mysterion] /crypto/verify/batch request →', JSON.stringify([item]));
      console.log('[Mysterion] /crypto/verify/batch status  →', response.status, response.statusText);
      console.log('[Mysterion] /crypto/verify/batch result  →', JSON.stringify(raw));
      const data = mapResponse(raw);
      return { raw, data };
    }

    // ══════════════════════════════════════════════════════════════════════
    //  DECISION LOGIC (v5.5.93-3 — Verdict Table Pathways)
    //
    //  The backend evaluates the 9-terminal Verdict Table Pathways
    //  (services/verdict_table.py) and returns verdict_terminal / label /
    //  detail in the /crypto/verify response. The extension trusts the
    //  server's verdict and only maps it to a badge + fires alert reports
    //  on the alert-class terminals. No client-side decision logic.
    //
    //  BLAKE3 fast-path (/crypto/verify/hash) already handled above: an
    //  exact byte-match is implicitly Terminal.VERIFIED.
    // ══════════════════════════════════════════════════════════════════════

    // Verdict Table Pathways → badge config in content.js BADGE_CONFIGS.
    const VERDICT_PATHWAY_TO_BADGE = {
      verified:                       'verified',
      verified_no_steg:               'verified',
      verified_transformed:           'altered',       // bytes changed, content intact
      verified_altered:               'altered',       // signed but tampered → alert
      verified_origin_altered:        'altered',       // origin+identity confirmed · content heavily altered (pHash fail)
      alert_creator_mismatch:         'alert',
      alert_altered_wrong_creator:    'alert',
      alert_unverified_match:         'unverified_match',  // patch21: own badge, NOT the forged 'alert'
      unknown:                        'unknown',
      fail_trust_anchor_broken:       'alert_critical',
    };
    // Terminals that must fire an alteration report to the backend.
    const ALERT_PATHWAYS = new Set([
      'verified_altered',
      'alert_creator_mismatch',
      'alert_altered_wrong_creator',
      'alert_unverified_match',
      'fail_trust_anchor_broken',
    ]);

    // If LSB found no steg_id, try DCT extraction (WASM). A DCT steg_id is
    // still valuable evidence (S=true) even without pre-sign-hash support.
    let dctStegId = null, dctPreSignHash = null, dctCreatorId = null;
    let dctOk = false;
    if (!stegId) {
      let dctPayload = null;
      if (_wasmEngine) {
        dctPayload = _wasmEngine.extractDCT(pixels, width, height);
      }
      // Legacy JS DCT fallback removed v5.5.81 — WASM DCT-SS only.
      if (dctPayload) {
        const parts = new TextDecoder().decode(dctPayload).split('|');
        const _sid = _normalizeStegId(parts[0]);
        if (_sid) {
          dctStegId = _sid;
          dctOk = true;
          if (parts[1] && _UUID_RE.test(parts[1])) dctCreatorId = parts[1];
          if (parts[2] && _HEX64_RE.test(parts[2])) dctPreSignHash = parts[2];
          console.log('[Mysterion] DCT steg_id extracted via WASM');
        }
      }
    }

    // patch28_always_dct: keyed DCT recovery runs whenever we don't yet have a
    // DCT confirmation — INCLUDING when LSB already self-ID'd. This
    // reports dct_extracted truthfully (the DCT-SS watermark is often
    // present alongside the plaintext LSB), so the plugin sends the same
    // complete evidence as the app and the server computes an identical
    // verdict + confidence. Previously gated on `!stegId` too, which made
    // LSB-fast-path images (e.g. pixel-altered PNGs) under-report DCT and
    // score lower than the app for the same file.
    if (!dctStegId) {
      const _rec = await _keyedRecoverImage(pixels, width, height, imgPhash, contentHash, mediaUrl);
      if (_rec && _rec.stegId) {
        dctStegId    = _rec.stegId;
        dctCreatorId = _rec.creatorId || dctCreatorId;
        dctOk        = true;
      }
    }

    const finalStegId     = stegId || dctStegId;
    const finalPreHash    = preSignHash || dctPreSignHash;
    const finalCreatorId  = extractedCreatorId || dctCreatorId;
    const lsbOk           = stegSource === 'lsb';

    if (finalStegId) {
      // ── Path 1: steg_id recovered → let the backend run the Pathways ──
      const result = await _verifyWithPlatform(
        finalStegId, finalPreHash, lsbOk, dctOk, finalCreatorId
      );
      if (!result) return; // maintenance mode
      const { raw, data } = result;
      const base = _baseFields(data, lsbOk ? 'lsb' : 'dct');

      const term  = raw.verdict_terminal || (raw.verified ? 'verified' : 'unknown');
      const badge = VERDICT_PATHWAY_TO_BADGE[term] || 'unknown';
      console.log(
        `[Mysterion] Verdict Table Pathways → ${term} (badge=${badge})`,
        raw.evidence_tuple || ''
      );

      _sendImgResult({
        ...base,
        result:         badge,
        detail:         raw.verdict_detail || raw.verdict_label || base.detail || '',
        signatureValid: term.startsWith('verified'),
      });

      if (ALERT_PATHWAYS.has(term)) {
        await _fireAlert(raw.steg_id || finalStegId);
      }
    } else if (imgPhash) {
      // ── Path 2: no steg_id → pHash-only visual lookup ──
      // Server runs the Pathways with S=false; terminals that apply are
      // ALERT_UNVERIFIED_MATCH (on a pHash DB hit) or UNKNOWN (no hit).
      const response = await fetch(`${API_URL}/crypto/verify/phash`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          phash:             imgPhash,
          content_type:      'image',
          hamming_threshold: 10 /* patch20: match server default (was 3, too tight for transformed images) */,
          filename_hint:     mediaUrl,
          content_hash:      contentHash,
        }),
      });
      if (await _checkMaintenance(response, mediaUrl, tabId)) return;
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const pData   = await response.json();
      const matched = pData.matched === true && pData.matches?.length > 0;
      const best    = matched ? (pData.canonical_original || pData.matches[0]) : null;

      // pHash-only hit with no steg_id maps to alert_unverified_match (row 7
      // of the Verdict Table Pathways); a miss is unknown (row 8).
      const term  = matched ? 'alert_unverified_match' : 'unknown';
      const badge = VERDICT_PATHWAY_TO_BADGE[term];
      console.log(`[Mysterion] Verdict Table Pathways (pHash-only) → ${term} (badge=${badge})`);

      _sendImgResult({
        result:             badge,
        detail:             matched
          ? `Visual match found but origin could not be confirmed (no watermark detected) — creator: ${best?.creator_name || best?.creator_id || 'unknown'}`
          : 'No Mysterion signature found',
        stegId:             best?.steg_id || null,
        creatorName:        best?.creator_name || null,
        signedAt:           best?.signed_at || null,
        signatureValid:     false,
        certValid:          false,
        verificationMethod: 'pHash Visual Fingerprint',
        phash:              imgPhash,
      });

      if (ALERT_PATHWAYS.has(term) && best?.steg_id) {
        await _fireAlert(best.steg_id);
      }
    } else {
      // Nothing to work with — no steg_id, no pHash. Terminal = unknown.
      console.log('[Mysterion] Verdict Table Pathways → unknown (no evidence)');
      _sendImgResult({
        result: 'unknown',
        detail: 'No Mysterion signature found',
      });
    }

  } catch (err) {
    console.error('[Mysterion] verifyMedia error:', err.message);
    chrome.tabs.sendMessage(tabId, {
      type: 'VERIFY_ERROR', imageUrl: mediaUrl,
      error: err.message || 'Verification failed.',
    });
  }
}

const verifyImage = verifyMedia;

// ── Context menu ─────────────────────────────────────────────
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== 'mysterion-verify') return;
  const mediaUrl = info.srcUrl;
  if (!mediaUrl) return;
  chrome.tabs.sendMessage(tab.id, { type: 'VERIFY_START', imageUrl: mediaUrl });
  await verifyMedia(mediaUrl, tab.id);
});

// ── pHash-based video verification (fallback for re-encoded videos) ──────
async function verifyByPHash(phash, contentType, videoId, tabId) {
  try {
    const response = await fetch(`${API_URL}/crypto/verify/phash`, {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify({ phash, content_type: contentType, hamming_threshold: 10, filename_hint: videoId, content_hash: null }),
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const data     = await response.json();
    const matched  = data.matched === true && data.matches?.length > 0;
    const bestMatch = matched ? data.matches[0] : null;
    const qpv = data.querier_provenance || 'unknown';
    chrome.tabs.sendMessage(tabId, {
      type: 'VERIFY_PHASH_RESULT', videoId, matched, bestMatch: data.canonical_original || bestMatch,
      result: matched ? _provenanceResult(qpv) : 'unknown',
      stegId: bestMatch?.steg_id || null,
    });
  } catch (err) {
    chrome.tabs.sendMessage(tabId, {
      type: 'VERIFY_ERROR', videoId,
      error: 'Could not reach Mysterion backend.',
    });
  }
}

// ── Message listener ─────────────────────────────────────────
// IMPORTANT: Must return true to keep service worker alive during async work.
// Chrome MV3 service workers are ephemeral — without return true, the SW can
// be terminated before fetch/verify operations complete.
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'GET_PDF_VERDICT') {
    const tid = (msg.tabId != null) ? msg.tabId : (sender.tab && sender.tab.id);
    sendResponse({ verdict: (tid != null && _verdictsByTab.has(tid)) ? _verdictsByTab.get(tid) : null });
    return; // synchronous response
  }
  if (msg.type === 'VERIFY_PDF_URL' && sender.tab?.id) {
    (async () => {
      try {
        const verdict = await verifyPdf(msg.pdfUrl, sender.tab.id);
        chrome.tabs.sendMessage(sender.tab.id, { type: 'PDF_VERDICT', pdfUrl: msg.pdfUrl, verdict: verdict || {} });
      } catch (e) {
        chrome.tabs.sendMessage(sender.tab.id, { type: 'PDF_VERDICT', pdfUrl: msg.pdfUrl, verdict: { badge: '!', signed: false, altered: false, creator: null, changes: null, error: String(e && e.message || e) } });
      }
    })();
    return true;
  }
  if (msg.type === 'VERIFY_URL' && sender.tab?.id) {
    verifyMedia(msg.imageUrl, sender.tab.id);
    return true; // keep service worker alive for async verification
  }
  if (msg.type === 'VERIFY_VIDEO_URL' && sender.tab?.id) {
    // Container metadata fetch first (strongest verification).
    // Only fall back to pHash if container fetch fails.
    // This avoids flashing "Unknown" from a negative pHash result
    // before the real container-based result arrives.
    (async () => {
      if (msg.videoUrl) {
        const ok = await verifyVideoByFetch(msg.videoUrl, msg.videoId, sender.tab.id);
        if (!ok && msg.phash) {
          // Container fetch failed — fall back to pHash
          await verifyByPHash(msg.phash, 'video', msg.videoId, sender.tab.id);
        } else if (!ok) {
          chrome.tabs.sendMessage(sender.tab.id, {
            type: 'VERIFY_PHASH_RESULT', videoId: msg.videoId,
            matched: false, bestMatch: null,
            result: 'unknown', stegId: null,
          });
        }
      } else if (msg.phash) {
        // No video URL available — pHash is the only option
        await verifyByPHash(msg.phash, 'video', msg.videoId, sender.tab.id);
      } else {
        chrome.tabs.sendMessage(sender.tab.id, {
          type: 'VERIFY_PHASH_RESULT', videoId: msg.videoId,
          matched: false, bestMatch: null,
          result: 'unknown', stegId: null,
        });
      }
    })();
    return true; // keep service worker alive for async verification
  }
  if (msg.type === 'VERIFY_PHASH' && sender.tab?.id) {
    verifyByPHash(msg.phash, msg.contentType, msg.videoId, sender.tab.id);
    return true; // keep service worker alive for async verification
  }
});

// ═══════════════════════════════════════════════════════════════════════════
// PDF VERIFY (Tier-1) — ported from Firefox mysterion-firefox/background.js.
// Pure-JS steg recovery (MysterionPdfSteg) + verdict. Tier-2 localization
// (PDFium WASM) self-disables via 'typeof MysterionPdfium' guard until added.
// MV3 note: context-menu registration moved into onInstalled below (not the
// FF top-level _cm.create, which can throw duplicate-id on SW restart).
// ═══════════════════════════════════════════════════════════════════════════
// ── MYSTERION PDF VERIFY (Stage 3) ──────────────────────────────────────────
//  Right-click "Verify PDF" path. Full-tab PDFs open in the pdf.js viewer,
//  which content scripts cannot inject into; a contextMenus action lets the
//  background script fetch the PDF bytes directly and verify them. No PDF
//  bytes leave the client except to the same verify endpoints images use.
const _MAX_PDF_FETCH_BYTES = 100 * 1024 * 1024;

const _cm = (typeof browser !== 'undefined' && browser.contextMenus)
  ? browser.contextMenus
  : (typeof chrome !== 'undefined' ? chrome.contextMenus : null);

// MV3: register the PDF context menu from onInstalled (not at top level), so a
// service-worker restart doesn't re-run create() and throw "duplicate id".
function _registerPdfContextMenu() {
  if (!(_cm && _cm.create)) return;
  try {
    _cm.create({
      id: 'mysterion-verify-pdf',
      title: 'Verify PDF with Mysterion',
      contexts: ['link', 'page'],
      targetUrlPatterns: [
        '*://*/*.pdf', '*://*/*.pdf?*',
        'file:///*.pdf', 'file://*/*.pdf', 'file:///*.pdf?*',
      ],
      // patch7: documentUrlPatterns constrains the PAGE the link lives on,
      // not the link target. Restricting it to *.pdf hid the item on normal
      // pages (e.g. the verify.html demo gallery). Widen to any page; the
      // targetUrlPatterns above still gate the item to actual .pdf links.
      documentUrlPatterns: ['*://*/*', 'file://*/*'],
    });
    // patch12: verify ANY link as a PDF (covers chat attachments,
    // SharePoint/Drive doc URLs with no .pdf extension). The fetch+sniff
    // (patch11) decides whether it's actually a PDF.
    _cm.create({
      id: 'mysterion-verify-pdf-link',
      title: 'Verify link as PDF (Mysterion)',
      contexts: ['link'],
      documentUrlPatterns: ['*://*/*', 'file://*/*'],
    });
    // patch12: verify the PDF currently open in this tab (full-tab viewer
    // or embedded viewer whose page URL is the PDF).
    _cm.create({
      id: 'mysterion-verify-pdf-page',
      title: "Verify this page's PDF (Mysterion)",
      contexts: ['page'],
      documentUrlPatterns: ['*://*/*', 'file://*/*'],
    });
  } catch (_) {
    // Fallback: some engines reject targetUrlPatterns+documentUrlPatterns
    // together; register a plain link/page entry and filter at click time.
    try {
      _cm.create({ id: 'mysterion-verify-pdf', title: 'Verify PDF with Mysterion', contexts: ['link', 'page'] });
    } catch (__) {}
  }
}
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onInstalled) {
  chrome.runtime.onInstalled.addListener(_registerPdfContextMenu);
} else {
  // MV2/Firefox: no onInstalled dependency needed; register directly.
  _registerPdfContextMenu();
}
// onClicked listener is safe at top level in MV3 (re-registers on each wake).
if (_cm && _cm.onClicked) {
  _cm.onClicked.addListener((info, tab) => {
    // patch12: accept the .pdf item plus the any-link and this-page items.
    const _ids = ['mysterion-verify-pdf', 'mysterion-verify-pdf-link', 'mysterion-verify-pdf-page'];
    if (_ids.indexOf(info.menuItemId) === -1) return;
    // For the any-link item prefer the link URL; for the page item prefer
    // the page/tab URL; the .pdf item can be either.
    let url;
    if (info.menuItemId === 'mysterion-verify-pdf-page') {
      url = info.pageUrl || (tab && tab.url);
    } else if (info.menuItemId === 'mysterion-verify-pdf-link') {
      url = info.linkUrl;
    } else {
      url = info.linkUrl || info.pageUrl || (tab && tab.url);
    }
    if (!url) return;
    verifyPdf(url, tab && tab.id).catch((e) =>
      console.warn('[Mysterion] verifyPdf error:', e && e.message));
  });
}

/**
 * Verify a PDF at [url] by fetching its bytes, BLAKE3-hashing, and querying
 * the platform. Mirrors the image BLAKE3 fast-path (_blake3FastPath) and, on a
 * signed match, requests the full result (which may include page/paragraph
 * localization) via /crypto/verify/batch — the same route the desktop app uses.
 */
async function _inflateAsync(bytes) {
  const ds = new DecompressionStream('deflate');
  const writer = ds.writable.getWriter();
  const reader = ds.readable.getReader();
  const chunks = [];
  const pump = (async () => {
    try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } }
    catch (e) {}
  })();
  try { await writer.write(bytes); await writer.close(); } catch (e) {}
  await pump;
  let n = 0; for (const c of chunks) n += c.length;
  const out = new Uint8Array(n); let o = 0;
  for (const c of chunks) { out.set(c, o); o += c.length; }
  return out;
}
// Per-tab PDF verdict cache so the browser-action popup can retrieve the
// current tab's full verdict (incl. localization changes[]). Cleared on
// tab close and on top-frame navigation.
const _verdictsByTab = new Map();

function _surfacePdfVerdict(verdict, tabId, pdfUrl) {  // patch14: +pdfUrl
  try {
    // patch8: MV3 uses chrome.action / browser.action; MV2 uses browserAction.
    // The old resolver only checked browserAction, which is undefined in
    // Chrome MV3 — so the badge never updated there.
    const ba =
        (typeof chrome !== 'undefined' && chrome.action) ? chrome.action
      : (typeof browser !== 'undefined' && browser.action) ? browser.action
      : (typeof browser !== 'undefined' && browser.browserAction) ? browser.browserAction
      : (typeof chrome !== 'undefined' && chrome.browserAction) ? chrome.browserAction
      : null;
    if (ba) {
      const _bt = (tabId != null) ? { text: verdict.badge, tabId } : { text: verdict.badge };
      const _tt = (tabId != null) ? { title: verdict.title, tabId } : { title: verdict.title };
      const _col = verdict.badge === '\u2713' ? '#1a9e4b'
                 : (verdict.badge === '!' ? '#d13438' : '#8a8a8a');
      try { if (ba.setBadgeText) ba.setBadgeText(_bt); } catch (_) {}
      try { if (ba.setTitle) ba.setTitle(_tt); } catch (_) {}
      try { if (ba.setBadgeBackgroundColor) ba.setBadgeBackgroundColor(
        (tabId != null) ? { color: _col, tabId } : { color: _col }); } catch (_) {}
    }
  } catch (_) {}
  try { if (tabId != null) _verdictsByTab.set(tabId, verdict); } catch (_) {}
  try {
    const send = (typeof browser !== 'undefined' && browser.tabs) ? browser.tabs.sendMessage
      : (typeof chrome !== 'undefined' ? chrome.tabs.sendMessage : null);
    if (send && tabId != null) send(tabId, { type: 'mysterion-pdf-verdict', verdict });
    // patch14: also send the message content.js renders as the in-page card badge.
    try { if (send && tabId != null && pdfUrl) send(tabId, { type: 'PDF_VERDICT', pdfUrl: pdfUrl, verdict }); } catch (_) {}
    // patch10: also broadcast on the runtime channel so an OPEN popup (which
    // cannot hear tabs.sendMessage) updates live.
    try {
      const _rt = (typeof browser !== 'undefined' && browser.runtime) ? browser.runtime
        : (typeof chrome !== 'undefined' ? chrome.runtime : null);
      if (_rt && _rt.sendMessage) {
        const _p = _rt.sendMessage({ type: 'mysterion-pdf-verdict', verdict, tabId });
        if (_p && _p.catch) _p.catch(function () {});
      }
    } catch (_) {}
  } catch (_) {}
}
// patch11: helper — surface a short error verdict to the user (badge/popup)
// so a failed/blocked/non-PDF verify explains itself instead of going silent.
function _pdfErrorVerdict(title) {
  return { badge: '!', title: 'Mysterion: ' + title, signed: false,
           altered: false, stegId: null, creator: null, changes: null };
}
async function verifyPdf(url, tabId) {
  console.log('[Mysterion] verifyPdf:', url);
  // patch13: fetch 'omit' FIRST (public PDFs — the path that always worked),
  // then fall back to 'include' (authenticated PDFs). patch11's hard
  // 'include' broke public cross-origin fetches because mysterion.io doesn't
  // send Access-Control-Allow-Credentials, so the browser rejected the
  // credentialed response. Trying 'omit' first restores the working case.
  let bytes;
  {
    let buf = null, lastErr = null, httpStatus = null;
    for (const cred of ['omit', 'include']) {
      try {
        const resp = await fetch(url, { credentials: cred, redirect: 'follow' });
        if (!resp.ok) { httpStatus = resp.status; lastErr = new Error('HTTP ' + resp.status);
          console.warn('[Mysterion] PDF fetch (' + cred + ') HTTP', resp.status); continue; }
        buf = await resp.arrayBuffer();
        console.log('[Mysterion] PDF fetch ok via credentials:' + cred + ' (' + buf.byteLength + ' bytes)');
        break;
      } catch (e) {
        lastErr = e;
        console.warn('[Mysterion] PDF fetch (' + cred + ') failed:', e && e.message);
        continue;
      }
    }
    if (buf === null) {
      const msg = (httpStatus === 401 || httpStatus === 403)
        ? 'cannot access this PDF (sign in to the site, then retry)'
        : (httpStatus ? ('could not fetch PDF (HTTP ' + httpStatus + ')')
                      : ('could not fetch PDF (' + (lastErr && lastErr.message || 'network/CORS error') + ')'));
      const ev = _pdfErrorVerdict(msg);
      try { _surfacePdfVerdict(ev, tabId, url); } catch (_) {}
      return ev;
    }
    if (buf.byteLength > _MAX_PDF_FETCH_BYTES) {
      console.warn('[Mysterion] PDF too large to verify:', buf.byteLength);
      const ev = _pdfErrorVerdict('PDF too large to verify in the browser');
      try { _surfacePdfVerdict(ev, tabId, url); } catch (_) {}
      return ev;
    }
    bytes = new Uint8Array(buf);
    // %PDF magic-byte sniff (small leading-junk tolerance).
    let _isPdf = false;
    const _scan = Math.min(bytes.length, 1024);
    for (let i = 0; i + 4 < _scan; i++) {
      if (bytes[i] === 0x25 && bytes[i+1] === 0x50 && bytes[i+2] === 0x44 && bytes[i+3] === 0x46) { _isPdf = true; break; }
    }
    if (!_isPdf) {
      console.warn('[Mysterion] fetched bytes are not a PDF (no %PDF header)');
      const ev = _pdfErrorVerdict('that link is not a PDF (or a login page was returned)');
      try { _surfacePdfVerdict(ev, tabId, url); } catch (_) {}
      return ev;
    }
  }

  // BLAKE3 over the raw bytes (retained for alteration reports / fallback).
  const contentHash = await _sha256hex(bytes.buffer);

  // Recover the embedded steg_id -- the PDF provenance lookup is steg_id-keyed
  // (mirrors the desktop app). Pure-JS: RC4/AES decrypt + inflate + LSB, no PDFium.
  let _recovered = null;
  try {
    _recovered = await MysterionPdfSteg.recoverStegIdFromPdfAsync(bytes, _inflateAsync);
  } catch (e) {
    console.warn('[Mysterion] steg_id recovery failed:', e && e.message);
  }
  if (_recovered && _recovered.passwordProtected) {
    const pv = {
      badge: '\uD83D\uDD12', signed: false, altered: false, stegId: null,
      creator: null, changes: null, passwordProtected: true,
      title: 'Mysterion: PDF is password-protected -- verify in the app',
    };
    console.log('[Mysterion] PDF verdict:', JSON.stringify(pv));
    _surfacePdfVerdict(pv, tabId, url);
    return pv;
  }
  const _stegId = (_recovered && _recovered.stegId) ? _recovered.stegId : null;
  const _creatorFromWm = (_recovered && _recovered.creatorId) ? _recovered.creatorId : null;
  let signed = false;
  let fast = null;
  if (_stegId) {
    try {
      const hr = await fetch(`${API_URL}/crypto/verify/hash`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ steg_id: _stegId, content_hash: contentHash, media_type: 'pdf' }),
      });
      if (hr.ok) { fast = await hr.json(); signed = !!(fast && (fast.found || fast.steg_id || fast.creator_name)); }
    } catch (e) { console.warn('[Mysterion] /crypto/verify/hash error:', e && e.message); }
  }

  // Full result (localization, creator) via the batch route the Flutter app
  // uses. Contract mirrors _verifyWithPlatform's /crypto/verify/batch call.
  let full = null;
  try {
    const response = await fetch(`${API_URL}/crypto/verify/batch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify([{ steg_id: _stegId, content_hash: contentHash, media_type: 'pdf', source_url: url }]),
    });
    if (response.ok) {
      const raw = await response.json();
      full = Array.isArray(raw) ? raw[0] : (raw && raw.results ? raw.results[0] : raw);
    }
  } catch (e) {
    console.warn('[Mysterion] /crypto/verify/batch (pdf) error:', e && e.message);
  }

  const verdict = _pdfVerdict(signed, full);
  if (!verdict.stegId && _stegId) verdict.stegId = _stegId;
  if (!verdict.creator && _creatorFromWm) verdict.creator = _creatorFromWm;
  if (verdict.stegId && !signed) { verdict.signed = true; }
  console.log('[Mysterion] PDF verdict:', JSON.stringify(verdict));

  // ── TIER-1 WHOLE-FILE INTEGRITY GATE (always runs when signed) ────────────
  // Fetch the signed-time record and let the server compare our content_hash
  // against the stored post-sign hash. This is a pure server call — NO PDFium /
  // WASM needed — so it MUST run independently of Tier-2 localization. A mismatch
  // means the file changed after signing (any object add, pixel edit, or text
  // edit). Only steg_id + content_hash leave the client; PDF bytes never do.
  let _pdfRef = null;
  if (verdict.signed && verdict.stegId) {
    try {
      const rr = await fetch(`${API_URL}/crypto/verify/pdf-resolve`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ steg_id: verdict.stegId, content_hash: contentHash }),
      });
      if (rr.ok) {
        _pdfRef = await rr.json();
        if (_pdfRef && _pdfRef.blake3_altered) {
          verdict.altered = true;
          verdict.blake3Altered = true;
          verdict.badge = '!';
          verdict.title = verdict.creator
            ? `Mysterion: PDF ALTERED (content changed after signing) — by ${verdict.creator}`
            : 'Mysterion: PDF ALTERED (content changed after signing)';
        } else if (_pdfRef && _pdfRef.blake3_checked) {
          // Explicit whole-file PASS — content is bit-intact vs signed hash.
          verdict.altered = false;
          verdict.changes = null;
          verdict.badge = '\u2713';
          verdict.title = verdict.creator
            ? `Mysterion: PDF verified (content intact) — by ${verdict.creator}`
            : 'Mysterion: PDF verified (content intact)';
        }
      } else {
        console.log('[Mysterion] Tier-1 pdf-resolve HTTP', rr.status);
      }
    } catch (e) {
      console.warn('[Mysterion] Tier-1 gate error (base verdict kept):', e && e.message);
    }
  }

  // ── TIER-2 LOCALIZATION (only when PDFium WASM is present) ────────────────
  // Refines WHERE a text change occurred (page/paragraph). Requires the PDFium
  // extractor + localizer; degrades gracefully — Tier-1 verdict above stands if
  // Tier-2 is unavailable or fails. Never overrides a Tier-1 ALTERED to verified.
  if (verdict.signed && verdict.stegId && _pdfRef &&
      typeof MysterionPdfium !== 'undefined' &&
      typeof MysterionPdfLoc !== 'undefined' &&
      typeof blake3hex === 'function') {
    try {
      const refTree = _pdfRef && (_pdfRef.pdf_hash_tree || _pdfRef.pdfHashTree);
      if (refTree && Array.isArray(refTree.pages)) {
        await blake3Init();
        const pageTexts = await MysterionPdfium.extractPageTexts(bytes);
        const result = MysterionPdfLoc.compareTree(pageTexts, refTree, (t) => blake3hex(t));
        verdict.localized = true;
        if (result.intact && !verdict.blake3Altered) {
          verdict.altered = false;
          verdict.changes = null;
          verdict.badge = '\u2713';
          verdict.title = verdict.creator
            ? `Mysterion: PDF verified (content intact) — by ${verdict.creator}`
            : 'Mysterion: PDF verified (content intact)';
        } else if (result.intact && verdict.blake3Altered) {
          // Whole-file hash changed but TEXT body did not — non-text change.
          verdict.altered = true;
          verdict.changes = [{ kind: 'nontext', detail: 'content changed after signing (non-text: object/image/graphics)' }];
          verdict.badge = '!';
          verdict.title = 'Mysterion: PDF ALTERED — content changed after signing (non-text)';
        } else {
          verdict.altered = true;
          verdict.changes = result.changes;
          const desc = result.changes.map(_describePdfChange).join('; ');
          verdict.badge = '!';
          verdict.title = `Mysterion: PDF ALTERED — ${desc}`;
        }
        console.log('[Mysterion] Tier-2 localization:', JSON.stringify(result));
      } else {
        console.log('[Mysterion] Tier-2: no reference tree for steg_id', verdict.stegId);
      }
    } catch (e) {
      console.warn('[Mysterion] Tier-2 localization error (Tier-1 verdict kept):', e && e.message);
    }
  }

  // If signed-but-altered, fire an alteration report (mirrors _fireVideoAlert).
  if (verdict.stegId && verdict.altered) {
    _firePdfAlert(verdict.stegId, url, contentHash).catch(() => {});
  }

  // Surface the verdict to the user via the browser action badge/title.
  try {
    // patch8: MV3 uses chrome.action / browser.action; MV2 uses browserAction.
    // The old resolver only checked browserAction, which is undefined in
    // Chrome MV3 — so the badge never updated there.
    const ba =
        (typeof chrome !== 'undefined' && chrome.action) ? chrome.action
      : (typeof browser !== 'undefined' && browser.action) ? browser.action
      : (typeof browser !== 'undefined' && browser.browserAction) ? browser.browserAction
      : (typeof chrome !== 'undefined' && chrome.browserAction) ? chrome.browserAction
      : null;
    if (ba) {
      const _bt = (tabId != null) ? { text: verdict.badge, tabId } : { text: verdict.badge };
      const _tt = (tabId != null) ? { title: verdict.title, tabId } : { title: verdict.title };
      const _col = verdict.badge === '\u2713' ? '#1a9e4b'
                 : (verdict.badge === '!' ? '#d13438' : '#8a8a8a');
      try { if (ba.setBadgeText) ba.setBadgeText(_bt); } catch (_) {}
      try { if (ba.setTitle) ba.setTitle(_tt); } catch (_) {}
      try { if (ba.setBadgeBackgroundColor) ba.setBadgeBackgroundColor(
        (tabId != null) ? { color: _col, tabId } : { color: _col }); } catch (_) {}
    }
  } catch (_) {}

  // patch14: message the tab so content.js renders the in-page card badge,
  // plus the popup live-update broadcast.
  try {
    const send = (typeof browser !== 'undefined' && browser.tabs) ? browser.tabs.sendMessage
      : (typeof chrome !== 'undefined' ? chrome.tabs.sendMessage : null);
    if (send && tabId != null) send(tabId, { type: 'mysterion-pdf-verdict', verdict });
    try { if (send && tabId != null && url) send(tabId, { type: 'PDF_VERDICT', pdfUrl: url, verdict }); } catch (_) {}
    // patch10: also broadcast on the runtime channel so an OPEN popup (which
    // cannot hear tabs.sendMessage) updates live.
    try {
      const _rt = (typeof browser !== 'undefined' && browser.runtime) ? browser.runtime
        : (typeof chrome !== 'undefined' ? chrome.runtime : null);
      if (_rt && _rt.sendMessage) {
        const _p = _rt.sendMessage({ type: 'mysterion-pdf-verdict', verdict, tabId });
        if (_p && _p.catch) _p.catch(function () {});
      }
    } catch (_) {}
  } catch (_) {}

  // patch9: persist the main-path verdict so the popup's GET_PDF_VERDICT
  // (-> _verdictsByTab.get(tabId)) can render it. Previously only the
  // password-protected branch stored via _surfacePdfVerdict(), so normal
  // signed/unsigned verdicts never reached the popup.
  try { if (tabId != null) _verdictsByTab.set(tabId, verdict); } catch (_) {}

  return verdict;
}

// Build a user-facing verdict from the fast-path + batch result. Defensive:
// works whether or not the batch response carries localization fields.
function _pdfVerdict(signed, full) {
  const creator = (full && (full.creator_name || full.creator)) || null;
  const stegId = (full && (full.steg_id || full.stegId)) || null;
  // Localization: verify.py may return a changes/localization array. Accept a
  // few shapes without assuming one is present.
  const changes = (full && (full.pdf_changes || full.changes || full.localization)) || null;
  const altered = Array.isArray(changes) ? changes.length > 0
    : (full && full.altered === true);

  if (!signed && !(full && full.found)) {
    return { badge: '?', title: 'Mysterion: PDF not verified (no signature found)',
      signed: false, altered: false, stegId, creator, changes: null };
  }
  if (altered) {
    const desc = Array.isArray(changes) && changes.length
      ? (changes.map(_describePdfChange).join('; '))
      : 'content altered';
    return { badge: '!', title: `Mysterion: PDF ALTERED — ${desc}`,
      signed: true, altered: true, stegId, creator, changes };
  }
  return { badge: '\u2713',
    title: creator ? `Mysterion: PDF verified — by ${creator}` : 'Mysterion: PDF verified',
    signed: true, altered: false, stegId, creator, changes: null };
}

// Render one localized change. Mirrors PdfChange.describe() in the Dart app.
function _describePdfChange(c) {
  if (!c || typeof c !== 'object') return String(c);
  const page = c.page != null ? c.page : '?';
  if (c.paragraphCountChanged) {
    const at = c.divergesAtParagraph;
    return at != null ? `page ${page} altered starting at paragraph ${at}`
      : `page ${page} altered (sections added or removed)`;
  }
  const paras = c.paragraphs || [];
  if (!paras.length) return `page ${page} altered`;
  if (paras.length === 1) return `page ${page}, section ${paras[0]} changed`;
  return `page ${page}, sections ${paras.join(', ')} changed`;
}

async function _firePdfAlert(stegId, sourceUrl, contentHash) {
  let reporterFP = 'anonymous';
  try {
    const raw = [navigator.userAgent || 'sw', 'offscreen',
      Intl.DateTimeFormat().resolvedOptions().timeZone].join('|');
    const fpHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
    reporterFP = Array.from(new Uint8Array(fpHash)).map(b => b.toString(16).padStart(2, '0')).join('');
  } catch (_) {}
  return fetch(`${API_URL}/alerts/alteration-report`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      steg_id: stegId,
      source_url: sourceUrl,
      client_rolling_hash: contentHash || null,
      reporter_fingerprint: reporterFP,
      media_type: 'pdf',
    }),
  });
}


// ══════════════════════════════════════════════════════════════════════════
//  MysterionNativeV2 keyed recovery (patch2)
//  Transformed-content recovery for images whose LSB + blind-DCT self-ID both
//  fail (e.g. JPEG recompression). The DCT-SS v2 verifier is KEYED, so we need
//  a candidate steg_id first: source it from /verify/phash (pHash only — no
//  bytes leave the device), fetch the keyed inputs via /verify/prepare, then
//  run the multi-layer keyed verify locally. On success we return the recovered
//  steg_id + creator so the existing Path-1 flow lets the server run the
//  Verdict Table (→ verified_origin_altered / verified_transformed).
// ══════════════════════════════════════════════════════════════════════════
let _nativeV2Ready = null;
async function _ensureNativeV2() {
  // MysterionNativeV2 is exposed by mysterion_native_loader.js. In MV3 the
  // service worker imports it via importScripts at SW startup (see patch2
  // importScripts splice); in MV2 it's a background-page <script>. Either way
  // globalThis.MysterionNativeV2 is present here.
  if (typeof globalThis.MysterionNativeV2 === 'undefined') return false;
  if (_nativeV2Ready === null) {
    _nativeV2Ready = globalThis.MysterionNativeV2.init()
      .then(() => true)
      .catch((e) => { console.warn('[Mysterion] NativeV2 init failed:', e && e.message); return false; });
  }
  return _nativeV2Ready;
}

// RGBA (w*h*4) → packed RGB (w*h*3), cropped to even dimensions.
function _rgbaToRgbEven(rgba, width, height) {
  const w = width  - (width  % 2);
  const h = height - (height % 2);
  if (w < 2 || h < 2) return null;
  const out = new Uint8Array(w * h * 3);
  let o = 0;
  for (let y = 0; y < h; y++) {
    let si = (y * width) * 4;
    for (let x = 0; x < w; x++) {
      out[o++] = rgba[si];
      out[o++] = rgba[si + 1];
      out[o++] = rgba[si + 2];
      si += 4;
    }
  }
  return { rgb: out, width: w, height: h };
}

function _hexToBytes(hex) {
  if (typeof hex !== 'string' || hex.length % 2 !== 0) return null;
  const out = new Uint8Array(hex.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(hex.substr(i * 2, 2), 16);
  }
  return out;
}

// steg_id canonical wire form is 32 lowercase hex (16 bytes).
function _stegIdToBytes(sid) {
  const hex = String(sid || '').replace(/-/g, '').toLowerCase();
  if (hex.length !== 32) return null;
  return _hexToBytes(hex);
}

function _uuidFromBytes16(b) {
  if (!b || b.length < 16) return null;
  const h = Array.from(b.subarray(0, 16)).map((x) => x.toString(16).padStart(2, '0')).join('');
  return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20,32)}`;
}

// Attempt keyed transformed-recovery. Returns {stegId, creatorId} on success,
// or null. Never uploads image bytes — only pHash / hashes leave the device.
async function _keyedRecoverImage(rgbaPixels, width, height, imgPhash, contentHash, mediaUrl) {
  try {
    if (!imgPhash) return null;
    const ready = await _ensureNativeV2();
    if (!ready) { console.log('[Mysterion] keyed-recovery: NativeV2 unavailable'); return null; }

    // 1. pHash → candidate steg_id (no bytes leave device).
    const pResp = await fetch(`${API_URL}/crypto/verify/phash`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        phash: imgPhash, content_type: 'image',
        hamming_threshold: 10, filename_hint: mediaUrl, content_hash: contentHash,
      }),
    });
    if (!pResp.ok) return null;
    const pData = await pResp.json();
    const cand = (pData.matched && pData.matches && pData.matches.length)
      ? (pData.canonical_original || pData.matches[0]) : null;
    const candSteg = cand && cand.steg_id;
    if (!candSteg) { console.log('[Mysterion] keyed-recovery: no pHash candidate'); return null; }

    // 2. /verify/prepare → keyed inputs for the candidate.
    const prep = await fetch(`${API_URL}/crypto/verify/prepare`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ steg_id: candSteg, content_hash: contentHash }),
    });
    if (!prep.ok) { console.log('[Mysterion] keyed-recovery: prepare', prep.status); return null; }
    const ev = await prep.json();
    if (ev.has_dct === false) { console.log('[Mysterion] keyed-recovery: record has no DCT'); return null; }

    const rawBlake3 = _hexToBytes(String(ev.raw_blake3 || '').toLowerCase());
    const stegIdB   = _stegIdToBytes(ev.steg_id);
    const creatorId = String(ev.creator_id || '');
    let rfcDer = new Uint8Array(0);
    try { if (ev.rfc3161_token_der_b64) {
      const bin = atob(ev.rfc3161_token_der_b64);
      rfcDer = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) rfcDer[i] = bin.charCodeAt(i);
    }} catch (_) {}

    // patchDBG_rfc: surface exactly what the extractor will receive.
    try {
      const _b64 = ev.rfc3161_token_der_b64 || '';
      const _fb = Array.from(rfcDer.slice(0,8)).map(x=>x.toString(16).padStart(2,'0')).join(' ');
      console.log('[Mysterion DBG rfc] prepare keys:', Object.keys(ev||{}).join(','));
      console.log('[Mysterion DBG rfc] b64 len:', _b64.length, '| rfcDer bytes:', rfcDer.length, '| first8:', _fb);
      console.log('[Mysterion DBG rfc] rawBlake3 len:', rawBlake3?rawBlake3.length:null, '| creatorId:', creatorId, '| steg_id:', ev.steg_id, '| has_dct:', ev.has_dct, '| native_wh:', ev.native_width, ev.native_height);
    } catch(_e){ console.log('[Mysterion DBG rfc] log failed', _e && _e.message); }
    if (!rawBlake3 || rawBlake3.length !== 32 || !stegIdB || creatorId.length !== 36) {
      console.log('[Mysterion] keyed-recovery: incomplete keyed inputs'); return null;
    }

    // patch25: match the desktop app's verifyImageV2 path EXACTLY so the
    // extension and app produce the same accurate verdict for the same file.
    // Step 1: v2 recompute verify with NO expected-dims hint (0/0). Passing
    // native dims forces the Lanczos resample branch in verify_frame, which
    // diverges from the app and previously panicked on re-encoded photos.
    if (!ev.has_dct) { console.log('[Mysterion] keyed-recovery: record has no DCT'); return null; }
    const packed = _rgbaToRgbEven(rgbaPixels, width, height);
    if (!packed) return null;
    // App gate: only attempt v2 at >=256x256 (matches verify_screen.dart).
    if (packed.width < 256 || packed.height < 256) {
      console.log('[Mysterion] keyed-recovery: below 256px min for v2'); return null;
    }
    let recovered = null;
    try {
      const res = globalThis.MysterionNativeV2.verifyImage({
        rgb: packed.rgb, width: packed.width, height: packed.height,
        expectedWidth: 0, expectedHeight: 0,   // patch25: app passes no dims hint
        appVersion: '5.5.400',
        rawBlake3, creatorId, rfc3161Der: rfcDer, stegId: stegIdB,
      });
      if (res && res.decoded === true) {
        // App verifies the decoded payload against the deterministic builder.
        const expected = globalThis.MysterionNativeV2.buildPayload({
          appVersion: '5.5.400', rawBlake3, creatorId, rfc3161Der: rfcDer, stegId: stegIdB,
        });
        let ok = res.payload && expected && res.payload.length === expected.length;
        if (ok) { let d = 0; for (let i = 0; i < expected.length; i++) d |= res.payload[i] ^ expected[i]; ok = (d === 0); }
        if (ok) {
          const recCreator = _uuidFromBytes16(res.payload) || creatorId;
          console.log('[Mysterion] keyed-recovery SUCCESS (v2) → steg_id', String(ev.steg_id).slice(0, 8),
                      'creator', recCreator.slice(0, 8), 'orient', res.orientation);
          recovered = { stegId: _normalizeStegId(ev.steg_id) || ev.steg_id, creatorId: recCreator };
        }
      }
    } catch (e) {
      console.warn('[Mysterion] keyed-recovery: v2 verify threw:', e && e.message);
    }
    if (recovered) return recovered;

    // Step 2 (fallback): explicit stored block list, only if v2 didn't decode
    // AND the signer persisted a list. Mirrors the app's v1 explicit fallback.
    let blocks = null;
    try {
      if (ev.dct_block_list_b64) {
        const rawbin = atob(ev.dct_block_list_b64);
        if (rawbin.length >= 4 && rawbin.length % 4 === 0) {
          const bytes = new Uint8Array(rawbin.length);
          for (let i = 0; i < rawbin.length; i++) bytes[i] = rawbin.charCodeAt(i);
          blocks = new Uint16Array(bytes.buffer, bytes.byteOffset, rawbin.length >>> 1);
        }
      }
    } catch (_) { blocks = null; }
    if (blocks && blocks.length) {
      const DCT_VER = '5.5.90', CHIP = 64;
      const alignedW = width - (width % 8);
      const alignedH = height - (height % 8);
      if (alignedW >= 8 && alignedH >= 8) {
        const yPlane = new Uint8Array(alignedW * alignedH);
        for (let yy = 0; yy < alignedH; yy++) {
          for (let xx = 0; xx < alignedW; xx++) {
            const si = (yy * width + xx) * 4;
            const yv = (77 * rgbaPixels[si] + 150 * rgbaPixels[si + 1] + 29 * rgbaPixels[si + 2] + 128) >> 8;
            yPlane[yy * alignedW + xx] = yv < 0 ? 0 : (yv > 255 ? 255 : yv);
          }
        }
        try {
          const expected = globalThis.MysterionNativeV2.buildPayload({
            appVersion: DCT_VER, rawBlake3, creatorId, rfc3161Der: rfcDer, stegId: stegIdB,
          });
          const extracted = globalThis.MysterionNativeV2.extractExplicit({
            appVersion: DCT_VER, rawBlake3, creatorId, rfc3161Der: rfcDer, stegId: stegIdB,
            yPlane, width: alignedW, height: alignedH, chipRate: CHIP, blocks,
          });
          if (extracted && expected && extracted.length === expected.length) {
            let d = 0; for (let i = 0; i < expected.length; i++) d |= extracted[i] ^ expected[i];
            if (d === 0) {
              const recCreator = _uuidFromBytes16(extracted) || creatorId;
              console.log('[Mysterion] keyed-recovery SUCCESS (explicit) → steg_id', String(ev.steg_id).slice(0, 8));
              return { stegId: _normalizeStegId(ev.steg_id) || ev.steg_id, creatorId: recCreator };
            }
          }
        } catch (e) {
          console.warn('[Mysterion] keyed-recovery: explicit extract threw:', e && e.message);
        }
      }
    }
    console.log('[Mysterion] keyed-recovery: did not decode (honest miss)');
    return null;
  } catch (e) {
    console.warn('[Mysterion] keyed-recovery error:', e && e.message);
    return null;
  }
}

// patch26: single-engine policy applied


// ── patch27_jpeg_quality: JPEG quality estimation (client-side measurement) ──────
// Verbatim port of phash_qadaptive.dart estimateJpegQuality. Pure byte-scan of
// the JPEG DQT marker; no decode, bytes never leave the device. Returns an int
// 1..100, or null for PNG / non-JPEG / undetectable. The server uses this to
// Q-band the perceptual-hash verdict identically to the app.
const _MYST_STD_LUMA = [
  16, 11, 10, 16, 24, 40, 51, 61,
  12, 12, 14, 19, 26, 58, 60, 55,
  14, 13, 16, 24, 40, 57, 69, 56,
  14, 17, 22, 29, 51, 87, 80, 62,
  18, 22, 37, 56, 68, 109, 103, 77,
  24, 35, 55, 64, 81, 104, 113, 92,
  49, 64, 78, 87, 103, 121, 120, 101,
  72, 92, 95, 98, 112, 100, 103, 99,
];

function _qualityFromLumaTable(observed) {
  let sumRatio = 0, count = 0;
  for (let k = 0; k < 64; k++) {
    const o = observed[k];
    if (o <= 0) continue;
    sumRatio += _MYST_STD_LUMA[k] / o;
    count++;
  }
  if (count === 0) return 50;
  const avg = sumRatio / count;
  let q;
  if (avg >= 1.0) q = (200.0 - 100.0 / avg) / 2.0;
  else            q = 50.0 * avg;
  const qi = Math.round(q);
  if (qi < 1) return 1;
  if (qi > 100) return 100;
  return qi;
}

function _estimateJpegQuality(bytes) {
  // bytes: Uint8Array
  if (!bytes || bytes.length < 4) return null;
  if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) return null; // not JPEG
  let i = 2;
  while (i < bytes.length - 1) {
    if (bytes[i] !== 0xFF) { i++; continue; }
    while (i < bytes.length && bytes[i] === 0xFF) i++;
    if (i >= bytes.length) return null;
    const marker = bytes[i]; i++;
    // standalone markers (SOI/EOI/RSTn/TEM) — no length
    if (marker === 0xD8 || marker === 0xD9 ||
        (marker >= 0xD0 && marker <= 0xD7) || marker === 0x01) continue;
    if (marker === 0xDA) return null; // SOS — stop
    if (i + 1 >= bytes.length) return null;
    const segLen = (bytes[i] << 8) | bytes[i + 1];
    if (segLen < 2 || i + segLen > bytes.length) return null;
    const segStart = i + 2, segEnd = i + segLen;
    i = segEnd;
    if (marker !== 0xDB) continue; // only DQT
    let p = segStart;
    while (p < segEnd) {
      if (p + 1 > segEnd) break;
      const pqTq = bytes[p]; p++;
      const precision = (pqTq >> 4) & 0x0F;
      const tableId = pqTq & 0x0F;
      const entrySize = precision === 0 ? 1 : 2;
      const tableBytes = 64 * entrySize;
      if (p + tableBytes > segEnd) break;
      if (tableId === 0) {
        const quant = new Array(64).fill(0);
        for (let k = 0; k < 64; k++) {
          quant[k] = precision === 0
            ? bytes[p + k]
            : ((bytes[p + 2 * k] << 8) | bytes[p + 2 * k + 1]);
        }
        return _qualityFromLumaTable(quant);
      }
      p += tableBytes;
    }
  }
  return null;
}
