import { DeepPartial, Omit } from 'utility-types'
import { Size, KeyValue } from '../types'
import { Registry } from '../registry'
import { Point, Rectangle, Angle } from '../geometry'
import { StringExt, ObjectExt, NumberExt } from '../util'
import { Markup } from '../view/markup'
import { Cell } from './cell'
import { Edge } from './edge'
import { Store } from './store'
import { ShareRegistry } from './registry'
import { PortManager } from './port'
import { Animation } from './animation'
import { Interp } from '../common'

export class Node<
  Properties extends Node.Properties = Node.Properties,
> extends Cell<Properties> {
  protected static defaults: Node.Defaults = {
    angle: 0,
    position: { x: 0, y: 0 },
    size: { width: 1, height: 1 },
  }
  protected readonly store: Store<Node.Properties>
  protected port: PortManager

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

  constructor(metadata: Node.Metadata = {}) {
    super(metadata)
    this.initPorts()
  }

  protected preprocess(
    metadata: Node.Metadata,
    ignoreIdCheck?: boolean,
  ): Properties {
    const { x, y, width, height, ...others } = metadata

    if (x != null || y != null) {
      const position = others.position
      others.position = {
        ...position,
        x: x != null ? x : position ? position.x : 0,
        y: y != null ? y : position ? position.y : 0,
      }
    }

    if (width != null || height != null) {
      const size = others.size
      others.size = {
        ...size,
        width: width != null ? width : size ? size.width : 0,
        height: height != null ? height : size ? size.height : 0,
      }
    }

    return super.preprocess(others, ignoreIdCheck)
  }

  isNode(): this is Node {
    return true
  }

  // #region size

  size(): Size
  size(size: Size, options?: Node.ResizeOptions): this
  size(width: number, height: number, options?: Node.ResizeOptions): this
  size(
    width?: number | Size,
    height?: number | Node.ResizeOptions,
    options?: Node.ResizeOptions,
  ) {
    if (width === undefined) {
      return this.getSize()
    }

    if (typeof width === 'number') {
      return this.setSize(width, height as number, options)
    }

    return this.setSize(width, height as Node.ResizeOptions)
  }

  getSize() {
    const size = this.store.get('size')
    return size ? { ...size } : { width: 1, height: 1 }
  }

  setSize(size: Size, options?: Node.ResizeOptions): this
  setSize(width: number, height: number, options?: Node.ResizeOptions): this
  setSize(
    width: number | Size,
    height?: number | Node.ResizeOptions,
    options?: Node.ResizeOptions,
  ) {
    if (typeof width === 'object') {
      this.resize(width.width, width.height, height as Node.ResizeOptions)
    } else {
      this.resize(width, height as number, options)
    }

    return this
  }

  resize(width: number, height: number, options: Node.ResizeOptions = {}) {
    this.startBatch('resize', options)
    const direction = options.direction

    if (direction) {
      const currentSize = this.getSize()
      switch (direction) {
        case 'left':
        case 'right':
          // Don't change height when resizing horizontally.
          height = currentSize.height // eslint-disable-line
          break
        case 'top':
        case 'bottom':
          // Don't change width when resizing vertically.
          width = currentSize.width // eslint-disable-line
          break
        default:
          break
      }

      const map: { [direction: string]: number } = {
        right: 0,
        'top-right': 0,
        top: 1,
        'top-left': 1,
        left: 2,
        'bottom-left': 2,
        bottom: 3,
        'bottom-right': 3,
      }

      let quadrant = map[direction]
      const angle = Angle.normalize(this.getAngle() || 0)
      if (options.absolute) {
        // We are taking the node's rotation into account
        quadrant += Math.floor((angle + 45) / 90)
        quadrant %= 4
      }

      // This is a rectangle in size of the un-rotated node.
      const bbox = this.getBBox()

      // Pick the corner point on the node, which meant to stay on its
      // place before and after the rotation.
      let fixedPoint: Point
      if (quadrant === 0) {
        fixedPoint = bbox.getBottomLeft()
      } else if (quadrant === 1) {
        fixedPoint = bbox.getCorner()
      } else if (quadrant === 2) {
        fixedPoint = bbox.getTopRight()
      } else {
        fixedPoint = bbox.getOrigin()
      }

      // Find an image of the previous indent point. This is the position,
      // where is the point actually located on the screen.
      const imageFixedPoint = fixedPoint
        .clone()
        .rotate(-angle, bbox.getCenter())

      // Every point on the element rotates around a circle with the centre of
      // rotation in the middle of the element while the whole element is being
      // rotated. That means that the distance from a point in the corner of
      // the element (supposed its always rect) to the center of the element
      // doesn't change during the rotation and therefore it equals to a
      // distance on un-rotated element.
      // We can find the distance as DISTANCE = (ELEMENTWIDTH/2)^2 + (ELEMENTHEIGHT/2)^2)^0.5.
      const radius = Math.sqrt(width * width + height * height) / 2

      // Now we are looking for an angle between x-axis and the line starting
      // at image of fixed point and ending at the center of the element.
      // We call this angle `alpha`.

      // The image of a fixed point is located in n-th quadrant. For each
      // quadrant passed going anti-clockwise we have to add 90 degrees.
      // Note that the first quadrant has index 0.
      //
      // 3 | 2
      // --c-- Quadrant positions around the element's center `c`
      // 0 | 1
      //
      let alpha = (quadrant * Math.PI) / 2

      // Add an angle between the beginning of the current quadrant (line
      // parallel with x-axis or y-axis going through the center of the
      // element) and line crossing the indent of the fixed point and the
      // center of the element. This is the angle we need but on the
      // un-rotated element.
      alpha += Math.atan(quadrant % 2 === 0 ? height / width : width / height)

      // Lastly we have to deduct the original angle the element was rotated
      // by and that's it.
      alpha -= Angle.toRad(angle)

      // With this angle and distance we can easily calculate the centre of
      // the un-rotated element.
      // Note that fromPolar constructor accepts an angle in radians.
      const center = Point.fromPolar(radius, alpha, imageFixedPoint)

      // The top left corner on the un-rotated element has to be half a width
      // on the left and half a height to the top from the center. This will
      // be the origin of rectangle we were looking for.
      const origin = center.clone().translate(width / -2, height / -2)

      this.store.set('size', { width, height }, options)
      this.setPosition(origin.x, origin.y, options)
    } else {
      this.store.set('size', { width, height }, options)
    }

    this.stopBatch('resize', options)

    return this
  }

  scale(
    sx: number,
    sy: number,
    origin?: Point.PointLike | null,
    options: Node.SetOptions = {},
  ) {
    const scaledBBox = this.getBBox().scale(
      sx,
      sy,
      origin == null ? undefined : origin,
    )

    this.startBatch('scale', options)
    this.setPosition(scaledBBox.x, scaledBBox.y, options)
    this.resize(scaledBBox.width, scaledBBox.height, options)
    this.stopBatch('scale')
    return this
  }

  // #endregion

  // #region position

  position(x: number, y: number, options?: Node.SetPositionOptions): this
  position(options?: Node.GetPositionOptions): Point.PointLike
  position(
    arg0?: number | Node.GetPositionOptions,
    arg1?: number,
    arg2?: Node.SetPositionOptions,
  ) {
    if (typeof arg0 === 'number') {
      return this.setPosition(arg0, arg1 as number, arg2)
    }
    return this.getPosition(arg0)
  }

  getPosition(options: Node.GetPositionOptions = {}): Point.PointLike {
    if (options.relative) {
      const parent = this.getParent()
      if (parent != null && parent.isNode()) {
        const currentPosition = this.getPosition()
        const parentPosition = parent.getPosition()

        return {
          x: currentPosition.x - parentPosition.x,
          y: currentPosition.y - parentPosition.y,
        }
      }
    }

    const pos = this.store.get('position')
    return pos ? { ...pos } : { x: 0, y: 0 }
  }

  setPosition(
    p: Point | Point.PointLike,
    options?: Node.SetPositionOptions,
  ): this
  setPosition(x: number, y: number, options?: Node.SetPositionOptions): this
  setPosition(
    arg0: number | Point | Point.PointLike,
    arg1?: number | Node.SetPositionOptions,
    arg2: Node.SetPositionOptions = {},
  ) {
    let x: number
    let y: number
    let options: Node.SetPositionOptions

    if (typeof arg0 === 'object') {
      x = arg0.x
      y = arg0.y
      options = (arg1 as Node.SetPositionOptions) || {}
    } else {
      x = arg0
      y = arg1 as number
      options = arg2 || {}
    }

    if (options.relative) {
      const parent = this.getParent() as Node
      if (parent != null && parent.isNode()) {
        const parentPosition = parent.getPosition()
        x += parentPosition.x
        y += parentPosition.y
      }
    }

    if (options.deep) {
      const currentPosition = this.getPosition()
      this.translate(x - currentPosition.x, y - currentPosition.y, options)
    } else {
      this.store.set('position', { x, y }, options)
    }

    return this
  }

  translate(tx = 0, ty = 0, options: Node.TranslateOptions = {}) {
    if (tx === 0 && ty === 0) {
      return this
    }

    // Pass the initiator of the translation.
    options.translateBy = options.translateBy || this.id

    const position = this.getPosition()

    if (options.restrict != null && options.translateBy === this.id) {
      // We are restricting the translation for the element itself only. We get
      // the bounding box of the element including all its embeds.
      // All embeds have to be translated the exact same way as the element.
      const bbox = this.getBBox({ deep: true })
      const ra = options.restrict
      // - - - - - - - - - - - - -> ra.x + ra.width
      // - - - -> position.x      |
      // -> bbox.x
      //                ▓▓▓▓▓▓▓   |
      //         ░░░░░░░▓▓▓▓▓▓▓
      //         ░░░░░░░░░        |
      //   ▓▓▓▓▓▓▓▓░░░░░░░
      //   ▓▓▓▓▓▓▓▓               |
      //   <-dx->                     | restricted area right border
      //         <-width->        |   ░ translated element
      //   <- - bbox.width - ->       ▓ embedded element
      const dx = position.x - bbox.x
      const dy = position.y - bbox.y
      // Find the maximal/minimal coordinates that the element can be translated
      // while complies the restrictions.
      const x = Math.max(
        ra.x + dx,
        Math.min(ra.x + ra.width + dx - bbox.width, position.x + tx),
      )
      const y = Math.max(
        ra.y + dy,
        Math.min(ra.y + ra.height + dy - bbox.height, position.y + ty),
      )

      // recalculate the translation taking the restrictions into account.
      tx = x - position.x // eslint-disable-line
      ty = y - position.y // eslint-disable-line
    }

    const translatedPosition = {
      x: position.x + tx,
      y: position.y + ty,
    }

    // To find out by how much an element was translated in event
    // 'change:position' handlers.
    options.tx = tx
    options.ty = ty

    if (options.transition) {
      if (typeof options.transition !== 'object') {
        options.transition = {}
      }

      this.transition('position', translatedPosition, {
        ...options.transition,
        interp: Interp.object,
      })
      this.eachChild((child) => {
        const excluded = options.exclude?.includes(child)
        if (!excluded) {
          child.translate(tx, ty, options)
        }
      })
    } else {
      this.startBatch('translate', options)
      this.store.set('position', translatedPosition, options)
      this.eachChild((child) => {
        const excluded = options.exclude?.includes(child)
        if (!excluded) {
          child.translate(tx, ty, options)
        }
      })
      this.stopBatch('translate', options)
    }

    return this
  }

  // #endregion

  // #region angle

  angle(): number
  angle(val: number, options?: Node.RotateOptions): this
  angle(val?: number, options?: Node.RotateOptions) {
    if (val == null) {
      return this.getAngle()
    }
    return this.rotate(val, options)
  }

  getAngle() {
    return this.store.get('angle', 0)
  }

  rotate(angle: number, options: Node.RotateOptions = {}) {
    const currentAngle = this.getAngle()
    if (options.center) {
      const size = this.getSize()
      const position = this.getPosition()
      const center = this.getBBox().getCenter()
      center.rotate(currentAngle - angle, options.center)
      const dx = center.x - size.width / 2 - position.x
      const dy = center.y - size.height / 2 - position.y
      this.startBatch('rotate', { angle, options })
      this.setPosition(position.x + dx, position.y + dy, options)
      this.rotate(angle, { ...options, center: null })
      this.stopBatch('rotate')
    } else {
      this.store.set(
        'angle',
        options.absolute ? angle : (currentAngle + angle) % 360,
        options,
      )
    }

    return this
  }

  // #endregion

  // #region common

  getBBox(options: { deep?: boolean } = {}) {
    if (options.deep) {
      const cells = this.getDescendants({ deep: true, breadthFirst: true })
      cells.push(this)
      return Cell.getCellsBBox(cells)!
    }

    return Rectangle.fromPositionAndSize(this.getPosition(), this.getSize())
  }

  getConnectionPoint(edge: Edge, type: Edge.TerminalType) {
    const bbox = this.getBBox()
    const center = bbox.getCenter()
    const terminal = edge.getTerminal(type) as Edge.TerminalCellData
    if (terminal == null) {
      return center
    }

    const portId = terminal.port
    if (!portId || !this.hasPort(portId)) {
      return center
    }

    const port = this.getPort(portId)
    if (!port || !port.group) {
      return center
    }

    const layouts = this.getPortsPosition(port.group)
    const position = layouts[portId].position
    const portCenter = Point.create(position).translate(bbox.getOrigin())

    const angle = this.getAngle()
    if (angle) {
      portCenter.rotate(-angle, center)
    }

    return portCenter
  }

  /**
   * Sets cell's size and position based on the children bbox and given padding.
   */
  fit(options: Node.FitEmbedsOptions = {}) {
    const children = this.getChildren() || []
    const embeds = children.filter((cell) => cell.isNode()) as Node[]
    if (embeds.length === 0) {
      return this
    }

    this.startBatch('fit-embeds', options)

    if (options.deep) {
      embeds.forEach((cell) => cell.fit(options))
    }

    let { x, y, width, height } = Cell.getCellsBBox(embeds)!
    const padding = NumberExt.normalizeSides(options.padding)

    x -= padding.left
    y -= padding.top
    width += padding.left + padding.right
    height += padding.bottom + padding.top

    this.store.set(
      {
        position: { x, y },
        size: { width, height },
      },
      options,
    )

    this.stopBatch('fit-embeds')

    return this
  }

  // #endregion

  // #region ports

  get portContainerMarkup() {
    return this.getPortContainerMarkup()
  }

  set portContainerMarkup(markup: Markup) {
    this.setPortContainerMarkup(markup)
  }

  getDefaultPortContainerMarkup() {
    return (
      this.store.get('defaultPortContainerMarkup') ||
      Markup.getPortContainerMarkup()
    )
  }

  getPortContainerMarkup() {
    return (
      this.store.get('portContainerMarkup') ||
      this.getDefaultPortContainerMarkup()
    )
  }

  setPortContainerMarkup(markup?: Markup, options: Node.SetOptions = {}) {
    this.store.set('portContainerMarkup', Markup.clone(markup), options)
    return this
  }

  get portMarkup() {
    return this.getPortMarkup()
  }

  set portMarkup(markup: Markup) {
    this.setPortMarkup(markup)
  }

  getDefaultPortMarkup() {
    return this.store.get('defaultPortMarkup') || Markup.getPortMarkup()
  }

  getPortMarkup() {
    return this.store.get('portMarkup') || this.getDefaultPortMarkup()
  }

  setPortMarkup(markup?: Markup, options: Node.SetOptions = {}) {
    this.store.set('portMarkup', Markup.clone(markup), options)
    return this
  }

  get portLabelMarkup() {
    return this.getPortLabelMarkup()
  }

  set portLabelMarkup(markup: Markup) {
    this.setPortLabelMarkup(markup)
  }

  getDefaultPortLabelMarkup() {
    return (
      this.store.get('defaultPortLabelMarkup') || Markup.getPortLabelMarkup()
    )
  }

  getPortLabelMarkup() {
    return this.store.get('portLabelMarkup') || this.getDefaultPortLabelMarkup()
  }

  setPortLabelMarkup(markup?: Markup, options: Node.SetOptions = {}) {
    this.store.set('portLabelMarkup', Markup.clone(markup), options)
    return this
  }

  get ports() {
    const res = this.store.get<PortManager.Metadata>('ports', { items: [] })
    if (res.items == null) {
      res.items = []
    }
    return res
  }

  getPorts() {
    return ObjectExt.cloneDeep(this.ports.items)
  }

  getPortsByGroup(groupName: string) {
    return this.getPorts().filter((port) => port.group === groupName)
  }

  getPort(portId: string) {
    return ObjectExt.cloneDeep(
      this.ports.items.find((port) => port.id && port.id === portId),
    )
  }

  getPortAt(index: number) {
    return this.ports.items[index] || null
  }

  hasPorts() {
    return this.ports.items.length > 0
  }

  hasPort(portId: string) {
    return this.getPortIndex(portId) !== -1
  }

  getPortIndex(port: PortManager.PortMetadata | string) {
    const portId = typeof port === 'string' ? port : port.id
    return portId != null
      ? this.ports.items.findIndex((item) => item.id === portId)
      : -1
  }

  getPortsPosition(groupName: string) {
    const size = this.getSize()
    const layouts = this.port.getPortsLayoutByGroup(
      groupName,
      new Rectangle(0, 0, size.width, size.height),
    )

    return layouts.reduce<
      KeyValue<{
        position: Point.PointLike
        angle: number
      }>
    >((memo, item) => {
      const layout = item.portLayout
      memo[item.portId] = {
        position: { ...layout.position },
        angle: layout.angle || 0,
      }
      return memo
    }, {})
  }

  getPortProp(portId: string): PortManager.PortMetadata
  getPortProp<T>(portId: string, path: string | string[]): T
  getPortProp(portId: string, path?: string | string[]) {
    return this.getPropByPath(this.prefixPortPath(portId, path))
  }

  setPortProp(
    portId: string,
    path: string | string[],
    value: any,
    options?: Node.SetOptions,
  ): this
  setPortProp(
    portId: string,
    value: DeepPartial<PortManager.PortMetadata>,
    options?: Node.SetOptions,
  ): this
  setPortProp(
    portId: string,
    arg1: string | string[] | DeepPartial<PortManager.PortMetadata>,
    arg2: any | Node.SetOptions,
    arg3?: Node.SetOptions,
  ) {
    if (typeof arg1 === 'string' || Array.isArray(arg1)) {
      const path = this.prefixPortPath(portId, arg1)
      const value = arg2
      return this.setPropByPath(path, value, arg3)
    }

    const path = this.prefixPortPath(portId)
    const value = arg1 as DeepPartial<PortManager.PortMetadata>
    return this.setPropByPath(path, value, arg2 as Node.SetOptions)
  }

  removePortProp(portId: string, options?: Node.SetOptions): this
  removePortProp(
    portId: string,
    path: string | string[],
    options?: Node.SetOptions,
  ): this
  removePortProp(
    portId: string,
    path?: string | string[] | Node.SetOptions,
    options?: Node.SetOptions,
  ) {
    if (typeof path === 'string' || Array.isArray(path)) {
      return this.removePropByPath(this.prefixPortPath(portId, path), options)
    }
    return this.removePropByPath(this.prefixPortPath(portId), path)
  }

  portProp(portId: string): PortManager.PortMetadata
  portProp<T>(portId: string, path: string | string[]): T
  portProp(
    portId: string,
    path: string | string[],
    value: any,
    options?: Node.SetOptions,
  ): this
  portProp(
    portId: string,
    value: DeepPartial<PortManager.PortMetadata>,
    options?: Node.SetOptions,
  ): this
  portProp(
    portId: string,
    path?: string | string[] | DeepPartial<PortManager.PortMetadata>,
    value?: any | Node.SetOptions,
    options?: Node.SetOptions,
  ) {
    if (path == null) {
      return this.getPortProp(portId)
    }
    if (typeof path === 'string' || Array.isArray(path)) {
      if (arguments.length === 2) {
        return this.getPortProp(portId, path)
      }
      if (value == null) {
        return this.removePortProp(portId, path, options)
      }
      return this.setPortProp(
        portId,
        path,
        value as DeepPartial<PortManager.PortMetadata>,
        options,
      )
    }
    return this.setPortProp(
      portId,
      path as DeepPartial<PortManager.PortMetadata>,
      value as Node.SetOptions,
    )
  }

  protected prefixPortPath(portId: string, path?: string | string[]) {
    const index = this.getPortIndex(portId)
    if (index === -1) {
      throw new Error(`Unable to find port with id: "${portId}"`)
    }

    if (path == null || path === '') {
      return ['ports', 'items', `${index}`]
    }

    if (Array.isArray(path)) {
      return ['ports', 'items', `${index}`, ...path]
    }

    return `ports/items/${index}/${path}`
  }

  addPort(port: PortManager.PortMetadata, options?: Node.SetOptions) {
    const ports = [...this.ports.items]
    ports.push(port)
    this.setPropByPath('ports/items', ports, options)
    return this
  }

  addPorts(ports: PortManager.PortMetadata[], options?: Node.SetOptions) {
    this.setPropByPath('ports/items', [...this.ports.items, ...ports], options)
    return this
  }

  removePort(
    port: PortManager.PortMetadata | string,
    options: Node.SetOptions = {},
  ) {
    return this.removePortAt(this.getPortIndex(port), options)
  }

  removePortAt(index: number, options: Node.SetOptions = {}) {
    if (index >= 0) {
      const ports = [...this.ports.items]
      ports.splice(index, 1)
      options.rewrite = true
      this.setPropByPath('ports/items', ports, options)
    }
    return this
  }

  removePorts(options?: Node.SetOptions): this
  removePorts(
    portsForRemoval: (PortManager.PortMetadata | string)[],
    options?: Node.SetOptions,
  ): this
  removePorts(
    portsForRemoval?: (PortManager.PortMetadata | string)[] | Node.SetOptions,
    opt?: Node.SetOptions,
  ) {
    let options

    if (Array.isArray(portsForRemoval)) {
      options = opt || {}
      if (portsForRemoval.length) {
        options.rewrite = true
        const currentPorts = [...this.ports.items]
        const remainingPorts = currentPorts.filter(
          (cp) =>
            !portsForRemoval.some((p) => {
              const id = typeof p === 'string' ? p : p.id
              return cp.id === id
            }),
        )
        this.setPropByPath('ports/items', remainingPorts, options)
      }
    } else {
      options = portsForRemoval || {}
      options.rewrite = true
      this.setPropByPath('ports/items', [], options)
    }

    return this
  }

  getParsedPorts() {
    return this.port.getPorts()
  }

  getParsedGroups() {
    return this.port.groups
  }

  getPortsLayoutByGroup(groupName: string | undefined, bbox: Rectangle) {
    return this.port.getPortsLayoutByGroup(groupName, bbox)
  }

  protected initPorts() {
    this.updatePortData()
    this.on('change:ports', () => {
      this.processRemovedPort()
      this.updatePortData()
    })
  }

  protected processRemovedPort() {
    const current = this.ports
    const currentItemsMap: { [id: string]: boolean } = {}

    current.items.forEach((item) => {
      if (item.id) {
        currentItemsMap[item.id] = true
      }
    })

    const removed: { [id: string]: boolean } = {}
    const previous = this.store.getPrevious<PortManager.Metadata>('ports') || {
      items: [],
    }

    previous.items.forEach((item) => {
      if (item.id && !currentItemsMap[item.id]) {
        removed[item.id] = true
      }
    })

    const model = this.model
    if (model && !ObjectExt.isEmpty(removed)) {
      const incomings = model.getConnectedEdges(this, { incoming: true })
      incomings.forEach((edge) => {
        const portId = edge.getTargetPortId()
        if (portId && removed[portId]) {
          edge.remove()
        }
      })
      const outgoings = model.getConnectedEdges(this, { outgoing: true })
      outgoings.forEach((edge) => {
        const portId = edge.getSourcePortId()
        if (portId && removed[portId]) {
          edge.remove()
        }
      })
    }
  }

  protected validatePorts() {
    const ids: { [id: string]: boolean } = {}
    const errors: string[] = []
    this.ports.items.forEach((p) => {
      if (typeof p !== 'object') {
        errors.push(`Invalid port ${p}.`)
      }

      if (p.id == null) {
        p.id = this.generatePortId()
      }

      if (ids[p.id]) {
        errors.push('Duplicitied port id.')
      }

      ids[p.id] = true
    })

    return errors
  }

  protected generatePortId() {
    return StringExt.uuid()
  }

  protected updatePortData() {
    const err = this.validatePorts()

    if (err.length > 0) {
      this.store.set(
        'ports',
        this.store.getPrevious<PortManager.Metadata>('ports'),
      )
      throw new Error(err.join(' '))
    }

    const prev = this.port ? this.port.getPorts() : null
    this.port = new PortManager(this.ports)
    const curr = this.port.getPorts()

    const added = prev
      ? curr.filter((item) => {
          if (!prev.find((prevPort) => prevPort.id === item.id)) {
            return item
          }
          return null
        })
      : [...curr]

    const removed = prev
      ? prev.filter((item) => {
          if (!curr.find((curPort) => curPort.id === item.id)) {
            return item
          }
          return null
        })
      : []

    if (added.length > 0) {
      this.notify('ports:added', { added, cell: this, node: this })
    }

    if (removed.length > 0) {
      this.notify('ports:removed', { removed, cell: this, node: this })
    }
  }

  // #endregion
}

