import { bindAll, each, isArray, isFunction, isString, result } from 'underscore';
import { BlockProperties } from '../block_manager/model/Block';
import CanvasModule from '../canvas';
import { CanvasSpotBuiltInTypes } from '../canvas/model/CanvasSpot';
import { $, Collection, Model, View } from '../common';
import EditorModel from '../editor/model/Editor';
import { getPointerEvent, isTextNode, off, on } from './dom';
import { getElement, getModel, matches } from './mixins';

type DropContent = BlockProperties['content'];

interface Dim {
  top: number;
  left: number;
  height: number;
  width: number;
  offsets: ReturnType<CanvasModule['getElementOffsets']>;
  dir?: boolean;
  el?: HTMLElement;
  indexEl?: number;
}

interface Pos {
  index: number;
  indexEl: number;
  method: string;
}

export interface SorterOptions {
  borderOffset?: number;
  container?: HTMLElement;
  containerSel?: string;
  itemSel?: string;
  draggable?: boolean | string[];
  nested?: boolean;
  pfx?: string;
  ppfx?: string;
  freezeClass?: string;
  onStart?: Function;
  onEndMove?: Function;
  customTarget?: Function;
  onEnd?: Function;
  onMove?: Function;
  direction?: 'v' | 'h' | 'a';
  relative?: boolean;
  ignoreViewChildren?: boolean;
  placer?: HTMLElement;
  document?: Document;
  wmargin?: number;
  offsetTop?: number;
  offsetLeft?: number;
  em?: EditorModel;
  canvasRelative?: boolean;
  avoidSelectOnEnd?: boolean;
  scale?: number;
}

const noop = () => {};

const targetSpotType = CanvasSpotBuiltInTypes.Target;

const spotTarget = {
  id: 'sorter-target',
  type: targetSpotType,
};

export default class Sorter extends View {
  opt!: SorterOptions;
  elT!: number;
  elL!: number;
  borderOffset!: number;
  containerSel!: string;
  itemSel!: string;
  draggable!: SorterOptions['draggable'];
  nested!: boolean;
  pfx!: string;
  ppfx?: string;
  freezeClass?: string;
  onStart!: Function;
  onEndMove?: Function;
  customTarget?: Function;
  onEnd?: Function;
  onMoveClb?: Function;
  direction!: 'v' | 'h' | 'a';
  relative!: boolean;
  ignoreViewChildren!: boolean;
  plh?: HTMLElement;
  document!: Document;
  wmargin!: number;
  offTop!: number;
  offLeft!: number;
  dropContent?: DropContent;
  em?: EditorModel;
  dragHelper?: HTMLElement;
  canvasRelative!: boolean;
  selectOnEnd!: boolean;
  scale?: number;
  activeTextModel?: Model;
  dropModel?: Model;

  target?: HTMLElement;
  prevTarget?: HTMLElement;
  sourceEl?: HTMLElement;
  moved?: boolean;
  srcModel?: Model;
  targetModel?: Model;
  rX?: number;
  rY?: number;
  eventMove?: MouseEvent;
  prevTargetDim?: Dim;
  cacheDimsP?: Dim[];
  cacheDims?: Dim[];
  targetP?: HTMLElement;
  targetPrev?: HTMLElement;
  lastPos?: Pos;
  lastDims?: Dim[];
  $plh?: any;
  toMove?: Model | Model[];

  /** @ts-ignore */
  initialize(opt: SorterOptions = {}) {
    this.opt = opt || {};
    bindAll(this, 'startSort', 'onMove', 'endMove', 'rollback', 'updateOffset', 'moveDragHelper');
    var o = opt || {};
    this.elT = 0;
    this.elL = 0;
    this.borderOffset = o.borderOffset || 10;

    var el = o.container;
    this.el = typeof el === 'string' ? document.querySelector(el)! : el!;
    this.$el = $(this.el); // TODO check if necessary

    this.containerSel = o.containerSel || 'div';
    this.itemSel = o.itemSel || 'div';
    this.draggable = o.draggable || true;
    this.nested = !!o.nested;
    this.pfx = o.pfx || '';
    this.ppfx = o.ppfx || '';
    this.freezeClass = o.freezeClass || this.pfx + 'freezed';
    this.onStart = o.onStart || noop;
    this.onEndMove = o.onEndMove;
    this.customTarget = o.customTarget;
    this.onEnd = o.onEnd;
    this.direction = o.direction || 'v'; // v (vertical), h (horizontal), a (auto)
    this.onMoveClb = o.onMove;
    this.relative = o.relative || false;
    this.ignoreViewChildren = !!o.ignoreViewChildren;
    this.plh = o.placer;
    // Frame offset
    this.wmargin = o.wmargin || 0;
    this.offTop = o.offsetTop || 0;
    this.offLeft = o.offsetLeft || 0;
    this.document = o.document || document;
    this.em = o.em;
    this.canvasRelative = !!o.canvasRelative;
    this.selectOnEnd = !o.avoidSelectOnEnd;
    this.scale = o.scale;

    if (this.em && this.em.on) {
      this.em.on('change:canvasOffset', this.updateOffset);
      this.updateOffset();
    }
  }

