const TAU = Math.PI * 2;

export const STAR_CRAWL_BUNDLED_FONT_FAMILY = 'Lazy Sunday Archivo Black';
export const STAR_CRAWL_BUNDLED_FONT_URL = '/public/fonts/ArchivoBlack-Regular.ttf';

const STAR_CRAWL_BUNDLED_FONT_ALIASES = new Set([
  'Arial Black',
  'Archivo Black',
  STAR_CRAWL_BUNDLED_FONT_FAMILY,
]);

export const STAR_CRAWL_DEFAULTS = Object.freeze({
  text: `<h1>THE LAST LIGHT</h1>

Beyond the silent orbit, a signal crosses the dark.

A new generation of dreamers follows it toward a horizon no one has seen before.`,
  typography: {
    fontFamily: 'Archivo Black',
    fontWeight: 400,
    fontSize: 76,
    lineHeight: 1.22,
    letterSpacing: 1.5,
    paragraphSpacing: 0.72,
    textWidth: 0.76,
    align: 'center',
    uppercase: false,
  },
  appearance: {
    color: '#ffd85a',
    titleColor: '#ff70d8',
    glowColor: '#ffb21c',
    glowBlur: 14,
    outlineColor: '#6b2a00',
    outlineWidth: 1.5,
    opacity: 1,
    horizonFade: 0.2,
    bottomFade: 0.08,
  },
  placement: {
    width: 13,
    length: 30,
    tilt: 67,
    position: { x: 0, y: -1.2, z: -7 },
    rotationZ: 0,
  },
  animation: {
    speed: 0.12,
    direction: 1,
    startOffset: -0.5,
    loop: false,
  },
  texture: {
    width: 1024,
    height: 2048,
    renderScale: 2,
  },
  stars: {
    enabled: true,
    count: 1400,
    seed: 1977,
    spread: 90,
    depth: 90,
    color: '#dce9ff',
    accentColor: '#9bbcff',
    size: 2.1,
    opacity: 0.9,
    drift: 0.008,
    twinkle: 0.34,
  },
});

export function starCrawlCanvasFont(config = {}) {
  const normalized = normalizeStarCrawlConfig(config);
  const requestedFamily = String(normalized.typography.fontFamily || '').trim();
  if (STAR_CRAWL_BUNDLED_FONT_ALIASES.has(requestedFamily)) {
    return `${normalized.typography.fontWeight} ${normalized.typography.fontSize}px "${STAR_CRAWL_BUNDLED_FONT_FAMILY}", sans-serif`;
  }
  const escapedFamily = requestedFamily.replaceAll('"', '\\"');
  return `${normalized.typography.fontWeight} ${normalized.typography.fontSize}px "${escapedFamily}", sans-serif`;
}

export function starCrawlFontLoadDescriptor(config = {}) {
  return starCrawlCanvasFont(config).replace(/,\s*sans-serif$/, '');
}

