const TAU = Math.PI * 2;

export const FIREWORK_PATTERNS = Object.freeze([
  'chrysanthemum',
  'peony',
  'ring',
  'willow',
  'palm',
  'heart',
  'spiral',
]);

export const FIREWORK_DEFAULTS = Object.freeze({
  shells: 12,
  sparksPerShell: 120,
  trailSteps: 6,
  launchTrailPoints: 18,
  seed: 2026,
  duration: 10,
  loop: true,
  timeScale: 1,
  timeOffset: 0,
  offsetX: 0,
  offsetY: 0,
  offsetZ: 0,
  patterns: ['chrysanthemum', 'peony'],
  area: {
    width: 18,
    minHeight: 3,
    maxHeight: 10,
    depth: 4,
    groundY: -7,
  },
  timing: {
    startDelay: 1.15,
    cadence: 0.72,
    volleySize: 2,
    intraVolley: 0.12,
    jitter: 0.12,
    launchDuration: 1.05,
    lifetime: 3.1,
    lifetimeVariation: 0.22,
  },
  motion: {
    speedMin: 4.8,
    speedMax: 7.8,
    gravity: -2.25,
    drag: 0.13,
    windX: 0.08,
    windZ: 0,
    spread: 0.1,
  },
  appearance: {
    particleSize: 1.25,
    brightness: 1.35,
    opacity: 1,
    trailSpacing: 0.045,
    trailFade: 4.2,
    coreStrength: 0.75,
    twinkle: 0.2,
    endColor: '#ff7a18',
    palette: ['#fff7c4', '#ffd447', '#ff7a18', '#ff315f'],
  },
  launch: {
    enabled: true,
    color: '#fff4cf',
    size: 1.25,
    trailWindow: 0.3,
    arc: 0.45,
  },
});

function numberOr(value, fallback) {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : fallback;
}

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

function normalizePalette(palette) {
  const source = Array.isArray(palette) ? palette.filter(Boolean) : [];
  return source.length ? source.slice(0, 12).map(String) : [...FIREWORK_DEFAULTS.appearance.palette];
}

function normalizePatterns(patterns) {
  const source = Array.isArray(patterns) ? patterns : [patterns];
  const valid = source.filter(pattern => FIREWORK_PATTERNS.includes(pattern));
  return valid.length ? valid : [...FIREWORK_DEFAULTS.patterns];
}

