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

// API:
//   <ModelViewer
//     cap={{ url, color }}                                  // fallback cap color (used when slot has no capColor)
//     letters={[{ url, color, capColor? }, ...]}            // one entry per character; per-slot cap color override is optional
//   />
//
// The scene is initialized ONCE per cap.url. After that, typing/erasing characters
// only spawns or despawns letter+cap pairs — no scene rebuild, no camera reset,
// no rotation reset. Colors update in place. Drag rotates, wheel zooms.

const _geomCache = new Map(); // url -> raw THREE.BufferGeometry
const _preparedLetterCache = new Map(); // url -> prepared (centered, oriented) BufferGeometry

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);
  });
}

function prepareCap(rawGeom) {
  const g = rawGeom.clone();
  g.computeBoundingBox();
  const bb = g.boundingBox;
  const c = new THREE.Vector3(); bb.getCenter(c);
  g.translate(-c.x, -c.y, -c.z);
  g.rotateX(-Math.PI / 2);
  g.computeBoundingBox();
  return g;
}

function prepareLetter(url, rawGeom) {
  if (_preparedLetterCache.has(url)) return _preparedLetterCache.get(url);
  const g = rawGeom.clone();
  g.computeBoundingBox();
  const bb = g.boundingBox;
  const c = new THREE.Vector3(); bb.getCenter(c);
  g.translate(-c.x, -c.y, -c.z);
  g.rotateX(-Math.PI / 2);
  g.computeBoundingBox();
  const bb2 = g.boundingBox;
  const size = new THREE.Vector3(); bb2.getSize(size);
  g.translate(
    -(bb2.min.x + size.x / 2),
    -bb2.min.y,
    -(bb2.min.z + size.z / 2)
  );
  g.computeBoundingBox();
  _preparedLetterCache.set(url, g);
  return g;
}