export async function ensureStarCrawlFont(
  config = {},
  fontSet = globalThis.document?.fonts,
  {
    FontFaceCtor = globalThis.FontFace,
    fetchImpl = globalThis.fetch,
  } = {},
) {
  if (!fontSet?.load) return false;
  const normalized = normalizeStarCrawlConfig(config);
  const requestedFamily = String(normalized.typography.fontFamily || '').trim();
  const descriptor = starCrawlFontLoadDescriptor(normalized);
  const sample = 'THE LAST LIGHT';

  // Register the packaged bytes explicitly instead of relying solely on the
  // stylesheet's URL resolution. This is important in standalone exports,
  // where root-relative requests are remapped into the exported folder by the
  // service worker. Waiting for the loaded FontFace prevents the crawl texture
  // from being drawn once with a platform fallback font.
  if (
    STAR_CRAWL_BUNDLED_FONT_ALIASES.has(requestedFamily)
    && typeof FontFaceCtor === 'function'
    && typeof fetchImpl === 'function'
    && typeof fontSet.add === 'function'
  ) {
    try {
      const response = await fetchImpl(STAR_CRAWL_BUNDLED_FONT_URL, {
        cache: 'force-cache',
      });
      if (!response?.ok) {
        throw new Error(`Font request failed with HTTP ${response?.status ?? 'unknown'}`);
      }
      const fontFace = new FontFaceCtor(
        STAR_CRAWL_BUNDLED_FONT_FAMILY,
        await response.arrayBuffer(),
        {
          style: 'normal',
          weight: String(normalized.typography.fontWeight),
        },
      );
      fontSet.add(await fontFace.load());
    } catch (error) {
      console.warn('[Star Crawl] Packaged font registration failed; trying stylesheet face:', error);
    }
  }

  try {
    const loadedFaces = await fontSet.load(descriptor, sample);
    if (typeof fontSet.check === 'function' && !fontSet.check(descriptor, sample)) {
      return false;
    }
    return !Array.isArray(loadedFaces) || loadedFaces.length > 0;
  } catch (error) {
    console.warn('[Star Crawl] Font could not be preloaded:', error);
    return false;
  }
}

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 normalizePosition(position = {}) {
  const defaults = STAR_CRAWL_DEFAULTS.placement.position;
  return {
    x: numberOr(position.x, defaults.x),
    y: numberOr(position.y, defaults.y),
    z: numberOr(position.z, defaults.z),
  };
}

