import {
  applyNodeStyle,
  LayoutType,
  type MindmapElementModel,
  type MindmapNode,
  type MindmapRoot,
  type MindmapStyle,
  type NodeDetail,
  type NodeType,
  type ShapeElementModel,
} from '@blocksuite/affine-model';
import {
  generateKeyBetween,
  type SurfaceBlockModel,
} from '@blocksuite/block-std/gfx';
import { assertType, isEqual, type IVec, last } from '@blocksuite/global/utils';
import { DocCollection } from '@blocksuite/store';

import { fitContent } from '../../renderer/elements/shape/utils.js';
import { layout } from './layout.js';

export function getHoveredArea(
  target: ShapeElementModel,
  position: [number, number],
  layoutDir: LayoutType
): 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' {
  const { x, y, w, h } = target;
  const center =
    layoutDir === LayoutType.BALANCE
      ? [x + w / 2, y + h / 2]
      : layoutDir === LayoutType.LEFT
        ? [x + (w / 3) * 1, y + h / 2]
        : [x + (w / 3) * 2, y + h / 2];

  return `${position[1] - center[1] > 0 ? 'bottom' : 'top'}-${position[0] - center[0] > 0 ? 'right' : 'left'}`;
}

/**
 * Hide the connector between the target node and its parent
 */
export function hideNodeConnector(
  mindmap: MindmapElementModel,
  /**
   * The mind map node which's connector will be hide
   */
  target: MindmapNode
) {
  const parent = mindmap.getParentNode(target.id);

  if (!parent) {
    return;
  }

  const connectorId = `#${parent.id}-${target.id}`;
  const connector = mindmap.connectors.get(connectorId);

  if (!connector) {
    return;
  }

  connector.opacity = 0;

  return () => {
    connector.opacity = 1;
  };
}

/**
 * Move the node to the new parent within the same mind map
 * @param mindmap
 * @param node the node should already exist in the mind map
 * @param parent
 * @param targetIndex
 * @param layout
 * @returns
 */
function moveNodePosition(
  mindmap: MindmapElementModel,
  node: MindmapNode,
  parent: string | MindmapNode,
  targetIndex: number,
  layout?: LayoutType
) {
  parent = mindmap.nodeMap.get(
    typeof parent === 'string' ? parent : parent.id
  )!;

  if (!parent || !mindmap.nodeMap.has(node.id)) {
    return;
  }

  assertType<MindmapNode>(parent);

  if (layout === LayoutType.BALANCE || parent !== mindmap.tree) {
    layout = undefined;
  }

  const siblings = parent.children.filter(child => child !== node);

  targetIndex = Math.min(targetIndex, parent.children.length);

  siblings.splice(targetIndex, 0, node);

  // calculate the index
  // the sibling node may be the same node, so we need to filter it out
  const preSibling = siblings[targetIndex - 1];
  const afterSibling = siblings[targetIndex + 1];
  const index =
    preSibling || afterSibling
      ? generateKeyBetween(
          preSibling?.detail.index ?? null,
          afterSibling?.detail.index ?? null
        )
      : (node.detail.index ?? undefined);

  mindmap.surface.doc.transact(() => {
    const val: NodeDetail =
      layout !== undefined
        ? {
            ...node.detail,
            index,
            parent: parent.id,
          }
        : {
            ...node.detail,
            index,
            parent: parent.id,
          };

    mindmap.children.set(node.id, val);
  });

  if (parent.detail.collapsed) {
    mindmap.toggleCollapse(parent);
  }

  mindmap.layout();

  return mindmap.nodeMap.get(node.id);
}

export function applyStyle(
  mindmap: MindmapElementModel,
  shouldFitContent: boolean = false
) {
  mindmap.surface.doc.transact(() => {
    const style = mindmap.styleGetter;

    if (!style) return;

    applyNodeStyle(mindmap.tree, style.root);
    if (shouldFitContent) {
      fitContent(mindmap.tree.element as ShapeElementModel);
    }

    const walk = (node: MindmapNode, path: number[]) => {
      node.children.forEach((child, idx) => {
        const currentPath = [...path, idx];
        const nodeStyle = style.getNodeStyle(child, currentPath);

        applyNodeStyle(child, nodeStyle.node);
        if (shouldFitContent) {
          fitContent(child.element as ShapeElementModel);
        }

        walk(child, currentPath);
      });
    };

    walk(mindmap.tree, [0]);
  });
}

