import { Util, Config } from '../global'
import { ArrayExt, FunctionExt, Dom, Vector } from '../util'
import { Rectangle, Point } from '../geometry'
import { Attr, PortLayout } from '../registry'
import { Cell } from '../model/cell'
import { Node } from '../model/node'
import { PortManager } from '../model/port'
import { Graph } from '../graph'
import { CellView } from './cell'
import { EdgeView } from './edge'
import { Markup } from './markup'
import { AttrManager } from './attr'

export class NodeView<
  Entity extends Node = Node,
  Options extends NodeView.Options = NodeView.Options,
> extends CellView<Entity, Options> {
  public scalableNode: Element | null = null
  public rotatableNode: Element | null = null
  protected readonly scalableSelector: string = 'scalable'
  protected readonly rotatableSelector: string = 'rotatable'
  protected readonly defaultPortMarkup = Markup.getPortMarkup()
  protected readonly defaultPortLabelMarkup = Markup.getPortLabelMarkup()
  protected readonly defaultPortContainerMarkup =
    Markup.getPortContainerMarkup()
  protected portsCache: { [id: string]: NodeView.PortCache } = {}

  protected get [Symbol.toStringTag]() {
    return NodeView.toStringTag
  }

  protected getContainerClassName() {
    const classList = [
      super.getContainerClassName(),
      this.prefixClassName('node'),
    ]
    if (!this.can('nodeMovable')) {
      classList.push(this.prefixClassName('node-immovable'))
    }
    return classList.join(' ')
  }

  protected updateClassName(e: JQuery.MouseEnterEvent) {
    const target = e.target
    if (target.hasAttribute('magnet')) {
      // port
      const className = this.prefixClassName('port-unconnectable')
      if (this.can('magnetConnectable')) {
        Dom.removeClass(target, className)
      } else {
        Dom.addClass(target, className)
      }
    } else {
      // node
      const className = this.prefixClassName('node-immovable')
      if (this.can('nodeMovable')) {
        this.removeClass(className)
      } else {
        this.addClass(className)
      }
    }
  }

  isNodeView(): this is NodeView {
    return true
  }

  confirmUpdate(flag: number, options: any = {}) {
    let ret = flag
    if (this.hasAction(ret, 'ports')) {
      this.removePorts()
      this.cleanPortsCache()
    }

    if (this.hasAction(ret, 'render')) {
      this.render()
      ret = this.removeAction(ret, [
        'render',
        'update',
        'resize',
        'translate',
        'rotate',
        'ports',
        'tools',
      ])
    } else {
      ret = this.handleAction(
        ret,
        'resize',
        () => this.resize(options),
        'update', // Resize method is calling `update()` internally
      )

      ret = this.handleAction(
        ret,
        'update',
        () => this.update(),
        // `update()` will render ports when useCSSSelectors are enabled
        Config.useCSSSelector ? 'ports' : null,
      )

      ret = this.handleAction(ret, 'translate', () => this.translate())
      ret = this.handleAction(ret, 'rotate', () => this.rotate())
      ret = this.handleAction(ret, 'ports', () => this.renderPorts())
      ret = this.handleAction(ret, 'tools', () => this.renderTools())
    }

    return ret
  }

  update(partialAttrs?: Attr.CellAttrs) {
    this.cleanCache()

    // When CSS selector strings are used, make sure no rule matches port nodes.
    if (Config.useCSSSelector) {
      this.removePorts()
    }

    const node = this.cell
    const size = node.getSize()
    const attrs = node.getAttrs()
    this.updateAttrs(this.container, attrs, {
      attrs: partialAttrs === attrs ? null : partialAttrs,
      rootBBox: new Rectangle(0, 0, size.width, size.height),
      selectors: this.selectors,
      scalableNode: this.scalableNode,
      rotatableNode: this.rotatableNode,
    })

    if (Config.useCSSSelector) {
      this.renderPorts()
    }
  }

  protected renderMarkup() {
    const markup = this.cell.markup
    if (markup) {
      if (typeof markup === 'string') {
        return this.renderStringMarkup(markup)
      }

      return this.renderJSONMarkup(markup)
    }

    throw new TypeError('Invalid node markup.')
  }

  protected renderJSONMarkup(markup: Markup.JSONMarkup | Markup.JSONMarkup[]) {
    const ret = this.parseJSONMarkup(markup, this.container)
    const one = (elems: Element | Element[] | null) =>
      Array.isArray(elems) ? elems[0] : elems
    this.selectors = ret.selectors
    this.rotatableNode = one(this.selectors[this.rotatableSelector])
    this.scalableNode = one(this.selectors[this.scalableSelector])
    this.container.appendChild(ret.fragment)
  }

  protected renderStringMarkup(markup: string) {
    Dom.append(this.container, Vector.toNodes(Vector.createVectors(markup)))
    this.rotatableNode = Dom.findOne(
      this.container,
      `.${this.rotatableSelector}`,
    )
    this.scalableNode = Dom.findOne(this.container, `.${this.scalableSelector}`)
    this.selectors = {}
    if (this.rootSelector) {
      this.selectors[this.rootSelector] = this.container
    }
  }

  render() {
    this.empty()
    this.renderMarkup()

    if (this.scalableNode) {
      // Double update is necessary for elements with the scalable group only
      // Note the `resize()` triggers the other `update`.
      this.update()
    }

    this.resize()

    if (this.rotatableNode) {
      this.rotate()
      this.translate()
    } else {
      this.updateTransform()
    }

    if (!Config.useCSSSelector) {
      this.renderPorts()
    }

    this.renderTools()

    return this
  }

  resize(opt: any = {}) {
    if (this.scalableNode) {
      return this.updateSize(opt)
    }

    if (this.cell.getAngle()) {
      this.rotate()
    }

    this.update()
  }

  translate() {
    if (this.rotatableNode) {
      return this.updateTranslation()
    }

    this.updateTransform()
  }

  rotate() {
    if (this.rotatableNode) {
      this.updateRotation()
      // It's necessary to call the update for the nodes outside
      // the rotatable group referencing nodes inside the group
      this.update()
      return
    }

    this.updateTransform()
  }

  protected getTranslationString() {
    const position = this.cell.getPosition()
    return `translate(${position.x},${position.y})`
  }

  protected getRotationString() {
    const angle = this.cell.getAngle()
    if (angle) {
      const size = this.cell.getSize()
      return `rotate(${angle},${size.width / 2},${size.height / 2})`
    }
  }

  protected updateTransform() {
    let transform = this.getTranslationString()
    const rot = this.getRotationString()
    if (rot) {
      transform += ` ${rot}`
    }
    this.container.setAttribute('transform', transform)
  }

  protected updateRotation() {
    if (this.rotatableNode != null) {
      const transform = this.getRotationString()
      if (transform != null) {
        this.rotatableNode.setAttribute('transform', transform)
      } else {
        this.rotatableNode.removeAttribute('transform')
      }
    }
  }

  protected updateTranslation() {
    this.container.setAttribute('transform', this.getTranslationString())
  }

  protected updateSize(opt: any = {}) {
    const cell = this.cell
    const size = cell.getSize()
    const angle = cell.getAngle()
    const scalableNode = this.scalableNode!

    // Getting scalable group's bbox.
    // Due to a bug in webkit's native SVG .getBBox implementation, the
    // bbox of groups with path children includes the paths' control points.
    // To work around the issue, we need to check whether there are any path
    // elements inside the scalable group.
    let recursive = false
    if (scalableNode.getElementsByTagName('path').length > 0) {
      // If scalable has at least one descendant that is a path, we need
      // toswitch to recursive bbox calculation. Otherwise, group bbox
      // calculation works and so we can use the (faster) native function.
      recursive = true
    }
    const scalableBBox = Dom.getBBox(scalableNode as SVGElement, { recursive })

    // Make sure `scalableBbox.width` and `scalableBbox.height` are not zero
    // which can happen if the element does not have any content.
    const sx = size.width / (scalableBBox.width || 1)
    const sy = size.height / (scalableBBox.height || 1)
    scalableNode.setAttribute('transform', `scale(${sx},${sy})`)

    // Now the interesting part. The goal is to be able to store the object geometry via just `x`, `y`, `angle`, `width` and `height`
    // Order of transformations is significant but we want to reconstruct the object always in the order:
    // resize(), rotate(), translate() no matter of how the object was transformed. For that to work,
    // we must adjust the `x` and `y` coordinates of the object whenever we resize it (because the origin of the
    // rotation changes). The new `x` and `y` coordinates are computed by canceling the previous rotation
    // around the center of the resized object (which is a different origin then the origin of the previous rotation)
    // and getting the top-left corner of the resulting object. Then we clean up the rotation back to what it originally was.

    // Cancel the rotation but now around a different origin, which is the center of the scaled object.
    const rotatableNode = this.rotatableNode
    if (rotatableNode != null) {
      const transform = rotatableNode.getAttribute('transform')
      if (transform) {
        rotatableNode.setAttribute(
          'transform',
          `${transform} rotate(${-angle},${size.width / 2},${size.height / 2})`,
        )
        const rotatableBBox = Dom.getBBox(scalableNode as SVGElement, {
          target: this.graph.view.stage,
        })

        // Store new x, y and perform rotate() again against the new rotation origin.
        cell.prop(
          'position',
          { x: rotatableBBox.x, y: rotatableBBox.y },
          { updated: true, ...opt },
        )
        this.translate()
        this.rotate()
      }
    }

    // Update must always be called on non-rotated element. Otherwise,
    // relative positioning would work with wrong (rotated) bounding boxes.
    this.update()
  }

  // #region ports

  findPortElem(portId?: string, selector?: string) {
    const cache = portId ? this.portsCache[portId] : null
    if (!cache) {
      return null
    }
    const portRoot = cache.portContentElement
    const portSelectors = cache.portContentSelectors || {}
    return this.findOne(selector, portRoot, portSelectors)
  }

  protected initializePorts() {
    this.cleanPortsCache()
  }

  protected refreshPorts() {
    this.removePorts()
    this.cleanPortsCache()
    this.renderPorts()
  }

  protected cleanPortsCache() {
    this.portsCache = {}
  }

  protected removePorts() {
    Object.keys(this.portsCache).forEach((portId) => {
      const cached = this.portsCache[portId]
      Dom.remove(cached.portElement)
    })
  }

  protected renderPorts() {
    const container = this.getPortsContainer()
    // References to rendered elements without z-index
    const references: Element[] = []
    container.childNodes.forEach((child) => {
      references.push(child as Element)
    })

    const portsGropsByZ = ArrayExt.groupBy(this.cell.getParsedPorts(), 'zIndex')
    const autoZIndexKey = 'auto'

    // render non-z first
    if (portsGropsByZ[autoZIndexKey]) {
      portsGropsByZ[autoZIndexKey].forEach((port) => {
        const portElement = this.getPortElement(port)
        container.append(portElement)
        references.push(portElement)
      })
    }

    Object.keys(portsGropsByZ).forEach((key) => {
      if (key !== autoZIndexKey) {
        const zIndex = parseInt(key, 10)
        this.appendPorts(portsGropsByZ[key], zIndex, references)
      }
    })

    this.updatePorts()
  }

  protected getPortsContainer() {
    return this.rotatableNode || this.container
  }

  protected appendPorts(
    ports: PortManager.Port[],
    zIndex: number,
    refs: Element[],
  ) {
    const elems = ports.map((p) => this.getPortElement(p))
    if (refs[zIndex] || zIndex < 0) {
      Dom.before(refs[Math.max(zIndex, 0)], elems)
    } else {
      Dom.append(this.getPortsContainer(), elems)
    }
  }

  protected getPortElement(port: PortManager.Port) {
    const cached = this.portsCache[port.id]
    if (cached) {
      return cached.portElement
    }
    return this.createPortElement(port)
  }

  protected createPortElement(port: PortManager.Port) {
    let renderResult = Markup.renderMarkup(this.getPortContainerMarkup())
    const portElement = renderResult.elem
    if (portElement == null) {
      throw new Error('Invalid port container markup.')
    }

    renderResult = Markup.renderMarkup(this.getPortMarkup(port))
    const portContentElement = renderResult.elem
    const portContentSelectors = renderResult.selectors

    if (portContentElement == null) {
      throw new Error('Invalid port markup.')
    }

    this.setAttrs(
      {
        port: port.id,
        'port-group': port.group,
      },
      portContentElement,
    )

    renderResult = Markup.renderMarkup(this.getPortLabelMarkup(port.label))
    const portLabelElement = renderResult.elem
    const portLabelSelectors = renderResult.selectors

    if (portLabelElement == null) {
      throw new Error('Invalid port label markup.')
    }

    let portSelectors: Markup.Selectors | undefined
    if (portContentSelectors && portLabelSelectors) {
      // eslint-disable-next-line
      for (const key in portLabelSelectors) {
        if (portContentSelectors[key] && key !== this.rootSelector) {
          throw new Error('Selectors within port must be unique.')
        }
      }
      portSelectors = {
        ...portContentSelectors,
        ...portLabelSelectors,
      }
    } else {
      portSelectors = portContentSelectors || portLabelSelectors
    }

    Dom.addClass(portElement, 'x6-port')
    Dom.addClass(portContentElement, 'x6-port-body')
    Dom.addClass(portLabelElement, 'x6-port-label')

    portElement.appendChild(portContentElement)
    portElement.appendChild(portLabelElement)

    this.portsCache[port.id] = {
      portElement,
      portSelectors,
      portLabelElement,
      portLabelSelectors,
      portContentElement,
      portContentSelectors,
    }

    this.graph.hook.onPortRendered({
      port,
      node: this.cell,
      container: portElement,
      selectors: portSelectors,
      labelContainer: portLabelElement,
      labelSelectors: portLabelSelectors,
      contentContainer: portContentElement,
      contentSelectors: portContentSelectors,
    })

    return portElement
  }

  protected updatePorts() {
    // Layout ports without group
    this.updatePortGroup()

    // Layout ports with explicit group
    const groups = this.cell.getParsedGroups()
    Object.keys(groups).forEach((groupName) => this.updatePortGroup(groupName))
  }

  protected updatePortGroup(groupName?: string) {
    const bbox = Rectangle.fromSize(this.cell.getSize())
    const metrics = this.cell.getPortsLayoutByGroup(groupName, bbox)

    for (let i = 0, n = metrics.length; i < n; i += 1) {
      const metric = metrics[i]
      const portId = metric.portId
      const cached = this.portsCache[portId] || {}
      const portLayout = metric.portLayout
      this.applyPortTransform(cached.portElement, portLayout)
      if (metric.portAttrs != null) {
        const options: Partial<AttrManager.UpdateOptions> = {
          selectors: cached.portSelectors || {},
        }

        if (metric.portSize) {
          options.rootBBox = Rectangle.fromSize(metric.portSize)
        }

        this.updateAttrs(cached.portElement, metric.portAttrs, options)
      }

      const labelLayout = metric.labelLayout
      if (labelLayout) {
        this.applyPortTransform(
          cached.portLabelElement,
          labelLayout,
          -(portLayout.angle || 0),
        )

        if (labelLayout.attrs) {
          const options: Partial<AttrManager.UpdateOptions> = {
            selectors: cached.portLabelSelectors || {},
          }

          if (metric.labelSize) {
            options.rootBBox = Rectangle.fromSize(metric.labelSize)
          }

          this.updateAttrs(cached.portLabelElement, labelLayout.attrs, options)
        }
      }
    }
  }

  protected applyPortTransform(
    element: Element,
    layout: PortLayout.Result,
    initialAngle = 0,
  ) {
    const angle = layout.angle
    const position = layout.position
    const matrix = Dom.createSVGMatrix()
      .rotate(initialAngle)
      .translate(position.x || 0, position.y || 0)
      .rotate(angle || 0)

    Dom.transform(element as SVGElement, matrix, { absolute: true })
  }

  protected getPortContainerMarkup() {
    return this.cell.getPortContainerMarkup() || this.defaultPortContainerMarkup
  }

  protected getPortMarkup(port: PortManager.Port) {
    return port.markup || this.cell.portMarkup || this.defaultPortMarkup
  }

  protected getPortLabelMarkup(label: PortManager.Label) {
    return (
      label.markup || this.cell.portLabelMarkup || this.defaultPortLabelMarkup
    )
  }

  // #endregion

  // #region events

  protected getEventArgs<E>(e: E): NodeView.MouseEventArgs<E>
  protected getEventArgs<E>(
    e: E,
    x: number,
    y: number,
  ): NodeView.PositionEventArgs<E>
  protected getEventArgs<E>(e: E, x?: number, y?: number) {
    const view = this // eslint-disable-line
    const node = view.cell
    const cell = node
    if (x == null || y == null) {
      return { e, view, node, cell } as NodeView.MouseEventArgs<E>
    }
    return { e, x, y, view, node, cell } as NodeView.PositionEventArgs<E>
  }

  notifyMouseDown(e: JQuery.MouseDownEvent, x: number, y: number) {
    super.onMouseDown(e, x, y)
    this.notify('node:mousedown', this.getEventArgs(e, x, y))
  }

  notifyMouseMove(e: JQuery.MouseMoveEvent, x: number, y: number) {
    super.onMouseMove(e, x, y)
    this.notify('node:mousemove', this.getEventArgs(e, x, y))
  }

  notifyMouseUp(e: JQuery.MouseUpEvent, x: number, y: number) {
    super.onMouseUp(e, x, y)
    this.notify('node:mouseup', this.getEventArgs(e, x, y))
  }

  onClick(e: JQuery.ClickEvent, x: number, y: number) {
    super.onClick(e, x, y)
    this.notify('node:click', this.getEventArgs(e, x, y))
  }

  onDblClick(e: JQuery.DoubleClickEvent, x: number, y: number) {
    super.onDblClick(e, x, y)
    this.notify('node:dblclick', this.getEventArgs(e, x, y))
  }

  onContextMenu(e: JQuery.ContextMenuEvent, x: number, y: number) {
    super.onContextMenu(e, x, y)
    this.notify('node:contextmenu', this.getEventArgs(e, x, y))
  }

  onMouseDown(e: JQuery.MouseDownEvent, x: number, y: number) {
    if (this.isPropagationStopped(e)) {
      return
    }
    this.notifyMouseDown(e, x, y)
    this.startNodeDragging(e, x, y)
  }

  onMouseMove(e: JQuery.MouseMoveEvent, x: number, y: number) {
    const data = this.getEventData<EventData.Mousemove>(e)
    const action = data.action
    if (action === 'magnet') {
      this.dragMagnet(e, x, y)
    } else {
      if (action === 'move') {
        const meta = data as EventData.Moving
        const view = meta.targetView || this
        view.dragNode(e, x, y)
        view.notify('node:moving', {
          e,
          x,
          y,
          view,
          cell: view.cell,
          node: view.cell,
        })
      }
      this.notifyMouseMove(e, x, y)
    }

    this.setEventData<EventData.Mousemove>(e, data)
  }

  onMouseUp(e: JQuery.MouseUpEvent, x: number, y: number) {
    const data = this.getEventData<EventData.Mousemove>(e)
    const action = data.action
    if (action === 'magnet') {
      this.stopMagnetDragging(e, x, y)
    } else {
      this.notifyMouseUp(e, x, y)
      if (action === 'move') {
        const meta = data as EventData.Moving
        const view = meta.targetView || this
        view.stopNodeDragging(e, x, y)
      }
    }

    const magnet = (data as EventData.Magnet).targetMagnet
    if (magnet) {
      this.onMagnetClick(e, magnet, x, y)
    }

    this.checkMouseleave(e)
  }

  onMouseOver(e: JQuery.MouseOverEvent) {
    super.onMouseOver(e)
    this.notify('node:mouseover', this.getEventArgs(e))
  }

  onMouseOut(e: JQuery.MouseOutEvent) {
    super.onMouseOut(e)
    this.notify('node:mouseout', this.getEventArgs(e))
  }

  onMouseEnter(e: JQuery.MouseEnterEvent) {
    this.updateClassName(e)
    super.onMouseEnter(e)
    this.notify('node:mouseenter', this.getEventArgs(e))
  }

  onMouseLeave(e: JQuery.MouseLeaveEvent) {
    super.onMouseLeave(e)
    this.notify('node:mouseleave', this.getEventArgs(e))
  }

  onMouseWheel(e: JQuery.TriggeredEvent, x: number, y: number, delta: number) {
    super.onMouseWheel(e, x, y, delta)
    this.notify('node:mousewheel', {
      delta,
      ...this.getEventArgs(e, x, y),
    })
  }

  onMagnetClick(e: JQuery.MouseUpEvent, magnet: Element, x: number, y: number) {
    const count = this.graph.view.getMouseMovedCount(e)
    if (count > this.graph.options.clickThreshold) {
      return
    }
    this.notify('node:magnet:click', {
      magnet,
      ...this.getEventArgs(e, x, y),
    })
  }

  onMagnetDblClick(
    e: JQuery.DoubleClickEvent,
    magnet: Element,
    x: number,
    y: number,
  ) {
    this.notify('node:magnet:dblclick', {
      magnet,
      ...this.getEventArgs(e, x, y),
    })
  }

  onMagnetContextMenu(
    e: JQuery.ContextMenuEvent,
    magnet: Element,
    x: number,
    y: number,
  ) {
    this.notify('node:magnet:contextmenu', {
      magnet,
      ...this.getEventArgs(e, x, y),
    })
  }

  onMagnetMouseDown(
    e: JQuery.MouseDownEvent,
    magnet: Element,
    x: number,
    y: number,
  ) {
    this.startMagnetDragging(e, x, y)
  }

  onCustomEvent(e: JQuery.MouseDownEvent, name: string, x: number, y: number) {
    this.notify('node:customevent', { name, ...this.getEventArgs(e, x, y) })
    super.onCustomEvent(e, name, x, y)
  }

  protected prepareEmbedding(e: JQuery.MouseMoveEvent) {
    // const cell = data.cell || this.cell
    // const graph = data.graph || this.graph
    // const model = graph.model

    // model.startBatch('to-front')

    // // Bring the model to the front with all his embeds.
    // cell.toFront({ deep: true, ui: true })

    // const maxZ = model
    //   .getNodes()
    //   .reduce((max, cell) => Math.max(max, cell.getZIndex() || 0), 0)

    // const connectedEdges = model.getConnectedEdges(cell, {
    //   deep: true,
    //   enclosed: true,
    // })

    // connectedEdges.forEach((edge) => {
    //   const zIndex = edge.getZIndex() || 0
    //   if (zIndex <= maxZ) {
    //     edge.setZIndex(maxZ + 1, { ui: true })
    //   }
    // })

    // model.stopBatch('to-front')

    // Before we start looking for suitable parent we remove the current one.
    // const parent = cell.getParent()
    // if (parent) {
    //   parent.unembed(cell, { ui: true })
    // }

    const data = this.getEventData<EventData.MovingTargetNode>(e)
    const node = data.cell || this.cell
    const view = this.graph.findViewByCell(node)
    const localPoint = this.graph.snapToGrid(e.clientX, e.clientY)

    this.notify('node:embed', {
      e,
      node,
      view,
      cell: node,
      x: localPoint.x,
      y: localPoint.y,
      currentParent: node.getParent(),
    })
  }

  processEmbedding(e: JQuery.MouseMoveEvent, data: EventData.MovingTargetNode) {
    const cell = data.cell || this.cell
    const graph = data.graph || this.graph
    const options = graph.options.embedding
    const findParent = options.findParent

    let candidates =
      typeof findParent === 'function'
        ? (
            FunctionExt.call(findParent, graph, {
              view: this,
              node: this.cell,
            }) as Cell[]
          ).filter((c) => {
            return (
              Cell.isCell(c) &&
              this.cell.id !== c.id &&
              !c.isDescendantOf(this.cell)
            )
          })
        : graph.model.getNodesUnderNode(cell, {
            by: findParent as Rectangle.KeyPoint,
          })

    // Picks the node with the highest `z` index
    if (options.frontOnly) {
      candidates = candidates.slice(-1)
    }

    let newCandidateView = null
    const prevCandidateView = data.candidateEmbedView
    const validateEmbeding = options.validate
    for (let i = candidates.length - 1; i >= 0; i -= 1) {
      const candidate = candidates[i]

      if (prevCandidateView && prevCandidateView.cell.id === candidate.id) {
        // candidate remains the same
        newCandidateView = prevCandidateView
        break
      } else {
        const view = candidate.findView(graph) as NodeView
        if (
          FunctionExt.call(validateEmbeding, graph, {
            child: this.cell,
            parent: view.cell,
            childView: this,
            parentView: view,
          })
        ) {
          // flip to the new candidate
          newCandidateView = view
          break
        }
      }
    }

    this.clearEmbedding(data)
    if (newCandidateView) {
      newCandidateView.highlight(null, { type: 'embedding' })
    }
    data.candidateEmbedView = newCandidateView

    const localPoint = graph.snapToGrid(e.clientX, e.clientY)
    this.notify('node:embedding', {
      e,
      cell,
      node: cell,
      view: graph.findViewByCell(cell),
      x: localPoint.x,
      y: localPoint.y,
      currentParent: cell.getParent(),
      candidateParent: newCandidateView ? newCandidateView.cell : null,
    })
  }

  clearEmbedding(data: EventData.MovingTargetNode) {
    const candidateView = data.candidateEmbedView
    if (candidateView) {
      candidateView.unhighlight(null, { type: 'embedding' })
      data.candidateEmbedView = null
    }
  }

  finalizeEmbedding(e: JQuery.MouseUpEvent, data: EventData.MovingTargetNode) {
    const cell = data.cell || this.cell
    const graph = data.graph || this.graph
    const view = graph.findViewByCell(cell)
    const parent = cell.getParent()
    const candidateView = data.candidateEmbedView
    if (candidateView) {
      // Candidate view is chosen to become the parent of the node.
      candidateView.unhighlight(null, { type: 'embedding' })
      data.candidateEmbedView = null
      if (parent == null || parent.id !== candidateView.cell.id) {
        candidateView.cell.insertChild(cell, undefined, { ui: true })
      }
    } else if (parent) {
      parent.unembed(cell, { ui: true })
    }

    graph.model.getConnectedEdges(cell, { deep: true }).forEach((edge) => {
      edge.updateParent({ ui: true })
    })

    const localPoint = graph.snapToGrid(e.clientX, e.clientY)

    if (view) {
      view.notify('node:embedded', {
        e,
        cell,
        x: localPoint.x,
        y: localPoint.y,
        node: cell,
        view: graph.findViewByCell(cell),
        previousParent: parent,
        currentParent: cell.getParent(),
      })
    }
  }

  getDelegatedView() {
    let cell = this.cell
    let view: NodeView = this // eslint-disable-line

    while (view) {
      if (cell.isEdge()) {
        break
      }
      if (!cell.hasParent() || view.can('stopDelegateOnDragging')) {
        return view
      }
      cell = cell.getParent() as Entity
      view = this.graph.renderer.findViewByCell(cell) as NodeView
    }

    return null
  }

  protected startMagnetDragging(
    e: JQuery.MouseDownEvent,
    x: number,
    y: number,
  ) {
    if (!this.can('magnetConnectable')) {
      return
    }

    e.stopPropagation()

    const magnet = e.currentTarget
    const graph = this.graph

    this.setEventData<Partial<EventData.Magnet>>(e, {
      targetMagnet: magnet,
    })

    if (graph.hook.validateMagnet(this, magnet, e)) {
      if (graph.options.magnetThreshold <= 0) {
        this.startConnectting(e, magnet, x, y)
      }

      this.setEventData<Partial<EventData.Magnet>>(e, {
        action: 'magnet',
      })
      this.stopPropagation(e)
    } else {
      this.onMouseDown(e, x, y)
    }

    graph.view.delegateDragEvents(e, this)
  }

  protected startConnectting(
    e: JQuery.MouseDownEvent,
    magnet: Element,
    x: number,
    y: number,
  ) {
    this.graph.model.startBatch('add-edge')
    const edgeView = this.createEdgeFromMagnet(magnet, x, y)
    edgeView.notifyMouseDown(e, x, y) // backwards compatibility events
    edgeView.setEventData(
      e,
      edgeView.prepareArrowheadDragging('target', {
        x,
        y,
        isNewEdge: true,
        fallbackAction: 'remove',
      }),
    )
    this.setEventData<Partial<EventData.Magnet>>(e, { edgeView })
  }

  protected createEdgeFromMagnet(magnet: Element, x: number, y: number) {
    const graph = this.graph
    const model = graph.model
    const edge = graph.hook.getDefaultEdge(this, magnet)

    edge.setSource({
      ...edge.getSource(),
      ...this.getEdgeTerminal(magnet, x, y, edge, 'source'),
    })
    edge.setTarget({ ...edge.getTarget(), x, y })
    edge.addTo(model, { async: false, ui: true })

    return edge.findView(graph) as EdgeView
  }

  protected dragMagnet(e: JQuery.MouseMoveEvent, x: number, y: number) {
    const data = this.getEventData<EventData.Magnet>(e)
    const edgeView = data.edgeView
    if (edgeView) {
      edgeView.onMouseMove(e, x, y)
      this.autoScrollGraph(e.clientX, e.clientY)
    } else {
      const graph = this.graph
      const magnetThreshold = graph.options.magnetThreshold as any
      const currentTarget = this.getEventTarget(e)
      const targetMagnet = data.targetMagnet

      // magnetThreshold when the pointer leaves the magnet
      if (magnetThreshold === 'onleave') {
        if (
          targetMagnet === currentTarget ||
          targetMagnet.contains(currentTarget)
        ) {
          return
        }
        // eslint-disable-next-line no-lonely-if
      } else {
        // magnetThreshold defined as a number of movements
        if (graph.view.getMouseMovedCount(e) <= magnetThreshold) {
          return
        }
      }
      this.startConnectting(e as any, targetMagnet, x, y)
    }
  }

  protected stopMagnetDragging(e: JQuery.MouseUpEvent, x: number, y: number) {
    const data = this.eventData<EventData.Magnet>(e)
    const edgeView = data.edgeView
    if (edgeView) {
      edgeView.onMouseUp(e, x, y)
      this.graph.model.stopBatch('add-edge')
    }
  }

  protected notifyUnhandledMouseDown(
    e: JQuery.MouseDownEvent,
    x: number,
    y: number,
  ) {
    this.notify('node:unhandled:mousedown', {
      e,
      x,
      y,
      view: this,
      cell: this.cell,
      node: this.cell,
    })
  }

  protected notifyNodeMove<Key extends keyof NodeView.EventArgs>(
    name: Key,
    e: JQuery.MouseMoveEvent | JQuery.MouseUpEvent,
    x: number,
    y: number,
    cell: Cell,
  ) {
    let cells = [cell]

    const selection = this.graph.selection.widget
    if (selection && selection.options.movable) {
      const selectedCells = this.graph.getSelectedCells()
      if (selectedCells.includes(cell)) {
        cells = selectedCells.filter((c: Cell) => c.isNode())
      }
    }

    cells.forEach((c: Cell) => {
      this.notify(name, {
        e,
        x,
        y,
        cell: c,
        node: c,
        view: c.findView(this.graph),
      })
    })
  }

  protected startNodeDragging(e: JQuery.MouseDownEvent, x: number, y: number) {
    const targetView = this.getDelegatedView()
    if (targetView == null || !targetView.can('nodeMovable')) {
      return this.notifyUnhandledMouseDown(e, x, y)
    }

    this.setEventData<EventData.Moving>(e, {
      targetView,
      action: 'move',
    })

    const position = Point.create(targetView.cell.getPosition())
    targetView.setEventData<EventData.MovingTargetNode>(e, {
      moving: false,
      offset: position.diff(x, y),
      restrict: this.graph.hook.getRestrictArea(targetView),
    })
  }

  protected dragNode(e: JQuery.MouseMoveEvent, x: number, y: number) {
    const node = this.cell
    const graph = this.graph
    const gridSize = graph.getGridSize()
    const data = this.getEventData<EventData.MovingTargetNode>(e)
    const offset = data.offset
    const restrict = data.restrict

    if (!data.moving) {
      data.moving = true
      this.addClass('node-moving')
      this.notifyNodeMove('node:move', e, x, y, this.cell)
    }

    this.autoScrollGraph(e.clientX, e.clientY)

    const posX = Util.snapToGrid(x + offset.x, gridSize)
    const posY = Util.snapToGrid(y + offset.y, gridSize)
    node.setPosition(posX, posY, {
      restrict,
      deep: true,
      ui: true,
    })

    if (graph.options.embedding.enabled) {
      if (!data.embedding) {
        this.prepareEmbedding(e)
        data.embedding = true
      }
      this.processEmbedding(e, data)
    }
  }

  protected stopNodeDragging(e: JQuery.MouseUpEvent, x: number, y: number) {
    const data = this.getEventData<EventData.MovingTargetNode>(e)
    if (data.embedding) {
      this.finalizeEmbedding(e, data)
    }

    if (data.moving) {
      this.removeClass('node-moving')
      this.notifyNodeMove('node:moved', e, x, y, this.cell)
    }

    data.moving = false
    data.embedding = false
  }

  protected autoScrollGraph(x: number, y: number) {
    const scroller = this.graph.scroller.widget
    if (scroller) {
      scroller.autoScroll(x, y)
    }
  }

  // #endregion
}

