// ============================================================
//  Mysterion Verify — Content Script v5.5.0 (WebCodecs pipeline)
//  v5.5.0: WebCodecs hardware-accelerated video frame extraction:
//          - MP4Box.js demuxes video → keyframe samples + codec config
//          - VideoDecoder hardware-decodes → VideoFrame
//          - createImageBitmap for GPU-accelerated 32×32 resize
//          - OffscreenCanvas → grayscale → pHash (4-8x speedup)
//          - Legacy HTMLVideoElement seek fallback preserved
//  v2.0.1: Parallel video clone pool for ~4x faster frame verification
//          - N hidden video clones seek concurrently (default 4)
//          - Fixes badge flicker (UNKNOWN → Verified → Checking)
//          - Console timing logs for performance debugging
//  v2.0.0: WASM-accelerated pHash computation:
//          - MysterionWASM: 18KB WASM module for DCT/pHash/LSB/Hamming
//          - Priority: WASM > WebGL > CPU fallback
//          - All existing JS code preserved as fallback
//  v1.6.0: GPU-accelerated video frame verification:
//          - WebGLPHashEngine: reusable WebGL shader for 32×32 DCT
//          - Pipelined frame seeking: overlap seek latency with hash computation
//          - CPU fallback preserved for all GPU code paths
//  v1.5.2: Video scan flow rewrite:
//          - Sample scan ALWAYS completes (even if mouse leaves)
//          - Full scan pauses on mouse-leave, resumes on re-hover
//          - _resumeFullScan replaces _fullScanVideo
//          - New 'sampled' state between sample-done and full-scan-done
//  v1.5.1: Guard against double _runVideoScanFlow from pHash + container results.
//  v1.5.0: Progressive hover-to-scan flow.
//          Hover starts sampled scan immediately.
//          If sample passes + mouse still hovering → full scan.
//          Mouse leave stops scan gracefully.
//          No popup, no settings page, no options toggle.
//          QR code button on verified content.
//          Captures frames from <video> via Canvas API,
//          computes pHash client-side, sends hash only to platform.
//          Works on YouTube and any site — no file download needed.
//  Hover any image or video to auto-verify.
//  Right-click → "Verify with Mysterion" also works.
// ============================================================

// ══════════════════════════════════════════════════════════════════════════════
//  Mysterion WASM Loader — v5.5.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' && chrome.runtime && chrome.runtime.getURL) {
      return chrome.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;
    }
  }

  // ── 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 BADGE_CONFIGS = {
  verified:       { color: '#4ade80', icon: '✓', label: 'Verified by Mysterion' },
  altered:        { color: '#facc15', icon: '!', label: 'Verified Origin · Content Altered' },
  alert:          { color: '#dc2626', icon: '‼', label: 'Origin Mismatch' },
  unverified_match: { color: '#f97316', icon: '?', label: 'Unverified Match' },  // patch21: pHash hit, origin unconfirmed (NOT forgery)
  alert_critical: { color: '#dc2626', icon: '⛔', label: 'Trust Broken' },
  synthetic:      { color: '#3b82f6', icon: '⚠', label: 'Synthetic Declared' },
  probable_match: { color: '#d1d5db', icon: '?', label: 'Unknown Origin' },
  likely_match:   { color: '#d1d5db', icon: '?', label: 'Unknown Origin' },
  unknown:        { color: '#d1d5db', icon: '?', label: 'Unknown Origin' },
  checking:       { color: '#9b5de5', icon: '…', label: 'Checking…' },
  scanning:       { color: '#9b5de5', icon: '◉', label: 'Deep Scanning…' },
  error:          { color: '#f87171', icon: '!', label: 'Error' },
  maintenance:    { color: '#fbbf24', icon: '⏳', label: 'Platform Maintenance' }
};

