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

export function audioClipRecords(tracks = [], { includeHidden = true } = {}) {
  return tracks
    .filter(track => (
      track.type === 'audio'
      && (includeHidden || track.visible !== false)
    ))
    .flatMap(track => (track.segments || []).map((segment, index) => ({
      key: `${track.id}:${segment.id || index}`,
      trackId: track.id,
      index,
      segment,
    })));
}

export function audioClipLocalTime(segment, timelineTime) {
  return Math.max(0, Number(segment.sourceOffset || 0) + timelineTime - segment.start);
}

export function trimAudioClipStart(segment, candidateStart, minimumLength = 0.25) {
  const originalStart = Number(segment.start || 0);
  const end = Number(segment.end || originalStart + minimumLength);
  const originalOffset = Math.max(0, Number(segment.sourceOffset || 0));
  const earliestStart = Math.max(0, originalStart - originalOffset);
  const latestStart = Math.max(earliestStart, end - minimumLength);
  const start = Math.min(latestStart, Math.max(earliestStart, Number(candidateStart)));
  return {
    start,
    sourceOffset: Math.max(0, originalOffset + start - originalStart),
  };
}

export function isAudioClipActive(segment, timelineTime) {
  return Boolean(
    segment?.audioFile
    && timelineTime >= segment.start
    && timelineTime < segment.end
    && audioClipLocalTime(segment, timelineTime) < Number(segment.sourceDuration || Infinity)
  );
}

export function applyAudioClipParameter(segment, paramId, value) {
  if (!segment || paramId !== 'audio_volume') return false;
  const volume = Number(value);
  if (!Number.isFinite(volume)) return false;
  segment.volume = Math.max(0, Math.min(1, volume));
  return true;
}

export function createSeekableAudioSourceCache({
  fetchImpl = globalThis.fetch?.bind(globalThis),
  createObjectURL = globalThis.URL?.createObjectURL?.bind(globalThis.URL),
  revokeObjectURL = globalThis.URL?.revokeObjectURL?.bind(globalThis.URL),
} = {}) {
  const sources = new Map();

  function resolve(source) {
    const normalizedSource = normalizePath(source);
    if (!normalizedSource || !fetchImpl || !createObjectURL) return normalizedSource;
    if (sources.has(normalizedSource)) return sources.get(normalizedSource).promise;

    const record = { objectUrl: '', promise: null };
    record.promise = (async () => {
      try {
        const response = await fetchImpl(normalizedSource, {
          headers: { Range: 'bytes=0-0' },
        });
        if (!response?.ok) {
          throw new Error(`HTTP ${response?.status || 'error'}`);
        }
        if (response.status === 206) {
          response.body?.cancel?.().catch?.(() => {});
          return normalizedSource;
        }
        const blob = await response.blob();
        record.objectUrl = createObjectURL(blob);
        return record.objectUrl;
      } catch (error) {
        console.warn('[Audio clips] Blob loading failed; using direct source:', normalizedSource, error);
        return normalizedSource;
      }
    })();
    sources.set(normalizedSource, record);
    return record.promise;
  }

  function destroy() {
    for (const record of sources.values()) {
      if (record.objectUrl) revokeObjectURL?.(record.objectUrl);
    }
    sources.clear();
  }

  return { resolve, destroy };
}

