// Face-aware slimming for the live preview + compositor.
//
// What this does (and what it doesn't): it loads MediaPipe's
// `FaceDetection` model (the lightweight one — bbox + 6 keypoints, ~2MB)
// and per frame computes the face's horizontal bounds. A "slim" value
// 0..100 horizontally compresses the face region only — the surrounding
// neck/shoulders/background stay at native scale. Soft alpha mask at
// the edges blends the compressed region back into the original frame.
//
// This is NOT the full mesh-based liquify that CapCut uses (that needs
// MediaPipe FaceMesh's 468 landmarks + a WebGL shader for triangle warp).
// At small slim values (10-30) it looks good; above that you'll notice
// the soft-edge zone. A full mesh warp is tracked as a follow-up.
//
// Exposed on `window`:
//   - useFaceSlim(videoRef, opts) → { canvasRef, ready, face, error }
//   - drawFaceSlim(ctx, video, face, W, H, slim) — used by compositor

const { useState: uFS, useEffect: uFE, useRef: uFR, useCallback: uFC } = React;

// ─────────────────────────────────────────────────────────────
// Singleton detector — loaded lazily on first use.
// MediaPipe ships UMD scripts that put `FaceDetection` on window.
// ─────────────────────────────────────────────────────────────
let _detectorPromise = null;
function getDetector() {
  if (_detectorPromise) return _detectorPromise;
  _detectorPromise = new Promise((resolve, reject) => {
    if (typeof window.FaceDetection !== 'function') {
      reject(new Error('MediaPipe FaceDetection script not loaded'));
      return;
    }
    try {
      const det = new window.FaceDetection({
        locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_detection@0.4/${file}`,
      });
      det.setOptions({
        model: 'short',          // 'short' for selfie distance; ~2MB
        minDetectionConfidence: 0.5,
      });
      // MediaPipe's detector resolves async after the first send() call.
      // We attach onResults BEFORE the first inference; results are
      // delivered to the consumer via this singleton's listeners.
      det._lastResult = null;
      det.onResults((res) => { det._lastResult = res; });
      resolve(det);
    } catch (err) {
      reject(err);
    }
  });
  return _detectorPromise;
}

// ─────────────────────────────────────────────────────────────
// useFaceSlim — drives a canvas that mirrors the video element and
// applies face-region horizontal compression each frame.
//
// Returns:
//   canvasRef   — attach to a <canvas> in your JSX
//   ready       — boolean, true once detector is loaded
//   face        — {x, y, w, h} in normalized 0..1 coords, or null
//   error       — string, or null
//
// The canvas size auto-tracks its CSS-rendered size via ResizeObserver
// so it stays crisp on retina displays and after layout changes.
// ─────────────────────────────────────────────────────────────
function useFaceSlim(videoRef, { enabled, slim, mirror, filterStr }) {
  const canvasRef = uFR(null);
  const [ready, setReady] = uFS(false);
  const [face, setFace] = uFS(null);
  const [error, setError] = uFS(null);

  // Internal refs: latest options without re-subscribing rAF every change.
  const optsRef = uFR({ enabled, slim, mirror, filterStr });
  uFE(() => { optsRef.current = { enabled, slim, mirror, filterStr }; });

  uFE(() => {
    if (!enabled) return;
    let cancelled = false;
    let rafId = 0;
    let rvfcId = 0;
    let intervalId = 0;
    let detector = null;
    let pendingDetection = false;
    let lastFace = null;
    let lastTickAt = 0;
    let frameCount = 0;

    (async () => {
      try {
        detector = await getDetector();
        if (cancelled) return;
        setReady(true);

        // Paint one frame synchronously so the canvas isn't empty while
        // we wait for the first scheduled tick. Eliminates the black-flash
        // window that users were seeing on slim toggle.
        tick();

        // Choose the best frame source:
        //   - requestVideoFrameCallback: fires per VIDEO frame, runs even
        //     when the tab is in the background (the WHATWG spec doesn't
        //     guarantee it, but Chrome/Safari currently do). Most efficient.
        //   - requestAnimationFrame: standard. Throttled to ~0fps when the
        //     tab is hidden — that's the black-screen bug we're fixing.
        //   - setInterval: last-resort fallback. Always runs.
        scheduleNext();

        // Always-on safety net: if no tick fired in the last 500ms (tab got
        // hidden, rAF starved, etc.), switch on a setInterval so playback
        // stays alive. Cleared automatically when normal ticks resume.
        intervalId = setInterval(() => {
          if (Date.now() - lastTickAt > 500) tick();
        }, 100);
      } catch (err) {
        if (!cancelled) setError(err.message || String(err));
      }
    })();

    function scheduleNext() {
      if (cancelled) return;
      try {
        const video = videoRef?.current;
        if (video && typeof video.requestVideoFrameCallback === 'function') {
          rvfcId = video.requestVideoFrameCallback(() => {
            if (cancelled) return;
            try { tick(); } catch (e) { console.error('[face-slim] tick error:', e); }
            scheduleNext();
          });
        } else {
          rafId = requestAnimationFrame(() => {
            if (cancelled) return;
            try { tick(); } catch (e) { console.error('[face-slim] tick error:', e); }
            scheduleNext();
          });
        }
      } catch (e) {
        console.error('[face-slim] scheduleNext error:', e);
      }
    }

    function tick() {
      lastTickAt = Date.now();
      const video = videoRef.current;
      const canvas = canvasRef.current;
      if (!video || !canvas || video.readyState < 2) return;
      // Self-correct backing-store size — ResizeObserver is async and
      // may not have fired yet on first mount, leaving the canvas at
      // its 300×150 default. The tick can size it itself.
      const r = canvas.getBoundingClientRect();
      if (r.width > 0 && r.height > 0) {
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        const targetW = Math.round(r.width * dpr);
        const targetH = Math.round(r.height * dpr);
        if (canvas.width !== targetW) canvas.width = targetW;
        if (canvas.height !== targetH) canvas.height = targetH;
      }
      const cw = canvas.width, ch = canvas.height;
      if (cw === 0 || ch === 0) return;
      const ctx = canvas.getContext('2d');
      const { slim: slimVal, mirror: mir, filterStr: filt } = optsRef.current;

      // Detection at ~15fps (every 4th tick) to keep CPU manageable.
      // Reuses the previous result on intermediate frames.
      frameCount++;
      if (detector && !pendingDetection && frameCount % 4 === 0) {
        pendingDetection = true;
        detector.send({ image: video }).then(() => {
          pendingDetection = false;
          const res = detector._lastResult;
          const det = res && res.detections && res.detections[0];
          if (det && det.boundingBox) {
            const bb = det.boundingBox;
            lastFace = {
              x: bb.xCenter - bb.width / 2,
              y: bb.yCenter - bb.height / 2,
              w: bb.width,
              h: bb.height,
            };
            setFace(lastFace);
          } else {
            lastFace = null;
            setFace(null);
          }
        }).catch(() => { pendingDetection = false; });
      }

      drawFaceSlim(ctx, video, lastFace, cw, ch, slimVal, mir, filt);
    }

    return () => {
      // EVERYTHING in cleanup must be crash-proof. Rapid slim slider
      // drags trigger fast unmount/remount cycles; if cleanup throws,
      // React tears down the parent component (the studio went blank
      // on the user). Wrap in try/catch + use optional chaining.
      try {
        cancelled = true;
        if (rafId) {
          try { cancelAnimationFrame(rafId); } catch {}
        }
        const v = videoRef?.current;
        if (v && typeof v.cancelVideoFrameCallback === 'function' && rvfcId) {
          try { v.cancelVideoFrameCallback(rvfcId); } catch {}
        }
        if (intervalId) {
          try { clearInterval(intervalId); } catch {}
        }
      } catch (err) {
        console.error('[face-slim] cleanup error (swallowed):', err);
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [enabled, videoRef]);

  // Keep the canvas backing-store in sync with its rendered CSS size.
  uFE(() => {
    if (!enabled) return;
    const canvas = canvasRef?.current;
    if (!canvas) return;
    let ro;
    try {
      ro = new ResizeObserver(() => {
        try {
          const r = canvas.getBoundingClientRect();
          const dpr = Math.min(window.devicePixelRatio || 1, 2);
          const w = Math.round(r.width * dpr), h = Math.round(r.height * dpr);
          if (canvas.width !== w) canvas.width = w;
          if (canvas.height !== h) canvas.height = h;
        } catch {}
      });
      ro.observe(canvas);
    } catch {}
    return () => { try { ro?.disconnect(); } catch {} };
  }, [enabled]);

  return { canvasRef, ready, face, error };
}

// ─────────────────────────────────────────────────────────────
// drawFaceSlim — the actual pixel-pushing.
// Two-pass strategy:
//   1. Draw the full video frame, cover-fit, optionally mirrored.
//   2. If a face is detected and slim > 0, redraw the face region
//      compressed horizontally on top, masked with a soft alpha
//      gradient so the edges blend back into the original frame.
//
// `face` is in NORMALIZED 0..1 coords from MediaPipe; we convert to
// pixel coords on the destination canvas (which uses cover-fit so we
// have to mirror the source-rect calculation).
// ─────────────────────────────────────────────────────────────
function drawFaceSlim(ctx, video, face, W, H, slim, mirror, filterStr) {
  // ── Pass 1: full video frame, cover-fit + optional mirror + filter ──
  const vw = video.videoWidth || 1280;
  const vh = video.videoHeight || 720;
  const scale = Math.max(W / vw, H / vh);
  const dw = vw * scale, dh = vh * scale;
  const dx = (W - dw) / 2, dy = (H - dh) / 2;

  ctx.clearRect(0, 0, W, H);
  ctx.save();
  if (filterStr && filterStr !== 'none') ctx.filter = filterStr;
  if (mirror) {
    ctx.translate(W, 0); ctx.scale(-1, 1);
    ctx.drawImage(video, W - dx - dw, dy, dw, dh);
  } else {
    ctx.drawImage(video, dx, dy, dw, dh);
  }
  ctx.restore();

  if (!face || slim <= 0) return;

  // ── Pass 2: face-only horizontal compression ──
  // The bbox is in normalized SOURCE-video coords. Project it into
  // destination-canvas pixel coords, accounting for cover-fit + mirror.
  let fx = face.x * dw + dx;
  let fy = face.y * dh + dy;
  let fw = face.w * dw;
  let fh = face.h * dh;
  if (mirror) fx = W - (fx + fw);

  // Expand vertically so chin/forehead don't get clipped.
  const padY = fh * 0.18;
  fy = Math.max(0, fy - padY);
  fh = Math.min(H - fy, fh + padY * 2);

  if (fw < 10 || fh < 10) return;   // tiny detections — skip to avoid artifacts

  // Compression: slim 0..100 → 0..18% horizontal squeeze.
  const k = 1 - (slim / 100) * 0.18;
  const newW = fw * k;
  const xOffset = (fw - newW) / 2;

  // Use an offscreen scratch canvas: snapshot the face region from
  // the main canvas, then re-paint it compressed with a soft alpha
  // mask so the seams blend back into the original cheeks.
  const tmp = drawFaceSlim._tmp ||= document.createElement('canvas');
  const tmpW = Math.ceil(newW), tmpH = Math.ceil(fh);
  if (tmp.width !== tmpW) tmp.width = tmpW;
  if (tmp.height !== tmpH) tmp.height = tmpH;
  const tctx = tmp.getContext('2d');

  // (a) Draw the face region from main canvas into tmp, horizontally
  //     compressed. Source is the already-painted main canvas — the
  //     mirror + filter are baked in, so we get them for free here.
  tctx.clearRect(0, 0, tmpW, tmpH);
  tctx.drawImage(ctx.canvas, fx, fy, fw, fh, 0, 0, newW, fh);

  // (b) Apply a soft horizontal alpha gradient via destination-in so
  //     the edges fade to transparent.
  tctx.globalCompositeOperation = 'destination-in';
  const grad = tctx.createLinearGradient(0, 0, newW, 0);
  const seam = 0.20;   // 20% fade band on each side
  grad.addColorStop(0,        'rgba(0,0,0,0)');
  grad.addColorStop(seam,     'rgba(0,0,0,1)');
  grad.addColorStop(1 - seam, 'rgba(0,0,0,1)');
  grad.addColorStop(1,        'rgba(0,0,0,0)');
  tctx.fillStyle = grad;
  tctx.fillRect(0, 0, newW, fh);
  tctx.globalCompositeOperation = 'source-over';

  // (c) Composite the slim'd face back onto main, centered horizontally
  //     within the original face bbox.
  ctx.drawImage(tmp, fx + xOffset, fy);
}

window.useFaceSlim = useFaceSlim;
window.drawFaceSlim = drawFaceSlim;
