import type { Dom, KeyValue, NumberExt } from '../common'
import { Basecoat, disposable } from '../common'
import { Point, Rectangle, type RectangleLike } from '../geometry'
import type { PointLike } from '../geometry/point'
import type {
  CellGetCellsBBoxOptions,
  CellRemoveOptions,
  CellSetOptions,
  CollectionRemoveOptions,
  CollectionSetOptions,
  EdgeMetadata,
  EdgeSetOptions,
  NodeMetadata,
} from '../model'
import { Cell, Edge, Model, Node } from '../model'
import type {
  AddOptions,
  GetPredecessorsOptions,
  ToJSONOptions,
  FromJSONData,
  FromJSONOptions,
  GetConnectedEdgesOptions,
  GetNeighborsOptions,
  GetSubgraphOptions,
  GetCellsInAreaOptions,
  SearchIterator,
  GetShortestPathOptions,
  BatchName,
  SearchOptions,
} from '../model'
import type { BackgroundOptions } from '../registry'
import {
  attrRegistry,
  backgroundRegistry,
  connectionPointRegistry,
  connectorRegistry,
  edgeAnchorRegistry,
  edgeToolRegistry,
  filterRegistry,
  gridRegistry,
  highlighterRegistry,
  markerRegistry,
  nodeAnchorRegistry,
  nodeToolRegistry,
  portLabelLayoutRegistry,
  portLayoutRegistry,
  routerRegistry,
} from '../registry'
import { Renderer as ViewRenderer } from '../renderer'
import { CellView } from '../view'
import { BackgroundManager as Background } from './background'
import { CoordManager as Coord } from './coord'
import { CSSManager as Css } from './css'
import { DefsManager as Defs } from './defs'
import type { FilterOptions, GradientOptions, MarkerOptions } from './defs'
import type { EventArgs } from './events'
import { GridManager as Grid, GridDrawOptions } from './grid'
import { HighlightManager as Highlight } from './highlight'
import { MouseWheel as Wheel } from './mousewheel'
import { GraphDefinition, GraphManual, getOptions } from './options'
import { PanningManager as Panning } from './panning'
import { SizeManager as Size } from './size'
import { TransformManager as Transform } from './transform'
import type {
  GetContentAreaOptions,
  ZoomOptions,
  ScaleContentToFitOptions,
  FitToContentOptions,
  FitToContentFullOptions,
  CenterOptions,
  PositionContentOptions,
  Direction,
} from './transform'
import { GraphView } from './view'
import { VirtualRenderManager as VirtualRender } from './virtual-render'
import type { KeyPoint } from '../types'

type FindViewsInAreaOptions = {
  strict?: boolean
}

export interface Options extends GraphManual {}

export type GraphPlugin = {
  name: string
  init: (graph: Graph, ...options: any[]) => any
  dispose: () => void

  enable?: () => void
  disable?: () => void
  isEnabled?: () => boolean
}
export class Graph extends Basecoat<EventArgs> {
  static toStringTag = `X6.${Graph.name}`
  static isGraph(instance: any): instance is Graph {
    if (instance == null) {
      return false
    }

    if (instance instanceof Graph) {
      return true
    }

    const tag = instance[Symbol.toStringTag]

    if (tag == null || tag === Graph.toStringTag) {
      return true
    }

    return false
  }
  static render(options: Partial<Options>, data?: FromJSONData): Graph
  static render(container: HTMLElement, data?: FromJSONData): Graph
  static render(
    options: Partial<Options> | HTMLElement,
    data?: FromJSONData,
  ): Graph {
    const graph =
      options instanceof HTMLElement
        ? new Graph({ container: options })
        : new Graph(options)

    if (data != null) {
      graph.fromJSON(data)
    }

    return graph
  }
  static registerNode = Node.registry.register
  static registerEdge = Edge.registry.register
  static registerView = CellView.registry.register
  static registerAttr = attrRegistry.register
  static registerGrid = gridRegistry.register
  static registerFilter = filterRegistry.register
  static registerNodeTool = nodeToolRegistry.register
  static registerEdgeTool = edgeToolRegistry.register
  static registerBackground = backgroundRegistry.register
  static registerHighlighter = highlighterRegistry.register
  static registerPortLayout = portLayoutRegistry.register
  static registerPortLabelLayout = portLabelLayoutRegistry.register
  static registerMarker = markerRegistry.register
  static registerRouter = routerRegistry.register
  static registerConnector = connectorRegistry.register
  static registerAnchor = nodeAnchorRegistry.register
  static registerEdgeAnchor = edgeAnchorRegistry.register
  static registerConnectionPoint = connectionPointRegistry.register
  static unregisterNode = Node.registry.unregister
  static unregisterEdge = Edge.registry.unregister
  static unregisterView = CellView.registry.unregister
  static unregisterAttr = attrRegistry.unregister
  static unregisterGrid = gridRegistry.unregister
  static unregisterFilter = filterRegistry.unregister
  static unregisterNodeTool = nodeToolRegistry.unregister
  static unregisterEdgeTool = edgeToolRegistry.unregister
  static unregisterBackground = backgroundRegistry.unregister
  static unregisterHighlighter = highlighterRegistry.unregister
  static unregisterPortLayout = portLayoutRegistry.unregister
  static unregisterPortLabelLayout = portLabelLayoutRegistry.unregister
  static unregisterMarker = markerRegistry.unregister
  static unregisterRouter = routerRegistry.unregister
  static unregisterConnector = connectorRegistry.unregister
  static unregisterAnchor = nodeAnchorRegistry.unregister
  static unregisterEdgeAnchor = edgeAnchorRegistry.unregister
  static unregisterConnectionPoint = connectionPointRegistry.unregister
  private installedPlugins: Set<GraphPlugin> = new Set()
  public model: Model