export function createAudioClipPlayback({
  createAudio = () => new Audio(),
  onCreate = () => {},
  resolveSource = source => source,
  driftTolerance = 0.12,
  hardDriftTolerance = 1.5,
  correctionCooldownMs = 5000,
  now = () => globalThis.performance?.now?.() ?? Date.now(),
} = {}) {
  const entries = new Map();

  function pauseEntry(entry) {
    if (!entry.audio.paused || entry.playPending) entry.audio.pause();
  }

  function startEntry(entry) {
    if (!entry.audio.paused || entry.playPending) return;
    let pending;
    try {
      pending = entry.audio.play();
    } catch {
      return;
    }
    if (!pending?.then) {
      if (!entry.active || !entry.shouldPlay) pauseEntry(entry);
      return;
    }

    entry.playPending = pending;
    Promise.resolve(pending).then(
      () => {
        if (entry.playPending !== pending) return;
        entry.playPending = null;
        if (!entry.active || !entry.shouldPlay) pauseEntry(entry);
      },
      () => {
        if (entry.playPending === pending) entry.playPending = null;
        if (!entry.active || !entry.shouldPlay) pauseEntry(entry);
      },
    );
  }

  function seekEntry(entry, { continuous = false } = {}) {
    const audio = entry.audio;
    if (!entry.sourceReady) return false;
    const duration = Number(audio.duration);
    if (!Number.isFinite(duration) && Number(audio.readyState || 0) < 1) return false;
    const desiredTime = Math.max(0, Number(entry.desiredTime || 0));
    const safeTime = Number.isFinite(duration)
      ? Math.min(desiredTime, Math.max(0, duration - 0.001))
      : desiredTime;
    const drift = Math.abs((audio.currentTime || 0) - safeTime);
    if (drift <= driftTolerance) return true;
    if (continuous) {
      // Audio and requestAnimationFrame use different clocks. Correcting tiny
      // differences every frame makes Safari repeatedly fade/seek the media.
      if (drift < hardDriftTolerance) return true;
      if (now() - entry.lastCorrectionAt < correctionCooldownMs) return true;
    }
    try {
      audio.currentTime = safeTime;
      entry.lastCorrectionAt = now();
      return true;
    } catch {
      return false;
    }
  }

  function applyEntryState(entry, options = {}) {
    if (!entry.active) {
      pauseEntry(entry);
      return;
    }
    const seekReady = seekEntry(entry, options);
    if (!entry.shouldPlay || !seekReady) {
      pauseEntry(entry);
      return;
    }
    startEntry(entry);
  }

  function ensureEntry(record) {
    let entry = entries.get(record.key);
    if (!entry) {
      const audio = createAudio(record);
      audio.crossOrigin = 'anonymous';
      audio.preload = 'auto';
      audio.playsInline = true;
      entry = {
        audio,
        source: '',
        desiredTime: 0,
        active: false,
        shouldPlay: false,
        playPending: null,
        onReady: null,
        sourceReady: false,
        sourceRevision: 0,
        lastCorrectionAt: -Infinity,
      };
      entry.onReady = () => applyEntryState(entry);
      audio.addEventListener?.('loadedmetadata', entry.onReady);
      audio.addEventListener?.('canplay', entry.onReady);
      entries.set(record.key, entry);
      onCreate(audio, record);
    }
    const source = normalizePath(record.segment.audioFile);
    if (source && entry.source !== source) {
      entry.source = source;
      entry.sourceReady = false;
      entry.sourceRevision += 1;
      const revision = entry.sourceRevision;
      pauseEntry(entry);

      const applyResolvedSource = resolvedSource => {
        if (entry.sourceRevision !== revision || entry.source !== source) return;
        entry.audio.src = resolvedSource || source;
        entry.sourceReady = true;
        entry.audio.load?.();
        applyEntryState(entry);
      };
      try {
        const resolvedSource = resolveSource(source);
        if (resolvedSource?.then) {
          resolvedSource
            .then(applyResolvedSource)
            .catch(() => applyResolvedSource(source));
        } else {
          applyResolvedSource(resolvedSource);
        }
      } catch {
        applyResolvedSource(source);
      }
    }
    entry.audio.volume = Math.max(0, Math.min(1, Number(record.segment.volume ?? 1)));
    entry.audio.muted = Boolean(record.segment.muted);
    return entry;
  }

  function disposeEntry(entry) {
    entry.active = false;
    entry.shouldPlay = false;
    entry.sourceReady = false;
    entry.sourceRevision += 1;
    pauseEntry(entry);
    entry.audio.removeEventListener?.('loadedmetadata', entry.onReady);
    entry.audio.removeEventListener?.('canplay', entry.onReady);
    entry.audio.removeAttribute?.('src');
    entry.audio.load?.();
  }

  function sync(tracks, timelineTime, playing, { continuous = false } = {}) {
    const records = audioClipRecords(tracks, { includeHidden: false });
    const liveKeys = new Set(records.map(record => record.key));

    for (const [key, entry] of entries) {
      if (!liveKeys.has(key)) {
        disposeEntry(entry);
        entries.delete(key);
      }
    }

    for (const record of records) {
      const entry = ensureEntry(record);
      const active = isAudioClipActive(record.segment, timelineTime);
      const continuingPlayback = Boolean(
        continuous
        && active
        && entry.active
        && entry.shouldPlay
        && playing
      );
      entry.active = active;
      entry.shouldPlay = Boolean(playing);
      if (!active) {
        applyEntryState(entry);
        continue;
      }

      entry.desiredTime = audioClipLocalTime(record.segment, timelineTime);
      applyEntryState(entry, { continuous: continuingPlayback });
    }
  }

  function unlock(tracks, timelineTime) {
    sync(tracks, timelineTime, true);
    let starts = 0;
    for (const entry of entries.values()) {
      starts += 1;
      // Calling play() here is intentional: Safari must see it in the original
      // user-activation stack. Unlock every clip element, not only the clip at
      // t=0, so later timeline clips may start without another tap.
      const mutedBeforeUnlock = entry.audio.muted;
      if (!entry.active) entry.audio.muted = true;
      startEntry(entry);
      if (!entry.active && entry.playPending?.then) {
        entry.playPending.then(
          () => { entry.audio.muted = mutedBeforeUnlock; },
          () => { entry.audio.muted = mutedBeforeUnlock; },
        );
      } else if (!entry.active) {
        entry.audio.muted = mutedBeforeUnlock;
      }
    }
    return starts;
  }

  async function prepare(tracks, timelineTime, { timeoutMs = 4000 } = {}) {
    sync(tracks, timelineTime, false);
    const cleanups = [];
    const pending = [...entries.values()]
      .filter(entry => entry.active)
      .map(entry => {
        if (entry.sourceReady && Number(entry.audio.readyState || 0) >= 1) {
          return Promise.resolve();
        }
        return new Promise(resolve => {
          let settled = false;
          const cleanup = () => {
            entry.audio.removeEventListener?.('loadedmetadata', finish);
            entry.audio.removeEventListener?.('canplay', finish);
            entry.audio.removeEventListener?.('error', finish);
          };
          const finish = () => {
            if (settled) return;
            settled = true;
            cleanup();
            resolve();
          };
          cleanups.push(cleanup);
          entry.audio.addEventListener?.('loadedmetadata', finish);
          entry.audio.addEventListener?.('canplay', finish);
          entry.audio.addEventListener?.('error', finish);
          entry.audio.load?.();
        });
      });
    if (pending.length === 0) return;
    try {
      await Promise.race([
        Promise.all(pending),
        new Promise(resolve => setTimeout(resolve, Math.max(0, timeoutMs))),
      ]);
    } finally {
      for (const cleanup of cleanups) cleanup();
    }
  }

  function pauseAll() {
    for (const entry of entries.values()) {
      entry.shouldPlay = false;
      pauseEntry(entry);
    }
  }

  function destroy() {
    for (const entry of entries.values()) disposeEntry(entry);
    entries.clear();
  }

  return {
    sync,
    unlock,
    prepare,
    pauseAll,
    destroy,
    getAudioElements: () => [...entries.values()].map(entry => entry.audio),
  };
}