// ── HTML sanitisation — all API-sourced strings go through this ──────────────
function _esc(str) {
  if (!str) return '';
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

// per-element: 'idle' | 'checking' | 'done' | 'scanning'
const imgState = new WeakMap();

// Tracks whether the mouse is currently hovering over a media element.
// Used by _fullScanVideo to stop gracefully when the user moves away.
const _isHovering = new WeakMap();

// ── VIDEO KEYFRAME SAMPLING CONSTANTS ────────────────────────────────────────
const MAX_SAMPLE_FRAMES = 120;  // Fixed budget for <7s hover verify
const FRAME_PROCESS_MS = 50;    // ~50ms per frame (decode + grayscale + DCT + hash)

function mysterionSVG(color) {
  return `<svg width="16" height="16" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
    <circle cx="16" cy="16" r="15" stroke="${color}" stroke-width="1.5" opacity="0.5"/>
    <ellipse cx="16" cy="16" rx="10" ry="5.5" stroke="${color}" stroke-width="1.5" opacity="0.8"/>
    <circle cx="16" cy="16" r="3.5" fill="${color}" opacity="0.95"/>
    <circle cx="16" cy="16" r="1.5" fill="#08060f"/>
    <line x1="16" y1="1.5" x2="16" y2="4" stroke="${color}" stroke-width="1.2"/>
    <line x1="16" y1="28" x2="16" y2="30.5" stroke="${color}" stroke-width="1.2"/>
    <line x1="1.5" y1="16" x2="4" y2="16" stroke="${color}" stroke-width="1.2"/>
    <line x1="28" y1="16" x2="30.5" y2="16" stroke="${color}" stroke-width="1.2"/>
  </svg>`;
}

function findImageElement(imageUrl) {
  for (const img of document.querySelectorAll('img')) {
    if (img.src === imageUrl || img.currentSrc === imageUrl) return img;
  }
  return null;
}

// Find either an img or video element matching the URL
function findMediaElement(mediaUrl) {
  for (const img of document.querySelectorAll('img')) {
    if (img.src === mediaUrl || img.currentSrc === mediaUrl) return img;
  }
  for (const vid of document.querySelectorAll('video')) {
    if (vid.src === mediaUrl || vid.currentSrc === mediaUrl) return vid;
    // Also check <source> children
    for (const src of vid.querySelectorAll('source')) {
      if (src.src === mediaUrl) return vid;
    }
  }
  return null;
}

// Position the badge over the hovered element using its live bounding rect.
// position:fixed + body-appended so no ancestor overflow/positioning misplaces
// or clips it. Anchored to the element's bottom-left, nudged 8px inward, and
// clamped to stay within the viewport.
function _positionBadge(el, badge) {
  const r = el.getBoundingClientRect();
  // Default anchor: inside the element's bottom-left corner.
  let left = r.left + 8;
  let top  = r.bottom - badge.offsetHeight - 8;
  // If the element is too short to seat the badge inside, place it just below.
  if (badge.offsetHeight + 8 > r.height) {
    top = r.bottom + 6;
  }
  // Clamp to viewport so it never flies off-screen.
  const vw = document.documentElement.clientWidth;
  const vh = document.documentElement.clientHeight;
  const bw = badge.offsetWidth || 140;
  const bh = badge.offsetHeight || 40;
  left = Math.max(6, Math.min(left, vw - bw - 6));
  top  = Math.max(6, Math.min(top,  vh - bh - 6));
  badge.style.left = left + 'px';
  badge.style.top  = top + 'px';
}

function getOrCreateBadge(el) {
  if (!el._mysterionBadge) {
    const badge = document.createElement('div');
    badge.className = 'mysterion-badge';
    badge.style.cssText = `
      position: fixed; left: 0; top: 0;
      background: rgba(8,6,15,0.88);
      border-radius: 7px;
      display: flex; flex-direction: column; gap: 0;
      padding: 5px 10px 5px 7px;
      z-index: 2147483647; cursor: default;
      box-shadow: 0 2px 12px rgba(0,0,0,0.6);
      backdrop-filter: blur(6px);
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-size: 11px; font-weight: 700;
      white-space: nowrap; min-width: 120px;
      opacity: 0; transition: opacity 0.25s ease;
      pointer-events: auto;
    `;
    // Hide badge when mouse leaves it (unless going back to the media element)
    badge.addEventListener('mouseleave', (e) => {
      if (e.relatedTarget === el) return; // going back to video/img
      if (el._mysterionDeepScanRequested) return; // deep scan in progress
      _isHovering.set(el, false);
      hideBadge(el);
    });
    document.body.appendChild(badge);
    el._mysterionBadge = badge;
  }
  return el._mysterionBadge;
}

// Overall confidence for the badge. The platform reports confidence per layer;
// the badge shows the mean of the layers that actually reported one, which is
// the same basis the app's verdict card uses. Returns null when no layer
// carried a confidence value, so the line is simply omitted.
function _confidenceLine(layers) {
  if (!Array.isArray(layers) || !layers.length) return null;
  const vals = layers
    .map(l => (typeof l.confidence === 'number' ? l.confidence : null))
    .filter(v => v !== null && v >= 0 && v <= 1);
  if (!vals.length) return null;
  const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
  return Math.round(mean * 100) + '% confidence';
}

function renderBadge(el, status, lines) {
  const cfg   = BADGE_CONFIGS[status] || BADGE_CONFIGS.unknown;
  const badge = getOrCreateBadge(el);
  badge.style.border = `1.5px solid ${cfg.color}`;

  const line1 = `<span style="color:${cfg.color};font-weight:800;letter-spacing:0.03em;">${cfg.icon} ${cfg.label}</span>`;
  const line2 = lines?.creator ? `<span style="color:rgba(255,255,255,0.75);font-size:10px;margin-top:2px;">${_esc(lines.creator)}</span>` : '';
  const line3 = lines?.signed  ? `<span style="color:rgba(255,255,255,0.45);font-size:9px;margin-top:1px;">${_esc(lines.signed)}</span>` : '';
  const line4 = lines?.method  ? `<span style="color:rgba(255,255,255,0.55);font-size:9px;margin-top:1px;">via ${_esc(lines.method)}</span>` : '';
  const line5 = lines?.source  ? `<span style="color:rgba(255,255,255,0.3);font-size:9px;margin-top:1px;">from ${_esc(lines.source)}</span>` : '';
  const line6 = lines?.content ? `<span style="color:rgba(255,255,255,0.35);font-size:9px;margin-top:1px;">${_esc(lines.content)}</span>` : '';
  const line7 = lines?.progress ? `<span style="color:rgba(155,93,229,0.8);font-size:9px;margin-top:2px;">${_esc(lines.progress)}</span>` : '';
  const line8 = lines?.confidence ? `<span style="color:${cfg.color};opacity:0.85;font-size:9px;margin-top:1px;font-weight:700;">${_esc(lines.confidence)}</span>` : '';

  badge.innerHTML = `
    <div style="display:flex;align-items:center;gap:5px;">${mysterionSVG(cfg.color)}${line1}</div>
    ${line2 ? `<div style="padding-left:21px;">${line2}</div>` : ''}
    ${line3 ? `<div style="padding-left:21px;">${line3}</div>` : ''}
    ${line4 ? `<div style="padding-left:21px;">${line4}</div>` : ''}
    ${line5 ? `<div style="padding-left:21px;">${line5}</div>` : ''}
    ${line6 ? `<div style="padding-left:21px;">${line6}</div>` : ''}
    ${line7 ? `<div style="padding-left:21px;">${line7}</div>` : ''}
    ${line8 ? `<div style="padding-left:21px;">${line8}</div>` : ''}
  `;
}

function showBadge(el)  {
  const b = el._mysterionBadge;
  if (!b) return;
  // Make measurable (offsetWidth/Height need layout) then position, then reveal.
  b.style.visibility = 'hidden';
  b.style.opacity = '0';
  _positionBadge(el, b);
  b.style.visibility = 'visible';
  b.style.opacity = '1';
}
function hideBadge(el)  { if (el._mysterionBadge) el._mysterionBadge.style.opacity = '0'; }

// Auto-hide badge after deep scan completes when user isn't hovering.
// Shows the result for 4 seconds then fades out.
function _autoHideBadge(el) {
  if (_isHovering.get(el)) return;          // user is watching — leave it
  clearTimeout(el._mysterionAutoHideTimer);
  el._mysterionAutoHideTimer = setTimeout(() => {
    if (!_isHovering.get(el)) hideBadge(el);
  }, 4000);
}

// ── pHash computation (DCT-based perceptual hash) ─────────────
// Exactly matches phash.dart in the Mysterion app and the platform:
// 1. Draw video frame to 32×32 canvas
// 2. Convert to grayscale (float)
// 3. Full 2D DCT on the 32×32 grid (row-wise then column-wise)
// 4. Take top-left 8×8 low-frequency block, skip DC (0,0)
// 5. Median threshold → 64-bit hex hash
//
// Precomputed cosine table: cos32[k][i] = cos(π(2i+1)k/64), k,i ∈ [0,32)
// Eliminates runtime Math.cos() calls from the inner loop.
const _COS32 = (() => {
  const N = 32, t = new Float64Array(N * N);
  for (let k = 0; k < N; k++)
    for (let i = 0; i < N; i++)
      t[k * N + i] = Math.cos(Math.PI * (2 * i + 1) * k / (2 * N));
  return t;
})();
const _SCALE32 = (() => {
  const N = 32, s = new Float64Array(N);
  s[0] = Math.sqrt(1.0 / N);
  const sk = Math.sqrt(2.0 / N);
  for (let k = 1; k < N; k++) s[k] = sk;
  return s;
})();

function _dct2d32(gray) {
  const N = 32;
  // Row-wise DCT
  const temp = new Float64Array(N * N);
  for (let r = 0; r < N; r++) {
    const rOff = r * N;
    for (let k = 0; k < N; k++) {
      let sum = 0;
      const kOff = k * N;
      for (let i = 0; i < N; i++) sum += gray[rOff + i] * _COS32[kOff + i];
      temp[rOff + k] = sum * _SCALE32[k];
    }
  }
  // Column-wise DCT
  const result = new Float64Array(N * N);
  for (let c = 0; c < N; c++) {
    for (let k = 0; k < N; k++) {
      let sum = 0;
      const kOff = k * N;
      for (let i = 0; i < N; i++) sum += temp[i * N + c] * _COS32[kOff + i];
      result[k * N + c] = sum * _SCALE32[k];
    }
  }
  return result;
}

// UNNORMALIZED 2D DCT — matches scipy.fftpack.dct type-II used by imagehash.
// X[k] = 2 * sum_n x[n] * cos(pi*(2n+1)*k/(2N))
function _dct2d32_unnorm(gray) {
  const N = 32;
  // Row-wise DCT (unnormalized)
  const temp = new Float64Array(N * N);
  for (let r = 0; r < N; r++) {
    const rOff = r * N;
    for (let k = 0; k < N; k++) {
      let sum = 0;
      const kOff = k * N;
      for (let i = 0; i < N; i++) sum += gray[rOff + i] * _COS32[kOff + i];
      temp[rOff + k] = 2.0 * sum;
    }
  }
  // Column-wise DCT (unnormalized)
  const result = new Float64Array(N * N);
  for (let c = 0; c < N; c++) {
    for (let k = 0; k < N; k++) {
      let sum = 0;
      const kOff = k * N;
      for (let i = 0; i < N; i++) sum += temp[i * N + c] * _COS32[kOff + i];
      result[k * N + c] = 2.0 * sum;
    }
  }
  return result;
}

// ══════════════════════════════════════════════════════════════════════════════
//  WebGL-Accelerated 32×32 pHash Engine (v1.6.0)
//  Two-pass fragment shader: row-wise DCT → framebuffer → column-wise DCT.
//  Reads back only the top-left 8×8 block (64 floats) for hashing.
//  Falls back to CPU _dct2d32_unnorm() if WebGL is unavailable.
//
//  The shader computes the UNNORMALIZED type-II DCT:
//    X[k] = 2.0 * sum_n(x[n] * cos(π * (2n+1) * k / 64))
//  This is mathematically identical to the CPU path.
// ══════════════════════════════════════════════════════════════════════════════
class WebGLPHashEngine {
  constructor() {
    this._ready = false;
    this._gl = null;
    try {
      this._init();
    } catch (e) {
      console.warn('[Mysterion] WebGL pHash engine init failed, using CPU fallback:', e.message);
      this._ready = false;
    }
  }

  _init() {
    const canvas = document.createElement('canvas');
    canvas.width = 32;
    canvas.height = 32;
    const gl = canvas.getContext('webgl', {
      antialias: false,
      depth: false,
      stencil: false,
      preserveDrawingBuffer: true,
    });
    if (!gl) return;

    // Check for required float texture support
    const floatExt = gl.getExtension('OES_texture_float');
    if (!floatExt) return;

    this._gl = gl;
    this._canvas = canvas;

    // Build cosine lookup texture (32×32, each texel = cos(π*(2*col+1)*row/64))
    const cosData = new Float32Array(32 * 32 * 4);
    for (let k = 0; k < 32; k++) {
      for (let n = 0; n < 32; n++) {
        const idx = (k * 32 + n) * 4;
        const val = Math.cos(Math.PI * (2 * n + 1) * k / 64);
        cosData[idx] = val;
        cosData[idx + 1] = val;
        cosData[idx + 2] = val;
        cosData[idx + 3] = val;
      }
    }
    this._cosTex = this._createFloatTexture(gl, 32, 32, cosData);

    // Input grayscale texture (32×32, single value in R channel)
    this._inputTex = this._createFloatTexture(gl, 32, 32, null);

    // Intermediate framebuffer for row-wise DCT result
    this._tempTex = this._createFloatTexture(gl, 32, 32, null);
    this._tempFb = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, this._tempFb);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._tempTex, 0);

    // Output framebuffer for column-wise DCT result (only need 8×8 readback)
    this._outputTex = this._createFloatTexture(gl, 32, 32, null);
    this._outputFb = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, this._outputFb);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._outputTex, 0);

    gl.bindFramebuffer(gl.FRAMEBUFFER, null);

    // Compile shaders
    const vsSource = `
      attribute vec2 a_pos;
      varying vec2 v_uv;
      void main() {
        v_uv = a_pos * 0.5 + 0.5;
        gl_Position = vec4(a_pos, 0.0, 1.0);
      }
    `;

    // Row-wise DCT shader: for each output texel (row r, col k),
    // compute X[k] = 2.0 * sum_n(input[r][n] * cos[k][n])
    const rowDctSource = `
      precision highp float;
      uniform sampler2D u_input;
      uniform sampler2D u_cos;
      varying vec2 v_uv;
      void main() {
        float k = floor(v_uv.x * 32.0);
        float r = v_uv.y;
        float sum = 0.0;
        for (int n = 0; n < 32; n++) {
          float nf = (float(n) + 0.5) / 32.0;
          float kf = (k + 0.5) / 32.0;
          float inputVal = texture2D(u_input, vec2(nf, r)).r;
          float cosVal = texture2D(u_cos, vec2(nf, kf)).r;
          sum += inputVal * cosVal;
        }
        gl_FragColor = vec4(2.0 * sum, 0.0, 0.0, 1.0);
      }
    `;

    // Column-wise DCT shader: for each output texel (row k, col c),
    // compute X[k] = 2.0 * sum_n(temp[n][c] * cos[k][n])
    const colDctSource = `
      precision highp float;
      uniform sampler2D u_input;
      uniform sampler2D u_cos;
      varying vec2 v_uv;
      void main() {
        float k = floor(v_uv.y * 32.0);
        float c = v_uv.x;
        float sum = 0.0;
        for (int n = 0; n < 32; n++) {
          float nf = (float(n) + 0.5) / 32.0;
          float kf = (k + 0.5) / 32.0;
          float inputVal = texture2D(u_input, vec2(c, nf)).r;
          float cosVal = texture2D(u_cos, vec2(nf, kf)).r;
          sum += inputVal * cosVal;
        }
        gl_FragColor = vec4(2.0 * sum, 0.0, 0.0, 1.0);
      }
    `;

    this._rowProgram = this._buildProgram(gl, vsSource, rowDctSource);
    this._colProgram = this._buildProgram(gl, vsSource, colDctSource);

    if (!this._rowProgram || !this._colProgram) return;

    // Full-screen quad
    const quadBuf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
    this._quadBuf = quadBuf;

    // Readback buffer (8×8 RGBA floats = 256 floats)
    this._readBuf = new Float32Array(8 * 8 * 4);

    this._ready = true;
  }

  _createFloatTexture(gl, w, h, data) {
    const tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.FLOAT, data);
    return tex;
  }

  _buildProgram(gl, vsSrc, fsSrc) {
    const vs = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vs, vsSrc);
    gl.compileShader(vs);
    if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
      console.warn('[Mysterion] VS compile error:', gl.getShaderInfoLog(vs));
      return null;
    }
    const fs = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fs, fsSrc);
    gl.compileShader(fs);
    if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
      console.warn('[Mysterion] FS compile error:', gl.getShaderInfoLog(fs));
      return null;
    }
    const prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.warn('[Mysterion] Program link error:', gl.getProgramInfoLog(prog));
      return null;
    }
    return prog;
  }

  /**
   * Compute the unnormalized 2D DCT of a 32×32 grayscale array on GPU.
   * Returns Float64Array(64) containing the top-left 8×8 block, or null on failure.
   */
  computeDCT(gray32x32) {
    if (!this._ready) return null;
    const gl = this._gl;

    try {
      // Upload grayscale data as input texture
      const inputData = new Float32Array(32 * 32 * 4);
      for (let i = 0; i < 1024; i++) {
        inputData[i * 4] = gray32x32[i];
        inputData[i * 4 + 1] = 0;
        inputData[i * 4 + 2] = 0;
        inputData[i * 4 + 3] = 1;
      }
      gl.bindTexture(gl.TEXTURE_2D, this._inputTex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 32, 32, 0, gl.RGBA, gl.FLOAT, inputData);

      // Pass 1: Row-wise DCT → tempFb
      gl.bindFramebuffer(gl.FRAMEBUFFER, this._tempFb);
      gl.viewport(0, 0, 32, 32);
      gl.useProgram(this._rowProgram);

      const rPosLoc = gl.getAttribLocation(this._rowProgram, 'a_pos');
      gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuf);
      gl.enableVertexAttribArray(rPosLoc);
      gl.vertexAttribPointer(rPosLoc, 2, gl.FLOAT, false, 0, 0);

      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, this._inputTex);
      gl.uniform1i(gl.getUniformLocation(this._rowProgram, 'u_input'), 0);
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, this._cosTex);
      gl.uniform1i(gl.getUniformLocation(this._rowProgram, 'u_cos'), 1);

      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

      // Pass 2: Column-wise DCT → outputFb
      gl.bindFramebuffer(gl.FRAMEBUFFER, this._outputFb);
      gl.viewport(0, 0, 32, 32);
      gl.useProgram(this._colProgram);

      const cPosLoc = gl.getAttribLocation(this._colProgram, 'a_pos');
      gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuf);
      gl.enableVertexAttribArray(cPosLoc);
      gl.vertexAttribPointer(cPosLoc, 2, gl.FLOAT, false, 0, 0);

      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, this._tempTex);
      gl.uniform1i(gl.getUniformLocation(this._colProgram, 'u_input'), 0);
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, this._cosTex);
      gl.uniform1i(gl.getUniformLocation(this._colProgram, 'u_cos'), 1);

      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

      // Read back only the top-left 8×8 block
      gl.readPixels(0, 0, 8, 8, gl.RGBA, gl.FLOAT, this._readBuf);

      gl.bindFramebuffer(gl.FRAMEBUFFER, null);

      // Extract R channel values into Float64Array(64)
      // WebGL readPixels reads bottom-to-top, so row 0 in the buffer = row 0 of the texture
      // (since our coordinate system puts row 0 at bottom).
      // Actually in our setup: v_uv.y maps row k (frequency index).
      // readPixels row 0 = texture row 0 = k=0 (since v_uv.y=0 → k=0).
      const top = new Float64Array(64);
      for (let r = 0; r < 8; r++) {
        for (let c = 0; c < 8; c++) {
          top[r * 8 + c] = this._readBuf[(r * 8 + c) * 4];
        }
      }
      return top;
    } catch (e) {
      console.warn('[Mysterion] WebGL DCT compute failed:', e.message);
      return null;
    }
  }

  get available() { return this._ready; }
}