export function normalizeStarCrawlConfig(config = {}) {
  const typography = config.typography || {};
  const appearance = config.appearance || {};
  const placement = config.placement || {};
  const animation = config.animation || {};
  const texture = config.texture || {};
  const stars = config.stars || {};
  const defaults = STAR_CRAWL_DEFAULTS;
  const rawTextureWidth = clamp(
    Math.round(numberOr(texture.width, defaults.texture.width)),
    512,
    2048,
  );
  const rawTextureHeight = clamp(
    Math.round(numberOr(texture.height, defaults.texture.height)),
    1024,
    4096,
  );
  // Older projects used 2048×4096 as a larger logical canvas, which changed
  // text layout instead of only increasing detail. Migrate that representation
  // to the original logical size plus a 2× backing-store scale.
  const legacyHighDetail = texture.renderScale == null && rawTextureWidth > 1024;
  const logicalTextureWidth = legacyHighDetail
    ? Math.round(rawTextureWidth * 0.5)
    : rawTextureWidth;
  const logicalTextureHeight = legacyHighDetail
    ? Math.round(rawTextureHeight * 0.5)
    : rawTextureHeight;
  const requestedRenderScale = texture.renderScale == null
    ? defaults.texture.renderScale
    : numberOr(texture.renderScale, defaults.texture.renderScale);
  const maxSafeRenderScale = Math.min(
    2,
    4096 / logicalTextureWidth,
    4096 / logicalTextureHeight,
  );

  return {
    enabled: config.enabled !== false,
    text: String(config.text ?? defaults.text),
    typography: {
      ...defaults.typography,
      ...typography,
      fontWeight: clamp(Math.round(numberOr(typography.fontWeight, defaults.typography.fontWeight)), 100, 900),
      fontSize: clamp(numberOr(typography.fontSize, defaults.typography.fontSize), 18, 220),
      lineHeight: clamp(numberOr(typography.lineHeight, defaults.typography.lineHeight), 0.8, 2.5),
      letterSpacing: clamp(numberOr(typography.letterSpacing, defaults.typography.letterSpacing), -3, 20),
      paragraphSpacing: clamp(numberOr(typography.paragraphSpacing, defaults.typography.paragraphSpacing), 0, 4),
      textWidth: clamp(numberOr(typography.textWidth, defaults.typography.textWidth), 0.2, 2),
      align: ['left', 'center', 'right'].includes(typography.align)
        ? typography.align
        : defaults.typography.align,
      uppercase: typography.uppercase === true,
    },
    appearance: {
      ...defaults.appearance,
      ...appearance,
      glowBlur: clamp(numberOr(appearance.glowBlur, defaults.appearance.glowBlur), 0, 80),
      outlineWidth: clamp(numberOr(appearance.outlineWidth, defaults.appearance.outlineWidth), 0, 12),
      opacity: clamp(numberOr(appearance.opacity, defaults.appearance.opacity), 0, 1),
      horizonFade: clamp(numberOr(appearance.horizonFade, defaults.appearance.horizonFade), 0, 0.8),
      bottomFade: clamp(numberOr(appearance.bottomFade, defaults.appearance.bottomFade), 0, 0.8),
    },
    placement: {
      ...defaults.placement,
      ...placement,
      width: clamp(numberOr(placement.width, defaults.placement.width), 0.1, 100),
      length: clamp(numberOr(placement.length, defaults.placement.length), 0.1, 200),
      tilt: clamp(numberOr(placement.tilt, defaults.placement.tilt), 0, 89.5),
      position: normalizePosition(placement.position),
      rotationZ: numberOr(placement.rotationZ, defaults.placement.rotationZ),
    },
    animation: {
      ...defaults.animation,
      ...animation,
      speed: clamp(numberOr(animation.speed, defaults.animation.speed), 0, 2),
      direction: numberOr(animation.direction, defaults.animation.direction) < 0 ? -1 : 1,
      startOffset: numberOr(animation.startOffset, defaults.animation.startOffset),
      loop: animation.loop === true,
    },
    texture: {
      width: logicalTextureWidth,
      height: logicalTextureHeight,
      renderScale: clamp(requestedRenderScale, 1, maxSafeRenderScale),
    },
    stars: {
      ...defaults.stars,
      ...stars,
      enabled: stars.enabled !== false,
      count: clamp(Math.round(numberOr(stars.count, defaults.stars.count)), 0, 10000),
      seed: Math.round(numberOr(stars.seed, defaults.stars.seed)),
      spread: clamp(numberOr(stars.spread, defaults.stars.spread), 1, 300),
      depth: clamp(numberOr(stars.depth, defaults.stars.depth), 1, 300),
      size: clamp(numberOr(stars.size, defaults.stars.size), 0.1, 20),
      opacity: clamp(numberOr(stars.opacity, defaults.stars.opacity), 0, 1),
      drift: clamp(numberOr(stars.drift, defaults.stars.drift), -2, 2),
      twinkle: clamp(numberOr(stars.twinkle, defaults.stars.twinkle), 0, 1),
    },
  };
}

export function starCrawlTextureDimensions(config = {}) {
  const texture = normalizeStarCrawlConfig(config).texture;
  return {
    logicalWidth: texture.width,
    logicalHeight: texture.height,
    renderScale: texture.renderScale,
    backingWidth: Math.round(texture.width * texture.renderScale),
    backingHeight: Math.round(texture.height * texture.renderScale),
  };
}

function measureSpacedText(context, text, letterSpacing) {
  if (!text) return 0;
  return context.measureText(text).width + Math.max(0, text.length - 1) * letterSpacing;
}

function splitLongToken(context, token, maxWidth, letterSpacing) {
  const pieces = [];
  let piece = '';
  for (const character of token) {
    const candidate = piece + character;
    if (piece && measureSpacedText(context, candidate, letterSpacing) > maxWidth) {
      pieces.push(piece);
      piece = character;
    } else {
      piece = candidate;
    }
  }
  if (piece) pieces.push(piece);
  return pieces;
}

export function parseStarCrawlMarkup(text) {
  const paragraphs = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
  let inTitle = false;
  return paragraphs.map(paragraph => {
    let isTitle = inTitle;
    let plainText = '';
    let cursor = 0;
    const tagPattern = /<\/?h1\s*>/gi;
    for (const match of paragraph.matchAll(tagPattern)) {
      plainText += paragraph.slice(cursor, match.index);
      const closing = /^<\//.test(match[0]);
      if (closing) {
        isTitle ||= inTitle;
        inTitle = false;
      } else {
        inTitle = true;
        isTitle = true;
      }
      cursor = match.index + match[0].length;
    }
    plainText += paragraph.slice(cursor);
    return {
      text: plainText,
      style: isTitle ? 'title' : 'body',
    };
  });
}

