import { alignPoint } from 'dom-align'
import { CssLoader, Dom, disposable, FunctionExt } from '../../common'
import { DocumentEvents } from '../../constants'
import {
  type Point,
  type PointLike,
  Rectangle,
  snapToGrid,
} from '../../geometry'
import {
  type EventArgs,
  Graph,
  type GraphPlugin,
  type Options,
} from '../../graph'
import type { CellBaseEventArgs, Node } from '../../model'
import { type NodeView, View } from '../../view'
import type { Scroller } from '../scroller'
import type { Snapline } from '../snapline'
import { content } from './style/raw'

export interface GetDragNodeOptions {
  sourceNode: Node
  targetGraph: Graph
  draggingGraph: Graph
}

export interface GetDropNodeOptions extends GetDragNodeOptions {
  draggingNode: Node
}

export interface ValidateNodeOptions extends GetDropNodeOptions {
  droppingNode: Node
}

export interface DndOptions {
  target: Graph
  /**
   * Should scale the dragging node or not.
   */
  scaled?: boolean
  delegateGraphOptions?: Options
  draggingContainer?: HTMLElement
  /**
   * dnd tool box container.
   */
  dndContainer?: HTMLElement
  getDragNode: (sourceNode: Node, options: GetDragNodeOptions) => Node
  getDropNode: (draggingNode: Node, options: GetDropNodeOptions) => Node
  validateNode?: (
    droppingNode: Node,
    options: ValidateNodeOptions,
  ) => boolean | Promise<boolean>
}

export const DndDefaults: Partial<DndOptions> = {
  // animation: false,
  getDragNode: (sourceNode) => sourceNode.clone(),
  getDropNode: (draggingNode) => draggingNode.clone(),
}

export class Dnd extends View implements GraphPlugin {
  public name = 'dnd'

  protected sourceNode: Node | null
  protected draggingNode: Node | null
  protected draggingView: NodeView | null
  protected draggingBBox: Rectangle
  protected geometryBBox: Rectangle
  protected candidateEmbedView: NodeView | null
  protected delta: Point | null
  protected padding: number | null
  protected snapOffset: PointLike | null

  public options: DndOptions
  public draggingGraph: Graph

  protected get targetScroller() {
    const target = this.options.target
    const scroller = target.getPlugin<Scroller>('scroller')
    return scroller
  }

  protected get targetGraph() {
    return this.options.target
  }

  protected get targetModel() {
    return this.targetGraph.model
  }

  protected get snapline() {
    const target = this.options.target
    const snapline = target.getPlugin<Snapline>('snapline')
    return snapline
  }

  constructor(options: Partial<DndOptions> & { target: Graph }) {
    super()
    this.options = {
      ...DndDefaults,
      ...options,
    } as DndOptions
    this.init()
  }

  init() {
    CssLoader.ensure(this.name, content)

    this.container = document.createElement('div')
    Dom.addClass(this.container, this.prefixClassName('widget-dnd'))

    this.draggingGraph = new Graph({
      ...this.options.delegateGraphOptions,
      container: document.createElement('div'),
      width: 1,
      height: 1,
      async: false,
    })

    Dom.append(this.container, this.draggingGraph.container)
  }

  start(node: Node, evt: Dom.MouseDownEvent | MouseEvent) {
    const e = evt as Dom.MouseDownEvent

    e.preventDefault()

    this.targetModel.startBatch('dnd')
    Dom.addClass(this.container, 'dragging')
    Dom.appendTo(
      this.container,
      this.options.draggingContainer || document.body,
    )

    this.sourceNode = node
    this.prepareDragging(node, e.clientX, e.clientY)

    const local = this.updateNodePosition(e.clientX, e.clientY)

    if (this.isSnaplineEnabled()) {
      this.snapline.captureCursorOffset({
        e,
        node,
        cell: node,
        view: this.draggingView,
        x: local.x,
        y: local.y,
      })
      this.draggingNode?.on('change:position', this.snap, this)
    }

    this.delegateDocumentEvents(DocumentEvents, e.data)
  }

  protected isSnaplineEnabled() {
    return this.snapline?.isEnabled()
  }

