// screens-capture.jsx — the capture moment: per-angle framing guide with live feedback,
// countdown, review, retake. Pairs with pose-detect.jsx for detection state.

const { useState, useEffect, useRef } = React;

// ── Camera stream hook ───────────────────────────────────────
// Acquires the rear-camera MediaStream while `active` is true and exposes it
// via a <video> ref. Exposes `grab()` that snapshots the current frame as a
// JPEG File for the upload pipeline. Falls back gracefully on permission
// denial or no-camera devices; CaptureScreen then uses a placeholder so the
// rest of the flow still works.
function useCameraStream({ active }) {
  const videoRef  = useRef(null);
  const streamRef = useRef(null);
  const [error,   setError]   = useState(null);
  const [ready,   setReady]   = useState(false);

  useEffect(() => {
    if (!active) {
      // Drop the stream when we leave the aim phase so the LED off-light
      // matches the user's expectation, and to free the camera for other
      // apps if they background us.
      if (streamRef.current) {
        streamRef.current.getTracks().forEach((t) => t.stop());
        streamRef.current = null;
      }
      setReady(false);
      return;
    }
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      setError('camera-unsupported');
      return;
    }
    let cancelled = false;
    (async () => {
      try {
        // Ask for the highest practical resolution the back camera can give.
        // Phones typically negotiate down to what they support — modern
        // mid-range up to 4K, older drops to 1920×1080. We use min:1920 to
        // refuse anything dramatically worse, ideal 3840 to pull up where
        // possible. More pixels per cm on the full-body capture means more
        // detail for Rodin to work with even though the body fills less of
        // the frame than a tight portrait would.
        const stream = await navigator.mediaDevices.getUserMedia({
          video: {
            facingMode: { ideal: 'environment' },
            width:  { min: 1920, ideal: 3840 },
            height: { min: 1080, ideal: 2160 },
          },
          audio: false,
        });
        if (cancelled) {
          stream.getTracks().forEach((t) => t.stop());
          return;
        }
        streamRef.current = stream;
        if (videoRef.current) {
          videoRef.current.srcObject = stream;
          // Some Chrome versions need an explicit play() in TWA context.
          try { await videoRef.current.play(); } catch (e) { /* autoplay policy, fine */ }
        }
        setReady(true);
      } catch (e) {
        if (cancelled) return;
        const name = (e && e.name) || '';
        if (name === 'NotAllowedError' || name === 'SecurityError') setError('permission-denied');
        else if (name === 'NotFoundError')                          setError('no-camera');
        else                                                         setError(name || 'camera-error');
      }
    })();
    return () => {
      cancelled = true;
      if (streamRef.current) {
        streamRef.current.getTracks().forEach((t) => t.stop());
        streamRef.current = null;
      }
      setReady(false);
    };
  }, [active]);

  // Snapshot the current video frame into a JPEG File. Returns null if the
  // stream isn't live yet, so callers can fall back to placeholder generation.
  const grab = () => {
    const v = videoRef.current;
    if (!v || !v.videoWidth) return Promise.resolve(null);
    const c = document.createElement('canvas');
    c.width  = v.videoWidth;
    c.height = v.videoHeight;
    const g = c.getContext('2d');
    if (!g) return Promise.resolve(null);
    g.drawImage(v, 0, 0, c.width, c.height);
    return new Promise((resolve) => {
      c.toBlob(
        (blob) => {
          if (!blob) { resolve(null); return; }
          resolve(new File([blob], `frame-${Date.now()}.jpg`, { type: 'image/jpeg' }));
        },
        'image/jpeg',
        0.92
      );
    });
  };

  return { videoRef, ready, error, grab };
}

// Generates a small JPEG that stands in for a real camera frame, so the
// upload pipeline still has a File to send while the camera hookup (CameraX
// on Android / MediaStream on web) is wired separately. Production cameras
// replace this with the actual frame buffer; the rest of the app does not care.
function makePlaceholderFile(angleKey) {
  try {
    const c = document.createElement('canvas');
    c.width = 768; c.height = 1024;
    const g = c.getContext('2d');
    if (g) {
      const grd = g.createRadialGradient(384, 380, 60, 384, 380, 720);
      grd.addColorStop(0, '#2a2c20');
      grd.addColorStop(1, '#0e0f0a');
      g.fillStyle = grd;
      g.fillRect(0, 0, c.width, c.height);
      g.fillStyle = '#ece7da';
      g.font = '600 48px Hanken Grotesk, system-ui, sans-serif';
      g.textAlign = 'center';
      g.fillText('Gravida Scan', 384, 480);
      g.font = '500 32px Cormorant Garamond, Georgia, serif';
      g.fillStyle = '#a99a78';
      g.fillText(angleKey.toUpperCase(), 384, 540);
      g.font = '400 18px Hanken Grotesk, system-ui, sans-serif';
      g.fillStyle = '#9aa295';
      g.fillText(new Date().toISOString(), 384, 580);
    }
    const dataUrl = c.toDataURL('image/jpeg', 0.85);
    const bin = atob(dataUrl.split(',')[1]);
    const buf = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
    return new File([buf], `${angleKey}.jpg`, { type: 'image/jpeg' });
  } catch (e) {
    // Fallback for environments without canvas (SSR / Node tests).
    return new File([new Uint8Array(1)], `${angleKey}.jpg`, { type: 'image/jpeg' });
  }
}

