/*
 * Copyright (c) 2010, 2025 BSI Business Systems Integration AG
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */
import {Event, EventEmitter, EventMap, events, graphics, Insets, keys, Point, Rectangle, Session, Widget} from '../index';
import $ from 'jquery';

export class MoveSupport<TElem extends Widget> extends EventEmitter {
  declare self: MoveSupport<TElem>;
  declare eventMap: MoveSupportEventMap;
  /**
   * Minimal distance in pixels for a "mouse move" action to take effect.
   * Prevents "mini jumps" when simply clicking on an element.
   */
  mouseMoveThreshold: number;
  /**
   * The maximum size the clone should have. If it exceeds that size it will be scaled down.
   */
  maxCloneSize: number;

  /**
   * Widget containing the draggable elements
   */
  widget: Widget;

  protected _moveData: MoveData<TElem>;
  protected _animationDurationFactor: number;
  protected _mouseMoveHandler: (event: JQuery.MouseMoveEvent) => void;
  protected _mouseUpHandler: (event: JQuery.MouseUpEvent) => void;
  protected _keyDownHandler: (event: KeyboardEvent) => void;
  protected _releasingScrollHandler: (event: JQuery.ScrollEvent) => void;

  /**
   * @param widget the widget containing the draggable elements. Is used to automatically cancel the move operation when the widget is removed.
   */
  constructor(widget: Widget) {
    super();

    this.maxCloneSize = 200;
    this.mouseMoveThreshold = 7;
    this.widget = widget;

    this._moveData = null;
    this._animationDurationFactor = 1; // for debugging to slow down the animation

    this._mouseMoveHandler = this._onMouseMove.bind(this);
    this._mouseUpHandler = this._onMouseUp.bind(this);
    this._keyDownHandler = this._onKeyDown.bind(this);
    this._releasingScrollHandler = this._onReleasingScroll.bind(this);
  }

  /**
   * @return `true` if the dragging was started successfully, falsy otherwise.
   */
  start(event: JQuery.MouseDownEvent, elements: TElem[], draggedElement: TElem): boolean {
    if (this._moveData) {
      // Do nothing, when dragging is already in progress. This can happen when the user leaves
      // the browser window (e.g. using Alt-Tab) while holding the mouse button pressed and
      // then returns and presses the mouse button again.
      return;
    }
    if (draggedElement.$container.hasClass('dragged')) {
      // If MoveSupport is created again for an already dragged element, do nothing. This makes sure the placeholder element cannot be dragged if clone is released and drag started right again
      return;
    }
    if (!event || !elements || !draggedElement || !elements.includes(draggedElement) || !draggedElement.$container) {
      return;
    }
    if (event.which !== 1) {
      // Only accept left mouse button clicks (right one is reserved for context menu)
      return;
    }
    events.fixTouchEvent(event);

    this._initMoveData(event, elements, draggedElement);
    $('iframe').addClass('dragging-in-progress');

    // TODO CGU on touch devices it must be possible to scroll but also to drag the element -> drag should start not when pointer is moved but when touch is pressed down for some time

    // Cancel moving when widget is removed
    let handler = () => this.cancel();
    this.widget.one('remove', handler);
    this.one('cancel end', () => {
      this.widget.off('remove', handler);
    });
    return true;
  }