  public readonly options: GraphDefinition
  public readonly css: Css
  public readonly view: GraphView
  public readonly grid: Grid
  public readonly defs: Defs
  public readonly coord: Coord
  public readonly renderer: ViewRenderer
  public readonly highlight: Highlight
  public readonly transform: Transform
  public readonly background: Background
  public readonly panning: Panning
  public readonly mousewheel: Wheel
  public readonly virtualRender: VirtualRender
  public readonly size: Size

  public get container() {
    return this.options.container
  }

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

  constructor(options: Partial<GraphManual>) {
    super()
    this.options = getOptions(options)
    this.css = new Css(this)
    this.view = new GraphView(this)
    this.defs = new Defs(this)
    this.coord = new Coord(this)
    this.transform = new Transform(this)
    this.highlight = new Highlight(this)
    this.grid = new Grid(this)
    this.background = new Background(this)

    if (this.options.model) {
      this.model = this.options.model
    } else {
      this.model = new Model()
      this.model.graph = this
    }

    this.renderer = new ViewRenderer(this)
    this.panning = new Panning(this)
    this.mousewheel = new Wheel(this)
    this.virtualRender = new VirtualRender(this)
    this.size = new Size(this)
  }

  // #region model

  isNode(cell: Cell): cell is Node {
    return cell.isNode()
  }

  isEdge(cell: Cell): cell is Edge {
    return cell.isEdge()
  }

  resetCells(cells: Cell[], options: CollectionSetOptions = {}) {
    this.model.resetCells(cells, options)
    return this
  }

  clearCells(options: CellSetOptions = {}) {
    this.model.clear(options)
    return this
  }

  toJSON(options: ToJSONOptions = {}) {
    return this.model.toJSON(options)
  }

  parseJSON(data: FromJSONData) {
    return this.model.parseJSON(data)
  }

  fromJSON(data: FromJSONData, options: FromJSONOptions = {}) {
    this.model.fromJSON(data, options)
    return this
  }

  getCellById(id: string) {
    return this.model.getCell(id)
  }

  addNode(metadata: NodeMetadata, options?: AddOptions): Node
  addNode(node: Node, options?: AddOptions): Node
  addNode(node: Node | NodeMetadata, options: AddOptions = {}): Node {
    return this.model.addNode(node, options)
  }

  addNodes(nodes: (Node | NodeMetadata)[], options: AddOptions = {}) {
    return this.addCell(
      nodes.map((node) => (Node.isNode(node) ? node : this.createNode(node))),
      options,
    )
  }

  createNode(metadata: NodeMetadata) {
    return this.model.createNode(metadata)
  }

  removeNode(nodeId: string, options?: CollectionRemoveOptions): Node | null
  removeNode(node: Node, options?: CollectionRemoveOptions): Node | null
  removeNode(node: Node | string, options: CollectionRemoveOptions = {}) {
    return this.model.removeCell(node as Node, options) as Node
  }

  addEdge(metadata: EdgeMetadata, options?: AddOptions): Edge
  addEdge(edge: Edge, options?: AddOptions): Edge
  addEdge(edge: Edge | EdgeMetadata, options: AddOptions = {}): Edge {
    return this.model.addEdge(edge, options)
  }

  addEdges(edges: (Edge | EdgeMetadata)[], options: AddOptions = {}) {
    return this.addCell(
      edges.map((edge) => (Edge.isEdge(edge) ? edge : this.createEdge(edge))),
      options,
    )
  }

  removeEdge(edgeId: string, options?: CollectionRemoveOptions): Edge | null
  removeEdge(edge: Edge, options?: CollectionRemoveOptions): Edge | null
  removeEdge(edge: Edge | string, options: CollectionRemoveOptions = {}) {
    return this.model.removeCell(edge as Edge, options) as Edge
  }