// Default ANGLES (kept for backwards compatibility). The runtime version,
// resolved through resolveAngles(ctx), looks up SCAN_MODE_COPY based on
// ctx.capture.scanMode so the capture screen wording matches what the
// customer just picked on the Overview screen.
const ANGLES = [
  { key: 'front', label: 'Front',      instruct: 'Stand face-on, belly centred',  turn: '0°',
    nextInstruct: 'Now turn a quarter to your right' },
  { key: 'right', label: 'Right side', instruct: 'Quarter-turn to the right',      turn: '90°',
    nextInstruct: 'Now turn to face the wall' },
  { key: 'back',  label: 'Back',       instruct: 'Turn to face the wall',          turn: '180°',
    nextInstruct: 'Now turn a quarter further, to your left side' },
  { key: 'left',  label: 'Left side',  instruct: 'Quarter-turn to the left',       turn: '270°',
    nextInstruct: 'Lovely, that is everything' },
];

// Pull the angle wording from the SCAN_MODE_COPY table set up in
// screens-onboarding.jsx, falling back to the ANGLES default if the table
// (or the mode) is unavailable.
// Ten-shot capture set: 4 body angles per location at TWO locations,
// followed by 2 hand close-ups (admin reference only).
//
// The 4 body angles are front, right, back, left. Front/right/left are
// tight head-to-knees crops (max pixels per cm on belly/hands/face);
// back is the full head-to-feet shot the pose detector needs to read the
// whole skeleton. There is exactly ONE front photo per location now: the
// old 'front_detail' close-up was identical to the front shot, so it was
// just a confusing duplicate and got dropped.
//
// Two locations give Rodin two independent input sets and two parallel
// STL jobs, so admin can pick the better of the two (different lighting
// and background per location lets one survive when the other has
// issues). Each body position is captured once at location A
// (location_idx=0) and once at location B (location_idx=1). The
// CAPTURE_SEQUENCE stores { key, locationIdx } pairs so resolveAngles can
// render the right label + transition messages.
const BODY_KEYS = ['front', 'right', 'back', 'left'];
// Single-location capture: 4 body angles + 1 under-belly shot (5 total).
// Rodin accepts up to 5 images; the under-belly shot gives the mesh better
// geometry for the lower-bump area when hands are held low.
const CAPTURE_SEQUENCE = [
  ...BODY_KEYS.map((k) => ({ key: k, locationIdx: 0 })),
  { key: 'belly_under', locationIdx: 0 },
];
const TURN_LABEL = { front: '0°', right: '90°', back: '180°', left: '270°', belly_under: 'from below' };

function resolveAngles(ctx) {
  const mode    = (ctx && ctx.capture && ctx.capture.scanMode) || 'standing';
  const armPose = (ctx && ctx.capture && ctx.capture.armPose)  || 'belly';
  const table = (typeof window !== 'undefined' && window.SCAN_MODE_COPY) || null;
  if (!table || !table[mode]) return ANGLES;
  const copy    = table[mode];
  const armCopy = (typeof window !== 'undefined' && window.ARM_POSE_COPY && window.ARM_POSE_COPY[armPose]) || null;
  const armSuffix = armCopy ? ' ' + armCopy.angleSuffix : '';

  const items = CAPTURE_SEQUENCE
    .filter(({ key }) => copy.angles[key])
    .map(({ key, locationIdx }) => {
      const isUnder  = key === 'belly_under';
      const baseHint = isUnder ? copy.angles[key].hint : copy.angles[key].hint + armSuffix;
      return {
        key,
        locationIdx,
        label:    copy.angles[key].label,
        instruct: baseHint,
        turn:     TURN_LABEL[key] || '',
        isUnder,
        takeIdx:  0,
      };
    });

  items.forEach((item, i) => {
    const next = items[i + 1];
    if (!next) {
      item.nextInstruct = 'Lovely, that is everything';
      return;
    }
    if (next.key === 'back') {
      item.nextInstruct = 'Now the back. Step back so her whole body fits in frame, head to feet';
      return;
    }
    if (next.key === 'belly_under') {
      item.nextInstruct = 'One last shot, from below her belly';
      return;
    }
    item.nextInstruct = copy.angles[item.key]?.next ?? '';
  });

  return items;
}

