import autoCrossfadeClips from '@suno/studiokit/autoCrossfadeClips';
import { areMarkersEqual } from '@suno/studiokit/projectState/warpMarkersRegistry';
import {
  getSecondsBetween,
  getSecondsFromZero,
} from '@suno/studiokit/timeMapping';
import {
  getEffectiveMarkers,
  getWarpEnabledAndPopulated,
} from '@suno/studiokit/warpUtils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { lruMemoize } from 'reselect';
import { useInterval } from 'usehooks-ts';

import { useStores } from '@/app/(root)/AppProviders';
import StemSplitContext from '@/app/(root)/stems/StemSplitContext';
import { fetchClip } from '@/hooks/useClip';
import { useContext } from '@/hooks/useContextSelector';
import useDebounceCallback from '@/hooks/useDebounceCallback';
import useDismount from '@/hooks/useDismount';
import { ContextType } from '@/logging/contextTypes';
import {
  AudioClip,
  FfmpegAudioBuffer,
  RandomAccessAudioReadable,
  Timeline,
  Track,
  WarpMap,
} from '@/utils/dsp';
import { eventLogger } from '@/utils/event-logger';
import { EventNames } from '@/utils/event-names';
import { staticAssetUrl } from '@/utils/staticAssetUrl';

import { DSPModuleContext } from './DSPModuleContext';
import {
  getDerivedTiming,
  getEffectivelyMutedTracks,
  getSelectionStartBeats,
  getSongEndBeats,
  getSongStartBeats,
  getSongStartSeconds,
  getStudioClips,
  getStudioClipsByTrackId,
  getTrackIds,
  getTracksById,
} from './selectors';
import { StudioClip, StudioProjectState, TrackEQ } from './types';
import { ffmpegBuffersByUploadId } from './uploadedClipCache';
import useFfmpegBufferCache from './useFfmpegBufferCache';
import { useStudioProjectSessionId } from './useStudioProjectSessionId';
import { useStudioTimelineAnalytics } from './useStudioTimelineAnalytics';

// EQ band property names for iteration
const EQ_BAND_NAMES = [
  'band1',
  'band2',
  'band3',
  'band4',
  'band5',
  'band6',
] as const;

export type MeterValue = {
  ppm: number;
  ppmHold: number;
  vu: number;
};

export const FALLBACK_METER_VALUE: MeterValue = {
  ppm: -Infinity,
  ppmHold: -Infinity,
  vu: -Infinity,
};

// Helper to check if automation arrays have changed
const hasAutomationChanged = (
  oldAutomation: any[] | null,
  newAutomation: any[]
): boolean => {
  if (!oldAutomation) return true;
  if (oldAutomation.length !== newAutomation.length) return true;

  for (let i = 0; i < oldAutomation.length; i++) {
    const oldPoint = oldAutomation[i];
    const newPoint = newAutomation[i];
    if (oldPoint.beats !== newPoint.beats || oldPoint.bps !== newPoint.bps) {
      return true;
    }
  }

  return false;
};

// Helper to check if timing has changed
const hasTimingChanged = (
  oldTiming: { bps: number; bpsAutomation: any[] } | null,
  newTiming: { bps: number; bpsAutomation: any[] }
): boolean => {
  if (!oldTiming) return true;
  if (oldTiming.bps !== newTiming.bps) return true;
  return hasAutomationChanged(oldTiming.bpsAutomation, newTiming.bpsAutomation);
};

// Helper to check if EQ settings have changed
const hasEQChanged = (oldEQ: TrackEQ, newEQ: TrackEQ): boolean => {
  if (oldEQ.enabled !== newEQ.enabled) return true;

  // Check all 6 EQ bands
  for (const bandName of EQ_BAND_NAMES) {
    const oldBand = oldEQ[bandName];
    const newBand = newEQ[bandName];
    if (
      oldBand.type !== newBand.type ||
      oldBand.enabled !== newBand.enabled ||
      oldBand.frequency !== newBand.frequency ||
      oldBand.q !== newBand.q ||
      oldBand.gain !== newBand.gain
    ) {
      return true;
    }
  }

  return false;
};

// Helper to create a deep copy of EQ settings
const copyEQSettings = (eq: TrackEQ): TrackEQ => ({
  enabled: eq.enabled,
  band1: { ...eq.band1 },
  band2: { ...eq.band2 },
  band3: { ...eq.band3 },
  band4: { ...eq.band4 },
  band5: { ...eq.band5 },
  band6: { ...eq.band6 },
});

// Helper to check if track properties have changed
const hasTrackChanged = (
  oldTrack: {
    amplitude: number;
    balance: number;
    muted: boolean;
    eq: TrackEQ;
  } | null,
  newTrack: {
    amplitude: number;
    balance: number;
    muted: boolean;
    eq: TrackEQ;
  }
): boolean => {
  if (!oldTrack) return true;
  return (
    oldTrack.amplitude !== newTrack.amplitude ||
    oldTrack.balance !== newTrack.balance ||
    oldTrack.muted !== newTrack.muted ||
    hasEQChanged(oldTrack.eq, newTrack.eq)
  );
};

// Helper to check if clip properties have changed
const hasClipChanged = (
  oldClip: {
    startBeats: number;
    endBeats: number;
    loop: { startBeats: number; endBeats: number };
    readStartBeats: number;
    fadeInBeats: number;
    fadeOutBeats: number;
    warp: { enabled: boolean; markers: Record<string, number>; speed: number };
    transposition: number;
    streaming: boolean;
  } | null,
  newClip: {
    startBeats: number;
    endBeats: number;
    loop: { startBeats: number; endBeats: number };
    readStartBeats: number;
    fadeInBeats: number;
    fadeOutBeats: number;
    warp: { enabled: boolean; markers: Record<string, number>; speed: number };
    transposition: number;
    streaming: boolean;
  }
): boolean => {
  if (!oldClip) return true;
  return (
    oldClip.startBeats !== newClip.startBeats ||
    oldClip.endBeats !== newClip.endBeats ||
    oldClip.loop.startBeats !== newClip.loop.startBeats ||
    oldClip.loop.endBeats !== newClip.loop.endBeats ||
    oldClip.readStartBeats !== newClip.readStartBeats ||
    oldClip.fadeInBeats !== newClip.fadeInBeats ||
    oldClip.fadeOutBeats !== newClip.fadeOutBeats ||
    getWarpEnabledAndPopulated(oldClip.warp) !==
      getWarpEnabledAndPopulated(newClip.warp) ||
    oldClip.warp.speed !== newClip.warp.speed ||
    oldClip.transposition !== newClip.transposition ||
    oldClip.streaming !== newClip.streaming ||
    !areMarkersEqual(oldClip.warp.markers, newClip.warp.markers)
  );
};

