import { lruMemoize } from 'reselect';

import { pitchToMultiplier } from './audioEngineeringUtils';
import { binarySearchUpperBound } from './binarySearch';
import { AutomationPoint, DerivedTiming } from './derivedTiming';
import { StudioClip } from './projectState/fixClip';
import {
  ClipBeats,
  ClipContentSeconds,
  MappedSegment,
  TimeMapper,
  TimelineBeats,
  TimelinePixels,
  TimelineSeconds,
  getMappedSegment,
  getPointMapper,
  getPointUnmapper,
} from './timeTransform';
import { getEffectiveMarkers, getWarpEnabledAndPopulated } from './warpUtils';

// ============================================================================
// Automation Point Utilities
// ============================================================================

export const getEffectiveAutomationPoints = (
  inputBeats: number,
  automationPoints: AutomationPoint[]
) => {
  const zeroPoint = {
    beats: 0,
    value: getValueAtBeats(0, automationPoints)[1],
    curve: 0,
  };
  const inputPoint = {
    beats: inputBeats,
    value: getValueAtBeats(inputBeats, automationPoints)[0],
    curve: 0,
  };
  const result: AutomationPoint[] = [zeroPoint];
  for (let i = 0; i < automationPoints.length; i++) {
    const point = automationPoints[i];
    if (point.beats >= inputBeats || point.beats < 0) {
      continue;
    }
    if (
      point.beats === result[result.length - 1].beats &&
      point.value === result[result.length - 1].value
    ) {
      result[result.length - 1] = point;
    } else {
      result.push(point);
    }
  }

  result.push(inputPoint);

  return result;
};

// Efficient FIFO cache implementation to avoid expensive Array.from() operations
class FIFOCache<K, V> {
  private cache = new Map<K, V>();
  private insertionOrder: K[] = [];
  private maxSize: number;

  constructor(maxSize: number) {
    this.maxSize = maxSize;
  }

  has(key: K): boolean {
    return this.cache.has(key);
  }

  get(key: K): V | undefined {
    return this.cache.get(key);
  }

  set(key: K, value: V): void {
    if (!this.cache.has(key)) {
      // Only add to insertion order if it's a new key
      this.insertionOrder.push(key);

      // Evict oldest if over capacity
      if (this.insertionOrder.length > this.maxSize) {
        const oldestKey = this.insertionOrder.shift()!;
        this.cache.delete(oldestKey);
      }
    }
    this.cache.set(key, value);
  }

  get size(): number {
    return this.cache.size;
  }
}

const getValueAtBeatsCache = new FIFOCache<
  AutomationPoint[],
  FIFOCache<number, [number, number]>
>(64);

export const getValueAtBeats = (
  inputBeats: number,
  automationPoints: AutomationPoint[]
): [number, number] => {
  if (automationPoints.length === 0) return [0, 0];

  const nextPointIndex = Math.min(
    automationPoints.length - 1,
    binarySearchUpperBound(automationPoints, inputBeats)
  );
  const prevPointIndex = Math.max(0, nextPointIndex - 1);

  const nextPoint = automationPoints[nextPointIndex];
  const prevPoint = automationPoints[prevPointIndex];

  if (inputBeats === nextPoint.beats && prevPoint.curve === 0)
    return [prevPoint.value, nextPoint.value];
  if (inputBeats >= nextPoint.beats) return [nextPoint.value, nextPoint.value];
  if (inputBeats <= prevPoint.beats) return [prevPoint.value, prevPoint.value];

  const pointSeparation = nextPoint.beats - prevPoint.beats;

  if (pointSeparation === 0) {
    return [prevPoint.value, nextPoint.value];
  }
  if (nextPoint.value === prevPoint.value) {
    return [nextPoint.value, nextPoint.value];
  }
  if (prevPoint.curve === 0) {
    return [prevPoint.value, prevPoint.value];
  }
  const valueDelta = nextPoint.value - prevPoint.value;
  const timeDeltaFromPrevPoint = inputBeats - prevPoint.beats;
  let valueDeltaFromPrevPoint = 0;
  const prevPointExponent = Math.pow(2, prevPoint.curve);

  if (prevPointExponent === 1) {
    valueDeltaFromPrevPoint =
      (valueDelta * timeDeltaFromPrevPoint) / pointSeparation;
  } else if (prevPointExponent !== 0) {
    valueDeltaFromPrevPoint =
      valueDelta *
      (1 -
        Math.pow(
          1 - timeDeltaFromPrevPoint / pointSeparation,
          prevPointExponent
        ));
  }

  return [
    prevPoint.value + valueDeltaFromPrevPoint,
    prevPoint.value + valueDeltaFromPrevPoint,
  ];
};

