/* 3D gift player scene for the recipient page. Falls back to SkeuoCassette. */

function useThreeReady() {
  const [ready, setReady] = React.useState(() => Boolean(window.THREE));
  React.useEffect(() => {
    if (window.THREE) {
      setReady(true);
      return undefined;
    }
    const onReady = () => setReady(Boolean(window.THREE));
    window.addEventListener("ij:three-ready", onReady);
    return () => window.removeEventListener("ij:three-ready", onReady);
  }, []);
  return ready;
}

function probeWebGLSupport() {
  let context = null;
  try {
    const canvas = document.createElement("canvas");
    context = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
    return Boolean(context);
  } catch {
    return false;
  } finally {
    context?.getExtension?.("WEBGL_lose_context")?.loseContext?.();
  }
}

function useWebGLSupport() {
  const [supported, setSupported] = React.useState(false);
  const checkedRef = React.useRef(false);

  React.useEffect(() => {
    if (checkedRef.current) return undefined;
    checkedRef.current = true;
    setSupported(probeWebGLSupport());
    return undefined;
  }, []);

  return supported;
}

function useReducedMotion() {
  const [reduced, setReduced] = React.useState(() => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches || false);
  React.useEffect(() => {
    const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
    if (!query) return undefined;
    const onChange = () => setReduced(query.matches);
    query.addEventListener?.("change", onChange);
    return () => query.removeEventListener?.("change", onChange);
  }, []);
  return reduced;
}

function GiftPlayerFallback({ traits, playing, progress, width, reducedMotion }) {
  return (
    <div className="ij-gift-player-fallback">
      <SkeuoCassette
        w={width || 620}
        title={traits.label.title}
        meta={`${traits.label.meta} · ${traits.editionId}`}
        spinning={reducedMotion ? false : playing}
        reelRotation={reducedMotion ? 0 : progress * 1440}
        accent={traits.labelStripe}
        body={traits.shellFinish.body}
        bodyHighlight={traits.shellFinish.bodyHighlight}
        bodyShadow={traits.shellFinish.bodyShadow}
        labelTone={traits.labelTone === "aged" ? "warm" : traits.labelTone}
      />
    </div>
  );
}

function normalizeCssColor(value, fallback) {
  if (typeof value === "number") return `#${value.toString(16).padStart(6, "0")}`;
  if (typeof value === "string" && value.trim()) return value;
  return fallback;
}

function truncateCanvasText(ctx, text, maxWidth) {
  const clean = String(text || "");
  if (ctx.measureText(clean).width <= maxWidth) return clean;

  let shortened = clean;
  while (shortened.length > 4 && ctx.measureText(`${shortened}...`).width > maxWidth) {
    shortened = shortened.slice(0, -1).trim();
  }
  return `${shortened}...`;
}

function drawRoundRect(ctx, x, y, width, height, radius) {
  if (ctx.roundRect) {
    ctx.beginPath();
    ctx.roundRect(x, y, width, height, radius);
    ctx.fill();
    return;
  }

  ctx.beginPath();
  ctx.moveTo(x + radius, y);
  ctx.arcTo(x + width, y, x + width, y + height, radius);
  ctx.arcTo(x + width, y + height, x, y + height, radius);
  ctx.arcTo(x, y + height, x, y, radius);
  ctx.arcTo(x, y, x + width, y, radius);
  ctx.fill();
}