// ── per-angle silhouettes ────────────────────────────────────
// Each one is an SVG drawn into a 150×380 viewport so the same outline
// can show: dashed-rose (waiting), dashed-gold (lock), solid-gold-glow (firing).
function AngleSilhouette({ angleKey, color, dashed = true, glow = false }) {
  const stroke = color;
  const fill   = 'none';
  const dash   = dashed ? '6 6' : '0';
  const filter = glow ? `drop-shadow(0 0 14px ${color})` : 'none';

  let head = null, body = null;
  switch (angleKey) {
    case 'front':
      // Tight front crop: head down to the KNEES only. Belly protrudes wide
      // to read as clearly pregnant — peak extends to ~x=17/x=133 on a 150px
      // viewport (about 77% of total width at the widest point).
      head = <circle cx="75" cy="44" r="30" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />;
      body = <path d="M 46 88 C 39 103, 35 119, 34 133 C 31 143, 27 154, 25 166
                       C 17 186, 16 212, 26 235 C 31 247, 35 256, 37 268
                       C 38 274, 39 281, 40 288 C 41 320, 42 352, 43 380
                       L 62 380 C 63 352, 64 320, 65 291 L 75 270 L 85 291
                       C 86 320, 87 352, 88 380 L 107 380
                       C 108 352, 109 320, 110 288 C 111 281, 112 274, 113 268
                       C 115 256, 119 247, 124 235 C 134 212, 133 186, 125 166
                       C 123 154, 119 143, 116 133 C 115 119, 111 103, 104 88
                       C 93 64, 57 64, 46 88 Z"
                   fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinejoin="round" />;
      break;
    case 'right':
      // Side profile. Belly peak at x=126 — a clear rounded dome shape.
      // Left (back) uses scale(-1,1) so this path serves both sides.
      head = <ellipse cx="68" cy="44" rx="22" ry="28" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />;
      body = <path d="M 57 84 C 49 110, 44 142, 48 172 C 44 202, 45 232, 53 252
                       C 54 300, 55 340, 56 380 L 90 380
                       C 89 340, 88 300, 87 252 C 86 243, 86 236, 87 227
                       C 104 210, 126 190, 126 166 C 126 154, 122 144, 113 135
                       C 108 125, 110 112, 102 100 C 97 90, 86 83, 81 86
                       C 74 80, 63 80, 57 84 Z"
                   fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinejoin="round" />;
      break;
    case 'back':
      // Full standing figure, head to feet — the back is the pose anchor
      // (our AI needs the whole skeleton), so the proportions are smaller
      // and the feet sit just inside the bottom of the frame. No belly or
      // breasts: nothing of that shows from behind.
      head = (
        <g>
          <circle cx="75" cy="30" r="21" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />
          {/* small hair line so it reads as "back of head" */}
          <path d="M 61 20 Q 75 12, 89 20" fill="none" stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinecap="round" />
        </g>
      );
      body = (
        <path d="M 55 56 C 47 82, 42 110, 42 138 C 42 162, 46 184, 54 200
                 C 56 205, 57 210, 58 216 C 59 250, 60 286, 61 312
                 C 60 336, 60 358, 59 368 C 59 374, 57 376, 55 376
                 L 55 380 L 73 380 C 73 372, 73 350, 72 312
                 L 74 226 L 75 220 L 76 226 L 78 312
                 C 77 350, 77 372, 77 380 L 95 380 L 95 376
                 C 93 376, 91 374, 91 368 C 90 358, 90 336, 89 312
                 C 90 286, 91 250, 92 216 C 93 210, 94 205, 96 200
                 C 104 184, 108 162, 108 138 C 108 110, 103 82, 95 56
                 C 87 38, 63 38, 55 56 Z"
              fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinejoin="round" />
      );
      break;
    case 'left':
      // Mirror of the right tight profile around the vertical centre line so
      // the belly + breast bump to the left. One transform keeps it perfectly
      // symmetric with 'right' instead of maintaining a second path.
      head = null;
      body = (
        <g transform="translate(150,0) scale(-1,1)">
          <ellipse cx="68" cy="44" rx="22" ry="28" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />
          <path d="M 58 86 C 50 112, 46 144, 50 172 C 47 202, 48 230, 56 250
                   C 57 298, 58 340, 59 380 L 92 380
                   C 91 340, 90 298, 89 250 C 88 242, 88 236, 89 228
                   C 103 210, 114 188, 104 162 C 101 152, 99 146, 98 140
                   C 106 130, 109 116, 101 102 C 96 92, 86 86, 82 88
                   C 75 82, 64 82, 58 86 Z"
                fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinejoin="round" />
        </g>
      );
      break;
    case 'front_detail':
      // Extra front close-up: same tight head-to-knees crop as the regular
      // front shot. It is a second front photo for resolution, framed the
      // same way, so it shares the front silhouette exactly.
      head = <circle cx="75" cy="44" r="30" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />;
      body = <path d="M 47 90 C 41 104, 38 118, 38 132 C 36 140, 34 150, 33 162
                       C 26 180, 26 205, 36 226 C 41 236, 44 244, 46 256
                       C 47 262, 48 268, 49 274 C 50 312, 51 346, 52 380
                       L 70 380 C 71 346, 72 312, 73 280 L 75 270 L 77 280
                       C 78 312, 79 346, 80 380 L 98 380
                       C 99 346, 100 312, 101 274 C 102 268, 103 262, 104 256
                       C 106 244, 109 236, 114 226 C 124 205, 124 180, 117 162
                       C 116 150, 114 140, 112 132 C 112 118, 109 104, 103 90
                       C 94 68, 56 68, 47 90 Z"
                   fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinejoin="round" />;
      break;
    case 'hand_right':
    case 'hand_left': {
      // Flat-hand silhouette, palm-down. Fingers CLOSED together (single
      // rounded block, not spread), thumb sits free and slightly away from
      // the rest. Same 150×380 viewport as the body silhouettes so the
      // capture-frame UI stays consistent. Left-hand variant mirrors.
      const mirrored = angleKey === 'hand_left';
      const tf = mirrored ? 'translate(150,0) scale(-1,1)' : undefined;
      head = null;
      body = (
        <g transform={tf}>
          {/* palm + wrist + closed-finger block as one continuous outline.
              Top edge sits at y=64 with a soft round so the four fingertips
              read as a single closed shape. Wrist tapers down to y=320. */}
          <path d="M 56 64
                   C 50 60, 122 60, 116 64
                   L 116 200
                   C 116 220, 122 240, 122 268
                   C 122 300, 110 320, 92 320
                   L 60 320
                   C 42 320, 30 300, 30 268
                   C 30 240, 36 220, 36 200
                   L 36 152
                   C 36 156, 36 156, 36 152 Z"
                fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinejoin="round" strokeLinecap="round" />
          {/* thumb sticking out to the side, clear gap from the fingers */}
          <path d="M 36 180
                   C 18 178, 6 162, 12 142
                   C 18 124, 38 122, 50 138
                   L 50 196"
                fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} strokeLinecap="round" strokeLinejoin="round" />
        </g>
      );
      break;
    }
    case 'belly_under': {
      // Phone held under the belly, pointing upward. Shows the underside of
      // the bump (large oval) with navel centre and the upper thighs visible.
      head = null;
      body = (
        <g>
          <ellipse cx="75" cy="172" rx="58" ry="74" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />
          <circle cx="75" cy="155" r="9" fill={fill} stroke={stroke} strokeWidth="2" strokeDasharray={dash} />
          <ellipse cx="35" cy="328" rx="20" ry="44" fill={fill} stroke={stroke} strokeWidth="2" strokeDasharray={dash} />
          <ellipse cx="115" cy="328" rx="20" ry="44" fill={fill} stroke={stroke} strokeWidth="2" strokeDasharray={dash} />
        </g>
      );
      break;
    }
    default:
      head = <circle cx="75" cy="34" r="30" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />;
      body = <rect x="32" y="72" width="86" height="282" rx="40" fill={fill} stroke={stroke} strokeWidth="2.5" strokeDasharray={dash} />;
  }

  // Each figure draws at its natural extent inside the 150×380 viewport:
  // the tight angles (front/right/left/front_detail) are head-to-knees with
  // the thighs running off the bottom (frame cuts at the knees, no shins or
  // feet); the back is the full head-to-feet figure. The caller scales the
  // SVG to fill the framing box, so the knees (tight) or feet (back) land at
  // the bottom of the guide.
  return (
    <svg viewBox="0 0 150 380" width="100%" height="100%" preserveAspectRatio="xMidYMid meet"
         style={{ filter, display: 'block', transition: 'filter .25s ease' }}>
      {head}
      {body}
    </svg>
  );
}