export const getValueAtBeatsCached = (
  inputBeats: number,
  automationPoints: AutomationPoint[]
): [number, number] => {
  if (!getValueAtBeatsCache.has(automationPoints)) {
    getValueAtBeatsCache.set(
      automationPoints,
      new FIFOCache<number, [number, number]>(2048)
    );
  }
  const cache = getValueAtBeatsCache.get(automationPoints)!;
  if (!cache.has(inputBeats)) {
    cache.set(inputBeats, getValueAtBeats(inputBeats, automationPoints));
  }
  return cache.get(inputBeats)!;
};

const getSegmentSeconds = (
  beats: number,
  r1: number,
  r2: number,
  b: number
): number => {
  const deltaR = r2 - r1;
  if (deltaR === 0) {
    return beats / r1;
  } else {
    const k = deltaR / b;
    const time = (1 / k) * Math.log((r1 + k * beats) / r1);
    return time;
  }
};

let lastSecondsFromZeroTiming: DerivedTiming | null = null;
let secondsFromZeroCache = new Map<number, number>();

// how many (signed) seconds is a given number of beats away from beat zero?
const computeSecondsFromZero = (
  inputBeats: number,
  timing: DerivedTiming
): number => {
  if (inputBeats === 0) {
    return timing.firstBeatSeconds;
  }

  if (timing.bpsAutomation.length === 0) {
    return inputBeats / timing.bps + timing.firstBeatSeconds;
  }

  if (timing.bpsAutomation.length === 1) {
    return inputBeats / timing.bpsAutomation[0].value + timing.firstBeatSeconds;
  }

  if (inputBeats < 0) {
    const flippedAutomation = timing.bpsAutomation
      .map((point) => ({
        beats: -point.beats,
        value: point.value,
        curve: point.curve,
      }))
      .reverse();
    return -computeSecondsFromZero(-inputBeats, {
      bps: timing.bps,
      bpsAutomation: flippedAutomation,
      firstBeatSeconds: -timing.firstBeatSeconds, // I'm not sure if this is correct.
    });
  }

  const points = getEffectiveAutomationPoints(inputBeats, timing.bpsAutomation);
  let duration = 0;

  for (let i = 0; i < points.length - 1; i++) {
    const startBeats = points[i].beats;
    const endBeats = points[i + 1].beats;
    const durationBeats = endBeats - startBeats;
    const startBPS = getValueAtBeatsCached(startBeats, timing.bpsAutomation)[1];
    const endBPS = getValueAtBeatsCached(endBeats, timing.bpsAutomation)[0];

    const segmentDuration =
      startBeats === endBeats
        ? 0
        : getSegmentSeconds(durationBeats, startBPS, endBPS, durationBeats);

    duration += segmentDuration;
  }

  return duration + timing.firstBeatSeconds;
};

export const computeSecondsFromZeroCached = (
  inputBeats: number,
  timing: DerivedTiming
): number => {
  if (timing === lastSecondsFromZeroTiming) {
    if (secondsFromZeroCache.has(inputBeats)) {
      return secondsFromZeroCache.get(inputBeats)!;
    }
  } else {
    secondsFromZeroCache = new Map<number, number>();
    lastSecondsFromZeroTiming = timing;
  }
  const result = computeSecondsFromZero(inputBeats, timing);
  secondsFromZeroCache.set(inputBeats, result);
  return result;
};

// ============================================================================
// Timeline Transformations
// ============================================================================

const getTimelinePixelsToTimelineBeatsSegments = (
  scrollX: number,
  pxPerBeat: number,
  width: number
) => {
  const scrollXInBeats = scrollX / pxPerBeat;
  const widthInBeats = width / pxPerBeat;
  return [
    getMappedSegment<TimelinePixels, TimelineBeats>(
      { start: 0, end: width },
      { start: scrollXInBeats, end: scrollXInBeats + widthInBeats }
    ),
  ];
};