  getScale() {
    return result(this, 'scale') || 1;
  }

  getContainerEl(elem?: HTMLElement) {
    if (elem) this.el = elem;

    if (!this.el) {
      var el = this.opt.container;
      this.el = typeof el === 'string' ? document.querySelector(el)! : el!;
      this.$el = $(this.el); // TODO check if necessary
    }

    return this.el;
  }

  getDocuments(el?: HTMLElement) {
    const em = this.em;
    const elDoc = el ? el.ownerDocument : em?.Canvas.getBody().ownerDocument;
    const docs = [document];
    elDoc && docs.push(elDoc);
    return docs;
  }

  /**
   * Triggered when the offset of the editro is changed
   */
  updateOffset() {
    const offset = this.em?.get('canvasOffset') || {};
    this.offTop = offset.top;
    this.offLeft = offset.left;
  }

  /**
   * Set content to drop
   * @param {String|Object} content
   */
  setDropContent(content: DropContent) {
    delete this.dropModel;
    this.dropContent = content;
  }

  updateTextViewCursorPosition(e: any) {
    const { em } = this;
    if (!em) return;
    const Canvas = em.Canvas;
    const targetDoc = Canvas.getDocument();
    let range = null;

    if (targetDoc.caretRangeFromPoint) {
      // Chrome
      const poiner = getPointerEvent(e);
      range = targetDoc.caretRangeFromPoint(poiner.clientX, poiner.clientY);
    } else if (e.rangeParent) {
      // Firefox
      range = targetDoc.createRange();
      range.setStart(e.rangeParent, e.rangeOffset);
    }

    const sel = Canvas.getWindow().getSelection();
    Canvas.getFrameEl().focus();
    sel?.removeAllRanges();
    range && sel?.addRange(range);
    this.setContentEditable(this.activeTextModel, true);
  }

  setContentEditable(model?: Model, mode?: boolean) {
    if (model) {
      // @ts-ignore
      const el = model.getEl();
      if (el.contentEditable != mode) el.contentEditable = mode;
    }
  }

  /**
   * Toggle cursor while sorting
   * @param {Boolean} active
   */
  toggleSortCursor(active?: boolean) {
    const { em } = this;
    const cv = em?.Canvas;

    // Avoid updating body className as it causes a huge repaint
    // Noticeable with "fast" drag of blocks
    cv && (active ? cv.startAutoscroll() : cv.stopAutoscroll());
  }

  /**
   * Set drag helper
   * @param {HTMLElement} el
   * @param {Event} event
   */
  setDragHelper(el: HTMLElement, event: Event) {
    const ev = event || '';
    const clonedEl = el.cloneNode(true) as HTMLElement;
    const rect = el.getBoundingClientRect();
    const computed = getComputedStyle(el);
    let style = '';

    for (var i = 0; i < computed.length; i++) {
      const prop = computed[i];
      style += `${prop}:${computed.getPropertyValue(prop)};`;
    }

    document.body.appendChild(clonedEl);
    clonedEl.className += ` ${this.pfx}bdrag`;
    clonedEl.setAttribute('style', style);
    this.dragHelper = clonedEl;
    clonedEl.style.width = `${rect.width}px`;
    clonedEl.style.height = `${rect.height}px`;
    ev && this.moveDragHelper(ev);

    // Listen mouse move events
    if (this.em) {
      const $doc = $(this.em.Canvas.getBody().ownerDocument);
      $doc.off('mousemove', this.moveDragHelper).on('mousemove', this.moveDragHelper);
    }
    $(document).off('mousemove', this.moveDragHelper).on('mousemove', this.moveDragHelper);
  }

  /**
   * Update the position of the helper
   * @param  {Event} e
   */
  moveDragHelper(e: any) {
    const doc = (e.target as HTMLElement).ownerDocument;

    if (!this.dragHelper || !doc) {
      return;
    }

    let posY = e.pageY;
    let posX = e.pageX;
    let addTop = 0;
    let addLeft = 0;
    // @ts-ignore
    const window = doc.defaultView || (doc.parentWindow as Window);
    const frame = window.frameElement;
    const dragHelperStyle = this.dragHelper.style;

    // If frame is present that means mouse has moved over the editor's canvas,
    // which is rendered inside the iframe and the mouse move event comes from
    // the iframe, not the parent window. Mouse position relative to the frame's
    // parent window needs to account for the frame's position relative to the
    // parent window.
    if (frame) {
      const frameRect = frame.getBoundingClientRect();
      addTop = frameRect.top + document.documentElement.scrollTop;
      addLeft = frameRect.left + document.documentElement.scrollLeft;
      posY = e.clientY;
      posX = e.clientX;
    }

    dragHelperStyle.top = posY + addTop + 'px';
    dragHelperStyle.left = posX + addLeft + 'px';
  }