  createEdge(metadata: EdgeMetadata) {
    return this.model.createEdge(metadata)
  }

  addCell(cell: Cell | Cell[], options: AddOptions = {}) {
    this.model.addCell(cell, options)
    return this
  }

  removeCell(cellId: string, options?: CollectionRemoveOptions): Cell | null
  removeCell(cell: Cell, options?: CollectionRemoveOptions): Cell | null
  removeCell(cell: Cell | string, options: CollectionRemoveOptions = {}) {
    return this.model.removeCell(cell as Cell, options)
  }

  removeCells(cells: (Cell | string)[], options: CellRemoveOptions = {}) {
    return this.model.removeCells(cells, options)
  }

  removeConnectedEdges(cell: Cell | string, options: CellRemoveOptions = {}) {
    return this.model.removeConnectedEdges(cell, options)
  }

  disconnectConnectedEdges(cell: Cell | string, options: EdgeSetOptions = {}) {
    this.model.disconnectConnectedEdges(cell, options)
    return this
  }

  hasCell(cellId: string): boolean
  hasCell(cell: Cell): boolean
  hasCell(cell: string | Cell): boolean {
    return this.model.has(cell as Cell)
  }

  getCells() {
    return this.model.getCells()
  }

  getCellCount() {
    return this.model.total()
  }

  /**
   * Returns all the nodes in the graph.
   */
  getNodes() {
    return this.model.getNodes()
  }

  /**
   * Returns all the edges in the graph.
   */
  getEdges() {
    return this.model.getEdges()
  }

  /**
   * Returns all outgoing edges for the node.
   */
  getOutgoingEdges(cell: Cell | string) {
    return this.model.getOutgoingEdges(cell)
  }

  /**
   * Returns all incoming edges for the node.
   */
  getIncomingEdges(cell: Cell | string) {
    return this.model.getIncomingEdges(cell)
  }

  /**
   * Returns edges connected with cell.
   */
  getConnectedEdges(
    cell: Cell | string,
    options: GetConnectedEdgesOptions = {},
  ) {
    return this.model.getConnectedEdges(cell, options)
  }

  /**
   * Returns an array of all the roots of the graph.
   */
  getRootNodes() {
    return this.model.getRoots()
  }

  /**
   * Returns an array of all the leafs of the graph.
   */
  getLeafNodes() {
    return this.model.getLeafs()
  }

  /**
   * Returns `true` if the node is a root node, i.e.
   * there is no  edges coming to the node.
   */
  isRootNode(cell: Cell | string) {
    return this.model.isRoot(cell)
  }

  /**
   * Returns `true` if the node is a leaf node, i.e.
   * there is no edges going out from the node.
   */
  isLeafNode(cell: Cell | string) {
    return this.model.isLeaf(cell)
  }

  /**
   * Returns all the neighbors of node in the graph. Neighbors are all
   * the nodes connected to node via either incoming or outgoing edge.
   */
  getNeighbors(cell: Cell, options: GetNeighborsOptions = {}) {
    return this.model.getNeighbors(cell, options)
  }

  /**
   * Returns `true` if `cell2` is a neighbor of `cell1`.
   */
  isNeighbor(cell1: Cell, cell2: Cell, options: GetNeighborsOptions = {}) {
    return this.model.isNeighbor(cell1, cell2, options)
  }

  getSuccessors(cell: Cell, options: GetPredecessorsOptions = {}) {
    return this.model.getSuccessors(cell, options)
  }

  /**
   * Returns `true` if `cell2` is a successor of `cell1`.
   */
  isSuccessor(cell1: Cell, cell2: Cell, options: GetPredecessorsOptions = {}) {
    return this.model.isSuccessor(cell1, cell2, options)
  }

  getPredecessors(cell: Cell, options: GetPredecessorsOptions = {}) {
    return this.model.getPredecessors(cell, options)
  }

  /**
   * Returns `true` if `cell2` is a predecessor of `cell1`.
   */
  isPredecessor(
    cell1: Cell,
    cell2: Cell,
    options: GetPredecessorsOptions = {},
  ) {
    return this.model.isPredecessor(cell1, cell2, options)
  }

  getCommonAncestor(...cells: (Cell | null | undefined)[]) {
    return this.model.getCommonAncestor(...cells)
  }

  /**
   * Returns an array of cells that result from finding nodes/edges that
   * are connected to any of the cells in the cells array. This function
   * loops over cells and if the current cell is a edge, it collects its
   * source/target nodes; if it is an node, it collects its incoming and
   * outgoing edges if both the edge terminal (source/target) are in the
   * cells array.
   */
  getSubGraph(cells: Cell[], options: GetSubgraphOptions = {}) {
    return this.model.getSubGraph(cells, options)
  }