  protected _initMoveData(event: JQuery.MouseDownEvent, elements: TElem[], draggedElement: TElem) {
    let $window = draggedElement.$container.window();
    let $elements = draggedElement.$container.parent();
    this._moveData = {} as MoveData<TElem>;
    this._moveData.session = draggedElement.session;
    this._moveData.$window = $window;
    this._moveData.$container = $elements;
    this._moveData.containerBounds = graphics.offsetBounds($elements, {
      includeMargin: true
    });

    this._moveData.elements = elements;
    this._moveData.elementInfos = this._createElementInfos(elements, draggedElement);

    this._moveData.startCursorPosition = new Point(
      event.pageX - this._moveData.containerBounds.x,
      event.pageY - this._moveData.containerBounds.y
    );
    this._moveData.currentCursorPosition = this._moveData.startCursorPosition;

    // Compute distances from the cursor to the edges of the dragged element
    let draggedElementInfo = this._moveData.draggedElementInfo;
    this._moveData.cursorDistance = new Insets(
      event.pageY - draggedElementInfo.bounds.y,
      draggedElementInfo.bounds.x + draggedElementInfo.bounds.width - event.pageX,
      draggedElementInfo.bounds.y + this._moveData.draggedElementInfo.bounds.height - event.pageY,
      event.pageX - draggedElementInfo.bounds.x
    );

    this._moveData.$window
      .off('mousemove touchmove', this._mouseMoveHandler)
      .off('mouseup touchend touchcancel', this._mouseUpHandler)
      .on('mousemove touchmove', this._mouseMoveHandler)
      .on('mouseup touchend touchcancel', this._mouseUpHandler);
    this._moveData.$window[0].removeEventListener('keydown', this._keyDownHandler, true);
    this._moveData.$window[0].addEventListener('keydown', this._keyDownHandler, true);
  }

  protected _createElementInfos(elements: TElem[], draggedElement: TElem): DraggableElementInfo<TElem>[] {
    return elements
      .filter(element => !!element.$container)
      .map((element, index) => {
        // Collect various information about each element. This allows us to retrieve positions later on without
        // needing to measure them each time the mouse cursor moves. We can also skip null checks for $element.
        let $element = element.$container;
        let info = {
          element: element,
          $element: $element
        } as DraggableElementInfo<TElem>;
        this._updateElementInfo(info);
        if (element === draggedElement) {
          this._moveData.draggedElementInfo = info;
          this._moveData.$draggedElement = $element;
        }
        return info;
      });
  }

  protected _updateElementInfo(elementInfo: DraggableElementInfo<TElem>) {
    let $element = elementInfo.$element;
    let bounds = graphics.offsetBounds($element);
    let position = new Point(
      bounds.x - this._moveData.containerBounds.x,
      bounds.y - this._moveData.containerBounds.y
    );
    $.extend(elementInfo, {
      position: position,
      bounds: bounds
    });
  }

  protected _updateElementInfos() {
    this._moveData.elementInfos.forEach(info => this._updateElementInfo(info));
  }

  cancel() {
    if (!this._moveData) {
      return;
    }

    this._cleanup();
    this._restoreStyles();
    this._moveData = null;
    this._cancel();
  }

  protected _restoreStyles() {
    // Remove clone
    this._moveData.$clone && this._moveData.$clone.remove();

    // A done class makes it possible to disable transitions that must not be active while the clone will be swapped with the dragged element
    this._moveData.$draggedElement.removeClass('dragged releasing');
    this._moveData.$container.removeClass('dragging-element');
  }

  protected _onMouseMove(event: JQuery.MouseMoveEvent) {
    events.fixTouchEvent(event);
    this._updateOffsets();

    this._moveData.currentCursorPosition = new Point(
      event.pageX - this._moveData.containerBounds.x,
      event.pageY - this._moveData.containerBounds.y
    );
    let distance = this._moveData.currentCursorPosition.subtract(this._moveData.startCursorPosition);

    // Ignore small mouse movements
    if (!this._moveData.moving) {
      if (Math.abs(distance.x) < this.mouseMoveThreshold && Math.abs(distance.y) < this.mouseMoveThreshold) {
        return;
      }
      this._moveData.moving = true;
      this._onFirstMouseMove();
    }

    // Create a clone of the dragged element that is positioned 'fixed', i.e. with document-absolute coordinates
    if (!this._moveData.$clone) {
      this._moveData.cloneBounds = graphics.offsetBounds(this._moveData.$draggedElement);
      this._moveData.cloneStartOffset = this._moveData.cloneBounds.point();
      this._append$Clone();

      // Change style of dragged element
      this._moveData.$draggedElement.addClass('dragged');
    }

    // Update clone position
    this._moveData.cloneBounds = this._moveData.cloneBounds.moveTo(this._moveData.cloneStartOffset.add(distance));

    // Scale down clone if necessary
    let scale = this._calculateScale();
    this._moveData.$clone.css({
      'top': this._moveData.cloneBounds.y,
      'left': this._moveData.cloneBounds.x,
      '--dragging-scale': scale,
      'transform-origin': this._moveData.cursorDistance.left + 'px ' + this._moveData.cursorDistance.top + 'px'
    });

    // Don't change element order if the clone is outside the container area
    if (!this._moveData.containerBounds.intersects(this._moveData.cloneBounds)) {
      return;
    }

    this._drag(event);
  }

