import { useEffect, useRef, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
import { useMeshStore, MeshLayer } from '../stores/meshStore';
import { generateMatcapTexture } from '../utils/generateMatcap';
import { initMeshLabCore } from '../wasm/meshlab-core';
import type { RenderMode } from './Toolbar';

const BG_MAP: Record<string, number> = { dark: 0x2a2a2a, light: 0x888888, white: 0xffffff };

// MeshLab default gradient from glarea_setting.cpp:
// Top: QColor(0,0,0) = black, Bottom: QColor(128,128,255) = #8080FF
function createMeshLabGradientTexture(): THREE.Texture {
  const canvas = document.createElement('canvas');
  canvas.width = 2; canvas.height = 512;
  const ctx = canvas.getContext('2d')!;
  const grad = ctx.createLinearGradient(0, 0, 0, 512);
  // MeshLab: top QColor(0,0,0), bottom QColor(128,128,255)
  // Adjusted for desktop appearance match
  grad.addColorStop(0, '#000005');   // top: nearly black
  grad.addColorStop(0.4, '#1a1a4a'); // mid-upper: very dark blue
  grad.addColorStop(0.7, '#4040a0'); // mid-lower: medium blue-purple
  grad.addColorStop(1, '#7878d8');   // bottom: blue-purple (desktop appearance)
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, 2, 512);
  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.needsUpdate = true;
  return tex;
}

interface SceneState {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  renderer: THREE.WebGLRenderer;
  controls: OrbitControls;
  composer: EffectComposer;
  ssaoPass: SSAOPass;
  layerGroup: THREE.Group;
  selectionGroup: THREE.Group;
  edgeGroup: THREE.Group;
  shadowPlane: THREE.Mesh;
  bboxHelpers: THREE.Box3Helper[];
  gridHelper: THREE.GridHelper;
  axesHelper: THREE.AxesHelper;
  dirLight1: THREE.DirectionalLight;
  dirLight2: THREE.DirectionalLight;
  raycaster: THREE.Raycaster;
  clippingPlane: THREE.Plane;
  useComposer: boolean;
  normalGroup: THREE.Group;
  boundaryGroup: THREE.Group;
  nonManifoldGroup: THREE.Group;
  trackballGroup: THREE.Group;
}

function buildLayerObject(layer: MeshLayer, mode: RenderMode, matcapType: string, clippingPlanes: THREE.Plane[]): THREE.Object3D {
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(layer.buffers.vertices, 3));
  geometry.setAttribute('normal', new THREE.Float32BufferAttribute(layer.buffers.normals, 3));
  geometry.setIndex(new THREE.Uint32BufferAttribute(layer.buffers.indices, 1));

  // Add vertex colors if available
  if (layer.vertexColors && layer.vertexColors.length === layer.buffers.vertices.length) {
    const colors = new Float32Array(layer.vertexColors.length);
    for (let i = 0; i < layer.vertexColors.length; i++) colors[i] = layer.vertexColors[i] / 255;
    geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  }

  const color = new THREE.Color(layer.color);
  const hasVC = !!layer.vertexColors && layer.vertexColors.length > 0;
  const group = new THREE.Group();
  group.name = layer.id;
  group.visible = layer.visible;

  const clip = clippingPlanes.length > 0 ? clippingPlanes : undefined;

  if (matcapType && mode !== 'wireframe' && mode !== 'points') {
    const tex = generateMatcapTexture(matcapType);
    const mat = new THREE.MeshMatcapMaterial({ matcap: tex, side: THREE.DoubleSide, clippingPlanes: clip });
    const mesh = new THREE.Mesh(geometry, mat);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    group.add(mesh);
  } else {
    switch (mode) {
      case 'solid': {
        const mat = new THREE.MeshStandardMaterial({
          color: hasVC ? 0xffffff : color, vertexColors: hasVC,
          side: THREE.DoubleSide, clippingPlanes: clip,
        });
        const m = new THREE.Mesh(geometry, mat);
        m.castShadow = true; m.receiveShadow = true;
        group.add(m);
        break;
      }
      case 'flat': {
        const mat = new THREE.MeshStandardMaterial({
          color: hasVC ? 0xffffff : color, vertexColors: hasVC,
          side: THREE.DoubleSide, flatShading: true, clippingPlanes: clip,
        });
        const m = new THREE.Mesh(geometry, mat);
        m.castShadow = true; m.receiveShadow = true;
        group.add(m);
        break;
      }
      case 'wireframe': {
        const wire = new THREE.WireframeGeometry(geometry);
        group.add(new THREE.LineSegments(wire, new THREE.LineBasicMaterial({ color })));
        break;
      }
      case 'points': {
        group.add(new THREE.Points(geometry, new THREE.PointsMaterial({ color, size: 0.02, sizeAttenuation: true })));
        break;
      }
    }
  }

  return group;
}