export namespace NodeView {
  export interface Options extends CellView.Options {}

  export interface PortCache {
    portElement: Element
    portSelectors?: Markup.Selectors | null
    portLabelElement: Element
    portLabelSelectors?: Markup.Selectors | null
    portContentElement: Element
    portContentSelectors?: Markup.Selectors | null
  }
}

export namespace NodeView {
  interface MagnetEventArgs {
    magnet: Element
  }

  export interface MouseEventArgs<E> {
    e: E
    node: Node
    cell: Node
    view: NodeView
  }

  export interface PositionEventArgs<E>
    extends MouseEventArgs<E>,
      CellView.PositionEventArgs {}

  export interface TranslateEventArgs<E> extends PositionEventArgs<E> {}

  export interface ResizeEventArgs<E> extends PositionEventArgs<E> {}

  export interface RotateEventArgs<E> extends PositionEventArgs<E> {}

  export interface EventArgs {
    'node:click': PositionEventArgs<JQuery.ClickEvent>
    'node:dblclick': PositionEventArgs<JQuery.DoubleClickEvent>
    'node:contextmenu': PositionEventArgs<JQuery.ContextMenuEvent>
    'node:mousedown': PositionEventArgs<JQuery.MouseDownEvent>
    'node:mousemove': PositionEventArgs<JQuery.MouseMoveEvent>
    'node:mouseup': PositionEventArgs<JQuery.MouseUpEvent>
    'node:mouseover': MouseEventArgs<JQuery.MouseOverEvent>
    'node:mouseout': MouseEventArgs<JQuery.MouseOutEvent>
    'node:mouseenter': MouseEventArgs<JQuery.MouseEnterEvent>
    'node:mouseleave': MouseEventArgs<JQuery.MouseLeaveEvent>
    'node:mousewheel': PositionEventArgs<JQuery.TriggeredEvent> &
      CellView.MouseDeltaEventArgs