export function normalizeFireworksConfig(config = {}) {
  const defaults = FIREWORK_DEFAULTS;
  const area = config.area || {};
  const timing = config.timing || {};
  const motion = config.motion || {};
  const appearance = config.appearance || {};
  const launch = config.launch || {};
  const minHeight = numberOr(area.minHeight, defaults.area.minHeight);
  const maxHeight = numberOr(area.maxHeight, defaults.area.maxHeight);
  const speedMin = numberOr(motion.speedMin, defaults.motion.speedMin);
  const speedMax = numberOr(motion.speedMax, defaults.motion.speedMax);

  return {
    enabled: config.enabled !== false,
    shells: clamp(Math.round(numberOr(config.shells, defaults.shells)), 1, 80),
    sparksPerShell: clamp(Math.round(numberOr(config.sparksPerShell, defaults.sparksPerShell)), 8, 600),
    trailSteps: clamp(Math.round(numberOr(config.trailSteps, defaults.trailSteps)), 1, 16),
    launchTrailPoints: clamp(
      Math.round(numberOr(config.launchTrailPoints, defaults.launchTrailPoints)),
      2,
      48,
    ),
    seed: Math.round(numberOr(config.seed, defaults.seed)) >>> 0,
    duration: clamp(numberOr(config.duration, defaults.duration), 1, 120),
    loop: config.loop !== false,
    timeScale: clamp(numberOr(config.timeScale, defaults.timeScale), 0, 5),
    timeOffset: numberOr(config.timeOffset, defaults.timeOffset),
    offsetX: numberOr(config.offsetX, defaults.offsetX),
    offsetY: numberOr(config.offsetY, defaults.offsetY),
    offsetZ: numberOr(config.offsetZ, defaults.offsetZ),
    patterns: normalizePatterns(config.patterns),
    area: {
      width: clamp(numberOr(area.width, defaults.area.width), 0.5, 100),
      minHeight: Math.min(minHeight, maxHeight),
      maxHeight: Math.max(minHeight, maxHeight),
      depth: clamp(numberOr(area.depth, defaults.area.depth), 0, 50),
      groundY: numberOr(area.groundY, defaults.area.groundY),
    },
    timing: {
      startDelay: clamp(numberOr(timing.startDelay, defaults.timing.startDelay), 0, 30),
      cadence: clamp(numberOr(timing.cadence, defaults.timing.cadence), 0.05, 10),
      volleySize: clamp(Math.round(numberOr(timing.volleySize, defaults.timing.volleySize)), 1, 16),
      intraVolley: clamp(numberOr(timing.intraVolley, defaults.timing.intraVolley), 0, 3),
      jitter: clamp(numberOr(timing.jitter, defaults.timing.jitter), 0, 2),
      launchDuration: clamp(numberOr(timing.launchDuration, defaults.timing.launchDuration), 0.05, 8),
      lifetime: clamp(numberOr(timing.lifetime, defaults.timing.lifetime), 0.2, 15),
      lifetimeVariation: clamp(
        numberOr(timing.lifetimeVariation, defaults.timing.lifetimeVariation),
        0,
        1,
      ),
    },
    motion: {
      speedMin: Math.min(speedMin, speedMax),
      speedMax: Math.max(speedMin, speedMax),
      gravity: numberOr(motion.gravity, defaults.motion.gravity),
      drag: clamp(numberOr(motion.drag, defaults.motion.drag), 0, 3),
      windX: numberOr(motion.windX, defaults.motion.windX),
      windZ: numberOr(motion.windZ, defaults.motion.windZ),
      spread: clamp(numberOr(motion.spread, defaults.motion.spread), 0, 1),
    },
    appearance: {
      particleSize: clamp(
        numberOr(appearance.particleSize, defaults.appearance.particleSize),
        0.05,
        12,
      ),
      brightness: clamp(numberOr(appearance.brightness, defaults.appearance.brightness), 0, 5),
      opacity: clamp(numberOr(appearance.opacity, defaults.appearance.opacity), 0, 1),
      trailSpacing: clamp(
        numberOr(appearance.trailSpacing, defaults.appearance.trailSpacing),
        0.005,
        0.3,
      ),
      trailFade: clamp(numberOr(appearance.trailFade, defaults.appearance.trailFade), 0, 20),
      coreStrength: clamp(
        numberOr(appearance.coreStrength, defaults.appearance.coreStrength),
        0,
        3,
      ),
      twinkle: clamp(numberOr(appearance.twinkle, defaults.appearance.twinkle), 0, 1),
      endColor: String(appearance.endColor || defaults.appearance.endColor),
      palette: normalizePalette(appearance.palette),
    },
    launch: {
      enabled: launch.enabled !== false,
      color: String(launch.color || defaults.launch.color),
      size: clamp(numberOr(launch.size, defaults.launch.size), 0.05, 12),
      trailWindow: clamp(numberOr(launch.trailWindow, defaults.launch.trailWindow), 0.02, 1),
      arc: clamp(numberOr(launch.arc, defaults.launch.arc), -5, 5),
    },
  };
}

export function createFireworksRandom(seed) {
  let state = Number(seed) >>> 0;
  return () => {
    state = (state + 0x6D2B79F5) >>> 0;
    let value = state;
    value = Math.imul(value ^ (value >>> 15), value | 1);
    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
  };
}

function rotateAroundX(vector, angle) {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  return {
    x: vector.x,
    y: vector.y * cos - vector.z * sin,
    z: vector.y * sin + vector.z * cos,
  };
}

function rotateAroundY(vector, angle) {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  return {
    x: vector.x * cos + vector.z * sin,
    y: vector.y,
    z: -vector.x * sin + vector.z * cos,
  };
}

function unitSphere(random) {
  const z = random() * 2 - 1;
  const angle = random() * TAU;
  const radius = Math.sqrt(Math.max(0, 1 - z * z));
  return { x: Math.cos(angle) * radius, y: z, z: Math.sin(angle) * radius };
}