  /**
   * Returns true if the element matches with selector
   * @param {Element} el
   * @param {String} selector
   * @return {Boolean}
   */
  matches(el: HTMLElement, selector: string) {
    return matches.call(el, selector);
  }

  /**
   * Closest parent
   * @param {Element} el
   * @param {String} selector
   * @return {Element|null}
   */
  closest(el: HTMLElement, selector: string): HTMLElement | undefined {
    if (!el) return;
    let elem = el.parentNode;

    while (elem && elem.nodeType === 1) {
      if (this.matches(elem as HTMLElement, selector)) return elem as HTMLElement;
      elem = elem.parentNode;
    }
  }

  /**
   * Get the offset of the element
   * @param  {HTMLElement} el
   * @return {Object}
   */
  offset(el: HTMLElement) {
    const rect = el.getBoundingClientRect();

    return {
      top: rect.top + document.body.scrollTop,
      left: rect.left + document.body.scrollLeft,
    };
  }

  /**
   * Create placeholder
   * @return {HTMLElement}
   */
  createPlaceholder() {
    const { pfx } = this;
    const el = document.createElement('div');
    const ins = document.createElement('div');
    el.className = pfx + 'placeholder';
    el.style.display = 'none';
    el.style.pointerEvents = 'none';
    ins.className = pfx + 'placeholder-int';
    el.appendChild(ins);
    return el;
  }

  /**
   * Picking component to move
   * @param {HTMLElement} src
   * */
  startSort(src?: HTMLElement, opts: { container?: HTMLElement } = {}) {
    const { em, itemSel, containerSel, plh } = this;
    const container = this.getContainerEl(opts.container);
    const docs = this.getDocuments(src);
    let srcModel;
    delete this.dropModel;
    delete this.target;
    delete this.prevTarget;
    this.moved = false;

    // Check if the start element is a valid one, if not, try the closest valid one
    if (src && !this.matches(src, `${itemSel}, ${containerSel}`)) {
      src = this.closest(src, itemSel)!;
    }

    this.sourceEl = src;

    // Create placeholder if doesn't exist yet
    if (!plh) {
      this.plh = this.createPlaceholder();
      container.appendChild(this.plh);
    }

    if (src) {
      srcModel = this.getSourceModel(src);
      srcModel?.set && srcModel.set('status', 'freezed');
      this.srcModel = srcModel;
    }

    on(container, 'mousemove dragover', this.onMove as any);
    on(docs, 'mouseup dragend touchend', this.endMove);
    on(docs, 'keydown', this.rollback);
    this.onStart({
      sorter: this,
      target: srcModel,
      // @ts-ignore
      parent: srcModel && srcModel.parent?.(),
      // @ts-ignore
      index: srcModel && srcModel.index?.(),
    });

    // Avoid strange effects on dragging
    em?.clearSelection();
    this.toggleSortCursor(true);
    em?.trigger('sorter:drag:start', src, srcModel);
  }

  /**
   * Get the model from HTMLElement target
   * @return {Model|null}
   */
  getTargetModel(el: HTMLElement) {
    const elem = el || this.target;
    return $(elem).data('model');
  }

  /**
   * Get the model of the current source element (element to drag)
   * @return {Model}
   */
  getSourceModel(source?: HTMLElement, { target, avoidChildren = 1 }: any = {}): Model {
    const { em, sourceEl } = this;
    const src = source || sourceEl;
    let { dropModel, dropContent } = this;
    const isTextable = (src: any) =>
      src && target && src.opt && src.opt.avoidChildren && this.isTextableActive(src, target);

    if (dropContent && em) {
      if (isTextable(dropModel)) {
        dropModel = undefined;
      }

      if (!dropModel) {
        const comps = em.Components.getComponents();
        const opts = {
          avoidChildren,
          avoidStore: 1,
          avoidUpdateStyle: 1,
        };
        const tempModel = comps.add(dropContent, { ...opts, temporary: true });
        // @ts-ignore
        dropModel = comps.remove(tempModel, opts as any);
        dropModel = dropModel instanceof Array ? dropModel[0] : dropModel;
        this.dropModel = dropModel;

        if (isTextable(dropModel)) {
          return this.getSourceModel(src, { target, avoidChildren: 0 });
        }
      }

      return dropModel!;
    }

    return src && $(src).data('model');
  }

  /**
   * Highlight target
   * @param  {Model|null} model
   */
  selectTargetModel(model?: Model, source?: Model) {
    if (model instanceof Collection) {
      return;
    }

    // Prevents loops in Firefox
    // https://github.com/GrapesJS/grapesjs/issues/2911
    if (source && source === model) return;

    const { targetModel } = this;

    // Reset the previous model but not if it's the same as the source
    // https://github.com/GrapesJS/grapesjs/issues/2478#issuecomment-570314736
    if (targetModel && targetModel !== this.srcModel) {
      targetModel.set('status', '');
    }

    if (model?.set) {
      const cv = this.em!.Canvas;
      const { Select, Hover, Spacing } = CanvasSpotBuiltInTypes;
      [Select, Hover, Spacing].forEach(type => cv.removeSpots({ type }));
      cv.addSpot({ ...spotTarget, component: model as any });
      model.set('status', 'selected-parent');
      this.targetModel = model;
    }
  }