    'node:customevent': PositionEventArgs<JQuery.MouseDownEvent> & {
      name: string
    }

    'node:unhandled:mousedown': PositionEventArgs<JQuery.MouseDownEvent>

    'node:highlight': {
      magnet: Element
      view: NodeView
      node: Node
      cell: Node
      options: CellView.HighlightOptions
    }
    'node:unhighlight': EventArgs['node:highlight']

    'node:magnet:click': PositionEventArgs<JQuery.MouseUpEvent> &
      MagnetEventArgs
    'node:magnet:dblclick': PositionEventArgs<JQuery.DoubleClickEvent> &
      MagnetEventArgs
    'node:magnet:contextmenu': PositionEventArgs<JQuery.ContextMenuEvent> &
      MagnetEventArgs

    'node:move': TranslateEventArgs<JQuery.MouseMoveEvent>
    'node:moving': TranslateEventArgs<JQuery.MouseMoveEvent>
    'node:moved': TranslateEventArgs<JQuery.MouseUpEvent>

    'node:resize': ResizeEventArgs<JQuery.MouseDownEvent>
    'node:resizing': ResizeEventArgs<JQuery.MouseMoveEvent>
    'node:resized': ResizeEventArgs<JQuery.MouseUpEvent>

    'node:rotate': RotateEventArgs<JQuery.MouseDownEvent>
    'node:rotating': RotateEventArgs<JQuery.MouseMoveEvent>
    'node:rotated': RotateEventArgs<JQuery.MouseUpEvent>