export const getTimelinePixelsToTimelineBeats = (
  scrollX: number,
  pxPerBeat: number,
  width: number
): TimeMapper<TimelinePixels, TimelineBeats> => {
  const segments = getTimelinePixelsToTimelineBeatsSegments(
    scrollX,
    pxPerBeat,
    width
  );
  return getPointMapper<TimelinePixels, TimelineBeats>(segments);
};

export const getTimelineBeatsToTimelinePixels = (
  scrollX: number,
  pxPerBeat: number,
  width: number
): TimeMapper<TimelineBeats, TimelinePixels> => {
  const segments = getTimelinePixelsToTimelineBeatsSegments(
    scrollX,
    pxPerBeat,
    width
  );
  return getPointUnmapper<TimelinePixels, TimelineBeats>(segments);
};

export const getTimelineBeatsToTimelineSecondsSegments = (
  timing: DerivedTiming
) => {
  const segments: MappedSegment<TimelineBeats, TimelineSeconds>[] = [];

  const bps =
    timing.bpsAutomation.length === 1
      ? timing.bpsAutomation[0].value
      : timing.bps;
  const bpsAutomation = timing.bpsAutomation.sort((a, b) => a.beats - b.beats);
  const firstBeatSeconds = timing.firstBeatSeconds;
  const spb = 1 / bps;

  if (bpsAutomation.length <= 1) {
    segments.push(
      getMappedSegment<TimelineBeats, TimelineSeconds>(
        { start: -Infinity, end: 0 },
        { start: -Infinity, end: firstBeatSeconds },
        spb
      )
    );
    segments.push(
      getMappedSegment<TimelineBeats, TimelineSeconds>(
        { start: 0, end: Infinity },
        { start: firstBeatSeconds, end: Infinity },
        spb
      )
    );
  } else {
    segments.push(
      getMappedSegment<TimelineBeats, TimelineSeconds>(
        { start: -Infinity, end: bpsAutomation[0].beats },
        {
          start: -Infinity,
          end: computeSecondsFromZeroCached(bpsAutomation[0].beats, timing),
        },
        1 / bpsAutomation[0].value
      )
    );
    for (let i = 1; i < bpsAutomation.length; i++) {
      segments.push(
        getMappedSegment<TimelineBeats, TimelineSeconds>(
          { start: bpsAutomation[i - 1].beats, end: bpsAutomation[i].beats },
          {
            start: computeSecondsFromZeroCached(
              bpsAutomation[i - 1].beats,
              timing
            ),
            end: computeSecondsFromZeroCached(bpsAutomation[i].beats, timing),
          },
          1 / bpsAutomation[i - 1].value
        )
      );
    }
    segments.push(
      getMappedSegment<TimelineBeats, TimelineSeconds>(
        { start: bpsAutomation[bpsAutomation.length - 1].beats, end: Infinity },
        {
          start: computeSecondsFromZeroCached(
            bpsAutomation[bpsAutomation.length - 1].beats,
            timing
          ),
          end: Infinity,
        },
        1 / bpsAutomation[bpsAutomation.length - 1].value
      )
    );
  }
  return segments;
};

let lastGetTimelineBeatsToTimelineSecondsAndTiming: {
  timing: DerivedTiming;
  getter: TimeMapper<TimelineBeats, TimelineSeconds>;
} | null = null;

export const getTimelineBeatsToTimelineSeconds = (
  timing: DerivedTiming
): TimeMapper<TimelineBeats, TimelineSeconds> => {
  if (lastGetTimelineBeatsToTimelineSecondsAndTiming?.timing === timing) {
    return lastGetTimelineBeatsToTimelineSecondsAndTiming.getter;
  }
  const segments = getTimelineBeatsToTimelineSecondsSegments(timing);

  const result = getPointMapper<TimelineBeats, TimelineSeconds>(segments);
  lastGetTimelineBeatsToTimelineSecondsAndTiming = {
    timing,
    getter: result,
  };
  return result;
};

let lastGetTimelineSecondsToTimelineBeatsAndTiming: {
  timing: DerivedTiming;
  getter: TimeMapper<TimelineSeconds, TimelineBeats>;
} | null = null;