export function fireworkPatternDirection(pattern, index, count, random, shellIndex = 0) {
  if (pattern === 'ring') {
    const angle = index / Math.max(1, count) * TAU;
    const ring = { x: Math.cos(angle), y: Math.sin(angle), z: 0 };
    return rotateAroundY(rotateAroundX(ring, 0.35 + shellIndex * 0.31), shellIndex * 0.73);
  }
  if (pattern === 'heart') {
    const angle = index / Math.max(1, count) * TAU;
    const x = 16 * Math.pow(Math.sin(angle), 3);
    const y = 13 * Math.cos(angle) - 5 * Math.cos(2 * angle)
      - 2 * Math.cos(3 * angle) - Math.cos(4 * angle);
    const length = Math.max(0.001, Math.hypot(x, y));
    return {
      x: x / length,
      y: y / length,
      z: (random() - 0.5) * 0.12,
    };
  }
  if (pattern === 'spiral') {
    const t = index / Math.max(1, count - 1);
    const turns = 3.5 + (shellIndex % 3) * 0.75;
    const angle = t * TAU * turns + shellIndex;
    const y = t * 2 - 1;
    const radial = Math.sqrt(Math.max(0.05, 1 - y * y));
    return { x: Math.cos(angle) * radial, y, z: Math.sin(angle) * radial };
  }
  if (pattern === 'palm') {
    const arms = 8 + shellIndex % 5;
    const arm = index % arms;
    const along = 0.55 + 0.45 * random();
    const angle = arm / arms * TAU + shellIndex * 0.37;
    return {
      x: Math.cos(angle) * (0.58 + random() * 0.16) * along,
      y: 0.72 + random() * 0.28,
      z: Math.sin(angle) * (0.58 + random() * 0.16) * along,
    };
  }
  const sphere = unitSphere(random);
  if (pattern === 'willow') {
    sphere.y = sphere.y * 0.72 + 0.28;
  } else if (pattern === 'peony') {
    const fullness = 0.82 + random() * 0.18;
    sphere.x *= fullness;
    sphere.y *= fullness;
    sphere.z *= fullness;
  }
  return sphere;
}

export function fireworkShellTime(shellIndex, config, jitter = 0) {
  const normalized = normalizeFireworksConfig(config);
  const timing = normalized.timing;
  const volley = Math.floor(shellIndex / timing.volleySize);
  const insideVolley = shellIndex % timing.volleySize;
  return timing.startDelay
    + volley * timing.cadence
    + insideVolley * timing.intraVolley
    + jitter * timing.jitter;
}

export function resolveFireworksTime(timelineTime, context = {}, config = {}) {
  const normalized = normalizeFireworksConfig(config);
  const sceneTime = Number.isFinite(context.sceneTime)
    ? context.sceneTime
    : Number.isFinite(context.segmentStart)
      ? numberOr(timelineTime, 0) - context.segmentStart
      : numberOr(timelineTime, 0);
  return Math.max(0, sceneTime) * normalized.timeScale + normalized.timeOffset;
}