export function drawAudioClipWaveform(canvas, peaks, {
  sourceOffset = 0,
  clipDuration = 0,
  sourceDuration = clipDuration,
  amplitudeMaximum = null,
  color = 'rgba(215, 245, 255, 0.92)',
  fill = 'rgba(35, 150, 190, 0.28)',
  devicePixelRatio = globalThis.devicePixelRatio || 1,
} = {}) {
  if (!canvas) return;
  const width = Math.max(1, canvas.clientWidth || 1);
  const height = Math.max(1, canvas.clientHeight || 1);
  const dpr = Math.min(2, devicePixelRatio);
  canvas.width = Math.floor(width * dpr);
  canvas.height = Math.floor(height * dpr);
  const context = canvas.getContext('2d');
  context.setTransform(dpr, 0, 0, dpr, 0, 0);
  context.clearRect(0, 0, width, height);
  context.fillStyle = fill;
  context.fillRect(0, 0, width, height);
  if (!peaks?.length || !sourceDuration) return;

  const visibleStart = Math.max(0, sourceOffset);
  const visibleEnd = Math.min(sourceDuration, sourceOffset + clipDuration);
  const firstPeak = Math.min(
    peaks.length - 1,
    Math.max(0, Math.floor((visibleStart / sourceDuration) * peaks.length)),
  );
  const lastPeak = Math.min(
    peaks.length - 1,
    Math.max(firstPeak, Math.ceil((visibleEnd / sourceDuration) * peaks.length) - 1),
  );
  let visibleMaximum = Number(amplitudeMaximum);
  if (!Number.isFinite(visibleMaximum) || visibleMaximum <= 0) {
    visibleMaximum = 0;
    for (let index = firstPeak; index <= lastPeak; index += 1) {
      visibleMaximum = Math.max(visibleMaximum, Math.abs(Number(peaks[index] || 0)));
    }
  }
  const amplitudeScale = visibleMaximum > 0 ? 1 / visibleMaximum : 0;
  const middle = height / 2;
  context.strokeStyle = color;
  context.lineWidth = 1;
  context.beginPath();
  for (let x = 0; x < width; x += 1) {
    const sourceTimeStart = sourceOffset + (x / width) * clipDuration;
    const sourceTimeEnd = sourceOffset + ((x + 1) / width) * clipDuration;
    const firstPixelPeak = Math.max(0, Math.min(
      peaks.length - 1,
      Math.floor((sourceTimeStart / sourceDuration) * peaks.length),
    ));
    const lastPixelPeak = Math.max(firstPixelPeak, Math.min(
      peaks.length - 1,
      Math.ceil((sourceTimeEnd / sourceDuration) * peaks.length) - 1,
    ));
    let pixelPeak = 0;
    for (let index = firstPixelPeak; index <= lastPixelPeak; index += 1) {
      pixelPeak = Math.max(pixelPeak, Math.abs(Number(peaks[index] || 0)));
    }
    const amplitude = Math.max(
      0.025,
      pixelPeak * amplitudeScale,
    );
    const halfHeight = amplitude * height * 0.48;
    context.moveTo(x + 0.5, middle - halfHeight);
    context.lineTo(x + 0.5, middle + halfHeight);
  }
  context.stroke();
}