export const getTimelineSecondsToTimelineBeats = (
  timing: DerivedTiming
): TimeMapper<TimelineSeconds, TimelineBeats> => {
  if (lastGetTimelineSecondsToTimelineBeatsAndTiming?.timing === timing) {
    return lastGetTimelineSecondsToTimelineBeatsAndTiming.getter;
  }
  const segments = getTimelineBeatsToTimelineSecondsSegments(timing);

  const result = getPointUnmapper<TimelineSeconds, TimelineBeats>(segments);
  lastGetTimelineSecondsToTimelineBeatsAndTiming = {
    timing,
    getter: result,
  };
  return result;
};

export const getTimelineBeatsToClipBeatsSegments = (
  clip: Pick<StudioClip, 'readStartBeats' | 'loop' | 'startBeats' | 'endBeats'>
): MappedSegment<TimelineBeats, ClipBeats>[] => {
  const segments: MappedSegment<TimelineBeats, ClipBeats>[] = [];

  if (!clip.loop.enabled) {
    segments.push(
      getMappedSegment<TimelineBeats, ClipBeats>(
        { start: clip.startBeats, end: clip.endBeats },
        {
          start: clip.readStartBeats,
          end: clip.readStartBeats + (clip.endBeats - clip.startBeats),
        }
      )
    );
  } else {
    let clipLoopStartBeats = clip.readStartBeats;
    let clipLoopEndBeats = clip.loop.endBeats;
    let loopDurationBeats = clipLoopEndBeats - clipLoopStartBeats;

    let timelineLoopStartBeats = clip.startBeats;
    let timelineLoopEndBeats = clip.startBeats + loopDurationBeats;

    while (timelineLoopStartBeats < clip.endBeats && loopDurationBeats > 0) {
      segments.push(
        getMappedSegment<TimelineBeats, ClipBeats>(
          { start: timelineLoopStartBeats, end: timelineLoopEndBeats },
          { start: clipLoopStartBeats, end: clipLoopEndBeats }
        )
      );

      clipLoopStartBeats = clip.loop.startBeats;
      clipLoopEndBeats = Math.min(
        clip.loop.endBeats,
        clip.loop.startBeats + (clip.endBeats - timelineLoopEndBeats)
      );
      loopDurationBeats = clipLoopEndBeats - clipLoopStartBeats;

      timelineLoopStartBeats = timelineLoopEndBeats;
      timelineLoopEndBeats = Math.min(
        clip.endBeats,
        timelineLoopStartBeats + loopDurationBeats
      );
    }
  }

  return segments;
};

export const getTimelineBeatsToClipBeats = (
  clip: Pick<StudioClip, 'readStartBeats' | 'loop' | 'startBeats' | 'endBeats'>
): TimeMapper<TimelineBeats, ClipBeats> => {
  const segments = getTimelineBeatsToClipBeatsSegments(clip);

  return getPointMapper<TimelineBeats, ClipBeats>(segments);
};

