import { Graph as GraphLib } from '@antv/graphlib';
import { isNil, isNumber, uniq } from '@antv/util';
import { COMBO_KEY, ChangeType, TREE_KEY } from '../constants';
import type { ComboData, EdgeData, GraphData, NodeData } from '../spec';
import type {
  DataAdded,
  DataChange,
  DataID,
  DataRemoved,
  DataUpdated,
  ElementDatum,
  HierarchyKey,
  ID,
  NodeLikeData,
  PartialEdgeData,
  PartialGraphData,
  PartialNodeLikeData,
  Point,
  State,
} from '../types';
import type { EdgeDirection } from '../types/edge';
import type { ElementType } from '../types/element';
import { isCollapsed } from '../utils/collapsibility';
import { cloneElementData, isElementDataEqual, mergeElementsData } from '../utils/data';
import { arrayDiff } from '../utils/diff';
import { toG6Data, toGraphlibData } from '../utils/graphlib';
import { idOf, parentIdOf } from '../utils/id';
import { positionOf } from '../utils/position';
import { format, print } from '../utils/print';
import { dfs } from '../utils/traverse';
import { add } from '../utils/vector';

export class DataController {
  public model: GraphLib<NodeLikeData, EdgeData>;

  /**
   * <zh/> 最近一次删除的 combo 的 id
   *
   * <en/> The ids of the last deleted combos
   * @remarks
   * <zh/> 当删除 combo 后，会将其 id 从 comboIds 中移除，此时根据 Graphlib 的 changes 事件获取到的 NodeRemoved 无法区分是 combo 还是 node。
   * 因此需要记录最近一次删除的 combo 的 id，并用于 isCombo 的判断
   *
   * <en/> When the combo is deleted, its id will be removed from comboIds. At this time, the NodeRemoved obtained according to the changes event of Graphlib cannot distinguish whether it is a combo or a node.
   * Therefore, it is necessary to record the id of the last deleted combo and use it to judge isCombo
   */
  protected latestRemovedComboIds = new Set<ID>();

  protected comboIds = new Set<ID>();

  /**
   * <zh/> 获取详细数据变更
   *
   * <en/> Get detailed data changes
   */
  private changes: DataChange[] = [];

  /**
   * <zh/> 批处理计数器
   *
   * <en/> Batch processing counter
   */
  private batchCount = 0;

  /**
   * <zh/> 是否处于无痕模式
   *
   * <en/> Whether it is in traceless mode
   */
  private isTraceless = false;

  constructor() {
    this.model = new GraphLib();
  }

  private pushChange(change: DataChange) {
    if (this.isTraceless) return;
    const { type } = change;

    if (type === ChangeType.NodeUpdated || type === ChangeType.EdgeUpdated || type === ChangeType.ComboUpdated) {
      const { value, original } = change;
      this.changes.push({ value: cloneElementData(value), original: cloneElementData(original), type } as DataUpdated);
    } else {
      this.changes.push({ value: cloneElementData(change.value), type } as DataAdded | DataRemoved);
    }
  }

  public getChanges(): DataChange[] {
    return this.changes;
  }

  public clearChanges() {
    this.changes = [];
  }

  public batch(callback: () => void) {
    this.batchCount++;
    this.model.batch(callback);
    this.batchCount--;
  }

  protected isBatching() {
    return this.batchCount > 0;
  }

  /**
   * <zh/> 执行操作而不会留下记录
   *
   * <en/> Perform operations without leaving records
   * @param callback - <zh/> 回调函数 | <en/> callback function
   * @remarks
   * <zh/> 通常用于运行时调整元素并同步数据，避免触发数据变更导致重绘
   *
   * <en/> Usually used to adjust elements at runtime and synchronize data to avoid triggering data changes and causing redraws
   */
  public silence(callback: () => void) {
    this.isTraceless = true;
    callback();
    this.isTraceless = false;
  }

  public isCombo(id: ID) {
    return this.comboIds.has(id) || this.latestRemovedComboIds.has(id);
  }

