import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';

const VIEW_LABELS = ['Top', 'Left', 'Front', 'Bottom', 'Right', 'Back'];
const DAY_MS = 24 * 60 * 60 * 1000;
const VIEW_SPECS = [
  { label: 'Top', dir: new THREE.Vector3(0, 1, 0), up: new THREE.Vector3(0, 0, 1) },
  { label: 'Left', dir: new THREE.Vector3(1, 0, 0), up: new THREE.Vector3(0, 1, 0) },
  { label: 'Front', dir: new THREE.Vector3(0, 0, 1), up: new THREE.Vector3(0, 1, 0) },
  { label: 'Bottom', dir: new THREE.Vector3(0, -1, 0), up: new THREE.Vector3(0, 0, 1) },
  { label: 'Right', dir: new THREE.Vector3(-1, 0, 0), up: new THREE.Vector3(0, 1, 0) },
  { label: 'Back', dir: new THREE.Vector3(0, 0, -1), up: new THREE.Vector3(0, 1, 0) },
];

function applyDefaultMaterial(object) {
  const meshMaterial = new THREE.MeshStandardMaterial({
    color: 0xe3e9f3,
    roughness: 0.58,
    metalness: 0.08,
    side: THREE.DoubleSide,
  });
  const lineMaterial = new THREE.LineBasicMaterial({ color: 0x9bdcff });
  const pointMaterial = new THREE.PointsMaterial({ color: 0x9bdcff, size: 0.02 });
  let renderableCount = 0;

  object.traverse((child) => {
    if (child.isMesh) {
      child.material = meshMaterial;
      child.castShadow = true;
      child.receiveShadow = true;
      child.geometry?.computeVertexNormals();
      renderableCount += 1;
    }

    if (child.isLine || child.isLineSegments) {
      child.material = lineMaterial;
      renderableCount += 1;
    }

    if (child.isPoints) {
      child.material = pointMaterial;
      renderableCount += 1;
    }
  });

  return renderableCount;
}

function disposeObject(object) {
  object?.traverse((child) => {
    if (!child.isMesh && !child.isLine && !child.isLineSegments && !child.isPoints) return;
    child.geometry?.dispose();
    if (Array.isArray(child.material)) {
      child.material.forEach((material) => material.dispose());
    } else {
      child.material?.dispose();
    }
  });
}

function getMeshes(object) {
  const meshes = [];
  object.traverse((child) => {
    if (child.isMesh && child.geometry?.getAttribute('position')) {
      meshes.push(child);
    }
  });
  return meshes;
}

function getPrimaryMesh(object) {
  return getMeshes(object).reduce((primary, mesh) => {
    const count = mesh.geometry.getAttribute('position')?.count || 0;
    const primaryCount = primary?.geometry.getAttribute('position')?.count || 0;
    return count > primaryCount ? mesh : primary;
  }, null);
}

function getPrimaryBounds(object) {
  const box = new THREE.Box3().setFromObject(object);

  if (box.isEmpty()) {
    throw new Error('OBJに表示可能なジオメトリがありません。');
  }

  const size = box.getSize(new THREE.Vector3());
  const center = box.getCenter(new THREE.Vector3());
  const maxDimension = Math.max(size.x, size.y, size.z) || 1;
  return { box, size, center, maxDimension };
}

function getCanvasAspect(renderer) {
  const width = Math.max(1, renderer?.domElement?.width || 1);
  const height = Math.max(1, renderer?.domElement?.height || 1);
  return width / height;
}

function setOrthographicFrame(camera, center, maxDimension, aspect) {
  const viewHeight = Math.max(1, maxDimension * 1.45);
  const viewWidth = viewHeight * Math.max(0.1, aspect || 1);

  camera.left = -viewWidth / 2;
  camera.right = viewWidth / 2;
  camera.top = viewHeight / 2;
  camera.bottom = -viewHeight / 2;
  camera.near = 0.001;
  camera.far = Math.max(1000, maxDimension * 80);
  camera.zoom = 1;
  camera.position.set(center.x, center.y, center.z + Math.max(10, maxDimension * 8));
  camera.lookAt(center);
  camera.updateProjectionMatrix();
}