export function wrapCrawlText(context, text, maxWidth, letterSpacing = 0) {
  const paragraphs = parseStarCrawlMarkup(text);
  const lines = [];

  paragraphs.forEach((paragraph, paragraphIndex) => {
    const words = paragraph.text.trim().split(/\s+/).filter(Boolean);
    const style = paragraph.style === 'title' ? { style: 'title' } : {};
    if (words.length === 0) {
      lines.push({ text: '', paragraphEnd: true, ...style });
      return;
    }

    let line = '';
    for (const rawWord of words) {
      const wordPieces = measureSpacedText(context, rawWord, letterSpacing) > maxWidth
        ? splitLongToken(context, rawWord, maxWidth, letterSpacing)
        : [rawWord];
      for (const word of wordPieces) {
        const candidate = line ? `${line} ${word}` : word;
        if (line && measureSpacedText(context, candidate, letterSpacing) > maxWidth) {
          lines.push({ text: line, paragraphEnd: false, ...style });
          line = word;
        } else {
          line = candidate;
        }
      }
    }
    lines.push({
      text: line,
      paragraphEnd: paragraphIndex < paragraphs.length - 1,
      ...style,
    });
  });

  return lines;
}

function drawSpacedText(context, text, x, y, letterSpacing, align) {
  if (!text) return;
  const totalWidth = measureSpacedText(context, text, letterSpacing);
  let cursorX = align === 'center'
    ? x - totalWidth * 0.5
    : align === 'right'
      ? x - totalWidth
      : x;
  for (const character of text) {
    context.strokeText(character, cursorX, y);
    context.fillText(character, cursorX, y);
    cursorX += context.measureText(character).width + letterSpacing;
  }
}

function drawCrawlTexture(canvas, config) {
  const context = canvas.getContext('2d');
  if (!context) throw new Error('2D canvas is unavailable for the 3D crawl');
  const { typography, appearance } = config;
  const logicalWidth = config.texture.width;
  const logicalHeight = config.texture.height;
  const renderScale = config.texture.renderScale;
  canvas.width = Math.round(logicalWidth * renderScale);
  canvas.height = Math.round(logicalHeight * renderScale);
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.scale(
    canvas.width / logicalWidth,
    canvas.height / logicalHeight,
  );
  context.textBaseline = 'alphabetic';
  context.lineJoin = 'round';
  context.font = starCrawlCanvasFont(config);

  const text = typography.uppercase ? config.text.toUpperCase() : config.text;
  const widthScale = starCrawlTextWidthScale(typography.textWidth);
  const logicalCanvasWidth = logicalWidth * widthScale;
  const maxWidth = logicalWidth * typography.textWidth;
  const lines = wrapCrawlText(context, text, maxWidth, typography.letterSpacing);
  const lineAdvance = typography.fontSize * typography.lineHeight;
  const paragraphAdvance = lineAdvance * typography.paragraphSpacing;
  const contentHeight = lines.reduce(
    (height, line) => height + lineAdvance + (line.paragraphEnd ? paragraphAdvance : 0),
    0,
  );
  const availableHeight = logicalHeight * 0.78;
  const scale = contentHeight > availableHeight ? availableHeight / contentHeight : 1;

  context.save();
  context.scale(1 / widthScale, 1);
  context.translate(logicalCanvasWidth * 0.5, logicalHeight * 0.1);
  context.scale(scale, scale);
  const alignedX = typography.align === 'left'
    ? -maxWidth * 0.5
    : typography.align === 'right'
      ? maxWidth * 0.5
      : 0;
  context.textAlign = 'left';
  context.strokeStyle = appearance.outlineWidth > 0
    ? appearance.outlineColor
    : 'rgba(0, 0, 0, 0)';
  context.lineWidth = appearance.outlineWidth * 2;
  context.shadowColor = appearance.glowColor;
  // Canvas shadow blur is expressed in backing pixels and is not scaled by
  // the current transform, so compensate to preserve the authored glow size.
  context.shadowBlur = appearance.glowBlur * renderScale;

  let y = typography.fontSize;
  for (const line of lines) {
    context.fillStyle = line.style === 'title'
      ? appearance.titleColor
      : appearance.color;
    drawSpacedText(
      context,
      line.text,
      alignedX,
      y,
      typography.letterSpacing,
      typography.align,
    );
    y += lineAdvance + (line.paragraphEnd ? paragraphAdvance : 0);
  }
  context.restore();
}