  public getData() {
    return {
      nodes: this.getNodeData(),
      edges: this.getEdgeData(),
      combos: this.getComboData(),
    };
  }

  public getNodeData(ids?: ID[]) {
    return this.model.getAllNodes().reduce((acc, node) => {
      const data = toG6Data(node);
      if (this.isCombo(idOf(data))) return acc;
      if (ids === undefined) acc.push(data);
      else ids.includes(idOf(data)) && acc.push(data);
      return acc;
    }, [] as NodeData[]);
  }

  public getEdgeDatum(id: ID) {
    return toG6Data(this.model.getEdge(id));
  }

  public getEdgeData(ids?: ID[]) {
    return this.model.getAllEdges().reduce((acc, edge) => {
      const data = toG6Data(edge);
      if (ids === undefined) acc.push(data);
      else ids.includes(idOf(data)) && acc.push(data);
      return acc;
    }, [] as EdgeData[]);
  }

  public getComboData(ids?: ID[]) {
    return this.model.getAllNodes().reduce((acc, combo) => {
      const data = toG6Data(combo);
      if (!this.isCombo(idOf(data))) return acc;

      if (ids === undefined) acc.push(data as ComboData);
      else ids.includes(idOf(data)) && acc.push(data as ComboData);
      return acc;
    }, [] as ComboData[]);
  }

  public getRootsData(hierarchyKey: HierarchyKey = TREE_KEY) {
    return this.model.getRoots(hierarchyKey).map(toG6Data);
  }

  public getAncestorsData(id: ID, hierarchyKey: HierarchyKey): NodeLikeData[] {
    const { model } = this;
    if (!model.hasNode(id) || !model.hasTreeStructure(hierarchyKey)) return [];
    return model.getAncestors(id, hierarchyKey).map(toG6Data);
  }

  public getDescendantsData(id: ID): NodeLikeData[] {
    const root = this.getElementDataById(id) as NodeLikeData;
    const data: NodeLikeData[] = [];
    dfs(
      root,
      (node) => {
        if (node !== root) data.push(node);
      },
      (node) => this.getChildrenData(idOf(node)),
      'TB',
    );
    return data;
  }

  public getParentData(id: ID, hierarchyKey: HierarchyKey): NodeLikeData | undefined {
    const { model } = this;
    if (!hierarchyKey) {
      print.warn('The hierarchy structure key is not specified');
      return undefined;
    }
    if (!model.hasNode(id) || !model.hasTreeStructure(hierarchyKey)) return undefined;
    const parent = model.getParent(id, hierarchyKey);
    return parent ? toG6Data(parent) : undefined;
  }

  public getChildrenData(id: ID): NodeLikeData[] {
    const structureKey = this.getElementType(id) === 'node' ? TREE_KEY : COMBO_KEY;
    const { model } = this;
    if (!model.hasNode(id) || !model.hasTreeStructure(structureKey)) return [];
    return model.getChildren(id, structureKey).map(toG6Data);
  }

  /**
   * <zh/> 获取指定类型元素的数据
   *
   * <en/> Get the data of the specified type of element
   * @param elementType - <zh/> 元素类型 | <en/> element type
   * @returns <zh/> 元素数据 | <en/> element data
   */
  public getElementsDataByType(elementType: ElementType) {
    if (elementType === 'node') return this.getNodeData();
    if (elementType === 'edge') return this.getEdgeData();
    if (elementType === 'combo') return this.getComboData();
    return [];
  }

  /**
   * <zh/> 根据 ID 获取元素的数据，不用关心元素的类型
   *
   * <en/> Get the data of the element by ID, no need to care about the type of the element
   * @param id - <zh/> 元素 ID 数组 | <en/> element ID array
   * @returns <zh/> 元素数据 | <en/> data of the element
   */
  public getElementDataById(id: ID): ElementDatum {
    const type = this.getElementType(id);
    if (type === 'edge') return this.getEdgeDatum(id);
    return this.getNodeLikeDatum(id);
  }

