/* eslint-disable */
/* cityform v2 — Model3DViewer
   Loads a GLB via Three.js (ES-module flavour, attached to window.__cfThree
   by the host HTML). Slowly auto-rotates; user can drag to orbit. Falls
   back to a static photo if the module didn't load (no internet, old
   browser) — so the hero always renders something beautiful.

   Loaded GLBs are cached at module scope so re-mounting the viewer on
   tab/page changes doesn't re-download. */

const _glbCache = new Map();

function loadGlb(url, THREE, GLTFLoader) {
  if (_glbCache.has(url)) return _glbCache.get(url);
  const p = new Promise((resolve, reject) => {
    new GLTFLoader().load(
      url,
      (gltf) => resolve(gltf),
      undefined,
      (err) => reject(err),
    );
  });
  _glbCache.set(url, p);
  return p;
}

function Model3DViewer({
  url,
  fallback,
  label,
  autoRotate = true,
  autoRotateSpeed = 0.55,
  height = 540,
  showHint = true,
  className,
  style,
}) {
  const mountRef = React.useRef(null);
  const [phase, setPhase] = React.useState('loading');   // loading | ready | error
  const [threeReady, setThreeReady] = React.useState(!!window.__cfThree);

  // Wait for the importmap module to attach window.__cfThree
  React.useEffect(() => {
    if (threeReady) return;
    let cancelled = false;
    const onReady = () => { if (!cancelled) setThreeReady(true); };
    window.addEventListener('cf-three-ready', onReady);
    // Re-check every 200ms in case the event already fired
    const id = setInterval(() => {
      if (window.__cfThree) { onReady(); clearInterval(id); }
    }, 200);
    // Abandon after 6s — fall back to the photo
    const giveUp = setTimeout(() => {
      if (!cancelled && !window.__cfThree) setPhase('error');
    }, 6000);
    return () => {
      cancelled = true;
      clearInterval(id);
      clearTimeout(giveUp);
      window.removeEventListener('cf-three-ready', onReady);
    };
  }, []);

  // No GLB for this city → don't sit on "LOADING MODEL…"; surface the
  // fallback photo immediately (per-city hero with no model_glb).
  React.useEffect(() => {
    if (!url) setPhase('error');
  }, [url]);

  React.useEffect(() => {
    if (!threeReady || !url || !mountRef.current) return;
    const mount = mountRef.current;
    const { THREE, GLTFLoader, OrbitControls, STLLoader } = window.__cfThree;

    let renderer, scene, camera, controls, root, rig, base, holder, plate, raf, ro;
    let disposed = false;
    let waitRO = null, waitRaf = 0, started = false;
    const userInteractRef = { current: false };

    // Defer all Three.js setup until the mount has real dimensions. In this
    // in-browser-Babel SPA the effect can run before first layout, when the
    // mount is 0×0; initialising at a fallback size would render the model
    // off-frame until a later resize. Wait for a real measurement instead.
    const start = () => {
      if (started || disposed) return;
      started = true;
      if (waitRO) { waitRO.disconnect(); waitRO = null; }
      if (waitRaf) { cancelAnimationFrame(waitRaf); waitRaf = 0; }
      const w = mount.clientWidth;
      const h = mount.clientHeight;

    scene = new THREE.Scene();
    // Transparent so the brand background shows through
    scene.background = null;

    camera = new THREE.PerspectiveCamera(36, w / h, 0.1, 5000);
    // Hero view: model lies flat (rig has no forward tilt) and the camera
    // looks DOWN at it from a 3/4 elevation. HERO_POLAR is the angle from
    // straight-up (0 = top-down, π/2 = edge-on); ~0.26π ≈ 47° → a "two-
    // thirds from above" product angle. Lower it for more top-down, raise
    // it toward π/2 for more side-on. Radius 320 keeps the fit-to-frame.
    const HERO_POLAR = Math.PI * 0.26;
    const HERO_R = 320;
    camera.position.set(0, HERO_R * Math.cos(HERO_POLAR), HERO_R * Math.sin(HERO_POLAR));

    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
    renderer.setSize(w, h);
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.0;
    if ('outputColorSpace' in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
    mount.appendChild(renderer.domElement);

    // Lighting — soft hemisphere + one warm key + one cool fill.
    // Tuned to make matte PETG read as a real object.
    const hemi = new THREE.HemisphereLight(0xffffff, 0xB8BDB7, 0.7);
    hemi.position.set(0, 1, 0);
    scene.add(hemi);
    scene.add(new THREE.AmbientLight(0xffffff, 0.18));
    const key  = new THREE.DirectionalLight(0xffe5cc, 0.85);
    key.position.set(2, 3, 4);
    scene.add(key);
    const rim  = new THREE.DirectionalLight(0xC9D2DC, 0.45);
    rim.position.set(-3, 1.5, -2);
    scene.add(rim);

    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    controls.enablePan = false;
    controls.enableZoom = false;
    controls.autoRotate = autoRotate;
    controls.autoRotateSpeed = autoRotateSpeed;
    // Allow the elevated hero angle (HERO_POLAR ≈ 0.26π) without clamping,
    // plus a little drag range around it. autoRotate only spins azimuth, so
    // the chosen elevation persists while it rotates.
    controls.minPolarAngle = Math.PI * 0.08;
    controls.maxPolarAngle = Math.PI * 0.55;
    controls.addEventListener('start', () => {
      userInteractRef.current = true;
      controls.autoRotate = false;
    });
    controls.addEventListener('end', () => {
      // Resume gentle rotation 3s after user lets go.
      setTimeout(() => { if (!disposed) controls.autoRotate = autoRotate; }, 3000);
    });

    // The GLB is city-mesh only (the preview-glb pipeline never bakes the
    // base in). To match v1 / the commission preview, we composite the SAME
    // real accessory STLs v1's STLViewer uses — base + tag holder — around
    // the GLB city in a shared rig, then tilt the rig so model and base
    // rotate together. Accessory STLs are tiny; the city stays a light GLB.
    const ACC_RATIO = 104.6 / 89.5;   // real base-foot : model-foot (v1)
    // Served as static files on Cloudflare Pages (the Flask /api/accessory
    // route does not exist here). Files live in public/accessory/.
    const accUrl = (f) => '/accessory/' + encodeURIComponent(f);

    let fit0 = null;   // { modelFoot, modelMinY }, derived once from the GLB

    // Faithful port of v1 STLViewer's accessory assembly. v1 builds base +
    // holder + tag in the accessory's native CAD frame, derives ONE fit
    // from the base, and applies that SAME fit to all three so the slot and
    // plate stay exactly as authored. v1 additionally rotateX(π/2) to reach
    // its Z-up scene; our scene is Y-up and the CAD frame is already Y-up
    // (base thickness on Y), so we drop ONLY that scene rotation — every
    // relative transform and the measured slot constants are kept verbatim.
    let fitParams = null;   // { s, tx, ty, tz } — derived from the base

    const computeFitFromBase = (bg) => {
      bg.computeBoundingBox();
      let bb = bg.boundingBox;
      const bFootX = (bb.max.x - bb.min.x) || 1;
      const s = (fit0.modelFoot * ACC_RATIO) / bFootX;   // GLB ≠ mm → always scale
      bg.scale(s, s, s);
      bg.computeBoundingBox();
      bb = bg.boundingBox;
      const cx = (bb.min.x + bb.max.x) / 2;
      const cz = (bb.min.z + bb.max.z) / 2;
      const thick = bb.max.y - bb.min.y;
      fitParams = {
        s, tx: -cx, tz: -cz,
        ty: fit0.modelMinY - bb.max.y + thick * 0.02,   // seat base top at city underside
      };
      bg.translate(fitParams.tx, fitParams.ty, fitParams.tz);
      return bg;
    };

    const applyFit = (g) => {
      g.scale(fitParams.s, fitParams.s, fitParams.s);
      g.translate(fitParams.tx, fitParams.ty, fitParams.tz);
      return g;
    };

    // Engraved label plate — same artwork as v1's STLViewer / the Make
    // Yours preview (carbon plate #2C313A, pearl #C8CCD0 text + divider,
    // single-line city plate). Catalogue items carry name + coords, so the
    // plate reads the same as the commission one.
    const makeLabelTexture = (lab) => {
      const S = 24;                       // px per mm
      const cv = document.createElement('canvas');
      cv.width = Math.round(57.6 * S); cv.height = Math.round(7.6 * S);
      const x = cv.getContext('2d');
      const mmX = v => v * S, mmY = v => v * S;
      x.fillStyle = '#2C313A'; x.fillRect(0, 0, cv.width, cv.height);
      const pearl = '#C8CCD0';
      x.strokeStyle = pearl; x.lineWidth = mmX(0.25);
      x.beginPath(); x.moveTo(mmX(33.6), mmY(1.9)); x.lineTo(mmX(33.6), mmY(5.7)); x.stroke();
      x.fillStyle = pearl; x.textBaseline = 'alphabetic';
      const FF = "'Inter','Helvetica Neue',Arial,sans-serif";
      const set = (w, sz, ls) => { x.font = `${w} ${mmY(sz)}px ${FF}`; x.letterSpacing = `${mmX(ls)}px`; };
      if (lab.subline) {
        x.textAlign = 'left';
        set(600, 1.55, 0.45); x.fillText(lab.title,   mmX(3), mmY(2.91));
        set(600, 2.0,  0.18); x.fillText(lab.subline, mmX(3), mmY(5.81));
      } else {
        x.textAlign = 'left';
        set(600, 2.4, 0.28); x.fillText(lab.title, mmX(3), mmY(4.70));
      }
      if (lab.showCoords !== false && lab.coord) {
        x.textAlign = 'right';
        set(400, 1.8, 0.16); x.fillText(lab.coord, mmX(54.6), mmY(4.46));
      }
      const tex = new THREE.CanvasTexture(cv);
      if ('SRGBColorSpace' in THREE) tex.colorSpace = THREE.SRGBColorSpace;
      else if (THREE.sRGBEncoding) tex.encoding = THREE.sRGBEncoding;
      tex.needsUpdate = true;
      return tex;
    };

    // v1's exact tag pill: built in the native CAD frame, leaned 14° into
    // the holder slot via the measured constants, then the shared fit
    // applied — identical to STLViewer / the Make Yours preview.
    const buildTagMesh = () => {
      const L = 57.6, H = 7.6, TH = 0.8, R = 3.8;
      const sh = new THREE.Shape();
      sh.moveTo(-L / 2 + R, -H / 2);
      sh.lineTo(L / 2 - R, -H / 2);
      sh.absarc(L / 2 - R, 0, R, -Math.PI / 2, Math.PI / 2, false);
      sh.lineTo(-L / 2 + R, H / 2);
      sh.absarc(-L / 2 + R, 0, R, Math.PI / 2, Math.PI * 1.5, false);
      const uvGen = {
        generateTopUV(g, vs, a, b, cc) {
          const f = i => new THREE.Vector2((vs[i * 3] + L / 2) / L, (vs[i * 3 + 1] + H / 2) / H);
          return [f(a), f(b), f(cc)];
        },
        generateSideWallUV() { const z = new THREE.Vector2(0, 0); return [z, z, z, z]; },
      };
      const tg = new THREE.ExtrudeGeometry(sh, { depth: TH, bevelEnabled: false, UVGenerator: uvGen });
      tg.rotateY(-Math.PI / 2);   // length→Z, height→Y, thickness→X (CAD frame)
      tg.rotateZ(-0.24495);       // match the holder slot's 14.036° wall lean
      tg.computeBoundingBox();
      const c = tg.boundingBox.getCenter(new THREE.Vector3());
      const TX = -4.910, TY = -3.652, TZ = -44.684;   // measured slot gap-centre
      tg.translate(TX - c.x, TY - c.y, TZ - c.z);
      applyFit(tg);               // SAME fit as base + holder → seated in slot
      const mat = label
        ? new THREE.MeshStandardMaterial({ map: makeLabelTexture(label), color: 0xffffff, metalness: 0, roughness: 0.5 })
        : new THREE.MeshStandardMaterial({ color: 0xC9CDD1, metalness: 0.85, roughness: 0.35 });
      const m = new THREE.Mesh(tg, mat);
      m.name = 'plate';                 // named → recolourable later
      return m;
    };

    loadGlb(url, THREE, GLTFLoader).then((gltf) => {
      if (disposed) return;
      root = gltf.scene;

      // The preview GLB comes from a Z-up source STL (heights on Z, same
      // convention v1's STLViewer uses with a Z-up scene). Model3DViewer's
      // scene is Y-up, so the city loads lying on its side — 90° off the
      // Y-up accessory base. Rotate it upright so model and base agree.
      root.rotation.x = -Math.PI / 2;

      // Fit-to-frame. The tile spins about its vertical axis, so its
      // worst-case on-screen size is the horizontal DIAGONAL (corner to
      // corner), not the longest single side — and the viewport is portrait
      // (fov 36 is vertical; horizontal is tighter by the aspect ratio).
      // Fit the rotation-safe diagonal into the SMALLER of the two visible
      // extents so the whole model stays in frame at every rotation angle.
      let bb = new THREE.Box3().setFromObject(root);
      const sz = bb.getSize(new THREE.Vector3());
      const fitDim = Math.max(Math.hypot(sz.x, sz.z), sz.y) || 1;
      const visV = 2 * HERO_R * Math.tan(THREE.MathUtils.degToRad(36 / 2));
      const visMin = Math.min(visV, visV * (w / h));   // tighter of vert/horiz
      const targetWorld = visMin * 0.82;
      root.scale.setScalar(targetWorld / fitDim);
      bb = new THREE.Box3().setFromObject(root);
      root.position.sub(bb.getCenter(new THREE.Vector3()));

      // Walk meshes — light material polish for matte PETG read.
      root.traverse((o) => {
        if (o.isMesh && o.material) {
          const m = o.material;
          if ('metalness' in m) m.metalness = 0;
          if ('roughness' in m) m.roughness = 0.95;
          if ('flatShading' in m) m.flatShading = true;
          m.needsUpdate = true;
        }
      });

      // Rig holds model + accessories so OrbitControls rotates the whole
      // assembly together; the forward tilt lives on the rig (same camera
      // /object angle the on-base photos use).
      rig = new THREE.Group();
      rig.add(root);
      const mb = new THREE.Box3().setFromObject(root);
      const ms = mb.getSize(new THREE.Vector3());
      fit0 = { modelFoot: Math.max(ms.x, ms.z) || 1, modelMinY: mb.min.y };
      // Model lies FLAT (base horizontal, city pointing up). The 3/4 "from
      // above" look comes from the elevated camera (HERO_POLAR), not from
      // tilting the assembly — so no rig pitch.
      rig.rotation.x = 0;
      // Starting yaw is derived from geometry once the base + holder STLs
      // load (see the orient step after rig.add(holder)) so auto-rotation
      // always begins on the FRONT / label-holder face. 0 until then.
      rig.rotation.y = 0;
      scene.add(rig);
      controls.target.set(0, 0, 0);
      controls.update();
      setPhase('ready');     // model visible immediately; base pops in async

      if (!STLLoader) return;
      const loader = new STLLoader();
      loader.load(accUrl('9x9 base (base only).stl'), (g) => {
        if (disposed) return;
        g.computeVertexNormals();
        computeFitFromBase(g);          // derive the one shared fit
        base = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
          color: 0x2C313A, metalness: 0, roughness: 0.82, flatShading: true,
        }));
        base.name = 'base';             // named → recolourable later
        rig.add(base);
        // Holder + tag reuse the base's fit verbatim (shared CAD origin).
        loader.load(accUrl('9x9 base (location tag holder).stl'), (hg) => {
          if (disposed) return;
          hg.computeVertexNormals();
          applyFit(hg);
          holder = new THREE.Mesh(hg, new THREE.MeshStandardMaterial({
            color: 0xF5F6F3, metalness: 0, roughness: 0.7, flatShading: true,
          }));
          holder.name = 'tagholder';
          rig.add(holder);
          // Orient the FRONT (label-holder) face toward the camera. The
          // camera sits on +Z looking at the origin, so we rotate the whole
          // rig about world-Y until the base→holder horizontal direction
          // points at +Z. Deterministic — no hardcoded yaw, and robust if
          // the accessory STLs' CAD orientation ever changes.
          rig.updateMatrixWorld(true);
          const baseC = new THREE.Box3().setFromObject(base).getCenter(new THREE.Vector3());
          const holdC = new THREE.Box3().setFromObject(holder).getCenter(new THREE.Vector3());
          const fx = holdC.x - baseC.x, fz = holdC.z - baseC.z;
          if (fx * fx + fz * fz > 1e-6) {
            const azim = Math.atan2(fx, fz);   // front-dir angle from +Z (camera)
            rig.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), -azim);
            rig.updateMatrixWorld(true);
          }
          plate = buildTagMesh();        // engraved plate, seated in the slot
          rig.add(plate);
        }, undefined, () => { /* holder optional — base alone still reads */ });
      }, undefined, () => { /* accessory unreachable — model still shows */ });
    }).catch((err) => {
      console.warn('[Model3DViewer] GLB load failed:', err);
      if (!disposed) setPhase('error');
    });

    const animate = () => {
      raf = requestAnimationFrame(animate);
      controls.update();
      renderer.render(scene, camera);
    };
    animate();

    ro = new ResizeObserver(() => {
      if (disposed) return;
      const nw = mount.clientWidth, nh = mount.clientHeight;
      if (!nw || !nh) return;
      camera.aspect = nw / nh;
      camera.updateProjectionMatrix();
      renderer.setSize(nw, nh);
    });
    ro.observe(mount);
    };

    if (mount.clientWidth > 0 && mount.clientHeight > 0) {
      start();
    } else {
      // No layout yet — start the moment the mount is measured.
      if (typeof ResizeObserver !== 'undefined') {
        waitRO = new ResizeObserver(() => {
          if (!disposed && mount.clientWidth > 0 && mount.clientHeight > 0) start();
        });
        waitRO.observe(mount);
      }
      const poll = () => {
        if (disposed || started) return;
        if (mount.clientWidth > 0 && mount.clientHeight > 0) start();
        else waitRaf = requestAnimationFrame(poll);
      };
      waitRaf = requestAnimationFrame(poll);
    }

    return () => {
      disposed = true;
      if (waitRO) waitRO.disconnect();
      if (waitRaf) cancelAnimationFrame(waitRaf);
      if (raf) cancelAnimationFrame(raf);
      if (ro) ro.disconnect();
      if (controls) controls.dispose();
      const disposeTree = (obj) => obj && obj.traverse((o) => {
        if (o.isMesh) {
          if (o.geometry) o.geometry.dispose();
          if (o.material) {
            const mats = Array.isArray(o.material) ? o.material : [o.material];
            mats.forEach((m) => { if (m.map) m.map.dispose(); m.dispose(); });
          }
        }
      });
      disposeTree(root);
      disposeTree(base);
      disposeTree(holder);
      disposeTree(plate);
      if (renderer) {
        renderer.dispose();
        if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
      }
    };
  }, [threeReady, url, autoRotate, autoRotateSpeed]);

  return (
    <div className={className} style={{
      position:'relative', width:'100%', height,
      background:'transparent',
      ...style,
    }}>
      <div ref={mountRef} style={{ width:'100%', height:'100%' }}/>

      {/* Loading / error overlay */}
      {phase !== 'ready' && (
        <div style={{
          position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center',
          pointerEvents: phase === 'error' ? 'none' : 'auto',
        }}>
          {phase === 'error' && fallback ? (
            <img src={fallback} alt="" style={{
              width:'100%', height:'100%', objectFit:'contain',
              filter:'contrast(1.04)',
            }}/>
          ) : (
            <span className="eyebrow" style={{ fontSize:9, color:'var(--stone)' }}>
              {phase === 'loading' ? 'LOADING MODEL…' : 'PREVIEW UNAVAILABLE'}
            </span>
          )}
        </div>
      )}

      {/* Interact hint — fades out once user has played */}
      {showHint && phase === 'ready' && (
        <div style={{
          position:'absolute', bottom:14, left:'50%',
          transform:'translateX(-50%)',
          pointerEvents:'none',
          display:'flex', alignItems:'center', gap:10,
          padding:'5px 12px',
          background:'rgba(247,248,246,0.85)',
          border:'1px solid var(--ink)',
        }}>
          <span style={{ width:6, height:6, background:'var(--ink)', borderRadius:'50%' }}/>
          <span className="eyebrow" style={{ fontSize:9 }}>DRAG TO ROTATE</span>
        </div>
      )}
    </div>
  );
}

Object.assign(window, { Model3DViewer });