  /**
   * Clones the whole subgraph (including all the connected links whose
   * source/target is in the subgraph). If `options.deep` is `true`, also
   * take into account all the embedded cells of all the subgraph cells.
   *
   * Returns a map of the form: { [original cell ID]: [clone] }.
   */
  cloneSubGraph(cells: Cell[], options: GetSubgraphOptions = {}) {
    return this.model.cloneSubGraph(cells, options)
  }

  cloneCells(cells: Cell[]) {
    return this.model.cloneCells(cells)
  }

  /**
   * Returns an array of nodes whose bounding box contains point.
   * Note that there can be more then one node as nodes might overlap.
   */
  getNodesFromPoint(x: number, y: number): Node[]
  getNodesFromPoint(p: PointLike): Node[]
  getNodesFromPoint(x: number | PointLike, y?: number) {
    return this.model.getNodesFromPoint(x as number, y as number)
  }

  /**
   * Returns an array of nodes whose bounding box top/left coordinate
   * falls into the rectangle.
   */
  getNodesInArea(
    x: number,
    y: number,
    w: number,
    h: number,
    options?: GetCellsInAreaOptions,
  ): Node[]
  getNodesInArea(rect: RectangleLike, options?: GetCellsInAreaOptions): Node[]
  getNodesInArea(
    x: number | RectangleLike,
    y?: number | GetCellsInAreaOptions,
    w?: number,
    h?: number,
    options?: GetCellsInAreaOptions,
  ): Node[] {
    return this.model.getNodesInArea(
      x as number,
      y as number,
      w as number,
      h as number,
      options,
    )
  }

  getNodesUnderNode(
    node: Node,
    options: {
      by?: 'bbox' | KeyPoint
    } = {},
  ) {
    return this.model.getNodesUnderNode(node, options)
  }

  searchCell(
    cell: Cell,
    iterator: SearchIterator,
    options: SearchOptions = {},
  ) {
    this.model.search(cell, iterator, options)
    return this
  }

  /** *
   * Returns an array of IDs of nodes on the shortest
   * path between source and target.
   */
  getShortestPath(
    source: Cell | string,
    target: Cell | string,
    options: GetShortestPathOptions = {},
  ) {
    return this.model.getShortestPath(source, target, options)
  }

  /**
   * Returns the bounding box that surrounds all cells in the graph.
   */
  getAllCellsBBox() {
    return this.model.getAllCellsBBox()
  }

  /**
   * Returns the bounding box that surrounds all the given cells.
   */
  getCellsBBox(cells: Cell[], options: CellGetCellsBBoxOptions = {}) {
    return this.model.getCellsBBox(cells, options)
  }

  startBatch(name: string | BatchName, data: KeyValue = {}) {
    this.model.startBatch(name as BatchName, data)
  }

  stopBatch(name: string | BatchName, data: KeyValue = {}) {
    this.model.stopBatch(name as BatchName, data)
  }

  batchUpdate<T>(execute: () => T, data?: KeyValue): T
  batchUpdate<T>(name: string | BatchName, execute: () => T, data?: KeyValue): T
  batchUpdate<T>(
    arg1: string | BatchName | (() => T),
    arg2?: (() => T) | KeyValue,
    arg3?: KeyValue,
  ): T {
    const name = typeof arg1 === 'string' ? arg1 : 'update'
    const execute = typeof arg1 === 'string' ? (arg2 as () => T) : arg1
    const data = typeof arg2 === 'function' ? arg3 : arg2
    this.startBatch(name, data)
    const result = execute()
    this.stopBatch(name, data)
    return result
  }

  updateCellId(cell: Cell, newId: string) {
    return this.model.updateCellId(cell, newId)
  }

  // #endregion

  // #region view

  findView(ref: Cell | Element) {
    if (Cell.isCell(ref)) {
      return this.findViewByCell(ref)
    }

    return this.findViewByElem(ref)
  }

  findViews(ref: PointLike | RectangleLike) {
    if (Rectangle.isRectangleLike(ref)) {
      return this.findViewsInArea(ref)
    }

    if (Point.isPointLike(ref)) {
      return this.findViewsFromPoint(ref)
    }

    return []
  }

  findViewByCell(cellId: string | number): CellView | null
  findViewByCell(cell: Cell | null): CellView | null
  findViewByCell(
    cell: Cell | string | number | null | undefined,
  ): CellView | null {
    return this.renderer.findViewByCell(cell as Cell)
  }

  findViewByElem(elem: string | Element | undefined | null) {
    return this.renderer.findViewByElem(elem)
  }