  /**
   * <zh/> 获取节点的数据
   *
   * <en/> Get node data
   * @param id - <zh/> 节点 ID | <en/> node ID
   * @returns <zh/> 节点数据 | <en/> node data
   */
  public getNodeLikeDatum(id: ID) {
    const data = this.model.getNode(id);
    return toG6Data(data);
  }

  /**
   * <zh/> 获取所有节点和 combo 的数据
   *
   * <en/> Get all node and combo data
   * @param ids - <zh/> 节点和 combo ID 数组 | <en/> node and combo ID array
   * @returns <zh/> 节点和 combo 的数据 | <en/> node and combo data
   */
  public getNodeLikeData(ids?: ID[]) {
    return this.model.getAllNodes().reduce((acc, node) => {
      const data = toG6Data(node);
      if (ids) ids.includes(idOf(data)) && acc.push(data);
      else acc.push(data);
      return acc;
    }, [] as NodeLikeData[]);
  }

  public getElementDataByState(elementType: ElementType, state: string) {
    const elementData = this.getElementsDataByType(elementType);
    return elementData.filter((datum) => datum.states?.includes(state));
  }

  public getElementState(id: ID): State[] {
    return this.getElementDataById(id)?.states || [];
  }

  public hasNode(id: ID) {
    return this.model.hasNode(id) && !this.isCombo(id);
  }

  public hasEdge(id: ID) {
    return this.model.hasEdge(id);
  }

  public hasCombo(id: ID) {
    return this.model.hasNode(id) && this.isCombo(id);
  }

  public getRelatedEdgesData(id: ID, direction: EdgeDirection = 'both') {
    return this.model.getRelatedEdges(id, direction).map(toG6Data) as EdgeData[];
  }

  public getNeighborNodesData(id: ID) {
    return this.model.getNeighbors(id).map(toG6Data);
  }

  public setData(data: GraphData) {
    const { nodes: modifiedNodes = [], edges: modifiedEdges = [], combos: modifiedCombos = [] } = data;
    const { nodes: originalNodes, edges: originalEdges, combos: originalCombos } = this.getData();

    const nodeDiff = arrayDiff(originalNodes, modifiedNodes, (node) => idOf(node), isElementDataEqual);
    const edgeDiff = arrayDiff(originalEdges, modifiedEdges, (edge) => idOf(edge), isElementDataEqual);
    const comboDiff = arrayDiff(originalCombos, modifiedCombos, (combo) => idOf(combo), isElementDataEqual);

    this.batch(() => {
      const dataToAdd = {
        nodes: nodeDiff.enter,
        edges: edgeDiff.enter,
        combos: comboDiff.enter,
      };
      this.addData(dataToAdd);
      this.computeZIndex(dataToAdd, 'add', true);

      const dataToUpdate = {
        nodes: nodeDiff.update,
        edges: edgeDiff.update,
        combos: comboDiff.update,
      };
      this.updateData(dataToUpdate);
      this.computeZIndex(dataToUpdate, 'update', true);

      const dataToRemove = {
        nodes: nodeDiff.exit.map(idOf),
        edges: edgeDiff.exit.map(idOf),
        combos: comboDiff.exit.map(idOf),
      };
      this.removeData(dataToRemove);
    });
  }

  public addData(data: GraphData) {
    const { nodes, edges, combos } = data;
    this.batch(() => {
      // add combo first
      this.addComboData(combos);
      this.addNodeData(nodes);
      this.addEdgeData(edges);
    });
    this.computeZIndex(data, 'add');
  }

  public addNodeData(nodes: NodeData[] = []) {
    if (!nodes.length) return;
    this.model.addNodes(
      nodes.map((node) => {
        this.pushChange({ value: node, type: ChangeType.NodeAdded });
        return toGraphlibData(node);
      }),
    );
    this.updateNodeLikeHierarchy(nodes);

    this.computeZIndex({ nodes }, 'add');
  }

  public addEdgeData(edges: EdgeData[] = []) {
    if (!edges.length) return;
    this.model.addEdges(
      edges.map((edge) => {
        this.pushChange({ value: edge, type: ChangeType.EdgeAdded });
        return toGraphlibData(edge);
      }),
    );

    this.computeZIndex({ edges }, 'add');
  }