export function renderStarCrawlTextureCanvas(
  config,
  { documentTarget = globalThis.document } = {},
) {
  if (!documentTarget?.createElement) {
    throw new Error('A document is required to render the crawl texture');
  }
  const canvas = documentTarget.createElement('canvas');
  drawCrawlTexture(canvas, normalizeStarCrawlConfig(config));
  return canvas;
}

export function loadStarCrawlBakedImage(
  config,
  { ImageCtor = globalThis.Image } = {},
) {
  const source = String(config?.texture?.bakedImage || '').trim();
  if (!source) return Promise.resolve(null);
  if (typeof ImageCtor !== 'function') {
    return Promise.reject(new Error('Images are unavailable for the baked crawl texture'));
  }
  return new Promise((resolve, reject) => {
    const image = new ImageCtor();
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`Failed to load baked crawl texture: ${source}`));
    image.src = source;
  });
}

export function starCrawlScrollOffset(time, config = {}) {
  const normalized = normalizeStarCrawlConfig(config);
  return normalized.animation.startOffset
    + Math.max(0, numberOr(time, 0))
      * normalized.animation.speed
      * normalized.animation.direction;
}

export function starCrawlTextWidthScale(textWidth) {
  return Math.max(1, clamp(numberOr(textWidth, STAR_CRAWL_DEFAULTS.typography.textWidth), 0.2, 2) / 0.9);
}

export function resolveStarCrawlTime(timelineTime, context = {}) {
  if (Number.isFinite(context.sceneTime)) {
    return Math.max(0, context.sceneTime);
  }
  if (Number.isFinite(context.segmentStart)) {
    return Math.max(0, numberOr(timelineTime, 0) - context.segmentStart);
  }
  return Math.max(0, numberOr(timelineTime, 0));
}

function seededRandomFactory(initialSeed) {
  let seed = Math.max(1, Math.floor(initialSeed || 1)) % 2147483647;
  return () => {
    seed = seed * 16807 % 2147483647;
    return (seed - 1) / 2147483646;
  };
}