function getProjectedBounds(object, camera) {
  object.updateMatrixWorld(true);
  camera.updateMatrixWorld(true);

  const worldPosition = new THREE.Vector3();
  const projected = new THREE.Vector3();
  const bounds = {
    minX: Infinity,
    maxX: -Infinity,
    minY: Infinity,
    maxY: -Infinity,
    count: 0,
  };

  getMeshes(object).forEach((mesh) => {
    const position = mesh.geometry.getAttribute('position');
    const step = Math.max(1, Math.floor(position.count / 8000));

    for (let index = 0; index < position.count; index += step) {
      worldPosition.fromBufferAttribute(position, index).applyMatrix4(mesh.matrixWorld);
      projected.copy(worldPosition).project(camera);

      if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) continue;

      bounds.minX = Math.min(bounds.minX, projected.x);
      bounds.maxX = Math.max(bounds.maxX, projected.x);
      bounds.minY = Math.min(bounds.minY, projected.y);
      bounds.maxY = Math.max(bounds.maxY, projected.y);
      bounds.count += 1;
    }
  });

  if (bounds.count === 0) return null;

  return {
    ...bounds,
    centerX: (bounds.minX + bounds.maxX) / 2,
    centerY: (bounds.minY + bounds.maxY) / 2,
    width: bounds.maxX - bounds.minX,
    height: bounds.maxY - bounds.minY,
  };
}

function panOrthographicCamera(camera, controls, ndcX, ndcY) {
  const viewWidth = (camera.right - camera.left) / camera.zoom;
  const viewHeight = (camera.top - camera.bottom) / camera.zoom;
  const cameraRight = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
  const cameraUp = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
  const shift = cameraRight
    .multiplyScalar((ndcX * viewWidth) / 2)
    .add(cameraUp.multiplyScalar((ndcY * viewHeight) / 2));

  camera.position.add(shift);
  controls.target.add(shift);
  camera.lookAt(controls.target);
  camera.updateMatrixWorld(true);
}

function refineOrthographicFit(camera, controls, object) {
  let projectedBounds = null;

  for (let iteration = 0; iteration < 4; iteration += 1) {
    projectedBounds = getProjectedBounds(object, camera);
    if (!projectedBounds) break;

    if (Math.abs(projectedBounds.centerX) > 0.01 || Math.abs(projectedBounds.centerY) > 0.01) {
      panOrthographicCamera(camera, controls, projectedBounds.centerX, projectedBounds.centerY);
    }
  }

  controls.update();
  return projectedBounds;
}

function getCanvasPixelBounds(renderer) {
  const sourceCanvas = renderer?.domElement;
  if (!sourceCanvas?.width || !sourceCanvas?.height) return null;

  const sampleWidth = Math.min(420, sourceCanvas.width);
  const sampleHeight = Math.max(1, Math.round((sourceCanvas.height / sourceCanvas.width) * sampleWidth));
  const sampleCanvas = document.createElement('canvas');
  sampleCanvas.width = sampleWidth;
  sampleCanvas.height = sampleHeight;
  const context = sampleCanvas.getContext('2d', { willReadFrequently: true });
  if (!context) return null;

  context.drawImage(sourceCanvas, 0, 0, sampleWidth, sampleHeight);
  const { data } = context.getImageData(0, 0, sampleWidth, sampleHeight);
  const bounds = {
    minX: sampleWidth,
    maxX: -1,
    minY: sampleHeight,
    maxY: -1,
    count: 0,
  };

  for (let y = 0; y < sampleHeight; y += 1) {
    for (let x = 0; x < sampleWidth; x += 1) {
      const offset = (y * sampleWidth + x) * 4;
      const red = data[offset];
      const green = data[offset + 1];
      const blue = data[offset + 2];
      const alpha = data[offset + 3];
      const colorDistance = Math.abs(red - 17) + Math.abs(green - 24) + Math.abs(blue - 39);

      if (alpha > 0 && red + green + blue > 100 && colorDistance > 70) {
        bounds.minX = Math.min(bounds.minX, x);
        bounds.maxX = Math.max(bounds.maxX, x);
        bounds.minY = Math.min(bounds.minY, y);
        bounds.maxY = Math.max(bounds.maxY, y);
        bounds.count += 1;
      }
    }
  }

  if (bounds.count < 20) return null;

  return {
    ...bounds,
    width: bounds.maxX - bounds.minX + 1,
    height: bounds.maxY - bounds.minY + 1,
    centerX: (bounds.minX + bounds.maxX + 1) / 2,
    centerY: (bounds.minY + bounds.maxY + 1) / 2,
    sampleWidth,
    sampleHeight,
  };
}