export function audioClipWaveformMaximum(peaks, {
  sourceOffset = 0,
  clipDuration = 0,
  sourceDuration = clipDuration,
} = {}) {
  if (!peaks?.length || !sourceDuration || clipDuration <= 0) return 0;
  const visibleStart = Math.max(0, sourceOffset);
  const visibleEnd = Math.min(sourceDuration, sourceOffset + clipDuration);
  const firstPeak = Math.min(
    peaks.length - 1,
    Math.max(0, Math.floor((visibleStart / sourceDuration) * peaks.length)),
  );
  const lastPeak = Math.min(
    peaks.length - 1,
    Math.max(firstPeak, Math.ceil((visibleEnd / sourceDuration) * peaks.length) - 1),
  );
  let maximum = 0;
  for (let index = firstPeak; index <= lastPeak; index += 1) {
    maximum = Math.max(maximum, Math.abs(Number(peaks[index] || 0)));
  }
  return maximum;
}

export function audioWaveformViewportWindow({
  segmentStart,
  segmentEnd,
  pixelsPerSecond,
  scrollLeft,
  viewportWidth,
  headerWidth = 188,
  overscanPixels = 256,
}) {
  const scale = Math.max(0, Number(pixelsPerSecond) || 0);
  const start = Number(segmentStart) || 0;
  const end = Math.max(start, Number(segmentEnd) || start);
  const segmentWidth = Math.max(0, (end - start) * scale);
  const timelineViewportWidth = Math.max(0, (Number(viewportWidth) || 0) - headerWidth);
  const viewportStart = Math.max(0, Number(scrollLeft) || 0);
  const segmentLeft = start * scale;
  const overscan = Math.max(0, Number(overscanPixels) || 0);
  const left = Math.max(0, Math.min(
    segmentWidth,
    viewportStart - segmentLeft - overscan,
  ));
  const right = Math.max(left, Math.min(
    segmentWidth,
    viewportStart + timelineViewportWidth - segmentLeft + overscan,
  ));
  const width = Math.max(0, right - left);
  return {
    visible: scale > 0 && width > 0,
    left,
    width,
    clipOffset: scale > 0 ? left / scale : 0,
    clipDuration: scale > 0 ? width / scale : 0,
    segmentWidth,
  };
}
