// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.


/** @typedef {?{elements: !Array<BookmarkNode>, sameProfile: boolean}} */
let NormalizedDragData;

cr.define('bookmarks', function() {
  /** @const {number} */
  const DRAG_THRESHOLD = 15;

  /**
   * @param {BookmarkElement} element
   * @return {boolean}
   */
  function isBookmarkItem(element) {
    return element.tagName == 'BOOKMARKS-ITEM';
  }

  /**
   * @param {BookmarkElement} element
   * @return {boolean}
   */
  function isBookmarkFolderNode(element) {
    return element.tagName == 'BOOKMARKS-FOLDER-NODE';
  }

  /**
   * @param {BookmarkElement} element
   * @return {boolean}
   */
  function isBookmarkList(element) {
    return element.tagName == 'BOOKMARKS-LIST';
  }

  /**
   * @param {BookmarkElement} element
   * @return {boolean}
   */
  function isClosedBookmarkFolderNode(element) {
    return isBookmarkFolderNode(element) &&
        !(/** @type {BookmarksFolderNodeElement} */ (element).isOpen);
  }

  /**
   * @param {Array<!Element>|undefined} path
   * @return {BookmarkElement}
   */
  function getBookmarkElement(path) {
    if (!path) {
      return null;
    }

    for (let i = 0; i < path.length; i++) {
      if (isBookmarkItem(path[i]) || isBookmarkFolderNode(path[i]) ||
          isBookmarkList(path[i])) {
        return path[i];
      }
    }
    return null;
  }

  /**
   * @param {Array<!Element>|undefined} path
   * @return {BookmarkElement}
   */
  function getDragElement(path) {
    const dragElement = getBookmarkElement(path);
    for (let i = 0; i < path.length; i++) {
      if (path[i].tagName == 'BUTTON') {
        return null;
      }
    }
    return dragElement && dragElement.getAttribute('draggable') ? dragElement :
                                                                  null;
  }

  /**
   * @param {BookmarkElement} bookmarkElement
   * @return {BookmarkNode}
   */
  function getBookmarkNode(bookmarkElement) {
    return bookmarks.Store.getInstance().data.nodes[bookmarkElement.itemId];
  }

  /**
   * Contains and provides utility methods for drag data sent by the
   * bookmarkManagerPrivate API.
   */
  class DragInfo {
    constructor() {
      /** @type {NormalizedDragData} */
      this.dragData = null;
    }

    /** @param {DragData} newDragData */
    setNativeDragData(newDragData) {
      this.dragData = {
        sameProfile: newDragData.sameProfile,
        elements:
            newDragData.elements.map((x) => bookmarks.util.normalizeNode(x))
      };
    }

    clearDragData() {
      this.dragData = null;
    }

    /** @return {boolean} */
    isDragValid() {
      return !!this.dragData;
    }

    /** @return {boolean} */
    isSameProfile() {
      return !!this.dragData && this.dragData.sameProfile;
    }

    /** @return {boolean} */
    isDraggingFolders() {
      return !!this.dragData && this.dragData.elements.some(function(node) {
        return !node.url;
      });
    }

    /** @return {boolean} */
    isDraggingBookmark(bookmarkId) {
      return !!this.dragData && this.isSameProfile() &&
          this.dragData.elements.some(function(node) {
            return node.id == bookmarkId;
          });
    }

    /** @return {boolean} */
    isDraggingChildBookmark(folderId) {
      return !!this.dragData && this.isSameProfile() &&
          this.dragData.elements.some(function(node) {
            return node.parentId == folderId;
          });
    }

    /** @return {boolean} */
    isDraggingFolderToDescendant(itemId, nodes) {
      if (!this.isSameProfile()) {
        return false;
      }

      let parentId = nodes[itemId].parentId;
      const parents = {};
      while (parentId) {
        parents[parentId] = true;
        parentId = nodes[parentId].parentId;
      }

      return !!this.dragData && this.dragData.elements.some(function(node) {
        return parents[node.id];
      });
    }
  }

  /**
   * Manages auto expanding of sidebar folders on hover while dragging.
   */
  class AutoExpander {
    constructor() {
      /** @const {number} */
      this.EXPAND_FOLDER_DELAY = 400;

      /** @private {?BookmarkElement} */
      this.lastElement_ = null;

      /** @type {!bookmarks.Debouncer} */
      this.debouncer_ = new bookmarks.Debouncer(() => {
        const store = bookmarks.Store.getInstance();
        store.dispatch(
            bookmarks.actions.changeFolderOpen(this.lastElement_.itemId, true));
        this.reset();
      });
    }

    /**
     * @param {Event} e
     * @param {?BookmarkElement} overElement
     */
    update(e, overElement) {
      const itemId = overElement ? overElement.itemId : null;
      const store = bookmarks.Store.getInstance();

      // If dragging over a new closed folder node with children reset the
      // expander. Falls through to reset the expander delay.
      if (overElement && overElement != this.lastElement_ &&
          isClosedBookmarkFolderNode(overElement) &&
          bookmarks.util.hasChildFolders(itemId, store.data.nodes)) {
        this.reset();
        this.lastElement_ = overElement;
      }

      // If dragging over the same node, reset the expander delay.
      if (overElement && overElement == this.lastElement_) {
        this.debouncer_.restartTimeout(this.EXPAND_FOLDER_DELAY);
        return;
      }

      // Otherwise, cancel the expander.
      this.reset();
    }

    reset() {
      this.debouncer_.reset();
      this.lastElement_ = null;
    }
  }

  /**
   * Encapsulates the behavior of the drag and drop indicator which puts a line
   * between items or highlights folders which are valid drop targets.
   */
  class DropIndicator {
    constructor() {
      /**
       * @private {number|null} Timer id used to help minimize flicker.
       */
      this.removeDropIndicatorTimeoutId_ = null;

      /**
       * The element that had a style applied it to indicate the drop location.
       * This is used to easily remove the style when necessary.
       * @private {BookmarkElement|null}
       */
      this.lastIndicatorElement_ = null;

      /**
       * The style that was applied to indicate the drop location.
       * @private {?string|null}
       */
      this.lastIndicatorClassName_ = null;

      /**
       * Used to instantly remove the indicator style in tests.
       * @private {!Object}
       */
      this.timerProxy = window;
    }

    /**
     * Applies the drop indicator style on the target element and stores that
     * information to easily remove the style in the future.
     * @param {HTMLElement} indicatorElement
     * @param {DropPosition} position
     */
    addDropIndicatorStyle(indicatorElement, position) {
      const indicatorStyleName = position == DropPosition.ABOVE ?
          'drag-above' :
          position == DropPosition.BELOW ? 'drag-below' : 'drag-on';

      this.lastIndicatorElement_ = indicatorElement;
      this.lastIndicatorClassName_ = indicatorStyleName;

      indicatorElement.classList.add(indicatorStyleName);
    }

    /**
     * Clears the drop indicator style from the last drop target.
     */
    removeDropIndicatorStyle() {
      if (!this.lastIndicatorElement_ || !this.lastIndicatorClassName_) {
        return;
      }

      this.lastIndicatorElement_.classList.remove(this.lastIndicatorClassName_);
      this.lastIndicatorElement_ = null;
      this.lastIndicatorClassName_ = null;
    }

    /**
     * Displays the drop indicator on the current drop target to give the
     * user feedback on where the drop will occur.
     * @param {DropDestination} dropDest
     */
    update(dropDest) {
      this.timerProxy.clearTimeout(this.removeDropIndicatorTimeoutId_);
      this.removeDropIndicatorTimeoutId_ = null;

      const indicatorElement = dropDest.element.getDropTarget();
      const position = dropDest.position;

      this.removeDropIndicatorStyle();
      this.addDropIndicatorStyle(indicatorElement, position);
    }

    /**
     * Stop displaying the drop indicator.
     */
    finish() {
      if (this.removeDropIndicatorTimeoutId_) {
        return;
      }

      // The use of a timeout is in order to reduce flickering as we move
      // between valid drop targets.
      this.removeDropIndicatorTimeoutId_ = this.timerProxy.setTimeout(() => {
        this.removeDropIndicatorStyle();
      }, 100);
    }
  }

  /**
   * Manages drag and drop events for the bookmarks-app.
   */
  class DNDManager {
    constructor() {
      /** @private {bookmarks.DragInfo} */
      this.dragInfo_ = null;

      /** @private {?DropDestination} */
      this.dropDestination_ = null;

      /** @private {bookmarks.DropIndicator} */
      this.dropIndicator_ = null;

      /** @private {Object<string, function(!Event)>} */
      this.documentListeners_ = null;

      /** @private {?bookmarks.AutoExpander} */
      this.autoExpander_ = null;

      /**
       * Used to instantly clearDragData in tests.
       * @private {!Object}
       */
      this.timerProxy_ = window;

      /** @private {boolean} */
      this.lastPointerWasTouch_ = false;
    }

    init() {
      this.dragInfo_ = new DragInfo();
      this.dropIndicator_ = new DropIndicator();
      this.autoExpander_ = new AutoExpander();

      this.documentListeners_ = {
        'dragstart': this.onDragStart_.bind(this),
        'dragenter': this.onDragEnter_.bind(this),
        'dragover': this.onDragOver_.bind(this),
        'dragleave': this.onDragLeave_.bind(this),
        'drop': this.onDrop_.bind(this),
        'dragend': this.clearDragData_.bind(this),
        'mousedown': this.onMouseDown_.bind(this),
        'touchstart': this.onTouchStart_.bind(this),
      };
      for (const event in this.documentListeners_) {
        document.addEventListener(event, this.documentListeners_[event]);
      }

      chrome.bookmarkManagerPrivate.onDragEnter.addListener(
          this.handleChromeDragEnter_.bind(this));
      chrome.bookmarkManagerPrivate.onDragLeave.addListener(
          this.clearDragData_.bind(this));
    }

    destroy() {
      for (const event in this.documentListeners_) {
        document.removeEventListener(event, this.documentListeners_[event]);
      }
    }

    ////////////////////////////////////////////////////////////////////////////
    // DragEvent handlers:

    /**
     * @private
     * @param {Event} e
     */
    onDragStart_(e) {
      const dragElement = getDragElement(e.path);
      if (!dragElement) {
        return;
      }

      e.preventDefault();

      const dragData = this.calculateDragData_(dragElement);
      if (!dragData) {
        this.clearDragData_();
        return;
      }

      const state = bookmarks.Store.getInstance().data;

      let draggedNodes = [];

      if (isBookmarkItem(dragElement)) {
        const displayingItems = assert(bookmarks.util.getDisplayedList(state));
        // TODO(crbug.com/980427): Make this search more time efficient to avoid
        // delay on large amount of bookmark dragging.
        for (const itemId of displayingItems) {
          for (const element of dragData.elements) {
            if (element.id == itemId) {
              draggedNodes.push(element.id);
              break;
            }
          }
        }
      } else {
        draggedNodes = dragData.elements.map((item) => item.id);
      }

      assert(draggedNodes.length == dragData.elements.length);

      const dragNodeIndex = draggedNodes.indexOf(dragElement.itemId);
      assert(dragNodeIndex != -1);

      chrome.bookmarkManagerPrivate.startDrag(
          draggedNodes, dragNodeIndex, this.lastPointerWasTouch_, e.clientX,
          e.clientY);
    }

    /** @private */
    onDragLeave_() {
      this.dropIndicator_.finish();
    }

    /**
     * @private
     * @param {!Event} e
     */
    onDrop_(e) {
      // Allow normal DND on text inputs.
      if (isTextInputElement(e.path[0])) {
        return;
      }

      e.preventDefault();

      if (this.dropDestination_) {
        const dropInfo = this.calculateDropInfo_(this.dropDestination_);
        const index = dropInfo.index != -1 ? dropInfo.index : undefined;
        const shouldHighlight = this.shouldHighlight_(this.dropDestination_);

        if (shouldHighlight) {
          bookmarks.ApiListener.trackUpdatedItems();
        }

        chrome.bookmarkManagerPrivate.drop(
            dropInfo.parentId, index,
            shouldHighlight ? bookmarks.ApiListener.highlightUpdatedItems :
                              undefined);
      }
      this.clearDragData_();
    }

    /**
     * @private
     * @param {Event} e
     */
    onDragEnter_(e) {
      e.preventDefault();
    }

    /**
     * @private
     * @param {Event} e
     */
    onDragOver_(e) {
      this.dropDestination_ = null;

      // Allow normal DND on text inputs.
      if (isTextInputElement(e.path[0])) {
        return;
      }

      // The default operation is to allow dropping links etc to do
      // navigation. We never want to do that for the bookmark manager.
      e.preventDefault();

      if (!this.dragInfo_.isDragValid()) {
        return;
      }

      const state = bookmarks.Store.getInstance().data;
      const items = this.dragInfo_.dragData.elements;

      const overElement = getBookmarkElement(e.path);
      this.autoExpander_.update(e, overElement);
      if (!overElement) {
        this.dropIndicator_.finish();
        return;
      }

      // Now we know that we can drop. Determine if we will drop above, on or
      // below based on mouse position etc.
      this.dropDestination_ =
          this.calculateDropDestination_(e.clientY, overElement);
      if (!this.dropDestination_) {
        this.dropIndicator_.finish();
        return;
      }

      this.dropIndicator_.update(this.dropDestination_);
    }

    /** @private */
    onMouseDown_() {
      this.lastPointerWasTouch_ = false;
    }

    /** @private */
    onTouchStart_() {
      this.lastPointerWasTouch_ = true;
    }

    /**
     * @private
     * @param {DragData} dragData
     */
    handleChromeDragEnter_(dragData) {
      this.dragInfo_.setNativeDragData(dragData);
    }

    ////////////////////////////////////////////////////////////////////////////
    // Helper methods:

    /** @private */
    clearDragData_() {
      this.autoExpander_.reset();

      // Defer the clearing of the data so that the bookmark manager API's drop
      // event doesn't clear the drop data before the web drop event has a
      // chance to execute (on Mac).
      this.timerProxy_.setTimeout(() => {
        this.dragInfo_.clearDragData();
        this.dropDestination_ = null;
        this.dropIndicator_.finish();
      }, 0);
    }

    /**
     * @param {DropDestination} dropDestination
     * @return {{parentId: string, index: number}}
     */
    calculateDropInfo_(dropDestination) {
      if (isBookmarkList(dropDestination.element)) {
        return {
          index: 0,
          parentId: bookmarks.Store.getInstance().data.selectedFolder,
        };
      }

      const node = getBookmarkNode(dropDestination.element);
      const position = dropDestination.position;
      let index = -1;
      let parentId = node.id;

      if (position != DropPosition.ON) {
        const state = bookmarks.Store.getInstance().data;

        // Drops between items in the normal list and the sidebar use the drop
        // destination node's parent.
        parentId = assert(node.parentId);
        index = state.nodes[parentId].children.indexOf(node.id);

        if (position == DropPosition.BELOW) {
          index++;
        }
      }

      return {
        index: index,
        parentId: parentId,
      };
    }

    /**
     * Calculates which items should be dragged based on the initial drag item
     * and the current selection. Dragged items will end up selected.
     * @param {!BookmarkElement} dragElement
     * @private
     */
    calculateDragData_(dragElement) {
      const dragId = dragElement.itemId;
      const store = bookmarks.Store.getInstance();
      const state = store.data;

      // Determine the selected bookmarks.
      let draggedNodes = Array.from(state.selection.items);

      // Change selection to the dragged node if the node is not part of the
      // existing selection.
      if (isBookmarkFolderNode(dragElement) ||
          draggedNodes.indexOf(dragId) == -1) {
        store.dispatch(bookmarks.actions.deselectItems());
        if (!isBookmarkFolderNode(dragElement)) {
          store.dispatch(bookmarks.actions.selectItem(dragId, state, {
            clear: false,
            range: false,
            toggle: false,
          }));
        }
        draggedNodes = [dragId];
      }

      // If any node can't be dragged, end the drag.
      const anyUnmodifiable = draggedNodes.some(
          (itemId) => !bookmarks.util.canEditNode(state, itemId));

      if (anyUnmodifiable) {
        return null;
      }

      return {
        elements: draggedNodes.map((id) => state.nodes[id]),
        sameProfile: true,
      };
    }

    /**
     * This function determines where the drop will occur.
     * @private
     * @param {number} elementClientY
     * @param {!BookmarkElement} overElement
     * @return {?DropDestination} If no valid drop position is found, null,
     *   otherwise:
     *       element - The target element that will receive the drop.
     *       position - A |DropPosition| relative to the |element|.
     */
    calculateDropDestination_(elementClientY, overElement) {
      const validDropPositions = this.calculateValidDropPositions_(overElement);
      if (validDropPositions == DropPosition.NONE) {
        return null;
      }

      const above = validDropPositions & DropPosition.ABOVE;
      const below = validDropPositions & DropPosition.BELOW;
      const on = validDropPositions & DropPosition.ON;
      const rect = overElement.getDropTarget().getBoundingClientRect();
      const yRatio = (elementClientY - rect.top) / rect.height;

      if (above && (yRatio <= .25 || yRatio <= .5 && (!below || !on))) {
        return {element: overElement, position: DropPosition.ABOVE};
      }

      if (below && (yRatio > .75 || yRatio > .5 && (!above || !on))) {
        return {element: overElement, position: DropPosition.BELOW};
      }

      if (on) {
        return {element: overElement, position: DropPosition.ON};
      }

      return null;
    }

    /**
     * Determines the valid drop positions for the given target element.
     * @private
     * @param {!BookmarkElement} overElement The element that we are currently
     *     dragging over.
     * @return {number} An bit field enumeration of valid drop locations.
     */
    calculateValidDropPositions_(overElement) {
      const dragInfo = this.dragInfo_;
      const state = bookmarks.Store.getInstance().data;
      let itemId = overElement.itemId;

      // Drags aren't allowed onto the search result list.
      if ((isBookmarkList(overElement) || isBookmarkItem(overElement)) &&
          bookmarks.util.isShowingSearch(state)) {
        return DropPosition.NONE;
      }

      if (isBookmarkList(overElement)) {
        itemId = state.selectedFolder;
      }

      if (!bookmarks.util.canReorderChildren(state, itemId)) {
        return DropPosition.NONE;
      }

      // Drags of a bookmark onto itself or of a folder into its children aren't
      // allowed.
      if (dragInfo.isDraggingBookmark(itemId) ||
          dragInfo.isDraggingFolderToDescendant(itemId, state.nodes)) {
        return DropPosition.NONE;
      }

      let validDropPositions = this.calculateDropAboveBelow_(overElement);
      if (this.canDropOn_(overElement)) {
        validDropPositions |= DropPosition.ON;
      }

      return validDropPositions;
    }

    /**
     * @private
     * @param {BookmarkElement} overElement
     * @return {number}
     */
    calculateDropAboveBelow_(overElement) {
      const dragInfo = this.dragInfo_;
      const state = bookmarks.Store.getInstance().data;

      if (isBookmarkList(overElement)) {
        return DropPosition.NONE;
      }

      // We cannot drop between Bookmarks bar and Other bookmarks.
      if (getBookmarkNode(overElement).parentId == ROOT_NODE_ID) {
        return DropPosition.NONE;
      }

      const isOverFolderNode = isBookmarkFolderNode(overElement);

      // We can only drop between items in the tree if we have any folders.
      if (isOverFolderNode && !dragInfo.isDraggingFolders()) {
        return DropPosition.NONE;
      }

      let validDropPositions = DropPosition.NONE;

      // Cannot drop above if the item above is already in the drag source.
      const previousElem = overElement.previousElementSibling;
      if (!previousElem || !dragInfo.isDraggingBookmark(previousElem.itemId)) {
        validDropPositions |= DropPosition.ABOVE;
      }

      // Don't allow dropping below an expanded sidebar folder item since it is
      // confusing to the user anyway.
      if (isOverFolderNode && !isClosedBookmarkFolderNode(overElement) &&
          bookmarks.util.hasChildFolders(overElement.itemId, state.nodes)) {
        return validDropPositions;
      }

      const nextElement = overElement.nextElementSibling;

      // Cannot drop below if the item below is already in the drag source.
      if (!nextElement || !dragInfo.isDraggingBookmark(nextElement.itemId)) {
        validDropPositions |= DropPosition.BELOW;
      }

      return validDropPositions;
    }

    /**
     * Determine whether we can drop the dragged items on the drop target.
     * @private
     * @param {!BookmarkElement} overElement The element that we are currently
     *     dragging over.
     * @return {boolean} Whether we can drop the dragged items on the drop
     *     target.
     */
    canDropOn_(overElement) {
      // Allow dragging onto empty bookmark lists.
      if (isBookmarkList(overElement)) {
        const state = bookmarks.Store.getInstance().data;
        return !!state.selectedFolder &&
            state.nodes[state.selectedFolder].children.length == 0;
      }

      // We can only drop on a folder.
      if (getBookmarkNode(overElement).url) {
        return false;
      }

      return !this.dragInfo_.isDraggingChildBookmark(overElement.itemId);
    }

    /**
     * @param {DropDestination} dropDestination
     * @private
     */
    shouldHighlight_(dropDestination) {
      return isBookmarkItem(dropDestination.element) ||
          isBookmarkList(dropDestination.element);
    }

    /** @param {!Object} timerProxy */
    setTimerProxyForTesting(timerProxy) {
      this.timerProxy_ = timerProxy;
      this.dropIndicator_.timerProxy = timerProxy;
    }
  }

  return {
    AutoExpander: AutoExpander,
    DNDManager: DNDManager,
    DragInfo: DragInfo,
    DropIndicator: DropIndicator,
  };
});