// Singleton WebGL engine — initialized once, reused across all frames
let _webglEngine = null;
function _getWebGLEngine() {
  if (_webglEngine === null) {
    _webglEngine = new WebGLPHashEngine();
  }
  return _webglEngine;
}

// ── Compute pHash from a video element at a specific time ─────────────────
// v2.0.1: Parallel clone pool + seek-based frame extraction.
// Creates N hidden clones of the video element and seeks them concurrently
// to process N frames at a time instead of 1.
const PARALLEL_CLONES = 4; // concurrent video decoders

// Seek the video to a specific time, returning a Promise that resolves when seeked.
function _seekVideo(videoEl, time) {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      videoEl.removeEventListener('seeked', onSeeked);
      reject(new Error('Seek timeout'));
    }, 3000);
    const onSeeked = () => {
      clearTimeout(timeout);
      videoEl.removeEventListener('seeked', onSeeked);
      resolve();
    };
    const onError = () => {
      clearTimeout(timeout);
      videoEl.removeEventListener('seeked', onSeeked);
      reject(new Error('Seek failed'));
    };
    videoEl.addEventListener('seeked', onSeeked);
    videoEl.addEventListener('error', onError, { once: true });
    videoEl.currentTime = time;
  });
}

// Create a pool of hidden video clones for parallel seeking.
// Returns a Promise that resolves when all clones are ready (loadeddata).
async function _createClonePool(videoEl, count) {
  const src = videoEl.src || videoEl.currentSrc || (videoEl.querySelector('source') || {}).src;
  const pool = [];
  const readyPromises = [];
  for (let i = 0; i < count; i++) {
    const clone = document.createElement('video');
    clone.crossOrigin = 'anonymous';
    clone.preload = 'auto';
    clone.muted = true;
    clone.playsInline = true;
    clone.style.display = 'none';
    document.body.appendChild(clone);
    const canvas = document.createElement('canvas');
    canvas.width = 32; canvas.height = 32;
    const ctx = canvas.getContext('2d');
    pool.push({ video: clone, canvas, ctx });
    // Wait for clone to buffer enough data before seeking
    readyPromises.push(new Promise((resolve) => {
      const onReady = () => { clone.removeEventListener('canplaythrough', onReady); resolve(); };
      clone.addEventListener('canplaythrough', onReady);
      // Timeout fallback — if canplaythrough never fires, resolve after 8s
      setTimeout(() => { clone.removeEventListener('canplaythrough', onReady); resolve(); }, 8000);
    }));
    clone.src = src;
  }
  await Promise.all(readyPromises);
  console.log(`[Mysterion] Clone pool ready: ${count} clones loaded`);
  return pool;
}

// Destroy the clone pool.
function _destroyClonePool(pool) {
  for (const { video } of pool) {
    video.pause();
    video.removeAttribute('src');
    video.load();
    if (video.parentNode) video.parentNode.removeChild(video);
  }
}

// Seek a clone, draw frame, and return grayscale data.
async function _seekAndGrayscale(clone, seekTime) {
  await _seekVideo(clone.video, seekTime);
  const SIZE = 32;
  clone.ctx.drawImage(clone.video, 0, 0, SIZE, SIZE);
  const imageData = clone.ctx.getImageData(0, 0, SIZE, SIZE);
  const pixels = imageData.data;
  const gray = new Float64Array(SIZE * SIZE);
  for (let i = 0; i < SIZE * SIZE; i++) {
    const r = pixels[i * 4], g = pixels[i * 4 + 1], b = pixels[i * 4 + 2];
    gray[i] = 0.299 * r + 0.587 * g + 0.114 * b;
  }
  return gray;
}

// Process a batch of frames in parallel using the clone pool.
// Returns array of { index, hash, error } in order.
async function _processBatchParallel(pool, entries, fps, duration) {
  const results = await Promise.allSettled(
    entries.map((entry, i) => {
      const clone = pool[i % pool.length];
      const seekTime = entry.frameNumber / fps;
      if (seekTime > duration) return Promise.resolve({ index: i, hash: null, skipped: true });
      return _seekAndGrayscale(clone, seekTime)
        .then(gray => ({ index: i, hash: _hashFromGray(gray), skipped: false }))
        .catch(() => ({ index: i, hash: null, skipped: true }));
    })
  );
  return results.map(r => r.status === 'fulfilled' ? r.value : { index: 0, hash: null, skipped: true });
}

// Draw the current video frame to a 32×32 canvas and return grayscale + ctx.
function _drawAndGrayscale(videoEl, canvas, ctx) {
  const SIZE = 32;
  ctx.drawImage(videoEl, 0, 0, SIZE, SIZE);
  const imageData = ctx.getImageData(0, 0, SIZE, SIZE);
  const pixels = imageData.data;
  const gray = new Float64Array(SIZE * SIZE);
  for (let i = 0; i < SIZE * SIZE; i++) {
    const r = pixels[i * 4], g = pixels[i * 4 + 1], b = pixels[i * 4 + 2];
    gray[i] = 0.299 * r + 0.587 * g + 0.114 * b;
  }
  return gray;
}

// Compute hash from grayscale 32×32 data (WASM > GPU > CPU fallback).
function _hashFromGray(gray) {
  const SIZE = 32;

  // v2.0.0: Try WASM first (fastest path)
  if (_wasmEngine) {
    const wasmHash = _wasmEngine.computePHash32(gray);
    if (wasmHash) return wasmHash;
  }

  let top;

  // Try WebGL GPU second
  const engine = _getWebGLEngine();
  if (engine.available) {
    top = engine.computeDCT(gray);
  }

  if (!top) {
    // CPU fallback — unnormalized 2D DCT
    const dct = _dct2d32_unnorm(gray);
    top = new Float64Array(64);
    for (let r = 0; r < 8; r++)
      for (let c = 0; c < 8; c++)
        top[r * 8 + c] = dct[r * SIZE + c];
  }

  // Median of all 64 values (matches numpy.median)
  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 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;
}

function _computeFramePHash(videoEl, time) {
  return new Promise((resolve, reject) => {
    const SIZE = 32;
    const canvas = document.createElement('canvas');
    canvas.width = SIZE; canvas.height = SIZE;
    const ctx = canvas.getContext('2d');

    if (time !== undefined && time !== null) {
      // Seek to specific time
      const onSeeked = () => {
        videoEl.removeEventListener('seeked', onSeeked);
        try {
          const gray = _drawAndGrayscale(videoEl, canvas, ctx);
          resolve(_hashFromGray(gray));
        } catch(e) { reject(e); }
      };
      videoEl.addEventListener('seeked', onSeeked);
      videoEl.currentTime = time;
    } else {
      // Use current frame
      try {
        const gray = _drawAndGrayscale(videoEl, canvas, ctx);
        resolve(_hashFromGray(gray));
      } catch(e) { reject(e); }
    }
  });
}