  public addComboData(combos: ComboData[] = []) {
    if (!combos.length) return;
    const { model } = this;

    if (!model.hasTreeStructure(COMBO_KEY)) {
      model.attachTreeStructure(COMBO_KEY);
    }

    model.addNodes(
      combos.map((combo) => {
        this.comboIds.add(idOf(combo));
        this.pushChange({ value: combo, type: ChangeType.ComboAdded });
        return toGraphlibData(combo);
      }),
    );

    this.updateNodeLikeHierarchy(combos);

    this.computeZIndex({ combos }, 'add');
  }

  public addChildrenData(parentId: ID, childrenData: NodeData[]) {
    const parentData = this.getNodeLikeDatum(parentId) as NodeData;
    const childrenId = childrenData.map(idOf);
    this.addNodeData(childrenData);
    this.updateNodeData([{ id: parentId, children: [...(parentData.children || []), ...childrenId] }]);
    this.addEdgeData(childrenId.map((childId) => ({ source: parentId, target: childId })));
  }

  /**
   * <zh/> 计算 zIndex
   *
   * <en/> Calculate zIndex
   * @param data - <zh/> 新增的数据 | <en/> newly added data
   * @param type - <zh/> 操作类型 | <en/> operation type
   * @param force - <zh/> 忽略批处理 | <en/> ignore batch processing
   * @remarks
   * <zh/> 调用该函数的情况：
   * - 新增元素
   * - 更新节点/组合的 combo
   * - 更新节点的 children
   *
   * <en/> The situation of calling this function:
   * - Add element
   * - Update the combo of the node/combo
   * - Update the children of the node
   */
  protected computeZIndex(data: PartialGraphData, type: 'add' | 'update', force = false) {
    if (!force && this.isBatching()) return;
    this.batch(() => {
      const { nodes = [], edges = [], combos = [] } = data;

      combos.forEach((combo) => {
        const id = idOf(combo);
        if (type === 'add' && isNumber(combo.style?.zIndex)) return;
        if (type === 'update' && !('combo' in combo)) return;

        const parent = this.getParentData(id, COMBO_KEY);
        const zIndex = parent ? (parent.style?.zIndex ?? 0) + 1 : 0;

        this.preventUpdateNodeLikeHierarchy(() => {
          this.updateComboData([{ id, style: { zIndex } }]);
        });
      });

      nodes.forEach((node) => {
        const id = idOf(node);
        if (type === 'add' && isNumber(node.style?.zIndex)) return;
        if (type === 'update' && !('combo' in node) && !('children' in node)) return;

        let zIndex = 0;

        const comboParent = this.getParentData(id, COMBO_KEY);
        if (comboParent) {
          zIndex = (comboParent.style?.zIndex || 0) + 1;
        } else {
          const nodeParent = this.getParentData(id, TREE_KEY);
          if (nodeParent) zIndex = nodeParent?.style?.zIndex || 0;
        }

        this.preventUpdateNodeLikeHierarchy(() => {
          this.updateNodeData([{ id, style: { zIndex } }]);
        });
      });

      edges.forEach((edge) => {
        if (isNumber(edge.style?.zIndex)) return;

        let { id, source, target } = edge;
        if (!id) id = idOf(edge);
        else {
          const datum = this.getEdgeDatum(id);
          source = datum.source;
          target = datum.target;
        }

        if (!source || !target) return;

        const sourceZIndex = this.getNodeLikeDatum(source)?.style?.zIndex || 0;
        const targetZIndex = this.getNodeLikeDatum(target)?.style?.zIndex || 0;

        this.updateEdgeData([{ id: idOf(edge), style: { zIndex: Math.max(sourceZIndex, targetZIndex) - 1 } }]);
      });
    });
  }