function refineCanvasFit(renderer, camera, controls, renderFrame) {
  let pixelBounds = null;

  for (let iteration = 0; iteration < 5; iteration += 1) {
    renderFrame();
    pixelBounds = getCanvasPixelBounds(renderer);
    if (!pixelBounds) break;

    const centerNdcX = (pixelBounds.centerX / pixelBounds.sampleWidth) * 2 - 1;
    const centerNdcY = -((pixelBounds.centerY / pixelBounds.sampleHeight) * 2 - 1);
    if (Math.abs(centerNdcX) > 0.015 || Math.abs(centerNdcY) > 0.015) {
      panOrthographicCamera(camera, controls, centerNdcX * 0.55, centerNdcY * 0.55);
    }
  }

  controls.update();
  return pixelBounds;
}

function buildCenteredObject(object, fileName) {
  object.updateMatrixWorld(true);

  const sourceBox = new THREE.Box3().setFromObject(object);
  if (sourceBox.isEmpty()) {
    throw new Error('OBJに表示可能なジオメトリがありません。');
  }

  const center = sourceBox.getCenter(new THREE.Vector3());
  const group = new THREE.Group();
  group.name = fileName || 'OBJ model';

  object.traverse((child) => {
    if (!child.geometry || (!child.isMesh && !child.isLine && !child.isLineSegments && !child.isPoints)) {
      return;
    }

    const geometry = child.geometry.clone();
    geometry.applyMatrix4(child.matrixWorld);
    geometry.translate(-center.x, -center.y, -center.z);
    geometry.computeBoundingBox();
    geometry.computeBoundingSphere();

    if (child.isMesh) {
      geometry.computeVertexNormals();
      group.add(new THREE.Mesh(geometry));
      return;
    }

    if (child.isLineSegments) {
      group.add(new THREE.LineSegments(geometry));
      return;
    }

    if (child.isLine) {
      group.add(new THREE.Line(geometry));
      return;
    }

    if (child.isPoints) {
      group.add(new THREE.Points(geometry));
    }
  });

  if (group.children.length === 0) {
    throw new Error('OBJを解析しましたが、表示可能なmesh/line/pointsがありません。');
  }

  const finalBox = new THREE.Box3().setFromObject(group);
  const finalCenter = finalBox.getCenter(new THREE.Vector3());
  getMeshes(group).forEach((mesh) => {
    mesh.geometry.translate(-finalCenter.x, -finalCenter.y, -finalCenter.z);
    mesh.geometry.computeBoundingBox();
    mesh.geometry.computeBoundingSphere();
  });

  return group;
}

function parseObjText(objText, fileName) {
  const sample = objText.trim().slice(0, 120).toLowerCase();
  if (sample.startsWith('<!doctype') || sample.startsWith('<html') || sample.startsWith('{')) {
    throw new Error('OBJではないレスポンスを受信しました。APIレスポンスを確認してください。');
  }

  const loader = new OBJLoader();
  const object = buildCenteredObject(loader.parse(objText), fileName);

  const renderableCount = applyDefaultMaterial(object);
  if (renderableCount === 0) {
    throw new Error('OBJを解析しましたが、表示可能なmesh/line/pointsがありません。');
  }

  return object;
}

function getPatientIdFromFileName(value) {
  return value?.match(/^ldsm_fit_original_(\d{5})_/i)?.[1] || '';
}

function parseStudyDate(value) {
  if (!/^\d{8}$/.test(value || '')) return null;
  const year = Number(value.slice(0, 4));
  const month = Number(value.slice(4, 6));
  const day = Number(value.slice(6, 8));
  const timestamp = Date.UTC(year, month - 1, day);
  return Number.isFinite(timestamp) ? timestamp : null;
}

function getFrameStudyDate(frame) {
  return frame?.file?.studyDate || frame?.file?.name?.match(/_(\d{8})\.obj$/i)?.[1] || '';
}