function _hashFromCanvas(ctx, SIZE) {
  const imageData = ctx.getImageData(0, 0, SIZE, SIZE);
  const pixels = imageData.data;

  // Grayscale (ITU-R BT.601 — matches app & platform)
  const gray = new Float64Array(SIZE * SIZE);
  for (let i = 0; i < SIZE * SIZE; i++) {
    const r = pixels[i * 4], g = pixels[i * 4 + 1], b = pixels[i * 4 + 2];
    gray[i] = 0.299 * r + 0.587 * g + 0.114 * b;
  }

  return _hashFromGray(gray);
}

// Legacy single-frame pHash (used for initial hover + pHash fallback)
function computeVideoPHash(videoEl) {
  return _computeFramePHash(videoEl, null);
}

// ── Hamming distance between two hex hashes ──────────────────────────────────
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;
}

// ── Fetch stored reference frame pHashes from platform ───────────────────────
const API_URL_FRAMES = 'https://mysterion.io';

async function _fetchReferenceHashes(stegId) {
  const url = `${API_URL_FRAMES}/crypto/frames/${stegId}`;
  console.log('[Mysterion] _fetchReferenceHashes: GET', url);
  try {
    const res = await fetch(url);
    console.log('[Mysterion] _fetchReferenceHashes: response', { status: res.status, ok: res.ok });
    if (!res.ok) return null;
    const data = await res.json();
    // Build ordered list of {frame_number, phash} sorted by frame_number
    const entries = [];
    if (data.frame_phashes && Array.isArray(data.frame_phashes)) {
      for (const fp of data.frame_phashes) {
        entries.push({ frameNumber: fp.frame_number, phash: fp.phash });
      }
      entries.sort((a, b) => a.frameNumber - b.frameNumber);
    }
    return {
      entries,
      totalFrameCount: data.total_frame_count || 0,
      keyframeCount: data.keyframe_count || 0,
      rollingHash: data.rolling_hash || null,
    };
  } catch (e) {
    console.warn('[Mysterion] Failed to fetch reference hashes:', e.message);
    return null;
  }
}

// ── Video keyframe sampling scan ─────────────────────────────────────────────
// v2.0.1: Parallel clone pool — seeks N frames concurrently on separate
// video elements. ~4x faster than sequential pipelined seek.
async function _sampleScanVideo(videoEl, refData) {
  const duration = videoEl.duration;
  if (!duration || !isFinite(duration) || duration <= 0) {
    return { passed: null, framesChecked: 0, totalFrames: 0, alteredAt: null, avgMsPerFrame: 0 };
  }
  if (!refData || refData.entries.length === 0) {
    return { passed: null, framesChecked: 0, totalFrames: 0, alteredAt: null, avgMsPerFrame: 0 };
  }

  const fps = refData.totalFrameCount > 0
    ? refData.totalFrameCount / duration
    : 30;

  // v5.5.107 (HOTFIX 17): skip frame 0 on fallback path. Frame 0 is often a
  // fade-in / near-black frame whose pHash is extremely sensitive to decoder
  // differences (Firefox HTMLVideoElement vs ffmpeg at sign time), producing
  // false "altered" results on unaltered video.
  const allEntries = refData.entries.filter(e => e.frameNumber > 0);
  const totalStored = allEntries.length;
  let toCheck;
  if (totalStored <= MAX_SAMPLE_FRAMES) {
    toCheck = allEntries;
  } else {
    toCheck = [];
    for (let i = 0; i < MAX_SAMPLE_FRAMES; i++) {
      const idx = Math.round(i * (totalStored - 1) / (MAX_SAMPLE_FRAMES - 1));
      toCheck.push(allEntries[idx]);
    }
  }

  // v5.5.107 (HOTFIX 17): threshold 3 → 10 on fallback sample path.
  // Threshold 3 was calibrated for client-side photo-pHash round-trips. The
  // video fallback path compares Firefox/Chromium-decoded frames against
  // ffmpeg-decoded pHashes from sign time — colorspace, chroma subsampling,
  // and seek granularity introduce 5–8 bits of drift on unaltered content.
  const HAMMING_THRESHOLD = 10;
  const scanStart = performance.now();
  let framesChecked = 0;

  // Create parallel clone pool (await readiness)
  const pool = await _createClonePool(videoEl, PARALLEL_CLONES);
  console.log(`[Mysterion] Sample scan: ${toCheck.length} frames, ${PARALLEL_CLONES} parallel clones`);

  try {
    // Process in batches of PARALLEL_CLONES
    for (let batchStart = 0; batchStart < toCheck.length; batchStart += PARALLEL_CLONES) {
      const batchEntries = toCheck.slice(batchStart, batchStart + PARALLEL_CLONES);
      const batchResults = await _processBatchParallel(pool, batchEntries, fps, duration);

      for (const result of batchResults) {
        if (result.skipped) continue;
        framesChecked++;
        const entry = batchEntries[result.index];
        const dist = _hammingHex(result.hash, entry.phash);
        // v5.5.107: log every distance so we can see drift distribution.
        console.log(`[Mysterion] sample frame=${entry.frameNumber} hamming=${dist} (threshold=${HAMMING_THRESHOLD})`);
        if (dist > HAMMING_THRESHOLD) {
          const seekTime = entry.frameNumber / fps;
          const elapsed = performance.now() - scanStart;
          console.log(`[Mysterion] Alteration at frame ${entry.frameNumber} (${seekTime.toFixed(1)}s), ${elapsed.toFixed(0)}ms elapsed`);
          _destroyClonePool(pool);
          return {
            passed: false,
            framesChecked,
            totalFrames: toCheck.length,
            alteredAt: seekTime,
            avgMsPerFrame: elapsed / framesChecked,
          };
        }
      }
    }

    const elapsed = performance.now() - scanStart;
    console.log(`[Mysterion] Sample scan complete: ${framesChecked} frames in ${elapsed.toFixed(0)}ms (${(elapsed / Math.max(framesChecked, 1)).toFixed(1)}ms/frame)`);
    _destroyClonePool(pool);
    return {
      passed: true,
      framesChecked,
      totalFrames: totalStored,
      alteredAt: null,
      avgMsPerFrame: framesChecked > 0 ? elapsed / framesChecked : FRAME_PROCESS_MS,
    };
  } catch (e) {
    console.warn('[Mysterion] Sample scan error:', e.message);
    _destroyClonePool(pool);
    return {
      passed: null,
      framesChecked,
      totalFrames: toCheck.length,
      alteredAt: null,
      avgMsPerFrame: FRAME_PROCESS_MS,
    };
  }
}

// ── Full video scan (all stored reference frames) ───────────────────────────
// Iterates over ALL stored reference frame positions.
// Sends progress updates to badge. Stops on first alteration found.
// Checks _isHovering each iteration — stops gracefully when mouse leaves.
// _fullScanVideo removed in v1.5.2 — replaced by _resumeFullScan
// which supports pause/resume on hover-away/re-hover.

// ── Hover verify: delegate to background service worker ───────
function doVerify(el) {
  const isVideo = el.tagName === 'VIDEO';

  imgState.set(el, 'checking');
  renderBadge(el, 'checking', null);
  showBadge(el);

  if (isVideo) {
    // Video: get source URL and compute pHash as fallback
    const videoUrl = _getVideoSourceUrl(el);
    const videoId = videoUrl || el.id || 'video';
    el._mysterionVideoId = videoId;
    const _verifyStart = performance.now();

    // Compute pHash as fallback (for re-encoded videos like YouTube)
    computeVideoPHash(el)
      .then(phash => {
        console.log(`[Mysterion] Initial pHash computed in ${(performance.now() - _verifyStart).toFixed(0)}ms`);
        // Store the reference hash for later sampling/full scan
        el._mysterionRefHash = phash;

        // Send video URL + pHash fallback to background.
        // Background will try direct fetch + container metadata first,
        // fall back to pHash if that fails.
        chrome.runtime.sendMessage({
          type: 'VERIFY_VIDEO_URL',
          videoUrl: videoUrl,
          videoId: videoId,
          phash: phash,
        });
      })
      .catch(() => {
        // pHash failed — still try direct fetch
        if (videoUrl) {
          chrome.runtime.sendMessage({
            type: 'VERIFY_VIDEO_URL',
            videoUrl: videoUrl,
            videoId: videoId,
            phash: null,
          });
        } else {
          renderBadge(el, 'error', null);
          imgState.set(el, 'done');
          showBadge(el);
        }
      });
  } else {
    // Image: use URL-based verification as before
    const mediaUrl = el.src || el.currentSrc;
    if (!mediaUrl || !mediaUrl.startsWith('http')) return;
    chrome.runtime.sendMessage({ type: 'VERIFY_URL', imageUrl: mediaUrl });
  }
}

// Get the best source URL for a video element
function _getVideoSourceUrl(vid) {
  if (vid.src && vid.src.startsWith('http')) return vid.src;
  if (vid.currentSrc && vid.currentSrc.startsWith('http')) return vid.currentSrc;
  const source = vid.querySelector('source');
  if (source && source.src && source.src.startsWith('http')) return source.src;
  return null;
}

// ── Attach hover to an image ──────────────────────────────────
function attachHover(img) {
  if (img._mysterionHoverBound) return;
  if (img.naturalWidth < 80 || img.naturalHeight < 80) return;
  img._mysterionHoverBound = true;
  imgState.set(img, 'idle');

  img.addEventListener('mouseenter', () => {
    if (!_modeAllowsHover()) return;  // patch5a
    const state = imgState.get(img);
    if (state === 'done')     { showBadge(img); return; }
    if (state === 'checking') { return; }
    doVerify(img);
  });

  img.addEventListener('mouseleave', (e) => {
    if (img._mysterionBadge && img._mysterionBadge.contains(e.relatedTarget)) return;
    hideBadge(img);
  });
}