export const getClipBeatsToTimelineBeats = (
  clip: Pick<StudioClip, 'readStartBeats' | 'loop' | 'startBeats' | 'endBeats'>
): TimeMapper<ClipBeats, TimelineBeats> => {
  const segments = getTimelineBeatsToClipBeatsSegments(clip);

  return getPointUnmapper<ClipBeats, TimelineBeats>(segments);
};
export const getClipBeatsToClipContentSecondsSegments = lruMemoize(
  (
    clip: Pick<StudioClip, 'warp' | 'transposition'>,
    contentDurationSeconds: number
  ): MappedSegment<ClipBeats, ClipContentSeconds>[] => {
    const segments: MappedSegment<ClipBeats, ClipContentSeconds>[] = [];
    const playbackRateScaling = getWarpEnabledAndPopulated(clip.warp)
      ? 1
      : pitchToMultiplier(clip.transposition);

    const entries = Object.entries(getEffectiveMarkers(clip.warp))
      .map(([seconds, beats]) => [Number(seconds) * playbackRateScaling, beats])
      .sort((a, b) => a[1] - b[1]);

    if (entries.length === 0) {
      // assume 1bps
      segments.push(
        getMappedSegment<ClipBeats, ClipContentSeconds>(
          { start: 0, end: contentDurationSeconds },
          { start: 0, end: contentDurationSeconds }
        )
      );
    } else if (entries.length === 1) {
      // assume 1bps
      const [seconds, beats] = entries[0];
      if (seconds > 0) {
        segments.push(
          getMappedSegment<ClipBeats, ClipContentSeconds>(
            { start: beats - seconds, end: beats },
            { start: 0, end: seconds }
          )
        );
        segments.push(
          getMappedSegment<ClipBeats, ClipContentSeconds>(
            { start: beats, end: beats + (contentDurationSeconds - seconds) },
            { start: seconds, end: contentDurationSeconds }
          )
        );
      } else {
        segments.push(
          getMappedSegment<ClipBeats, ClipContentSeconds>(
            { start: beats, end: beats + (contentDurationSeconds - seconds) },
            { start: seconds, end: contentDurationSeconds }
          )
        );
      }
    } else {
      const firstMarker = entries[0];
      const secondMarker = entries[1];

      const firstMarkerSeconds = firstMarker[0];
      const firstMarkerBeats = firstMarker[1];

      if (firstMarkerSeconds > 0) {
        const firstSegmentBps =
          (secondMarker[1] - firstMarker[1]) /
          (secondMarker[0] - firstMarker[0]);

        const beatsBeforeFirstMarker = firstMarkerSeconds * firstSegmentBps;

        segments.push(
          getMappedSegment<ClipBeats, ClipContentSeconds>(
            {
              start: firstMarkerBeats - beatsBeforeFirstMarker,
              end: firstMarkerBeats,
            },
            { start: 0, end: firstMarkerSeconds }
          )
        );
      }

      for (let i = 0; i < entries.length - 1; i++) {
        const [seconds, beats] = entries[i];
        const nextMarker = entries[i + 1];
        const nextMarkerSeconds = nextMarker[0];
        const nextMarkerBeats = nextMarker[1];

        segments.push(
          getMappedSegment<ClipBeats, ClipContentSeconds>(
            { start: beats, end: nextMarkerBeats },
            { start: seconds, end: nextMarkerSeconds }
          )
        );
      }

      const lastMarker = entries[entries.length - 1];
      const secondLastMarker = entries[entries.length - 2];
      const lastMarkerSeconds = lastMarker[0];
      const lastMarkerBeats = lastMarker[1];

      if (lastMarkerSeconds < contentDurationSeconds) {
        const lastSegmentBps =
          (lastMarker[1] - secondLastMarker[1]) /
          (lastMarker[0] - secondLastMarker[0]);

        const beatsAfterLastMarker =
          (contentDurationSeconds - lastMarkerSeconds) * lastSegmentBps;

        segments.push(
          getMappedSegment<ClipBeats, ClipContentSeconds>(
            {
              start: lastMarkerBeats,
              end: lastMarkerBeats + beatsAfterLastMarker,
            },
            { start: lastMarkerSeconds, end: contentDurationSeconds }
          )
        );
      } else {
        segments.push(
          getMappedSegment<ClipBeats, ClipContentSeconds>(
            { start: lastMarkerBeats, end: lastMarkerBeats },
            { start: lastMarkerSeconds, end: contentDurationSeconds }
          )
        );
      }
    }

    return [
      getMappedSegment<ClipBeats, ClipContentSeconds>(
        { start: -Infinity, end: segments[0].sourceRange.start },
        { start: -Infinity, end: segments[0].targetRange.start },
        Infinity
      ),
      ...segments,
      getMappedSegment<ClipBeats, ClipContentSeconds>(
        { start: segments[segments.length - 1].sourceRange.end, end: Infinity },
        {
          start: segments[segments.length - 1].targetRange.end,
          end: Infinity,
        },
        0
      ),
    ];
  },
  {
    maxSize: 100000,
  }
);

export const getClipBeatsToClipContentSeconds = (
  clip: Pick<StudioClip, 'warp' | 'transposition'>,
  contentDurationSeconds: number
): TimeMapper<ClipBeats, ClipContentSeconds> => {
  const segments = getClipBeatsToClipContentSecondsSegments(
    clip,
    contentDurationSeconds
  );

  return getPointMapper<ClipBeats, ClipContentSeconds>(segments);
};

export const getClipContentSecondsToClipBeats = (
  clip: Pick<StudioClip, 'warp' | 'transposition'>,
  contentDurationSeconds: number
): TimeMapper<ClipContentSeconds, ClipBeats> => {
  const segments = getClipBeatsToClipContentSecondsSegments(
    clip,
    contentDurationSeconds
  );

  return getPointUnmapper<ClipContentSeconds, ClipBeats>(segments);
};