/**
 *
 * @param mindmap the mind map to add the node to
 * @param parent the parent node or the parent node id
 * @param node the node must be an detached node
 * @param targetIndex the index to insert the node at
 * @returns
 */
export function addNode(
  mindmap: MindmapElementModel,
  parent: string | MindmapNode,
  node: MindmapNode,
  targetIndex?: number
) {
  const parentNode = mindmap.getNode(
    typeof parent === 'string' ? parent : parent.id
  );

  if (!parentNode) {
    return;
  }

  const children = parentNode.children.slice();

  targetIndex =
    targetIndex !== undefined
      ? Math.min(targetIndex, children.length)
      : children.length;

  children.splice(targetIndex, 0, node);

  const before = children[targetIndex - 1] ?? null;
  const after = children[targetIndex + 1] ?? null;
  const index =
    before || after
      ? generateKeyBetween(
          before?.detail.index ?? null,
          after?.detail.index ?? null
        )
      : node.detail.index;

  mindmap.surface.doc.transact(() => {
    mindmap.children.set(node.id, {
      ...node.detail,
      index,
      parent: parentNode.id,
    });

    const recursiveAddChild = (node: MindmapNode) => {
      node.children?.forEach(child => {
        mindmap.children.set(child.id, {
          ...child.detail,
          parent: node.id,
        });

        recursiveAddChild(child);
      });
    };

    recursiveAddChild(node);
  });

  if (parentNode.detail.collapsed) {
    mindmap.toggleCollapse(parentNode);
  }

  mindmap.layout();
}

export function addTree(
  mindmap: MindmapElementModel,
  parent: string | MindmapNode,
  tree: NodeType | MindmapNode,
  /**
   * `sibling` indicates where to insert a subtree among peer elements.
   * If it's a string, it represents a peer element's ID;
   * if it's a number, it represents its index.
   * The subtree will be inserted before the sibling element.
   */
  sibling?: string | number
) {
  parent = typeof parent === 'string' ? parent : parent.id;

  if (!mindmap.nodeMap.has(parent) || !parent) {
    return null;
  }

  assertType<string>(parent);

  const traverse = (
    node: NodeType | MindmapNode,
    parent: string,
    sibling?: string | number
  ) => {
    let nodeId: string;
    if ('text' in node) {
      nodeId = mindmap.addNode(parent, sibling, 'before', {
        text: node.text,
      });
    } else {
      mindmap.children.set(node.id, {
        ...node.detail,
        parent,
      });
      nodeId = node.id;
    }

    node.children?.forEach(child => traverse(child, nodeId));

    return nodeId;
  };

  if (!('text' in tree)) {
    // Modify the children ymap directly hence need transaction
    mindmap.surface.doc.transact(() => {
      traverse(tree, parent, sibling);
    });

    applyStyle(mindmap);
    mindmap.layout();

    return mindmap.nodeMap.get(tree.id);
  } else {
    const nodeId = traverse(tree, parent, sibling);

    mindmap.layout();

    return mindmap.nodeMap.get(nodeId);
  }
}

/**
 * Detach a mindmap node or subtree. It is similar to `removeChild` but
 * it does not delete the node.
 *
 * So the node can be used to create a new mind map or merge into other mind map
 * @param mindmap the mind map that the subtree belongs to
 * @param subtree the subtree to detach
 */
export function detachMindmap(
  mindmap: MindmapElementModel,
  subtree: string | MindmapNode
) {
  subtree =
    typeof subtree === 'string' ? mindmap.nodeMap.get(subtree)! : subtree;

  assertType<MindmapNode>(subtree);

  if (!subtree) return;

  const traverse = (subtree: MindmapNode) => {
    mindmap.children.delete(subtree.id);

    // cut the reference inside the ymap
    subtree.detail = {
      ...subtree.detail,
    };

    subtree.children.forEach(child => traverse(child));
  };

  mindmap.surface.doc.transact(() => {
    traverse(subtree);
  });

  mindmap.layout();

  delete subtree.detail.parent;

  return subtree;
}