function createFireworksGeometry(THREE, config) {
  const sparkVertices = config.shells * config.sparksPerShell * config.trailSteps;
  const launchVertices = config.launch.enabled
    ? config.shells * config.launchTrailPoints
    : 0;
  const totalVertices = sparkVertices + launchVertices;
  const positions = new Float32Array(totalVertices * 3);
  const centers = new Float32Array(totalVertices * 3);
  const velocities = new Float32Array(totalVertices * 3);
  const timing = new Float32Array(totalVertices * 4);
  const colorsA = new Float32Array(totalVertices * 3);
  const colorsB = new Float32Array(totalVertices * 3);
  const trailData = new Float32Array(totalVertices * 3);
  const random = createFireworksRandom(config.seed);
  const colorA = new THREE.Color();
  const colorB = new THREE.Color();
  const launchColor = new THREE.Color(config.launch.color);
  let vertex = 0;

  const writeVertex = ({
    center,
    velocity,
    burstTime,
    lifetime,
    seed,
    size,
    firstColor,
    secondColor,
    trailOffset,
    kind,
    launchProgress,
  }) => {
    const positionOffset = vertex * 3;
    const timingOffset = vertex * 4;
    centers[positionOffset] = center.x;
    centers[positionOffset + 1] = center.y;
    centers[positionOffset + 2] = center.z;
    velocities[positionOffset] = velocity.x;
    velocities[positionOffset + 1] = velocity.y;
    velocities[positionOffset + 2] = velocity.z;
    timing[timingOffset] = burstTime;
    timing[timingOffset + 1] = lifetime;
    timing[timingOffset + 2] = seed;
    timing[timingOffset + 3] = size;
    colorsA[positionOffset] = firstColor.r;
    colorsA[positionOffset + 1] = firstColor.g;
    colorsA[positionOffset + 2] = firstColor.b;
    colorsB[positionOffset] = secondColor.r;
    colorsB[positionOffset + 1] = secondColor.g;
    colorsB[positionOffset + 2] = secondColor.b;
    trailData[positionOffset] = trailOffset;
    trailData[positionOffset + 1] = kind;
    trailData[positionOffset + 2] = launchProgress;
    vertex += 1;
  };

  for (let shellIndex = 0; shellIndex < config.shells; shellIndex++) {
    const volleyPosition = shellIndex % config.timing.volleySize;
    const volleySpread = config.timing.volleySize > 1
      ? volleyPosition / (config.timing.volleySize - 1) - 0.5
      : 0;
    const center = {
      x: volleySpread * config.area.width * 0.7
        + (random() - 0.5) * config.area.width * 0.45,
      y: config.area.minHeight + random() * (config.area.maxHeight - config.area.minHeight),
      z: (random() - 0.5) * config.area.depth,
    };
    const launchOrigin = {
      x: center.x * 0.38 + (random() - 0.5) * 1.2,
      y: config.area.groundY,
      z: center.z * 0.25,
    };
    const burstTime = fireworkShellTime(shellIndex, config, random() * 2 - 1);
    const pattern = config.patterns[shellIndex % config.patterns.length];
    const paletteIndex = shellIndex % config.appearance.palette.length;
    colorA.set(config.appearance.palette[paletteIndex]);
    colorB.set(config.appearance.palette[(paletteIndex + 1) % config.appearance.palette.length]);
    const lifetime = config.timing.lifetime * (
      1 + (random() * 2 - 1) * config.timing.lifetimeVariation
    );

    for (let sparkIndex = 0; sparkIndex < config.sparksPerShell; sparkIndex++) {
      const direction = fireworkPatternDirection(
        pattern,
        sparkIndex,
        config.sparksPerShell,
        random,
        shellIndex,
      );
      const speed = config.motion.speedMin
        + random() * (config.motion.speedMax - config.motion.speedMin);
      const spread = config.motion.spread;
      const velocity = {
        x: (direction.x + (random() - 0.5) * spread) * speed,
        y: (direction.y + (random() - 0.5) * spread) * speed,
        z: (direction.z + (random() - 0.5) * spread) * speed,
      };
      const sparkSeed = random();
      const sparkSize = 0.72 + random() * 0.65;
      for (let trailIndex = 0; trailIndex < config.trailSteps; trailIndex++) {
        writeVertex({
          center,
          velocity,
          burstTime,
          lifetime,
          seed: sparkSeed,
          size: sparkSize,
          firstColor: colorA,
          secondColor: colorB,
          trailOffset: trailIndex * config.appearance.trailSpacing,
          kind: 0,
          launchProgress: 0,
        });
      }
    }

    if (config.launch.enabled) {
      for (let launchIndex = 0; launchIndex < config.launchTrailPoints; launchIndex++) {
        const progress = launchIndex / Math.max(1, config.launchTrailPoints - 1);
        writeVertex({
          center,
          velocity: launchOrigin,
          burstTime,
          lifetime: config.timing.launchDuration,
          seed: random(),
          size: 0.9 + random() * 0.3,
          firstColor: launchColor,
          secondColor: colorA,
          trailOffset: 0,
          kind: 1,
          launchProgress: progress,
        });
      }
    }
  }

  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('aCenter', new THREE.BufferAttribute(centers, 3));
  geometry.setAttribute('aVelocity', new THREE.BufferAttribute(velocities, 3));
  geometry.setAttribute('aTiming', new THREE.BufferAttribute(timing, 4));
  geometry.setAttribute('aColorA', new THREE.BufferAttribute(colorsA, 3));
  geometry.setAttribute('aColorB', new THREE.BufferAttribute(colorsB, 3));
  geometry.setAttribute('aTrailData', new THREE.BufferAttribute(trailData, 3));
  geometry.userData.vertexCount = totalVertices;
  return geometry;
}