// ── Attach hover to a video element ──────────────────────────
function attachVideoHover(vid) {
  if (vid._mysterionHoverBound) return;
  // Skip tiny/invisible videos (e.g. tracking pixels)
  if (vid.offsetWidth < 80 && vid.offsetWidth !== 0) return;
  vid._mysterionHoverBound = true;
  imgState.set(vid, 'idle');
  _isHovering.set(vid, false);

  vid.addEventListener('mouseenter', () => {
    if (!_modeAllowsHover()) return;  // patch5a
    _isHovering.set(vid, true);
    showBadge(vid);
    const state = imgState.get(vid);
    if (state === 'checking' || state === 'scanning') { return; }
    // If sample is done but full scan hasn't run or was paused, resume it
    if (state === 'sampled' && vid._mysterionRefData && vid._mysterionStegId) {
      _resumeFullScan(vid);
      return;
    }
    if (state === 'done') { return; }
    doVerify(vid);
  });

  vid.addEventListener('mouseleave', (e) => {
    // Don't count moving onto the badge as leaving
    if (vid._mysterionBadge && vid._mysterionBadge.contains(e.relatedTarget)) return;
    _isHovering.set(vid, false);
    // Keep badge visible during Deep Scan
    if (!vid._mysterionDeepScanRequested) {
      hideBadge(vid);
    }
  });
}

function attachAll() {
  document.querySelectorAll('img').forEach(img => {
    if (img.complete && img.naturalWidth > 0) attachHover(img);
    else img.addEventListener('load', () => attachHover(img), { once: true });
  });
  document.querySelectorAll('video').forEach(vid => {
    // Videos are ready once they have a src
    if (vid.src || vid.currentSrc || vid.querySelector('source')) attachVideoHover(vid);
    else vid.addEventListener('loadedmetadata', () => attachVideoHover(vid), { once: true });
  });
  document.querySelectorAll('a').forEach(a => attachPdfHover(a));
}


// ══════════════════════════════════════════════════════════════════════════
//  Mysterion scan-mode gating (patch5a)
//  Modes: 'disable' | 'click' | 'hover' | 'all'  (default 'hover')
// ══════════════════════════════════════════════════════════════════════════
const _MYST_MODES = ['disable', 'click', 'hover', 'all'];
let _mysterionMode = 'click';  // patch15: default On right-click
const _mystApi = (typeof browser !== 'undefined' && browser.storage) ? browser : chrome;

function _modeAllowsHover() { return _mysterionMode === 'hover' || _mysterionMode === 'all'; }
function _modeAllowsClick() { return _mysterionMode === 'click'; }
function _modeIsAll()       { return _mysterionMode === 'all'; }
function _modeIsDisabled()  { return _mysterionMode === 'disable'; }

// Bind a click->verify handler once per element (used in 'click' mode). The
// handler itself re-checks the mode so a live switch away from 'click' makes
// it inert without unbinding.
function _bindClickVerify(el) {
  // patch6a: left-click hijack removed. 'click' mode is served by the
  // right-click "Verify with Mysterion" context menu (background.js),
  // which fires in every mode and does not collide with page zoom /
  // lightbox / link clicks. This is now a no-op.
}

// IntersectionObserver for 'all' mode: verify each media element once as it
// becomes visible. Created lazily; disconnected when leaving 'all' mode.
let _mystVisObserver = null;
function _startAllScan() {
  if (_mystVisObserver) return;
  _mystVisObserver = new IntersectionObserver((entries) => {
    if (!_modeIsAll()) return;
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      const el = e.target;
      const state = imgState.get(el);
      if (state === 'checking' || state === 'done' || state === 'sampled') continue;
      // Respect the same size guards the hover attachers use.
      if (el.tagName === 'IMG' && (el.naturalWidth < 80 || el.naturalHeight < 80)) continue;
      doVerify(el);
    }
  }, { threshold: 0.1 });
  document.querySelectorAll('img, video').forEach((el) => {
    try { _mystVisObserver.observe(el); } catch (_) {}
  });
}
function _stopAllScan() {
  if (_mystVisObserver) { try { _mystVisObserver.disconnect(); } catch (_) {} _mystVisObserver = null; }
}
// Called by the MutationObserver so newly-added nodes join the 'all' scan.
function _observeForAll(el) {
  if (_mystVisObserver && (el.tagName === 'IMG' || el.tagName === 'VIDEO')) {
    try { _mystVisObserver.observe(el); } catch (_) {}
  }
}

function _applyMode(newMode) {
  if (!_MYST_MODES.includes(newMode)) newMode = 'click';  // patch15
  _mysterionMode = newMode;
  if (_modeIsAll()) _startAllScan(); else _stopAllScan();
  if (_modeIsDisabled()) {
    // Tear down any visible badges; leave listeners (inert via mode checks).
    document.querySelectorAll('img, video, a').forEach((el) => {
      if (el._mysterionBadge) { try { hideBadge(el); } catch (_) {} }
    });
  }
  console.log('[Mysterion] scan mode →', _mysterionMode);
}

// Hydrate from storage, then keep live.
try {
  _mystApi.storage.local.get('mysterionScanMode', (r) => {
    const stored = r && r.mysterionScanMode;
    _applyMode(_MYST_MODES.includes(stored) ? stored : 'click');
  });
  _mystApi.storage.onChanged.addListener((changes, area) => {
    if (area === 'local' && changes.mysterionScanMode) {
      _applyMode(changes.mysterionScanMode.newValue);
    }
  });
} catch (e) {
  console.warn('[Mysterion] storage unavailable, defaulting to hover:', e && e.message);
}

attachAll();

new MutationObserver(muts => {
  for (const m of muts) for (const node of m.addedNodes) {
    if (node.nodeName === 'IMG') {
      node.addEventListener('load', () => attachHover(node), { once: true });
      if (node.complete) attachHover(node);
      _observeForAll(node);  // patch5a
    } else if (node.nodeName === 'VIDEO') {
      if (node.src || node.currentSrc) attachVideoHover(node);
      else node.addEventListener('loadedmetadata', () => attachVideoHover(node), { once: true });
    } else if (node.querySelectorAll) {
      node.querySelectorAll('img').forEach(img => {
        img.addEventListener('load', () => attachHover(img), { once: true });
        if (img.complete) attachHover(img);
      });
      node.querySelectorAll('video').forEach(vid => {
        if (vid.src || vid.currentSrc) attachVideoHover(vid);
        else vid.addEventListener('loadedmetadata', () => attachVideoHover(vid), { once: true });
      });
      node.querySelectorAll('a').forEach(a => attachPdfHover(a));
    }
  }
}).observe(document.body, { childList: true, subtree: true });

// ── Find video element by stored videoId ──────────────────────
function findVideoById(videoId) {
  for (const vid of document.querySelectorAll('video')) {
    if (vid._mysterionVideoId === videoId) return vid;
  }
  return null;
}

// ── Video scan flow after initial verify result ──────────────────────────────
// Progressive hover-to-scan: hover → sample scan → if passes + still
// hovering → full scan proceeds automatically. No popup, no settings.
// Progressive hover-to-scan flow:
//   1. Hover → initial verify (pHash/container) → if verified:
//   2. Sample scan (120 keyframes) — ALWAYS runs to completion even if
//      mouse leaves. Shows result when done.
//   3. If still hovering after sample → auto-start full scan.
//      If not hovering, state = 'sampled'. Re-hover resumes full scan.
//   4. Full scan stops when mouse leaves, resumes on re-hover.
//      State = 'sampled' with _mysterionFullScanIndex tracking progress.
//   5. Full scan complete → state = 'done'.
// ── Rolling hash computation (client-side) ──────────────────────────────────
// Must match the server-side _compute_rolling_hash in sign.py:
// BLAKE3 of concatenated pHashes sorted by frame_number.
async function _computeRollingHash(frameHashes) {
  // frameHashes: array of hex pHash strings, already in frame_number order
  const concatenated = frameHashes.join('');
  const encoder = new TextEncoder();
  const data = encoder.encode(concatenated);
  // Use BLAKE3 via background script message if available, else SHA-256 fallback
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

// ── Report alteration to platform ───────────────────────────────────────────
// Fire-and-forget: notifies the creator that their video was detected as altered.
async function _reportAlteration(stegId, clientRollingHash, sourceUrl) {
  try {
    // Generate anonymous reporter fingerprint (no PII)
    const fp = await _reporterFingerprint();
    const url = sourceUrl || window.location.href;
    // v5.5.108: video flow — include media_type so creator app renders
    // Video badge + routes Investigate to Forensic Analysis (frame-level).
    await fetch(`${API_URL_FRAMES}/alerts/alteration-report`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        steg_id: stegId,
        source_url: url,
        client_rolling_hash: clientRollingHash || 'none',
        reporter_fingerprint: fp,
        media_type: 'video',
      }),
    });
    console.log('[Mysterion] Alteration reported to platform for steg_id:', stegId.slice(0, 8));
  } catch (e) {
    console.warn('[Mysterion] Failed to report alteration (non-fatal):', e.message);
  }
}

async function _reporterFingerprint() {
  try {
    const raw = [navigator.userAgent, screen.width + 'x' + screen.height,
      Intl.DateTimeFormat().resolvedOptions().timeZone].join('|');
    const data = new TextEncoder().encode(raw);
    const hash = await crypto.subtle.digest('SHA-256', data); // fingerprint only (not content hash)
    return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
  } catch {
    return 'anonymous';
  }
}