// ── framing guide overlay ────────────────────────────────────
// frameState: 'seeking' | 'in-frame' | 'locked' | 'firing'
function FramingGuide({ angleKey, frame }) {
  let color = 'rgba(236,231,218,.55)';
  let glow  = false;
  let dashed = true;
  if (frame === 'in-frame') color = 'rgba(212,165,154,.95)';
  if (frame === 'locked')   { color = '#cbb273'; glow = true; }
  if (frame === 'firing')   { color = '#e5cf88'; glow = true; dashed = false; }
  const isTightBody = ['front', 'right', 'left', 'front_detail'].includes(angleKey);
  const isFullBody  = angleKey === 'back';
  const isBody      = isTightBody || isFullBody;
  // Top of the frame is always the top of her head. Every shot reaches at
  // least her knees: we never want a torso-only crop. The tight shots stop
  // at the knees (no lower legs); the back goes all the way to the feet.
  const topLabel    = 'top of her head';
  const bottomLabel = isFullBody ? 'full body' : 'her knees';

  // The silhouette and the crop frame share one box: top of her head at the
  // top, the bottom bar just above the shutter. The figure fills it, so its
  // bottom lands on the bar for every shot — her knees on the tight crops
  // (head-to-knees figure, no legs), the feet on the full-body back.
  const frameTop    = '4%';
  const frameBottom = '18%';

  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
      {/* faint thirds grid */}
      {[1, 2].map((i) => (
        <div key={'v' + i} style={{ position: 'absolute', top: 0, bottom: 0, left: `${i * 33.3}%`, width: 1, background: 'rgba(236,231,218,.08)' }} />
      ))}
      {[1, 2].map((i) => (
        <div key={'h' + i} style={{ position: 'absolute', left: 0, right: 0, top: `${i * 33.3}%`, height: 1, background: 'rgba(236,231,218,.08)' }} />
      ))}
      {/* CROP FRAME: corner brackets + bottom bar + label. The bottom marks
          where the shot stops (her knees on the tight shots, her feet on the
          full-body back). Tight shots sit a little lower (frameTop) so the
          partner does not try to fit the whole body in. The bar stays clear
          of the shutter UI (hint + 76px button + padding ≈ bottom 17%). The
          captured photo is the full video frame regardless; this only
          governs the visible guide. */}
      {isBody && (
        <div style={{ position: 'absolute', top: frameTop, bottom: frameBottom, left: '14%', right: '14%', pointerEvents: 'none' }}>
          {[
            { top: 0,    left: 0,    borderTop: true,  borderLeft: true  },
            { top: 0,    right: 0,   borderTop: true,  borderRight: true },
            { bottom: 0, left: 0,    borderBottom: true, borderLeft: true },
            { bottom: 0, right: 0,   borderBottom: true, borderRight: true },
          ].map((c, i) => (
            <div key={i} style={{
              position: 'absolute',
              width: 28, height: 28,
              top: c.top, bottom: c.bottom, left: c.left, right: c.right,
              borderTop:    c.borderTop    ? `2.5px solid ${color}` : undefined,
              borderBottom: c.borderBottom ? `2.5px solid ${color}` : undefined,
              borderLeft:   c.borderLeft   ? `2.5px solid ${color}` : undefined,
              borderRight:  c.borderRight  ? `2.5px solid ${color}` : undefined,
              filter: glow ? `drop-shadow(0 0 8px ${color})` : 'none',
              transition: 'border-color .25s, filter .25s',
            }} />
          ))}
          <div style={{ position: 'absolute', top: -2, left: '50%', transform: 'translateX(-50%)',
            fontSize: 9, letterSpacing: '.18em', textTransform: 'uppercase',
            color: 'rgba(236,231,218,.55)', background: 'rgba(20,22,15,.7)',
            padding: '3px 8px', borderRadius: 4, whiteSpace: 'nowrap' }}>
            {topLabel}
          </div>
          <div style={{ position: 'absolute', bottom: 5, left: '50%', transform: 'translateX(-50%)',
            fontSize: 9, letterSpacing: '.18em', textTransform: 'uppercase',
            color: 'rgba(236,231,218,.55)', background: 'rgba(20,22,15,.7)',
            padding: '3px 8px', borderRadius: 4, whiteSpace: 'nowrap' }}>
            {bottomLabel}
          </div>
        </div>
      )}
      {/* silhouette. For body shots it fills the SAME box as the crop frame
          (top frameTop / bottom frameBottom / left+right 14%) so the figure's
          bottom — her knees on the tight crops, her feet on the full-body
          back shot — lands exactly on the bottom bar.
          Hand close-ups keep the centred, fixed-size placement. */}
      {isBody ? (
        <div style={{ position: 'absolute', top: frameTop, bottom: frameBottom, left: '14%', right: '14%', pointerEvents: 'none' }}>
          <AngleSilhouette angleKey={angleKey} color={color} dashed={dashed} glow={glow} />
        </div>
      ) : (
        <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <div style={{ width: 150, height: 380 }}>
            <AngleSilhouette angleKey={angleKey} color={color} dashed={dashed} glow={glow} />
          </div>
        </div>
      )}
      {/* floor line for close-up shots (hands, etc.) but not belly_under */}
      {!isBody && angleKey !== 'belly_under' && (
        <div style={{ position: 'absolute', left: '12%', right: '12%', bottom: '11%', height: 1, background: 'rgba(236,231,218,.28)' }} />
      )}
    </div>
  );
}