function createFireworksMaterial(THREE, config) {
  return new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 },
      uDuration: { value: config.duration },
      uLoop: { value: config.loop ? 1 : 0 },
      uGravity: { value: config.motion.gravity },
      uDrag: { value: config.motion.drag },
      uWind: { value: new THREE.Vector2(config.motion.windX, config.motion.windZ) },
      uParticleSize: { value: config.appearance.particleSize },
      uBrightness: { value: config.appearance.brightness },
      uOpacity: { value: config.appearance.opacity },
      uTrailFade: { value: config.appearance.trailFade },
      uCoreStrength: { value: config.appearance.coreStrength },
      uTwinkle: { value: config.appearance.twinkle },
      uEndColor: { value: new THREE.Color(config.appearance.endColor) },
      uLaunchDuration: { value: config.timing.launchDuration },
      uLaunchSize: { value: config.launch.size },
      uLaunchTrailWindow: { value: config.launch.trailWindow },
      uLaunchArc: { value: config.launch.arc },
      uGroundY: { value: config.area.groundY },
      uViewportHeight: { value: 878 },
    },
    vertexShader: `
      attribute vec3 aCenter;
      attribute vec3 aVelocity;
      attribute vec4 aTiming;
      attribute vec3 aColorA;
      attribute vec3 aColorB;
      attribute vec3 aTrailData;

      uniform float uTime;
      uniform float uDuration;
      uniform float uLoop;
      uniform float uGravity;
      uniform float uDrag;
      uniform vec2 uWind;
      uniform float uParticleSize;
      uniform float uTrailFade;
      uniform float uTwinkle;
      uniform vec3 uEndColor;
      uniform float uLaunchDuration;
      uniform float uLaunchSize;
      uniform float uLaunchTrailWindow;
      uniform float uLaunchArc;
      uniform float uViewportHeight;

      varying vec3 vColor;
      varying float vOpacity;
      varying float vKind;
      varying float vSeed;
      varying float vTrail;
      varying float vRotation;

      void main() {
        float localTime = max(0.0, uTime);
        float cycleIndex = uLoop > 0.5 ? floor(localTime / uDuration) : 0.0;
        float cycleTime = uLoop > 0.5 ? mod(localTime, uDuration) : localTime;
        float burstTime = aTiming.x;
        float lifetime = aTiming.y;
        float seed = aTiming.z;
        float baseSize = aTiming.w;
        float kind = aTrailData.y;
        vec3 pos = aCenter;
        float opacity = 0.0;
        float size = 0.0;
        float life = 0.0;
        vec3 velocityNow = aVelocity;

        if (kind < 0.5) {
          float age = cycleTime - burstTime - aTrailData.x;
          if (uLoop > 0.5 && cycleIndex > 0.0 && age < 0.0) age += uDuration;
          if (age >= 0.0 && age <= lifetime) {
            life = age / lifetime;
            float travel = uDrag > 0.0001
              ? (1.0 - exp(-uDrag * age)) / uDrag
              : age;
            pos += aVelocity * travel;
            pos.y += 0.5 * uGravity * age * age;
            pos.x += 0.5 * uWind.x * age * age;
            pos.z += 0.5 * uWind.y * age * age;
            velocityNow = aVelocity * exp(-uDrag * age);
            velocityNow.y += uGravity * age;
            velocityNow.x += uWind.x * age;
            velocityNow.z += uWind.y * age;
            float birth = smoothstep(0.0, 0.035, age);
            float decay = pow(max(0.0, 1.0 - life), 1.25);
            float trailDecay = exp(-aTrailData.x * uTrailFade);
            float flicker = mix(
              1.0,
              0.58 + 0.42 * sin(age * 31.0 + seed * 91.0),
              uTwinkle * smoothstep(0.28, 1.0, life)
            );
            opacity = birth * decay * trailDecay * flicker;
            size = uParticleSize * baseSize * mix(1.15, 0.34, life) * trailDecay;
          }
        } else {
          float launchStart = burstTime - uLaunchDuration;
          float phase = (cycleTime - launchStart) / uLaunchDuration;
          if (uLoop > 0.5 && cycleIndex > 0.0 && phase < 0.0) {
            phase += uDuration / uLaunchDuration;
          }
          float progress = aTrailData.z;
          float distanceBehind = phase - progress;
          if (phase >= 0.0 && phase <= 1.08 && distanceBehind >= 0.0
              && distanceBehind <= uLaunchTrailWindow) {
            pos = mix(aVelocity, aCenter, progress);
            pos.y += sin(progress * 3.14159265) * uLaunchArc;
            float head = 1.0 - distanceBehind / max(0.001, uLaunchTrailWindow);
            opacity = head * smoothstep(0.0, 0.08, phase) * (1.0 - smoothstep(0.96, 1.08, phase));
            size = uLaunchSize * baseSize * mix(0.35, 1.35, head);
            life = progress;
            velocityNow = normalize(aCenter - aVelocity);
          }
        }

        vColor = mix(mix(aColorA, aColorB, smoothstep(0.0, 0.42, life)), uEndColor, life * 0.62);
        vOpacity = opacity;
        vKind = kind;
        vSeed = seed;
        vTrail = aTrailData.x;
        vRotation = atan(velocityNow.y, velocityNow.x);

        vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
        gl_Position = projectionMatrix * mvPosition;
        float viewportScale = uViewportHeight / 878.0;
        gl_PointSize = clamp(size * viewportScale * (330.0 / max(0.5, -mvPosition.z)), 0.0, 42.0);
        if (opacity <= 0.001) gl_PointSize = 0.0;
      }
    `,
    fragmentShader: `
      uniform float uBrightness;
      uniform float uOpacity;
      uniform float uCoreStrength;
      varying vec3 vColor;
      varying float vOpacity;
      varying float vKind;
      varying float vSeed;
      varying float vTrail;
      varying float vRotation;

      void main() {
        vec2 p = gl_PointCoord - 0.5;
        float c = cos(vRotation);
        float s = sin(vRotation);
        p = mat2(c, -s, s, c) * p;
        float radius = length(p);
        float core = 1.0 - smoothstep(0.025, 0.16, radius);
        float halo = 1.0 - smoothstep(0.08, 0.5, radius);
        float streak = exp(-abs(p.y) * 34.0)
          * (1.0 - smoothstep(0.08, 0.5, abs(p.x)));
        float crossSpark = exp(-abs(p.x) * 48.0)
          * (1.0 - smoothstep(0.06, 0.34, abs(p.y)));
        float sprite = vKind > 0.5
          ? max(core, streak * 0.9)
          : max(core * uCoreStrength, max(streak, crossSpark * 0.48));
        sprite = max(sprite, halo * 0.18);
        float alpha = sprite * vOpacity * uOpacity;
        if (alpha < 0.006) discard;
        vec3 color = vColor * uBrightness * (1.0 + core * 0.85);
        gl_FragColor = vec4(color, alpha);
      }
    `,
    transparent: true,
    depthWrite: false,
    depthTest: true,
    blending: THREE.AdditiveBlending,
    toneMapped: false,
  });
}