// ── Video scan flow ─────────────────────────────────────────────────────────
// v2.0.1: Rolling hash for instant pass/fail. No per-frame comparison needed
// for viewers. If rolling hash mismatches, report alteration to platform.
async function _runVideoScanFlow(videoEl, initialResult, stegId) {
  console.log('[Mysterion] _runVideoScanFlow entered', { initialResult, stegId, scanRunning: videoEl._mysterionScanRunning });
  if (initialResult !== 'verified') { console.log('[Mysterion] _runVideoScanFlow: early-exit initialResult!=verified'); return; }
  if (!stegId) { console.log('[Mysterion] _runVideoScanFlow: early-exit no stegId'); return; }

  // Guard: prevent double trigger from pHash + container results
  if (videoEl._mysterionScanRunning) { console.log('[Mysterion] _runVideoScanFlow: early-exit already running (lock held)'); return; }
  videoEl._mysterionScanRunning = true;

  try {
  // Step 0: Fetch stored per-frame reference hashes + rolling_hash
  renderBadge(videoEl, 'checking', { progress: 'Loading reference data...' });
  showBadge(videoEl);

  console.log('[Mysterion] _runVideoScanFlow: fetching reference hashes', { url: `${API_URL_FRAMES}/crypto/frames/${stegId}` });
  const refData = await _fetchReferenceHashes(stegId);
  console.log('[Mysterion] _runVideoScanFlow: refData received', { hasData: !!refData, entries: refData ? refData.entries.length : 0, rollingHash: refData ? !!refData.rollingHash : false });
  if (!refData || refData.entries.length === 0) {
    renderBadge(videoEl, 'verified', {
      content: 'Verified (no frame hashes stored for deep scan)',
    });
    imgState.set(videoEl, 'done');
    showBadge(videoEl);
    return;
  }

  const hasRollingHash = refData.rollingHash && refData.rollingHash.length === 64;

  if (hasRollingHash) {
    // ── Per-frame Hamming comparison ─────────────────────────────────────────
    // v5.5.0: Try WebCodecs pipeline first (4-8x faster),
    // fall back to blob-buffered HTMLVideoElement seeking.
    // v5.5.107 (HOTFIX 17): raised 3 → 10 — browser decoder drift (colorspace,
    // chroma subsampling, seek granularity) introduces 5–8 bits vs ffmpeg
    // sign-time pHashes. 10 passes unaltered video, still catches real edits.
    const HAMMING_THRESHOLD = 10;
    renderBadge(videoEl, 'checking', { progress: 'Buffering video...' });
    showBadge(videoEl);

    const scanStart = performance.now();
    const fps = refData.totalFrameCount > 0
      ? refData.totalFrameCount / videoEl.duration
      : 30;
    const duration = videoEl.duration;

    const videoSrc = videoEl.src || videoEl.currentSrc || (videoEl.querySelector('source') || {}).src;
    const allHashes = [];
    let usedWebCodecs = false;

    // ── WebCodecs path (v5.5.0) ──────────────────────────────────────────
    if (_hasWebCodecs()) {
      try {
        renderBadge(videoEl, 'checking', { progress: 'Decoding keyframes (hardware)...' });
        showBadge(videoEl);

        const frames = await _webCodecsPipeline(videoSrc, (stage, current, total) => {
          if (stage === 'fetch') {
            renderBadge(videoEl, 'checking', { progress: 'Fetching video bytes...' });
          } else if (stage === 'demux') {
            renderBadge(videoEl, 'checking', { progress: 'Demuxing keyframes...' });
          } else if (stage === 'decode') {
            renderBadge(videoEl, 'checking', { progress: `Decoding keyframes... ${current}/${total}` });
          }
          showBadge(videoEl);
        });

        console.log(`[Mysterion WebCodecs] Got ${frames.length} decoded frames, matching against ${refData.entries.length} reference entries`);

        // Match decoded frames to reference entries by closest timestamp
        for (let i = 0; i < refData.entries.length; i++) {
          const entry = refData.entries[i];
          const seekTime = entry.frameNumber / fps;
          if (seekTime > duration) continue;

          // Find closest decoded frame
          let closest = null;
          let closestDist = Infinity;
          for (const frame of frames) {
            const d = Math.abs(frame.timestamp - seekTime);
            if (d < closestDist) { closestDist = d; closest = frame; }
          }

          if (closest && closestDist < 1.0) {
            allHashes.push({ index: i, hash: _hashFromGray(closest.gray) });
          }

          if (i % 10 === 0) {
            renderBadge(videoEl, 'checking', { progress: `Hashing keyframes... ${i+1}/${refData.entries.length}` });
            showBadge(videoEl);
          }
        }

        usedWebCodecs = true;
        console.log(`[Mysterion WebCodecs] Matched ${allHashes.length}/${refData.entries.length} reference entries in ${((performance.now() - scanStart)/1000).toFixed(1)}s`);
      } catch (e) {
        console.warn(`[Mysterion] WebCodecs pipeline failed, falling back to legacy: ${e.message}`);
        allHashes.length = 0; // reset
        usedWebCodecs = false;
      }
    }

    // ── Legacy blob-buffered seek path (fallback) ─────────────────────────
    if (!usedWebCodecs) {
      let blobUrl = null;
      let scanVideo = videoEl;
      try {
        const res = await fetch(videoSrc);
        const blob = await res.blob();
        blobUrl = URL.createObjectURL(blob);
        scanVideo = document.createElement('video');
        scanVideo.muted = true;
        scanVideo.playsInline = true;
        scanVideo.preload = 'auto';
        scanVideo.style.display = 'none';
        document.body.appendChild(scanVideo);
        scanVideo.src = blobUrl;
        await new Promise((resolve) => {
          const onReady = () => { scanVideo.removeEventListener('canplaythrough', onReady); resolve(); };
          scanVideo.addEventListener('canplaythrough', onReady);
          setTimeout(resolve, 10000);
        });
        console.log(`[Mysterion] Video blob buffered: ${(blob.size / 1048576).toFixed(1)} MB in ${((performance.now() - scanStart) / 1000).toFixed(1)}s`);
      } catch (e) {
        console.warn(`[Mysterion] Blob fetch failed, using original element: ${e.message}`);
        scanVideo = videoEl;
      }

      renderBadge(videoEl, 'checking', { progress: 'Verifying keyframes...' });
      showBadge(videoEl);

      const canvas = document.createElement('canvas');
      canvas.width = 32; canvas.height = 32;
      const ctx = canvas.getContext('2d');

      console.log(`[Mysterion] Legacy sequential frame verify: ${refData.entries.length} frames`);

      for (let i = 0; i < refData.entries.length; i++) {
        const entry = refData.entries[i];
        const seekTime = entry.frameNumber / fps;
        if (seekTime > duration) continue;
        try {
          await _seekVideo(scanVideo, seekTime);
          ctx.drawImage(scanVideo, 0, 0, 32, 32);
          const imageData = ctx.getImageData(0, 0, 32, 32);
          const pixels = imageData.data;
          const gray = new Float64Array(32 * 32);
          for (let p = 0; p < 32 * 32; p++) {
            gray[p] = 0.299 * pixels[p*4] + 0.587 * pixels[p*4+1] + 0.114 * pixels[p*4+2];
          }
          allHashes.push({ index: i, hash: _hashFromGray(gray) });
        } catch (e) {
          console.warn(`[Mysterion] Frame ${entry.frameNumber} seek failed: ${e.message}`);
        }

        if (i % 5 === 0) {
          renderBadge(videoEl, 'checking', { progress: `Verifying keyframes... ${i+1}/${refData.entries.length}` });
          showBadge(videoEl);
        }
      }

      // Cleanup blob video
      if (scanVideo !== videoEl) {
        scanVideo.pause();
        scanVideo.removeAttribute('src');
        scanVideo.load();
        if (scanVideo.parentNode) scanVideo.parentNode.removeChild(scanVideo);
      }
      if (blobUrl) URL.revokeObjectURL(blobUrl);
    }

    const elapsed = performance.now() - scanStart;
    const pipeline = usedWebCodecs ? 'WebCodecs' : 'legacy seek';

    // Per-frame Hamming comparison against reference
    let alteredFrames = 0;
    let matchedFrames = 0;
    let skippedFrames = 0;
    for (const entry of allHashes) {
      const ref = refData.entries[entry.index];
      if (!ref || !ref.phash) { skippedFrames++; continue; }
      const dist = _hammingHex(entry.hash, ref.phash);
      if (dist <= HAMMING_THRESHOLD) {
        matchedFrames++;
      } else {
        alteredFrames++;
        console.log(`[Mysterion] Frame ${ref.frameNumber}: hamming=${dist} (threshold=${HAMMING_THRESHOLD})`);
      }
    }
    skippedFrames += refData.entries.length - allHashes.length;

    console.log(`[Mysterion] Frame verification (${pipeline}) in ${elapsed.toFixed(0)}ms: ${matchedFrames} matched, ${alteredFrames} altered, ${skippedFrames} skipped of ${refData.entries.length}`);

    if (alteredFrames === 0 && matchedFrames > 0) {
      renderBadge(videoEl, 'verified', {
        content: `All ${matchedFrames} keyframes verified (${(elapsed/1000).toFixed(1)}s)`,
      });
      imgState.set(videoEl, 'done');
      videoEl._mysterionDeepScanRequested = false;
      showBadge(videoEl);
      _autoHideBadge(videoEl);
    } else if (alteredFrames > 0) {
      renderBadge(videoEl, 'altered', {
        content: `Alteration detected (${alteredFrames}/${refData.entries.length} frames differ)`,
      });
      imgState.set(videoEl, 'done');
      videoEl._mysterionDeepScanRequested = false;
      showBadge(videoEl);
      const clientRollingHash = await _computeRollingHash(allHashes.map(h => h.hash));
      _reportAlteration(stegId, clientRollingHash);
    } else {
      renderBadge(videoEl, 'verified', {
        content: `Verified (metadata only — could not extract frames for deep scan)`,
      });
      imgState.set(videoEl, 'done');
      videoEl._mysterionDeepScanRequested = false;
      showBadge(videoEl);
      _autoHideBadge(videoEl);
    }
  } else {
    // ── Fallback: legacy per-frame comparison (no rolling_hash stored) ──
    renderBadge(videoEl, 'checking', { progress: 'Sampling keyframes...' });
    showBadge(videoEl);

    const sampleResult = await _sampleScanVideo(videoEl, refData);

    if (sampleResult.passed === false) {
      renderBadge(videoEl, 'altered', {
        content: `Alteration detected (${sampleResult.framesChecked}/${sampleResult.totalFrames} frames checked)`,
      });
      imgState.set(videoEl, 'done');
      showBadge(videoEl);
      _reportAlteration(stegId, null);
      return;
    }

    if (sampleResult.passed === null) return;

    const totalStoredFrames = refData.entries.length;

    if (totalStoredFrames <= sampleResult.framesChecked) {
      renderBadge(videoEl, 'verified', {
        content: `All ${sampleResult.framesChecked} keyframes verified`,
      });
      imgState.set(videoEl, 'done');
      showBadge(videoEl);
      return;
    }

    renderBadge(videoEl, 'verified', {
      content: `Verified (${sampleResult.framesChecked}/${totalStoredFrames} keyframes sampled)`,
    });
    showBadge(videoEl);

    videoEl._mysterionRefData = refData;
    videoEl._mysterionStegId = stegId;
    videoEl._mysterionFullScanIndex = 0;

    if (_isHovering.get(videoEl) || videoEl._mysterionDeepScanRequested) {
      await _resumeFullScan(videoEl);
    } else {
      imgState.set(videoEl, 'sampled');
    }
  }
  } catch (err) {
    console.error('[Mysterion] _runVideoScanFlow threw:', err);
  } finally {
    videoEl._mysterionScanRunning = false;
    console.log('[Mysterion] _runVideoScanFlow: finally — scan lock released');
  }
}