  /**
   * <zh/> 计算元素置顶后的 zIndex
   *
   * <en/> Calculate the zIndex after the element is placed on top
   * @param id - <zh/> 元素 ID | <en/> ID of the element
   * @returns <zh/> zIndex | <en/> zIndex
   */
  public getFrontZIndex(id: ID) {
    const elementType = this.getElementType(id);
    const elementData = this.getElementDataById(id);
    const data = this.getData();

    // 排除当前元素 / Exclude the current element
    Object.assign(data, {
      [`${elementType}s`]: data[`${elementType}s`].filter((element) => idOf(element) !== id),
    });

    if (elementType === 'combo') {
      // 如果 combo 展开，则排除 combo 的子节点/combo 及内部边
      // If the combo is expanded, exclude the child nodes/combos of the combo and the internal edges
      if (!isCollapsed(elementData as ComboData)) {
        const ancestorIds = new Set(this.getAncestorsData(id, COMBO_KEY).map(idOf));
        data.nodes = data.nodes.filter((element) => !ancestorIds.has(idOf(element)));
        data.combos = data.combos.filter((element) => !ancestorIds.has(idOf(element)));
        data.edges = data.edges.filter(({ source, target }) => !ancestorIds.has(source) && !ancestorIds.has(target));
      }
    }

    return Math.max(
      elementData.style?.zIndex || 0,
      0,
      ...Object.values(data)
        .flat()
        .map((datum) => (datum?.style?.zIndex || 0) + 1),
    );
  }

  protected updateNodeLikeHierarchy(data: NodeLikeData[]) {
    if (!this.enableUpdateNodeLikeHierarchy) return;
    const { model } = this;

    data.forEach((datum) => {
      const id = idOf(datum);
      const parent = parentIdOf(datum);

      if (parent !== undefined) {
        if (!model.hasTreeStructure(COMBO_KEY)) model.attachTreeStructure(COMBO_KEY);

        // 解除原父节点的子节点关系，更新原父节点及其祖先的数据
        // Remove the child relationship of the original parent node, update the data of the original parent node and its ancestors
        if (parent === null) {
          this.refreshComboData(id);
        }

        this.setParent(id, parentIdOf(datum), COMBO_KEY);
      }

      const children = (datum as NodeData).children || [];
      if (children.length) {
        if (!model.hasTreeStructure(TREE_KEY)) model.attachTreeStructure(TREE_KEY);
        const _children = children.filter((child) => model.hasNode(child));
        _children.forEach((child) => this.setParent(child, id, TREE_KEY));
        if (_children.length !== children.length) {
          // 从数据中移除不存在的子节点
          // Remove non-existent child nodes from the data
          this.updateNodeData([{ id, children: _children }]);
        }
      }
    });
  }

  private enableUpdateNodeLikeHierarchy = true;

  /**
   * <zh/> 执行变更时不要更新节点层次结构
   *
   * <en/> Do not update the node hierarchy when executing changes
   * @param callback - <zh/> 变更函数 | <en/> change function
   */
  public preventUpdateNodeLikeHierarchy(callback: () => void) {
    this.enableUpdateNodeLikeHierarchy = false;
    callback();
    this.enableUpdateNodeLikeHierarchy = true;
  }

  public updateData(data: PartialGraphData) {
    const { nodes, edges, combos } = data;
    this.batch(() => {
      this.updateNodeData(nodes);
      this.updateComboData(combos);
      this.updateEdgeData(edges);
    });
    this.computeZIndex(data, 'update');
  }

  public updateNodeData(nodes: PartialNodeLikeData<NodeData>[] = []) {
    if (!nodes.length) return;
    const { model } = this;
    this.batch(() => {
      const modifiedNodes: NodeData[] = [];
      nodes.forEach((modifiedNode) => {
        const id = idOf(modifiedNode);
        const originalNode = toG6Data(model.getNode(id));
        if (isElementDataEqual(originalNode, modifiedNode)) return;

        const value = mergeElementsData(originalNode, modifiedNode);
        this.pushChange({ value, original: originalNode, type: ChangeType.NodeUpdated });
        model.mergeNodeData(id, value);
        modifiedNodes.push(value);
      });

      this.updateNodeLikeHierarchy(modifiedNodes);
    });

    this.computeZIndex({ nodes }, 'update');
  }

