import BScroll, { TranslaterPoint } from '@better-scroll/core'
import {
  style,
  EventEmitter,
  between,
  getNow,
  Probe,
  EventRegister,
} from '@better-scroll/shared-utils'
import EventHandler from './event-handler'

export const enum IndicatorDirection {
  Horizontal = 'horizontal',
  Vertical = 'vertical',
}

const enum ScrollTo {
  Up = -1,
  Down = 1,
}

export const enum OffsetType {
  Step = 'step',
  Point = 'clickedPoint',
}

export interface IndicatorOptions {
  wrapper: HTMLElement
  direction: IndicatorDirection
  fade: boolean
  fadeInTime: number
  fadeOutTime: number
  interactive: boolean
  minSize: number
  isCustom: boolean
  scrollbarTrackClickable: boolean
  scrollbarTrackOffsetType: OffsetType
  scrollbarTrackOffsetTime: number
}

interface KeysMap {
  hasScroll: 'hasVerticalScroll' | 'hasHorizontalScroll'
  size: 'height' | 'width'
  wrapperSize: 'clientHeight' | 'clientWidth'
  scrollerSize: 'scrollerHeight' | 'scrollerWidth'
  maxScrollPos: 'maxScrollY' | 'maxScrollX'
  pos: 'y' | 'x'
  point: 'pageX' | 'pageY'
  translateProperty: 'translateY' | 'translateX'
  domRect: 'top' | 'left'
}

interface ScrollInfo {
  maxScrollPos: number
  minScrollPos: number
  sizeRatio: number
  baseSize: number
}

export default class Indicator {
  wrapper: HTMLElement
  wrapperRect: DOMRect
  indicatorEl: HTMLElement
  direction: IndicatorDirection
  scrollInfo: ScrollInfo
  currentPos: number
  moved: boolean
  startTime: number
  keysMap: KeysMap
  eventHandler: EventHandler
  clickEventRegister: EventRegister
  hooksFn: [EventEmitter, string, Function][] = []

  constructor(public scroll: BScroll, public options: IndicatorOptions) {
    this.wrapper = options.wrapper
    this.direction = options.direction
    this.indicatorEl = this.wrapper.children[0] as HTMLElement
    this.keysMap = this.getKeysMap()

    this.handleFade()

    this.handleHooks()
  }

  private handleFade() {
    if (this.options.fade) {
      this.wrapper.style.opacity = '0'
    }
  }

  private handleHooks() {
    const { fade, interactive, scrollbarTrackClickable } = this.options
    const scroll = this.scroll
    const scrollHooks = scroll.hooks
    const translaterHooks = scroll.scroller.translater.hooks
    const animaterHooks = scroll.scroller.animater.hooks

    this.registerHooks(
      scrollHooks,
      scrollHooks.eventTypes.refresh,
      this.refresh
    )

    this.registerHooks(
      translaterHooks,
      translaterHooks.eventTypes.translate,
      (pos: { x: number; y: number }) => {
        const { hasScroll: hasScrollKey } = this.keysMap
        if (this.scroll[hasScrollKey]) {
          this.updatePosition(pos)
        }
      }
    )

    this.registerHooks(
      animaterHooks,
      animaterHooks.eventTypes.time,
      this.transitionTime
    )

    this.registerHooks(
      animaterHooks,
      animaterHooks.eventTypes.timeFunction,
      this.transitionTimingFunction
    )

    if (fade) {
      this.registerHooks(scroll, scroll.eventTypes.scrollEnd, () => {
        this.fade()
      })

      this.registerHooks(scroll, scroll.eventTypes.scrollStart, () => {
        this.fade(true)
      })

      // for mousewheel event
      if (
        scroll.eventTypes.mousewheelStart &&
        scroll.eventTypes.mousewheelEnd
      ) {
        this.registerHooks(scroll, scroll.eventTypes.mousewheelStart, () => {
          this.fade(true)
        })

        this.registerHooks(scroll, scroll.eventTypes.mousewheelMove, () => {
          this.fade(true)
        })

        this.registerHooks(scroll, scroll.eventTypes.mousewheelEnd, () => {
          this.fade()
        })
      }
    }

    if (interactive) {
      const { disableMouse, disableTouch } = this.scroll.options
      this.eventHandler = new EventHandler(this, {
        disableMouse,
        disableTouch,
      })
      const eventHandlerHooks = this.eventHandler.hooks
      this.registerHooks(
        eventHandlerHooks,
        eventHandlerHooks.eventTypes.touchStart,
        this.startHandler
      )
      this.registerHooks(
        eventHandlerHooks,
        eventHandlerHooks.eventTypes.touchMove,
        this.moveHandler
      )
      this.registerHooks(
        eventHandlerHooks,
        eventHandlerHooks.eventTypes.touchEnd,
        this.endHandler
      )
    }

    if (scrollbarTrackClickable) {
      this.bindClick()
    }
  }