// Resume (or start) full scan from where we left off.
// Runs while mouse hovers; pauses when mouse leaves.
// Re-hover calls this again to continue.
// v5.5.0: WebCodecs pipeline for hardware-accelerated decode,
//         falls back to v2.0.1 parallel clone pool.
async function _resumeFullScan(videoEl) {
  const refData = videoEl._mysterionRefData;
  if (!refData) return;

  const duration = videoEl.duration;
  if (!duration || !isFinite(duration) || duration <= 0) return;

  const fps = refData.totalFrameCount > 0
    ? refData.totalFrameCount / duration
    : 30;

  const allEntries = refData.entries;
  const totalFrames = allEntries.length;
  // v5.5.107 (HOTFIX 17): raised 3 → 10 for decoder drift tolerance.
  const HAMMING_THRESHOLD = 10;
  let i = videoEl._mysterionFullScanIndex || 0;

  imgState.set(videoEl, 'scanning');
  renderBadge(videoEl, 'scanning', {
    progress: `Deep scan — ${i}/${totalFrames} reference frames...`,
  });
  showBadge(videoEl);

  const scanStart = performance.now();

  // ── v5.5.0: Try WebCodecs for full scan (all-at-once decode, no seeking) ──
  if (i === 0 && _hasWebCodecs()) {
    try {
      const videoSrc = videoEl.src || videoEl.currentSrc || (videoEl.querySelector('source') || {}).src;
      const frames = await _webCodecsPipeline(videoSrc, (stage, current, total) => {
        if (stage === 'decode') {
          renderBadge(videoEl, 'scanning', { progress: `Deep scan — decoding ${current}/${total} keyframes...` });
          showBadge(videoEl);
        }
      });

      console.log(`[Mysterion WebCodecs] Full scan: ${frames.length} decoded frames`);

      for (let j = 0; j < totalFrames; j++) {
        // Pause when mouse leaves (unless Deep Scan was explicitly requested)
        if (!_isHovering.get(videoEl) && !videoEl._mysterionDeepScanRequested) {
          videoEl._mysterionFullScanIndex = j;
          renderBadge(videoEl, 'verified', {
            content: `Verified (${j}/${totalFrames} frames scanned — hover to continue)`,
          });
          imgState.set(videoEl, 'sampled');
          return;
        }

        const entry = allEntries[j];
        const seekTime = entry.frameNumber / fps;
        if (seekTime > duration) continue;

        // Find closest decoded frame
        let closest = null;
        let closestDist = Infinity;
        for (const frame of frames) {
          const d = Math.abs(frame.timestamp - seekTime);
          if (d < closestDist) { closestDist = d; closest = frame; }
        }

        if (closest && closestDist < 1.0) {
          const hash = _hashFromGray(closest.gray);
          const dist = _hammingHex(hash, entry.phash);

          if (dist > HAMMING_THRESHOLD) {
            const mins = Math.floor(seekTime / 60);
            const secs = Math.round(seekTime % 60);
            console.log(`[Mysterion WebCodecs] Full scan: alteration at frame ${entry.frameNumber}`);
            renderBadge(videoEl, 'altered', {
              content: `Alteration detected at ${mins}:${secs.toString().padStart(2,'0')} (full scan — ${j + 1}/${totalFrames} frames)`,
            });
            imgState.set(videoEl, 'done');
            videoEl._mysterionDeepScanRequested = false;
            showBadge(videoEl);
            _reportAlteration(stegId, null);
            return;
          }
        }

        if (j % 10 === 0) {
          renderBadge(videoEl, 'scanning', { progress: `Deep scan — ${j}/${totalFrames} reference frames...` });
          showBadge(videoEl);
        }
      }

      // All frames passed
      const elapsed = performance.now() - scanStart;
      console.log(`[Mysterion WebCodecs] Full scan complete: ${totalFrames} frames in ${elapsed.toFixed(0)}ms`);
      renderBadge(videoEl, 'verified', {
        content: `All ${totalFrames} keyframes verified (full scan)`,
      });
      imgState.set(videoEl, 'done');
      videoEl._mysterionDeepScanRequested = false;
      showBadge(videoEl);
      _autoHideBadge(videoEl);
      return;
    } catch (e) {
      console.warn(`[Mysterion] WebCodecs full scan failed, falling back to clone pool: ${e.message}`);
      // Fall through to legacy path
    }
  }

  // ── Legacy clone pool path ───────────────────────────────────────────
  const pool = await _createClonePool(videoEl, PARALLEL_CLONES);
  console.log(`[Mysterion] Full scan: resuming at ${i}/${totalFrames}, ${PARALLEL_CLONES} parallel clones`);

  try {
    while (i < totalFrames) {
      // Pause when mouse leaves (unless Deep Scan was explicitly requested)
      if (!_isHovering.get(videoEl) && !videoEl._mysterionDeepScanRequested) {
        videoEl._mysterionFullScanIndex = i;
        renderBadge(videoEl, 'verified', {
          content: `Verified (${i}/${totalFrames} frames scanned — hover to continue)`,
        });
        imgState.set(videoEl, 'sampled');
        _destroyClonePool(pool);
        return;
      }

      // Build batch from current position
      const batchEntries = allEntries.slice(i, i + PARALLEL_CLONES);
      const batchResults = await _processBatchParallel(pool, batchEntries, fps, duration);

      for (const result of batchResults) {
        if (result.skipped) continue;
        const entry = batchEntries[result.index];
        const dist = _hammingHex(result.hash, entry.phash);

        if (dist > HAMMING_THRESHOLD) {
          const seekTime = entry.frameNumber / fps;
          const mins = Math.floor(seekTime / 60);
          const secs = Math.round(seekTime % 60);
          console.log(`[Mysterion] Full scan: alteration at frame ${entry.frameNumber}`);
          renderBadge(videoEl, 'altered', {
            content: `Alteration detected at ${mins}:${secs.toString().padStart(2,'0')} (full scan — ${i + result.index + 1}/${totalFrames} frames)`,
          });
          imgState.set(videoEl, 'done');
          videoEl._mysterionDeepScanRequested = false;
          showBadge(videoEl);
          _reportAlteration(stegId, null);
          _destroyClonePool(pool);
          return;
        }
      }

      i += batchEntries.length;

      // Update progress every batch
      renderBadge(videoEl, 'scanning', {
        progress: `Deep scan — ${i}/${totalFrames} reference frames...`,
      });
      showBadge(videoEl);
    }

    const elapsed = performance.now() - scanStart;
    console.log(`[Mysterion] Full scan complete: ${totalFrames} frames in ${elapsed.toFixed(0)}ms`);
    renderBadge(videoEl, 'verified', {
      content: `All ${totalFrames} keyframes verified (full scan)`,
    });
    imgState.set(videoEl, 'done');
    videoEl._mysterionDeepScanRequested = false;
    showBadge(videoEl);
    _autoHideBadge(videoEl);
  } finally {
    _destroyClonePool(pool);
  }
}


// ── Messages from background.js ──────────────────────────────
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'PDF_VERDICT') {
    const a = findPdfLink(msg.pdfUrl);
    if (a) { _renderPdfVerdict(a, msg.verdict || {}); }
    // patch29_pdf_floating: no anchor (e.g. right-click path) -> floating card
    else { _renderPdfVerdictFloating(msg.verdict || {}); }
    return;
  }
  if (msg.type === 'VERIFY_START') {
    const el = findMediaElement(msg.imageUrl);
    if (el) { renderBadge(el, 'checking', null); showBadge(el); }
  }

  if (msg.type === 'VERIFY_RESULT') {
    const el = findMediaElement(msg.imageUrl);
    if (!el) return;
    let creatorLine = null, signedLine = null, sourceDomain = null, methodLine = null, contentLine = null;
    // v5.5.425: hide raw creator_id UUID when display name unavailable.
    // Upstream fix is in mysterion-platform to add creator_name to the
    // fast-path verify response; this guard is the extension-side fallback.
    const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
    if (msg.creatorName && !_UUID_RE.test(msg.creatorName)) {
      const prefix = 'By ';
      creatorLine = prefix + (msg.orgName ? `${msg.creatorName} · ${msg.orgName}` : msg.creatorName);
    }
    if (msg.signedAt) {
      const d = new Date(msg.signedAt);
      signedLine = `Signed ${d.toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' })}`;
    }
    if (msg.verificationMethod) methodLine = msg.verificationMethod;
    if (msg.cameraId) {
      // Camera info would need a lookup — show camera ID prefix for now
    }
    if (msg.contentType === 'video') {
      const framePart = (msg.frameMismatches && msg.frameMismatches.length > 0)
        ? ` · ${msg.frameMismatches.length} frame(s) altered` : '';
      contentLine = `Video verified via 8-layer cascade${framePart}`;
    }
    try { sourceDomain = new URL(msg.imageUrl).hostname; } catch(e) {}
    // If this is a video that will enter scan flow, skip the intermediate
    // "Verified" flash — go straight to scan (fixes UNKNOWN → Verified → Checking flicker)
    if (el.tagName === 'VIDEO' && msg.result === 'verified' && msg.stegId) {
      // Store metadata for badge display after scan completes
      el._mysterionVerifyMeta = { creator: creatorLine, signed: signedLine, method: methodLine, source: sourceDomain, content: contentLine };
      // If Post-Sign BLAKE3 layer passed (content hash matches), the file is
      // byte-identical to what was signed — skip expensive frame scan.
      // Fallback to legacy 'File Integrity' name for pre-5.5.93 servers.
      const fileIntegrityPassed = msg.layers && msg.layers.some(
        l => (l.layer === 'Post-Sign BLAKE3' || l.layer === 'File Integrity') && l.passed === true
      );
      if (fileIntegrityPassed) {
        console.log('[Mysterion] Post-Sign BLAKE3 passed — quick verified');
        renderBadge(el, 'verified', { creator: creatorLine, signed: signedLine, method: methodLine, source: sourceDomain, content: contentLine, confidence: _confidenceLine(msg.layers) });
        imgState.set(el, 'done');
        showBadge(el);
        // Add Deep Scan button for on-demand frame verification
        _addDeepScanButton(el._mysterionBadge, el, msg.stegId);
      } else {
        // Hash didn't match (re-encoded video) — auto-run frame scan
        _runVideoScanFlow(el, msg.result, msg.stegId);
      }
    } else {
      renderBadge(el, msg.result, { creator: creatorLine, signed: signedLine, method: methodLine, source: sourceDomain, content: contentLine, confidence: _confidenceLine(msg.layers) });
      imgState.set(el, 'done');
      showBadge(el);
      // Alteration report is handled by background.js (steg_id + pHash mismatch).
      // Do not duplicate here — background.js already fires the POST with reporter_fingerprint.
    }
  }

  if (msg.type === 'VERIFY_PHASH_RESULT') {
    const el = findVideoById(msg.videoId);
    if (!el) return;
    let creatorLine = null, signedLine = null, methodLine = null, contentLine = null;
    if (msg.matched && msg.bestMatch) {
      const match = msg.bestMatch;
      const _UUID_RE_PH = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
      if (match.creator_name && !_UUID_RE_PH.test(match.creator_name)) creatorLine = 'By ' + match.creator_name;
      if (match.signed_at) {
        const d = new Date(match.signed_at);
        signedLine = `Signed ${d.toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' })}`;
      }
      methodLine = msg.matched ? 'pHash (perceptual)' : 'SEI NAL + Container';
      const conf = Math.round(match.confidence * 100);
      contentLine = `${conf}% visual match · hamming ${match.hamming_distance}`;
    } else {
      contentLine = 'No matching signed video found';
    }
    // Skip if already verified via Post-Sign BLAKE3 (VERIFY_RESULT arrived first)
    if (imgState.get(el) === 'done') return;
    // Same flicker fix: skip intermediate badge if scan flow will start
    if (msg.result === 'verified' && msg.stegId) {
      el._mysterionVerifyMeta = { creator: creatorLine, signed: signedLine, method: methodLine, content: contentLine };
      _runVideoScanFlow(el, msg.result, msg.stegId);
    } else {
      renderBadge(el, msg.result, { creator: creatorLine, signed: signedLine, method: methodLine, content: contentLine, confidence: _confidenceLine(msg.layers) });
      imgState.set(el, 'done');
      showBadge(el);
    }
  }

  if (msg.type === 'VERIFY_MAINTENANCE') {
    const el = msg.videoId
      ? findVideoById(msg.videoId)
      : findMediaElement(msg.imageUrl);
    if (!el) return;
    renderBadge(el, 'maintenance', { content: msg.message || 'Platform maintenance in progress' });
    imgState.set(el, 'done');
    showBadge(el);
  }

  if (msg.type === 'VERIFY_ERROR') {
    const el = msg.videoId
      ? findVideoById(msg.videoId)
      : findMediaElement(msg.imageUrl);
    if (!el) return;
    if (msg.error && msg.error.includes('too large')) {
      renderBadge(el, 'unknown', { content: 'Video too large — use Mysterion app' });
    } else {
      renderBadge(el, 'error', null);
    }
    imgState.set(el, 'done');
    showBadge(el);
  }
});

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