  protected _calculateScale(): number {
    let scale = 1;
    if (this._moveData.cloneBounds.width > this.maxCloneSize) {
      scale = this.maxCloneSize / this._moveData.cloneBounds.width;
    }
    if (this._moveData.cloneBounds.height > this.maxCloneSize) {
      scale = Math.min(this.maxCloneSize / this._moveData.cloneBounds.height, scale);
    }
    return scale;
  }

  /**
   * Adjusts relative values if the panel has been scrolled while dragging (e.g. using the mouse wheel)
   */
  protected _updateOffsets() {
    let containerOffset = graphics.offset(this._moveData.$container);
    if (!containerOffset.equals(this._moveData.containerBounds.point())) {
      let diff = containerOffset.subtract(this._moveData.containerBounds.point());
      this._moveData.containerBounds = this._moveData.containerBounds.translate(diff);
      if (this._moveData.cloneStartOffset) {
        this._moveData.cloneStartOffset = this._moveData.cloneStartOffset.add(diff);
      }
      this._moveData.elementInfos.forEach(info => {
        info.bounds = info.bounds.translate(diff);
      });
    }
  }

  protected _drag(event: JQuery.MouseMoveEvent) {
    this.trigger('drag');
  }

  protected _onFirstMouseMove() {
    this._moveData.$container.addClass('dragging-element');
  }

  protected _append$Clone() {
    let $clone = this._moveData.$draggedElement.clone()
      .addClass('dragged-clone')
      .removeAttr('data-id')
      .css('position', 'fixed')
      .appendTo(this._moveData.session.$entryPoint);

    // Because the clone is added to the $entryPoint (to ensure it is drawn above everything else),
    // the wheel events won't bubble to the container. To make the mouse work while dragging,
    // we delegate the event manually.
    $clone.on('wheel', event => this._moveData.$container.trigger(event));

    // Clone canvas contents manually
    let origCanvases = this._moveData.$draggedElement.find('canvas:visible') as JQuery<HTMLCanvasElement>;
    $clone.find('canvas:visible').each((index, canvas: HTMLCanvasElement) => {
      try {
        canvas.getContext('2d').drawImage(origCanvases.get(index), 0, 0);
      } catch (err) {
        // Drawing on the canvas can throw unexpected errors, for example:
        // "DOMException: Failed to execute 'drawImage' on 'CanvasRenderingContext2D':
        // The image argument is a canvas element with a width or height of 0."
        $.log.isWarnEnabled() && $.log.warn('Unable to clone canvas. Reason: ', err);
      }
    });
    this._moveData.$clone = $clone;
    this._moveData.$cloneShadow = this._moveData.$clone.prependDiv('shadow')
      .animate({
        opacity: 1
      }, {
        duration: 250 * this._animationDurationFactor
      });
  }

  protected _onMouseUp(event: JQuery.MouseUpEvent) {
    events.fixTouchEvent(event);
    this._updateOffsets();
    this._cleanup();
    this._dragEnd(event)
      .then(targetBounds => this._moveToTarget(targetBounds).then(() => targetBounds))
      .then(targetBounds => {
        this._restoreStyles();

        if (!targetBounds.equals(this._moveData.draggedElementInfo.bounds)) {
          this._moveEnd();
        }
        this._moveData = null;
        this._end();
      });
  }

  protected _onKeyDown(event: KeyboardEvent) {
    if (event.which === keys.ESC) {
      this.cancel();
      event.stopPropagation();
    }
  }

  protected _cleanup() {
    this._moveData.$window
      .off('mousemove touchmove', this._mouseMoveHandler)
      .off('mouseup touchend touchcancel', this._mouseUpHandler);
    this._moveData.$window[0].removeEventListener('keydown', this._keyDownHandler, true);
    $('iframe').removeClass('dragging-in-progress');
  }