const getTransformedMarkers = lruMemoize(
  (warp: StudioClip['warp']) => {
    const transformedMarkers = Object.entries(getEffectiveMarkers(warp)).map(
      ([seconds, beats]) => ({
        timeInUnderlyingBuffer: Number(seconds),
        timeInOutput: Number(beats),
      })
    );
    return transformedMarkers;
  },
  { maxSize: 100000 }
);

const metronomeMarkers = Object.entries({ 0: 0, 1: 1, 2: 2, 3: 3 }).map(
  ([seconds, beats]) => ({
    timeInUnderlyingBuffer: Number(seconds),
    timeInOutput: Number(beats),
  })
);

export default function useStudioPlaybackController(
  state: StudioProjectState,
  stopAtSongEnd: boolean,
  studioProjectId: string,
  isPreviewMode: boolean = false
) {
  const { module: dspModule, context: dspContext } =
    useContext(DSPModuleContext);

  const derivedTiming = getDerivedTiming(state);

  // Generate a unique session ID for each studio project session
  const studioProjectSessionId = useStudioProjectSessionId(studioProjectId);

  // Get stores early so session can be used in callbacks
  const { session, clips: clipsStore } = useStores();

  // Timeline analytics tracking
  const timelineAnalytics = useStudioTimelineAnalytics(
    studioProjectId,
    studioProjectSessionId
  );

  // Seek tracking state
  const seekInProgressRef = useRef<boolean>(false);

  const metronomeBufferRef = useRef<FfmpegAudioBuffer | null>(null);

  useEffect(() => {
    if (!metronomeBufferRef.current) {
      metronomeBufferRef.current = dspModule.FfmpegAudioBuffer.createFromUrl(
        staticAssetUrl('studio/metronome.opus')
      )!;
    }
    return () => {
      if (metronomeBufferRef.current) {
        metronomeBufferRef.current.delete();
        metronomeBufferRef.current = null;
      }
      // Clean up all meters on unmount
      for (const meter of Object.values(moduleReferencesRef.current.meterMap)) {
        if (meter && !meter.isDeleted()) {
          meter.delete();
        }
      }
    };
  }, [dspModule]);

  const { getFfmpegBuffer, getStreamingFfmpegBuffer } = useFfmpegBufferCache();

  const stopAtTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const stopAtBeatsRef = useRef(Infinity);
  const stopAt = useCallback((beats: number) => {
    stopAtBeatsRef.current = beats;
  }, []);
  const clearStopAt = useCallback(() => {
    stopAtBeatsRef.current = Infinity;
    if (stopAtTimeoutRef.current) {
      clearTimeout(stopAtTimeoutRef.current);
      stopAtTimeoutRef.current = null;
    }
  }, []);
  const [playing, setPlayingState] = useState(false);

  // Debounced seek end handler
  const debouncedSeekEnd = useDebounceCallback((newTimeBeats: number) => {
    if (seekInProgressRef.current && playing) {
      const newTimeSeconds = getSecondsFromZero(newTimeBeats, derivedTiming);
      (async () => timelineAnalytics.onSeekEnd(newTimeSeconds))();
    }
    seekInProgressRef.current = false;
  }, 300);

  const [lastSeekBeats, setLastSeekBeats] = useState(0);
  const lastSeekBeatsRef = useRef(lastSeekBeats);
  useEffect(() => {
    lastSeekBeatsRef.current = lastSeekBeats;
  }, [lastSeekBeats]);

  const isPreviewModeRef = useRef(isPreviewMode);
  isPreviewModeRef.current = isPreviewMode;
  const derivedTimingRef = useRef(derivedTiming);
  derivedTimingRef.current = derivedTiming;

  const updatePlayingAnalytics = useDebounceCallback(
    (playing: boolean, currentTimeBeats: number) => {
      if (isPreviewModeRef.current) {
        return;
      }
      const currentTimeSeconds = getSecondsFromZero(
        currentTimeBeats,
        derivedTimingRef.current
      );
      if (playing) {
        (async () =>
          timelineAnalytics.onTimelinePlayStart(currentTimeSeconds))();
      } else {
        (async () =>
          timelineAnalytics.onTimelinePlayStop(currentTimeSeconds))();
      }
    },
    100
  );

  const setPlaying = useCallback(
    (playing: boolean) => {
      setPlayingState(playing);

      // Update timeline playing state
      const timeline = moduleReferencesRef.current.timeline;
      if (!timeline) {
        return;
      }
      const wasPlaying = timeline.playing;
      if (playing) {
        timeline.position = lastSeekBeatsRef.current;

        // Track timeline playback start
        if (!wasPlaying) {
          updatePlayingAnalytics(playing, lastSeekBeatsRef.current);
        }
      } else {
        // Track timeline playback stop
        if (wasPlaying) {
          const currentTimeBeats =
            timeline.position || lastSeekBeatsRef.current;
          updatePlayingAnalytics(playing, currentTimeBeats);
        }

        // Clean up any pending seek state when stopping
        seekInProgressRef.current = false;
      }
      timeline.playing = playing;
    },
    [
      timelineAnalytics.onTimelinePlayStart,
      timelineAnalytics.onTimelinePlayStop,
      derivedTiming,
      isPreviewMode,
    ]
  );

  const seek = useCallback(
    (beats: number) => {
      if (!Number.isFinite(beats)) {
        console.error('got non-finite beats in seek', { beats });
        return;
      }
      clearStopAt();

      // Handle seek tracking for analytics
      if (playing && !isPreviewMode) {
        const currentTimeSeconds = getSecondsFromZero(
          getCurrentBeats(),
          derivedTiming
        );

        // If this is the first seek in a sequence, emit SeekProgressBarPauseSong
        if (!seekInProgressRef.current) {
          timelineAnalytics.onSeekStart(currentTimeSeconds);
          seekInProgressRef.current = true;
        }

        // Debounce the seek end event
        debouncedSeekEnd(beats);
      }

      setLastSeekBeats(beats);
      lastSeekBeatsRef.current = beats;
      if (!moduleReferencesRef.current.timeline) return;
      moduleReferencesRef.current.timeline.position = beats;
    },
    [
      playing,
      isPreviewMode,
      derivedTiming,
      timelineAnalytics.onSeekStart,
      debouncedSeekEnd,
      clearStopAt,
    ]
  );

  // Cache WarpMap objects by warp.markers reference to avoid recreating identical WarpMaps
  const warpMapCacheRef = useRef(
    new Map<StudioClip['warp']['markers'], Map<string, WarpMap>>()
  );

  const getCachedWarpMap = useCallback(
    (warp: StudioClip['warp']) => {
      // Create a composite key from markers reference and speed for caching
      const cacheKey = `${warp.speed}`;

      let markersCache = warpMapCacheRef.current.get(warp.markers);
      if (!markersCache) {
        markersCache = new Map();
        warpMapCacheRef.current.set(warp.markers, markersCache);
      }

      let warpMap = markersCache.get(cacheKey);
      if (!warpMap) {
        const transformedMarkers = getTransformedMarkers(warp);
        warpMap = dspModule.WarpMap.fromArray(transformedMarkers);
        markersCache.set(cacheKey, warpMap);
      }
      return warpMap;
    },
    [dspModule]
  );

  const moduleReferencesRef = useRef<{
    timeline: Timeline | null;
    trackMap: Record<string, Track>;
    // Map track IDs to their meters to avoid recreating them on every change
    meterMap: Record<string, any>;
    // Map clip IDs to their DSP engine references. This is necessary because while the order
    // of clips in the React state array matches the DSP engine's array right after a timeline
    // rebuild, it can drift when:
    // - duplicating a clip
    // - splitting a clip
    // - doing any edit that removes one clip and inserts one at a different position
    // Without this mapping, real-time setters would occasionally control the wrong clip
    // (or nothing if the index is out of range) since array indices would no longer match.
    clipMap: Record<string, { track: Track; clip: AudioClip }>;
    // Store previous state for comparison
    previousState: {
      amplitude: number;
      metronome: { enabled: boolean; amplitude: number };
      timing: { bps: number; bpsAutomation: any[] } | null;
      tracks: Record<
        string,
        {
          amplitude: number;
          balance: number;
          muted: boolean;
          eq: TrackEQ;
          clips: Record<
            string,
            {
              streaming: boolean;
              startBeats: number;
              endBeats: number;
              fadeInBeats: number;
              fadeOutBeats: number;
              loop: { startBeats: number; endBeats: number };
              readStartBeats: number;
              transposition: number;
              warp: {
                enabled: boolean;
                markers: Record<string, number>;
                speed: number;
              };
            }
          >;
        }
      >;
    };
  }>({
    timeline: null,
    trackMap: {},
    meterMap: {},
    clipMap: {},
    previousState: {
      amplitude: state.amplitude,
      metronome: { enabled: state.metronome.enabled, amplitude: 1.0 },
      timing: null,
      tracks: {},
    },
  });

  const updateTrackPan = useCallback((trackId: string, pan: number) => {
    const track = moduleReferencesRef.current.trackMap[trackId];
    if (track) {
      track.pan = pan;
    }
  }, []);

  const updateTrackEQ = useCallback(
    (
      trackId: string,
      bandKey: 'band1' | 'band2' | 'band3' | 'band4' | 'band5' | 'band6',
      frequency: number,
      q: number,
      gain: number,
      bandType: string,
      enabled: boolean
    ) => {
      const track = moduleReferencesRef.current.trackMap[trackId];
      if (track && track.filterChain) {
        // Convert band key to index for DSP engine
        const bandIndex = parseInt(bandKey.replace('band', '')) - 1;

        // Map band type to DSP engine filter mode
        const FILTER_MODE_MAP = {
          highpass: dspModule.BiQuadFilterMode.HIGHPASS,
          lowshelf: dspModule.BiQuadFilterMode.LOW_SHELF,
          peaking: dspModule.BiQuadFilterMode.EQ_BAND,
          notch: dspModule.BiQuadFilterMode.NOTCH,
          highshelf: dspModule.BiQuadFilterMode.HIGH_SHELF,
          lowpass: dspModule.BiQuadFilterMode.LOWPASS,
        } as const;

        const mode =
          FILTER_MODE_MAP[bandType as keyof typeof FILTER_MODE_MAP] ??
          dspModule.BiQuadFilterMode.EQ_BAND;

        // Determine if this band should be bypassed
        // Bypass if: band is disabled OR (for gain-based bands, gain is zero)
        const isGainBased =
          bandType === 'peaking' ||
          bandType === 'lowshelf' ||
          bandType === 'highshelf';
        const shouldBypass = !enabled || (isGainBased && gain === 0);

        track.filterChain.setFrequency(bandIndex, frequency);
        track.filterChain.setQ(bandIndex, q);
        track.filterChain.setGain(bandIndex, gain);
        track.filterChain.setMode(
          bandIndex,
          shouldBypass ? dspModule.BiQuadFilterMode.BYPASS : mode
        );
      }
    },
    [dspModule]
  );

  const updateTrackFilter = useCallback(
    (
      trackId: string,
      filterType: 'hp' | 'lp',
      frequency: number,
      q: number,
      enabled: boolean
    ) => {
      const track = moduleReferencesRef.current.trackMap[trackId];
      if (track && track.filterChain) {
        const filterIndex = filterType === 'hp' ? 4 : 5;
        // When disabled, use extreme frequencies that pass everything through
        track.filterChain.setFrequency(filterIndex, frequency);
        track.filterChain.setQ(filterIndex, q);
        track.filterChain.setGain(filterIndex, 0.0);
        track.filterChain.setMode(
          filterIndex,
          enabled
            ? filterType === 'hp'
              ? dspModule.BiQuadFilterMode.HIGHPASS
              : dspModule.BiQuadFilterMode.LOWPASS
            : dspModule.BiQuadFilterMode.BYPASS
        );
      }
    },
    []
  );

  const updateClipGain = useCallback((clipId: string, gain: number) => {
    const clipRef = moduleReferencesRef.current.clipMap[clipId];
    if (clipRef?.clip) {
      clipRef.clip.gain = gain;
    }
  }, []);

  const updateClipTransposition = useCallback(
    (clipId: string, transposition: number) => {
      const clipRef = moduleReferencesRef.current.clipMap[clipId];
      if (clipRef?.clip) {
        clipRef.clip.transposition = transposition;
      }
    },
    []
  );

  const updateClipWarpEnabled = useCallback(
    (clipId: string, enabled: boolean) => {
      const clipRef = moduleReferencesRef.current.clipMap[clipId];
      if (clipRef?.clip) {
        clipRef.clip.warpEnabled = enabled;
      }
    },
    []
  );

  const updateClipFadeIn = useCallback(
    (clipId: string, fadeInBeats: number) => {
      const clipRef = moduleReferencesRef.current.clipMap[clipId];
      if (clipRef?.clip) {
        clipRef.clip.fadeInBeats = fadeInBeats;
      }
    },
    []
  );

  const updateClipFadeOut = useCallback(
    (clipId: string, fadeOutBeats: number) => {
      const clipRef = moduleReferencesRef.current.clipMap[clipId];
      if (clipRef?.clip) {
        clipRef.clip.fadeOutBeats = fadeOutBeats;
      }
    },
    []
  );

  const updateClipLoop = useCallback(
    (clipId: string, loopStartBeats: number, loopEndBeats: number) => {
      const clipRef = moduleReferencesRef.current.clipMap[clipId];
      if (clipRef?.clip) {
        clipRef.clip.loopStartBeats = loopStartBeats;
        clipRef.clip.loopEndBeats = loopEndBeats;
      }
    },
    []
  );

  // NOTE: disabling because of issues related to clip occlusion.
  // right now if you drag the start of a clip to overlap an earlier clip, you will hear both simultaneously.
  // should fix this to have the audio preview match what the user will hear when they release the mouse button.
  // additionally, `readStartBeats` is not properly updating during playback. this might need more work.
  const ENABLE_UPDATE_CLIP_TIMELINE = false;
  const updateClipTimeline = useCallback(
    (
      clipId: string,
      startBeats: number,
      readStartBeats: number,
      endBeats: number // eslint-disable-line @typescript-eslint/no-unused-vars
    ) => {
      if (!ENABLE_UPDATE_CLIP_TIMELINE) return;
      const clipRef = moduleReferencesRef.current.clipMap[clipId];
      if (clipRef?.clip) {
        // TODO: setting these properties is no longer supported in dsp-engine
        //clipRef.clip.timelineStartBeats = startBeats;
        //clipRef.clip.timelineEndBeats = endBeats;
        clipRef.clip.readStartBeats = readStartBeats;
      }
    },
    []
  );

  const stop = useCallback(
    (keepCurrentTime: boolean = false) => {
      clearStopAt();
      setPlaying(false);
      const timeline = moduleReferencesRef.current.timeline;
      if (timeline) {
        if (keepCurrentTime) {
          if (!Number.isFinite(timeline.position)) {
            console.error('got non-finite position in stop', {
              position: timeline.position,
            });
          } else {
            setLastSeekBeats(timeline.position);
          }
        }
      }
    },
    [clearStopAt, setPlaying]
  );

  const play = useCallback(() => {
    setPlaying(true);
    if (!Number.isFinite(stopAtBeatsRef.current)) return;

    const currentTimeBeats = getCurrentBeats();
    const stopAtBeats = stopAtBeatsRef.current;
    const secondsBetween = getSecondsBetween(
      currentTimeBeats ?? 0,
      stopAtBeats,
      derivedTiming
    );
    stopAtTimeoutRef.current = setTimeout(() => {
      stop();
    }, secondsBetween * 1000);
  }, [derivedTiming, stop, setPlaying]);

  const needsTimelineRebuild = useCallback(() => {
    const prev = moduleReferencesRef.current.previousState;

    const timing = getDerivedTiming(state);
    const trackIds = getTrackIds(state);
    const tracksById = getTracksById(state);
    const studioClipsByTrackId = getStudioClipsByTrackId(state);
    const effectivelyMutedTracks = getEffectivelyMutedTracks(state);

    // Check timing changes
    if (hasTimingChanged(prev.timing, timing)) {
      return true;
    }
    if (state.metronome.enabled !== prev.metronome.enabled) {
      return true;
    }
    if (state.amplitude !== prev.amplitude) {
      return true;
    }

    // Check track changes
    const prevTrackIds = new Set(Object.keys(prev.tracks));
    const currTrackIds = new Set(trackIds);

    // Check for added/removed tracks
    if (prevTrackIds.size !== currTrackIds.size) return true;
    for (const id of prevTrackIds) if (!currTrackIds.has(id)) return true;
    for (const id of currTrackIds) if (!prevTrackIds.has(id)) return true;

    // Check track property changes
    for (const trackId of currTrackIds) {
      const prevTrack = prev.tracks[trackId];
      const currTrack = tracksById[trackId];
      if (!currTrack) continue;

      // reduced clips = clips where purely-cosmetic cuts are merged
      const currReducedClips = studioClipsByTrackId[trackId];

      if (
        hasTrackChanged(prevTrack, {
          amplitude: currTrack.amplitude,
          balance: currTrack.balance,
          muted: !!effectivelyMutedTracks[trackId],
          eq: currTrack.eq,
        })
      ) {
        return true;
      }

      // Check clip changes
      const prevClips = prevTrack.clips;
      const prevClipIds = new Set(Object.keys(prevClips));
      const currClipIds = new Set(currReducedClips.map((c) => c.id));

      // Check for added/removed clips
      if (prevClipIds.size !== currClipIds.size) return true;
      for (const id of prevClipIds) if (!currClipIds.has(id)) return true;
      for (const id of currClipIds) if (!prevClipIds.has(id)) return true;

      // Check clip property changes
      for (const clip of currReducedClips) {
        if (hasClipChanged(prevClips[clip.id], clip)) {
          return true;
        }
      }
    }

    return false;
  }, [state]);

  const syncTimeline = useCallback(async () => {
    // Only rebuild if necessary
    if (!needsTimelineRebuild()) {
      return;
    }
    const tracks: Track[] = [];
    const newClipMap: Record<string, { track: Track; clip: AudioClip }> = {};

    const trackIds = getTrackIds(state);
    const tracksById = getTracksById(state);
    const studioClips = getStudioClips(state);
    const studioClipsByTrackId = getStudioClipsByTrackId(state);
    const effectivelyMutedTracks = getEffectivelyMutedTracks(state);

    // Preload all clips (uploadId-based clips are already cached in ffmpegBuffersByUploadId)
    const preloadedClips = new Map<string, FfmpegAudioBuffer>(
      await Promise.all(
        studioClips
          .filter((clip) => clip.clipId !== null)
          .map(
            async (clip) =>
              [
                clip.clipId,
                await (clip.streaming
                  ? getStreamingFfmpegBuffer(clip.clipId)
                  : getFfmpegBuffer(clip.clipId)),
              ] satisfies [string, FfmpegAudioBuffer]
          )
      )
    );

    // Add all tracks
    for (const trackId of trackIds) {
      const track = tracksById[trackId];
      // reduced clips = clips where purely-cosmetic cuts are merged
      const studioTrackClips = studioClipsByTrackId[trackId];
      if (!track) continue;

      const crossfadeClips = autoCrossfadeClips(studioTrackClips);

      const trackClips: AudioClip[] = [];

      // Add all clips to the track
      for (const clip of crossfadeClips) {
        // Get FfmpegAudioBuffer for this clip (from cache for uploaded files, or preloaded for clips)
        const dspAudioBuffer = clip.uploadId
          ? ffmpegBuffersByUploadId[clip.uploadId]
          : preloadedClips.get(clip.clipId!)!;

        // Use cached WarpMap based on warp settings (markers + speed)
        const warpMap = getCachedWarpMap(clip.warp);

        const audioClip = dspContext.createAudioClip(
          dspAudioBuffer as RandomAccessAudioReadable,
          warpMap,
          clip.amplitude, // gain
          clip.startBeats,
          clip.endBeats,
          clip.loop.startBeats,
          999999, // clip.loop.endBeats,
          clip.readStartBeats,
          clip.fadeInBeats, // fadeInBeats
          1, // fadeInExponent
          clip.fadeOutBeats, // fadeOutBeats
          1, // fadeOutExponent
          clip.transposition, // transposition
          1, // clip BPS
          getWarpEnabledAndPopulated(clip.warp),
          clip.id
        );

        trackClips.push(audioClip);
      }

      // Check if we already have a meter for this track, reuse it if possible
      let meter = moduleReferencesRef.current.meterMap[trackId];
      if (!meter || meter.isDeleted()) {
        meter = dspContext.createMeter();
        moduleReferencesRef.current.meterMap[trackId] = meter;
      }

      // Create filter chain with 6 filters: 4 EQ bands + high-pass + low-pass
      const filterChain = dspContext.createFilterChain(6);

      const moduleTrack = dspContext.createTrack(
        track.amplitude,
        track.balance,
        !!effectivelyMutedTracks[trackId],
        meter,
        filterChain,
        trackClips
      );

      // Map EQ band types to DSP engine filter modes
      const FILTER_MODE_MAP = {
        highpass: dspModule.BiQuadFilterMode.HIGHPASS,
        lowshelf: dspModule.BiQuadFilterMode.LOW_SHELF,
        peaking: dspModule.BiQuadFilterMode.EQ_BAND,
        notch: dspModule.BiQuadFilterMode.NOTCH,
        highshelf: dspModule.BiQuadFilterMode.HIGH_SHELF,
        lowpass: dspModule.BiQuadFilterMode.LOWPASS,
      } as const;

      // Set up all 6 EQ bands from track state
      EQ_BAND_NAMES.forEach((bandName, i) => {
        const band = track.eq[bandName];
        const mode =
          FILTER_MODE_MAP[band.type as keyof typeof FILTER_MODE_MAP] ??
          dspModule.BiQuadFilterMode.EQ_BAND;

        // Determine if this band should be bypassed
        // Bypass if: overall EQ disabled OR band disabled OR (for gain-based bands, gain is zero)
        const isGainBased =
          band.type === 'peaking' ||
          band.type === 'lowshelf' ||
          band.type === 'highshelf';
        const shouldBypass =
          !track.eq.enabled ||
          !band.enabled ||
          (isGainBased && band.gain === 0);

        filterChain.setMode(
          i,
          shouldBypass ? dspModule.BiQuadFilterMode.BYPASS : mode
        );
        filterChain.setFrequency(i, band.frequency);
        filterChain.setQ(i, band.q);
        filterChain.setGain(i, band.gain);
      });

      // Store track reference
      moduleReferencesRef.current.trackMap[trackId] = moduleTrack;
      tracks.push(moduleTrack);

      // Store clip references
      for (let i = 0; i < crossfadeClips.length; i++) {
        newClipMap[crossfadeClips[i].id] = {
          track: moduleTrack,
          clip: trackClips[i],
        };
      }
    }

    // Update clip map
    for (const old of Object.values(moduleReferencesRef.current.clipMap)) {
      old.clip.delete();
      if (!old.track.isDeleted()) {
        old.track.delete();
      }
    }
    moduleReferencesRef.current.clipMap = newClipMap;

    // Clean up meters for removed tracks
    const currentTrackIds = new Set(trackIds);
    const previousTrackIds = new Set(
      Object.keys(moduleReferencesRef.current.trackMap)
    );

    // Delete meters for tracks that no longer exist
    for (const trackId of previousTrackIds) {
      if (!currentTrackIds.has(trackId)) {
        const meter = moduleReferencesRef.current.meterMap[trackId];
        if (meter && !meter.isDeleted()) {
          meter.delete();
        }
        delete moduleReferencesRef.current.meterMap[trackId];
      }
    }

    let metronomeTrack: Track | null = null;
    if (state.metronome.enabled && metronomeBufferRef.current) {
      const metronomeWarpMap = dspModule.WarpMap.fromArray(metronomeMarkers);
      const metronomeClip = dspContext.createAudioClip(
        metronomeBufferRef.current,
        metronomeWarpMap,
        1, // gain
        -32, // start beats
        1024, // end beats
        0, // loop start
        4, // loop end
        0, // read start
        0, // fadeInBeats
        1, // fadeInExponent
        0, // fadeOutBeats,
        1, // fadeOutExponent
        0, // transposition
        1, // clip BPS
        true,
        ''
      );
      metronomeWarpMap.delete();

      metronomeTrack = dspContext.createTrack(
        state.metronome.amplitude,
        0.0,
        false,
        dspContext.createMeter(),
        null,
        [metronomeClip]
      );
      metronomeClip.delete();

      tracks.push(metronomeTrack);
    }

    const timing = getDerivedTiming(state);
    const songStartBeats = getSongStartBeats(state);
    const songEndBeats = getSongEndBeats(state);

    const moduleTiming = dspModule.TimelineTempoMap.fromArray(
      timing.bps,
      timing.bpsAutomation
    )!;

    // Create new timeline
    const timeline = dspContext.createTimeline(moduleTiming, tracks);

    timeline.fadeInStartBeats = state.songFadeInBeats
      ? songStartBeats
      : -Infinity;
    timeline.fadeInLengthBeats = state.songFadeInBeats;
    timeline.fadeInExponent = 2;
    timeline.fadeOutEndBeats = state.songFadeOutBeats ? songEndBeats : Infinity;
    timeline.fadeOutLengthBeats = state.songFadeOutBeats;
    timeline.fadeOutExponent = 2;
    timeline.masterGain = state.amplitude ?? 1;

    moduleTiming.delete();

    if (metronomeTrack) {
      metronomeTrack.delete();
    }

    for (const cache of warpMapCacheRef.current.values()) {
      for (const warpMap of cache.values()) {
        warpMap.delete();
      }
    }
    warpMapCacheRef.current.clear();

    // Set the new timeline
    const oldTimeline = moduleReferencesRef.current.timeline;
    if (oldTimeline) {
      oldTimeline.delete();
    }
    moduleReferencesRef.current.timeline = timeline;
    timeline.loopEnabled = state.loop.enabled;
    timeline.loopStart = state.loop.startBeats;
    timeline.loopEnd = state.loop.endBeats;
    dspContext.swapLiveTimeline(timeline);

    // Update previous state
    moduleReferencesRef.current.previousState = {
      amplitude: state.amplitude,
      metronome: {
        enabled: state.metronome.enabled,
        amplitude: state.metronome.amplitude,
      },
      timing: {
        bps: timing.bps,
        bpsAutomation: timing.bpsAutomation,
      },
      tracks: trackIds.reduce(
        (acc, trackId) => {
          const track = tracksById[trackId];
          if (!track) return acc;
          const studioTrackClips =
            // reduced clips = clips where purely-cosmetic cuts are merged
            studioClipsByTrackId[trackId];

          acc[trackId] = {
            amplitude: track.amplitude,
            balance: track.balance,
            muted: !!effectivelyMutedTracks[trackId],
            eq: copyEQSettings(track.eq),
            clips: studioTrackClips.reduce(
              (clipAcc, clip) => {
                clipAcc[clip.id] = {
                  streaming: clip.streaming,
                  transposition: clip.transposition,
                  startBeats: clip.startBeats,
                  endBeats: clip.endBeats,
                  loop: { ...clip.loop },
                  readStartBeats: clip.readStartBeats,
                  fadeInBeats: clip.fadeInBeats,
                  fadeOutBeats: clip.fadeOutBeats,
                  warp: {
                    enabled: clip.warp.enabled,
                    markers: clip.warp.markers,
                    speed: clip.warp.speed,
                  },
                };
                return clipAcc;
              },
              {} as Record<string, any>
            ),
          };
          return acc;
        },
        {} as Record<string, any>
      ),
    };
  }, [state, dspModule, needsTimelineRebuild]);

  // Analytics
  const songSessionIdMap = useRef<Record<string, string>>({});

  // Cleanup timeline tracking on unmount
  useDismount(
    useCallback(() => {
      const timeline = moduleReferencesRef.current.timeline;
      if (timeline) {
        const currentTimeBeats = timeline.position || lastSeekBeatsRef.current;
        const currentTimeSeconds = getSecondsFromZero(
          currentTimeBeats,
          derivedTiming
        );
        (async () =>
          timelineAnalytics.onCleanup(currentTimeSeconds, playing))();
      }
    }, [derivedTiming, timelineAnalytics.onCleanup, playing]),
    100
  );

  const pollAnalytics = useCallback(async () => {
    const analyticsObserver = dspContext.analyticsObserver;
    if (!analyticsObserver || analyticsObserver.isDeleted()) {
      return;
    }
    try {
      const spans = analyticsObserver.flushSpans();
      const trackIds = getTrackIds(state);
      const studioClipsByTrackId = getStudioClipsByTrackId(state);
      const studioClipsByArrangementId = getStudioClips(state).reduce(
        (acc, c) => {
          acc[c.id] = c;
          return acc;
        },
        {} as Record<string, StudioClip>
      );
      const arrangementToClip = trackIds
        .map((t) => studioClipsByTrackId[t])
        .flat()
        .filter((c) => c.clipId !== null)
        .reduce(
          (acc, c) => {
            acc[c.id] = c.clipId;
            return acc;
          },
          {} as Record<string, string>
        );
      const newSongIds: Set<string> = new Set();
      const analyticsEvents = (
        await Promise.all(
          spans
            .map((s) => ({
              ...s,
              clipId: arrangementToClip[s.arrangementId.toString()],
            }))
            .filter((s) => s.clipId !== undefined && s.end - s.begin > 0.1)
            .map(async (s) => {
              const { clipId, begin: startTime, end: endTime } = s;

              const isNewSong = !songSessionIdMap.current[clipId];
              if (isNewSong) {
                newSongIds.add(clipId);
              }
              const songSessionId = isNewSong
                ? crypto.randomUUID()
                : songSessionIdMap.current[clipId];
              songSessionIdMap.current[clipId] = songSessionId;
              const clip = await fetchClip(clipsStore, clipId, false);
              const clipUserId = clip?.user_id;
              const clipDuration = clip?.metadata?.duration ?? 0;
              const baseEvent = {
                songSessionId: songSessionId,
                hasClip: true,
                songId: clipId,
                contextId: studioProjectId,
                contextType: isPreviewMode
                  ? ContextType.StudioNewPreview
                  : ContextType.StudioNew,
                isPlaying: true,
                isAudioElementNull: false,
                audioElementCurrentTime: startTime,
                actionName: isNewSong ? 'PlayNewSong' : 'PlaySong',
                isUserSongOwner:
                  session?.userId !== undefined &&
                  session?.userId === clipUserId,
                volume: 100,
                clickSourceUrl: location.pathname,
                isAutoplayOn: false,
                isRepeatOn: false,
                userId: session?.userId,
                previousSongSessionId: null,
                actionIndex: -1,
                songLength: clipDuration,
                studioProjectSessionId: studioProjectSessionId,
              };
              const arrangementClip =
                studioClipsByArrangementId[s.arrangementId.toString()];
              return [
                baseEvent,
                {
                  ...baseEvent,
                  actionName: 'PauseSong',
                  isPlaying: false,
                  startTime,
                  endTime,
                  playDuration: endTime - startTime,
                  audioElementCurrentTime: endTime,
                  arrangementStartSeconds: arrangementClip
                    ? getSecondsFromZero(
                        arrangementClip.startBeats,
                        derivedTimingRef.current
                      )
                    : null,
                  arrangementEndSeconds: arrangementClip
                    ? getSecondsFromZero(
                        arrangementClip.endBeats,
                        derivedTimingRef.current
                      )
                    : null,
                },
              ];
            })
        )
      ).flat();
      for (const event of analyticsEvents) {
        eventLogger.segmentTrack(EventNames.audioPlayerEvent, event, session);
      }
      if (newSongIds.size > 0) {
        clipsStore.apiClient.POST('/api/gen/bulk_increment_play_counts/v2', {
          body: {
            gen_ids: Array.from(newSongIds),
            sample_factor: 1,
          },
        });
      }
    } finally {
      analyticsObserver.delete();
    }
  }, [
    state,
    dspContext,
    session,
    clipsStore,
    isPreviewMode,
    studioProjectId,
    studioProjectSessionId,
  ]);

  useInterval(pollAnalytics, 5000);

  // Poll before teardown to ensure we get the last events
  useDismount(
    useCallback(() => {
      try {
        pollAnalytics();
      } catch (error) {
        console.error(error);
      }
    }, [pollAnalytics]),
    100
  );

  // Sync timeline when state changes
  useEffect(() => {
    syncTimeline();
  }, [syncTimeline]);

  const selectionStartBeats = getSelectionStartBeats(state);

  useEffect(() => {
    if (!Number.isFinite(selectionStartBeats)) {
      console.error(
        'got non-finite selectionStartBeats in useStudioPlaybackController',
        { selectionStartBeats }
      );
      return;
    }
    setLastSeekBeats(selectionStartBeats);
    lastSeekBeatsRef.current = selectionStartBeats;
  }, [selectionStartBeats]);

  // Update playhead position when selection changes
  useEffect(() => {
    if (!playing) {
      seek(lastSeekBeatsRef.current);
    }
  }, [selectionStartBeats, playing, seek]);

  const getCurrentBeats = useCallback(() => {
    return (
      moduleReferencesRef.current.timeline?.position ?? lastSeekBeatsRef.current
    );
  }, []);

  const timing = getDerivedTiming(state);
  const songStartSeconds = getSongStartSeconds(state);
  const getCurrentSeconds = useCallback(() => {
    return getSecondsFromZero(getCurrentBeats(), timing) - songStartSeconds;
  }, [getCurrentBeats, timing, songStartSeconds]);

  const getTrackMeter = useCallback(
    (trackId: string | null, channel: number): MeterValue => {
      const track = trackId
        ? moduleReferencesRef.current.trackMap[trackId]
        : dspContext;

      if (track?.isDeleted()) return FALLBACK_METER_VALUE;
      if (!track?.meter) return FALLBACK_METER_VALUE;

      return {
        ppm: track.meter.getPpmIndicated(channel),
        ppmHold: track.meter.getPpmHoldIndicated(channel),
        vu: track.meter.getVuIndicated(channel),
      };
    },
    [dspContext]
  );

  const getMasterMeter = useCallback((): MeterValue => {
    const meter = dspContext.meter;
    if (!meter)
      return {
        ppm: -Infinity,
        ppmHold: -Infinity,
        vu: -Infinity,
      };
    return {
      ppm: meter.getPpmIndicated(0),
      ppmHold: meter.getPpmHoldIndicated(0),
      vu: meter.getVuIndicated(0),
    };
  }, [dspContext.meter]);

  const setTrackFFTObserverEnabled = useCallback(
    (trackId: string, enabled: boolean) => {
      const track = moduleReferencesRef.current.trackMap[trackId];
      if (track?.isDeleted()) return;
      if (!track?.meter) return;
      if (enabled) {
        track.meter.fftObserverDecayAlpha = 0.97;
      }
      track.meter.fftObserverEnabled = enabled;
    },
    []
  );

  const getTrackFFTObserverBuffer = useCallback((trackId: string) => {
    const track = moduleReferencesRef.current.trackMap[trackId];
    if (track?.isDeleted()) return null;
    if (!track?.meter) return null;
    return track.meter.getFFTObserverBuffer();
  }, []);

  const setLoopStart = useCallback((start: number) => {
    if (!moduleReferencesRef.current.timeline) return;
    moduleReferencesRef.current.timeline.loopStart = start;
  }, []);

  const setLoopEnd = useCallback((end: number) => {
    if (!moduleReferencesRef.current.timeline) return;
    moduleReferencesRef.current.timeline.loopEnd = end;
  }, []);

  const setLoopEnabled = useCallback((enabled: boolean) => {
    if (!moduleReferencesRef.current.timeline) return;
    moduleReferencesRef.current.timeline.loopEnabled = enabled;
  }, []);

  const getLoopStart = useCallback(() => {
    return moduleReferencesRef.current.timeline?.loopStart ?? 0;
  }, []);

  const getLoopEnd = useCallback(() => {
    return moduleReferencesRef.current.timeline?.loopEnd ?? 0;
  }, []);

  const isLoopEnabled = useCallback(() => {
    return moduleReferencesRef.current.timeline?.loopEnabled ?? false;
  }, []);

  const setFadeInBeats = useCallback((beats: number) => {
    if (!moduleReferencesRef.current.timeline) return;
    moduleReferencesRef.current.timeline.fadeInLengthBeats = beats;
  }, []);

  const setFadeOutBeats = useCallback((beats: number) => {
    if (!moduleReferencesRef.current.timeline) return;
    moduleReferencesRef.current.timeline.fadeOutLengthBeats = beats;
  }, []);

  const getClipAmplitude = useCallback((clipId: string) => {
    return moduleReferencesRef.current.clipMap[clipId]?.clip.gain ?? 1.0;
  }, []);

  const setClipAmplitude = useCallback((clipId: string, gain: number) => {
    if (!moduleReferencesRef.current.clipMap[clipId]) return;
    moduleReferencesRef.current.clipMap[clipId].clip.gain = gain;
  }, []);
  const setTrackGain = useCallback((trackId: string, volume: number) => {
    if (!moduleReferencesRef.current.timeline) return;
    if (!moduleReferencesRef.current.trackMap[trackId]) return;
    moduleReferencesRef.current.trackMap[trackId].gain = volume;
  }, []);
  const setMasterGain = useCallback((gain: number) => {
    if (!moduleReferencesRef.current.timeline) return;
    moduleReferencesRef.current.timeline.masterGain = gain;
  }, []);

  const setLimiterParams = useCallback(
    ({
      lookaheadSeconds,
      attackSeconds,
      releaseSeconds,
      preGainDb,
      bypass,
      stereoLink,
    }: {
      lookaheadSeconds: number;
      attackSeconds: number;
      releaseSeconds: number;
      preGainDb: number;
      bypass: boolean;
      stereoLink: number;
    }) => {
      console.log('setLimiterParams', {
        lookaheadSeconds,
        attackSeconds,
        releaseSeconds,
        preGainDb,
        bypass,
        stereoLink,
      });
      dspContext.setLimiterParams(
        lookaheadSeconds,
        attackSeconds,
        releaseSeconds,
        preGainDb,
        bypass,
        stereoLink
      );
    },
    [dspContext]
  );

  // Add effect to log selection changes and update loop points
  useEffect(() => {
    // Separate dragging constraints should prevent the user from setting an invalid loop
    setLoopStart(state.loop.startBeats);
    setLoopEnd(state.loop.endBeats);
    setLoopEnabled(state.loop.enabled);
  }, [
    state.loop.startBeats,
    state.loop.endBeats,
    state.loop.enabled,
    setLoopStart,
    setLoopEnd,
    setLoopEnabled,
  ]);

  const hardRefreshState = useCallback(() => {
    // Clean up existing meters before resetting
    for (const meter of Object.values(moduleReferencesRef.current.meterMap)) {
      if (meter && !meter.isDeleted()) {
        meter.delete();
      }
    }

    moduleReferencesRef.current = {
      timeline: null,
      trackMap: {},
      meterMap: {},
      clipMap: {},
      previousState: {
        amplitude: 1.0,
        metronome: { enabled: state.metronome.enabled, amplitude: 1.0 },
        timing: null,
        tracks: {},
      },
    };
    syncTimeline();
  }, [state.metronome.enabled, syncTimeline]);

  // quick hack to prevent stemSplitContext from wiping out normal edit v3 playback
  const stemSplitContext = useContext(StemSplitContext);
  const lastSplittingClipId = useRef(stemSplitContext?.splittingClipId);
  useEffect(() => {
    if (lastSplittingClipId.current && !stemSplitContext?.splittingClipId) {
      hardRefreshState();
    }
    lastSplittingClipId.current = stemSplitContext?.splittingClipId;
  }, [stemSplitContext?.splittingClipId, hardRefreshState]);

  useInterval(() => {
    if (
      stopAtSongEnd &&
      playing &&
      getCurrentBeats() > getSongEndBeats(state)
    ) {
      setPlaying(false);
    }
  }, 100);

  const muteArmedTracks = useCallback(() => {
    let tracksToUnmute: { trackId: string; originalGain: number }[] = [];
    Object.entries(getTracksById(state)).forEach(([trackId, track]) => {
      if (track.arm) {
        setTrackGain(trackId, 0);
        tracksToUnmute.push({ trackId, originalGain: track.amplitude });
      }
    });
    return () => {
      tracksToUnmute.forEach(({ trackId, originalGain }) => {
        setTrackGain(trackId, originalGain);
      });
    };
  }, [setTrackGain, state]);

  return useMemo(
    () => ({
      playing,
      setPlaying,
      seek,
      getCurrentBeats,
      getCurrentSeconds,
      play,
      stop,
      updateTrackPan,
      updateTrackEQ,
      updateTrackFilter,
      updateClipGain,
      updateClipTransposition,
      updateClipWarpEnabled,
      updateClipFadeIn,
      updateClipFadeOut,
      updateClipLoop,
      updateClipTimeline,
      getTrackMeter,
      getMasterMeter,
      setTrackFFTObserverEnabled,
      getTrackFFTObserverBuffer,
      setLoopStart,
      setLoopEnd,
      setLoopEnabled,
      getLoopStart,
      getLoopEnd,
      isLoopEnabled,
      setFadeInBeats,
      setFadeOutBeats,
      setClipAmplitude,
      getClipAmplitude,
      setTrackGain,
      setMasterGain,
      stopAt,
      clearStopAt,
      dspModule,
      dspContext,
      moduleReferencesRef,
      studioProjectSessionId,
      setLimiterParams,
      getFfmpegBuffer,
      getStreamingFfmpegBuffer,
      muteArmedTracks,
    }),
    [
      playing,
      setPlaying,
      seek,
      getCurrentBeats,
      getCurrentSeconds,
      play,
      stop,
      updateTrackPan,
      updateTrackEQ,
      updateTrackFilter,
      updateClipGain,
      updateClipTransposition,
      updateClipWarpEnabled,
      updateClipFadeIn,
      updateClipFadeOut,
      updateClipLoop,
      updateClipTimeline,
      getTrackMeter,
      getMasterMeter,
      setTrackFFTObserverEnabled,
      getTrackFFTObserverBuffer,
      setLoopStart,
      setLoopEnd,
      setLoopEnabled,
      getLoopStart,
      getLoopEnd,
      isLoopEnabled,
      setFadeInBeats,
      setFadeOutBeats,
      setClipAmplitude,
      getClipAmplitude,
      setTrackGain,
      setMasterGain,
      stopAt,
      clearStopAt,
      dspModule,
      dspContext,
      moduleReferencesRef,
      studioProjectSessionId,
      setLimiterParams,
      getFfmpegBuffer,
      getStreamingFfmpegBuffer,
      muteArmedTracks,
    ]
  );
}
