const FIXED_STEP = 1 / 60;
const MAX_FRAME_DELTA = 0.12;
const EPSILON = 1e-7;
const VIDEO_TEXTURE_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'ogg', 'm4v']);

const clampInt = (value, min, max, fallback) => (
  Math.max(min, Math.min(max, Math.round(Number.isFinite(Number(value)) ? Number(value) : fallback)))
);

const particleIndex = (x, y, segmentsX) => y * (segmentsX + 1) + x;

export function isClothVideoTexturePath(path) {
  if (typeof path !== 'string' || !path.trim()) return false;
  const cleanPath = path.split(/[?#]/, 1)[0];
  const extension = cleanPath.includes('.') ? cleanPath.split('.').pop().toLowerCase() : '';
  return VIDEO_TEXTURE_EXTENSIONS.has(extension);
}

export function clothTextureParameters(materialConfig = {}) {
  const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
  return {
    repeatX: Math.max(0.01, Math.min(20, finite(materialConfig.textureRepeatX, 1))),
    repeatY: Math.max(0.01, Math.min(20, finite(materialConfig.textureRepeatY, 1))),
    offsetX: Math.max(-10, Math.min(10, finite(materialConfig.textureOffsetX, 0))),
    offsetY: Math.max(-10, Math.min(10, finite(materialConfig.textureOffsetY, 0))),
    rotation: finite(materialConfig.textureRotation, 0) * Math.PI / 180,
    playbackRate: Math.max(0.05, Math.min(4, finite(materialConfig.texturePlaybackRate, 1))),
    timeOffset: finite(materialConfig.textureTimeOffset, 0),
  };
}

export function clothPinIndices(segmentsX, segmentsY, mode = 'top') {
  const sx = clampInt(segmentsX, 2, 48, 20);
  const sy = clampInt(segmentsY, 2, 48, 16);
  const pins = new Set();
  if (mode === 'none') return pins;
  if (mode === 'top') {
    for (let x = 0; x <= sx; x++) pins.add(particleIndex(x, 0, sx));
  } else if (mode === 'left') {
    for (let y = 0; y <= sy; y++) pins.add(particleIndex(0, y, sx));
  } else if (mode === 'topCorners') {
    pins.add(particleIndex(0, 0, sx));
    pins.add(particleIndex(sx, 0, sx));
  } else if (mode === 'corners') {
    pins.add(particleIndex(0, 0, sx));
    pins.add(particleIndex(sx, 0, sx));
    pins.add(particleIndex(0, sy, sx));
    pins.add(particleIndex(sx, sy, sx));
  } else if (mode === 'topCenter') {
    const center = Math.floor(sx / 2);
    pins.add(particleIndex(Math.max(0, center - 1), 0, sx));
    pins.add(particleIndex(center, 0, sx));
    pins.add(particleIndex(Math.min(sx, center + 1), 0, sx));
  }
  return pins;
}

export function createClothTopology(width, height, segmentsX, segmentsY) {
  const sx = clampInt(segmentsX, 2, 48, 24);
  const sy = clampInt(segmentsY, 2, 48, 18);
  const w = Math.max(0.1, Number(width) || 10);
  const h = Math.max(0.1, Number(height) || 7);
  const positions = new Float32Array((sx + 1) * (sy + 1) * 3);
  const uvs = new Float32Array((sx + 1) * (sy + 1) * 2);
  const indices = [];
  const constraints = [];

  for (let y = 0; y <= sy; y++) {
    for (let x = 0; x <= sx; x++) {
      const index = particleIndex(x, y, sx);
      positions[index * 3] = (x / sx - 0.5) * w;
      positions[index * 3 + 1] = (0.5 - y / sy) * h;
      positions[index * 3 + 2] = 0;
      uvs[index * 2] = x / sx;
      uvs[index * 2 + 1] = 1 - y / sy;
    }
  }

  const addConstraint = (a, b, type) => {
    const ax = positions[a * 3], ay = positions[a * 3 + 1], az = positions[a * 3 + 2];
    const bx = positions[b * 3], by = positions[b * 3 + 1], bz = positions[b * 3 + 2];
    constraints.push({ a, b, rest: Math.hypot(bx - ax, by - ay, bz - az), type });
  };

  for (let y = 0; y <= sy; y++) {
    for (let x = 0; x <= sx; x++) {
      const i = particleIndex(x, y, sx);
      if (x < sx) addConstraint(i, particleIndex(x + 1, y, sx), 0);
      if (y < sy) addConstraint(i, particleIndex(x, y + 1, sx), 0);
      if (x < sx && y < sy) {
        addConstraint(i, particleIndex(x + 1, y + 1, sx), 1);
        addConstraint(particleIndex(x + 1, y, sx), particleIndex(x, y + 1, sx), 1);
      }
      if (x + 2 <= sx) addConstraint(i, particleIndex(x + 2, y, sx), 2);
      if (y + 2 <= sy) addConstraint(i, particleIndex(x, y + 2, sx), 2);
    }
  }

  for (let y = 0; y < sy; y++) {
    for (let x = 0; x < sx; x++) {
      const a = particleIndex(x, y, sx);
      const b = particleIndex(x + 1, y, sx);
      const c = particleIndex(x, y + 1, sx);
      const d = particleIndex(x + 1, y + 1, sx);
      indices.push(a, c, b, b, c, d);
    }
  }

  return { width: w, height: h, segmentsX: sx, segmentsY: sy, positions, uvs, indices, constraints };
}

function colorizeTopology(THREE, topology, colors) {
  const primary = new THREE.Color(colors.primary || '#ff5fbf');
  const secondary = new THREE.Color(colors.secondary || '#6de8ff');
  const accent = new THREE.Color(colors.accent || '#fff1b8');
  const result = new Float32Array(topology.positions.length);
  const color = new THREE.Color();
  const stripeFrequency = Math.max(0, colors.stripeFrequency ?? 4);
  const stripeStrength = Math.max(0, Math.min(1, colors.stripeStrength ?? 0.35));
  for (let i = 0; i < topology.uvs.length / 2; i++) {
    const u = topology.uvs[i * 2];
    const v = topology.uvs[i * 2 + 1];
    color.copy(primary).lerp(secondary, Math.max(0, Math.min(1, u * 0.72 + v * 0.28)));
    const stripe = 0.5 + 0.5 * Math.sin((u + v * 0.32) * Math.PI * 2 * stripeFrequency);
    color.lerp(accent, stripe * stripeStrength);
    result[i * 3] = color.r;
    result[i * 3 + 1] = color.g;
    result[i * 3 + 2] = color.b;
  }
  return result;
}

function createMaterial(THREE, config) {
  const materialConfig = config.material || {};
  const colors = config.colors || {};
  const material = new THREE.MeshPhysicalMaterial({
    color: '#ffffff',
    vertexColors: true,
    metalness: materialConfig.metalness ?? 0.2,
    roughness: materialConfig.roughness ?? 0.28,
    clearcoat: materialConfig.clearcoat ?? 0.75,
    clearcoatRoughness: materialConfig.clearcoatRoughness ?? 0.12,
    sheen: materialConfig.sheen ?? 0.5,
    sheenRoughness: materialConfig.sheenRoughness ?? 0.45,
    sheenColor: new THREE.Color(materialConfig.sheenColor || colors.secondary || '#6de8ff'),
    transmission: materialConfig.transmission ?? 0,
    thickness: materialConfig.thickness ?? 0.25,
    iridescence: materialConfig.iridescence ?? 0,
    iridescenceIOR: materialConfig.iridescenceIOR ?? 1.3,
    envMapIntensity: materialConfig.envMapIntensity ?? 1.8,
    emissive: new THREE.Color(materialConfig.emissive || colors.emissive || '#16051f'),
    emissiveIntensity: materialConfig.emissiveIntensity ?? 0.12,
    transparent: (materialConfig.opacity ?? 1) < 1 || (materialConfig.transmission ?? 0) > 0,
    opacity: materialConfig.opacity ?? 1,
    side: THREE.DoubleSide,
    wireframe: materialConfig.wireframe === true,
  });
  if (materialConfig.envMap) {
    const path = materialConfig.envMap.startsWith('/') ? materialConfig.envMap : `/${materialConfig.envMap}`;
    const envMap = new THREE.TextureLoader().load(path);
    envMap.mapping = THREE.EquirectangularReflectionMapping;
    envMap.colorSpace = THREE.SRGBColorSpace;
    material.envMap = envMap;
  }
  return material;
}

function clothAssetUrl(path) {
  if (!path) return '';
  return path.startsWith('/') ? path : `/${path}`;
}

function applyClothTextureParameters(THREE, texture, materialConfig) {
  if (!texture) return;
  const settings = clothTextureParameters(materialConfig);
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  texture.repeat.set(settings.repeatX, settings.repeatY);
  texture.offset.set(settings.offsetX, settings.offsetY);
  texture.center.set(0.5, 0.5);
  texture.rotation = settings.rotation;
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.needsUpdate = true;
}

function stopClothTextureVideo(data) {
  const video = data?.textureVideo;
  if (!video) return;
  video.pause?.();
  video.removeAttribute?.('src');
  video.load?.();
  video.remove?.();
  data.textureVideo = null;
}

function replaceClothMaterialMap(THREE, data, texture, video = null) {
  const material = data.mesh.material;
  const previous = material.map;
  if (previous && previous !== texture) previous.dispose?.();
  stopClothTextureVideo(data);
  material.map = texture;
  data.textureVideo = video;
  material.vertexColors = !texture || data.config.material?.textureTint !== false;
  material.needsUpdate = true;
  if (texture) applyClothTextureParameters(THREE, texture, data.config.material || {});
}

export async function setClothSimulationTexture(
  THREE,
  group,
  texturePath,
  { documentTarget = globalThis.document } = {},
) {
  const data = group?.userData;
  if (!data?.mesh) return null;
  const requestId = (data.textureRequestId || 0) + 1;
  data.textureRequestId = requestId;

  if (!texturePath) {
    replaceClothMaterialMap(THREE, data, null);
    return null;
  }

  let texture = null;
  let video = null;
  try {
    if (isClothVideoTexturePath(texturePath)) {
      if (!documentTarget?.createElement) throw new Error('Video textures require a document');
      video = documentTarget.createElement('video');
      video.src = clothAssetUrl(texturePath);
      video.crossOrigin = 'anonymous';
      video.loop = true;
      video.muted = true;
      video.playsInline = true;
      video.preload = 'auto';
      await new Promise((resolve, reject) => {
        video.onloadeddata = resolve;
        video.onerror = () => reject(new Error(`Failed to load video texture ${texturePath}`));
        video.load();
      });
      texture = new THREE.VideoTexture(video);
      texture.minFilter = THREE.LinearFilter;
      texture.magFilter = THREE.LinearFilter;
      texture.format = THREE.RGBAFormat;
    } else {
      texture = await new Promise((resolve, reject) => {
        new THREE.TextureLoader().load(clothAssetUrl(texturePath), resolve, undefined, reject);
      });
    }

    if (requestId !== data.textureRequestId || data.disposed) {
      texture.dispose?.();
      if (video) {
        video.pause?.();
        video.removeAttribute?.('src');
        video.load?.();
        video.remove?.();
      }
      return null;
    }

    replaceClothMaterialMap(THREE, data, texture, video);
    if (video) {
      const settings = clothTextureParameters(data.config.material || {});
      video.playbackRate = settings.playbackRate;
      video.play().catch(() => {});
    }
    return texture;
  } catch (error) {
    texture?.dispose?.();
    if (video) {
      video.pause?.();
      video.removeAttribute?.('src');
      video.load?.();
      video.remove?.();
    }
    console.warn(`[Cloth] Failed to load surface texture ${texturePath}:`, error);
    return null;
  }
}

function updateClothTexturePlayback(data, time) {
  const video = data.textureVideo;
  if (!video) return;
  const settings = clothTextureParameters(data.config.material || {});
  if (video.playbackRate !== settings.playbackRate) video.playbackRate = settings.playbackRate;
  const previousTime = data.textureTimelineTime;
  const delta = Number.isFinite(previousTime) ? time - previousTime : Number.NaN;
  data.textureTimelineTime = time;

  if (Number.isFinite(video.duration) && video.duration > 0) {
    const desired = ((time * settings.playbackRate + settings.timeOffset) % video.duration + video.duration) % video.duration;
    const drift = Math.abs(video.currentTime - desired);
    if (!Number.isFinite(delta) || delta < -EPSILON || delta > MAX_FRAME_DELTA || drift > 0.25) {
      video.currentTime = desired;
    }
  }

  if (Number.isFinite(delta) && Math.abs(delta) <= EPSILON) {
    if (!video.paused) video.pause();
  } else if (video.paused) {
    video.play().catch(() => {});
  }
}

function setGroupTransform(group, config) {
  const position = config.position || {};
  const rotation = config.rotation || {};
  const scale = config.scale || {};
  group.position.set(position.x ?? config.offsetX ?? 0, position.y ?? config.offsetY ?? 0, position.z ?? config.offsetZ ?? 0);
  group.rotation.set(
    (rotation.x ?? 0) * Math.PI / 180,
    (rotation.y ?? 0) * Math.PI / 180,
    (rotation.z ?? 0) * Math.PI / 180,
  );
  group.scale.set(scale.x ?? 1, scale.y ?? 1, scale.z ?? 1);
}

function resetState(data, time = 0) {
  data.positions.set(data.restPositions);
  data.previous.set(data.restPositions);
  data.accumulator = 0;
  data.lastTime = time;
  data.currentCollider.set(
    data.config.collision?.position?.x ?? 0,
    data.config.collision?.position?.y ?? 0,
    data.config.collision?.position?.z ?? 0,
  );
}

function anchorPosition(data, particle, time, target) {
  const base = particle * 3;
  target[0] = data.restPositions[base];
  target[1] = data.restPositions[base + 1];
  target[2] = data.restPositions[base + 2];
  const animation = data.config.animation || {};
  const amplitude = animation.anchorAmplitude ?? 0;
  if (!amplitude) return;
  const speed = animation.anchorSpeed ?? 0.7;
  const frequency = animation.anchorFrequency ?? 1.5;
  const phase = data.uvs[particle * 2] * Math.PI * 2 * frequency;
  const wave = Math.sin(time * speed + phase) * amplitude;
  const axis = animation.anchorAxis || 'y';
  target[axis === 'x' ? 0 : axis === 'z' ? 2 : 1] += wave;
}

function applyPins(data, time) {
  for (const particle of data.pins) {
    anchorPosition(data, particle, time, data.anchorScratch);
    const base = particle * 3;
    data.positions[base] = data.anchorScratch[0];
    data.positions[base + 1] = data.anchorScratch[1];
    data.positions[base + 2] = data.anchorScratch[2];
    data.previous[base] = data.anchorScratch[0];
    data.previous[base + 1] = data.anchorScratch[1];
    data.previous[base + 2] = data.anchorScratch[2];
  }
}

function updateCollider(data, time) {
  const collision = data.config.collision || {};
  const origin = collision.position || {};
  const motion = collision.motion || {};
  data.currentCollider.set(
    (origin.x ?? 0) + Math.sin(time * (motion.speedX ?? motion.speed ?? 0.4)) * (motion.x ?? 0),
    (origin.y ?? 0) + Math.sin(time * (motion.speedY ?? motion.speed ?? 0.5) + 1.7) * (motion.y ?? 0),
    (origin.z ?? 0) + Math.sin(time * (motion.speedZ ?? motion.speed ?? 0.6) + 3.1) * (motion.z ?? 0),
  );
  if (data.colliderMesh) data.colliderMesh.position.copy(data.currentCollider);
}

function solveCollisions(data) {
  const collision = data.config.collision || {};
  if (collision.sphereEnabled) {
    const radius = Math.max(0.01, collision.radius ?? 2) + (collision.thickness ?? 0.04);
    for (let i = 0; i < data.particleCount; i++) {
      if (data.pins.has(i)) continue;
      const base = i * 3;
      const dx = data.positions[base] - data.currentCollider.x;
      const dy = data.positions[base + 1] - data.currentCollider.y;
      const dz = data.positions[base + 2] - data.currentCollider.z;
      const distance = Math.hypot(dx, dy, dz);
      if (distance < radius) {
        if (distance < EPSILON) {
          // A particle exactly at the sphere center has no usable normal.
          // Give it a stable outward direction so it cannot remain trapped.
          data.positions[base] = data.currentCollider.x;
          data.positions[base + 1] = data.currentCollider.y;
          data.positions[base + 2] = data.currentCollider.z + radius;
        } else {
          const inv = radius / distance;
          data.positions[base] = data.currentCollider.x + dx * inv;
          data.positions[base + 1] = data.currentCollider.y + dy * inv;
          data.positions[base + 2] = data.currentCollider.z + dz * inv;
        }
      }
    }
  }
  if (collision.floorEnabled) {
    const axis = collision.floorAxis || 'y';
    const component = axis === 'x' ? 0 : axis === 'z' ? 2 : 1;
    const value = collision.floorValue ?? -4;
    const keepAbove = collision.floorSide !== 'below';
    for (let i = 0; i < data.particleCount; i++) {
      if (data.pins.has(i)) continue;
      const index = i * 3 + component;
      data.positions[index] = keepAbove
        ? Math.max(value, data.positions[index])
        : Math.min(value, data.positions[index]);
    }
  }
}

function simulateStep(data, time, audioData) {
  const config = data.config;
  const physics = config.physics || {};
  const wind = config.wind || {};
  const dt2 = FIXED_STEP * FIXED_STEP;
  const damping = Math.max(0, Math.min(0.25, physics.damping ?? 0.018));
  const gravity = physics.gravity || { x: 0, y: -9.8, z: 0 };
  const windDirection = wind.direction || { x: 0.15, y: 0.05, z: 1 };
  const length = Math.hypot(windDirection.x ?? 0, windDirection.y ?? 0, windDirection.z ?? 1) || 1;
  const windStrength = wind.enabled === false ? 0 : (wind.strength ?? 7)
    * (1 + (audioData?.bass || 0) * (wind.audioAmount ?? 0));
  const windSpeed = wind.speed ?? 1;
  const turbulence = wind.turbulence ?? 0.5;
  const frequency = wind.frequency ?? 0.8;

  for (let i = 0; i < data.particleCount; i++) {
    if (data.pins.has(i)) continue;
    const base = i * 3;
    const px = data.positions[base], py = data.positions[base + 1], pz = data.positions[base + 2];
    const vx = (px - data.previous[base]) * (1 - damping);
    const vy = (py - data.previous[base + 1]) * (1 - damping);
    const vz = (pz - data.previous[base + 2]) * (1 - damping);
    const phase = time * windSpeed + px * frequency + py * frequency * 0.73 + i * 0.017;
    const gust = 0.55 + 0.45 * Math.sin(phase) + turbulence * 0.22 * Math.sin(phase * 2.37 + 1.4);
    const flutter = turbulence * Math.sin(phase * 3.11 + data.uvs[i * 2] * 9.3);
    data.previous[base] = px;
    data.previous[base + 1] = py;
    data.previous[base + 2] = pz;
    data.positions[base] = px + vx + ((gravity.x ?? 0) + windDirection.x / length * windStrength * gust) * dt2;
    data.positions[base + 1] = py + vy + ((gravity.y ?? -9.8) + windDirection.y / length * windStrength * gust) * dt2;
    data.positions[base + 2] = pz + vz + ((gravity.z ?? 0) + windDirection.z / length * windStrength * (gust + flutter)) * dt2;
  }

  updateCollider(data, time);
  const iterations = clampInt(physics.iterations, 1, 12, 5);
  const stretch = Math.max(0, Math.min(1, physics.stretchStiffness ?? 0.92));
  const shear = Math.max(0, Math.min(1, physics.shearStiffness ?? 0.72));
  const bend = Math.max(0, Math.min(1, physics.bendStiffness ?? 0.28));
  const restScale = Math.max(0.65, Math.min(1.35, physics.restScale ?? 1));
  for (let iteration = 0; iteration < iterations; iteration++) {
    for (const constraint of data.constraints) {
      const aPinned = data.pins.has(constraint.a);
      const bPinned = data.pins.has(constraint.b);
      if (aPinned && bPinned) continue;
      const ai = constraint.a * 3;
      const bi = constraint.b * 3;
      const dx = data.positions[bi] - data.positions[ai];
      const dy = data.positions[bi + 1] - data.positions[ai + 1];
      const dz = data.positions[bi + 2] - data.positions[ai + 2];
      const distance = Math.hypot(dx, dy, dz);
      if (distance < EPSILON) continue;
      const stiffness = constraint.type === 0 ? stretch : constraint.type === 1 ? shear : bend;
      const correction = (distance - constraint.rest * restScale) / distance * stiffness;
      const aWeight = aPinned ? 0 : 1;
      const bWeight = bPinned ? 0 : 1;
      const weight = aWeight + bWeight;
      if (aWeight) {
        data.positions[ai] += dx * correction * aWeight / weight;
        data.positions[ai + 1] += dy * correction * aWeight / weight;
        data.positions[ai + 2] += dz * correction * aWeight / weight;
      }
      if (bWeight) {
        data.positions[bi] -= dx * correction * bWeight / weight;
        data.positions[bi + 1] -= dy * correction * bWeight / weight;
        data.positions[bi + 2] -= dz * correction * bWeight / weight;
      }
    }
    solveCollisions(data);
    applyPins(data, time);
  }
}

export function createClothSimulation(THREE, config, scene) {
  const topology = createClothTopology(config.width, config.height, config.segmentsX, config.segmentsY);
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(topology.positions.slice(), 3));
  geometry.setAttribute('uv', new THREE.BufferAttribute(topology.uvs, 2));
  geometry.setAttribute('color', new THREE.BufferAttribute(colorizeTopology(THREE, topology, config.colors || {}), 3));
  geometry.setIndex(topology.indices);
  geometry.getAttribute('position').setUsage(THREE.DynamicDrawUsage);
  geometry.computeVertexNormals();
  const mesh = new THREE.Mesh(geometry, createMaterial(THREE, config));
  mesh.name = 'Simulated Cloth';
  mesh.frustumCulled = false;

  const group = new THREE.Group();
  group.name = 'Cloth Simulation';
  group.add(mesh);
  setGroupTransform(group, config);
  scene.add(group);

  const collision = config.collision || {};
  let colliderMesh = null;
  if (collision.sphereEnabled && collision.visible) {
    colliderMesh = new THREE.Mesh(
      new THREE.SphereGeometry(collision.radius ?? 2, 48, 32),
      new THREE.MeshPhysicalMaterial({
        color: collision.color || '#d9f7ff',
        metalness: collision.metalness ?? 0.75,
        roughness: collision.roughness ?? 0.08,
        clearcoat: 1,
        clearcoatRoughness: 0.05,
        envMapIntensity: 2,
      }),
    );
    group.add(colliderMesh);
  }

  const positions = geometry.getAttribute('position').array;
  const initialWave = config.initialWave ?? 0;
  if (initialWave) {
    for (let i = 0; i < positions.length / 3; i++) {
      const u = topology.uvs[i * 2];
      const v = topology.uvs[i * 2 + 1];
      positions[i * 3 + 2] += Math.sin(u * Math.PI) * Math.sin(v * Math.PI) * initialWave;
    }
  }
  const restPositions = positions.slice();
  group.userData = {
    ...group.userData,
    config,
    mesh,
    colliderMesh,
    colliderBaseRadius: collision.radius ?? 2,
    topology,
    positions,
    previous: positions.slice(),
    restPositions,
    uvs: topology.uvs,
    constraints: topology.constraints,
    pins: clothPinIndices(topology.segmentsX, topology.segmentsY, config.pinMode || 'top'),
    particleCount: positions.length / 3,
    lastTime: Number.NaN,
    accumulator: 0,
    anchorScratch: [0, 0, 0],
    currentCollider: new THREE.Vector3(),
    textureRequestId: 0,
    textureTimelineTime: Number.NaN,
    textureVideo: null,
    disposed: false,
  };
  group.userData.dispose = () => {
    group.userData.disposed = true;
    group.userData.textureRequestId++;
    stopClothTextureVideo(group.userData);
  };
  resetState(group.userData, 0);
  updateCollider(group.userData, 0);
  const prewarm = clampInt(config.prewarmSteps, 0, 60, 0);
  for (let i = 0; i < prewarm; i++) simulateStep(group.userData, i * FIXED_STEP, {});
  positions.set(group.userData.positions);
  geometry.getAttribute('position').needsUpdate = true;
  geometry.computeVertexNormals();
  if (config.material?.map) {
    setClothSimulationTexture(THREE, group, config.material.map).catch(() => {});
  }
  return group;
}

export function resetClothSimulation(group, time = 0) {
  const data = group?.userData;
  if (!data?.positions) return;
  resetState(data, time);
  updateCollider(data, time);
  data.mesh.geometry.getAttribute('position').needsUpdate = true;
  data.mesh.geometry.computeVertexNormals();
}

export function updateClothSimulation(group, time, audioData = {}) {
  const data = group?.userData;
  if (!data?.mesh) return;
  updateClothTexturePlayback(data, time);
  if (!Number.isFinite(data.lastTime) || time < data.lastTime || time - data.lastTime > MAX_FRAME_DELTA) {
    resetState(data, time);
    updateCollider(data, time);
    data.mesh.geometry.getAttribute('position').needsUpdate = true;
    data.mesh.geometry.computeVertexNormals();
    return;
  }
  const delta = Math.max(0, Math.min(MAX_FRAME_DELTA, time - data.lastTime));
  data.lastTime = time;
  data.accumulator += delta;
  const substeps = clampInt(data.config.physics?.substeps, 1, 4, 1);
  const step = FIXED_STEP / substeps;
  let steps = 0;
  while (data.accumulator >= FIXED_STEP && steps < 4) {
    for (let substep = 0; substep < substeps; substep++) simulateStep(data, time, audioData);
    data.accumulator -= FIXED_STEP;
    steps++;
  }
  if (!steps) return;
  data.mesh.geometry.getAttribute('position').needsUpdate = true;
  data.mesh.geometry.computeVertexNormals();
}

export function applyClothSimulationConfig(THREE, config, group) {
  const data = group?.userData;
  if (!data?.mesh) return;
  data.config = config;
  setGroupTransform(group, config);
  const material = data.mesh.material;
  const settings = config.material || {};
  const colors = config.colors || {};
  material.metalness = settings.metalness ?? material.metalness;
  material.roughness = settings.roughness ?? material.roughness;
  material.clearcoat = settings.clearcoat ?? material.clearcoat;
  material.clearcoatRoughness = settings.clearcoatRoughness ?? material.clearcoatRoughness;
  material.sheen = settings.sheen ?? material.sheen;
  material.sheenRoughness = settings.sheenRoughness ?? material.sheenRoughness;
  material.sheenColor.set(settings.sheenColor || colors.secondary || '#6de8ff');
  material.transmission = settings.transmission ?? material.transmission;
  material.thickness = settings.thickness ?? material.thickness;
  material.iridescence = settings.iridescence ?? material.iridescence;
  material.envMapIntensity = settings.envMapIntensity ?? material.envMapIntensity;
  material.emissive.set(settings.emissive || colors.emissive || '#16051f');
  material.emissiveIntensity = settings.emissiveIntensity ?? material.emissiveIntensity;
  material.opacity = settings.opacity ?? material.opacity;
  material.transparent = material.opacity < 1 || material.transmission > 0;
  material.wireframe = settings.wireframe === true;
  material.vertexColors = !material.map || settings.textureTint !== false;
  if (material.map) applyClothTextureParameters(THREE, material.map, settings);
  if (data.textureVideo) {
    data.textureVideo.playbackRate = clothTextureParameters(settings).playbackRate;
  }
  if (data.colliderMesh) {
    const collision = config.collision || {};
    const baseRadius = Math.max(EPSILON, data.colliderBaseRadius || 1);
    data.colliderMesh.scale.setScalar(Math.max(0.01, collision.radius ?? baseRadius) / baseRadius);
    data.colliderMesh.material.color.set(collision.color || '#d9f7ff');
    data.colliderMesh.material.metalness = collision.metalness ?? data.colliderMesh.material.metalness;
    data.colliderMesh.material.roughness = collision.roughness ?? data.colliderMesh.material.roughness;
  }
  const colorAttribute = data.mesh.geometry.getAttribute('color');
  colorAttribute.array.set(colorizeTopology(THREE, data.topology, colors));
  colorAttribute.needsUpdate = true;
  material.needsUpdate = true;
}