export function handleLayout(
  mindmap: MindmapElementModel,
  tree?: MindmapNode | MindmapRoot,
  shouldApplyStyle = true,
  layoutType?: LayoutType
) {
  if (!tree || !tree.element) return;

  if (shouldApplyStyle) {
    applyStyle(mindmap, true);
  }

  mindmap.surface.doc.transact(() => {
    const path = mindmap.getPath(tree.id);
    layout(tree, mindmap, layoutType ?? mindmap.getLayoutDir(tree.id), path);
  });
}

export function createFromTree(
  tree: MindmapNode,
  style: MindmapStyle,
  layoutType: LayoutType,
  surface: SurfaceBlockModel
) {
  const children = new DocCollection.Y.Map();
  const traverse = (subtree: MindmapNode, parent?: string) => {
    const value: NodeDetail = {
      ...subtree.detail,
      parent,
    };

    if (!parent) {
      delete value.parent;
    }

    children.set(subtree.id, value);

    subtree.children.forEach(child => traverse(child, subtree.id));
  };

  traverse(tree);

  const mindmapId = surface.addElement({
    type: 'mindmap',
    children,
    layoutType,
    style,
  });
  const mindmap = surface.getElementById(mindmapId) as MindmapElementModel;
  handleLayout(mindmap, mindmap.tree, true, mindmap.layoutType);

  return mindmap;
}

/**
 * Move a subtree from one mind map to another
 * @param from the mind map that the `subtree` belongs to
 * @param subtree the subtree to move
 * @param to the mind map to move the `subtree` to
 * @param parent the new parent node to attach the `subtree` to
 * @param index the index to insert the `subtree` at
 */
export function moveNode(
  from: MindmapElementModel,
  subtree: MindmapNode,
  to: MindmapElementModel,
  parent: MindmapNode | string,
  index: number
) {
  if (from === to) {
    return moveNodePosition(from, subtree, parent, index);
  }

  if (!detachMindmap(from, subtree)) return;

  return addNode(to, parent, subtree, index);
}

export function findTargetNode(
  mindmap: MindmapElementModel,
  position: IVec
): MindmapNode | null {
  const find = (node: MindmapNode): MindmapNode | null => {
    if (!node.responseArea) {
      return null;
    }

    const layoutDir = mindmap.getLayoutDir(node);

    if (
      layoutDir === LayoutType.BALANCE ||
      (layoutDir === LayoutType.RIGHT &&
        position[0] > node.element.x + node.element.w) ||
      (layoutDir === LayoutType.LEFT && position[0] < node.element.x)
    ) {
      for (const child of node.children) {
        const result = find(child);
        if (result) {
          return result;
        }
      }
    }

    return node.responseArea.containsPoint(position) ? node : null;
  };

  return find(mindmap.tree);
}

function determineInsertPosition(
  mindmap: MindmapElementModel,
  mindmapNode: MindmapNode,
  position: IVec
):
  | {
      type: 'child';
      layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
    }
  | {
      type: 'sibling';
      layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
      position: 'prev' | 'next';
    }
  | null {
  if (
    !mindmapNode.responseArea ||
    !mindmapNode.responseArea.containsPoint(position)
  ) {
    return null;
  }

  const layoutDir = mindmap.getLayoutDir(mindmapNode);
  const elementBound = mindmapNode.element.elementBound;
  const targetLayout: LayoutType.LEFT | LayoutType.RIGHT =
    layoutDir === LayoutType.BALANCE
      ? position[0] > elementBound.x + elementBound.w / 2
        ? LayoutType.RIGHT
        : LayoutType.LEFT
      : layoutDir;

  if (
    elementBound.containsPoint(position) ||
    (layoutDir === LayoutType.RIGHT
      ? position[0] > elementBound.x + elementBound.w
      : position[0] < elementBound.x)
  ) {
    return {
      type: 'child',
      layoutDir: targetLayout,
    };
  }

  if (
    mindmap.layoutType === LayoutType.BALANCE &&
    mindmap.getPath(mindmapNode.id).length === 2
  ) {
    return {
      type: 'sibling',
      layoutDir: targetLayout,
      position:
        targetLayout === LayoutType.LEFT
          ? position[1] > elementBound.y + elementBound.h / 2
            ? 'prev'
            : 'next'
          : position[1] > elementBound.y + elementBound.h / 2
            ? 'next'
            : 'prev',
    };
  }

  return {
    type: 'sibling',
    layoutDir: targetLayout,
    position:
      position[1] > elementBound.y + elementBound.h / 2 ? 'next' : 'prev',
  };
}