export function applyFireworksConfig(THREE, config, group) {
  const data = group?.userData;
  const mesh = data?.mesh;
  if (!mesh) return;
  const normalized = normalizeFireworksConfig(config);
  data.config = config;
  data.normalized = normalized;
  group.position.set(normalized.offsetX, normalized.offsetY, normalized.offsetZ);
  const uniforms = mesh.material.uniforms;
  uniforms.uDuration.value = normalized.duration;
  uniforms.uLoop.value = normalized.loop ? 1 : 0;
  uniforms.uGravity.value = normalized.motion.gravity;
  uniforms.uDrag.value = normalized.motion.drag;
  uniforms.uWind.value.set(normalized.motion.windX, normalized.motion.windZ);
  uniforms.uParticleSize.value = normalized.appearance.particleSize;
  uniforms.uBrightness.value = normalized.appearance.brightness;
  uniforms.uOpacity.value = normalized.appearance.opacity;
  uniforms.uTrailFade.value = normalized.appearance.trailFade;
  uniforms.uCoreStrength.value = normalized.appearance.coreStrength;
  uniforms.uTwinkle.value = normalized.appearance.twinkle;
  uniforms.uEndColor.value.set(normalized.appearance.endColor);
  uniforms.uLaunchDuration.value = normalized.timing.launchDuration;
  uniforms.uLaunchSize.value = normalized.launch.size;
  uniforms.uLaunchTrailWindow.value = normalized.launch.trailWindow;
  uniforms.uLaunchArc.value = normalized.launch.arc;
}

export function createFireworksDisplay(THREE, config, scene) {
  const normalized = normalizeFireworksConfig(config);
  const mesh = new THREE.Points(
    createFireworksGeometry(THREE, normalized),
    createFireworksMaterial(THREE, normalized),
  );
  mesh.name = 'GPU Fireworks Sparks';
  mesh.frustumCulled = false;
  mesh.renderOrder = 3;
  const group = new THREE.Group();
  group.name = 'Fireworks Display';
  group.add(mesh);
  scene.add(group);
  group.userData = {
    ...group.userData,
    config,
    normalized,
    mesh,
  };
  applyFireworksConfig(THREE, config, group);
  updateFireworksDisplay(group, 0);
  return group;
}

export function updateFireworksDisplay(group, time, { viewportHeight } = {}) {
  const data = group?.userData;
  const uniforms = data?.mesh?.material?.uniforms;
  if (!uniforms) return;
  uniforms.uTime.value = Math.max(0, numberOr(time, 0));
  if (Number.isFinite(viewportHeight)) uniforms.uViewportHeight.value = viewportHeight;
}