  findViewsFromPoint(x: number, y: number): CellView[]
  findViewsFromPoint(p: PointLike): CellView[]
  findViewsFromPoint(x: number | PointLike, y?: number) {
    const p = typeof x === 'number' ? { x, y: y as number } : x
    return this.renderer.findViewsFromPoint(p)
  }

  findViewsInArea(
    x: number,
    y: number,
    width: number,
    height: number,
    options?: FindViewsInAreaOptions,
  ): CellView[]
  findViewsInArea(
    rect: RectangleLike,
    options?: FindViewsInAreaOptions,
  ): CellView[]
  findViewsInArea(
    x: number | RectangleLike,
    y?: number | FindViewsInAreaOptions,
    width?: number,
    height?: number,
    options?: FindViewsInAreaOptions,
  ) {
    const rect =
      typeof x === 'number'
        ? {
            x,
            y: y as number,
            width: width as number,
            height: height as number,
          }
        : x
    const localOptions =
      typeof x === 'number' ? options : (y as FindViewsInAreaOptions)
    return this.renderer.findViewsInArea(rect, localOptions)
  }

  // #endregion

  // #region transform

  /**
   * Returns the current transformation matrix of the graph.
   */
  matrix(): DOMMatrix
  /**
   * Sets new transformation with the given `matrix`
   */
  matrix(mat: DOMMatrix | Dom.MatrixLike | null): this
  matrix(mat?: DOMMatrix | Dom.MatrixLike | null) {
    if (typeof mat === 'undefined') {
      return this.transform.getMatrix()
    }
    this.transform.setMatrix(mat)
    return this
  }

