import { FunctionExt } from '../../util'
import { View } from '../../view/view'
import { Graph } from '../../graph/graph'
import { EventArgs } from '../../graph/events'
import { Point } from '../../geometry'

namespace ClassName {
  export const root = 'widget-minimap'
  export const viewport = `${root}-viewport`
  export const zoom = `${viewport}-zoom`
}

export class MiniMap extends View {
  public readonly options: MiniMap.Options
  public readonly container: HTMLDivElement
  public readonly $container: JQuery<HTMLElement>
  protected readonly zoomHandle: HTMLDivElement
  protected readonly $viewport: JQuery<HTMLElement>
  protected readonly sourceGraph: Graph
  protected readonly targetGraph: Graph
  protected geometry: Util.ViewGeometry
  protected ratio: number

  protected get graph() {
    return this.options.graph
  }

  protected get scroller() {
    return this.graph.scroller.widget
  }

  protected get graphContainer() {
    if (this.scroller) {
      return this.scroller.container
    }
    return this.graph.container
  }

  protected get $graphContainer() {
    if (this.scroller) {
      return this.scroller.$container
    }
    return this.$(this.graph.container)
  }

  constructor(options: Partial<MiniMap.Options> & { graph: Graph }) {
    super()

    this.options = {
      ...Util.defaultOptions,
      ...options,
    } as MiniMap.Options

    this.updateViewport = FunctionExt.debounce(
      this.updateViewport.bind(this),
      0,
    )

    this.container = document.createElement('div')
    this.$container = this.$(this.container).addClass(
      this.prefixClassName(ClassName.root),
    )

    const graphContainer = document.createElement('div')
    this.container.appendChild(graphContainer)

    this.$viewport = this.$('<div>').addClass(
      this.prefixClassName(ClassName.viewport),
    )

    if (this.options.scalable) {
      this.zoomHandle = this.$('<div>')
        .addClass(this.prefixClassName(ClassName.zoom))
        .appendTo(this.$viewport)
        .get(0)
    }

    this.$container.append(this.$viewport).css({
      width: this.options.width,
      height: this.options.height,
      padding: this.options.padding,
    })

    if (this.options.container) {
      this.options.container.appendChild(this.container)
    }

    this.sourceGraph = this.graph
    const targetGraphOptions: Graph.Options = {
      ...this.options.graphOptions,
      container: graphContainer,
      model: this.sourceGraph.model,
      frozen: true,
      async: this.sourceGraph.isAsync(),
      interacting: false,
      grid: false,
      background: false,
      rotating: false,
      resizing: false,
      embedding: false,
      selecting: false,
      snapline: false,
      clipboard: false,
      history: false,
      scroller: false,
    }

    this.targetGraph = this.options.createGraph
      ? this.options.createGraph(targetGraphOptions)
      : new Graph(targetGraphOptions)

    this.targetGraph.renderer.unfreeze()

    this.updatePaper(
      this.sourceGraph.options.width,
      this.sourceGraph.options.height,
    )

    this.startListening()
  }

  protected startListening() {
    if (this.scroller) {
      this.$graphContainer.on(
        `scroll${this.getEventNamespace()}`,
        this.updateViewport,
      )
    } else {
      this.sourceGraph.on('translate', this.updateViewport, this)
    }
    this.sourceGraph.on('resize', this.updatePaper, this)
    this.delegateEvents({
      mousedown: 'startAction',
      touchstart: 'startAction',
      [`mousedown .${this.prefixClassName('graph')}`]: 'scrollTo',
      [`touchstart .${this.prefixClassName('graph')}`]: 'scrollTo',
    })
  }

  protected stopListening() {
    if (this.scroller) {
      this.$graphContainer.off(this.getEventNamespace())
    } else {
      this.sourceGraph.off('translate', this.updateViewport, this)
    }
    this.sourceGraph.off('resize', this.updatePaper, this)
    this.undelegateEvents()
  }

  protected onRemove() {
    this.targetGraph.view.remove()
    this.stopListening()
    this.targetGraph.dispose()
  }

  protected updatePaper(width: number, height: number): this
  protected updatePaper({ width, height }: EventArgs['resize']): this
  protected updatePaper(w: number | EventArgs['resize'], h?: number) {
    let width: number
    let height: number
    if (typeof w === 'object') {
      width = w.width
      height = w.height
    } else {
      width = w
      height = h as number
    }

    const origin = this.sourceGraph.options
    const scale = this.sourceGraph.transform.getScale()
    const maxWidth = this.options.width - 2 * this.options.padding
    const maxHeight = this.options.height - 2 * this.options.padding

    width /= scale.sx // eslint-disable-line
    height /= scale.sy // eslint-disable-line

    this.ratio = Math.min(maxWidth / width, maxHeight / height)

    const ratio = this.ratio
    const x = (origin.x * ratio) / scale.sx
    const y = (origin.y * ratio) / scale.sy

    width *= ratio // eslint-disable-line
    height *= ratio // eslint-disable-line
    this.targetGraph.resizeGraph(width, height)
    this.targetGraph.translate(x, y)
    this.targetGraph.scale(ratio, ratio)
    this.updateViewport()
    return this
  }