export const getTimelineBeatsToUnwarpedClipContentSeconds = (
  timing: DerivedTiming,
  clip: StudioClip,
  contentDurationSeconds: number
): TimeMapper<TimelineBeats, ClipContentSeconds> => {
  // note: assumes no looping.

  const timelineBeatsToTimelineSeconds =
    getTimelineBeatsToTimelineSeconds(timing);

  const clipStartTimelineSeconds = timelineBeatsToTimelineSeconds(
    clip.startBeats
  );
  if (clipStartTimelineSeconds === null) return () => null;

  const clipBeatsToClipContentSeconds = getClipBeatsToClipContentSeconds(
    clip,
    contentDurationSeconds
  );

  const clipContentStartSeconds = clipBeatsToClipContentSeconds(
    clip.readStartBeats
  );

  return (timelineBeats) => {
    if (clipContentStartSeconds === null) return null;
    const timelineSeconds = timelineBeatsToTimelineSeconds(timelineBeats);
    if (timelineSeconds === null) return null;

    const elapsedTimelineSeconds =
      timelineSeconds[0] - clipStartTimelineSeconds[1];

    const elapsedClipContentSeconds =
      elapsedTimelineSeconds * pitchToMultiplier(clip.transposition);

    const clipContentSeconds =
      clipContentStartSeconds[1] + elapsedClipContentSeconds;

    return [clipContentSeconds, clipContentSeconds];
  };
};

export const getTimelineBeatsToWarpedClipContentSeconds = (
  clip: StudioClip,
  contentDurationSeconds: number
): TimeMapper<TimelineBeats, ClipContentSeconds> => {
  const timelineBeatsToClipBeats = getTimelineBeatsToClipBeats(clip);
  const clipBeatsToClipContentSeconds = getClipBeatsToClipContentSeconds(
    clip,
    contentDurationSeconds
  );

  return (timelineBeats) => {
    const clipBeats = timelineBeatsToClipBeats(timelineBeats);
    if (clipBeats === null) return null;

    const clipContentSecondsLeft = clipBeatsToClipContentSeconds(clipBeats[0]);
    if (clipContentSecondsLeft === null) return null;

    const clipContentSecondsRight = clipBeatsToClipContentSeconds(clipBeats[1]);
    if (clipContentSecondsRight === null) return null;

    return [clipContentSecondsLeft[0], clipContentSecondsRight[1]];
  };
};

export const getTimelineBeatsToClipContentSeconds = (
  timing: DerivedTiming,
  clip: StudioClip,
  contentDurationSeconds: number
): TimeMapper<TimelineBeats, ClipContentSeconds> => {
  if (getWarpEnabledAndPopulated(clip.warp)) {
    return getTimelineBeatsToWarpedClipContentSeconds(
      clip,
      contentDurationSeconds
    );
  } else {
    return getTimelineBeatsToUnwarpedClipContentSeconds(
      timing,
      clip,
      contentDurationSeconds
    );
  }
};

// Additional timing utility functions that depend on timeMapping
// These are defined here to avoid circular dependencies

export const getSecondsFromZero = (
  inputBeats: number,
  timing: DerivedTiming
): number => {
  const timelineBeatsToTimelineSeconds =
    getTimelineBeatsToTimelineSeconds(timing);
  return timelineBeatsToTimelineSeconds(inputBeats)?.[0] ?? 0;
};

export const getSecondsBetween = (
  beats1: number,
  beats2: number,
  timing: DerivedTiming
) => {
  return (
    getSecondsFromZero(beats2, timing) - getSecondsFromZero(beats1, timing)
  );
};

const getSegmentBeats = (
  segmentExponent: number,
  seconds: number,
  bps1: number,
  bps2: number,
  beatsBetweenNodes: number
): number => {
  const deltaR = bps2 - bps1;
  // because float
  if (Math.abs(deltaR) < 0.000000001 || segmentExponent === 0) {
    return bps1 * seconds;
  } else {
    // TODO: factor in non-0 non-1 segmentExponent here.
    const k = deltaR / beatsBetweenNodes;
    const exponent = k * seconds;
    const beats = (bps1 * (Math.exp(exponent) - 1)) / k;
    return beats;
  }
};

let lastBeatsFromZeroTiming: DerivedTiming | null = null;
let beatsFromZeroCache = new Map<number, number>();