function createCassetteLabelTexture(THREE, sceneTraits) {
  const canvas = document.createElement("canvas");
  canvas.width = 1024;
  canvas.height = 320;
  const ctx = canvas.getContext("2d");
  const accent = normalizeCssColor(sceneTraits.labelStripe, "#D93616");

  ctx.fillStyle = "#F3DEAA";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = "rgba(58, 44, 34, 0.08)";
  for (let y = 22; y < canvas.height; y += 34) {
    ctx.fillRect(48, y, canvas.width - 96, 2);
  }

  ctx.fillStyle = accent;
  ctx.fillRect(0, 236, canvas.width, 54);
  ctx.fillStyle = "rgba(26, 23, 32, 0.18)";
  ctx.fillRect(0, 290, canvas.width, 6);

  ctx.fillStyle = "#16141B";
  ctx.font = "700 64px Georgia, serif";
  ctx.textBaseline = "top";
  ctx.fillText(truncateCanvasText(ctx, sceneTraits.labelTitle, 820), 72, 48);

  ctx.font = "500 30px JetBrains Mono, monospace";
  ctx.fillText(truncateCanvasText(ctx, sceneTraits.labelMeta, 760), 74, 140);

  ctx.font = "700 28px JetBrains Mono, monospace";
  ctx.fillStyle = "#FFF4C9";
  ctx.fillText(sceneTraits.editionId, 74, 249);

  ctx.fillStyle = "rgba(22, 20, 27, 0.72)";
  ctx.font = "700 38px JetBrains Mono, monospace";
  ctx.fillText("SIDE A", 782, 244);

  ctx.strokeStyle = "rgba(22, 20, 27, 0.16)";
  ctx.lineWidth = 8;
  ctx.strokeRect(24, 24, canvas.width - 48, canvas.height - 48);

  const texture = new THREE.CanvasTexture(canvas);
  if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace;
  texture.needsUpdate = true;
  return texture;
}