interface Viewer3DProps {
  fitToViewTrigger: number;
  viewFromTarget?: string | null;
  onViewFromComplete?: () => void;
  selectionMask?: Uint8Array | null;
  onFaceClicked?: (faceIndex: number, modifiers: { ctrl: boolean; shift: boolean }) => void;
  onRectSelect?: (faceIndices: number[]) => void;
  onMeshPointClicked?: (point: { x: number; y: number; z: number }) => void;
}

export function Viewer3D({ fitToViewTrigger, viewFromTarget, onViewFromComplete, selectionMask, onFaceClicked, onRectSelect, onMeshPointClicked }: Viewer3DProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const sceneRef = useRef<SceneState | null>(null);
  const { state } = useMeshStore();

  const fitToView = useCallback(() => {
    const ss = sceneRef.current;
    if (!ss || state.layers.length === 0) return;
    const box = new THREE.Box3();
    ss.layerGroup.traverse((obj) => {
      if (obj instanceof THREE.Mesh || obj instanceof THREE.LineSegments || obj instanceof THREE.Points) {
        obj.geometry.computeBoundingBox();
        box.expandByObject(obj);
      }
    });
    if (box.isEmpty()) return;
    const center = new THREE.Vector3(); box.getCenter(center);
    const size = new THREE.Vector3(); box.getSize(size);
    const maxDim = Math.max(size.x, size.y, size.z) || 1;

    ss.controls.target.copy(center);
    ss.camera.position.set(center.x + maxDim * 1.5, center.y + maxDim, center.z + maxDim * 2);
    ss.camera.near = maxDim * 0.001; ss.camera.far = maxDim * 100;
    ss.camera.updateProjectionMatrix();
    ss.controls.update();

    const gridSize = Math.pow(10, Math.ceil(Math.log10(maxDim * 2)));
    ss.scene.remove(ss.gridHelper);
    ss.gridHelper = new THREE.GridHelper(gridSize, 10, 0x555555, 0x333333);
    if (state.showGrid) ss.scene.add(ss.gridHelper);

    // Adjust shadow camera
    ss.dirLight1.shadow.camera.left = -maxDim * 2;
    ss.dirLight1.shadow.camera.right = maxDim * 2;
    ss.dirLight1.shadow.camera.top = maxDim * 2;
    ss.dirLight1.shadow.camera.bottom = -maxDim * 2;
    ss.dirLight1.shadow.camera.updateProjectionMatrix();

    // Shadow ground plane
    ss.shadowPlane.position.set(center.x, box.min.y - 0.01, center.z);
    ss.shadowPlane.scale.set(maxDim * 4, maxDim * 4, 1);
  }, [state.layers, state.showGrid]);

  // ---- Scene init ----
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const scene = new THREE.Scene();
    scene.background = createMeshLabGradientTexture();

    const camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.01, 10000);
    camera.position.set(2, 2, 3);

    const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
    renderer.setSize(container.clientWidth, container.clientHeight);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.localClippingEnabled = true;
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.NoToneMapping;
    container.appendChild(renderer.domElement);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.target.set(0, 0, 0);
    controls.enableDamping = true;
    controls.dampingFactor = 0.1;
    controls.update();

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
    scene.add(ambientLight);
    const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
    dirLight1.position.set(5, 10, 7);
    dirLight1.castShadow = true;
    dirLight1.shadow.mapSize.width = 2048;
    dirLight1.shadow.mapSize.height = 2048;
    scene.add(dirLight1);
    const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
    dirLight2.position.set(-5, -3, -5);
    scene.add(dirLight2);

    const gridHelper = new THREE.GridHelper(10, 10, 0x555555, 0x333333);
    scene.add(gridHelper);
    const axesHelper = new THREE.AxesHelper(2);
    scene.add(axesHelper);

    // Shadow-receiving ground plane
    const shadowGeo = new THREE.PlaneGeometry(1, 1);
    shadowGeo.rotateX(-Math.PI / 2);
    const shadowMat = new THREE.ShadowMaterial({ opacity: 0.3 });
    const shadowPlane = new THREE.Mesh(shadowGeo, shadowMat);
    shadowPlane.receiveShadow = true;
    shadowPlane.visible = false;
    scene.add(shadowPlane);

    const layerGroup = new THREE.Group();
    scene.add(layerGroup);
    const selectionGroup = new THREE.Group();
    scene.add(selectionGroup);
    const edgeGroup = new THREE.Group();
    scene.add(edgeGroup);
    const normalGroup = new THREE.Group();
    scene.add(normalGroup);
    const boundaryGroup = new THREE.Group();
    scene.add(boundaryGroup);
    const nonManifoldGroup = new THREE.Group();
    scene.add(nonManifoldGroup);
    const trackballGroup = new THREE.Group();
    scene.add(trackballGroup);

    // Create trackball sphere (hidden by default)
    const tbSphere = new THREE.SphereGeometry(1, 32, 24);
    const tbWire = new THREE.WireframeGeometry(tbSphere);
    const tbLines = new THREE.LineSegments(tbWire, new THREE.LineBasicMaterial({ color: 0x888888, transparent: true, opacity: 0.12 }));
    tbLines.renderOrder = 999;
    trackballGroup.add(tbLines);
    // X/Y/Z axis rings
    const ringGeo = new THREE.RingGeometry(0.98, 1.02, 64);
    const xRing = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xff4444, transparent: true, opacity: 0.2, side: THREE.DoubleSide }));
    xRing.rotation.y = Math.PI / 2;
    trackballGroup.add(xRing);
    const yRing = new THREE.Mesh(ringGeo.clone(), new THREE.MeshBasicMaterial({ color: 0x44ff44, transparent: true, opacity: 0.2, side: THREE.DoubleSide }));
    yRing.rotation.x = Math.PI / 2;
    trackballGroup.add(yRing);
    const zRing = new THREE.Mesh(ringGeo.clone(), new THREE.MeshBasicMaterial({ color: 0x4444ff, transparent: true, opacity: 0.2, side: THREE.DoubleSide }));
    trackballGroup.add(zRing);
    trackballGroup.visible = false;

    const raycaster = new THREE.Raycaster();
    const clippingPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);

    // Post-processing
    const composer = new EffectComposer(renderer);
    const renderPass = new RenderPass(scene, camera);
    composer.addPass(renderPass);
    const ssaoPass = new SSAOPass(scene, camera, container.clientWidth, container.clientHeight);
    ssaoPass.kernelRadius = 8;
    ssaoPass.minDistance = 0.005;
    ssaoPass.maxDistance = 0.1;
    ssaoPass.enabled = false;
    composer.addPass(ssaoPass);
    const outputPass = new OutputPass();
    composer.addPass(outputPass);

    const ss: SceneState = {
      scene, camera, renderer, controls, composer, ssaoPass,
      layerGroup, selectionGroup, edgeGroup, shadowPlane, raycaster, clippingPlane,
      bboxHelpers: [], gridHelper, axesHelper, dirLight1, dirLight2,
      useComposer: false,
      normalGroup, boundaryGroup, nonManifoldGroup, trackballGroup,
    };
    sceneRef.current = ss;

    let animId: number;
    const animate = () => {
      animId = requestAnimationFrame(animate);
      controls.update();
      dirLight1.position.copy(camera.position).add(new THREE.Vector3(2, 5, 3));
      dirLight2.position.copy(camera.position).add(new THREE.Vector3(-3, -2, -2));
      if (ss.useComposer) {
        composer.render();
      } else {
        renderer.render(scene, camera);
      }
    };
    animate();

    renderer.domElement.addEventListener('dblclick', () => fitToView());

    // Click for face picking
    const handleClick = (e: MouseEvent) => {
      if (state.toolMode === 'navigate') return;
      const rect = renderer.domElement.getBoundingClientRect();
      const mouse = new THREE.Vector2(
        ((e.clientX - rect.left) / rect.width) * 2 - 1,
        -((e.clientY - rect.top) / rect.height) * 2 + 1
      );
      raycaster.setFromCamera(mouse, camera);
      const meshes: THREE.Mesh[] = [];
      layerGroup.traverse((obj) => { if (obj instanceof THREE.Mesh) meshes.push(obj); });
      const intersects = raycaster.intersectObjects(meshes);
      if (intersects.length > 0) {
        if (state.toolMode.startsWith('measure_') && onMeshPointClicked) {
          const p = intersects[0].point;
          onMeshPointClicked({ x: p.x, y: p.y, z: p.z });
        } else if (onFaceClicked && intersects[0].faceIndex != null) {
          onFaceClicked(intersects[0].faceIndex, { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey });
        }
      }
    };
    renderer.domElement.addEventListener('click', handleClick);

    // Rect select
    let rectStart: { x: number; y: number } | null = null;
    const handleMouseDown = (e: MouseEvent) => {
      if (state.toolMode !== 'select_rect' || e.button !== 0) return;
      rectStart = { x: e.clientX, y: e.clientY };
    };
    const handleMouseUp = (e: MouseEvent) => {
      if (!rectStart || state.toolMode !== 'select_rect' || !onRectSelect) return;
      const rect = renderer.domElement.getBoundingClientRect();
      const x1 = Math.min(rectStart.x, e.clientX) - rect.left;
      const y1 = Math.min(rectStart.y, e.clientY) - rect.top;
      const x2 = Math.max(rectStart.x, e.clientX) - rect.left;
      const y2 = Math.max(rectStart.y, e.clientY) - rect.top;
      rectStart = null;
      if (x2 - x1 < 5 && y2 - y1 < 5) return;
      const selected: number[] = [];
      layerGroup.traverse((obj) => {
        if (!(obj instanceof THREE.Mesh)) return;
        const geo = obj.geometry;
        const pos = geo.getAttribute('position');
        const idx = geo.getIndex();
        if (!pos || !idx) return;
        for (let fi = 0; fi < idx.count / 3; fi++) {
          const i0 = idx.getX(fi * 3), i1 = idx.getX(fi * 3 + 1), i2 = idx.getX(fi * 3 + 2);
          const cx = (pos.getX(i0) + pos.getX(i1) + pos.getX(i2)) / 3;
          const cy = (pos.getY(i0) + pos.getY(i1) + pos.getY(i2)) / 3;
          const cz = (pos.getZ(i0) + pos.getZ(i1) + pos.getZ(i2)) / 3;
          const v = new THREE.Vector3(cx, cy, cz).project(camera);
          const sx = (v.x * 0.5 + 0.5) * rect.width;
          const sy = (-v.y * 0.5 + 0.5) * rect.height;
          if (sx >= x1 && sx <= x2 && sy >= y1 && sy <= y2 && v.z >= 0 && v.z <= 1) selected.push(fi);
        }
      });
      if (selected.length > 0) onRectSelect(selected);
    };
    renderer.domElement.addEventListener('mousedown', handleMouseDown);
    renderer.domElement.addEventListener('mouseup', handleMouseUp);

    const ro = new ResizeObserver(() => {
      const w = container.clientWidth, h = container.clientHeight;
      if (w === 0 || h === 0) return;
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
      renderer.setSize(w, h);
      composer.setSize(w, h);
    });
    ro.observe(container);

    return () => {
      cancelAnimationFrame(animId);
      ro.disconnect();
      renderer.domElement.removeEventListener('click', handleClick);
      renderer.domElement.removeEventListener('mousedown', handleMouseDown);
      renderer.domElement.removeEventListener('mouseup', handleMouseUp);
      controls.dispose();
      renderer.dispose();
      if (renderer.domElement.parentElement) container.removeChild(renderer.domElement);
      sceneRef.current = null;
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ---- Tool mode ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (ss) {
      // Enable orbit controls for navigate and measure modes (measure uses left-click for points, orbit uses right/middle)
      const isMeasure = state.toolMode.startsWith('measure_');
      ss.controls.enabled = state.toolMode === 'navigate' || isMeasure;
      if (isMeasure) {
        ss.controls.mouseButtons = { LEFT: -1 as THREE.MOUSE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
      } else {
        ss.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
      }
    }
  }, [state.toolMode]);

  // ---- Selection overlay ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    while (ss.selectionGroup.children.length > 0) ss.selectionGroup.remove(ss.selectionGroup.children[0]);
    if (!selectionMask || selectionMask.length === 0) return;
    const activeLayer = state.layers.find((l) => l.id === state.activeLayerId);
    if (!activeLayer) return;
    const { vertices, indices } = activeLayer.buffers;
    const selPositions: number[] = [];
    for (let ti = 0; ti < indices.length / 3; ti++) {
      if (ti < selectionMask.length && selectionMask[ti]) {
        const i0 = indices[ti * 3], i1 = indices[ti * 3 + 1], i2 = indices[ti * 3 + 2];
        selPositions.push(vertices[i0 * 3], vertices[i0 * 3 + 1], vertices[i0 * 3 + 2]);
        selPositions.push(vertices[i1 * 3], vertices[i1 * 3 + 1], vertices[i1 * 3 + 2]);
        selPositions.push(vertices[i2 * 3], vertices[i2 * 3 + 1], vertices[i2 * 3 + 2]);
      }
    }
    if (selPositions.length > 0) {
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(selPositions, 3));
      const mat = new THREE.MeshBasicMaterial({ color: 0xff4444, transparent: true, opacity: 0.4, side: THREE.DoubleSide, depthTest: false });
      ss.selectionGroup.add(new THREE.Mesh(geo, mat));
    }
  }, [selectionMask, state.layers, state.activeLayerId]);

  // ---- Sync layers ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    while (ss.layerGroup.children.length > 0) ss.layerGroup.remove(ss.layerGroup.children[0]);
    const clipPlanes = state.clippingEnabled ? [ss.clippingPlane] : [];
    for (const layer of state.layers) {
      const obj = buildLayerObject(layer, state.globalRenderMode, state.matcapType, clipPlanes);
      ss.layerGroup.add(obj);
    }
  }, [state.layers, state.globalRenderMode, state.matcapType, state.clippingEnabled]);

  // ---- SSAO ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    ss.ssaoPass.enabled = state.ssaoEnabled;
    ss.useComposer = state.ssaoEnabled;
  }, [state.ssaoEnabled]);

  // ---- Shadows ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    ss.dirLight1.castShadow = state.shadowsEnabled;
    ss.shadowPlane.visible = state.shadowsEnabled;
    ss.renderer.shadowMap.enabled = state.shadowsEnabled;
    ss.renderer.shadowMap.needsUpdate = true;
  }, [state.shadowsEnabled]);

  // ---- Clipping plane ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    if (!state.clippingEnabled) {
      ss.renderer.clippingPlanes = [];
      return;
    }
    const normal = new THREE.Vector3(
      state.clippingAxis === 'X' ? 1 : 0,
      state.clippingAxis === 'Y' ? 1 : 0,
      state.clippingAxis === 'Z' ? 1 : 0,
    );
    if (state.clippingFlipped) normal.negate();

    // Compute position from bbox
    const box = new THREE.Box3();
    ss.layerGroup.traverse((obj) => {
      if (obj instanceof THREE.Mesh) { obj.geometry.computeBoundingBox(); box.expandByObject(obj); }
    });
    if (box.isEmpty()) return;
    const min = box.min, max = box.max;
    const axisIdx = state.clippingAxis === 'X' ? 0 : state.clippingAxis === 'Y' ? 1 : 2;
    const minVal = [min.x, min.y, min.z][axisIdx];
    const maxVal = [max.x, max.y, max.z][axisIdx];
    const pos = minVal + (maxVal - minVal) * (state.clippingPosition / 100);
    const constant = state.clippingFlipped ? pos : -pos;

    ss.clippingPlane.set(normal, constant);
    ss.renderer.clippingPlanes = [ss.clippingPlane];
  }, [state.clippingEnabled, state.clippingAxis, state.clippingPosition, state.clippingFlipped, state.layers]);

  // ---- Light intensity ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    ss.dirLight1.intensity = state.lightIntensity * 0.8;
    ss.dirLight2.intensity = state.lightIntensity * 0.3;
  }, [state.lightIntensity]);

  // ---- Crease edges ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    while (ss.edgeGroup.children.length > 0) ss.edgeGroup.remove(ss.edgeGroup.children[0]);
    if (!state.edgesEnabled) return;

    const activeLayer = state.layers.find((l) => l.id === state.activeLayerId);
    if (!activeLayer || activeLayer.wasmHandle < 0) return;

    (async () => {
      const core = await initMeshLabCore();
      const edgeData = new Float32Array(core.getCreaseEdges(activeLayer.wasmHandle, state.edgeAngle));
      if (edgeData.length === 0) return;
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(edgeData, 3));
      const mat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
      ss.edgeGroup.add(new THREE.LineSegments(geo, mat));
    })();
  }, [state.edgesEnabled, state.edgeAngle, state.layers, state.activeLayerId]);

  // ---- BBox ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    for (const h of ss.bboxHelpers) ss.scene.remove(h);
    ss.bboxHelpers = [];
    if (state.showBBox) {
      ss.layerGroup.children.forEach((child) => {
        if (!child.visible) return;
        const box = new THREE.Box3().setFromObject(child);
        if (!box.isEmpty()) {
          const helper = new THREE.Box3Helper(box, new THREE.Color(0xffff00));
          ss.scene.add(helper);
          ss.bboxHelpers.push(helper);
        }
      });
    }
  }, [state.showBBox, state.layers]);

  // ---- Grid ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss) return;
    if (state.showGrid && !ss.gridHelper.parent) ss.scene.add(ss.gridHelper);
    if (!state.showGrid && ss.gridHelper.parent) ss.scene.remove(ss.gridHelper);
  }, [state.showGrid]);

  // ---- Background ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (ss) ss.scene.background = new THREE.Color(BG_MAP[state.bgColor] ?? 0x2a2a2a);
  }, [state.bgColor]);

  // ---- Fit to view trigger ----
  useEffect(() => {
    if (fitToViewTrigger > 0) fitToView();
  }, [fitToViewTrigger, fitToView]);

  // ---- View From camera preset ----
  useEffect(() => {
    const ss = sceneRef.current;
    if (!ss || !viewFromTarget) return;
    // Compute bounding box center and size
    const box = new THREE.Box3();
    ss.layerGroup.traverse((o) => {
      if (o instanceof THREE.Mesh || o instanceof THREE.Points) box.expandByObject(o);
    });
    if (box.isEmpty()) { onViewFromComplete?.(); return; }
    const center = new THREE.Vector3(); box.getCenter(center);
    const size = new THREE.Vector3(); box.getSize(size);
    const d = Math.max(size.x, size.y, size.z) * 1.5;

    const presets: Record<string, { pos: [number, number, number]; up: [number, number, number] }> = {
      front:  { pos: [0, 0, d],  up: [0, 1, 0] },
      back:   { pos: [0, 0, -d], up: [0, 1, 0] },
      top:    { pos: [0, d, 0],  up: [0, 0, -1] },
      bottom: { pos: [0, -d, 0], up: [0, 0, 1] },
      left:   { pos: [-d, 0, 0], up: [0, 1, 0] },
      right:  { pos: [d, 0, 0],  up: [0, 1, 0] },
    };
    const preset = presets[viewFromTarget];
    if (!preset) { onViewFromComplete?.(); return; }

    const targetPos = new THREE.Vector3(center.x + preset.pos[0], center.y + preset.pos[1], center.z + preset.pos[2]);
    const targetUp = new THREE.Vector3(...preset.up);

    // Animate camera
    const startPos = ss.camera.position.clone();
    const startUp = ss.camera.up.clone();
    const duration = 300;
    const startTime = performance.now();
    const anim = () => {
      const t = Math.min(1, (performance.now() - startTime) / duration);
      const ease = t * (2 - t); // ease-out
      ss.camera.position.lerpVectors(startPos, targetPos, ease);
      ss.camera.up.lerpVectors(startUp, targetUp, ease).normalize();
      ss.controls.target.copy(center);
      ss.controls.update();
      if (t < 1) requestAnimationFrame(anim);
      else onViewFromComplete?.();
    };
    requestAnimationFrame(anim);
  }, [viewFromTarget, onViewFromComplete]);

  // ---- Trackball visualization ----
  useEffect(() => {
    const ss = sceneRef.current; if (!ss) return;
    // Show trackball when dragging or when toggled on
    // For now, show on any mouse drag over the canvas
    const show = () => {
      ss.trackballGroup.visible = true;
      // Position at controls target, scale to match camera distance
      const target = ss.controls.target;
      const dist = ss.camera.position.distanceTo(target);
      ss.trackballGroup.position.copy(target);
      ss.trackballGroup.scale.setScalar(dist * 0.4);
    };
    const hide = () => { ss.trackballGroup.visible = false; };
    const el = ss.renderer.domElement;
    el.addEventListener('mousedown', show);
    el.addEventListener('mouseup', hide);
    el.addEventListener('mouseleave', hide);
    return () => {
      el.removeEventListener('mousedown', show);
      el.removeEventListener('mouseup', hide);
      el.removeEventListener('mouseleave', hide);
    };
  }, []);

  // ---- Show Normals decoration ----
  useEffect(() => {
    const ss = sceneRef.current; if (!ss) return;
    ss.normalGroup.clear();
    if (!state.showNormals) return;
    state.layers.forEach(layer => {
      if (!layer.visible || layer.buffers.vertices.length === 0) return;
      const verts = layer.buffers.vertices;
      const norms = layer.buffers.normals;
      if (norms.length !== verts.length) return;
      // Compute BBox diagonal for normal length
      let maxDim = 1;
      const box = new THREE.Box3();
      for (let i = 0; i < verts.length; i += 3) box.expandByPoint(new THREE.Vector3(verts[i], verts[i+1], verts[i+2]));
      const size = new THREE.Vector3(); box.getSize(size);
      maxDim = Math.max(size.x, size.y, size.z);
      const len = maxDim * 0.02;

      const positions: number[] = [];
      const stride = Math.max(1, Math.floor(verts.length / 3 / 5000)); // Limit to 5000 normals
      for (let i = 0; i < verts.length / 3; i += stride) {
        positions.push(verts[i*3], verts[i*3+1], verts[i*3+2]);
        positions.push(verts[i*3] + norms[i*3]*len, verts[i*3+1] + norms[i*3+1]*len, verts[i*3+2] + norms[i*3+2]*len);
      }
      const geom = new THREE.BufferGeometry();
      geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      const lines = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ color: 0x4444ff }));
      ss.normalGroup.add(lines);
    });
  }, [state.showNormals, state.layers]);

  // ---- Show Boundary edges ----
  useEffect(() => {
    const ss = sceneRef.current; if (!ss) return;
    ss.boundaryGroup.clear();
    if (!state.showBoundary) return;
    (async () => {
      const core = await initMeshLabCore();
      state.layers.forEach(layer => {
        if (!layer.visible || layer.wasmHandle < 0) return;
        const edges = core.getBoundaryEdges(layer.wasmHandle);
        if (!edges || edges.length === 0) return;
        const geom = new THREE.BufferGeometry();
        geom.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(edges), 3));
        const lines = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 3 }));
        ss.boundaryGroup.add(lines);
      });
    })();
  }, [state.showBoundary, state.layers]);

  // ---- Show Non-Manifold edges ----
  useEffect(() => {
    const ss = sceneRef.current; if (!ss) return;
    ss.nonManifoldGroup.clear();
    if (!state.showNonManifold) return;
    (async () => {
      const core = await initMeshLabCore();
      state.layers.forEach(layer => {
        if (!layer.visible || layer.wasmHandle < 0) return;
        const edges = core.getNonManifoldEdges(layer.wasmHandle);
        if (!edges || edges.length === 0) return;
        const geom = new THREE.BufferGeometry();
        geom.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(edges), 3));
        const lines = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ color: 0xffff00, linewidth: 4 }));
        ss.nonManifoldGroup.add(lines);
      });
    })();
  }, [state.showNonManifold, state.layers]);

  return (
    <div ref={containerRef} data-testid="viewer-canvas" style={{ width: '100%', height: '100%' }} />
  );
}