  /**
   * During move
   * @param {Event} e
   * */
  onMove(e: MouseEvent) {
    const ev = e;
    const { em, onMoveClb, plh, customTarget } = this;
    this.moved = true;

    // Turn placeholder visibile
    const dsp = plh!.style.display;
    if (!dsp || dsp === 'none') plh!.style.display = 'block';

    // Cache all necessary positions
    var eO = this.offset(this.el);
    this.elT = this.wmargin ? Math.abs(eO.top) : eO.top;
    this.elL = this.wmargin ? Math.abs(eO.left) : eO.left;
    var rY = e.pageY - this.elT + this.el.scrollTop;
    var rX = e.pageX - this.elL + this.el.scrollLeft;

    if (this.canvasRelative && em) {
      const mousePos = em.Canvas.getMouseRelativeCanvas(e, { noScroll: 1 });
      rX = mousePos.x;
      rY = mousePos.y;
    }

    this.rX = rX;
    this.rY = rY;
    this.eventMove = e;

    //var targetNew = this.getTargetFromEl(e.target);
    const sourceModel = this.getSourceModel();
    const targetEl = customTarget ? customTarget({ sorter: this, event: e }) : e.target;
    const dims = this.dimsFromTarget(targetEl as HTMLElement, rX, rY);
    const target = this.target;
    const targetModel = target && this.getTargetModel(target);
    this.selectTargetModel(targetModel, sourceModel);
    if (!targetModel) plh!.style.display = 'none';
    if (!target) return;
    this.lastDims = dims;
    const pos = this.findPosition(dims, rX, rY);

    if (this.isTextableActive(sourceModel, targetModel)) {
      this.activeTextModel = targetModel;
      plh!.style.display = 'none';
      this.lastPos = pos;
      this.updateTextViewCursorPosition(ev);
    } else {
      this.disableTextable();
      delete this.activeTextModel;

      // If there is a significant changes with the pointer
      if (!this.lastPos || this.lastPos.index != pos.index || this.lastPos.method != pos.method) {
        this.movePlaceholder(this.plh!, dims, pos, this.prevTargetDim);
        if (!this.$plh) this.$plh = $(this.plh!);

        // With canvasRelative the offset is calculated automatically for
        // each element
        if (!this.canvasRelative) {
          if (this.offTop) this.$plh.css('top', '+=' + this.offTop + 'px');
          if (this.offLeft) this.$plh.css('left', '+=' + this.offLeft + 'px');
        }

        this.lastPos = pos;
      }
    }

    isFunction(onMoveClb) &&
      onMoveClb({
        event: e,
        target: sourceModel,
        parent: targetModel,
        index: pos.index + (pos.method == 'after' ? 1 : 0),
      });

    em &&
      em.trigger('sorter:drag', {
        target,
        targetModel,
        sourceModel,
        dims,
        pos,
        x: rX,
        y: rY,
      });
  }

  isTextableActive(src: any, trg: any) {
    return src?.get?.('textable') && trg?.isInstanceOf('text');
  }

  disableTextable() {
    const { activeTextModel } = this;
    // @ts-ignore
    activeTextModel?.getView().disableEditing();
    this.setContentEditable(activeTextModel, false);
  }

  /**
   * Returns true if the elements is in flow, so is not in flow where
   * for example the component is with float:left
   * @param  {HTMLElement} el
   * @param  {HTMLElement} parent
   * @return {Boolean}
   * @private
   * */
  isInFlow(el: HTMLElement, parent?: HTMLElement) {
    if (!el) return false;

    parent = parent || document.body;
    var ch = -1,
      h;
    var elem = el;
    h = elem.offsetHeight;
    if (/*h < ch || */ !this.styleInFlow(elem, parent)) return false;
    else return true;
  }

  /**
   * Check if el has style to be in flow
   * @param  {HTMLElement} el
   * @param  {HTMLElement} parent
   * @return {Boolean}
   * @private
   */
  styleInFlow(el: HTMLElement, parent: HTMLElement) {
    if (isTextNode(el)) return;
    const style = el.style || {};
    const $el = $(el);
    const $parent = parent && $(parent);

    if (style.overflow && style.overflow !== 'visible') return;
    const propFloat = $el.css('float');
    if (propFloat && propFloat !== 'none') return;
    if ($parent && $parent.css('display') == 'flex' && $parent.css('flex-direction') !== 'column') return;
    switch (style.position) {
      case 'static':
      case 'relative':
      case '':
        break;
      default:
        return;
    }
    switch (el.tagName) {
      case 'TR':
      case 'TBODY':
      case 'THEAD':
      case 'TFOOT':
        return true;
    }
    switch ($el.css('display')) {
      case 'block':
      case 'list-item':
      case 'table':
      case 'flex':
      case 'grid':
        return true;
    }
    return;
  }