  private registerHooks(hooks: EventEmitter, name: string, handler: Function) {
    hooks.on(name, handler, this)
    this.hooksFn.push([hooks, name, handler])
  }

  private bindClick() {
    const wrapper = this.wrapper
    this.clickEventRegister = new EventRegister(wrapper, [
      {
        name: 'click',
        handler: this.handleClick.bind(this),
      },
    ])
  }

  private handleClick(e: MouseEvent) {
    const newPos = this.calculateclickOffsetPos(e)
    let { x, y } = this.scroll
    x = this.direction === IndicatorDirection.Horizontal ? newPos : x
    y = this.direction === IndicatorDirection.Vertical ? newPos : y
    this.scroll.scrollTo(x, y, this.options.scrollbarTrackOffsetTime)
  }

  private calculateclickOffsetPos(e: MouseEvent) {
    const { point: poinKey, domRect: domRectKey } = this.keysMap
    const { scrollbarTrackOffsetType } = this.options
    const clickPointOffset = e[poinKey] - this.wrapperRect[domRectKey]
    const scrollToWhere =
      clickPointOffset < this.currentPos ? ScrollTo.Up : ScrollTo.Down
    let delta = 0
    let currentPos = this.currentPos
    if (scrollbarTrackOffsetType === OffsetType.Step) {
      delta = this.scrollInfo.baseSize * scrollToWhere
    } else {
      delta = 0
      currentPos = clickPointOffset
    }
    return this.newPos(currentPos, delta, this.scrollInfo)
  }

  getKeysMap(): KeysMap {
    if (this.direction === IndicatorDirection.Vertical) {
      return {
        hasScroll: 'hasVerticalScroll',
        size: 'height',
        wrapperSize: 'clientHeight',
        scrollerSize: 'scrollerHeight',
        maxScrollPos: 'maxScrollY',
        pos: 'y',
        point: 'pageY',
        translateProperty: 'translateY',
        domRect: 'top',
      }
    }
    return {
      hasScroll: 'hasHorizontalScroll',
      size: 'width',
      wrapperSize: 'clientWidth',
      scrollerSize: 'scrollerWidth',
      maxScrollPos: 'maxScrollX',
      pos: 'x',
      point: 'pageX',
      translateProperty: 'translateX',
      domRect: 'left',
    }
  }

  fade(visible?: boolean) {
    const { fadeInTime, fadeOutTime } = this.options
    const time = visible ? fadeInTime : fadeOutTime
    const wrapper = this.wrapper
    wrapper.style[style.transitionDuration as any] = time + 'ms'
    wrapper.style.opacity = visible ? '1' : '0'
  }

  refresh() {
    const { hasScroll: hasScrollKey } = this.keysMap
    const scroll = this.scroll
    const { x, y } = scroll
    this.wrapperRect = this.wrapper.getBoundingClientRect()
    if (this.canScroll(scroll[hasScrollKey])) {
      let {
        wrapperSize: wrapperSizeKey,
        scrollerSize: scrollerSizeKey,
        maxScrollPos: maxScrollPosKey,
      } = this.keysMap

      this.scrollInfo = this.refreshScrollInfo(
        this.wrapper[wrapperSizeKey],
        scroll[scrollerSizeKey],
        scroll[maxScrollPosKey],
        this.indicatorEl[wrapperSizeKey]
      )

      this.updatePosition({
        x,
        y,
      })
    }
  }

  transitionTime(time: number = 0) {
    this.indicatorEl.style[style.transitionDuration as any] = time + 'ms'
  }

  transitionTimingFunction(easing: string) {
    this.indicatorEl.style[style.transitionTimingFunction as any] = easing
  }

  private canScroll(hasScroll: boolean): boolean {
    this.wrapper.style.display = hasScroll ? 'block' : 'none'
    return hasScroll
  }

  private refreshScrollInfo(
    wrapperSize: number,
    scrollerSize: number,
    maxScrollPos: number,
    indicatorElSize: number
  ): ScrollInfo {
    let baseSize = Math.max(
      Math.round(
        (wrapperSize * wrapperSize) / (scrollerSize || wrapperSize || 1)
      ),
      this.options.minSize
    )

    if (this.options.isCustom) {
      baseSize = indicatorElSize
    }

    const maxIndicatorScrollPos = wrapperSize - baseSize
    // sizeRatio is negative
    let sizeRatio = maxIndicatorScrollPos / maxScrollPos

    return {
      baseSize,
      maxScrollPos: maxIndicatorScrollPos,
      minScrollPos: 0,
      sizeRatio,
    }
  }