  /**
   * <zh/> 将所有数据提交到变更记录中以进行重绘
   *
   * <en/> Submit all data to the change record for redrawing
   */
  public refreshData() {
    const { nodes, edges, combos } = this.getData();
    nodes.forEach((node) => {
      this.pushChange({ value: node, original: node, type: ChangeType.NodeUpdated });
    });
    edges.forEach((edge) => {
      this.pushChange({ value: edge, original: edge, type: ChangeType.EdgeUpdated });
    });
    combos.forEach((combo) => {
      this.pushChange({ value: combo, original: combo, type: ChangeType.ComboUpdated });
    });
  }

  public syncNodeLikeDatum(datum: PartialNodeLikeData<NodeData>) {
    const { model } = this;

    const id = idOf(datum);
    if (!model.hasNode(id)) return;
    const original = toG6Data(model.getNode(id));
    const value = mergeElementsData(original, datum);
    model.mergeNodeData(id, value);
  }

  public syncEdgeDatum(datum: PartialEdgeData<EdgeData>) {
    const { model } = this;

    const id = idOf(datum);
    if (!model.hasEdge(id)) return;
    const original = toG6Data(model.getEdge(id));
    const value = mergeElementsData(original, datum);
    model.mergeEdgeData(id, value);
  }

  public updateEdgeData(edges: PartialEdgeData<EdgeData>[] = []) {
    if (!edges.length) return;
    const { model } = this;
    this.batch(() => {
      edges.forEach((modifiedEdge) => {
        const id = idOf(modifiedEdge);
        const originalEdge = toG6Data(model.getEdge(id));
        if (isElementDataEqual(originalEdge, modifiedEdge)) return;

        if (modifiedEdge.source && originalEdge.source !== modifiedEdge.source) {
          model.updateEdgeSource(id, modifiedEdge.source);
        }
        if (modifiedEdge.target && originalEdge.target !== modifiedEdge.target) {
          model.updateEdgeTarget(id, modifiedEdge.target);
        }
        const updatedData = mergeElementsData(originalEdge, modifiedEdge);
        this.pushChange({ value: updatedData, original: originalEdge, type: ChangeType.EdgeUpdated });
        model.mergeEdgeData(id, updatedData);
      });
    });

    this.computeZIndex({ edges }, 'update');
  }

  public updateComboData(combos: PartialNodeLikeData<ComboData>[] = []) {
    if (!combos.length) return;
    const { model } = this;
    model.batch(() => {
      const modifiedCombos: ComboData[] = [];
      combos.forEach((modifiedCombo) => {
        const id = idOf(modifiedCombo);
        const originalCombo = toG6Data(model.getNode(id)) as ComboData;
        if (isElementDataEqual(originalCombo, modifiedCombo)) return;

        const value = mergeElementsData(originalCombo, modifiedCombo);
        this.pushChange({ value, original: originalCombo, type: ChangeType.ComboUpdated });
        model.mergeNodeData(id, value);
        modifiedCombos.push(value);
      });

      this.updateNodeLikeHierarchy(modifiedCombos);
    });

    this.computeZIndex({ combos }, 'update');
  }

  /**
   * <zh/> 设置节点的父节点
   *
   * <en/> Set the parent node of the node
   * @param id - <zh/> 节点 ID | <en/> node ID
   * @param parent - <zh/> 父节点 ID | <en/> parent node ID
   * @param hierarchyKey - <zh/> 层次结构类型 | <en/> hierarchy type
   * @param update - <zh/> 添加新/旧父节点数据更新记录 | <en/> add new/old parent node data update record
   */
  public setParent(id: ID, parent: ID | undefined | null, hierarchyKey: HierarchyKey, update: boolean = true) {
    if (id === parent) return;
    const elementData = this.getNodeLikeDatum(id);
    const originalParentId = parentIdOf(elementData);

    if (originalParentId !== parent && hierarchyKey === COMBO_KEY) {
      const modifiedDatum = { id, combo: parent };
      if (this.isCombo(id)) this.syncNodeLikeDatum(modifiedDatum);
      else this.syncNodeLikeDatum(modifiedDatum);
    }

    this.model.setParent(id, parent, hierarchyKey);

    if (update && hierarchyKey === COMBO_KEY) {
      uniq([originalParentId, parent]).forEach((pId) => {
        if (pId !== undefined) this.refreshComboData(pId);
      });
    }
  }