export namespace Node {
  interface Common extends Cell.Common {
    size?: { width: number; height: number }
    position?: { x: number; y: number }
    angle?: number
    ports?: Partial<PortManager.Metadata> | PortManager.PortMetadata[]
    portContainerMarkup?: Markup
    portMarkup?: Markup
    portLabelMarkup?: Markup
    defaultPortMarkup?: Markup
    defaultPortLabelMarkup?: Markup
    defaultPortContainerMarkup?: Markup
  }

  interface Boundary {
    x?: number
    y?: number
    width?: number
    height?: number
  }

  export interface Defaults extends Common, Cell.Defaults {}

  export interface Metadata extends Common, Cell.Metadata, Boundary {}

  export interface Properties
    extends Common,
      Omit<Cell.Metadata, 'tools'>,
      Cell.Properties {}

  export interface Config
    extends Defaults,
      Boundary,
      Cell.Config<Metadata, Node> {}
}

export namespace Node {
  export interface SetOptions extends Cell.SetOptions {}

  export interface GetPositionOptions {
    relative?: boolean
  }

  export interface SetPositionOptions extends SetOptions {
    deep?: boolean
    relative?: boolean
  }

  export interface TranslateOptions extends Cell.TranslateOptions {
    transition?: boolean | Animation.StartOptions<Point.PointLike>
    restrict?: Rectangle.RectangleLike | null
    exclude?: Cell[]
  }