  updatePosition(point: TranslaterPoint) {
    const { pos, size } = this.caculatePosAndSize(point, this.scrollInfo)
    this.refreshStyle(size, pos)
    this.currentPos = pos
  }

  private caculatePosAndSize(
    point: TranslaterPoint,
    scrollInfo: ScrollInfo
  ): { pos: number; size: number } {
    const { pos: posKey } = this.keysMap
    const { sizeRatio, baseSize, maxScrollPos, minScrollPos } = scrollInfo
    const minSize = this.options.minSize

    let pos = Math.round(sizeRatio * point[posKey])
    let size
    // when out of boundary, slow down size reduction
    if (pos < minScrollPos) {
      size = Math.max(baseSize + pos * 3, minSize)
      pos = minScrollPos
    } else if (pos > maxScrollPos) {
      size = Math.max(baseSize - (pos - maxScrollPos) * 3, minSize)
      pos = maxScrollPos + baseSize - size
    } else {
      size = baseSize
    }

    return {
      pos,
      size,
    }
  }

  private refreshStyle(size: number, pos: number) {
    const {
      translateProperty: translatePropertyKey,
      size: sizeKey,
    } = this.keysMap
    const translateZ = this.scroll.options.translateZ

    this.indicatorEl.style[sizeKey] = `${size}px`

    this.indicatorEl.style[
      style.transform as any
    ] = `${translatePropertyKey}(${pos}px)${translateZ}`
  }

  startHandler() {
    this.moved = false
    this.startTime = getNow()
    this.transitionTime()
    this.scroll.scroller.hooks.trigger(
      this.scroll.scroller.hooks.eventTypes.beforeScrollStart
    )
  }

  moveHandler(delta: number) {
    if (!this.moved && !this.indicatorNotMoved(delta)) {
      this.moved = true
      this.scroll.scroller.hooks.trigger(
        this.scroll.scroller.hooks.eventTypes.scrollStart
      )
    }
    if (this.moved) {
      const newPos = this.newPos(this.currentPos, delta, this.scrollInfo)
      this.syncBScroll(newPos)
    }
  }

  endHandler() {
    if (this.moved) {
      const { x, y } = this.scroll
      this.scroll.scroller.hooks.trigger(
        this.scroll.scroller.hooks.eventTypes.scrollEnd,
        {
          x,
          y,
        }
      )
    }
  }

  private indicatorNotMoved(delta: number): boolean {
    const currentPos = this.currentPos
    const { maxScrollPos, minScrollPos } = this.scrollInfo
    const notMoved =
      (currentPos === minScrollPos && delta <= 0) ||
      (currentPos === maxScrollPos && delta >= 0)
    return notMoved
  }

  private syncBScroll(newPos: number) {
    const timestamp = getNow()
    const {
      x,
      y,
      options,
      scroller,
      maxScrollY,
      minScrollY,
      maxScrollX,
      minScrollX,
    } = this.scroll
    const { probeType, momentumLimitTime } = options
    const position = { x, y }
    if (this.direction === IndicatorDirection.Vertical) {
      position.y = between(newPos, maxScrollY, minScrollY)
    } else {
      position.x = between(newPos, maxScrollX, minScrollX)
    }
    scroller.translater.translate(position)

    // dispatch scroll in interval time
    if (timestamp - this.startTime > momentumLimitTime) {
      this.startTime = timestamp
      if (probeType === Probe.Throttle) {
        scroller.hooks.trigger(scroller.hooks.eventTypes.scroll, position)
      }
    }

    // dispatch scroll all the time
    if (probeType > Probe.Throttle) {
      scroller.hooks.trigger(scroller.hooks.eventTypes.scroll, position)
    }
  }

  private newPos(
    currentPos: number,
    delta: number,
    scrollInfo: ScrollInfo
  ): number {
    const { maxScrollPos, sizeRatio, minScrollPos } = scrollInfo
    let newPos = currentPos + delta

    newPos = between(newPos, minScrollPos, maxScrollPos)

    return Math.round(newPos / sizeRatio)
  }

  destroy() {
    const { interactive, scrollbarTrackClickable, isCustom } = this.options
    if (interactive) {
      this.eventHandler.destroy()
    }
    if (scrollbarTrackClickable) {
      this.clickEventRegister.destroy()
    }
    if (!isCustom) {
      this.wrapper.parentNode!.removeChild(this.wrapper)
    }

    this.hooksFn.forEach((item) => {
      const hooks = item[0]
      const hooksName = item[1]
      const handlerFn = item[2]
      hooks.off(hooksName, handlerFn)
    })
    this.hooksFn.length = 0
  }
}
