/* Forked from react-virtualized and react-tiny-virtual-list 💖 */
export enum ALIGNMENT {
  AUTO = 'auto',
  START = 'start',
  CENTER = 'center',
  END = 'end'
}

export type ItemSizeGetter = (index: number) => number

export interface SizeAndPosition {
  size: number
  offset: number
}

interface SizeAndPositionData {
  [id: number]: SizeAndPosition
}

export interface Options {
  itemCount: number
  estimatedItemSize: number
}

export default class SizeAndPositionManager {
  private itemCount: number

  private estimatedItemSize: number

  public lastMeasuredIndex: number

  private itemSizeAndPositionData: SizeAndPositionData

  constructor({ itemCount, estimatedItemSize }: Options) {
    this.itemCount = itemCount
    this.estimatedItemSize = estimatedItemSize

    // Cache of size and position data for items, mapped by item index.
    this.itemSizeAndPositionData = {}

    // Offsets for items up to this index can be trusted; items afterward should be estimated.
    this.lastMeasuredIndex = -1
  }

  public resize({ itemCount, estimatedItemSize }: Partial<Options>) {
    if (itemCount != null) {
      this.itemCount = itemCount
      this.lastMeasuredIndex = Math.min(
        this.lastMeasuredIndex,
        this.itemCount,
        itemCount
      )
    }

    if (estimatedItemSize != null) {
      this.estimatedItemSize = estimatedItemSize
    }
  }

  private getSizeForIndex(index: number) {
    const cachedItem = this.itemSizeAndPositionData[index]

    return cachedItem ? cachedItem.size : this.estimatedItemSize
  }

  /**
   * This method returns the size and position for the item at the specified index.
   * It calculates (or used cached values) for items leading up to the index.
   */
  public getSizeAndPositionForIndex(index: number) {
    if (index < 0 || index >= this.itemCount) {
      throw Error(
        `Requested index ${index} is outside of range 0..${this.itemCount}`
      )
    }

    if (index > this.lastMeasuredIndex) {
      const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem()

      let offset =
        lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size

      for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {
        const size = this.getSizeForIndex(i)

        this.itemSizeAndPositionData[i] = {
          offset,
          size
        }

        offset += size
      }

      this.lastMeasuredIndex = index
    }

    return this.itemSizeAndPositionData[index]
  }

  public setItem(index: number, size: number): boolean {
    const cachedItem = this.itemSizeAndPositionData[index]

    if (cachedItem) {
      if (cachedItem.size !== size) {
        this.itemSizeAndPositionData[index] = {
          offset: cachedItem.offset,
          size
        }
        this.lastMeasuredIndex = Math.min(index, this.lastMeasuredIndex)
      } else {
        return false
      }
    } else {
      this.itemSizeAndPositionData[index] = {
        offset: null!,
        size
      }
      if (index)
        this.lastMeasuredIndex = Math.min(index - 1, this.lastMeasuredIndex)
    }
    return true
  }

  private getSizeAndPositionOfLastMeasuredItem() {
    return this.lastMeasuredIndex >= 0
      ? this.itemSizeAndPositionData[this.lastMeasuredIndex]
      : { offset: 0, size: 0 }
  }

  /**
   * Total size of all items being measured.
   * This value will be completedly estimated initially.
   * As items as measured the estimate will be updated.
   */
  public getTotalSize(): number {
    const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem()

    /* TODO consider whether to measure to end */

    return (
      lastMeasuredSizeAndPosition.offset +
      lastMeasuredSizeAndPosition.size +
      (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize
    )
  }

  /**
   * Determines a new offset that ensures a certain item is visible, given the alignment.
   *
   * @param align Desired alignment within container; one of "start" (default), "center", or "end"
   * @param containerSize Size (width or height) of the container viewport
   * @return Offset to use to ensure the specified item is visible
   */
  public getUpdatedOffsetForIndex({
    align = ALIGNMENT.START,
    containerSize,
    currentOffset,
    targetIndex
  }: {
    align: ALIGNMENT | string | undefined
    containerSize: number
    currentOffset: number
    targetIndex: number
  }): number {
    if (containerSize <= 0) {
      return 0
    }

    const datum = this.getSizeAndPositionForIndex(targetIndex)
    const maxOffset = datum.offset
    const minOffset = maxOffset - containerSize + datum.size

    let idealOffset

    switch (align) {
      case ALIGNMENT.END:
        idealOffset = minOffset
        break
      case ALIGNMENT.CENTER:
        idealOffset = maxOffset - (containerSize - datum.size) / 2
        break
      case ALIGNMENT.START:
        idealOffset = maxOffset
        break
      default:
        idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset))
    }