  export interface RotateOptions extends SetOptions {
    absolute?: boolean
    center?: Point.PointLike | null
  }

  export type ResizeDirection =
    | 'left'
    | 'top'
    | 'right'
    | 'bottom'
    | 'top-left'
    | 'top-right'
    | 'bottom-left'
    | 'bottom-right'

  export interface ResizeOptions extends SetOptions {
    absolute?: boolean
    direction?: ResizeDirection
  }

  export interface FitEmbedsOptions extends SetOptions {
    deep?: boolean
    padding?: NumberExt.SideOptions
  }
}

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

  export function isNode(instance: any): instance is Node {
    if (instance == null) {
      return false
    }

    if (instance instanceof Node) {
      return true
    }

    const tag = instance[Symbol.toStringTag]
    const node = instance as Node

    if (
      (tag == null || tag === toStringTag) &&
      typeof node.isNode === 'function' &&
      typeof node.isEdge === 'function' &&
      typeof node.prop === 'function' &&
      typeof node.attr === 'function' &&
      typeof node.size === 'function' &&
      typeof node.position === 'function'
    ) {
      return true
    }

    return false
  }
}

export namespace Node {
  Node.config<Node.Config>({
    propHooks({ ports, ...metadata }) {
      if (ports) {
        metadata.ports = Array.isArray(ports) ? { items: ports } : ports
      }
      return metadata
    },
  })
}