function prepareMorphTimeline(frames) {
  const prepared = frames
    .map((frame, index) => ({
      ...frame,
      originalIndex: index,
      studyDate: getFrameStudyDate(frame),
    }))
    .sort((a, b) => {
      const dateCompare = a.studyDate.localeCompare(b.studyDate);
      return dateCompare || a.originalIndex - b.originalIndex;
    });

  const firstTimestamp = parseStudyDate(prepared[0]?.studyDate);
  const offsets = prepared.map((frame, index) => {
    const timestamp = parseStudyDate(frame.studyDate);
    if (firstTimestamp === null || timestamp === null) return index;
    return Math.max(0, Math.round((timestamp - firstTimestamp) / DAY_MS));
  });
  const lastDay = Math.max(1, offsets[offsets.length - 1] || prepared.length - 1);

  return { frames: prepared, offsets, lastDay };
}

function getTimelineState(day, offsets) {
  if (!offsets.length) {
    return {
      progress: 0,
      meshIndex: 0,
      meshCount: 0,
      day: 0,
    };
  }

  const maxDay = offsets[offsets.length - 1] || offsets.length - 1;
  const clampedDay = Math.max(0, Math.min(maxDay, day));
  let segmentIndex = 0;

  while (segmentIndex < offsets.length - 2 && clampedDay > offsets[segmentIndex + 1]) {
    segmentIndex += 1;
  }

  const fromDay = offsets[segmentIndex] ?? 0;
  const toDay = offsets[Math.min(offsets.length - 1, segmentIndex + 1)] ?? fromDay + 1;
  const span = Math.max(1, toDay - fromDay);
  const alpha = offsets.length === 1 ? 0 : (clampedDay - fromDay) / span;
  const progress =
    offsets.length === 1 ? 0 : (segmentIndex + THREE.MathUtils.clamp(alpha, 0, 1)) / (offsets.length - 1);
  const meshIndex =
    Math.abs(clampedDay - fromDay) <= Math.abs(toDay - clampedDay)
      ? segmentIndex
      : Math.min(offsets.length - 1, segmentIndex + 1);

  return {
    progress,
    meshIndex,
    meshCount: offsets.length,
    day: Math.round(clampedDay),
  };
}

function formatMorphLabel(state) {
  if (!state?.meshCount) return '';
  return `Mesh ${state.meshIndex + 1}/${state.meshCount} · Day ${state.day}`;
}

function drawCaptureLabel(context, label, width, height) {
  if (!label) return;

  context.save();
  context.font = 'bold 16px Inter, Arial, sans-serif';
  const metrics = context.measureText(label);
  const boxWidth = Math.min(width - 24, metrics.width + 24);
  const boxHeight = 34;
  const x = width - boxWidth - 12;
  const y = 12;

  context.fillStyle = 'rgba(13, 17, 23, 0.78)';
  context.strokeStyle = 'rgba(125, 211, 252, 0.52)';
  context.lineWidth = 1;
  context.beginPath();
  context.roundRect(x, y, boxWidth, boxHeight, 8);
  context.fill();
  context.stroke();
  context.fillStyle = '#dff6ff';
  context.fillText(label, x + 12, y + 22);
  context.restore();
}

function getSupportedMp4MimeType() {
  const mimeTypes = [
    'video/mp4;codecs="avc1.42E01E"',
    'video/mp4;codecs="avc1.4D401E"',
    'video/mp4;codecs=h264',
    'video/mp4',
  ];

  return mimeTypes.find((mimeType) => window.MediaRecorder?.isTypeSupported(mimeType)) || '';
}

function extractPositionFrames(objects) {
  const meshGroups = objects.map(getMeshes);
  const meshCount = meshGroups[0].length;

  if (meshCount === 0) {
    throw new Error('アニメーション対象のmeshがありません。');
  }

  for (const meshes of meshGroups) {
    if (meshes.length !== meshCount) {
      throw new Error('OBJ間のmesh数が一致しません。');
    }
  }

  const frames = meshGroups.map((meshes) =>
    meshes.map((mesh, meshIndex) => {
      const source = mesh.geometry.getAttribute('position').array;
      const first = meshGroups[0][meshIndex].geometry.getAttribute('position').array;
      if (source.length !== first.length) {
        throw new Error('OBJ間の頂点数が一致しません。');
      }
      return new Float32Array(source);
    }),
  );

  return { meshRefs: meshGroups[0], frames };
}