  /**
   * Check if the target is valid with the actual source
   * @param  {HTMLElement} trg
   * @return {Boolean}
   */
  validTarget(trg: HTMLElement, src?: HTMLElement) {
    const trgModel = this.getTargetModel(trg);
    const srcModel = this.getSourceModel(src, { target: trgModel });
    // @ts-ignore
    src = srcModel && srcModel.view && srcModel.view.el;
    trg = trgModel && trgModel.view && trgModel.view.el;
    let result = {
      valid: true,
      src,
      srcModel,
      trg,
      trgModel,
      draggable: false,
      droppable: false,
      dragInfo: '',
      dropInfo: '',
    };

    if (!src || !trg) {
      result.valid = false;
      return result;
    }

    // Check if the source is draggable in target
    let draggable = srcModel.get('draggable');
    if (isFunction(draggable)) {
      const res = draggable(srcModel, trgModel);
      result.dragInfo = res;
      result.draggable = res;
      draggable = res;
    } else {
      draggable = draggable instanceof Array ? draggable.join(', ') : draggable;
      result.dragInfo = draggable;
      draggable = isString(draggable) ? this.matches(trg, draggable) : draggable;
      result.draggable = draggable;
    }

    // Check if the target could accept the source
    let droppable = trgModel.get('droppable');
    if (isFunction(droppable)) {
      const res = droppable(srcModel, trgModel);
      result.droppable = res;
      result.dropInfo = res;
      droppable = res;
    } else {
      droppable = droppable instanceof Collection ? 1 : droppable;
      droppable = droppable instanceof Array ? droppable.join(', ') : droppable;
      result.dropInfo = droppable;
      droppable = isString(droppable) ? this.matches(src, droppable) : droppable;
      droppable = draggable && this.isTextableActive(srcModel, trgModel) ? 1 : droppable;
      result.droppable = droppable;
    }

    if (!droppable || !draggable) {
      result.valid = false;
    }

    return result;
  }

  /**
   * Get dimensions of nodes relative to the coordinates
   * @param  {HTMLElement} target
   * @param {number} rX Relative X position
   * @param {number} rY Relative Y position
   * @return {Array<Array>}
   */
  dimsFromTarget(target: HTMLElement, rX = 0, rY = 0): Dim[] {
    const em = this.em;
    let dims: Dim[] = [];

    if (!target) {
      return dims;
    }

    // Select the first valuable target
    if (!this.matches(target, `${this.itemSel}, ${this.containerSel}`)) {
      target = this.closest(target, this.itemSel)!;
    }

    // If draggable is an array the target will be one of those
    if (this.draggable instanceof Array) {
      target = this.closest(target, this.draggable.join(','))!;
    }

    if (!target) {
      return dims;
    }

    // Check if the target is different from the previous one
    if (this.prevTarget && this.prevTarget != target) {
      delete this.prevTarget;
    }

    // New target found
    if (!this.prevTarget) {
      this.targetP = this.closest(target, this.containerSel);

      // Check if the source is valid with the target
      let validResult = this.validTarget(target);
      em && em.trigger('sorter:drag:validation', validResult);

      if (!validResult.valid && this.targetP) {
        return this.dimsFromTarget(this.targetP, rX, rY);
      }

      this.prevTarget = target;
      this.prevTargetDim = this.getDim(target);
      this.cacheDimsP = this.getChildrenDim(this.targetP!);
      this.cacheDims = this.getChildrenDim(target);
    }

    // If the target is the previous one will return the cached dims
    if (this.prevTarget == target) dims = this.cacheDims!;

    // Target when I will drop element to sort
    this.target = this.prevTarget;

    // Generally, on any new target the poiner enters inside its area and
    // triggers nearBorders(), so have to take care of this
    if (this.nearBorders(this.prevTargetDim!, rX, rY) || (!this.nested && !this.cacheDims!.length)) {
      const targetParent = this.targetP;

      if (targetParent && this.validTarget(targetParent).valid) {
        dims = this.cacheDimsP!;
        this.target = targetParent;
      }
    }

    delete this.lastPos;
    return dims;
  }

  /**
   * Get valid target from element
   * This method should replace dimsFromTarget()
   * @param  {HTMLElement} el
   * @return {HTMLElement}
   */
  getTargetFromEl(el: HTMLElement): HTMLElement {
    let target = el;
    let targetParent;
    let targetPrev = this.targetPrev;
    const em = this.em;
    const containerSel = this.containerSel;
    const itemSel = this.itemSel;

    // Select the first valuable target
    if (!this.matches(target, `${itemSel}, ${containerSel}`)) {
      target = this.closest(target, itemSel)!;
    }

    // If draggable is an array the target will be one of those
    // TODO check if this options is used somewhere
    if (this.draggable instanceof Array) {
      target = this.closest(target, this.draggable.join(','))!;
    }

    // Check if the target is different from the previous one
    if (targetPrev && targetPrev != target) {
      delete this.targetPrev;
    }

    // New target found
    if (!this.targetPrev) {
      targetParent = this.closest(target, containerSel);

      // If the current target is not valid (src/trg reasons) try with
      // the parent one (if exists)
      const validResult = this.validTarget(target);
      em && em.trigger('sorter:drag:validation', validResult);

      if (!validResult.valid && targetParent) {
        return this.getTargetFromEl(targetParent);
      }

      this.targetPrev = target;
    }

    // Generally, on any new target the poiner enters inside its area and
    // triggers nearBorders(), so have to take care of this
    if (this.nearElBorders(target)) {
      targetParent = this.closest(target, containerSel);

      if (targetParent && this.validTarget(targetParent).valid) {
        target = targetParent;
      }
    }

    return target;
  }