function showMergeIndicator(
  targetMindMap: MindmapElementModel,
  target: MindmapNode,
  sourceMindMap: MindmapElementModel,
  source: MindmapNode,
  insertPosition:
    | {
        type: 'sibling';
        layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
        position: 'prev' | 'next';
      }
    | { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> },
  callback: (option: {
    targetMindMap: MindmapElementModel;
    target: MindmapNode;
    sourceMindMap: MindmapElementModel;
    source: MindmapNode;
    newParent: MindmapNode;
    insertPosition:
      | {
          type: 'sibling';
          layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
          position: 'prev' | 'next';
        }
      | { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
    path: number[];
  }) => () => void
) {
  const newParent =
    insertPosition.type === 'child'
      ? target
      : targetMindMap.getParentNode(target.id)!;

  if (!newParent) {
    return null;
  }

  const path = targetMindMap.getPath(newParent);
  const curPath = sourceMindMap.getPath(source.id);

  if (insertPosition.type === 'sibling') {
    const curPath = targetMindMap.getPath(target.id);
    const parent = targetMindMap.getParentNode(target.id);

    if (!parent) {
      return null;
    }

    const idx = parent.children
      .filter(child => child.id !== source.id)
      .indexOf(target);

    path.push(
      idx === -1
        ? Math.max(
            0,
            last(curPath)! + (insertPosition.position === 'next' ? 1 : 0)
          )
        : Math.max(0, idx + (insertPosition.position === 'next' ? 1 : 0))
    );
  } else {
    path.push(target.children.length);
  }

  // hide original connector
  const abortPreview = callback({
    targetMindMap,
    target: target,
    sourceMindMap,
    source,
    newParent,
    insertPosition,
    path,
  });

  const abort = () => {
    abortPreview?.();
  };

  const merge = () => {
    abort();

    if (targetMindMap === sourceMindMap && isEqual(path, curPath)) {
      return;
    }

    moveNode(sourceMindMap, source, targetMindMap, newParent, last(path)!);
  };

  return {
    abort,
    merge,
  };
}

/**
 * Try to move a node to another mind map.
 * It will show a merge indicator if the node can be merged to the target mind map.
 * @param targetMindMap
 * @param target
 * @param sourceMindMap
 * @param source
 * @param position
 * @return return two functions, `abort` and `merge`. `abort` will cancel the operation and `merge` will merge the node to the target mind map.
 */
export function tryMoveNode(
  targetMindMap: MindmapElementModel,
  target: MindmapNode,
  sourceMindMap: MindmapElementModel,
  source: MindmapNode,
  position: IVec,
  callback: (option: {
    targetMindMap: MindmapElementModel;
    target: MindmapNode;
    sourceMindMap: MindmapElementModel;
    source: MindmapNode;
    newParent: MindmapNode;
    insertPosition:
      | {
          type: 'sibling';
          layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
          position: 'prev' | 'next';
        }
      | { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
    path: number[];
  }) => () => void
) {
  const insertInfo = determineInsertPosition(targetMindMap, target, position);

  if (!insertInfo) {
    return null;
  }

  return showMergeIndicator(
    targetMindMap,
    target,
    sourceMindMap,
    source,
    insertInfo,
    callback
  );
}

/**
 * Check if the mind map contains the target node.
 * @param mindmap Mind map to check
 * @param targetNode Node to check
 * @param searchParent If provided, check if the node is a descendant of the parent node. Otherwise, check the whole mind map.
 * @returns
 */
export function containsNode(
  mindmap: MindmapElementModel,
  targetNode: MindmapNode,
  searchParent?: MindmapNode
) {
  searchParent = searchParent ?? mindmap.tree;

  const find = (checkAgainstNode: MindmapNode) => {
    if (checkAgainstNode.id === targetNode.id) {
      return true;
    }

    for (const child of checkAgainstNode.children) {
      if (find(child)) {
        return true;
      }
    }

    return false;
  };

  return find(searchParent);
}
