function clamp(value, minimum = 0, maximum = 1) {
  return Math.max(minimum, Math.min(maximum, value));
}

const channelDataCache = new WeakMap();

function sourceUrl(audioFile) {
  return audioFile.startsWith('/') ? audioFile : `/${audioFile}`;
}

function volumeAtDestinationTime(clip, destinationTime) {
  const curve = clip.volumeCurve;
  if (!curve?.length) return clamp(Number(clip.volume) || 0);
  if (curve.length === 1 || !(clip.duration > 0)) return clamp(curve[0] || 0);

  const progress = clamp(
    (destinationTime - clip.destinationStart) / clip.duration,
  );
  const position = progress * (curve.length - 1);
  const left = Math.floor(position);
  const right = Math.min(curve.length - 1, left + 1);
  const mix = position - left;
  return clamp((curve[left] || 0) * (1 - mix) + (curve[right] || 0) * mix);
}

function createFftState(size) {
  if (size < 32 || (size & (size - 1)) !== 0) {
    throw new Error('Audio analysis FFT size must be a power of two');
  }

  const bits = Math.log2(size);
  const bitReversed = new Uint32Array(size);
  const window = new Float32Array(size);
  const cos = new Float32Array(size / 2);
  const sin = new Float32Array(size / 2);
  let windowSum = 0;

  for (let index = 0; index < size; index += 1) {
    let source = index;
    let reversed = 0;
    for (let bit = 0; bit < bits; bit += 1) {
      reversed = (reversed << 1) | (source & 1);
      source >>= 1;
    }
    bitReversed[index] = reversed;
    const weight = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (size - 1));
    window[index] = weight;
    windowSum += weight;
  }

  for (let index = 0; index < size / 2; index += 1) {
    const angle = (2 * Math.PI * index) / size;
    cos[index] = Math.cos(angle);
    sin[index] = Math.sin(angle);
  }

  return {
    size,
    bitReversed,
    window,
    windowSum,
    cos,
    sin,
    real: new Float32Array(size),
    imaginary: new Float32Array(size),
  };
}

function fftInPlace(state) {
  const {
    size,
    real,
    imaginary,
    cos,
    sin,
  } = state;

  for (let length = 2; length <= size; length *= 2) {
    const half = length / 2;
    const tableStep = size / length;
    for (let start = 0; start < size; start += length) {
      for (let offset = 0; offset < half; offset += 1) {
        const tableIndex = offset * tableStep;
        const evenIndex = start + offset;
        const oddIndex = evenIndex + half;
        const oddReal = real[oddIndex];
        const oddImaginary = imaginary[oddIndex];
        const rotatedReal = oddReal * cos[tableIndex] + oddImaginary * sin[tableIndex];
        const rotatedImaginary = oddImaginary * cos[tableIndex] - oddReal * sin[tableIndex];
        const evenReal = real[evenIndex];
        const evenImaginary = imaginary[evenIndex];

        real[evenIndex] = evenReal + rotatedReal;
        imaginary[evenIndex] = evenImaginary + rotatedImaginary;
        real[oddIndex] = evenReal - rotatedReal;
        imaginary[oddIndex] = evenImaginary - rotatedImaginary;
      }
    }
  }
}

function analyzeBufferAtTime(buffer, time, state) {
  const sampleRate = Number(buffer.sampleRate) || 48000;
  const channelCount = Math.max(1, Number(buffer.numberOfChannels) || 1);
  let channels = channelDataCache.get(buffer);
  if (!channels) {
    channels = Array.from(
      { length: channelCount },
      (_, channel) => buffer.getChannelData(channel),
    );
    channelDataCache.set(buffer, channels);
  }
  const center = Math.round(Math.max(0, time) * sampleRate);
  const firstSample = center - Math.floor(state.size / 2);

  state.real.fill(0);
  state.imaginary.fill(0);
  for (let index = 0; index < state.size; index += 1) {
    const sourceIndex = firstSample + index;
    let sample = 0;
    if (sourceIndex >= 0 && sourceIndex < buffer.length) {
      for (const channel of channels) sample += channel[sourceIndex] || 0;
      sample /= channelCount;
    }
    state.real[state.bitReversed[index]] = sample * state.window[index];
  }

  fftInPlace(state);

  let bass = 0;
  let mid = 0;
  let treble = 0;
  let bassBins = 0;
  let midBins = 0;
  let trebleBins = 0;
  const binHz = sampleRate / state.size;
  const magnitudeScale = 2 / Math.max(1, state.windowSum);

  for (let bin = 1; bin < state.size / 2; bin += 1) {
    const magnitude = Math.hypot(
      state.real[bin],
      state.imaginary[bin],
    ) * magnitudeScale;
    const decibels = 20 * Math.log10(Math.max(1e-8, magnitude));
    const normalized = clamp((decibels + 100) / 70);
    const hz = bin * binHz;
    if (hz < 150) {
      bass += normalized;
      bassBins += 1;
    } else if (hz < 2000) {
      mid += normalized;
      midBins += 1;
    } else {
      treble += normalized;
      trebleBins += 1;
    }
  }

  return {
    bass: bassBins ? clamp((bass / bassBins) * 1.6) : 0,
    mid: midBins ? clamp((mid / midBins) * 1.3) : 0,
    treble: trebleBins ? clamp((treble / trebleBins) * 1.2) : 0,
  };
}