// ── Deep Scan button — on-demand frame-by-frame verification ──────────────
function _addDeepScanButton(badge, el, stegId) {
  if (!stegId || !badge || badge.querySelector('.mysterion-deepscan-btn')) return;

  const btn = document.createElement('div');
  btn.className = 'mysterion-deepscan-btn';
  btn.title = 'Run frame-by-frame deep scan';
  btn.style.cssText = `
    display:inline-flex;align-items:center;justify-content:center;gap:5px;
    margin:6px 0 2px 21px;cursor:pointer;
    background:rgba(74,222,128,0.12);border:1px solid rgba(74,222,128,0.35);
    border-radius:50px;padding:6px 18px;
    color:rgba(74,222,128,0.9);font-size:11px;font-weight:700;
    letter-spacing:0.04em;text-transform:uppercase;
    transition:all 0.15s;
  `;
  btn.innerHTML = `
    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
      <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
    </svg>
    Deep scan
  `;
  btn.addEventListener('mouseenter', () => {
    btn.style.background = 'rgba(74,222,128,0.22)';
    btn.style.borderColor = 'rgba(74,222,128,0.6)';
    btn.style.color = '#4ade80';
  });
  btn.addEventListener('mouseleave', () => {
    btn.style.background = 'rgba(74,222,128,0.12)';
    btn.style.borderColor = 'rgba(74,222,128,0.35)';
    btn.style.color = 'rgba(74,222,128,0.9)';
  });
  btn.addEventListener('click', (e) => {
    e.stopPropagation();
    console.log('[Mysterion] Deep Scan button clicked', { stegId, src: el.currentSrc || el.src, scanRunning: el._mysterionScanRunning, deepReq: el._mysterionDeepScanRequested });
    btn.remove();
    // Reset state so scan flow can run
    el._mysterionScanRunning = false;
    el._mysterionDeepScanRequested = true; // ignore hover state
    imgState.set(el, 'checking');
    _runVideoScanFlow(el, 'verified', stegId);
  });

  badge.appendChild(btn);
}


// ═══ PDF HOVER-VERIFY (ported from Firefox for parity) ═══
// PDF HOVER-VERIFY (mirrors image/video hover flow)
// Hovering an <a href="*.pdf"> link auto-verifies the PDF and shows the same
// mysterion-badge popup used for images/video.
// ================================================================================

// Is this anchor a PDF link?
function _isPdfLink(a) {
  if (!a || a.tagName !== 'A' || !a.href) return false;
  try {
    const u = new URL(a.href, location.href);
    return /\.pdf(\?.*)?$/i.test(u.pathname) || /\.pdf(\?.*)?$/i.test(u.href);
  } catch (_) { return false; }
}

// Attach hover-verify to a PDF link.
function attachPdfHover(a) {
  if (a._mysterionHoverBound) return;
  if (!_isPdfLink(a)) return;
  a._mysterionHoverBound = true;
  imgState.set(a, 'idle');

  a.addEventListener('mouseenter', () => {
    if (!_modeAllowsHover()) return;  // patch5a
    _isHovering.set(a, true);
    const state = imgState.get(a);
    if (state === 'done')     { showBadge(a); return; }
    if (state === 'checking') { return; }
    doVerifyPdf(a);
  });

  a.addEventListener('mouseleave', (e) => {
    if (a._mysterionBadge && a._mysterionBadge.contains(e.relatedTarget)) return;
    // Linger 4s after the pointer leaves instead of hiding instantly — the
    // verdict is unreadable if it only exists while hovering.
    _isHovering.set(a, false);
    _autoHideBadge(a);
  });
}

// Kick off verification for a PDF link.
function doVerifyPdf(a) {
  imgState.set(a, 'checking');
  renderBadge(a, 'checking', null);
  showBadge(a);

  let pdfUrl;
  try { pdfUrl = new URL(a.href, location.href).href; }
  catch (_) { pdfUrl = a.href; }

  a._mysterionPdfUrl = pdfUrl;
  chrome.runtime.sendMessage({ type: 'VERIFY_PDF_URL', pdfUrl: pdfUrl });
}

// Find a PDF link element by the URL we verified.
function findPdfLink(pdfUrl) {
  for (const a of document.querySelectorAll('a')) {
    if (a._mysterionPdfUrl === pdfUrl) return a;
  }
  // Fallback: match by href
  for (const a of document.querySelectorAll('a')) {
    try { if (new URL(a.href, location.href).href === pdfUrl) return a; } catch (_) {}
  }
  return null;
}

// Map the background PDF verdict (badge char + fields) to a badge render.
function _renderPdfVerdict(a, verdict) {
  // verdict.badge: '✓' verified | '!' altered | '?' no signature
  let status = 'unknown';
  if (verdict.altered)      status = 'altered';
  else if (verdict.signed)  status = 'verified';
  else                      status = 'unknown';

  const lines = {};
  if (verdict.creator) lines.creator = 'By ' + verdict.creator;
  if (verdict.changes && verdict.changes.length) {
    lines.content = Array.isArray(verdict.changes)
      ? verdict.changes.join('; ')
      : String(verdict.changes);
  } else if (status === 'verified') {
    lines.content = 'PDF verified · document content intact';
  } else if (status === 'unknown') {
    lines.content = 'No Mysterion signature found';
  }
  lines.method = 'PDF provenance';

  renderBadge(a, status, lines);
  imgState.set(a, 'done');
  showBadge(a);
  // Right-click path fires with no hover event, so _isHovering was never set
  // false and _autoHideBadge would return early without arming its timer.
  // Clear it explicitly, then schedule the 4s fade.
  _isHovering.set(a, false);
  _autoHideBadge(a);
}

// patch29_pdf_floating: render the PDF verdict as a standalone floating card when
// there is no <a> link to anchor to (right-click / context-menu verification).
// Reuses the exact badge styling via a synthetic fixed-position host element so
// the card is visually identical to the hover-path card.
let _mystFloatingHost = null;
function _renderPdfVerdictFloating(verdict) {
  let status = 'unknown';
  if (verdict.altered)      status = 'altered';
  else if (verdict.signed)  status = 'verified';
  else                      status = 'unknown';

  const lines = {};
  if (verdict.creator) lines.creator = 'By ' + verdict.creator;
  if (verdict.changes && verdict.changes.length) {
    lines.content = Array.isArray(verdict.changes)
      ? verdict.changes.join('; ')
      : String(verdict.changes);
  } else if (status === 'verified') {
    lines.content = 'PDF verified \u00b7 document content intact';
  } else if (status === 'unknown') {
    lines.content = 'No Mysterion signature found';
  }
  lines.method = 'PDF provenance';

  // Synthetic host: an off-DOM object carrying the badge; positioned fixed
  // top-center. renderBadge/showBadge operate on host._mysterionBadge.
  if (!_mystFloatingHost) _mystFloatingHost = {};
  renderBadge(_mystFloatingHost, status, lines);
  const b = _mystFloatingHost._mysterionBadge;
  if (!b) return;
  b.style.visibility = 'hidden';
  b.style.opacity = '0';
  // Fixed top-center placement (no element rect to seat against).
  b.style.left = 'calc(50vw - ' + ((b.offsetWidth || 140) / 2) + 'px)';
  b.style.top  = '16px';
  b.style.visibility = 'visible';
  b.style.opacity = '1';
  // Auto-dismiss after 6s so it doesn't linger.
  clearTimeout(_mystFloatingHost._t);
  _mystFloatingHost._t = setTimeout(() => { if (b) b.style.opacity = '0'; }, 4000);
}


// patch26: single-engine policy applied