const computeBeatsFromZero = (
  inputSeconds: number,
  timing: DerivedTiming
): number => {
  const adjustedInputSeconds = inputSeconds - timing.firstBeatSeconds;
  if (timing.bpsAutomation.length === 0) {
    return adjustedInputSeconds * timing.bps;
  }

  if (timing.bpsAutomation.length === 1) {
    return adjustedInputSeconds * timing.bpsAutomation[0].value;
  }

  if (adjustedInputSeconds < 0) {
    const flippedAutomation = timing.bpsAutomation
      .map((point) => ({
        beats: -point.beats,
        value: point.value,
        curve: point.curve,
      }))
      .reverse();
    return -computeBeatsFromZero(-adjustedInputSeconds, {
      bps: timing.bps,
      bpsAutomation: flippedAutomation,
      firstBeatSeconds: 0,
    });
  } else if (timing.firstBeatSeconds > 0) {
    return computeBeatsFromZero(adjustedInputSeconds, {
      bps: timing.bps,
      bpsAutomation: timing.bpsAutomation,
      firstBeatSeconds: 0,
    });
  }

  let currentBPS = getValueAtBeatsCached(0, timing.bpsAutomation)[1];
  let currentBeats = 0;
  let currentCurve = 0;
  let accumulatedSeconds = 0;
  const firstAutomationPointIndex = timing.bpsAutomation.findIndex(
    (point) => point.beats >= 0
  );
  if (firstAutomationPointIndex === -1) {
    return adjustedInputSeconds * currentBPS;
  }
  for (
    let i = firstAutomationPointIndex;
    i < timing.bpsAutomation.length;
    i++
  ) {
    const nextPoint = timing.bpsAutomation[i];
    const nextBeats = nextPoint.beats;
    const nextBPS = nextPoint.value;
    const nextCurve = nextPoint.curve;
    const durationBeats = nextBeats - currentBeats;
    const segmentDuration =
      computeSecondsFromZero(nextBeats, timing) - accumulatedSeconds;

    if (accumulatedSeconds + segmentDuration > adjustedInputSeconds) {
      const remainingSeconds = adjustedInputSeconds - accumulatedSeconds;
      const beatsIntoRamp = getSegmentBeats(
        currentCurve,
        remainingSeconds,
        currentBPS,
        nextBPS,
        durationBeats
      );
      const result = currentBeats + beatsIntoRamp;
      return result;
    }

    accumulatedSeconds += segmentDuration;
    if (accumulatedSeconds === adjustedInputSeconds) {
      return nextBeats;
    }
    currentBPS = nextBPS;
    currentBeats = nextBeats;
    currentCurve = nextCurve;
  }

  const beatsRemaining =
    (adjustedInputSeconds - accumulatedSeconds) * currentBPS;
  return currentBeats + beatsRemaining;
};

export const getBeatsFromZero = (
  inputSeconds: number,
  timing: DerivedTiming
): number => {
  const timelineSecondsToTimelineBeats =
    getTimelineSecondsToTimelineBeats(timing);
  return timelineSecondsToTimelineBeats(inputSeconds)?.[0] ?? 0;
};

export const computeBeatsFromZeroCached = (
  inputSeconds: number,
  timing: DerivedTiming
): number => {
  if (timing === lastBeatsFromZeroTiming) {
    if (beatsFromZeroCache.has(inputSeconds)) {
      return beatsFromZeroCache.get(inputSeconds)!;
    }
  } else {
    beatsFromZeroCache = new Map<number, number>();
    lastBeatsFromZeroTiming = timing;
  }
  const result = computeBeatsFromZero(inputSeconds, timing);
  beatsFromZeroCache.set(inputSeconds, result);
  return result;
};

export const getBeatsBetween = (
  seconds1: number,
  seconds2: number,
  timing: DerivedTiming
) => {
  return (
    getBeatsFromZero(seconds2, timing) - getBeatsFromZero(seconds1, timing)
  );
};

// returns beats
export const addSecondsToBeats = (
  seconds: number,
  beats: number,
  timing: DerivedTiming
) => {
  return getBeatsFromZero(getSecondsFromZero(beats, timing) + seconds, timing);
};

// returns seconds
export const addBeatsToSeconds = (
  beats: number,
  seconds: number,
  timing: DerivedTiming
) => {
  return getSecondsFromZero(getBeatsFromZero(seconds, timing) + beats, timing);
};