// ── rotation overlay between shots ──────────────────────────
function RotationOverlay({ from, to, instruct, onDone, hold = false }) {
  useEffect(() => {
    // The quick angle turns auto-advance after 1.5s. The big "walk to a
    // different spot" transition (hold) waits for a tap instead, so the
    // message stays on screen while the couple actually moves.
    if (hold) return;
    const t = setTimeout(onDone, 1500);
    return () => clearTimeout(t);
  }, [hold]);
  const fromDeg = { front: 0, right: 90, back: 180, left: 270 }[from] || 0;
  const toDeg   = { front: 0, right: 90, back: 180, left: 270 }[to]   || 0;
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 5, display: 'flex',
      alignItems: 'center', justifyContent: 'center',
      background: 'rgba(11,12,8,.72)', backdropFilter: 'blur(4px)',
      animation: 'fadein .25s ease both',
    }}>
      <style>{`@keyframes rotIcon-${from}-${to} { from { transform: rotate(${fromDeg}deg);} to { transform: rotate(${toDeg}deg);} }`}</style>
      <div style={{ textAlign: 'center', padding: '0 36px' }}>
        <div style={{ width: 140, height: 140, margin: '0 auto', position: 'relative' }}>
          <svg viewBox="0 0 140 140" width="140" height="140" style={{
            transformOrigin: '70px 70px',
            animation: `rotIcon-${from}-${to} 1.2s cubic-bezier(.6,.05,.3,1) .15s both`,
          }}>
            <circle cx="70" cy="70" r="58" fill="none" stroke="rgba(212,165,154,.4)" strokeWidth="1.5" strokeDasharray="3 5" />
            {/* fixed arrow at top showing camera direction */}
            <path d="M 70 6 L 64 16 L 76 16 Z" fill="rgba(236,231,218,.85)" />
            {/* simple person silhouette in the middle */}
            <circle cx="70" cy="56" r="9" fill="#ece7da" />
            <path d="M 56 70 C 56 66, 62 65, 70 65 C 78 65, 84 66, 84 70
                     L 86 96 C 86 106, 82 112, 78 116 L 62 116 C 58 112, 54 106, 54 96 Z" fill="#ece7da" />
          </svg>
        </div>
        <div className="eyebrow" style={{ marginTop: 22, color: 'var(--gold-soft)' }}>
          {hold ? 'Move to a new spot' : 'Next angle'}
        </div>
        <h3 style={{ fontFamily: 'var(--serif)', fontWeight: 500, fontSize: 26, color: 'var(--paper)', margin: '10px 0 0', lineHeight: 1.25 }}>
          {instruct}
        </h3>
        {hold && (
          <button onClick={onDone} style={{
            marginTop: 26, padding: '13px 30px', borderRadius: 999, border: 'none',
            background: '#e5cf88', color: '#1a1c14', fontSize: 15, fontWeight: 700,
            cursor: 'pointer', boxShadow: '0 8px 26px rgba(229,207,136,.35)',
          }}>
            We are in the new spot, continue
          </button>
        )}
      </div>
    </div>
  );
}