  /**
   * Check if the current pointer is neare to element borders
   * @return {Boolen}
   */
  nearElBorders(el: HTMLElement) {
    const off = 10;
    const rect = el.getBoundingClientRect();
    const body = el.ownerDocument.body;
    const { x, y } = this.getCurrentPos();
    const top = rect.top + body.scrollTop;
    const left = rect.left + body.scrollLeft;
    const width = rect.width;
    const height = rect.height;

    if (
      y < top + off || // near top edge
      y > top + height - off || // near bottom edge
      x < left + off || // near left edge
      x > left + width - off // near right edge
    ) {
      return 1;
    }
  }

  getCurrentPos() {
    const ev = this.eventMove;
    const x = ev?.pageX || 0;
    const y = ev?.pageY || 0;
    return { x, y };
  }

  /**
   * Returns dimensions and positions about the element
   * @param {HTMLElement} el
   * @return {Array<number>}
   */
  getDim(el: HTMLElement): Dim {
    const { em, canvasRelative } = this;
    const canvas = em?.Canvas;
    const offsets = canvas ? canvas.getElementOffsets(el) : {};
    let top, left, height, width;

    if (canvasRelative && em) {
      const pos = canvas!.getElementPos(el, { noScroll: 1 })!;
      top = pos.top; // - offsets.marginTop;
      left = pos.left; // - offsets.marginLeft;
      height = pos.height; // + offsets.marginTop + offsets.marginBottom;
      width = pos.width; // + offsets.marginLeft + offsets.marginRight;
    } else {
      var o = this.offset(el);
      top = this.relative ? el.offsetTop : o.top - (this.wmargin ? -1 : 1) * this.elT;
      left = this.relative ? el.offsetLeft : o.left - (this.wmargin ? -1 : 1) * this.elL;
      height = el.offsetHeight;
      width = el.offsetWidth;
    }

    return { top, left, height, width, offsets };
  }

  /**
   * Get children dimensions
   * @param {HTMLELement} el Element root
   * @return {Array}
   * */
  getChildrenDim(trg: HTMLElement) {
    const dims: Dim[] = [];
    if (!trg) return dims;

    // Get children based on getChildrenContainer
    const trgModel = this.getTargetModel(trg);
    if (trgModel && trgModel.view && !this.ignoreViewChildren) {
      const view = trgModel.getCurrentView ? trgModel.getCurrentView() : trgModel.view;
      trg = view.getChildrenContainer();
    }

    each(trg.children, (ele, i) => {
      const el = ele as HTMLElement;
      const model = getModel(el, $);
      const elIndex = model && model.index ? model.index() : i;

      if (!isTextNode(el) && !this.matches(el, this.itemSel)) {
        return;
      }

      const dim = this.getDim(el);
      let dir = this.direction;
      let dirValue: boolean;

      if (dir == 'v') dirValue = true;
      else if (dir == 'h') dirValue = false;
      else dirValue = this.isInFlow(el, trg);

      dim.dir = dirValue;
      dim.el = el;
      dim.indexEl = elIndex;
      dims.push(dim);
    });

    return dims;
  }

  /**
   * Check if the coordinates are near to the borders
   * @param {Array<number>} dim
   * @param {number} rX Relative X position
   * @param {number} rY Relative Y position
   * @return {Boolean}
   * */
  nearBorders(dim: Dim, rX: number, rY: number) {
    let result = false;
    const off = this.borderOffset;
    const x = rX || 0;
    const y = rY || 0;
    const t = dim.top;
    const l = dim.left;
    const h = dim.height;
    const w = dim.width;
    if (t + off > y || y > t + h - off || l + off > x || x > l + w - off) result = true;

    return result;
  }