function fitCameraToObject(camera, controls, object, displayMaxDimension) {
  const { center, maxDimension } = getPrimaryBounds(object);
  const lockedMaxDimension = displayMaxDimension || maxDimension;
  const distance = Math.max(10, lockedMaxDimension * 8);

  setOrthographicFrame(camera, center, lockedMaxDimension, getCanvasAspect(camera.userData.renderer));

  controls.target.copy(center);
  controls.minDistance = Math.max(0.01, lockedMaxDimension * 0.05);
  controls.maxDistance = Math.max(20, lockedMaxDimension * 30);
  controls.minZoom = 0.25;
  controls.maxZoom = 12;
  controls.update();
  const projectedBounds = refineOrthographicFit(camera, controls, object);

  window.__OBJ_VIEWER_DEBUG__ = {
    center: center.toArray(),
    maxDimension,
    camera: {
      left: camera.left,
      right: camera.right,
      top: camera.top,
      bottom: camera.bottom,
      position: camera.position.toArray(),
      target: controls.target.toArray(),
    },
    objectMaxDimension: maxDimension,
    displayMaxDimension: lockedMaxDimension,
    projectedBounds,
  };

  return { center, distance, maxDimension: lockedMaxDimension, objectMaxDimension: maxDimension };
}

function applyMorphFrame(meshRefs, frames, progress) {
  if (!meshRefs.length || frames.length === 0) return;

  const lastIndex = frames.length - 1;
  const scaled = Math.max(0, Math.min(lastIndex, progress * lastIndex));
  const frameIndex = Math.min(lastIndex - 1, Math.floor(scaled));
  const alpha = scaled - frameIndex;
  const fromFrame = frames[frameIndex];
  const toFrame = frames[Math.min(lastIndex, frameIndex + 1)];

  meshRefs.forEach((mesh, meshIndex) => {
    const target = mesh.geometry.getAttribute('position');
    const values = target.array;
    const from = fromFrame[meshIndex];
    const to = toFrame[meshIndex];

    for (let i = 0; i < values.length; i += 1) {
      values[i] = from[i] + (to[i] - from[i]) * alpha;
    }

    target.needsUpdate = true;
    mesh.geometry.computeVertexNormals();
    mesh.geometry.computeBoundingBox();
    mesh.geometry.computeBoundingSphere();
  });
}