    'node:embed': PositionEventArgs<JQuery.MouseMoveEvent> & {
      currentParent: Node | null
    }
    'node:embedding': PositionEventArgs<JQuery.MouseMoveEvent> & {
      currentParent: Node | null
      candidateParent: Node | null
    }
    'node:embedded': PositionEventArgs<JQuery.MouseUpEvent> & {
      currentParent: Node | null
      previousParent: Node | null
    }
  }
}

export namespace NodeView {
  export const toStringTag = `X6.${NodeView.name}`

  export function isNodeView(instance: any): instance is NodeView {
    if (instance == null) {
      return false
    }

    if (instance instanceof NodeView) {
      return true
    }

    const tag = instance[Symbol.toStringTag]
    const view = instance as NodeView

    if (
      (tag == null || tag === toStringTag) &&
      typeof view.isNodeView === 'function' &&
      typeof view.isEdgeView === 'function' &&
      typeof view.confirmUpdate === 'function' &&
      typeof view.update === 'function' &&
      typeof view.findPortElem === 'function' &&
      typeof view.resize === 'function' &&
      typeof view.rotate === 'function' &&
      typeof view.translate === 'function'
    ) {
      return true
    }

    return false
  }
}

namespace EventData {
  export type Mousemove = Moving | Magnet

  export interface Magnet {
    action: 'magnet'
    targetMagnet: Element
    edgeView?: EdgeView
  }

  export interface Moving {
    action: 'move'
    targetView: NodeView
  }

  export interface MovingTargetNode {
    moving: boolean
    offset: Point.PointLike
    restrict?: Rectangle.RectangleLike | null
    embedding?: boolean
    candidateEmbedView?: NodeView | null
    cell?: Node
    graph?: Graph
  }
}

NodeView.config({
  isSvgElement: true,
  priority: 0,
  bootstrap: ['render'],
  actions: {
    view: ['render'],
    markup: ['render'],
    attrs: ['update'],
    size: ['resize', 'ports', 'tools'],
    angle: ['rotate', 'tools'],
    position: ['translate', 'tools'],
    ports: ['ports'],
    tools: ['tools'],
  },
})

NodeView.registry.register('node', NodeView, true)