  /**
   * <zh/> 刷新 combo 数据
   *
   * <en/> Refresh combo data
   * @param id - <zh/> combo ID | <en/> combo ID
   * @remarks
   * <zh/> 不会更改数据，但会触发数据变更事件
   *
   * <en/> Will not change the data, but will trigger data change events
   */
  public refreshComboData(id: ID) {
    const combo = this.getComboData([id])[0];
    const ancestors = this.getAncestorsData(id, COMBO_KEY) as ComboData[];

    if (combo) this.pushChange({ value: combo, original: combo, type: ChangeType.ComboUpdated });

    ancestors.forEach((value) => {
      this.pushChange({ value: value, original: value, type: ChangeType.ComboUpdated });
    });
  }

  public getElementPosition(id: ID): Point {
    const datum = this.getElementDataById(id) as NodeLikeData;
    return positionOf(datum);
  }

  public translateNodeLikeBy(id: ID, offset: Point) {
    if (this.isCombo(id)) this.translateComboBy(id, offset);
    else this.translateNodeBy(id, offset);
  }

  public translateNodeLikeTo(id: ID, position: Point) {
    if (this.isCombo(id)) this.translateComboTo(id, position);
    else this.translateNodeTo(id, position);
  }

  public translateNodeBy(id: ID, offset: Point) {
    const curr = this.getElementPosition(id);
    const position = add(curr, [...offset, 0].slice(0, 3) as Point);
    this.translateNodeTo(id, position);
  }

  public translateNodeTo(id: ID, position: Point) {
    const [x = 0, y = 0, z = 0] = position;
    this.preventUpdateNodeLikeHierarchy(() => {
      this.updateNodeData([{ id, style: { x, y, z } }]);
    });
  }

  public translateComboBy(id: ID, offset: Point) {
    const [dx = 0, dy = 0, dz = 0] = offset;
    if ([dx, dy, dz].some(isNaN) || [dx, dy, dz].every((o) => o === 0)) return;
    const combo = this.getComboData([id])[0];
    if (!combo) return;
    const seenNodeLikeIds = new Set<ID>();
    dfs<NodeLikeData>(
      combo,
      (succeed) => {
        const succeedID = idOf(succeed);
        if (seenNodeLikeIds.has(succeedID)) return;
        seenNodeLikeIds.add(succeedID);
        const [x, y, z] = positionOf(succeed);
        const value = mergeElementsData(succeed, {
          style: { x: x + dx, y: y + dy, z: z + dz },
        });
        this.pushChange({
          value,
          // @ts-ignore
          original: succeed,
          type: this.isCombo(succeedID) ? ChangeType.ComboUpdated : ChangeType.NodeUpdated,
        });

        this.model.mergeNodeData(succeedID, value);
      },
      (node) => this.getChildrenData(idOf(node)),
      'BT',
    );
  }

  public translateComboTo(id: ID, position: Point) {
    if (position.some(isNaN)) return;
    const [tx = 0, ty = 0, tz = 0] = position;
    const combo = this.getComboData([id])?.[0];
    if (!combo) return;

    const [comboX, comboY, comboZ] = positionOf(combo);
    const dx = tx - comboX;
    const dy = ty - comboY;
    const dz = tz - comboZ;

    dfs<NodeLikeData>(
      combo,
      (succeed) => {
        const succeedId = idOf(succeed);
        const [x, y, z] = positionOf(succeed);
        const value = mergeElementsData(succeed, {
          style: { x: x + dx, y: y + dy, z: z + dz },
        });
        this.pushChange({
          value,
          // @ts-ignore
          original: succeed,
          type: this.isCombo(succeedId) ? ChangeType.ComboUpdated : ChangeType.NodeUpdated,
        });
        this.model.mergeNodeData(succeedId, value);
      },
      (node) => this.getChildrenData(idOf(node)),
      'BT',
    );
  }