function createReelFaceTexture(THREE, sceneTraits, reelIndex) {
  const canvas = document.createElement("canvas");
  canvas.width = 768;
  canvas.height = 768;
  const ctx = canvas.getContext("2d");
  const accent = normalizeCssColor(sceneTraits.labelStripe, "#5C9EAD");
  const spokeCount = getHubSpokeCount(sceneTraits.editionId) + reelIndex;
  const cx = canvas.width / 2;
  const cy = canvas.height / 2;

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.translate(cx, cy);

  const tapeGradient = ctx.createRadialGradient(0, 0, 82, 0, 0, 330);
  tapeGradient.addColorStop(0, "#6e4429");
  tapeGradient.addColorStop(0.58, "#3a1d10");
  tapeGradient.addColorStop(1, "#241006");
  ctx.fillStyle = tapeGradient;
  ctx.beginPath();
  ctx.arc(0, 0, 330, 0, Math.PI * 2);
  ctx.fill();

  ctx.strokeStyle = "rgba(255, 236, 194, 0.9)";
  ctx.lineWidth = 16;
  [260, 292].forEach((radius) => {
    ctx.beginPath();
    ctx.arc(0, 0, radius, 0, Math.PI * 2);
    ctx.stroke();
  });

  ctx.strokeStyle = accent;
  ctx.lineWidth = 13;
  ctx.beginPath();
  ctx.arc(0, 0, 205, 0, Math.PI * 2);
  ctx.stroke();

  ctx.fillStyle = "rgba(255, 248, 224, 0.24)";
  for (let i = 0; i < 36; i += 1) {
    const angle = (Math.PI * 2 * i) / 36;
    ctx.save();
    ctx.rotate(angle);
    drawRoundRect(ctx, -5, -304, 10, 34, 5);
    ctx.restore();
  }

  const hubGradient = ctx.createRadialGradient(-42, -56, 20, 0, 0, 155);
  hubGradient.addColorStop(0, "#fff2d0");
  hubGradient.addColorStop(0.62, "#ead8b5");
  hubGradient.addColorStop(1, "#cdb98f");
  ctx.fillStyle = hubGradient;
  ctx.beginPath();
  ctx.arc(0, 0, 158, 0, Math.PI * 2);
  ctx.fill();

  ctx.strokeStyle = "rgba(80, 54, 31, 0.18)";
  ctx.lineWidth = 6;
  [82, 126].forEach((radius) => {
    ctx.beginPath();
    ctx.arc(0, 0, radius, 0, Math.PI * 2);
    ctx.stroke();
  });

  ctx.fillStyle = "#fff3d4";
  for (let i = 0; i < spokeCount; i += 1) {
    const angle = (Math.PI * 2 * i) / spokeCount;
    ctx.save();
    ctx.rotate(angle);
    drawRoundRect(ctx, -18, 48, 36, 150, 18);
    ctx.restore();
  }

  ctx.fillStyle = "rgba(55, 35, 20, 0.14)";
  for (let i = 0; i < spokeCount; i += 1) {
    const angle = (Math.PI * 2 * (i + 0.5)) / spokeCount;
    ctx.save();
    ctx.rotate(angle);
    drawRoundRect(ctx, -13, 86, 26, 82, 13);
    ctx.restore();
  }

  ctx.fillStyle = "#f9e8c6";
  ctx.beginPath();
  ctx.arc(0, 0, 74, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = accent;
  ctx.save();
  ctx.rotate(reelIndex ? -0.28 : 0.45);
  drawRoundRect(ctx, -74, -12, 148, 24, 10);
  ctx.restore();

  ctx.strokeStyle = "rgba(255, 255, 255, 0.26)";
  ctx.lineWidth = 9;
  ctx.beginPath();
  ctx.arc(-42, -52, 98, Math.PI * 1.05, Math.PI * 1.55);
  ctx.stroke();

  const texture = new THREE.CanvasTexture(canvas);
  if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace;
  texture.needsUpdate = true;
  return texture;
}

function createReelMotionTexture(THREE, sceneTraits) {
  const canvas = document.createElement("canvas");
  canvas.width = 512;
  canvas.height = 512;
  const ctx = canvas.getContext("2d");
  const accent = normalizeCssColor(sceneTraits.labelStripe, "#5C9EAD");
  const cx = canvas.width / 2;
  const cy = canvas.height / 2;

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.translate(cx, cy);

  const glow = ctx.createRadialGradient(0, 0, 42, 0, 0, 225);
  glow.addColorStop(0, "rgba(255, 246, 220, 0.72)");
  glow.addColorStop(0.34, "rgba(255, 239, 200, 0.34)");
  glow.addColorStop(0.58, "rgba(255, 239, 200, 0.16)");
  glow.addColorStop(1, "rgba(255, 239, 200, 0)");
  ctx.fillStyle = glow;
  ctx.beginPath();
  ctx.arc(0, 0, 225, 0, Math.PI * 2);
  ctx.fill();

  ctx.filter = "blur(3px)";
  for (let i = 0; i < 28; i += 1) {
    const angle = (Math.PI * 2 * i) / 28;
    ctx.save();
    ctx.rotate(angle);
    const length = i % 3 === 0 ? 160 : 118;
    ctx.fillStyle = i % 4 === 0 ? "rgba(255, 250, 226, 0.2)" : "rgba(255, 250, 226, 0.12)";
    drawRoundRect(ctx, -7, 42, 14, length, 7);
    ctx.restore();
  }
  ctx.filter = "none";

  ctx.strokeStyle = accent;
  ctx.globalAlpha = 0.5;
  ctx.lineWidth = 8;
  [112, 150, 192].forEach((radius) => {
    ctx.beginPath();
    ctx.arc(0, 0, radius, 0, Math.PI * 2);
    ctx.stroke();
  });
  ctx.globalAlpha = 1;

  const texture = new THREE.CanvasTexture(canvas);
  if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace;
  texture.needsUpdate = true;
  return texture;
}

function disposeMaterial(material) {
  if (!material) return;
  if (material.map?.dispose) material.map.dispose();
  material.dispose?.();
}

const IJ_PLAYER_VIEW_OPTIONS = [
  { id: "inspect", icon: "◩", label: "Three-quarter cassette view" },
  { id: "top", icon: "▤", label: "Top cassette view" },
  { id: "detail", icon: "⌕", label: "Detail cassette view" },
];

const IJ_PLAYER_VIEW_PRESETS = {
  inspect: {
    desktop: {
      camera: [0, 6.7, 6.85],
      lookAt: [0, 0.66, -0.2],
      rootPosition: [0, 0, 0.42],
      rootScale: 1.15,
      rootRotation: [-0.1, 0, -0.03],
    },
    compact: {
      camera: [0, 7.05, 7.35],
      lookAt: [0, 0.58, -0.2],
      rootPosition: [0, -0.12, 0.5],
      rootScale: 0.9,
      rootRotation: [-0.1, 0, -0.03],
    },
  },
  top: {
    desktop: {
      camera: [0, 8.7, 4.25],
      lookAt: [0, 0.72, -0.2],
      rootPosition: [0, 0, 0.5],
      rootScale: 1.08,
      rootRotation: [-0.02, 0, -0.01],
    },
    compact: {
      camera: [0, 8.65, 5.4],
      lookAt: [0, 0.58, -0.18],
      rootPosition: [0, -0.15, 0.6],
      rootScale: 0.84,
      rootRotation: [-0.02, 0, -0.01],
    },
  },
  detail: {
    desktop: {
      camera: [1.45, 5.05, 4.35],
      lookAt: [0.55, 0.82, 0.18],
      rootPosition: [-0.12, 0, 0.62],
      rootScale: 1.38,
      rootRotation: [-0.08, -0.08, -0.045],
    },
    compact: {
      camera: [1.25, 5.8, 5.25],
      lookAt: [0.45, 0.64, 0.2],
      rootPosition: [-0.08, -0.14, 0.58],
      rootScale: 0.98,
      rootRotation: [-0.08, -0.08, -0.045],
    },
  },
};

const IJ_REEL_PROGRESS_RADIANS = Math.PI * 6;

function clamp(value, min, max) {
  return Math.min(max, Math.max(min, value));
}

function getPlayerViewPreset(viewMode, compact) {
  const preset = IJ_PLAYER_VIEW_PRESETS[viewMode] || IJ_PLAYER_VIEW_PRESETS.inspect;
  return compact ? preset.compact : preset.desktop;
}

function getHubSpokeCount(editionId) {
  const numeric = Number(String(editionId || "").replace(/\D/g, "")) || 0;
  return 5 + (numeric % 3);
}

function GiftPlayer3D({ traits, playing, progress, duration = 54, scrubbing, compact }) {
  const hostRef = React.useRef(null);
  const sceneRef = React.useRef(null);
  const sceneApiRef = React.useRef(null);
  const dragRef = React.useRef(null);
  const threeReady = useThreeReady();
  const webGLSupported = useWebGLSupport();
  const reducedMotion = useReducedMotion();
  const [sceneFailed, setSceneFailed] = React.useState(false);
  const [viewMode, setViewMode] = React.useState("inspect");
  const [dragTilt, setDragTilt] = React.useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = React.useState(false);
  const canUse3d = threeReady && webGLSupported && !reducedMotion && !sceneFailed;
  const sceneTraits = React.useMemo(() => ({
    shellBody: traits.shellFinish.body,
    labelStripe: traits.labelStripe,
    reelTapeColor: traits.reelTapeColor,
    hubStyle: traits.hubStyle,
    labelTitle: traits.label.title,
    labelMeta: traits.label.meta,
    editionId: traits.editionId,
  }), [traits.shellFinish.body, traits.labelStripe, traits.reelTapeColor, traits.hubStyle, traits.label.title, traits.label.meta, traits.editionId]);

  const startDrag = React.useCallback((event) => {
    if (!canUse3d || event.button > 0) return;
    event.currentTarget.setPointerCapture?.(event.pointerId);
    dragRef.current = {
      pointerId: event.pointerId,
      x: event.clientX,
      y: event.clientY,
      tilt: dragTilt,
    };
    setIsDragging(true);
  }, [canUse3d, dragTilt]);

  const moveDrag = React.useCallback((event) => {
    if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return;
    const dx = (event.clientX - dragRef.current.x) / 280;
    const dy = (event.clientY - dragRef.current.y) / 320;
    setDragTilt({
      x: clamp(dragRef.current.tilt.x + dx, -0.28, 0.28),
      y: clamp(dragRef.current.tilt.y + dy, -0.18, 0.18),
    });
  }, []);

  const stopDrag = React.useCallback((event) => {
    if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
    setIsDragging(false);
  }, []);

  const selectViewMode = React.useCallback((nextViewMode) => {
    setViewMode(nextViewMode);
    setDragTilt({ x: 0, y: 0 });
  }, []);

  React.useEffect(() => {
    setSceneFailed(false);
  }, [threeReady, webGLSupported, reducedMotion, compact, sceneTraits]);

  React.useEffect(() => {
    let frame = 0;
    let scene = null;
    let renderer = null;
    let resizeObserver = null;
    let visibilityObserver = null;
    let visible = true;
    let cleanedUp = false;
    const trackDuration = Math.max(1, Number(duration) || 54);
    const reelRadiansPerSecond = IJ_REEL_PROGRESS_RADIANS / trackDuration;
    let visualReelTurn = progress * IJ_REEL_PROGRESS_RADIANS;
    let lastRenderTime = performance.now();
    let lastProgress = progress;
    let wasPlaying = playing;
    let shadowMapFrozen = false;

    const cleanup = () => {
      cleanedUp = true;
      if (frame) cancelAnimationFrame(frame);
      frame = 0;
      resizeObserver?.disconnect?.();
      visibilityObserver?.disconnect?.();
      sceneApiRef.current = null;
      sceneRef.current = null;
      scene?.traverse((object) => {
        if (!object.isMesh) return;
        object.geometry?.dispose?.();
        if (Array.isArray(object.material)) {
          object.material.forEach(disposeMaterial);
        } else {
          disposeMaterial(object.material);
        }
      });
      renderer?.dispose?.();
      hostRef.current?.replaceChildren?.();
    };

    if (!canUse3d || !hostRef.current) {
      sceneApiRef.current = null;
      return undefined;
    }

    const THREE = window.THREE;
    const host = hostRef.current;

    try {
      scene = new THREE.Scene();
      const initialView = getPlayerViewPreset(viewMode, compact);
      const camera = new THREE.PerspectiveCamera(compact ? 34 : 30, 1, 0.1, 100);
      camera.position.set(...initialView.camera);
      camera.lookAt(...initialView.lookAt);

      renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, compact ? 1.2 : 1.35));
      if (THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace;
      if (THREE.ACESFilmicToneMapping) renderer.toneMapping = THREE.ACESFilmicToneMapping;
      renderer.toneMappingExposure = 1.12;
      renderer.shadowMap.enabled = true;
      renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      host.appendChild(renderer.domElement);

      const root = new THREE.Group();
      root.position.set(...initialView.rootPosition);
      root.rotation.set(...initialView.rootRotation);
      root.scale.setScalar(initialView.rootScale);
      scene.add(root);

      const ambient = new THREE.AmbientLight(0xf7e4c5, 1.4);
      scene.add(ambient);

      const key = new THREE.DirectionalLight(0xffffff, 2.6);
      key.position.set(-3, 6, 5);
      key.castShadow = true;
      scene.add(key);

      const fill = new THREE.PointLight(0xff4d2e, 1.2, 12);
      fill.position.set(3.6, 2.2, 3);
      scene.add(fill);

      const deckMaterial = new THREE.MeshStandardMaterial({ color: 0x181820, roughness: 0.42, metalness: 0.28 });
      const deck = new THREE.Mesh(new THREE.BoxGeometry(7.65, 0.62, 4.35), deckMaterial);
      deck.position.set(0, 0, 0.15);
      deck.castShadow = true;
      deck.receiveShadow = true;
      root.add(deck);

      const bay = new THREE.Mesh(
        new THREE.BoxGeometry(6.7, 0.18, 3.12),
        new THREE.MeshStandardMaterial({ color: 0x050508, roughness: 0.72, metalness: 0.12 }),
      );
      bay.position.set(0, 0.42, -0.32);
      bay.castShadow = true;
      bay.receiveShadow = true;
      root.add(bay);

      const bayLipMaterial = new THREE.MeshStandardMaterial({ color: 0x2c2c36, roughness: 0.38, metalness: 0.24 });
      [
        { size: [6.86, 0.08, 0.16], position: [0, 0.56, -1.92] },
        { size: [6.86, 0.08, 0.16], position: [0, 0.56, 1.28] },
        { size: [0.16, 0.08, 3.18], position: [-3.43, 0.56, -0.32] },
        { size: [0.16, 0.08, 3.18], position: [3.43, 0.56, -0.32] },
      ].forEach(({ size, position }) => {
        const lip = new THREE.Mesh(new THREE.BoxGeometry(...size), bayLipMaterial);
        lip.position.set(...position);
        lip.castShadow = true;
        root.add(lip);
      });

      const cassette = new THREE.Group();
      cassette.position.set(0, 0.72, -0.32);
      root.add(cassette);

      const shell = new THREE.Mesh(
        new THREE.BoxGeometry(6.08, 0.36, 3.42),
        new THREE.MeshStandardMaterial({ color: sceneTraits.shellBody, roughness: 0.5, metalness: 0.1 }),
      );
      shell.castShadow = true;
      shell.receiveShadow = true;
      cassette.add(shell);

      const shellRailMaterial = new THREE.MeshStandardMaterial({ color: 0x33333d, roughness: 0.52, metalness: 0.08 });
      [
        { size: [5.82, 0.035, 0.08], position: [0, 0.205, -1.58] },
        { size: [5.82, 0.035, 0.08], position: [0, 0.205, 1.58] },
        { size: [0.08, 0.035, 3.08], position: [-2.92, 0.205, 0] },
        { size: [0.08, 0.035, 3.08], position: [2.92, 0.205, 0] },
      ].forEach(({ size, position }) => {
        const rail = new THREE.Mesh(new THREE.BoxGeometry(...size), shellRailMaterial);
        rail.position.set(...position);
        rail.castShadow = true;
        cassette.add(rail);
      });

      const labelTexture = createCassetteLabelTexture(THREE, sceneTraits);
      const label = new THREE.Mesh(
        new THREE.PlaneGeometry(5.15, 1.08),
        new THREE.MeshBasicMaterial({ map: labelTexture, side: THREE.DoubleSide }),
      );
      label.rotation.x = -Math.PI / 2;
      label.position.set(0, 0.228, -0.88);
      cassette.add(label);

      const stripe = new THREE.Mesh(
        new THREE.BoxGeometry(5.15, 0.035, 0.09),
        new THREE.MeshStandardMaterial({ color: sceneTraits.labelStripe, roughness: 0.58, metalness: 0.03 }),
      );
      stripe.position.set(0, 0.236, -1.31);
      cassette.add(stripe);

      const windowPanel = new THREE.Mesh(
        new THREE.BoxGeometry(4.7, 0.06, 1.1),
        new THREE.MeshStandardMaterial({ color: 0x08080c, roughness: 0.34, metalness: 0.28 }),
      );
      windowPanel.position.set(0, 0.238, 0.58);
      cassette.add(windowPanel);

      const reelMaterial = new THREE.MeshStandardMaterial({ color: sceneTraits.reelTapeColor, roughness: 0.62, metalness: 0.05 });
      const hubMaterial = new THREE.MeshStandardMaterial({ color: sceneTraits.hubStyle === "chrome" ? 0xb8b7ad : 0xf2e4cc, roughness: 0.36, metalness: sceneTraits.hubStyle === "chrome" ? 0.55 : 0.12 });
      const grooveMaterial = new THREE.MeshStandardMaterial({ color: sceneTraits.labelStripe, roughness: 0.5, metalness: 0.12 });
      const reelFaces = [];
      const reelMotionFaces = [];
      const reels = [-1.18, 1.18].map((x, reelIndex) => {
        const reel = new THREE.Group();
        reel.position.set(x, 0.295, 0.58);
        const spool = new THREE.Mesh(new THREE.CylinderGeometry(0.53, 0.53, 0.08, 72), reelMaterial);
        const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.23, 0.23, 0.105, 48), hubMaterial);
        hub.position.y = 0.016;
        reel.add(spool, hub);

        const faceTexture = createReelFaceTexture(THREE, sceneTraits, reelIndex);
        const face = new THREE.Mesh(
          new THREE.CircleGeometry(0.535, 96),
          new THREE.MeshBasicMaterial({ map: faceTexture, transparent: true, side: THREE.DoubleSide }),
        );
        face.position.y = 0.146;
        face.rotation.x = -Math.PI / 2;
        reel.add(face);
        reelFaces.push(face);

        const motionTexture = createReelMotionTexture(THREE, sceneTraits);
        const motionFace = new THREE.Mesh(
          new THREE.CircleGeometry(0.535, 96),
          new THREE.MeshBasicMaterial({
            map: motionTexture,
            transparent: true,
            opacity: 0,
            depthWrite: false,
            side: THREE.DoubleSide,
          }),
        );
        motionFace.position.y = 0.242;
        motionFace.rotation.x = -Math.PI / 2;
        reel.add(motionFace);
        reelMotionFaces.push(motionFace);

        const centerCap = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.145, 0.06, 36), hubMaterial);
        centerCap.position.y = 0.19;
        reel.add(centerCap);

        const spindleSlot = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.018, 0.035), grooveMaterial);
        spindleSlot.position.y = 0.225;
        spindleSlot.rotation.y = reelIndex ? -0.28 : 0.45;
        reel.add(spindleSlot);

        cassette.add(reel);
        return reel;
      });

      const screwMaterial = new THREE.MeshStandardMaterial({ color: 0xc8b289, roughness: 0.4, metalness: 0.28 });
      [
        [-2.68, -1.38],
        [2.68, -1.38],
        [-2.68, 1.38],
        [2.68, 1.38],
      ].forEach(([x, z]) => {
        const screw = new THREE.Mesh(new THREE.CylinderGeometry(0.075, 0.075, 0.035, 24), screwMaterial);
        screw.position.set(x, 0.235, z);
        cassette.add(screw);
      });

      const buttons = ["rewind", "play", "stop", "forward"].map((name, index) => {
        const button = new THREE.Mesh(
          new THREE.BoxGeometry(0.54, 0.25, 0.42),
          new THREE.MeshStandardMaterial({ color: name === "play" ? 0xd93616 : 0xd8c59f, roughness: 0.38, metalness: 0.18 }),
        );
        button.position.set(-2.75 + index * 0.68, 0.12, 2.36);
        button.castShadow = true;
        button.visible = !compact;
        root.add(button);
        return { name, button };
      });

      const meterFaceMaterial = new THREE.MeshStandardMaterial({ color: 0xf4dca1, roughness: 0.64, metalness: 0.03 });
      const needleMaterial = new THREE.MeshStandardMaterial({ color: 0xc81d11, roughness: 0.42, metalness: 0.08 });
      const meters = [-0.55, 0.55].map((x) => {
        const group = new THREE.Group();
        group.position.set(2.25 + x, 0.18, 2.18);
        const face = new THREE.Mesh(new THREE.BoxGeometry(0.82, 0.08, 0.42), meterFaceMaterial);
        const needle = new THREE.Mesh(new THREE.BoxGeometry(0.025, 0.035, 0.36), needleMaterial);
        needle.position.set(0, 0.07, 0.02);
        group.add(face, needle);
        group.visible = !compact;
        root.add(group);
        return { group, needle };
      });

      const shadowPlane = new THREE.Mesh(
        new THREE.PlaneGeometry(11, 8),
        new THREE.ShadowMaterial({ opacity: 0.24 }),
      );
      shadowPlane.rotation.x = -Math.PI / 2;
      shadowPlane.position.y = -0.45;
      shadowPlane.receiveShadow = true;
      scene.add(shadowPlane);

      const renderScene = () => {
        const state = sceneRef.current || { playing, progress, scrubbing, viewMode, tilt: dragTilt };
        const view = getPlayerViewPreset(state.viewMode, compact);
        const tilt = state.tilt || { x: 0, y: 0 };
        const now = performance.now();
        const elapsed = Math.min(0.05, Math.max(0, (now - lastRenderTime) / 1000));
        const progressTurn = state.progress * IJ_REEL_PROGRESS_RADIANS;
        const progressJumped = Math.abs(state.progress - lastProgress) > 0.06;
        lastRenderTime = now;

        if (!state.playing || state.scrubbing || progressJumped || !wasPlaying) {
          visualReelTurn = progressTurn;
        }
        if (state.playing && !state.scrubbing) {
          visualReelTurn += elapsed * reelRadiansPerSecond;
        }
        lastProgress = state.progress;
        wasPlaying = state.playing;

        camera.position.set(...view.camera);
        camera.lookAt(...view.lookAt);
        root.position.set(...view.rootPosition);
        root.scale.setScalar(view.rootScale);
        root.rotation.x = view.rootRotation[0] + tilt.y;
        root.rotation.y = view.rootRotation[1] + tilt.x;
        reels[0].rotation.y = visualReelTurn;
        reels[1].rotation.y = -visualReelTurn;
        reelFaces.forEach((face) => {
          face.material.opacity = state.playing && !state.scrubbing ? 0.9 : 1;
        });
        reelMotionFaces.forEach((face) => {
          face.material.opacity = state.playing && !state.scrubbing ? 0.24 : 0;
          face.rotation.z = visualReelTurn * 0.35;
        });
        const playButton = buttons.find((entry) => entry.name === "play")?.button;
        if (playButton) playButton.position.y = state.playing ? 0.02 : 0.12;
        meters[0].needle.rotation.y = state.playing ? Math.sin(performance.now() / 210) * 0.44 : -0.35;
        meters[1].needle.rotation.y = state.playing ? Math.cos(performance.now() / 250) * 0.38 : -0.3;
        root.rotation.z = view.rootRotation[2] + (state.scrubbing ? -0.015 : 0);
        renderer.render(scene, camera);
        if (!shadowMapFrozen) {
          renderer.shadowMap.autoUpdate = false;
          shadowMapFrozen = true;
        }
      };

      const stopFrame = () => {
        if (!frame) return;
        cancelAnimationFrame(frame);
        frame = 0;
      };

      const tick = () => {
        frame = 0;
        if (!visible || cleanedUp) return;
        renderScene();
        const state = sceneRef.current;
        if (state?.playing || state?.scrubbing) {
          frame = requestAnimationFrame(tick);
        }
      };

      const requestRender = () => {
        if (frame || !visible || cleanedUp) return;
        frame = requestAnimationFrame(tick);
      };

      const resize = () => {
        const rect = host.getBoundingClientRect();
        const width = Math.max(1, rect.width || host.clientWidth || 1);
        const height = Math.max(1, rect.height || host.clientHeight || 1);
        renderer.setSize(width, height, false);
        camera.aspect = width / height;
        camera.updateProjectionMatrix();
        requestRender();
      };

      sceneRef.current = { playing, progress, scrubbing, viewMode, tilt: dragTilt };
      sceneApiRef.current = { requestRender, stopFrame };

      resize();
      resizeObserver = new ResizeObserver(resize);
      resizeObserver.observe(host);

      if (window.IntersectionObserver) {
        visibilityObserver = new IntersectionObserver((entries) => {
          visible = entries.some((entry) => entry.isIntersecting);
          if (visible) requestRender();
          else stopFrame();
        }, { threshold: 0.01 });
        visibilityObserver.observe(host);
      }

      requestRender();
    } catch (error) {
      console.warn("InsideJams 3D player fell back to SVG cassette.", error);
      cleanup();
      setSceneFailed(true);
      return undefined;
    }

    return cleanup;
  }, [canUse3d, compact, duration, sceneTraits]);

  React.useEffect(() => {
    if (sceneRef.current) {
      sceneRef.current = { playing, progress, scrubbing, viewMode, tilt: dragTilt };
      sceneApiRef.current?.requestRender?.();
    }
  }, [playing, progress, scrubbing, viewMode, dragTilt]);

  if (!canUse3d) {
    return <GiftPlayerFallback traits={traits} playing={playing} progress={progress} reducedMotion={reducedMotion} width={compact ? 340 : 620}/>;
  }

  const playerClassName = [
    "ij-gift-player-3d",
    compact ? "is-compact" : "",
    isDragging ? "is-dragging" : "",
  ].filter(Boolean).join(" ");

  return (
    <div className={playerClassName} aria-label={`${traits.label.title} cassette player`}>
      <div
        ref={hostRef}
        className="ij-gift-player-3d-canvas"
        onPointerDown={startDrag}
        onPointerMove={moveDrag}
        onPointerUp={stopDrag}
        onPointerCancel={stopDrag}
        onLostPointerCapture={stopDrag}
      />
      <div className="ij-gift-player-view-controls" aria-label="Cassette view controls">
        {IJ_PLAYER_VIEW_OPTIONS.map((view) => (
          <button
            key={view.id}
            type="button"
            aria-label={view.label}
            aria-pressed={viewMode === view.id}
            title={view.label}
            className={viewMode === view.id ? "is-active" : ""}
            onClick={() => selectViewMode(view.id)}
          >
            <span aria-hidden="true">{view.icon}</span>
          </button>
        ))}
      </div>
      <div className="ij-gift-player-label">
        <strong>{traits.label.title}</strong>
        <span>{traits.label.meta} · {traits.editionId}</span>
      </div>
    </div>
  );
}

Object.assign(window, {
  GiftPlayer3D,
  GiftPlayerFallback,
});