  resize(width?: number, height?: number) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.resize(width, height)
    } else {
      this.transform.resize(width, height)
    }
    return this
  }

  scale(): Dom.Scale
  scale(sx: number, sy?: number, cx?: number, cy?: number): this
  scale(sx?: number, sy: number = sx as number, cx = 0, cy = 0) {
    if (typeof sx === 'undefined') {
      return this.transform.getScale()
    }
    this.transform.scale(sx, sy, cx, cy)
    return this
  }

  zoom(): number
  zoom(factor: number, options?: ZoomOptions): this
  zoom(factor?: number, options?: ZoomOptions) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      if (typeof factor === 'undefined') {
        return scroller.zoom()
      }
      scroller.zoom(factor, options)
    } else {
      if (typeof factor === 'undefined') {
        return this.transform.getZoom()
      }
      this.transform.zoom(factor, options)
    }

    return this
  }

  zoomTo(factor: number, options: Omit<ZoomOptions, 'absolute'> = {}) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.zoom(factor, { ...options, absolute: true })
    } else {
      this.transform.zoom(factor, { ...options, absolute: true })
    }

    return this
  }

  zoomToRect(
    rect: RectangleLike,
    options: ScaleContentToFitOptions & ScaleContentToFitOptions = {},
  ) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.zoomToRect(rect, options)
    } else {
      this.transform.zoomToRect(rect, options)
    }

    return this
  }

  zoomToFit(options: GetContentAreaOptions & ScaleContentToFitOptions = {}) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.zoomToFit(options)
    } else {
      this.transform.zoomToFit(options)
    }

    return this
  }

  rotate(): Dom.Rotation
  rotate(angle: number, cx?: number, cy?: number): this
  rotate(angle?: number, cx?: number, cy?: number) {
    if (typeof angle === 'undefined') {
      return this.transform.getRotation()
    }

    this.transform.rotate(angle, cx, cy)
    return this
  }

  translate(): Dom.Translation
  translate(tx: number, ty: number): this
  translate(tx?: number, ty?: number) {
    if (typeof tx === 'undefined') {
      return this.transform.getTranslation()
    }

    this.transform.translate(tx, ty as number)
    return this
  }

  translateBy(dx: number, dy: number): this {
    const ts = this.translate()
    const tx = ts.tx + dx
    const ty = ts.ty + dy
    return this.translate(tx, ty)
  }

  getGraphArea() {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      const area = scroller.getVisibleArea?.()
      if (area) return area
    }
    return this.transform.getGraphArea()
  }

  getContentArea(options: GetContentAreaOptions = {}) {
    return this.transform.getContentArea(options)
  }

  getContentBBox(options: GetContentAreaOptions = {}) {
    return this.transform.getContentBBox(options)
  }

  fitToContent(
    gridWidth?: number,
    gridHeight?: number,
    padding?: NumberExt.SideOptions,
    options?: FitToContentOptions,
  ): Rectangle
  fitToContent(options?: FitToContentFullOptions): Rectangle
  fitToContent(
    gridWidth?: number | FitToContentFullOptions,
    gridHeight?: number,
    padding?: NumberExt.SideOptions,
    options?: FitToContentOptions,
  ) {
    return this.transform.fitToContent(gridWidth, gridHeight, padding, options)
  }

  scaleContentToFit(options: ScaleContentToFitOptions = {}) {
    this.transform.scaleContentToFit(options)
    return this
  }

  /**
   * Position the center of graph to the center of the viewport.
   */
  center(options?: CenterOptions) {
    return this.centerPoint(options)
  }

  /**
   * Position the point (x,y) on the graph (in local coordinates) to the
   * center of the viewport. If only one of the coordinates is specified,
   * only center along the specified dimension and keep the other coordinate
   * unchanged.
   */
  centerPoint(x: number, y: null | number, options?: CenterOptions): this
  centerPoint(x: null | number, y: number, options?: CenterOptions): this
  centerPoint(optons?: CenterOptions): this
  centerPoint(
    x?: number | null | CenterOptions,
    y?: number | null,
    options?: CenterOptions,
  ) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.centerPoint(x as number, y as number, options)
    } else {
      this.transform.centerPoint(x as number, y as number)
    }

    return this
  }

  centerContent(options?: PositionContentOptions) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.centerContent(options)
    } else {
      this.transform.centerContent(options)
    }

    return this
  }

  centerCell(cell: Cell, options?: PositionContentOptions) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.centerCell(cell, options)
    } else {
      this.transform.centerCell(cell)
    }

    return this
  }

  positionPoint(
    point: PointLike,
    x: number | string,
    y: number | string,
    options: CenterOptions = {},
  ) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.positionPoint(point, x, y, options)
    } else {
      this.transform.positionPoint(point, x, y)
    }

    return this
  }

  positionRect(
    rect: RectangleLike,
    direction: Direction,
    options?: CenterOptions,
  ) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.positionRect(rect, direction, options)
    } else {
      this.transform.positionRect(rect, direction)
    }

    return this
  }

  positionCell(cell: Cell, direction: Direction, options?: CenterOptions) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.positionCell(cell, direction, options)
    } else {
      this.transform.positionCell(cell, direction)
    }

    return this
  }

  positionContent(pos: Direction, options?: PositionContentOptions) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.positionContent(pos, options)
    } else {
      this.transform.positionContent(pos, options)
    }

    return this
  }

  // #endregion

  // #region coord

  snapToGrid(p: PointLike): Point
  snapToGrid(x: number, y: number): Point
  snapToGrid(x: number | PointLike, y?: number) {
    return this.coord.snapToGrid(x, y)
  }

  pageToLocal(rect: RectangleLike): Rectangle
  pageToLocal(x: number, y: number, width: number, height: number): Rectangle
  pageToLocal(p: PointLike): Point
  pageToLocal(x: number, y: number): Point
  pageToLocal(
    x: number | PointLike | RectangleLike,
    y?: number,
    width?: number,
    height?: number,
  ) {
    if (Rectangle.isRectangleLike(x)) {
      return this.coord.pageToLocalRect(x)
    }

    if (
      typeof x === 'number' &&
      typeof y === 'number' &&
      typeof width === 'number' &&
      typeof height === 'number'
    ) {
      return this.coord.pageToLocalRect(x, y, width, height)
    }

    return this.coord.pageToLocalPoint(x, y)
  }

  localToPage(rect: RectangleLike): Rectangle
  localToPage(x: number, y: number, width: number, height: number): Rectangle
  localToPage(p: PointLike): Point
  localToPage(x: number, y: number): Point
  localToPage(
    x: number | PointLike | RectangleLike,
    y?: number,
    width?: number,
    height?: number,
  ) {
    if (Rectangle.isRectangleLike(x)) {
      return this.coord.localToPageRect(x)
    }

    if (
      typeof x === 'number' &&
      typeof y === 'number' &&
      typeof width === 'number' &&
      typeof height === 'number'
    ) {
      return this.coord.localToPageRect(x, y, width, height)
    }

    return this.coord.localToPagePoint(x, y)
  }

  clientToLocal(rect: RectangleLike): Rectangle
  clientToLocal(x: number, y: number, width: number, height: number): Rectangle
  clientToLocal(p: PointLike): Point
  clientToLocal(x: number, y: number): Point
  clientToLocal(
    x: number | PointLike | RectangleLike,
    y?: number,
    width?: number,
    height?: number,
  ) {
    if (Rectangle.isRectangleLike(x)) {
      return this.coord.clientToLocalRect(x)
    }

    if (
      typeof x === 'number' &&
      typeof y === 'number' &&
      typeof width === 'number' &&
      typeof height === 'number'
    ) {
      return this.coord.clientToLocalRect(x, y, width, height)
    }

    return this.coord.clientToLocalPoint(x, y)
  }

  localToClient(rect: RectangleLike): Rectangle
  localToClient(x: number, y: number, width: number, height: number): Rectangle
  localToClient(p: PointLike): Point
  localToClient(x: number, y: number): Point
  localToClient(
    x: number | PointLike | RectangleLike,
    y?: number,
    width?: number,
    height?: number,
  ) {
    if (Rectangle.isRectangleLike(x)) {
      return this.coord.localToClientRect(x)
    }

    if (
      typeof x === 'number' &&
      typeof y === 'number' &&
      typeof width === 'number' &&
      typeof height === 'number'
    ) {
      return this.coord.localToClientRect(x, y, width, height)
    }

    return this.coord.localToClientPoint(x, y)
  }

  /**
   * Transform the rectangle `rect` defined in the local coordinate system to
   * the graph coordinate system.
   */
  localToGraph(rect: RectangleLike): Rectangle
  /**
   * Transform the rectangle `x`, `y`, `width`, `height` defined in the local
   * coordinate system to the graph coordinate system.
   */
  localToGraph(x: number, y: number, width: number, height: number): Rectangle
  /**
   * Transform the point `p` defined in the local coordinate system to
   * the graph coordinate system.
   */
  localToGraph(p: PointLike): Point
  /**
   * Transform the point `x`, `y` defined in the local coordinate system to
   * the graph coordinate system.
   */
  localToGraph(x: number, y: number): Point
  localToGraph(
    x: number | PointLike | RectangleLike,
    y?: number,
    width?: number,
    height?: number,
  ) {
    if (Rectangle.isRectangleLike(x)) {
      return this.coord.localToGraphRect(x)
    }

    if (
      typeof x === 'number' &&
      typeof y === 'number' &&
      typeof width === 'number' &&
      typeof height === 'number'
    ) {
      return this.coord.localToGraphRect(x, y, width, height)
    }

    return this.coord.localToGraphPoint(x, y)
  }

  graphToLocal(rect: RectangleLike): Rectangle
  graphToLocal(x: number, y: number, width: number, height: number): Rectangle
  graphToLocal(p: PointLike): Point
  graphToLocal(x: number, y: number): Point
  graphToLocal(
    x: number | PointLike | RectangleLike,
    y?: number,
    width?: number,
    height?: number,
  ) {
    if (Rectangle.isRectangleLike(x)) {
      return this.coord.graphToLocalRect(x)
    }

    if (
      typeof x === 'number' &&
      typeof y === 'number' &&
      typeof width === 'number' &&
      typeof height === 'number'
    ) {
      return this.coord.graphToLocalRect(x, y, width, height)
    }
    return this.coord.graphToLocalPoint(x, y)
  }

  clientToGraph(rect: RectangleLike): Rectangle
  clientToGraph(x: number, y: number, width: number, height: number): Rectangle
  clientToGraph(p: PointLike): Point
  clientToGraph(x: number, y: number): Point
  clientToGraph(
    x: number | PointLike | RectangleLike,
    y?: number,
    width?: number,
    height?: number,
  ) {
    if (Rectangle.isRectangleLike(x)) {
      return this.coord.clientToGraphRect(x)
    }
    if (
      typeof x === 'number' &&
      typeof y === 'number' &&
      typeof width === 'number' &&
      typeof height === 'number'
    ) {
      return this.coord.clientToGraphRect(x, y, width, height)
    }
    return this.coord.clientToGraphPoint(x, y)
  }

  // #endregion

  // #region defs

  defineFilter(options: FilterOptions) {
    return this.defs.filter(options)
  }

  defineGradient(options: GradientOptions) {
    return this.defs.gradient(options)
  }

  defineMarker(options: MarkerOptions) {
    return this.defs.marker(options)
  }

  // #endregion

  // #region grid

  getGridSize() {
    return this.grid.getGridSize()
  }

  setGridSize(gridSize: number) {
    this.grid.setGridSize(gridSize)
    return this
  }

  showGrid() {
    this.grid.show()
    return this
  }

  hideGrid() {
    this.grid.hide()
    return this
  }

  clearGrid() {
    this.grid.clear()
    return this
  }

  drawGrid(options?: GridDrawOptions) {
    this.grid.draw(options)
    return this
  }

  // #endregion

  // #region background

  updateBackground() {
    this.background.update()
    return this
  }

  drawBackground(options?: BackgroundOptions, onGraph?: boolean) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller != null && (this.options.background == null || !onGraph)) {
      scroller.drawBackground(options, onGraph)
    } else {
      this.background.draw(options)
    }
    return this
  }

  clearBackground(onGraph?: boolean) {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller != null && (this.options.background == null || !onGraph)) {
      scroller.clearBackground(onGraph)
    } else {
      this.background.clear()
    }
    return this
  }

  // #endregion

  // #region virtual-render

  enableVirtualRender() {
    this.virtualRender.enableVirtualRender()
    return this
  }

  disableVirtualRender() {
    this.virtualRender.disableVirtualRender()
    return this
  }

  // #endregion

  // #region mousewheel

  isMouseWheelEnabled() {
    return !this.mousewheel.disabled
  }

  enableMouseWheel() {
    this.mousewheel.enable()
    return this
  }

  disableMouseWheel() {
    this.mousewheel.disable()
    return this
  }

  toggleMouseWheel(enabled?: boolean) {
    if (enabled == null) {
      if (this.isMouseWheelEnabled()) {
        this.disableMouseWheel()
      } else {
        this.enableMouseWheel()
      }
    } else if (enabled) {
      this.enableMouseWheel()
    } else {
      this.disableMouseWheel()
    }
    return this
  }

  // #endregion

  // #region panning

  isPannable() {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      return scroller.isPannable()
    }
    return this.panning.pannable
  }

  enablePanning() {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.enablePanning()
    } else {
      this.panning.enablePanning()
    }

    return this
  }

  disablePanning() {
    const scroller = this.getPlugin<any>('scroller')
    if (scroller) {
      scroller.disablePanning()
    } else {
      this.panning.disablePanning()
    }
    return this
  }

  togglePanning(pannable?: boolean) {
    if (pannable == null) {
      if (this.isPannable()) {
        this.disablePanning()
      } else {
        this.enablePanning()
      }
    } else if (pannable !== this.isPannable()) {
      if (pannable) {
        this.enablePanning()
      } else {
        this.disablePanning()
      }
    }

    return this
  }

  // #endregion

  // #region plugin

  handleScrollerPluginStateChange(
    plugin: GraphPlugin,
    isBeingEnabled: boolean,
  ) {
    if (plugin.name === 'scroller') {
      if (isBeingEnabled) {
        this.virtualRender.onScrollerReady(plugin)
      } else {
        this.virtualRender.unbindScroller()
      }
    }
  }

  use(plugin: GraphPlugin, ...options: any[]) {
    if (!this.installedPlugins.has(plugin)) {
      this.installedPlugins.add(plugin)
      plugin.init(this, ...options)
      this.handleScrollerPluginStateChange(plugin, true)
    }
    return this
  }

  getPlugin<T extends GraphPlugin>(pluginName: string): T | undefined {
    return Array.from(this.installedPlugins).find(
      (plugin) => plugin.name === pluginName,
    ) as T
  }

  getPlugins<T extends GraphPlugin[]>(pluginName: string[]): T | undefined {
    return Array.from(this.installedPlugins).filter((plugin) =>
      pluginName.includes(plugin.name),
    ) as T
  }

  enablePlugins(plugins: string[] | string) {
    let postPlugins = plugins
    if (!Array.isArray(postPlugins)) {
      postPlugins = [postPlugins]
    }
    const aboutToChangePlugins = this.getPlugins(postPlugins)
    aboutToChangePlugins?.forEach((plugin) => {
      plugin?.enable?.()
      this.handleScrollerPluginStateChange(plugin, true)
    })
    return this
  }

  disablePlugins(plugins: string[] | string) {
    let postPlugins = plugins
    if (!Array.isArray(postPlugins)) {
      postPlugins = [postPlugins]
    }
    const aboutToChangePlugins = this.getPlugins(postPlugins)
    aboutToChangePlugins?.forEach((plugin) => {
      plugin?.disable?.()
      this.handleScrollerPluginStateChange(plugin, false)
    })
    return this
  }

  isPluginEnabled(pluginName: string) {
    const pluginIns = this.getPlugin(pluginName)
    return pluginIns?.isEnabled?.()
  }

  disposePlugins(plugins: string[] | string) {
    let postPlugins = plugins
    if (!Array.isArray(postPlugins)) {
      postPlugins = [postPlugins]
    }
    const aboutToChangePlugins = this.getPlugins(postPlugins)
    aboutToChangePlugins?.forEach((plugin) => {
      plugin.dispose()
      this.handleScrollerPluginStateChange(plugin, false)
      this.installedPlugins.delete(plugin)
    })
    return this
  }

  // #endregion

  // #region dispose

  @disposable()
  dispose(clean = true) {
    if (clean) {
      this.model.dispose()
    }

    this.css.dispose()
    this.defs.dispose()
    this.grid.dispose()
    this.coord.dispose()
    this.transform.dispose()
    this.highlight.dispose()
    this.background.dispose()
    this.mousewheel.dispose()
    this.panning.dispose()
    this.view.dispose()
    this.renderer.dispose()

    this.installedPlugins.forEach((plugin) => {
      plugin.dispose()
    })
  }

  // #endregion
}