  public removeData(data: DataID) {
    const { nodes, edges, combos } = data;
    this.batch(() => {
      // remove edges first
      this.removeEdgeData(edges);
      this.removeNodeData(nodes);
      this.removeComboData(combos);

      this.latestRemovedComboIds = new Set(combos);
    });
  }

  public removeNodeData(ids: ID[] = []) {
    if (!ids.length) return;
    this.batch(() => {
      ids.forEach((id) => {
        // 移除关联边、子节点
        // remove related edges and child nodes
        this.removeEdgeData(this.getRelatedEdgesData(id).map(idOf));
        // TODO 树图情况下移除子节点

        this.pushChange({ value: this.getNodeData([id])[0], type: ChangeType.NodeRemoved });
        this.removeNodeLikeHierarchy(id);
      });
      this.model.removeNodes(ids);
    });
  }

  public removeEdgeData(ids: ID[] = []) {
    if (!ids.length) return;
    ids.forEach((id) => this.pushChange({ value: this.getEdgeData([id])[0], type: ChangeType.EdgeRemoved }));
    this.model.removeEdges(ids);
  }

  public removeComboData(ids: ID[] = []) {
    if (!ids.length) return;
    this.batch(() => {
      ids.forEach((id) => {
        this.pushChange({ value: this.getComboData([id])[0], type: ChangeType.ComboRemoved });
        this.removeNodeLikeHierarchy(id);
        this.comboIds.delete(id);
      });
      this.model.removeNodes(ids);
    });
  }

  /**
   * <zh/> 移除节点层次结构，将其子节点移动到父节点的 children 列表中
   *
   * <en/> Remove the node hierarchy and move its child nodes to the parent node's children list
   * @param id - <zh/> 待处理的节点 | <en/> node to be processed
   */
  protected removeNodeLikeHierarchy(id: ID) {
    if (this.model.hasTreeStructure(COMBO_KEY)) {
      const grandParent = parentIdOf(this.getNodeLikeDatum(id));

      // 从父节点的 children 列表中移除
      // remove from its parent's children list
      // 调用 graphlib.setParent，不需要更新数据
      this.setParent(id, undefined, COMBO_KEY, false);
      // 将子节点移动到父节点的 children 列表中
      // move the children to the grandparent's children list

      this.model.getChildren(id, COMBO_KEY).forEach((child) => {
        const childData = toG6Data(child);
        const childId = idOf(childData);
        this.setParent(idOf(childData), grandParent, COMBO_KEY, false);
        const value = mergeElementsData(childData, {
          id: idOf(childData),
          combo: grandParent,
        });
        this.pushChange({
          value,
          original: childData,
          type: this.isCombo(childId) ? ChangeType.ComboUpdated : ChangeType.NodeUpdated,
        });
        this.model.mergeNodeData(idOf(childData), value);
      });

      if (!isNil(grandParent)) this.refreshComboData(grandParent);
    }
  }

  /**
   * <zh/> 获取元素的类型
   *
   * <en/> Get the type of the element
   * @param id - <zh/> 元素 ID | <en/> ID of the element
   * @returns <zh/> 元素类型 | <en/> type of the element
   */
  public getElementType(id: ID): ElementType {
    if (this.model.hasNode(id)) {
      if (this.isCombo(id)) return 'combo';
      return 'node';
    }

    if (this.model.hasEdge(id)) return 'edge';

    throw new Error(format(`Unknown element type of id: ${id}`));
  }

  public destroy() {
    const { model } = this;
    const nodes = model.getAllNodes();
    const edges = model.getAllEdges();

    model.removeEdges(edges.map((edge) => edge.id));
    model.removeNodes(nodes.map((node) => node.id));

    // @ts-expect-error force delete
    this.context = {};
  }
}