export namespace Node {
  export const registry = Registry.create<
    Definition,
    never,
    Config & { inherit?: string | Definition }
  >({
    type: 'node',
    process(shape, options) {
      if (ShareRegistry.exist(shape, true)) {
        throw new Error(
          `Node with name '${shape}' was registered by anthor Edge`,
        )
      }

      if (typeof options === 'function') {
        options.config({ shape })
        return options
      }

      let parent = Node
      const { inherit, ...config } = options
      if (inherit) {
        if (typeof inherit === 'string') {
          const base = this.get(inherit)
          if (base == null) {
            this.onNotFound(inherit, 'inherited')
          } else {
            parent = base
          }
        } else {
          parent = inherit
        }
      }

      if (config.constructorName == null) {
        config.constructorName = shape
      }

      const ctor: Definition = parent.define.call(parent, config)
      ctor.config({ shape })
      return ctor as any
    },
  })

  ShareRegistry.setNodeRegistry(registry)
}

export namespace Node {
  type NodeClass = typeof Node

  export interface Definition extends NodeClass {
    new <T extends Properties = Properties>(metadata: T): Node
  }

  let counter = 0
  function getClassName(name?: string) {
    if (name) {
      return StringExt.pascalCase(name)
    }
    counter += 1
    return `CustomNode${counter}`
  }

  export function define(config: Config) {
    const { constructorName, overwrite, ...others } = config
    const ctor = ObjectExt.createClass<NodeClass>(
      getClassName(constructorName || others.shape),
      this as NodeClass,
    )

    ctor.config(others)

    if (others.shape) {
      registry.register(others.shape, ctor, overwrite)
    }

    return ctor
  }

  export function create(options: Metadata) {
    const shape = options.shape || 'rect'
    const Ctor = registry.get(shape)
    if (Ctor) {
      return new Ctor(options)
    }
    return registry.onNotFound(shape)
  }
}