  protected _moveToTarget(targetBounds: Rectangle): JQuery.Promise<void> {
    if (!this._moveData.$clone) {
      return $.resolvedPromise();
    }

    // stop all animations in case of scroll (e.g. by mousewheel, page down etc.)
    let $scrollParents = this._moveData.$draggedElement.scrollParents();
    $scrollParents.on('scroll', this._releasingScrollHandler);

    let promises = [];
    this._moveData.$clone.addClass('releasing');
    this._moveData.$draggedElement.addClass('releasing');

    // Move clone to target position and restore original size
    promises.push(this._moveData.$clone
      .css('pointer-events', 'none')
      .css('--dragging-scale', '1')
      .animate({
        top: targetBounds.y,
        left: targetBounds.x,
        width: targetBounds.width,
        height: targetBounds.height
      }, {
        easing: 'easeOutQuart',
        duration: 500 * this._animationDurationFactor
      })
      .promise());

    // Fade out shadow
    promises.push(this._moveData.$cloneShadow
      .stop(true)
      .animate({
        opacity: 0
      }, {
        duration: 500 * this._animationDurationFactor
      })
      .promise());

    return $.promiseAll(promises).then(() => {
      $scrollParents.off('scroll', this._releasingScrollHandler);
    });
  }

  protected _onReleasingScroll(event: JQuery.ScrollEvent) {
    this._moveData.elementInfos.forEach(info => info.$element.stop(true, true));
    this._moveData.$cloneShadow.stop(true, true);
    this._moveData.$clone.stop(true, true);
  }

  /**
   * @returns the target offset bounds to where the element should be moved
   */
  protected _dragEnd(event: JQuery.MouseUpEvent): JQuery.Promise<Rectangle> {
    let info = this._moveData.draggedElementInfo;
    return $.resolvedPromise(new Rectangle(info.bounds.x, info.bounds.y, info.bounds.width, info.bounds.height));
  }

  protected _moveEnd() {
    this.trigger('moveEnd');
  }

  protected _end() {
    this.trigger('end');
  }

  protected _cancel() {
    this.trigger('cancel');
  }
}

/**
 * Temporary data structure to store data while mouse actions are handled.
 */
export interface MoveData<TElem extends Widget> {
  /**
   * Distance from cursor to the edges of the dragged element.
   */
  cursorDistance: Insets;
  session: Session;
  $window: JQuery<Window>;
  /**
   * The container containing the draggable elements
   */
  $container: JQuery;
  /**
   * The offset bounds of the container;
   */
  containerBounds: Rectangle;
  /**
   * The draggable elements.
   */
  elements: TElem[];
  /**
   * Contains various information about each element.
   */
  elementInfos: DraggableElementInfo<TElem>[];
  /**
   * Contains various information about the dragged element.
   */
  draggedElementInfo: DraggableElementInfo<TElem>;
  /**
   * Points to draggedElementInfo.$element.
   */
  $draggedElement: JQuery;
  /**
   * The position of the cursor when the dragging started.
   */
  startCursorPosition: Point;
  /**
   * The current position of the cursor.
   */
  currentCursorPosition: Point;
  /**
   * Whether an element is being moved.
   */
  moving: boolean;
  /**
   * A clone of the dragged element that follows the cursor. The dragged element itself stays at its original position until it should be moved to a new location.
   */
  $clone: JQuery;
  /**
   * A dedicated shadow element so it can be animated.
   */
  $cloneShadow: JQuery;
  cloneStartOffset: Point;
  cloneBounds: Rectangle;
}

export interface DraggableElementInfo<TElem extends Widget> {
  element: TElem;
  $element: JQuery;
  /**
   * The relative position to the container.
   */
  position: Point;
  /**
   * The size and absolute position (relative to the window).
   */
  bounds: Rectangle;
}

export interface MoveSupportEventMap extends EventMap {
  'drag': Event;
  'moveEnd': Event;
  'end': Event;
  'cancel': Event;
}