  protected prepareDragging(
    sourceNode: Node,
    clientX: number,
    clientY: number,
  ) {
    const draggingGraph = this.draggingGraph
    const draggingModel = draggingGraph.model
    const draggingNode = this.options.getDragNode(sourceNode, {
      sourceNode,
      draggingGraph,
      targetGraph: this.targetGraph,
    })

    draggingNode.position(0, 0)

    let padding = 5
    if (this.isSnaplineEnabled()) {
      padding += this.snapline.options.tolerance || 0
    }

    if (this.isSnaplineEnabled() || this.options.scaled) {
      const scale = this.targetGraph.transform.getScale()
      draggingGraph.scale(scale.sx, scale.sy)
      padding *= Math.max(scale.sx, scale.sy)
    } else {
      draggingGraph.scale(1, 1)
    }

    this.clearDragging()

    // if (this.options.animation) {
    //   this.$container.stop(true, true)
    // }

    draggingModel.resetCells([draggingNode])

    const delegateView = draggingGraph.findViewByCell(draggingNode) as NodeView
    delegateView.undelegateEvents()
    delegateView.cell.off('changed')
    draggingGraph.fitToContent({
      padding,
      allowNewOrigin: 'any',
      useCellGeometry: false,
    })

    const bbox = delegateView.getBBox()
    this.geometryBBox = delegateView.getBBox({ useCellGeometry: true })
    this.delta = this.geometryBBox.getTopLeft().diff(bbox.getTopLeft())
    this.draggingNode = draggingNode
    this.draggingView = delegateView
    this.draggingBBox = draggingNode.getBBox()
    this.padding = padding
    this.updateGraphPosition(clientX, clientY)
  }

  protected updateGraphPosition(clientX: number, clientY: number) {
    const delta = this.delta
    const nodeBBox = this.geometryBBox
    const padding = this.padding || 5
    const offset = {
      left: clientX - delta.x - nodeBBox.width / 2 - padding,
      top: clientY - delta.y - nodeBBox.height / 2 - padding,
    }

    if (this.draggingGraph) {
      alignPoint(
        this.container,
        {
          clientX: offset.left,
          clientY: offset.top,
        },
        {
          points: ['tl'],
        },
      )
    }
  }

  protected updateNodePosition(x: number, y: number) {
    const local = this.targetGraph.clientToLocal(x, y)
    const bbox = this.draggingBBox
    if (bbox) {
      local.x -= bbox.width / 2
      local.y -= bbox.height / 2
      this.draggingNode!.position(local.x, local.y)
    }
    return local
  }

  protected snap({
    cell,
    current,
    options,
  }: CellBaseEventArgs['change:position']) {
    const node = cell as Node
    if (options.snapped) {
      const bbox = this.draggingBBox
      node.position(bbox.x + options.tx, bbox.y + options.ty, { silent: true })
      this.draggingView!.translate()
      node.position(current!.x, current!.y, { silent: true })

      this.snapOffset = {
        x: options.tx,
        y: options.ty,
      }
    } else {
      this.snapOffset = null
    }
  }

  protected onMouseMove(evt: Dom.MouseMoveEvent) {
    this.onDragging(evt)
  }

  protected onMouseUp(evt: Dom.MouseUpEvent) {
    this.onDragEnd(evt)
  }

  protected onDragging(evt: Dom.MouseMoveEvent) {
    const draggingView = this.draggingView
    if (draggingView) {
      evt.preventDefault()
      const e = this.normalizeEvent(evt)
      const clientX = e.clientX
      const clientY = e.clientY

      this.updateGraphPosition(clientX, clientY)
      const local = this.updateNodePosition(clientX, clientY)
      const embeddingMode = this.targetGraph.options.embedding.enabled
      const isValidArea =
        (embeddingMode || this.isSnaplineEnabled()) &&
        this.isInsideValidArea({
          x: clientX,
          y: clientY,
        })

      if (embeddingMode) {
        draggingView.setEventData(e, {
          graph: this.targetGraph,
          candidateEmbedView: this.candidateEmbedView,
        })
        const data = draggingView.getEventData<any>(e)
        if (isValidArea) {
          draggingView.processEmbedding(e, data)
        } else {
          draggingView.clearEmbedding(data)
        }
        this.candidateEmbedView = data.candidateEmbedView
      }

      // update snapline
      if (this.isSnaplineEnabled()) {
        if (isValidArea) {
          this.snapline.snapOnMoving({
            e,
            view: draggingView!,
            x: local.x,
            y: local.y,
          } as EventArgs['node:mousemove'])
        } else {
          this.snapline.hide()
        }
      }
    }
  }

  protected onDragEnd(evt: Dom.MouseUpEvent) {
    const draggingNode = this.draggingNode
    if (draggingNode) {
      const e = this.normalizeEvent(evt)
      const draggingView = this.draggingView
      const draggingBBox = this.draggingBBox
      const snapOffset = this.snapOffset
      let x = draggingBBox.x
      let y = draggingBBox.y

      if (snapOffset) {
        x += snapOffset.x
        y += snapOffset.y
      }

      draggingNode.position(x, y, { silent: true })

      const ret = this.drop(draggingNode, { x: e.clientX, y: e.clientY })
      const callback = (node: null | Node) => {
        if (node) {
          this.onDropped(draggingNode)
          if (this.targetGraph.options.embedding.enabled && draggingView) {
            draggingView.setEventData(e, {
              cell: node,
              graph: this.targetGraph,
              candidateEmbedView: this.candidateEmbedView,
            })
            draggingView.finalizeEmbedding(e, draggingView.getEventData<any>(e))
          }
        } else {
          this.onDropInvalid()
        }

        this.candidateEmbedView = null
        this.targetModel.stopBatch('dnd')
      }

      if (FunctionExt.isAsync(ret)) {
        // stop dragging
        this.undelegateDocumentEvents()
        ret.then(callback) // eslint-disable-line
      } else {
        callback(ret)
      }
    }
  }