    const totalSize = this.getTotalSize()

    return Math.max(0, Math.min(totalSize - containerSize, idealOffset))
  }

  public getVisibleRange({
    containerSize,
    offset,
    overscanCount
  }: {
    containerSize: number
    offset: number
    overscanCount: number
  }): { start?: number; stop?: number; paddingTop?: number } {
    const totalSize = this.getTotalSize()

    if (totalSize === 0) {
      return {}
    }

    const maxOffset = offset + containerSize

    let start = this.findNearestItem(offset)

    if (typeof start === 'undefined') {
      throw Error(`Invalid offset ${offset} specified`)
    }

    const datum = this.getSizeAndPositionForIndex(start)

    let new_offset = datum.offset + datum.size

    let stop = start

    while (new_offset < maxOffset && stop < this.itemCount - 1) {
      stop++
      new_offset += this.getSizeAndPositionForIndex(stop).size
    }

    if (overscanCount) {
      start = Math.max(0, start - overscanCount)
      stop = Math.min(stop + overscanCount, this.itemCount - 1)
    }

    const paddingTop = this.getSizeAndPositionForIndex(start).offset

    return {
      start,
      stop,
      paddingTop
    }
  }

  /**
   * Searches for the item (index) nearest the specified offset.
   *
   * If no exact match is found the next lowest item index will be returned.
   * This allows partially visible items (with offsets just before/above the fold) to be visible.
   */
  private findNearestItem(offset: number) {
    if (isNaN(offset)) {
      throw Error(`Invalid offset ${offset} specified`)
    }

    // Our search algorithms find the nearest match at or below the specified offset.
    // So make sure the offset is at least 0 or no match will be found.
    offset = Math.max(0, offset)

    const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem()
    const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex)

    if (lastMeasuredSizeAndPosition.offset >= offset) {
      // If we've already measured items within this range just use a binary search as it's faster.
      return this.binarySearch({
        high: lastMeasuredIndex,
        low: 0,
        offset
      })
    }
    // If we haven't yet measured this high, fallback to an exponential search with an inner binary search.
    // The exponential search avoids pre-computing sizes for the full set of items as a binary search would.
    // The overall complexity for this approach is O(log n).
    return this.exponentialSearch({
      index: lastMeasuredIndex,
      offset
    })
  }

  private binarySearch({
    low,
    high,
    offset
  }: {
    low: number
    high: number
    offset: number
  }) {
    let middle = 0
    let currentOffset = 0

    while (low <= high) {
      middle = low + Math.floor((high - low) / 2)
      currentOffset = this.getSizeAndPositionForIndex(middle).offset

      if (currentOffset === offset) {
        return middle
      }
      if (currentOffset < offset) {
        low = middle + 1
      } else if (currentOffset > offset) {
        high = middle - 1
      }
    }

    if (low > 0) {
      return low - 1
    }

    return 0
  }

  private exponentialSearch({
    index,
    offset
  }: {
    index: number
    offset: number
  }) {
    let interval = 1

    while (
      index < this.itemCount &&
      this.getSizeAndPositionForIndex(index).offset < offset
    ) {
      index += interval
      interval *= 2
    }

    return this.binarySearch({
      high: Math.min(index, this.itemCount - 1),
      low: Math.floor(index / 2),
      offset
    })
  }
}