const ObjViewer = forwardRef(function ObjViewer({ objText, fileName }, ref) {
  const containerRef = useRef(null);
  const objectRef = useRef(null);
  const sceneRef = useRef(null);
  const rendererRef = useRef(null);
  const cameraRef = useRef(null);
  const controlsRef = useRef(null);
  const viewModeRef = useRef('3d');
  const modelCenterRef = useRef(new THREE.Vector3(0, 0, 0));
  const modelDistanceRef = useRef(5);
  const modelSizeRef = useRef(5);
  const fixedDisplaySizeRef = useRef(null);
  const patientIdRef = useRef('');
  const morphAnimationRef = useRef(0);
  const [parseError, setParseError] = useState('');
  const [viewMode, setViewMode] = useState('3d');
  const [viewerStatus, setViewerStatus] = useState('Waiting');
  const [morphLabel, setMorphLabel] = useState('');

  const renderCurrentFrame = () => {
    const renderer = rendererRef.current;
    const scene = sceneRef.current;
    const camera = cameraRef.current;
    if (!renderer || !scene || !camera) return;

    const renderSize = renderer.getSize(new THREE.Vector2());
    const width = Math.max(1, Math.round(renderSize.x));
    const height = Math.max(1, Math.round(renderSize.y));

    if (viewModeRef.current === 'six') {
      renderer.setScissorTest(true);
      renderer.clear();

      const cols = 3;
      const rows = 2;
      const cellW = Math.floor(width / cols);
      const cellH = Math.floor(height / rows);
      const center = modelCenterRef.current;
      const activeObject = objectRef.current;

      VIEW_SPECS.forEach((spec, index) => {
        const col = index % cols;
        const row = Math.floor(index / cols);
        const x = col * cellW;
        const y = height - (row + 1) * cellH;
        const aspect = cellW / cellH;
        const distance = modelDistanceRef.current * 1.8;
        const viewHeight = Math.max(1, modelSizeRef.current * 1.45);
        const viewWidth = viewHeight * aspect;
        const viewCamera = new THREE.OrthographicCamera(
          -viewWidth / 2,
          viewWidth / 2,
          viewHeight / 2,
          -viewHeight / 2,
          0.001,
          distance * 4,
        );
        viewCamera.position.copy(center).add(spec.dir.clone().multiplyScalar(distance));
        viewCamera.up.copy(spec.up);
        viewCamera.lookAt(center);
        viewCamera.updateProjectionMatrix();
        if (activeObject) {
          refineOrthographicFit(
            viewCamera,
            {
              target: center.clone(),
              update() {},
            },
            activeObject,
          );
        }

        renderer.setViewport(x, y, cellW, cellH);
        renderer.setScissor(x, y, cellW, cellH);
        renderer.render(scene, viewCamera);
      });

      renderer.setScissorTest(false);
      renderer.setViewport(0, 0, width, height);
      return;
    }

    renderer.setScissorTest(false);
    renderer.setViewport(0, 0, width, height);
    renderer.render(scene, camera);
  };

  const setSceneObject = (object) => {
    const scene = sceneRef.current;
    const camera = cameraRef.current;
    const controls = controlsRef.current;
    if (!scene || !camera || !controls) return;

    if (objectRef.current) {
      scene.remove(objectRef.current);
      disposeObject(objectRef.current);
    }

    scene.add(object);
    objectRef.current = object;
    const patientId = getPatientIdFromFileName(object.name);
    if (patientId && patientId !== patientIdRef.current) {
      patientIdRef.current = patientId;
      fixedDisplaySizeRef.current = null;
    }

    const objectBounds = getPrimaryBounds(object);
    if (!fixedDisplaySizeRef.current) {
      fixedDisplaySizeRef.current = objectBounds.maxDimension;
    }

    const fit = fitCameraToObject(camera, controls, object, fixedDisplaySizeRef.current);
    const pixelBounds = refineCanvasFit(rendererRef.current, camera, controls, renderCurrentFrame);
    modelCenterRef.current.copy(fit.center);
    modelDistanceRef.current = fit.distance;
    modelSizeRef.current = fit.maxDimension;
    if (window.__OBJ_VIEWER_DEBUG__) {
      window.__OBJ_VIEWER_DEBUG__.pixelBounds = pixelBounds;
    }
    renderCurrentFrame();
  };

  const parseMorphObjects = (frames) => {
    if (!frames || frames.length < 2) {
      throw new Error('アニメーションには2件以上のoriginalデータが必要です。');
    }

    return frames.map((frame) => parseObjText(frame.text, frame.file?.name));
  };

  const playMorph = (frames) => {
    try {
      setParseError('');
      setMorphLabel('');
      setViewerStatus('Animating');
      window.cancelAnimationFrame(morphAnimationRef.current);

      const timeline = prepareMorphTimeline(frames);
      const objects = parseMorphObjects(timeline.frames);
      const { meshRefs, frames: positionFrames } = extractPositionFrames(objects);
      fixedDisplaySizeRef.current = getPrimaryBounds(objects[0]).maxDimension;
      patientIdRef.current = getPatientIdFromFileName(objects[0].name);
      setSceneObject(objects[0]);

      for (let i = 1; i < objects.length; i += 1) {
        disposeObject(objects[i]);
      }

      const start = performance.now();
      const duration = Math.max(1200, Math.min(18000, timeline.lastDay * 35));

      const tick = (now) => {
        const elapsedRatio = Math.min(1, (now - start) / duration);
        const state = getTimelineState(elapsedRatio * timeline.lastDay, timeline.offsets);
        applyMorphFrame(meshRefs, positionFrames, state.progress);
        setMorphLabel(formatMorphLabel(state, timeline.lastDay));
        renderCurrentFrame();

        if (elapsedRatio < 1) {
          morphAnimationRef.current = window.requestAnimationFrame(tick);
        } else {
          setMorphLabel(formatMorphLabel(getTimelineState(timeline.lastDay, timeline.offsets), timeline.lastDay));
          setViewerStatus('Loaded');
        }
      };

      morphAnimationRef.current = window.requestAnimationFrame(tick);
    } catch (error) {
      setViewerStatus('Loaded');
      setMorphLabel('');
      setParseError(error.message || 'アニメーション作成に失敗しました。');
    }
  };

  const downloadMorphMp4 = async (frames, filename) => {
    try {
      setParseError('');
      setMorphLabel('');
      setViewerStatus('MP4');
      window.cancelAnimationFrame(morphAnimationRef.current);

      const mimeType = getSupportedMp4MimeType();
      if (!mimeType) {
        throw new Error('このブラウザはMP4録画に対応していません。Chromeを最新版にしてください。');
      }

      const timeline = prepareMorphTimeline(frames);
      const objects = parseMorphObjects(timeline.frames);
      const { meshRefs, frames: positionFrames } = extractPositionFrames(objects);
      fixedDisplaySizeRef.current = getPrimaryBounds(objects[0]).maxDimension;
      patientIdRef.current = getPatientIdFromFileName(objects[0].name);
      setSceneObject(objects[0]);

      for (let i = 1; i < objects.length; i += 1) {
        disposeObject(objects[i]);
      }

      const sourceCanvas = rendererRef.current.domElement;
      const captureCanvas = document.createElement('canvas');
      const captureWidth = 640;
      const captureImageHeight = 360;
      const captureLabelHeight = 52;
      const captureHeight = captureImageHeight + captureLabelHeight;
      captureCanvas.width = captureWidth;
      captureCanvas.height = captureHeight;
      const captureContext = captureCanvas.getContext('2d');
      const stream = captureCanvas.captureStream(12);
      const chunks = [];
      const recorder = new MediaRecorder(stream, {
        mimeType,
        videoBitsPerSecond: 2_400_000,
      });
      const frameCount = timeline.lastDay + 1;
      const frameDelay = 1000 / 12;

      recorder.ondataavailable = (event) => {
        if (event.data?.size) chunks.push(event.data);
      };

      const recordingDone = new Promise((resolve, reject) => {
        recorder.onstop = resolve;
        recorder.onerror = () => reject(new Error('MP4録画に失敗しました。'));
      });

      recorder.start();

      try {
        for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
          const state = getTimelineState(frameIndex, timeline.offsets);
          const label = formatMorphLabel(state, timeline.lastDay);
          applyMorphFrame(meshRefs, positionFrames, state.progress);
          setMorphLabel(label);
          renderCurrentFrame();
          captureContext.fillStyle = '#0d1117';
          captureContext.fillRect(0, 0, captureWidth, captureHeight);
          captureContext.drawImage(sourceCanvas, 0, captureLabelHeight, captureWidth, captureImageHeight);
          drawCaptureLabel(captureContext, label, captureWidth, captureHeight);

          await new Promise((resolve) => window.setTimeout(resolve, frameDelay));
        }
      } finally {
        if (recorder.state !== 'inactive') {
          recorder.stop();
        }
      }

      await recordingDone;
      stream.getTracks().forEach((track) => track.stop());

      const blob = new Blob(chunks, { type: mimeType });
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = filename;
      link.click();
      URL.revokeObjectURL(url);
      setViewerStatus('Loaded');
      setMorphLabel(formatMorphLabel(getTimelineState(timeline.lastDay, timeline.offsets), timeline.lastDay));
    } catch (error) {
      setViewerStatus('Loaded');
      setMorphLabel('');
      setParseError(error.message || 'MP4作成に失敗しました。');
    }
  };

  useImperativeHandle(ref, () => ({ playMorph, downloadMorphMp4 }));

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return undefined;

    const getCanvasSize = () => {
      const rect = container.getBoundingClientRect();
      return {
        width: Math.max(320, Math.round(rect.width || container.clientWidth || 320)),
        height: Math.min(
          2400,
          Math.max(360, Math.round(rect.height || container.clientHeight || 560)),
        ),
      };
    };

    const initialSize = getCanvasSize();

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x111827);
    sceneRef.current = scene;

    const renderer = new THREE.WebGLRenderer({
      antialias: true,
      preserveDrawingBuffer: true,
    });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setSize(initialSize.width, initialSize.height, false);
    renderer.shadowMap.enabled = true;
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.autoClear = true;
    rendererRef.current = renderer;
    container.appendChild(renderer.domElement);

    const initialAspect = initialSize.width / initialSize.height;
    const camera = new THREE.OrthographicCamera(
      -initialAspect,
      initialAspect,
      1,
      -1,
      0.001,
      1000,
    );
    camera.position.set(0, 0, 10);
    camera.userData.renderer = renderer;
    cameraRef.current = camera;

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    controlsRef.current = controls;

    const hemiLight = new THREE.HemisphereLight(0xffffff, 0x354052, 2.4);
    scene.add(hemiLight);

    const keyLight = new THREE.DirectionalLight(0xffffff, 2.2);
    keyLight.position.set(5, 8, 6);
    keyLight.castShadow = true;
    scene.add(keyLight);

    const fillLight = new THREE.DirectionalLight(0xffffff, 0.65);
    fillLight.position.set(-4, 3, -6);
    scene.add(fillLight);

    const resizeObserver = new ResizeObserver(() => {
      const { width, height } = getCanvasSize();
      renderer.setSize(width, height, false);
      if (objectRef.current) {
        const { center, maxDimension } = getPrimaryBounds(objectRef.current);
        const displayMaxDimension = fixedDisplaySizeRef.current || maxDimension;
        setOrthographicFrame(camera, center, displayMaxDimension, width / height);
        controls.target.copy(center);
        controls.update();
        refineOrthographicFit(camera, controls, objectRef.current);
      } else {
        setOrthographicFrame(camera, new THREE.Vector3(0, 0, 0), 5, width / height);
      }
      renderCurrentFrame();
    });
    resizeObserver.observe(container);

    let animationFrameId = 0;
    const animate = () => {
      controls.enabled = viewModeRef.current === '3d';
      controls.update();
      renderCurrentFrame();
      animationFrameId = window.requestAnimationFrame(animate);
    };
    animate();

    return () => {
      window.cancelAnimationFrame(animationFrameId);
      window.cancelAnimationFrame(morphAnimationRef.current);
      resizeObserver.disconnect();
      controls.dispose();
      renderer.dispose();
      disposeObject(objectRef.current);
      renderer.domElement.remove();
    };
  }, []);

  useEffect(() => {
    if (!objText) {
      setViewerStatus('Waiting');
      setMorphLabel('');
      return;
    }

    try {
      setParseError('');
      setMorphLabel('');
      setViewerStatus('Loaded');
      window.cancelAnimationFrame(morphAnimationRef.current);
      setSceneObject(parseObjText(objText, fileName));
    } catch (error) {
      setViewerStatus('Error');
      setParseError(error.message || 'OBJ parse failed.');
    }
  }, [objText, fileName]);

  const handleViewModeChange = (mode) => {
    viewModeRef.current = mode;
    setViewMode(mode);
    renderCurrentFrame();
  };

  return (
    <section className="viewer-shell" aria-label="OBJ preview">
      <div className="viewer-header">
        <div>
          <p className="eyebrow">Preview</p>
          <h2>{fileName || 'OBJファイルを選択'}</h2>
        </div>
        <div className="viewer-actions">
          <div className="segmented-control" aria-label="view mode">
            <button
              className={viewMode === '3d' ? 'active' : ''}
              onClick={() => handleViewModeChange('3d')}
            >
              3D
            </button>
            <button
              className={viewMode === 'six' ? 'active' : ''}
              onClick={() => handleViewModeChange('six')}
            >
              6 Views
            </button>
          </div>
          <span className="viewer-status">{viewerStatus}</span>
        </div>
      </div>

      <div ref={containerRef} className="viewer-canvas">
        {!objText && (
          <div className="empty-state">
            <strong>患者IDと日付を選択してください</strong>
            <span>3Dではドラッグで回転、ホイールでズーム</span>
          </div>
        )}
        {viewMode === 'six' && objText && (
          <div className="six-view-labels" aria-hidden="true">
            {VIEW_LABELS.map((label) => (
              <span key={label}>{label}</span>
            ))}
          </div>
        )}
        {morphLabel && <div className="morph-frame-label">{morphLabel}</div>}
        {parseError && <div className="error-banner">{parseError}</div>}
      </div>
    </section>
  );
});

export default ObjViewer;
