/* global React, THREE */
const { useRef, useEffect } = React;

// API:
//   <MacaronViewer
//     top={{ url, color }}
//     middle={{ url, color }}
//     base={{ url, color }}
//     onPress={() => {}}    // fires on click; host plays sound
//   />
//
// One scene per session. Drag rotates the whole stack, wheel zooms,
// click bounces the stack and triggers onPress.

const _geomCache = new Map();

function loadGeometry(url) {
  if (_geomCache.has(url)) return Promise.resolve(_geomCache.get(url));
  return new Promise((resolve, reject) => {
    const loader = new THREE.STLLoader();
    loader.load(url, (geom) => {
      geom.computeBoundingBox();
      _geomCache.set(url, geom);
      resolve(geom);
    }, undefined, reject);
  });
}

// Center each part in X/Z (leave Y for stacking), orient with the keyboard's
// convention so the authored "top" faces +Y.
function prepareMacaronPart(rawGeom) {
  const g = rawGeom.clone();
  g.computeBoundingBox();
  const c = new THREE.Vector3();
  g.boundingBox.getCenter(c);
  g.translate(-c.x, -c.y, -c.z);
  g.rotateX(-Math.PI / 2);
  g.computeBoundingBox();
  return g;
}

function MacaronViewer({ top, middle, base, onPress }) {
  const mountRef = useRef(null);
  const stateRef = useRef(null);
  const onPressRef = useRef(null);
  useEffect(() => { onPressRef.current = onPress; }, [onPress]);

  // One-time init (per set of URLs)
  useEffect(() => {
    const mount = mountRef.current;
    if (!mount || !window.THREE || !top || !middle || !base) return;

    let raf = 0;
    let cancelled = false;

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    if (THREE.ColorManagement) THREE.ColorManagement.enabled = false;
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    const W = mount.clientWidth, H = mount.clientHeight;
    renderer.setSize(W, H);
    renderer.toneMapping = THREE.NoToneMapping;
    mount.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 5000);
    scene.add(new THREE.AmbientLight(0xffffff, 0.92));
    const d1 = new THREE.DirectionalLight(0xffffff, 0.18); d1.position.set(2, 5, 3); scene.add(d1);
    const d2 = new THREE.DirectionalLight(0xffffff, 0.08); d2.position.set(-3, 2, -2); scene.add(d2);

    // Outer group handles drag-rotate; inner stackGroup handles press-bounce.
    const group = new THREE.Group();
    scene.add(group);
    const stackGroup = new THREE.Group();
    group.add(stackGroup);

    // Trackball-style rotation: each drag delta is composed onto group.quaternion
    // as a world-axis rotation, giving full sphere coverage (not Euler-limited).
    const ctrl = {
      dragging: false,
      lastX: 0, lastY: 0,
      camDist: null,
      camDistInitial: null,
    };
    // Initial tilt: ~30° below horizontal so all 3 layers are visible.
    const X_AXIS = new THREE.Vector3(1, 0, 0);
    const Y_AXIS = new THREE.Vector3(0, 1, 0);
    const dragQuat = new THREE.Quaternion();
    group.quaternion.setFromAxisAngle(X_AXIS, Math.PI / 2 - 0.9);

    const baseMat   = new THREE.MeshLambertMaterial({ color: base.color   || "#cccccc" });
    const middleMat = new THREE.MeshLambertMaterial({ color: middle.color || "#cccccc" });
    const topMat    = new THREE.MeshLambertMaterial({ color: top.color    || "#cccccc" });

    const state = {
      renderer, scene, camera, group, stackGroup, ctrl,
      baseMat, middleMat, topMat,
      baseMesh: null, middleMesh: null, topMesh: null,
      baseRestY: 0, middleRestY: 0, topRestY: 0,
      stackHeight: 0,
      maxRadialSize: 0,
      pressing: false,
      pressStart: 0,
      ready: false,
      destroyed: false,
    };
    stateRef.current = state;

    // Load all 3 STLs in parallel, then assemble the stack and frame the camera.
    Promise.all([
      loadGeometry(base.url),
      loadGeometry(middle.url),
      loadGeometry(top.url),
    ]).then(([baseRaw, midRaw, topRaw]) => {
      if (cancelled || state.destroyed) return;

      const baseGeom = prepareMacaronPart(baseRaw);
      const midGeom  = prepareMacaronPart(midRaw);
      const topGeom  = prepareMacaronPart(topRaw);
      // Flip the base 180° on X so its dome mirrors the top — the two shells
      // hug the cream like a real macaron.
      baseGeom.rotateX(Math.PI);
      baseGeom.computeBoundingBox();

      state.baseMesh   = new THREE.Mesh(baseGeom, baseMat);
      state.middleMesh = new THREE.Mesh(midGeom,  middleMat);
      state.topMesh    = new THREE.Mesh(topGeom,  topMat);
      stackGroup.add(state.baseMesh, state.middleMesh, state.topMesh);

      // Anchor each part's bottom at a cumulative Y cursor (bottom → top).
      const parts = [
        { mesh: state.baseMesh,   geom: baseGeom },
        { mesh: state.middleMesh, geom: midGeom  },
        { mesh: state.topMesh,    geom: topGeom  },
      ];
      let cursor = 0;
      for (const { mesh, geom } of parts) {
        const bb = geom.boundingBox;
        const sz = new THREE.Vector3(); bb.getSize(sz);
        mesh.position.y = cursor - bb.min.y;
        cursor += sz.y;
        state.maxRadialSize = Math.max(state.maxRadialSize, sz.x, sz.z);
      }
      state.stackHeight = cursor;

      // Center the stack around y=0 by shifting each mesh down by half the
      // stack height. This way the outer rotation `group` pivots around the
      // visible center of the macaron, matching how the keyboard rotates.
      const halfH = cursor / 2;
      state.baseMesh.position.y   -= halfH;
      state.middleMesh.position.y -= halfH;
      state.topMesh.position.y    -= halfH;
      // Nudge the base 15% of stack height upward so the bottom shell hugs
      // the cream instead of sitting with a visible gap below it.
      state.baseMesh.position.y += cursor * 0.12;
      state.baseRestY   = state.baseMesh.position.y;
      state.middleRestY = state.middleMesh.position.y;
      state.topRestY    = state.topMesh.position.y;
      stackGroup.position.y = 0;

      // Frame the camera around the whole stack.
      const fov = camera.fov * (Math.PI / 180);
      const frameSize = Math.max(state.maxRadialSize, state.stackHeight) * 1.6;
      const dist = (frameSize / 2) / Math.tan(fov / 2) * 1.4 * 1.2;
      camera.position.set(0, 0, dist);
      camera.lookAt(0, 0, 0);
      ctrl.camDist = dist;
      ctrl.camDistInitial = dist;

      state.ready = true;
    }).catch((e) => console.error("Macaron STL load failed", e));

    // ---------- input ----------
    const dom = renderer.domElement;
    dom.style.cursor = "grab";
    dom.style.touchAction = "none";

    let moveDist = 0;
    const pointers = new Map(); // pointerId → { x, y }
    let pinchDist = null;
    const raycaster = new THREE.Raycaster();
    const ndc = new THREE.Vector2();

    function pickAnyPart(clientX, clientY) {
      const s = stateRef.current;
      if (!s || !s.ready) return false;
      const rect = dom.getBoundingClientRect();
      ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
      ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
      raycaster.setFromCamera(ndc, camera);
      const meshes = [s.baseMesh, s.middleMesh, s.topMesh].filter(Boolean);
      const hits = raycaster.intersectObjects(meshes, false);
      return hits.length > 0;
    }

    function pressStack() {
      const s = stateRef.current;
      if (!s || !s.ready || s.pressing) return;
      s.pressing = true;
      s.pressStart = performance.now();
      if (onPressRef.current) onPressRef.current();
    }

    dom.addEventListener("wheel", (e) => {
      e.preventDefault();
      if (ctrl.camDist == null) return;
      const factor = Math.exp(e.deltaY * 0.0015);
      ctrl.camDist = Math.max(ctrl.camDist * 0.2, Math.min(ctrl.camDist * 8, camera.position.length() * factor));
      const d = camera.position.clone().normalize();
      camera.position.copy(d.multiplyScalar(ctrl.camDist));
    }, { passive: false });
    dom.addEventListener("pointerdown", (e) => {
      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
      try { dom.setPointerCapture(e.pointerId); } catch (_) {}
      if (pointers.size === 1) {
        ctrl.dragging = true;
        ctrl.lastX = e.clientX;
        ctrl.lastY = e.clientY;
        moveDist = 0;
        dom.style.cursor = "grabbing";
      } else if (pointers.size === 2) {
        ctrl.dragging = false; // pause rotation during pinch
        const pts = Array.from(pointers.values());
        pinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
      }
    });
    dom.addEventListener("pointermove", (e) => {
      if (!pointers.has(e.pointerId)) return;
      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });

      if (pointers.size === 2 && pinchDist != null) {
        const pts = Array.from(pointers.values());
        const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
        const factor = pinchDist / Math.max(dist, 0.0001); // spread fingers → factor < 1 → zoom in
        if (ctrl.camDist != null) {
          const init = ctrl.camDistInitial || camera.position.length();
          ctrl.camDist = Math.max(init * 0.2, Math.min(init * 8, ctrl.camDist * factor));
          const d = camera.position.clone().normalize();
          camera.position.copy(d.multiplyScalar(ctrl.camDist));
        }
        pinchDist = dist;
        return;
      }

      if (!ctrl.dragging || pointers.size !== 1) return;
      const dx = e.clientX - ctrl.lastX;
      const dy = e.clientY - ctrl.lastY;
      moveDist += Math.abs(dx) + Math.abs(dy);
      // Horizontal drag → rotate around world Y (camera-up axis)
      dragQuat.setFromAxisAngle(Y_AXIS, dx * 0.01);
      group.quaternion.premultiply(dragQuat);
      // Vertical drag → rotate around world X (camera-right axis)
      dragQuat.setFromAxisAngle(X_AXIS, dy * 0.01);
      group.quaternion.premultiply(dragQuat);
      ctrl.lastX = e.clientX; ctrl.lastY = e.clientY;
    });
    function endPointer(e) {
      pointers.delete(e.pointerId);
      try { dom.releasePointerCapture(e.pointerId); } catch (_) {}
      if (pointers.size < 2) pinchDist = null;
      if (pointers.size === 0) {
        ctrl.dragging = false;
        dom.style.cursor = "grab";
        if (moveDist < 5) {
          if (pickAnyPart(e.clientX, e.clientY)) pressStack();
        }
      }
    }
    dom.addEventListener("pointerup", endPointer);
    dom.addEventListener("pointercancel", endPointer);

    const ro = new ResizeObserver(() => {
      const w = mount.clientWidth, h = mount.clientHeight;
      renderer.setSize(w, h);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
    });
    ro.observe(mount);

    function animate() {
      raf = requestAnimationFrame(animate);
      const s = stateRef.current;
      if (s && s.ready && s.pressing) {
        const downMs = 40, holdMs = 50, upMs = 60;
        const total = downMs + holdMs + upMs;
        // Each shell moves this much toward the cream; middle stays frozen.
        const depth = s.stackHeight * 0.04;
        const t = performance.now() - s.pressStart;
        let squish = 0;
        if      (t < downMs)            squish = depth * (t / downMs);
        else if (t < downMs + holdMs)   squish = depth;
        else if (t < total)             squish = depth * (1 - (t - downMs - holdMs) / upMs);
        else { squish = 0; s.pressing = false; }
        s.topMesh.position.y    = s.topRestY    - squish;
        s.baseMesh.position.y   = s.baseRestY   + squish;
        s.middleMesh.position.y = s.middleRestY;
      }
      // group.quaternion is updated directly in pointermove; nothing to do here.
      renderer.render(scene, camera);
    }
    animate();

    return () => {
      cancelled = true;
      state.destroyed = true;
      cancelAnimationFrame(raf);
      ro.disconnect();
      stateRef.current = null;
      renderer.dispose();
      if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
    };
    // Re-init only if any URL changes (colors are handled by the effect below).
  }, [top && top.url, middle && middle.url, base && base.url]);

  // Live color updates — never rebuild the scene.
  useEffect(() => {
    const s = stateRef.current;
    if (!s) return;
    if (top    && top.color)    s.topMat.color.set(top.color);
    if (middle && middle.color) s.middleMat.color.set(middle.color);
    if (base   && base.color)   s.baseMat.color.set(base.color);
  }, [top && top.color, middle && middle.color, base && base.color]);

  return React.createElement("div", { className: "three-mount", ref: mountRef });
}

window.MacaronViewer = MacaronViewer;