function ModelViewer({ cap, letters = [], base = null, background = "transparent", onCapPress }) {
  const mountRef = useRef(null);
  const stateRef = useRef(null);
  const onCapPressRef = useRef(null);
  useEffect(() => { onCapPressRef.current = onCapPress; }, [onCapPress]);

  // ---------- One-time init (per cap URL) ----------
  useEffect(() => {
    const mount = mountRef.current;
    if (!mount || !window.THREE || !cap) 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);

    const group = new THREE.Group();
    scene.add(group);

    // Top-down default angle: looking nearly straight down with a small forward tilt.
    const ctrl = {
      rotY: 0,
      rotX: Math.PI / 2 - 0.35,
      dragging: false,
      lastX: 0, lastY: 0,
      camDist: null,
      camDistInitial: null,
    };

    // Default cap color used when a slot has no per-slot override.
    const defaultCapColor = (cap && cap.color) || "#cccccc";
    const baseMat = new THREE.MeshLambertMaterial({ color: (base && base.color) || "#cccccc" });

    const state = {
      renderer, scene, camera, group, ctrl,
      defaultCapColor,        // current global cap color (used when slot.capColor is null)
      capMats: [],            // per-slot cap material, index-aligned with slots
      baseMat,
      letterMats: [],         // index-aligned with current slots
      slots: [],              // [{ capMesh, letterMesh|null, url }]
      capGeom: null,
      capWidth: 0,
      capTopY: 0,
      capBottomY: 0,
      baseGeom: null,
      baseSize: null,
      baseDesignedFor: (base && base.designedFor) || 4,
      baseMesh: null,
      baseUrl: (base && base.url) || null,
      ready: false,
      pendingLetters: null,
      pendingCapColor: null,
      destroyed: false,
    };
    stateRef.current = state;

    // Load cap once.
    loadGeometry(cap.url).then((rawCap) => {
      if (cancelled) return;
      const capGeom = prepareCap(rawCap);
      const capBB = capGeom.boundingBox;
      const capSize = new THREE.Vector3(); capBB.getSize(capSize);
      state.capGeom = capGeom;
      state.capWidth = capSize.x;
      state.capTopY = capBB.max.y;
      state.capBottomY = capBB.min.y;
      state.ready = true;

      // Frame the camera around a single cap initially (we'll keep camera distance
      // stable as letters are added so it doesn't jump).
      const fov = camera.fov * (Math.PI / 180);
      const initial = Math.max(capSize.x, capSize.y, capSize.z) * 4; // headroom for ~6 caps
      const dist = (initial / 2) / Math.tan(fov / 2) * 2.0;
      camera.position.set(0, 0, dist);
      camera.lookAt(0, 0, 0);
      ctrl.camDist = dist;
      ctrl.camDistInitial = dist;

      // Apply any letters that arrived during load.
      if (state.pendingLetters) {
        reconcile(state.pendingLetters);
        state.pendingLetters = null;
      }
    }).catch((e) => console.error("Cap STL load failed", e));

    // Load base STL (if provided). Center on origin and rotate same as cap so
    // its authored "top" face is +Y.
    if (state.baseUrl) {
      loadGeometry(state.baseUrl).then((rawBase) => {
        if (cancelled) return;
        const g = rawBase.clone();
        g.computeBoundingBox();
        const bb = g.boundingBox;
        const c = new THREE.Vector3(); bb.getCenter(c);
        g.translate(-c.x, -c.y, -c.z);
        // Match the cap's authored orientation: rotate -90° on X.
        g.rotateX(-Math.PI / 2);
        // The base STL's long axis after that rotation runs along Z (front-to-back),
        // so add a 90° Y rotation to align the long axis with X (left-to-right),
        // matching the cap row.
        g.rotateY(Math.PI / 2);
        g.computeBoundingBox();
        const sz = new THREE.Vector3(); g.boundingBox.getSize(sz);
        state.baseGeom = g;
        state.baseSize = sz;
        if (state.ready) reconcileFor(state, letters);
      }).catch((e) => console.error("Base STL load failed", e));
    }

    // ---- Eager preload of every letter STL ----
    // The viewer is created once per session; warming the cache up front means
    // typing has no async latency and avoids visible swap delays.
    const PRELOAD_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map((ch) => `models/letters/${ch}.stl`);
    PRELOAD_LETTERS.push("models/letters/AMP.stl");
    PRELOAD_LETTERS.forEach((url) => { loadGeometry(url).catch(() => {}); });

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

    // Click vs drag detection — track total movement during pointer down.
    let downX = 0, downY = 0, moveDist = 0;
    const pointers = new Map();
    let pinchDist = null;
    const raycaster = new THREE.Raycaster();
    const ndc = new THREE.Vector2();

    function pickCapSlot(clientX, clientY) {
      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 state = stateRef.current;
      if (!state) return -1;
      // Build the candidate list: every cap mesh AND every letter mesh, mapped back to slot index.
      const meshList = [];
      const slotIndexByMesh = new Map();
      state.slots.forEach((s, i) => {
        if (s.capMesh) { meshList.push(s.capMesh); slotIndexByMesh.set(s.capMesh, i); }
        if (s.letterMesh) { meshList.push(s.letterMesh); slotIndexByMesh.set(s.letterMesh, i); }
      });
      const hits = raycaster.intersectObjects(meshList, false);
      if (hits.length === 0) return -1;
      return slotIndexByMesh.get(hits[0].object) ?? -1;
    }

    function pressSlot(slotIndex) {
      const state = stateRef.current;
      if (!state) return;
      const slot = state.slots[slotIndex];
      if (!slot || slot.dead || slot.pressing) return;
      slot.pressing = true;
      slot.pressStart = performance.now();
      // Notify host (e.g. play sound)
      if (onCapPressRef.current) onCapPressRef.current(slotIndex);
    }

    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;
        downX = e.clientX; downY = e.clientY; moveDist = 0;
        dom.style.cursor = "grabbing";
      } else if (pointers.size === 2) {
        ctrl.dragging = false;
        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);
        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);
      ctrl.rotY += dx * 0.01;
      ctrl.rotX += dy * 0.01;
      ctrl.rotX = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, ctrl.rotX));
      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";
        // Treat as a click only if movement was tiny — otherwise it was a drag.
        if (moveDist < 5) {
          const idx = pickCapSlot(e.clientX, e.clientY);
          if (idx >= 0) pressSlot(idx);
        }
      }
    }
    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 state = stateRef.current;
      // Press animations: drop cap (and its letter) Y by ~30% of cap height,
      // hold for 1s, then ease back up.
      if (state && state.capWidth) {
        const now = performance.now();
        const downMs = 40, holdMs = 50, upMs = 60;
        const total = downMs + holdMs + upMs;
        const depth = state.capWidth * 0.18; // press travel
        for (let i = 0; i < state.slots.length; i++) {
          const s = state.slots[i];
          if (!s.pressing) continue;
          const t = now - s.pressStart;
          let drop = 0;
          if (t < downMs)            drop = depth * (t / downMs);              // press down
          else if (t < downMs + holdMs) drop = depth;                          // held
          else if (t < total)        drop = depth * (1 - (t - downMs - holdMs) / upMs); // release
          else { drop = 0; s.pressing = false; }
          if (s.capMesh)    s.capMesh.position.y    = -drop;
          if (s.letterMesh) s.letterMesh.position.y = state.capTopY - drop;
        }
      }
      group.rotation.y = ctrl.rotY;
      group.rotation.x = ctrl.rotX;
      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);
    };
  }, [cap && cap.url, base && base.url]);

  // ---------- Reconcile letter row whenever `letters` changes ----------
  useEffect(() => {
    const state = stateRef.current;
    if (!state) return;
    if (!state.ready) {
      state.pendingLetters = letters;
      return;
    }
    reconcileFor(state, letters);
  }, [letters.map((l) => l.url).join("|"), letters.length]);

  // ---------- Color updates in place ----------
  useEffect(() => {
    const state = stateRef.current;
    if (!state) return;
    const newDefault = (cap && cap.color) || state.defaultCapColor;
    state.defaultCapColor = newDefault;
    if (base && base.color) state.baseMat.color.set(base.color);
    // Update each slot's cap color: per-slot capColor wins, else global default.
    state.capMats.forEach((m, i) => {
      if (!m) return;
      const override = letters[i] && letters[i].capColor;
      m.color.set(override || newDefault);
    });
    // Update each slot's letter color independently.
    state.letterMats.forEach((m, i) => {
      if (!m) return;
      const lc = letters[i] && letters[i].color;
      if (lc) m.color.set(lc);
    });
  }, [
    cap && cap.color,
    base && base.color,
    letters.map((l) => l.color).join("|"),
    letters.map((l) => l.capColor || "").join("|"),
  ]);

  function reconcile(letters) {
    const state = stateRef.current;
    if (!state || !state.ready) return;
    reconcileFor(state, letters);
  }

  function reconcileFor(state, letters) {
    const { group, capGeom, capWidth, capTopY } = state;
    const n = Math.max(1, letters.length); // always show at least one cap
    // Previous gap (use to revert): const gap = capWidth * 0.04;
    const gap = 0;
    const pitch = capWidth + gap;
    const totalWidth = n * capWidth + (n - 1) * gap;
    const startX = -totalWidth / 2 + capWidth / 2;

    // ---- Base enclosure: scale X to match the row width ----
    if (state.baseGeom && state.baseSize) {
      const designedFor = Math.max(1, state.baseDesignedFor);
      // Authored base width corresponds to `designedFor` keys → per-key width
      // along X = baseSize.x / designedFor. Span N keys' worth.
      const targetWidth = (state.baseSize.x / designedFor) * n;
      const sx = targetWidth / state.baseSize.x;

      if (!state.baseMesh) {
        const m = new THREE.Mesh(state.baseGeom, state.baseMat);
        // Place base so its top face sits AT the bottom of the caps.
        m.position.y = (state.capBottomY ?? 0) - state.baseSize.y / 2;
        group.add(m);
        state.baseMesh = m;
      }
      state.baseMesh.scale.set(sx, 1, 1);
      state.baseMesh.position.x = 0;
    }

    // Trim excess slots — mark them dead so any in-flight async load drops its result.
    while (state.slots.length > n) {
      const slot = state.slots.pop();
      slot.dead = true;
      if (slot.capMesh) group.remove(slot.capMesh);
      if (slot.letterMesh) group.remove(slot.letterMesh);
      state.letterMats.pop();
      state.capMats.pop();
    }

    // Add or update slots
    for (let i = 0; i < n; i++) {
      const x = startX + i * pitch;
      const wantUrl = letters[i] ? letters[i].url : null;
      const lc = (letters[i] && letters[i].color) || "#ffffff";

      const capColorForSlot = (letters[i] && letters[i].capColor) || state.defaultCapColor;
      let slot = state.slots[i];
      if (!slot) {
        const slotCapMat = new THREE.MeshLambertMaterial({ color: capColorForSlot });
        const capMesh = new THREE.Mesh(capGeom, slotCapMat);
        capMesh.position.set(x, 0, 0);
        group.add(capMesh);
        slot = { capMesh, letterMesh: null, url: null, epoch: 0, dead: false };
        state.slots.push(slot);
        state.letterMats.push(null);
        state.capMats.push(slotCapMat);
      } else {
        slot.capMesh.position.x = x;
        if (state.capMats[i]) state.capMats[i].color.set(capColorForSlot);
      }

      // Letter handling — bump epoch so any prior in-flight load is invalidated.
      if (wantUrl && wantUrl !== slot.url) {
        slot.epoch++;
        const myEpoch = slot.epoch;
        const slotIndex = i;
        // Remove the old letter mesh IMMEDIATELY (don't wait for new STL to load) —
        // this prevents stale letters from persisting visually during fast edits.
        if (slot.letterMesh) {
          group.remove(slot.letterMesh);
          slot.letterMesh = null;
          slot.url = null;
          state.letterMats[slotIndex] = null;
        }
        loadGeometry(wantUrl).then((raw) => {
          // Bail if scene is gone, slot was trimmed, or another reconcile superseded us.
          if (state.destroyed || slot.dead || slot.epoch !== myEpoch) return;
          // Re-check slot is still in current array at this index (defensive).
          if (state.slots[slotIndex] !== slot) return;
          const lg = prepareLetter(wantUrl, raw);
          const mat = new THREE.MeshLambertMaterial({ color: lc });
          const lm = new THREE.Mesh(lg, mat);
          lm.position.set(slot.capMesh.position.x, capTopY, 0);
          group.add(lm);
          slot.letterMesh = lm;
          slot.url = wantUrl;
          state.letterMats[slotIndex] = mat;
        }).catch((e) => console.error("Letter STL load failed", wantUrl, e));
      } else if (slot.letterMesh) {
        // Just keep position aligned with the (possibly shifted) cap.
        slot.letterMesh.position.x = slot.capMesh.position.x;
        if (state.letterMats[i]) state.letterMats[i].color.set(lc);
      }

      if (!wantUrl && slot.letterMesh) {
        group.remove(slot.letterMesh);
        slot.letterMesh = null;
        slot.url = null;
        state.letterMats[i] = null;
      }
    }

    // Recenter group horizontally so the row stays centered as it grows/shrinks.
    // (We DO NOT move the camera — the initial framing has headroom for several caps.)
    group.position.x = 0;
  }

  return <div ref={mountRef} className="mv-mount" style={{ width: "100%", height: "100%", background }} />;
}

window.ModelViewer = ModelViewer;
