// avatar-qa.jsx — in-app runtime QA for MediaPipe → VRM avatar motion
const { useEffect: useEffectAvatarQa, useRef: useRefAvatarQa, useState: useStateAvatarQa } = React;

const AVATAR_QA_PATHS = {
  runtime: 'vendor/avatar-runtime/dist/index.js',
  gltfLoader: 'vendor/three/examples/jsm/loaders/GLTFLoader.js',
  threeVrm: 'vendor/@pixiv/three-vrm/lib/three-vrm.module.js',
  three: 'vendor/three/build/three.module.js',
  wasm: 'vendor/@mediapipe/tasks-vision/wasm',
  model: 'wasm/pose_landmarker_lite.task',
  sample: 'assets/avatar-qa/sample-pose.jpg',
};

function avatarQaUrl(path) {
  if (/^https?:\/\//.test(path) || path.startsWith('/')) return path;
  return new URL(path, window.location.href).href;
}

const AVATAR_CAMERA_FACING_YAW = Math.PI;


function avatarQaInitialChecks() {
  return [
    { id: 'modules', label: 'Runtime modules', status: 'pending', detail: '대기 중' },
    { id: 'catalog', label: 'Avatar catalog', status: 'pending', detail: '대기 중' },
    { id: 'assets', label: 'Local VRM assets', status: 'pending', detail: '대기 중' },
    { id: 'vrm', label: 'three-vrm load', status: 'pending', detail: '대기 중' },
    { id: 'pose', label: 'MediaPipe pose', status: 'pending', detail: '대기 중' },
    { id: 'retarget', label: 'Retarget → bones', status: 'pending', detail: '대기 중' },
    { id: 'camera', label: 'Live camera loop', status: 'idle', detail: '선택 실행' },
  ];
}

function avatarQaBadgeColor(status) {
  if (status === 'pass') return { bg: T.goodSoft, fg: T.good, text: 'PASS' };
  if (status === 'fail') return { bg: T.pinkSoft, fg: T.pink, text: 'FAIL' };
  if (status === 'running') return { bg: T.calmSoft, fg: T.calm, text: 'RUN' };
  if (status === 'idle') return { bg: T.surface2, fg: T.ink2, text: 'IDLE' };
  return { bg: T.amberSoft, fg: T.amberDark, text: 'WAIT' };
}

function AvatarQaStatusRow({ row }) {
  const badge = avatarQaBadgeColor(row.status);
  return (
    <div data-avatar-qa-status={row.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 0', borderBottom: `0.5px solid ${T.line}` }}>
      <div style={{ width: 58, height: 24, borderRadius: 999, background: badge.bg, color: badge.fg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10.5, fontWeight: 900, letterSpacing: 0.5 }}>{badge.text}</div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 14.5, fontWeight: 800, color: T.ink }}>{row.label}</div>
        <div style={{ fontSize: 12.5, fontWeight: 600, color: T.ink3, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.detail}</div>
      </div>
    </div>
  );
}

function AvatarQaScreen({ back }) {
  const canvasRef = useRefAvatarQa(null);
  const videoRef = useRefAvatarQa(null);
  const imageRef = useRefAvatarQa(null);
  const qaRef = useRefAvatarQa({ modules: null, renderer: null, scene: null, camera: null, renderRaf: 0, vrm: null, modelRoot: null, runtime: null, cameraRuntime: null, cameraDetach: null, disposed: false });
  const [checks, setChecks] = useStateAvatarQa(avatarQaInitialChecks);
  const [avatars, setAvatars] = useStateAvatarQa([]);
  const [selectedId, setSelectedId] = useStateAvatarQa('');
  const [mode, setMode] = useStateAvatarQa('sample');
  const [busy, setBusy] = useStateAvatarQa(false);
  const [stats, setStats] = useStateAvatarQa({ landmarks: 0, worldLandmarks: 0, rigBones: 0, hipsChanged: false, catalogVerified: 0, frameCount: 0, poseScore: 0, cue: '대기 중' });
  const [error, setError] = useStateAvatarQa('');

  const patchCheck = (id, patch) => {
    setChecks(rows => rows.map(row => row.id === id ? { ...row, ...patch } : row));
  };

  const selectedAvatar = avatars.find(a => a.id === selectedId) || avatars[0];

  async function loadModules() {
    if (qaRef.current.modules) return qaRef.current.modules;
    patchCheck('modules', { status: 'running', detail: 'ESM 로딩 중' });
    const modules = window.FitbodyAvatarQaModules || await (window.FitbodyAvatarQaModulesReady || new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('Avatar QA ESM modules did not load.')), 8000);
      window.addEventListener('fitbody-avatar-qa-modules-ready', () => {
        clearTimeout(timer);
        resolve(window.FitbodyAvatarQaModules);
      }, { once: true });
    }));
    qaRef.current.modules = modules;
    patchCheck('modules', { status: 'pass', detail: 'avatar-runtime / MediaPipe / three-vrm import OK' });
    return modules;
  }

  function ensureScene(modules) {
    const qa = qaRef.current;
    if (qa.renderer) return qa;
    const { THREE } = modules;
    const canvas = canvasRef.current;
    if (!canvas) throw new Error('QA canvas is not mounted.');
    const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100);
    camera.position.set(0, 1.25, 3.0);
    scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2.4));
    const key = new THREE.DirectionalLight(0xffffff, 2.0);
    key.position.set(2.5, 3.2, 3.5);
    scene.add(key);
    qa.renderer = renderer;
    qa.scene = scene;
    qa.camera = camera;
    const render = () => {
      if (qa.disposed) return;
      const w = Math.max(1, canvas.clientWidth || 320);
      const h = Math.max(1, canvas.clientHeight || 300);
      if (canvas.width !== Math.round(w * renderer.getPixelRatio()) || canvas.height !== Math.round(h * renderer.getPixelRatio())) {
        renderer.setSize(w, h, false);
        camera.aspect = w / h;
        camera.updateProjectionMatrix();
      }
      renderer.render(scene, camera);
      qa.renderRaf = requestAnimationFrame(render);
    };
    render();
    return qa;
  }

  function frameObject(root, modules) {
    const { THREE } = modules;
    const box = new THREE.Box3().setFromObject(root);
    const size = new THREE.Vector3();
    const center = new THREE.Vector3();
    box.getSize(size);
    box.getCenter(center);
    root.position.x -= center.x;
    root.position.z -= center.z;
    root.position.y -= box.min.y;
    const span = Math.max(size.x, size.y, size.z, 0.001);
    const scale = Math.min(1.4, 1.9 / span);
    root.scale.multiplyScalar(scale);
    root.rotation.y = AVATAR_CAMERA_FACING_YAW;
    root.updateMatrixWorld?.(true);
  }

  async function verifyCatalogAssets(catalog) {
    patchCheck('assets', { status: 'running', detail: '5개 VRM HTTP/GLB 검증 중' });
    const results = [];
    for (const avatar of catalog) {
      const path = avatar.metadata && avatar.metadata.localModelPath;
      const expectedBytes = avatar.metadata && avatar.metadata.localModelBytes;
      if (!path || !expectedBytes) throw new Error(`${avatar.name}: local VRM metadata missing.`);
      const response = await fetch(path);
      const data = await response.arrayBuffer();
      const magic = new TextDecoder().decode(new Uint8Array(data.slice(0, 4)));
      const version = new DataView(data).getUint32(4, true);
      results.push({ name: avatar.name, ok: response.ok && data.byteLength === expectedBytes && magic === 'glTF' && version === 2, bytes: data.byteLength });
    }
    const passed = results.filter(r => r.ok).length;
    patchCheck('assets', { status: passed === catalog.length ? 'pass' : 'fail', detail: `${passed}/${catalog.length} VRM served with GLB v2 header` });
    setStats(s => ({ ...s, catalogVerified: passed }));
    return results;
  }

  async function loadVrm(avatar, modules) {
    const qa = ensureScene(modules);
    const { GLTFLoader, VRMLoaderPlugin } = modules;
    patchCheck('vrm', { status: 'running', detail: `${avatar.name} 로딩 중` });
    if (qa.modelRoot) qa.scene.remove(qa.modelRoot);
    const loader = new GLTFLoader();
    loader.register(parser => new VRMLoaderPlugin(parser));
    const url = avatar.metadata && avatar.metadata.localModelPath ? avatar.metadata.localModelPath : avatar.modelUrl;
    const gltf = await loader.loadAsync(url);
    const vrm = gltf.userData && gltf.userData.vrm;
    if (!vrm || !vrm.humanoid) throw new Error(`${avatar.name}: VRM humanoid not found.`);
    const root = vrm.scene || gltf.scene;
    frameObject(root, modules);
    qa.scene.add(root);
    qa.vrm = vrm;
    qa.modelRoot = root;
    patchCheck('vrm', { status: 'pass', detail: `${avatar.name} humanoid bones loaded` });
    return vrm;
  }

  function loadSampleImage() {
    if (imageRef.current) return Promise.resolve(imageRef.current);
    return new Promise((resolve, reject) => {
      const image = new Image();
      image.crossOrigin = 'anonymous';
      image.onload = () => {
        const canvas = document.createElement('canvas');
        canvas.width = image.naturalWidth || image.width;
        canvas.height = image.naturalHeight || image.height;
        const ctx = canvas.getContext('2d');
        if (!ctx) return reject(new Error('Sample pose canvas is unavailable.'));
        ctx.drawImage(image, 0, 0);
        imageRef.current = canvas;
        resolve(canvas);
      };
      image.onerror = () => reject(new Error('Sample pose image failed to load.'));
      image.src = avatarQaUrl(AVATAR_QA_PATHS.sample);
    });
  }

  function publishResult(result) {
    window.__fitbodyAvatarQaResult = result;
    if (result && result.error) {
      document.title = `Fitbody Avatar QA: FAIL ${result.error}`;
    } else if (result && result.mode === 'catalog') {
      document.title = `Fitbody Avatar QA: ${result.passed === result.total ? 'PASS' : 'FAIL'} ${result.passed}/${result.total}`;
    } else if (result && result.mode === 'sample') {
      document.title = `Fitbody Avatar QA: SAMPLE ${result.landmarks}/${result.worldLandmarks}/${result.rigBones}`;
    } else if (result && result.mode === 'camera') {
      document.title = `Fitbody Avatar QA: CAMERA ${result.landmarks}/${result.worldLandmarks} score ${result.poseScore || 0}`;
    } else if (result && result.mode === 'injected') {
      document.title = `Fitbody Avatar QA: INJECTED ${result.passed ? 'PASS' : 'FAIL'} ${result.frameCount}/${result.rigBones}`;
    }
    if (window.__fitbodyQaReport) {
      window.__fitbodyQaReport(result);
    }
    window.dispatchEvent(new CustomEvent('fitbody-avatar-qa-result', { detail: result }));
  }

  function syntheticLandmark(x, y, z, visibility = 0.99) {
    return { x, y, z, visibility, presence: visibility };
  }

  function buildSyntheticPoseFrame(phase) {
    const sway = phase * 0.035;
    const armLift = phase * 0.11;
    const headTurn = phase * 0.07;
    const points = Array.from({ length: 33 }, () => syntheticLandmark(0.5, 0.5, 0, 0.96));
    const set = (index, x, y, z, visibility) => { points[index] = syntheticLandmark(1 - x, y, z, visibility); };

    set(0, 0.5 + headTurn, 0.17, -0.18);
    set(2, 0.46 + headTurn * 0.7, 0.15, -0.12);
    set(5, 0.54 + headTurn * 0.7, 0.15, -0.12);
    set(7, 0.43 + headTurn * 0.45, 0.18, -0.1);
    set(8, 0.57 + headTurn * 0.45, 0.18, -0.1);
    set(11, 0.36 + sway, 0.34, -0.04);
    set(12, 0.64 + sway, 0.35, 0.04);
    set(13, 0.25 + sway, 0.48 - armLift, -0.12);
    set(14, 0.75 + sway, 0.48 + armLift * 0.2, 0.1);
    set(15, 0.2 + sway, 0.64 - armLift * 1.3, -0.2);
    set(16, 0.8 + sway, 0.62 + armLift * 0.35, 0.16);
    set(17, 0.18 + sway, 0.66 - armLift * 1.35, -0.2);
    set(18, 0.82 + sway, 0.64 + armLift * 0.35, 0.16);
    set(19, 0.2 + sway, 0.66 - armLift * 1.38, -0.22);
    set(20, 0.8 + sway, 0.64 + armLift * 0.35, 0.18);
    set(23, 0.42 - sway * 0.45, 0.68, 0.03);
    set(24, 0.58 - sway * 0.45, 0.69, -0.03);
    set(25, 0.39 - sway, 0.88, 0.07);
    set(26, 0.61 - sway, 0.87, -0.07);
    set(27, 0.38 - sway, 1.06, 0.12);
    set(28, 0.62 - sway, 1.05, -0.12);
    set(29, 0.37 - sway, 1.09, 0.17);
    set(30, 0.63 - sway, 1.08, -0.17);
    set(31, 0.36 - sway, 1.08, 0.23);
    set(32, 0.64 - sway, 1.07, -0.23);

    const worldLandmarks = points.map(point => ({
      x: (point.x - 0.5) * 1.8,
      y: (point.y - 0.68) * -1.8,
      z: point.z,
      visibility: point.visibility,
      presence: point.presence,
    }));

    return { landmarks: [points], worldLandmarks: [worldLandmarks] };
  }

  function scorePoseLandmarks(landmarks, worldLandmarks) {
    const points = Array.isArray(landmarks) ? landmarks : [];
    const world = Array.isArray(worldLandmarks) ? worldLandmarks : [];
    const visible = points.filter(point => (point.visibility ?? point.presence ?? 1) >= 0.55).length;
    const hasCore = [11, 12, 23, 24].every(index => points[index] && (points[index].visibility ?? points[index].presence ?? 1) >= 0.55);
    const leftShoulder = points[11];
    const rightShoulder = points[12];
    const leftHip = points[23];
    const rightHip = points[24];
    const shoulderSpan = leftShoulder && rightShoulder ? Math.abs(leftShoulder.x - rightShoulder.x) : 0;
    const hipSpan = leftHip && rightHip ? Math.abs(leftHip.x - rightHip.x) : 0;
    const centeredCore = leftShoulder && rightShoulder && leftHip && rightHip
      ? Math.abs(((leftShoulder.x + rightShoulder.x + leftHip.x + rightHip.x) / 4) - 0.5)
      : 1;
    const visibilityScore = Math.min(1, visible / 33);
    const coreScore = hasCore ? 0.28 : 0;
    const framingScore = shoulderSpan > 0.12 && hipSpan > 0.10 && centeredCore < 0.22 ? 0.22 : 0;
    const worldScore = world.length === 33 ? 0.12 : 0;
    const poseScore = Math.round(Math.max(0, Math.min(1, visibilityScore * 0.38 + coreScore + framingScore + worldScore)) * 100);
    let cue = '전신이 카메라에 보이도록 한 걸음 물러서세요.';
    if (points.length === 33 && visible >= 30 && hasCore && centeredCore < 0.22) cue = '좋아요. 전신 관절점이 안정적으로 잡혔습니다.';
    else if (!hasCore) cue = '어깨와 골반이 모두 보이게 정면을 맞춰주세요.';
    else if (centeredCore >= 0.22) cue = '몸을 화면 중앙으로 옮겨주세요.';
    else if (visible >= 24) cue = '거의 좋아요. 손과 발끝까지 프레임에 넣어주세요.';
    return { poseScore, cue, visibleLandmarks: visible };
  }

  function rotationSnapshot(vrm) {
    const readBone = (name) => {
      const node = vrm.humanoid.getNormalizedBoneNode(name);
      if (!node || !node.rotation) return null;
      return { x: node.rotation.x || 0, y: node.rotation.y || 0, z: node.rotation.z || 0 };
    };
    return {
      hips: readBone('hips'),
      head: readBone('head'),
      leftUpperArm: readBone('leftUpperArm'),
      rightUpperArm: readBone('rightUpperArm'),
    };
  }

  function rotationDelta(before, after) {
    const delta = {};
    Object.keys(after).forEach(name => {
      if (!before[name] || !after[name]) {
        delta[name] = null;
        return;
      }
      delta[name] = {
        x: Math.abs(after[name].x - before[name].x),
        y: Math.abs(after[name].y - before[name].y),
        z: Math.abs(after[name].z - before[name].z),
      };
      delta[name].max = Math.max(delta[name].x, delta[name].y, delta[name].z);
    });
    return delta;
  }

  function maxRotationDelta(deltas, names) {
    return names.reduce((max, name) => Math.max(max, deltas[name] ? deltas[name].max : 0), 0);
  }

  async function runSampleQa(avatarOverride) {
    setBusy(true);
    setError('');
    try {
      const modules = await loadModules();
      const catalog = modules.runtime.FITBODY_AVATARS || [];
      if (!catalog.length) throw new Error('FITBODY_AVATARS is empty.');
      setAvatars(catalog);
      const nextAvatar = avatarOverride || selectedAvatar || catalog[0];
      if (!selectedId) setSelectedId(nextAvatar.id);
      patchCheck('catalog', { status: catalog.length === 5 ? 'pass' : 'fail', detail: `${catalog.length} avatars · human ${catalog.filter(a => a.category === 'human').length} / special ${catalog.filter(a => a.category === 'special').length}` });
      await verifyCatalogAssets(catalog);
      const vrm = await loadVrm(nextAvatar, modules);
      const image = await loadSampleImage();
      patchCheck('pose', { status: 'running', detail: 'sample image inference 중' });
      const runtime = await modules.runtime.createBrowserVrmAvatarRuntime({
        source: image,
        vrm,
        pose: {
          wasmPath: avatarQaUrl(AVATAR_QA_PATHS.wasm),
          modelAssetPath: avatarQaUrl(AVATAR_QA_PATHS.model),
          delegate: 'CPU',
          numPoses: 1,
        },
        adapter: { updateDeltaSeconds: 0.016 },
        session: { minFrameIntervalMs: 0 },
      });
      const before = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x;
      const frame = runtime.step(performance.now());
      runtime.close();
      const after = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x;
      const result = {
        mode: 'sample',
        avatar: nextAvatar.name,
        landmarks: frame.pose.landmarks[0] ? frame.pose.landmarks[0].length : 0,
        worldLandmarks: frame.pose.worldLandmarks && frame.pose.worldLandmarks[0] ? frame.pose.worldLandmarks[0].length : 0,
        rigBones: Object.keys(frame.rig).length,
        hipsChanged: Math.abs(after - before) > 1e-8,
        catalogCount: catalog.length,
      };
      patchCheck('pose', { status: result.landmarks === 33 ? 'pass' : 'fail', detail: `${result.landmarks} landmarks / ${result.worldLandmarks} world` });
      patchCheck('retarget', { status: result.rigBones >= 18 && result.hipsChanged ? 'pass' : 'fail', detail: `${result.rigBones} rig bones · hips rotation ${result.hipsChanged ? 'changed' : 'unchanged'}` });
      setStats(s => ({ ...s, ...result }));
      publishResult(result);
      return result;
    } catch (e) {
      const message = e && e.message ? e.message : String(e);
      setError(message);
      publishResult({ mode: 'sample', error: message });
      throw e;
    } finally {
      setBusy(false);
    }
  }

  async function runFullCatalogQa() {
    setBusy(true);
    setError('');
    try {
      const modules = await loadModules();
      const catalog = modules.runtime.FITBODY_AVATARS || [];
      const image = await loadSampleImage();
      const detector = await modules.runtime.createMediaPipePoseDetector({ wasmPath: avatarQaUrl(AVATAR_QA_PATHS.wasm), modelAssetPath: avatarQaUrl(AVATAR_QA_PATHS.model), delegate: 'CPU', numPoses: 1 });
      const results = [];
      for (const avatar of catalog) {
        setSelectedId(avatar.id);
        const vrm = await loadVrm(avatar, modules);
        const adapter = modules.runtime.createVrmAvatarPoseAdapter(vrm, { updateDeltaSeconds: 0.016 });
        const motion = modules.runtime.createAvatarMotionRuntime({ detector, adapter, minFrameIntervalMs: 0 });
        const before = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x;
        const frame = motion.step(image, performance.now());
        const after = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x;
        motion.stop();
        results.push({ name: avatar.name, landmarks: frame.pose.landmarks[0] ? frame.pose.landmarks[0].length : 0, worldLandmarks: frame.pose.worldLandmarks && frame.pose.worldLandmarks[0] ? frame.pose.worldLandmarks[0].length : 0, rigBones: Object.keys(frame.rig).length, hipsChanged: Math.abs(after - before) > 1e-8 });
      }
      detector.close();
      const passed = results.filter(r => r.landmarks === 33 && r.worldLandmarks === 33 && r.rigBones >= 18 && r.hipsChanged).length;
      const result = { mode: 'catalog', passed, total: results.length, results };
      patchCheck('retarget', { status: passed === results.length ? 'pass' : 'fail', detail: `${passed}/${results.length} avatars passed full chain` });
      setStats(s => ({ ...s, catalogVerified: passed }));
      publishResult(result);
      return result;
    } catch (e) {
      const message = e && e.message ? e.message : String(e);
      setError(message);
      publishResult({ mode: 'catalog', error: message });
      throw e;
    } finally {
      setBusy(false);
    }
  }

  async function runInjectedPoseQa(avatarOverride) {
    setBusy(true);
    setError('');
    try {
      const modules = await loadModules();
      const catalog = modules.runtime.FITBODY_AVATARS || [];
      if (catalog.length) setAvatars(catalog);
      const avatar = avatarOverride || selectedAvatar || catalog[0];
      if (!avatar) throw new Error('No avatar selected.');
      if (!selectedId) setSelectedId(avatar.id);
      if (qaRef.current.runtime) qaRef.current.runtime.close();
      if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close();
      if (qaRef.current.cameraDetach) qaRef.current.cameraDetach();
      qaRef.current.runtime = null;
      qaRef.current.cameraRuntime = null;
      qaRef.current.cameraDetach = null;

      patchCheck('pose', { status: 'running', detail: '가상 33-landmark 프레임 주입 중' });
      const vrm = await loadVrm(avatar, modules);
      const syntheticFrames = [buildSyntheticPoseFrame(0), buildSyntheticPoseFrame(1), buildSyntheticPoseFrame(2)];
      let detectIndex = 0;
      const detector = {
        detectForVideo: (_input, timestampMs) => {
          const sourceFrame = syntheticFrames[Math.min(detectIndex, syntheticFrames.length - 1)];
          detectIndex += 1;
          return { ...sourceFrame, timestampMs };
        },
        close: () => {},
      };
      const adapter = modules.runtime.createVrmAvatarPoseAdapter(vrm, { updateDeltaSeconds: 0.016 });
      const motion = modules.runtime.createAvatarMotionRuntime({
        detector,
        adapter,
        retarget: { cameraFacing: true },
        minFrameIntervalMs: 0,
        stabilization: false,
      });
      const input = canvasRef.current || document.createElement('canvas');
      const before = rotationSnapshot(vrm);
      const frames = syntheticFrames.map((_, index) => motion.step(input, 1000 + index * 33));
      motion.close();
      const after = rotationSnapshot(vrm);
      const deltas = rotationDelta(before, after);
      const frame = frames[frames.length - 1];
      const rigBones = Object.keys(frame.rig).length;
      const hipsRotationDelta = deltas.hips ? deltas.hips.max : 0;
      const headRotationDelta = deltas.head ? deltas.head.max : 0;
      const leftArmRotationDelta = deltas.leftUpperArm ? deltas.leftUpperArm.max : 0;
      const rightArmRotationDelta = deltas.rightUpperArm ? deltas.rightUpperArm.max : 0;
      const modelRootYaw = qaRef.current.modelRoot ? qaRef.current.modelRoot.rotation.y : null;
      const evidence = {
        frameCount: frames.length,
        rigBones,
        landmarks: frame.pose.landmarks[0] ? frame.pose.landmarks[0].length : 0,
        worldLandmarks: frame.pose.worldLandmarks && frame.pose.worldLandmarks[0] ? frame.pose.worldLandmarks[0].length : 0,
        modelRootYaw,
        facingCamera: Math.abs(Math.abs(modelRootYaw || 0) - Math.PI) < 1e-6,
        hipsRotationDelta,
        headRotationDelta,
        leftArmRotationDelta,
        rightArmRotationDelta,
        maxCoreDelta: Math.max(hipsRotationDelta, headRotationDelta),
        maxArmDelta: Math.max(leftArmRotationDelta, rightArmRotationDelta),
        rotationDeltas: deltas,
      };
      const passed = evidence.frameCount === syntheticFrames.length
        && evidence.landmarks === 33
        && evidence.worldLandmarks === 33
        && evidence.rigBones >= 18
        && evidence.facingCamera
        && evidence.maxCoreDelta > 1e-5
        && evidence.maxArmDelta > 1e-5;
      const result = { mode: 'injected', avatar: avatar.name, passed, ...evidence };
      patchCheck('pose', { status: evidence.landmarks === 33 && evidence.worldLandmarks === 33 ? 'pass' : 'fail', detail: `가상 ${evidence.frameCount}프레임 · ${evidence.landmarks}/${evidence.worldLandmarks} landmarks` });
      patchCheck('retarget', { status: passed ? 'pass' : 'fail', detail: `${evidence.rigBones} rig bones · facing ${evidence.facingCamera ? 'OK' : 'NO'} · core Δ ${evidence.maxCoreDelta.toFixed(6)} · arm Δ ${evidence.maxArmDelta.toFixed(6)}` });
      setStats(s => ({ ...s, ...evidence, hipsChanged: hipsRotationDelta > 1e-5 }));
      setMode('injected');
      publishResult(result);
      return result;
    } catch (e) {
      const message = e && e.message ? e.message : String(e);
      setError(message);
      patchCheck('retarget', { status: 'fail', detail: message });
      publishResult({ mode: 'injected', error: message });
      throw e;
    } finally {
      setBusy(false);
    }
  }

  async function startCameraQa() {
    setBusy(true);
    setError('');
    try {
      const modules = await loadModules();
      const video = videoRef.current;
      if (!video) throw new Error('Camera video element is not mounted.');
      if (!window.FitbodyCamera) throw new Error('FitbodyCamera is unavailable.');
      if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close();
      if (qaRef.current.cameraDetach) qaRef.current.cameraDetach();
      qaRef.current.cameraRuntime = null;
      qaRef.current.cameraDetach = null;
      patchCheck('camera', { status: 'running', detail: '카메라 권한 대기 중 · 아바타는 안내용입니다' });
      qaRef.current.cameraDetach = window.FitbodyCamera.attach(video, { facingMode: 'user' });
      await new Promise((resolve, reject) => {
        const startedAt = performance.now();
        const tick = () => {
          if (video.readyState >= 2 && video.videoWidth) return resolve();
          if (performance.now() - startedAt > 8000) return reject(new Error('Camera video did not become ready.'));
          requestAnimationFrame(tick);
        };
        tick();
      });
      const detector = await modules.runtime.createMediaPipePoseDetector({
        wasmPath: avatarQaUrl(AVATAR_QA_PATHS.wasm),
        modelAssetPath: avatarQaUrl(AVATAR_QA_PATHS.model),
        delegate: 'CPU',
        numPoses: 1,
      });
      let raf = 0;
      let stopped = false;
      let lastInferenceAt = 0;
      const closeCameraRuntime = () => {
        stopped = true;
        if (raf) cancelAnimationFrame(raf);
        detector.close?.();
      };
      const inferFrame = () => {
        if (stopped) return;
        const now = performance.now();
        if (video.readyState >= 2 && now - lastInferenceAt >= 66) {
          lastInferenceAt = now;
          const pose = (detector.detectForVideo ? detector.detectForVideo(video, now) : detector.detect(video)) || {};
          const landmarks = pose.landmarks && pose.landmarks[0] ? pose.landmarks[0] : [];
          const worldLandmarks = pose.worldLandmarks && pose.worldLandmarks[0] ? pose.worldLandmarks[0] : [];
          const coaching = scorePoseLandmarks(landmarks, worldLandmarks);
          const result = { mode: 'camera', landmarks: landmarks.length, worldLandmarks: worldLandmarks.length, poseScore: coaching.poseScore, cue: coaching.cue, visibleLandmarks: coaching.visibleLandmarks };
          setStats(s => ({ ...s, ...result, rigBones: 0, frameCount: s.frameCount + 1 }));
          patchCheck('camera', { status: result.landmarks ? 'pass' : 'running', detail: `${result.landmarks} dots · score ${result.poseScore} · ${result.cue}` });
          patchCheck('retarget', { status: 'idle', detail: 'camera QA는 VRM 매핑 없이 모션 캡처 점수만 확인' });
          publishResult(result);
        }
        raf = requestAnimationFrame(inferFrame);
      };
      qaRef.current.cameraRuntime = { close: closeCameraRuntime };
      setMode('camera');
      patchCheck('camera', { status: 'running', detail: '모션 캡처 점/코칭 루프 실행 중' });
      inferFrame();
    } catch (e) {
      const message = e && e.message ? e.message : String(e);
      if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close();
      qaRef.current.cameraRuntime = null;
      if (qaRef.current.cameraDetach) qaRef.current.cameraDetach();
      qaRef.current.cameraDetach = null;
      setError(message);
      patchCheck('camera', { status: 'fail', detail: message });
      publishResult({ mode: 'camera', error: message });
      throw e;
    } finally {
      setBusy(false);
    }
  }

  function stopCameraQa() {
    if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close();
    qaRef.current.cameraRuntime = null;
    if (qaRef.current.cameraDetach) qaRef.current.cameraDetach();
    qaRef.current.cameraDetach = null;
    setMode('sample');
    patchCheck('camera', { status: 'idle', detail: 'stopped' });
  }

  useEffectAvatarQa(() => {
    qaRef.current.disposed = false;
    runSampleQa().then(() => {
      const autorun = new URLSearchParams(window.location.search).get('autorun');
      if (autorun === 'catalog') return runFullCatalogQa();
      if (autorun === 'injected') return runInjectedPoseQa();
      return undefined;
    }).catch(() => {});
    window.__fitbodyRunAvatarQa = runFullCatalogQa;
    window.__fitbodyRunAvatarSampleQa = runSampleQa;
    window.__fitbodyRunAvatarInjectedQa = runInjectedPoseQa;
    return () => {
      qaRef.current.disposed = true;
      if (qaRef.current.runtime) qaRef.current.runtime.close();
      if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close();
      if (qaRef.current.cameraDetach) qaRef.current.cameraDetach();
      if (qaRef.current.renderRaf) cancelAnimationFrame(qaRef.current.renderRaf);
      if (qaRef.current.renderer) qaRef.current.renderer.dispose();
      delete window.__fitbodyRunAvatarQa;
      delete window.__fitbodyRunAvatarSampleQa;
      delete window.__fitbodyRunAvatarInjectedQa;
    };
  }, []);

  const selectAvatar = (avatar) => {
    setSelectedId(avatar.id);
    runSampleQa(avatar).catch(() => {});
  };

  return (
    <Screen bg={T.surface} scroll>
      <div data-avatar-qa-root="true">
        <TopBar title="Avatar Runtime QA" onBack={back} right={<span style={{ padding: '7px 10px', borderRadius: 999, background: T.coralSoft, color: T.coralDark, fontSize: 11, fontWeight: 900, letterSpacing: 0.6 }}>TEST</span>}/>
        <AvaSays size={40} style={{ marginBottom: 14 }}>아바타는 “이렇게 움직이세요”를 보여주는 안내/demo asset입니다. 실제 카메라는 VRM에 매핑하지 않고 관절점 모션 캡처와 자세 코칭 점수만 확인합니다.</AvaSays>

        <Card pad={0} style={{ overflow: 'hidden', marginBottom: 12, background: T.ink }}>
          <div style={{ height: 300, position: 'relative', background: `radial-gradient(120% 90% at 50% 20%, ${T.calmSoft}, #101014 70%)` }}>
            <canvas ref={canvasRef} data-avatar-qa-canvas="true" style={{ width: '100%', height: '100%', display: 'block' }}/>
            <video ref={videoRef} playsInline muted style={{ position: 'absolute', right: 12, bottom: 12, width: 94, height: 126, borderRadius: 14, objectFit: 'cover', transform: 'scaleX(-1)', opacity: mode === 'camera' ? 1 : 0.18, border: '1px solid rgba(255,255,255,0.22)' }}/>
            <div style={{ position: 'absolute', top: 12, left: 12, padding: '6px 10px', borderRadius: 999, background: 'rgba(0,0,0,0.45)', color: '#fff', fontSize: 11, fontWeight: 900, letterSpacing: 0.5 }}>{mode === 'camera' ? 'MOCAP DOTS · AVATAR GUIDE ONLY' : mode === 'injected' ? 'VIRTUAL INJECTION' : 'SAMPLE POSE'}</div>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, background: T.line }}>
            {[['랜드마크', stats.landmarks], ['월드', stats.worldLandmarks], [mode === 'camera' ? '점수' : '본', mode === 'camera' ? stats.poseScore : stats.rigBones], ['아바타', stats.catalogVerified || 0]].map((item) => (
              <div key={item[0]} style={{ background: T.bg, padding: '12px 4px', textAlign: 'center' }}>
                <div style={{ fontSize: 18, fontWeight: 900, color: T.ink, fontVariantNumeric: 'tabular-nums' }}>{item[1]}</div>
                <div style={{ fontSize: 10.5, fontWeight: 800, color: T.ink3, marginTop: 2 }}>{item[0]}</div>
              </div>
            ))}
          </div>
        </Card>
          {mode === 'camera' && <div style={{ background: T.bg, borderTop: `1px solid ${T.line}`, padding: '12px 14px', color: T.ink, fontSize: 13, fontWeight: 800, lineHeight: 1.35 }}>카메라 QA: VRM 매핑 없음 · 관절점 {stats.visibleLandmarks || 0}/33 · {stats.cue}</div>}

        {!!avatars.length && (
          <div style={{ display: 'flex', overflowX: 'auto', gap: 8, padding: '2px 0 12px' }}>
            {avatars.map(avatar => (
              <button key={avatar.id} data-avatar-qa-avatar={avatar.id} onClick={() => selectAvatar(avatar)} style={{ flexShrink: 0, height: 38, padding: '0 13px', borderRadius: 999, border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 800, background: (selectedAvatar && selectedAvatar.id === avatar.id) ? T.coral : T.bg, color: (selectedAvatar && selectedAvatar.id === avatar.id) ? '#fff' : T.ink, boxShadow: `inset 0 0 0 1px ${(selectedAvatar && selectedAvatar.id === avatar.id) ? T.coral : T.line2}` }}>{avatar.name}</button>
            ))}
          </div>
        )}

        <Card pad={16} style={{ marginBottom: 12 }}>
          {checks.map(row => <AvatarQaStatusRow key={row.id} row={row}/>) }
        </Card>

        {error && <div data-avatar-qa-error="true" style={{ marginBottom: 12, padding: 13, borderRadius: 14, background: T.pinkSoft, color: T.pink, fontSize: 13, fontWeight: 700, lineHeight: 1.4 }}>{error}</div>}

        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 18 }}>
          <Btn variant="calm" disabled={busy} onClick={() => runSampleQa().catch(() => {})}>샘플 포즈 QA 다시 실행</Btn>
          <Btn variant="soft" size="md" disabled={busy} onClick={() => runFullCatalogQa().catch(() => {})}>5개 아바타 전체 체인 검증</Btn>
          <button type="button" disabled={busy} onClick={() => runInjectedPoseQa().catch(() => {})} data-avatar-injected-qa-button="true" style={{ width: '100%', height: 48, border: 'none', borderRadius: 14, cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.4 : 1, fontFamily: 'inherit', fontSize: 16, fontWeight: 600, letterSpacing: -0.3, background: 'transparent', color: T.coral, boxShadow: `inset 0 0 0 1px ${T.line2}` }}>가상 카메라 주입 QA 실행</button>
          {mode === 'camera' ? <Btn variant="ghost" size="md" onClick={stopCameraQa}>카메라 모션 캡처 QA 정지</Btn> : <Btn variant="outline" size="md" disabled={busy} onClick={() => startCameraQa().catch(() => {})}>실제 카메라 모션 캡처 QA 시작</Btn>}
        </div>
      </div>
    </Screen>
  );
}

Object.assign(window, { AvatarQaScreen });