  protected updateViewport() {
    const ratio = this.ratio
    const scale = this.sourceGraph.transform.getScale()

    let origin = null
    if (this.scroller) {
      origin = this.scroller.clientToLocalPoint(0, 0)
    } else {
      const ctm = this.sourceGraph.matrix()
      origin = new Point(-ctm.e / ctm.a, -ctm.f / ctm.d)
    }

    const position = this.$(this.targetGraph.container).position()
    const translation = this.targetGraph.translate()
    translation.ty = translation.ty || 0

    this.geometry = {
      top: position.top + origin.y * ratio + translation.ty,
      left: position.left + origin.x * ratio + translation.tx,
      width: (this.$graphContainer.innerWidth()! * ratio) / scale.sx,
      height: (this.$graphContainer.innerHeight()! * ratio) / scale.sy,
    }
    this.$viewport.css(this.geometry)
  }

  protected startAction(evt: JQuery.MouseDownEvent) {
    const e = this.normalizeEvent(evt)
    const action = e.target === this.zoomHandle ? 'zooming' : 'panning'
    const { tx, ty } = this.sourceGraph.translate()
    const eventData: Util.EventData = {
      action,
      clientX: e.clientX,
      clientY: e.clientY,
      scrollLeft: this.graphContainer.scrollLeft,
      scrollTop: this.graphContainer.scrollTop,
      zoom: this.sourceGraph.zoom(),
      scale: this.sourceGraph.transform.getScale(),
      geometry: this.geometry,
      translateX: tx,
      translateY: ty,
    }

    this.delegateDocumentEvents(Util.documentEvents, eventData)
  }

  protected doAction(evt: JQuery.MouseMoveEvent) {
    const e = this.normalizeEvent(evt)
    const clientX = e.clientX
    const clientY = e.clientY
    const data = e.data as Util.EventData
    switch (data.action) {
      case 'panning': {
        const scale = this.sourceGraph.transform.getScale()
        const rx = (clientX - data.clientX) * scale.sx
        const ry = (clientY - data.clientY) * scale.sy
        if (this.scroller) {
          this.graphContainer.scrollLeft = data.scrollLeft + rx / this.ratio
          this.graphContainer.scrollTop = data.scrollTop + ry / this.ratio
        } else {
          this.sourceGraph.translate(
            data.translateX - rx / this.ratio,
            data.translateY - ry / this.ratio,
          )
        }
        break
      }

      case 'zooming': {
        const startScale = data.scale
        const startGeometry = data.geometry
        const delta =
          1 + (data.clientX - clientX) / startGeometry.width / startScale.sx

        if (data.frameId) {
          cancelAnimationFrame(data.frameId)
        }

        data.frameId = requestAnimationFrame(() => {
          this.sourceGraph.zoom(delta * data.zoom, {
            absolute: true,
            minScale: this.options.minScale,
            maxScale: this.options.maxScale,
          })
        })
        break
      }

      default:
        break
    }
  }

  protected stopAction() {
    this.undelegateDocumentEvents()
  }

  protected scrollTo(evt: JQuery.MouseDownEvent) {
    const e = this.normalizeEvent(evt)

    let x
    let y

    const ts = this.targetGraph.translate()
    ts.ty = ts.ty || 0

    if (e.offsetX == null) {
      const offset = this.$(this.targetGraph.container).offset()!
      x = e.pageX - offset.left
      y = e.pageY - offset.top
    } else {
      x = e.offsetX
      y = e.offsetY
    }

    const cx = (x - ts.tx) / this.ratio
    const cy = (y - ts.ty) / this.ratio
    this.sourceGraph.centerPoint(cx, cy)
  }

  @View.dispose()
  dispose() {
    this.remove()
  }
}

export namespace MiniMap {
  export interface Options {
    graph: Graph
    container?: HTMLElement
    width: number
    height: number
    padding: number
    scalable?: boolean
    minScale?: number
    maxScale?: number
    createGraph?: (options: Graph.Options) => Graph
    graphOptions?: Graph.Options
  }
}

namespace Util {
  export const defaultOptions: Partial<MiniMap.Options> = {
    width: 300,
    height: 200,
    padding: 10,
    scalable: true,
    minScale: 0.01,
    maxScale: 16,
    graphOptions: {},
    createGraph: (options) => new Graph(options),
  }

  export const documentEvents = {
    mousemove: 'doAction',
    touchmove: 'doAction',
    mouseup: 'stopAction',
    touchend: 'stopAction',
  }

  export interface ViewGeometry extends JQuery.PlainObject<number> {
    top: number
    left: number
    width: number
    height: number
  }

  export interface EventData {
    frameId?: number
    action: 'zooming' | 'panning'
    clientX: number
    clientY: number
    scrollLeft: number
    scrollTop: number
    zoom: number
    scale: { sx: number; sy: number }
    geometry: ViewGeometry
    translateX: number
    translateY: number
  }
}