  /**
   * Find the position based on passed dimensions and coordinates
   * @param {Array<Array>} dims Dimensions of nodes to parse
   * @param {number} posX X coordindate
   * @param {number} posY Y coordindate
   * @return {Object}
   * */
  findPosition(dims: Dim[], posX: number, posY: number): Pos {
    const result: Pos = { index: 0, indexEl: 0, method: 'before' };
    let leftLimit = 0;
    let xLimit = 0;
    let dimRight = 0;
    let yLimit = 0;
    let xCenter = 0;
    let yCenter = 0;
    let dimDown = 0;
    let dim: Dim;

    // Each dim is: Top, Left, Height, Width
    for (var i = 0, len = dims.length; i < len; i++) {
      dim = dims[i];
      const { top, left, height, width } = dim;
      // Right position of the element. Left + Width
      dimRight = left + width;
      // Bottom position of the element. Top + Height
      dimDown = top + height;
      // X center position of the element. Left + (Width / 2)
      xCenter = left + width / 2;
      // Y center position of the element. Top + (Height / 2)
      yCenter = top + height / 2;
      // Skip if over the limits
      if (
        (xLimit && left > xLimit) ||
        (yLimit && yCenter >= yLimit) || // >= avoid issue with clearfixes
        (leftLimit && dimRight < leftLimit)
      )
        continue;
      result.index = i;
      result.indexEl = dim.indexEl!;
      // If it's not in flow (like 'float' element)
      if (!dim.dir) {
        if (posY < dimDown) yLimit = dimDown;
        //If x lefter than center
        if (posX < xCenter) {
          xLimit = xCenter;
          result.method = 'before';
        } else {
          leftLimit = xCenter;
          result.method = 'after';
        }
      } else {
        // If y upper than center
        if (posY < yCenter) {
          result.method = 'before';
          break;
        } else result.method = 'after'; // After last element
      }
    }

    return result;
  }

  /**
   * Updates the position of the placeholder
   * @param {HTMLElement} phl
   * @param {Array<Array>} dims
   * @param {Object} pos Position object
   * @param {Array<number>} trgDim target dimensions ([top, left, height, width])
   * */
  movePlaceholder(plh: HTMLElement, dims: Dim[], pos: Pos, trgDim?: Dim) {
    let marg = 0;
    let t = 0;
    let l = 0;
    let w = '';
    let h = '';
    let un = 'px';
    let margI = 5;
    let method = pos.method;
    const elDim = dims[pos.index];

    // Placeholder orientation
    plh.classList.remove('vertical');
    plh.classList.add('horizontal');

    if (elDim) {
      // If it's not in flow (like 'float' element)
      const { top, left, height, width } = elDim;
      if (!elDim.dir) {
        w = 'auto';
        h = height - marg * 2 + un;
        t = top + marg;
        l = method == 'before' ? left - marg : left + width - marg;

        plh.classList.remove('horizontal');
        plh.classList.add('vertical');
      } else {
        w = width + un;
        h = 'auto';
        t = method == 'before' ? top - marg : top + height - marg;
        l = left;
      }
    } else {
      // Placeholder inside the component
      if (!this.nested) {
        plh.style.display = 'none';
        return;
      }
      if (trgDim) {
        const offset = trgDim.offsets || {};
        const pT = offset.paddingTop || margI;
        const pL = offset.paddingLeft || margI;
        t = trgDim.top + pT;
        l = trgDim.left + pL;
        w = parseInt(`${trgDim.width}`) - pL * 2 + un;
        h = 'auto';
      }
    }
    plh.style.top = t + un;
    plh.style.left = l + un;
    if (w) plh.style.width = w;
    if (h) plh.style.height = h;
  }

  /**
   * Build an array of all the parents, including the component itself
   * @return {Model|null}
   */
  parents(model: any): any[] {
    return model ? [model].concat(this.parents(model.parent())) : [];
  }

  /**
   * Sort according to the position in the dom
   * @param {Object} obj1 contains {model, parents}
   * @param {Object} obj2 contains {model, parents}
   */
  sort(obj1: any, obj2: any) {
    // common ancesters
    const ancesters = obj1.parents.filter((p: any) => obj2.parents.includes(p));
    const ancester = ancesters[0];
    if (!ancester) {
      // this is never supposed to happen
      return obj2.model.index() - obj1.model.index();
    }
    // find siblings in the common ancester
    // the sibling is the element inside the ancester
    const s1 = obj1.parents[obj1.parents.indexOf(ancester) - 1];
    const s2 = obj2.parents[obj2.parents.indexOf(ancester) - 1];
    // order according to the position in the DOM
    return s2.index() - s1.index();
  }