export function audioReactiveFrameAt(frames, frameIndex) {
  if (!(frames instanceof Float32Array) || frames.length < 4) {
    return { bass: 0, mid: 0, treble: 0, volume: 0 };
  }
  const frameCount = Math.floor(frames.length / 4);
  const index = clamp(Math.round(Number(frameIndex) || 0), 0, frameCount - 1) * 4;
  return {
    bass: frames[index],
    mid: frames[index + 1],
    treble: frames[index + 2],
    volume: frames[index + 3],
  };
}

export async function analyzeAudioMixForExport({
  plan,
  fps,
  totalFrames,
  fetchImpl = fetch,
  decodeAudioData,
  fftSize = 2048,
  smoothing = 0.8,
  yieldEvery = 120,
  onProgress = () => {},
} = {}) {
  const frameCount = Math.max(0, Math.floor(Number(totalFrames) || 0));
  const frames = new Float32Array(frameCount * 4);
  if (!plan?.clips?.length || frameCount === 0) return frames;
  if (typeof decodeAudioData !== 'function') {
    throw new Error('Audio decoder is unavailable for export analysis');
  }

  const decoded = new Map();
  const uniqueFiles = [...new Set(plan.clips.map(clip => clip.audioFile))];
  for (let index = 0; index < uniqueFiles.length; index += 1) {
    const audioFile = uniqueFiles[index];
    onProgress({ stage: 'decode', index, total: uniqueFiles.length, audioFile });
    const response = await fetchImpl(sourceUrl(audioFile));
    if (!response?.ok) {
      throw new Error(
        `Could not load audio for export analysis: ${audioFile} `
        + `(HTTP ${response?.status || 'error'})`,
      );
    }
    try {
      decoded.set(audioFile, await decodeAudioData(await response.arrayBuffer()));
    } catch (error) {
      throw new Error(`Could not decode audio for export analysis: ${audioFile}`, {
        cause: error,
      });
    }
  }

  const fftStates = new Map(
    [...decoded.keys()].map(audioFile => [audioFile, createFftState(fftSize)]),
  );
  const frameRate = Math.max(1, Number(fps) || 30);
  const smooth = clamp(Number(smoothing) || 0);
  let previousBass = 0;
  let previousMid = 0;
  let previousTreble = 0;

  for (let frame = 0; frame < frameCount; frame += 1) {
    const destinationTime = frame / frameRate;
    let bass = 0;
    let mid = 0;
    let treble = 0;

    for (const clip of plan.clips) {
      const clipTime = destinationTime - clip.destinationStart;
      if (clipTime < 0 || clipTime >= clip.duration) continue;
      const buffer = decoded.get(clip.audioFile);
      const state = fftStates.get(clip.audioFile);
      if (!buffer || !state) continue;
      const sourceTime = clip.sourceOffset + clipTime;
      if (sourceTime < 0 || sourceTime >= buffer.duration) continue;

      const bands = analyzeBufferAtTime(buffer, sourceTime, state);
      const gain = volumeAtDestinationTime(clip, destinationTime);
      bass += bands.bass * gain;
      mid += bands.mid * gain;
      treble += bands.treble * gain;
    }

    bass = previousBass * smooth + clamp(bass) * (1 - smooth);
    mid = previousMid * smooth + clamp(mid) * (1 - smooth);
    treble = previousTreble * smooth + clamp(treble) * (1 - smooth);
    previousBass = bass;
    previousMid = mid;
    previousTreble = treble;

    const offset = frame * 4;
    frames[offset] = bass;
    frames[offset + 1] = mid;
    frames[offset + 2] = treble;
    frames[offset + 3] = (bass + mid + treble) / 3;

    if (
      frame === frameCount - 1
      || (yieldEvery > 0 && (frame + 1) % yieldEvery === 0)
    ) {
      onProgress({ stage: 'analyze', frame: frame + 1, total: frameCount });
      if (frame < frameCount - 1) {
        await new Promise(resolve => setTimeout(resolve, 0));
      }
    }
  }

  decoded.clear();
  return frames;
}