function CaptureScreen({ ctx }) {
  const [shot, setShot]                 = useState(0);
  const [phase, setPhase]               = useState('aim');      // aim | count | review | rotating
  const [count, setCount]               = useState(3);
  const [flash, setFlash]               = useState(false);
  const [failedOnce, setFailedOnce]     = useState(false);      // scripted fail on shot index 1
  const [reviewFailed, setReviewFailed] = useState(false);
  const [toast, setToast]               = useState(null);
  const [holdStill, setHoldStill]       = useState(false);
  const [prevAngleKey, setPrevAngleKey] = useState(null);
  const [reviewFrame, setReviewFrame]   = useState(null);       // dataURL of last grabbed frame, for review tile
  const capturedFileRef = useRef(null);
  // Per-mode angle list (resolved once per render). `ANGLES` stays for the
  // default mode + RotationOverlay's compass-direction lookup.
  const angles    = resolveAngles(ctx);
  const approved  = shot;
  const angle     = angles[shot];

  // Real camera stream while aiming. Stops + restarts as we move between
  // angles and back from review/retake.
  const cameraActive = phase === 'aim' || phase === 'count';
  const camera       = useCameraStream({ active: cameraActive });

  // Live detection state. The mock progression sits in pose-detect.jsx; that's
  // the single seam to swap in real ML Kit / MoveNet later.
  const detectActive = phase === 'aim';
  const det          = useFraming({ angleKey: angle.key, active: detectActive, videoRef: camera.videoRef });
  const level        = useDeviceLevel();

  const frameState = (() => {
    if (phase === 'count')                                                return 'firing';
    // Bad exposure blocks the 'locked' state so silent auto-capture cannot
    // fire on a pitch-black or blown-out frame. The customer sees the
    // exposure hint and corrects the light before we shoot.
    if (det.locked && level.levelOk && det.distance === 'ok' && det.exposure === 'ok') return 'locked';
    if (det.inFrame)                                                       return 'in-frame';
    return 'seeking';
  })();
  const ready = frameState === 'locked';

  const vibrate = (p) => { try { navigator.vibrate && navigator.vibrate(p); } catch (e) {} };

  // Silent auto-capture: once the lock has held for LOCK_HOLD_TO_AUTO_MS,
  // show "Holding still..." then fire shutter ~350ms later.
  useEffect(() => {
    if (phase !== 'aim' || !ready) { setHoldStill(false); return; }
    if (det.lockMs < LOCK_HOLD_TO_AUTO_MS) return;
    if (holdStill) return;
    setHoldStill(true);
    const t = setTimeout(() => setPhase('count'), 350);
    return () => clearTimeout(t);
  }, [det.lockMs, ready, phase, holdStill]);

  // Countdown driver. The shutter "fires" when n reaches 0: we grab the
  // current video frame, flash, then jump to the review sheet.
  useEffect(() => {
    if (phase !== 'count') return;
    setCount(3);
    vibrate(18);
    let n = 3;
    const id = setInterval(async () => {
      n -= 1;
      if (n > 0) { setCount(n); vibrate(18); return; }
      clearInterval(id);
      vibrate([0, 32, 40, 32]);
      setFlash(true);
      setTimeout(() => setFlash(false), 220);

      // Grab the live frame, falling back to a generated placeholder if the
      // camera never came up (permission denied, no camera, etc.).
      let file = await camera.grab();
      if (!file) file = makePlaceholderFile(angle.key);
      capturedFileRef.current = file;
      try { setReviewFrame(URL.createObjectURL(file)); } catch (e) { setReviewFrame(null); }

      const willFail = shot === 1 && !failedOnce;
      setReviewFailed(willFail);
      setHoldStill(false);
      setPhase('review');
    }, 820);
    return () => clearInterval(id);
  }, [phase]);

  // Stage the most recently grabbed photo into the cross-screen store.
  // (angle, locationIdx) is the unique key — same angle at different
  // locations are kept side by side, not overwritten.
  const stageCapture = (angleKey, takeIdx = 0, locationIdx = 0) => {
    if (!ctx || !ctx.capture) return;
    const file = capturedFileRef.current || makePlaceholderFile(angleKey);
    ctx.capture.photos = (ctx.capture.photos || [])
      .filter((p) => !(p.angle === angleKey && (p.order_idx ?? 0) === takeIdx && (p.location_idx ?? 0) === locationIdx));
    ctx.capture.photos.push({ angle: angleKey, file, order_idx: takeIdx, location_idx: locationIdx });
  };

  const approve = () => {
    stageCapture(angle.key, angle.takeIdx ?? 0, angle.locationIdx ?? 0);
    // Free the temporary object URL from the review tile, no longer needed.
    if (reviewFrame) { try { URL.revokeObjectURL(reviewFrame); } catch (e) {} setReviewFrame(null); }
    if (shot >= angles.length - 1) { ctx.go('details'); return; }
    const next = shot + 1;
    setToast(`${next} of ${angles.length} captured`);
    setTimeout(() => setToast(null), 1500);
    setPrevAngleKey(angle.key);
    setShot(next);
    setPhase('rotating');
  };

  const retake = () => {
    if (reviewFailed) setFailedOnce(true);
    setReviewFailed(false);
    if (reviewFrame) { try { URL.revokeObjectURL(reviewFrame); } catch (e) {} setReviewFrame(null); }
    capturedFileRef.current = null;
    setPhase('aim');
  };

  const onShutter = () => {
    if (phase === 'aim' && det.inFrame) setPhase('count');
  };

  const hint = (() => {
    if (phase === 'count')                                  return 'Hold still...';
    // Exposure beats every other state — a too-dark or too-bright frame is
    // useless regardless of pose.
    if (det.exposure === 'too_dark')                         return 'Too dark, find more light or move closer to a window';
    if (det.exposure === 'too_bright')                       return 'Too bright, dim the light or step out of direct sun';
    if (det.exposure === 'too_hard')                         return 'Light is too one-sided, step away from the window or add a softer light on the other side';
    // Differentiate front-back tilt (camera too high / too low / pointing up
    // or down) from left-right tilt (rotated around its long axis). Front-
    // back tilt is what made the back-angle look like she was photographed
    // from a chair — that needs an explicit "drop to her belly height" hint.
    if (level.supported && level.tiltFB > 12)                return 'Camera angle too steep, hold the phone at her belly height, perfectly straight';
    if (level.supported && !level.levelOk)                   return 'Hold the phone perfectly upright, no left or right tilt';
    if (!det.inFrame)                                        return 'Step into the dashed guide';
    if (det.distance === 'too_far')                          return 'A small step closer';
    if (det.distance === 'too_close')                        return 'A small step back';
    if (det.locked)                                          return holdStill ? 'Holding still...' : 'Lovely, tap or hold still';
    return 'Settle into the guide';
  })();

  const shutterBorder = ready ? '#e5cf88' : det.inFrame ? 'rgba(212,165,154,.95)' : 'rgba(236,231,218,.55)';
  const shutterFill   = phase === 'count' ? 'var(--rose)' : ready ? '#e5cf88' : det.inFrame ? 'var(--rose)' : 'var(--paper)';

  return (
    <div className="scr scr--dark" style={{ background: '#13160f' }}>
      {/* Camera feed — stronger desaturation so it reads as a scanning device,
          not a colour photo. CSS filter is display-only; grab bytes stay raw. */}
      <video ref={camera.videoRef} autoPlay playsInline muted
        style={{
          position: 'absolute', inset: 0, width: '100%', height: '100%',
          objectFit: 'cover', background: '#0e0f0a',
          opacity: camera.ready && phase !== 'review' && phase !== 'rotating' ? 1 : 0,
          transition: 'opacity .25s',
          filter: 'grayscale(0.72) brightness(0.82) contrast(1.12)',
        }} />
      {phase === 'aim' && camera.ready && (
        <>
          {/* Horizontal raster overlay — mimics scanner CCD line structure */}
          <div style={{
            position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 2,
            background: 'repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,.08) 3px, rgba(0,0,0,.08) 4px)',
          }} />
          {/* Sweep beam: 80px tall, bright gold line at the leading edge with a
              glowing wake above it. Bounces top-to-bottom continuously. */}
          <div style={{
            position: 'absolute', left: 0, right: 0, top: 0, height: 80,
            background: 'linear-gradient(180deg, transparent 0%, rgba(229,207,136,.06) 50%, rgba(229,207,136,.32) 82%, rgba(245,220,110,.95) 96%, rgba(255,240,140,1) 100%)',
            boxShadow: '0 6px 30px 12px rgba(229,207,136,.35)',
            animation: 'scan-sweep 2.5s ease-in-out infinite alternate',
            pointerEvents: 'none', zIndex: 3,
          }} />
          {/* Scanning badge — bottom-right, above shutter */}
          <div style={{
            position: 'absolute', bottom: 118, right: 20, zIndex: 4,
            display: 'flex', alignItems: 'center', gap: 6,
            padding: '5px 11px 5px 9px', borderRadius: 6,
            background: 'rgba(14,15,10,.78)',
            border: '1px solid rgba(229,207,136,.24)',
            fontSize: 10, letterSpacing: '.14em', fontWeight: 700,
            color: 'rgba(229,207,136,.82)', textTransform: 'uppercase',
            pointerEvents: 'none',
          }}>
            <span style={{
              width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
              background: 'rgba(229,207,136,.9)',
              animation: 'scan-dot 1s ease-in-out infinite alternate',
              display: 'inline-block',
            }} />
            Scanning
          </div>
        </>
      )}
      {(!camera.ready || phase === 'review' || phase === 'rotating') && (
        <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(120% 80% at 50% 38%, #2a2c20 0%, #1a1c14 55%, #0e0f0a 100%)' }} />
      )}
      {/* Soft top + bottom vignette so the framing guide stays legible
          regardless of the camera scene's brightness. */}
      <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', background: 'linear-gradient(180deg, rgba(0,0,0,.45) 0%, transparent 22% 72%, rgba(0,0,0,.55) 100%)' }} />

      {/* Camera-permission banner when getUserMedia failed. The flow still
          works: the shutter falls back to a placeholder file, the user just
          can't see themselves. */}
      {camera.error === 'permission-denied' && phase === 'aim' && (
        <div style={{ position: 'absolute', top: 60, left: 18, right: 18, padding: '10px 14px', borderRadius: 12, background: 'rgba(192,140,128,.18)', border: '1px solid rgba(192,140,128,.34)', color: 'var(--rose)', fontSize: 12.5, fontWeight: 600, zIndex: 4, textAlign: 'center' }}>
          We need camera access to help frame your photos. Allow it in your
          phone settings and reopen the app.
        </div>
      )}

      {phase !== 'review' && phase !== 'rotating' && (
        <FramingGuide angleKey={angle.key} frame={frameState} />
      )}

      {/* top status */}
      <div style={{ position: 'relative', padding: '8px 20px 0' }}>
        <TopBar onBack={() => ctx.back()} dark onHelp={ctx.onHelp} />
        <div style={{ display: 'flex', gap: 7, justifyContent: 'center', marginTop: 2 }}>
          {angles.map((a, i) => (
            <div key={`${a.key}-${a.takeIdx ?? 0}-${a.locationIdx ?? 0}`} style={{
              flex: 1, maxWidth: 56, height: 4, borderRadius: 999,
              background: i < approved ? 'var(--rose)' : i === shot ? 'rgba(236,231,218,.65)' : 'rgba(236,231,218,.2)',
              transition: 'background .3s',
            }} />
          ))}
        </div>
      </div>

      {/* angle instruction + level-check pill */}
      {phase !== 'review' && phase !== 'rotating' && (
        <div style={{ position: 'relative', textAlign: 'center', marginTop: 10 }} className="fadein" key={angle.key}>
          <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '7px 15px', borderRadius: 999, background: 'rgba(20,22,15,.55)', border: '1px solid rgba(236,231,218,.16)', backdropFilter: 'blur(4px)' }}>
            <span className="serif-num" style={{ color: 'var(--gold-soft)', fontSize: 17 }}>{String(shot + 1).padStart(2, '0')}</span>
            <span style={{ fontWeight: 700, fontSize: 14.5 }}>{angle.label}</span>
            <span style={{ width: 1, height: 14, background: 'rgba(236,231,218,.25)' }} />
            <span style={{ fontSize: 13, color: 'var(--on-dark-mute)' }}>{angle.instruct}</span>
          </div>
          {level.supported && !level.levelOk && (
            <div style={{ marginTop: 10 }}>
              <span style={{
                display: 'inline-flex', alignItems: 'center', gap: 6,
                padding: '5px 12px', borderRadius: 999, fontSize: 12, fontWeight: 600,
                background: 'rgba(192,140,128,.18)', color: 'var(--rose)',
                border: '1px solid rgba(192,140,128,.32)',
              }}>
                Hold the phone level
              </span>
            </div>
          )}
        </div>
      )}

      {/* countdown */}
      {phase === 'count' && (
        <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
          <div key={count} className="serif-num" style={{
            fontSize: 132, color: 'var(--paper)', textShadow: '0 4px 30px rgba(0,0,0,.5)',
            animation: 'cnt .82s ease both',
          }}>{count}</div>
        </div>
      )}

      <div className="grow" />

      {/* shutter / hint */}
      {phase !== 'review' && phase !== 'rotating' && (
        <div style={{ position: 'relative', padding: '0 26px 26px', textAlign: 'center' }}>
          <p style={{ fontSize: 13, color: 'var(--on-dark-mute)', margin: '0 0 4px', minHeight: 18, transition: 'color .3s' }}>
            {hint}
          </p>
          <p style={{ fontSize: 10.5, color: 'rgba(236,231,218,.28)', margin: '0 0 14px', letterSpacing: '.02em' }}>
            Live sculpture preview. Photos upload in full colour.
          </p>
          <button onClick={onShutter} aria-label="Capture"
            disabled={phase === 'count' || !det.inFrame}
            style={{
              width: 76, height: 76, borderRadius: 999, margin: '0 auto',
              cursor: (phase === 'count' || !det.inFrame) ? 'default' : 'pointer',
              border: `4px solid ${shutterBorder}`,
              background: 'transparent', padding: 4, display: 'flex',
              transition: 'border-color .3s, opacity .3s, transform .15s, box-shadow .3s',
              opacity: phase === 'count' ? .5 : det.inFrame ? 1 : .55,
              transform: ready ? 'scale(1.04)' : 'scale(1)',
              boxShadow: ready ? '0 0 28px rgba(229,207,136,.45)' : 'none',
            }}>
            <span style={{
              flex: 1, borderRadius: 999, background: shutterFill,
              transition: 'background .25s',
            }} />
          </button>
        </div>
      )}

      {/* flash */}
      {flash && <div style={{ position: 'absolute', inset: 0, background: '#fff', animation: 'flash .22s ease both' }} />}

      {/* toast checkpoint */}
      {toast && (
        <div style={{ position: 'absolute', top: 92, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: 8, padding: '9px 16px', borderRadius: 999, background: 'var(--ink)', color: 'var(--paper)', fontSize: 13.5, fontWeight: 600, boxShadow: '0 10px 30px rgba(0,0,0,.4)', animation: 'scr-in .3s ease both', zIndex: 7 }}>
          <span style={{ display: 'flex', color: 'var(--rose)' }}><IcCheck size={16} /></span> {toast}
        </div>
      )}

      {/* rotation overlay between shots */}
      {phase === 'rotating' && (() => {
        const prevA = angles[(shot - 1 + angles.length) % angles.length];
        return (
          <RotationOverlay
            from={prevAngleKey || 'front'}
            to={angle.key}
            instruct={prevA.nextInstruct}
            hold={false}
            onDone={() => setPhase('aim')}
          />
        );
      })()}

      {/* review sheet */}
      {phase === 'review' && (
        <ReviewSheet angle={angle} failed={reviewFailed} onApprove={approve} onRetake={retake} idx={shot} total={angles.length} frameSrc={reviewFrame} />
      )}
    </div>
  );
}