function createCrawlMaterial(THREE, texture, config) {
  return new THREE.ShaderMaterial({
    uniforms: {
      uMap: { value: texture },
      uScroll: { value: config.animation.startOffset },
      uOpacity: { value: config.appearance.opacity },
      uHorizonFade: { value: config.appearance.horizonFade },
      uBottomFade: { value: config.appearance.bottomFade },
      uLoop: { value: config.animation.loop ? 1 : 0 },
    },
    vertexShader: `
      varying vec2 vUv;
      void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
    fragmentShader: `
      uniform sampler2D uMap;
      uniform float uScroll;
      uniform float uOpacity;
      uniform float uHorizonFade;
      uniform float uBottomFade;
      uniform float uLoop;
      varying vec2 vUv;

      void main() {
        float sampleY = vUv.y - uScroll;
        if (uLoop > 0.5) {
          sampleY = fract(sampleY);
        } else if (sampleY < 0.0 || sampleY > 1.0) {
          discard;
        }
        vec4 texel = texture2D(uMap, vec2(vUv.x, sampleY));
        float bottomFade = uBottomFade <= 0.0001
          ? 1.0
          : smoothstep(0.0, uBottomFade, vUv.y);
        float horizonFade = uHorizonFade <= 0.0001
          ? 1.0
          : 1.0 - smoothstep(1.0 - uHorizonFade, 1.0, vUv.y);
        float alpha = texel.a * bottomFade * horizonFade * uOpacity;
        if (alpha < 0.002) discard;
        gl_FragColor = vec4(texel.rgb, alpha);
      }
    `,
    transparent: true,
    depthWrite: false,
    depthTest: true,
    side: THREE.DoubleSide,
    blending: THREE.NormalBlending,
    toneMapped: false,
  });
}

function createStarfield(THREE, config) {
  const stars = config.stars;
  const geometry = new THREE.BufferGeometry();
  const positions = new Float32Array(stars.count * 3);
  const phases = new Float32Array(stars.count);
  const sizes = new Float32Array(stars.count);
  const random = seededRandomFactory(stars.seed);
  for (let index = 0; index < stars.count; index++) {
    const offset = index * 3;
    positions[offset] = (random() - 0.5) * stars.spread;
    positions[offset + 1] = (random() - 0.36) * stars.spread * 0.58;
    positions[offset + 2] = 12 - random() * stars.depth;
    phases[index] = random() * TAU;
    sizes[index] = 0.45 + random() * 1.55;
  }
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('aPhase', new THREE.BufferAttribute(phases, 1));
  geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));

  const material = new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 },
      uSize: { value: stars.size },
      uOpacity: { value: stars.opacity },
      uTwinkle: { value: stars.twinkle },
      uColor: { value: new THREE.Color(stars.color) },
      uAccentColor: { value: new THREE.Color(stars.accentColor) },
    },
    vertexShader: `
      attribute float aPhase;
      attribute float aSize;
      uniform float uTime;
      uniform float uSize;
      varying float vPhase;
      void main() {
        vPhase = aPhase;
        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
        gl_PointSize = clamp(uSize * aSize * (160.0 / max(1.0, -mvPosition.z)), 0.8, 12.0);
        gl_Position = projectionMatrix * mvPosition;
      }
    `,
    fragmentShader: `
      uniform float uTime;
      uniform float uOpacity;
      uniform float uTwinkle;
      uniform vec3 uColor;
      uniform vec3 uAccentColor;
      varying float vPhase;
      void main() {
        vec2 point = gl_PointCoord - 0.5;
        float radius = length(point);
        float core = 1.0 - smoothstep(0.02, 0.5, radius);
        float sparkle = exp(-abs(point.x) * 26.0) + exp(-abs(point.y) * 26.0);
        float flicker = mix(1.0, 0.56 + 0.44 * sin(uTime * 2.7 + vPhase), uTwinkle);
        vec3 color = mix(uColor, uAccentColor, 0.5 + 0.5 * sin(vPhase));
        float alpha = max(core, sparkle * 0.23) * flicker * uOpacity;
        if (alpha < 0.01) discard;
        gl_FragColor = vec4(color, alpha);
      }
    `,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    toneMapped: false,
  });
  const points = new THREE.Points(geometry, material);
  points.name = 'Galactic Starfield';
  points.frustumCulled = false;
  points.visible = stars.enabled;
  return points;
}

function syncCrawlTransform(THREE, mesh, config) {
  const placement = config.placement;
  mesh.position.set(
    placement.position.x,
    placement.position.y,
    placement.position.z,
  );
  mesh.rotation.set(
    THREE.MathUtils.degToRad(-placement.tilt),
    0,
    THREE.MathUtils.degToRad(placement.rotationZ),
  );
  mesh.scale.set(
    placement.width * starCrawlTextWidthScale(config.typography.textWidth),
    placement.length,
    1,
  );
}

export function redrawStarCrawlTexture(config, group) {
  const data = group?.userData;
  if (!data?.canvas || !data?.texture) return false;
  const normalized = normalizeStarCrawlConfig(config);
  const backingWidth = Math.round(normalized.texture.width * normalized.texture.renderScale);
  const backingHeight = Math.round(normalized.texture.height * normalized.texture.renderScale);
  if (
    data.canvas.width !== backingWidth
    || data.canvas.height !== backingHeight
  ) {
    data.canvas.width = backingWidth;
    data.canvas.height = backingHeight;
  }
  drawCrawlTexture(data.canvas, normalized);
  data.texture.needsUpdate = true;
  data.normalized = normalized;
  return true;
}

export function applyStarCrawlConfig(THREE, config, group, { redrawText = false } = {}) {
  const data = group?.userData;
  if (!data?.crawlMesh) return;
  const normalized = normalizeStarCrawlConfig(config);
  data.config = config;
  data.normalized = normalized;
  syncCrawlTransform(THREE, data.crawlMesh, normalized);

  const crawlUniforms = data.crawlMesh.material.uniforms;
  crawlUniforms.uOpacity.value = normalized.appearance.opacity;
  crawlUniforms.uHorizonFade.value = normalized.appearance.horizonFade;
  crawlUniforms.uBottomFade.value = normalized.appearance.bottomFade;
  crawlUniforms.uLoop.value = normalized.animation.loop ? 1 : 0;

  if (data.starfield) {
    const stars = normalized.stars;
    data.starfield.visible = stars.enabled;
    data.starfield.material.uniforms.uSize.value = stars.size;
    data.starfield.material.uniforms.uOpacity.value = stars.opacity;
    data.starfield.material.uniforms.uTwinkle.value = stars.twinkle;
    data.starfield.material.uniforms.uColor.value.set(stars.color);
    data.starfield.material.uniforms.uAccentColor.value.set(stars.accentColor);
  }
  if (redrawText) redrawStarCrawlTexture(config, group);
}

export function createStarCrawl(
  THREE,
  config,
  scene,
  {
    documentTarget = document,
    bakedImage = null,
  } = {},
) {
  const normalized = normalizeStarCrawlConfig(config);
  const canvas = bakedImage
    ? null
    : renderStarCrawlTextureCanvas(normalized, { documentTarget });
  const texture = bakedImage
    ? new THREE.Texture(bakedImage)
    : new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.minFilter = THREE.LinearMipmapLinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.anisotropy = 4;
  texture.generateMipmaps = true;
  texture.needsUpdate = true;

  const crawlMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(1, 1, 1, 1),
    createCrawlMaterial(THREE, texture, normalized),
  );
  crawlMesh.name = 'Perspective Text Crawl';
  crawlMesh.renderOrder = 2;
  crawlMesh.frustumCulled = false;
  syncCrawlTransform(THREE, crawlMesh, normalized);

  const starfield = createStarfield(THREE, normalized);
  starfield.renderOrder = 0;
  const group = new THREE.Group();
  group.name = 'Galactic Perspective Crawl';
  group.add(starfield, crawlMesh);
  scene.add(group);
  group.userData = {
    ...group.userData,
    config,
    normalized,
    canvas,
    bakedImage,
    texture,
    crawlMesh,
    starfield,
    dispose: () => texture.dispose(),
  };
  updateStarCrawl(group, 0);
  return group;
}

export function updateStarCrawl(group, time) {
  const data = group?.userData;
  if (!data?.crawlMesh) return;
  const normalized = data.normalized || normalizeStarCrawlConfig(data.config);
  data.crawlMesh.material.uniforms.uScroll.value = normalized.animation.startOffset
    + Math.max(0, numberOr(time, 0))
      * normalized.animation.speed
      * normalized.animation.direction;
  if (data.starfield) {
    data.starfield.rotation.z = time * normalized.stars.drift;
    data.starfield.material.uniforms.uTime.value = time;
  }
}