  /**
   * Leave item
   * @param event
   *
   * @return void
   * */
  endMove() {
    const src = this.sourceEl;
    const moved = [];
    const docs = this.getDocuments();
    const container = this.getContainerEl();
    const onEndMove = this.onEndMove;
    const onEnd = this.onEnd;
    const { target, lastPos } = this;
    let srcModel;
    off(container, 'mousemove dragover', this.onMove as any);
    off(docs, 'mouseup dragend touchend', this.endMove);
    off(docs, 'keydown', this.rollback);
    this.plh!.style.display = 'none';

    if (src) {
      srcModel = this.getSourceModel();
      if (this.selectOnEnd && srcModel && srcModel.set) {
        srcModel.set('status', '');
        srcModel.set('status', 'selected');
      }
    }

    if (this.moved && target) {
      const toMove = this.toMove;
      const toMoveArr = isArray(toMove) ? toMove : toMove ? [toMove] : [src];
      let domPositionOffset = 0;
      if (toMoveArr.length === 1) {
        // do not sort the array in this case
        // there are cases for the sorter where toMoveArr is [undefined]
        // which allows the drop from blocks, native D&D and sort of layers in Style Manager
        moved.push(this.move(target, toMoveArr[0]!, lastPos!));
      } else {
        toMoveArr
          // add the model's parents
          .map(model => ({
            model,
            parents: this.parents(model),
          }))
          // sort based on elements positions in the dom
          .sort(this.sort)
          // move each component to the new parent and position
          .forEach(({ model }) => {
            // @ts-ignore store state before move
            const index = model.index();
            // @ts-ignore
            const parent = model.parent().getEl();
            // move the component to the desired position
            moved.push(
              this.move(target, model!, {
                ...lastPos!,
                indexEl: lastPos!.indexEl - domPositionOffset,
                index: lastPos!.index - domPositionOffset,
              })
            );
            // when the element is dragged to the same parent and after its position
            //  it will be removed from the children list
            //  in that case we need to adjust the following elements target position
            if (parent === target && index <= lastPos!.index) {
              // the next elements will be inserted 1 element before this one
              domPositionOffset++;
            }
          });
      }
    }

    if (this.plh) this.plh.style.display = 'none';
    const dragHelper = this.dragHelper;

    if (dragHelper) {
      dragHelper.parentNode!.removeChild(dragHelper);
      delete this.dragHelper;
    }

    this.disableTextable();
    this.selectTargetModel();
    this.toggleSortCursor();
    this.em?.Canvas.removeSpots(spotTarget);

    delete this.toMove;
    delete this.eventMove;
    delete this.dropModel;

    if (isFunction(onEndMove)) {
      const data = {
        target: srcModel,
        // @ts-ignore
        parent: srcModel && srcModel.parent(),
        // @ts-ignore
        index: srcModel && srcModel.index(),
      };
      moved.length ? moved.forEach(m => onEndMove(m, this, data)) : onEndMove(null, this, { ...data, cancelled: 1 });
    }

    isFunction(onEnd) && onEnd({ sorter: this });
  }

  /**
   * Move component to new position
   * @param {HTMLElement} dst Destination target
   * @param {HTMLElement} src Element to move
   * @param {Object} pos Object with position coordinates
   * */
  move(dst: HTMLElement, src: HTMLElement | Model, pos: Pos) {
    const { em, dropContent } = this;
    const srcEl = getElement(src as HTMLElement);
    const warns = [];
    const index = pos.method === 'after' ? pos.indexEl + 1 : pos.indexEl;
    const validResult = this.validTarget(dst, srcEl);
    const targetCollection = $(dst).data('collection');
    const { trgModel, srcModel, draggable } = validResult;
    const droppable = trgModel instanceof Collection ? 1 : validResult.droppable;
    let modelToDrop, created;

    if (targetCollection && droppable && draggable) {
      const opts: any = { at: index, action: 'move-component' };
      const isTextable = this.isTextableActive(srcModel, trgModel);

      if (!dropContent) {
        const srcIndex = srcModel.collection.indexOf(srcModel);
        const sameCollection = targetCollection === srcModel.collection;
        const sameIndex = srcIndex === index || srcIndex === index - 1;
        const canRemove = !sameCollection || !sameIndex || isTextable;

        if (canRemove) {
          modelToDrop = srcModel.collection.remove(srcModel, {
            temporary: true,
          } as any);
          if (sameCollection && index > srcIndex) {
            opts.at = index - 1;
          }
        }
      } else {
        // @ts-ignore
        modelToDrop = isFunction(dropContent) ? dropContent() : dropContent;
        opts.avoidUpdateStyle = true;
        opts.action = 'add-component';
      }

      if (modelToDrop) {
        if (isTextable) {
          delete opts.at;
          created = trgModel.getView().insertComponent(modelToDrop, opts);
        } else {
          created = targetCollection.add(modelToDrop, opts);
        }
      }

      delete this.dropContent;
      delete this.prevTarget; // This will recalculate children dimensions
    } else if (em) {
      const dropInfo = validResult.dropInfo || trgModel?.get('droppable');
      const dragInfo = validResult.dragInfo || srcModel?.get('draggable');

      !targetCollection && warns.push('Target collection not found');
      !droppable && dropInfo && warns.push(`Target is not droppable, accepts [${dropInfo}]`);
      !draggable && dragInfo && warns.push(`Component not draggable, acceptable by [${dragInfo}]`);
      em.logWarning('Invalid target position', {
        errors: warns,
        model: srcModel,
        context: 'sorter',
        target: trgModel,
      });
    }

    em?.trigger('sorter:drag:end', {
      targetCollection,
      modelToDrop,
      warns,
      validResult,
      dst,
      srcEl,
    });

    return created;
  }

  /**
   * Rollback to previous situation
   * @param {Event}
   * @param {Bool} Indicates if rollback in anycase
   * */
  rollback(e: any) {
    off(this.getDocuments(), 'keydown', this.rollback);
    const key = e.which || e.keyCode;

    if (key == 27) {
      this.moved = false;
      this.endMove();
    }
  }
}