function ReviewSheet({ angle, failed, onApprove, onRetake, idx, total, frameSrc }) {
  return (
    <div style={{ position: 'absolute', inset: 0, background: 'rgba(11,12,8,.55)', backdropFilter: 'blur(3px)', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', zIndex: 6 }}>
      <div className="fadein" style={{ background: 'var(--paper)', borderRadius: '26px 26px 0 0', padding: 22, color: 'var(--ink)' }}>
        <div style={{ width: 38, height: 4, borderRadius: 999, background: 'var(--hairline)', margin: '0 auto 16px' }} />
        <div style={{ borderRadius: 16, overflow: 'hidden', position: 'relative', height: 210 }}>
          {frameSrc
            ? <img src={frameSrc} alt={`${angle.label} captured`}
                style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block',
                         filter: 'grayscale(1) contrast(1.9) brightness(0.82) sepia(0.85) saturate(3.8) hue-rotate(3deg)' }} />
            : <Placeholder label={`${angle.label.toLowerCase()} · captured`} dark />}
          {failed && <div style={{ position: 'absolute', inset: 0, background: 'rgba(8,9,6,.45)' }} />}
        </div>

        {failed ? (
          <>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}>
              <span className="tag" style={{ background: 'color-mix(in oklch, var(--rose), transparent 84%)', borderColor: 'color-mix(in oklch, var(--rose), transparent 64%)', color: 'var(--rose-deep)' }}>
                <IcSun size={14} /> A little underexposed
              </span>
            </div>
            <h3 className="title" style={{ fontSize: 21, marginTop: 12 }}>Let's catch more light.</h3>
            <p className="lede" style={{ fontSize: 14.5, marginTop: 6 }}>
              The right side fell into shadow. Turn the subject a touch toward the window, then we'll take this one again.
            </p>
            <button className="btn btn--primary" style={{ marginTop: 16 }} onClick={onRetake}>
              <IcRetake size={18} /> Retake this photo
            </button>
          </>
        ) : (
          <>
            <h3 className="title" style={{ fontSize: 21, marginTop: 16 }}>Lovely, keep this one?</h3>
            <p className="lede" style={{ fontSize: 14.5, marginTop: 6 }}>
              {angle.label} angle looks well-lit and centred.
            </p>
            <div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
              <button className="btn btn--ghost" style={{ flex: 1 }} onClick={onRetake}>
                <IcRetake size={18} /> Retake
              </button>
              <button className="btn btn--accent" style={{ flex: 1.4 }} onClick={onApprove}>
                <IcCheck size={18} /> {idx >= (total || ANGLES.length) - 1 ? 'Finish' : 'Keep & continue'}
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

Object.assign(window, { CaptureScreen, FramingGuide, ReviewSheet, AngleSilhouette, RotationOverlay, ANGLES });