  protected clearDragging() {
    if (this.draggingNode) {
      this.sourceNode = null
      this.draggingNode.remove()
      this.draggingNode = null
      this.draggingView = null
      this.delta = null
      this.padding = null
      this.snapOffset = null
      this.undelegateDocumentEvents()
    }
  }

  protected onDropped(draggingNode: Node) {
    if (this.draggingNode === draggingNode) {
      this.clearDragging()
      Dom.removeClass(this.container, 'dragging')
      Dom.remove(this.container)
    }
  }

  protected onDropInvalid() {
    const draggingNode = this.draggingNode
    if (draggingNode) {
      this.onDropped(draggingNode)
      // todo
      // const anim = this.options.animation
      // if (anim) {
      //   const duration = (typeof anim === 'object' && anim.duration) || 150
      //   const easing = (typeof anim === 'object' && anim.easing) || 'swing'

      //   this.draggingView = null

      //   this.$container.animate(this.originOffset!, duration, easing, () =>
      //     this.onDropped(draggingNode),
      //   )
      // } else {
      //   this.onDropped(draggingNode)
      // }
    }
  }

  protected isInsideValidArea(p: PointLike) {
    let targetRect: Rectangle
    let dndRect: Rectangle | null = null
    const targetGraph = this.targetGraph
    const targetScroller = this.targetScroller

    if (this.options.dndContainer) {
      dndRect = this.getDropArea(this.options.dndContainer)
    }
    const isInsideDndRect = dndRect?.containsPoint(p)

    if (targetScroller) {
      if (targetScroller.options.autoResize) {
        targetRect = this.getDropArea(targetScroller.container)
      } else {
        const outter = this.getDropArea(targetScroller.container)
        targetRect = this.getDropArea(targetGraph.container).intersectsWithRect(
          outter,
        )!
      }
    } else {
      targetRect = this.getDropArea(targetGraph.container)
    }

    return !isInsideDndRect && targetRect && targetRect.containsPoint(p)
  }

  protected getDropArea(elem: Element) {
    const offset = Dom.offset(elem)
    const scrollTop =
      document.body.scrollTop || document.documentElement.scrollTop
    const scrollLeft =
      document.body.scrollLeft || document.documentElement.scrollLeft

    return Rectangle.create({
      x:
        offset.left +
        parseInt(Dom.css(elem, 'border-left-width')!, 10) -
        scrollLeft,
      y:
        offset.top +
        parseInt(Dom.css(elem, 'border-top-width'), 10) -
        scrollTop,
      width: elem.clientWidth,
      height: elem.clientHeight,
    })
  }

  protected drop(draggingNode: Node, pos: PointLike) {
    if (this.isInsideValidArea(pos)) {
      const targetGraph = this.targetGraph
      const targetModel = targetGraph.model
      const local = targetGraph.clientToLocal(pos)
      const sourceNode = this.sourceNode
      const droppingNode = this.options.getDropNode(draggingNode, {
        sourceNode,
        draggingNode,
        targetGraph: this.targetGraph,
        draggingGraph: this.draggingGraph,
      })
      const bbox = droppingNode.getBBox()
      local.x += bbox.x - bbox.width / 2
      local.y += bbox.y - bbox.height / 2
      const gridSize = this.snapOffset ? 1 : targetGraph.getGridSize()

      droppingNode.position(
        snapToGrid(local.x, gridSize),
        snapToGrid(local.y, gridSize),
      )

      droppingNode.removeZIndex()

      const validateNode = this.options.validateNode
      const ret = validateNode
        ? validateNode(droppingNode, {
            sourceNode,
            draggingNode,
            droppingNode,
            targetGraph,
            draggingGraph: this.draggingGraph,
          })
        : true

      if (typeof ret === 'boolean') {
        if (ret) {
          targetModel.addCell(droppingNode, { stencil: this.cid })
          return droppingNode
        }
        return null
      }

      return FunctionExt.toDeferredBoolean(ret).then((valid) => {
        if (valid) {
          targetModel.addCell(droppingNode, { stencil: this.cid })
          return droppingNode
        }
        return null
      })
    }

    return null
  }

  protected onRemove() {
    if (this.draggingGraph) {
      this.draggingGraph.view.remove()
      this.draggingGraph.dispose()
    }
  }

  @disposable()
  dispose() {
    this.remove()
    CssLoader.clean(this.name)
  }
}
