// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { ArrayExt } from '@lumino/algorithm';

import { CommandRegistry } from '@lumino/commands';

import { DisposableDelegate, IDisposable } from '@lumino/disposable';

import { Selector } from '@lumino/domutils';

import { Menu } from './menu';

/**
 * An object which implements a universal context menu.
 *
 * #### Notes
 * The items shown in the context menu are determined by CSS selector
 * matching against the DOM hierarchy at the site of the mouse click.
 * This is similar in concept to how keyboard shortcuts are matched
 * in the command registry.
 */
export class ContextMenu {
  /**
   * Construct a new context menu.
   *
   * @param options - The options for initializing the menu.
   */
  constructor(options: ContextMenu.IOptions) {
    const { groupByTarget, sortBySelector, ...others } = options;
    this.menu = new Menu(others);
    this._groupByTarget = groupByTarget !== false;
    this._sortBySelector = sortBySelector !== false;
  }

  /**
   * The menu widget which displays the matched context items.
   */
  readonly menu: Menu;

  /**
   * Add an item to the context menu.
   *
   * @param options - The options for creating the item.
   *
   * @returns A disposable which will remove the item from the menu.
   */
  addItem(options: ContextMenu.IItemOptions): IDisposable {
    // Create an item from the given options.
    let item = Private.createItem(options, this._idTick++);

    // Add the item to the internal array.
    this._items.push(item);

    // Return a disposable which will remove the item.
    return new DisposableDelegate(() => {
      ArrayExt.removeFirstOf(this._items, item);
    });
  }

  /**
   * Open the context menu in response to a `'contextmenu'` event.
   *
   * @param event - The `'contextmenu'` event of interest.
   *
   * @returns `true` if the menu was opened, or `false` if no items
   *   matched the event and the menu was not opened.
   *
   * #### Notes
   * This method will populate the context menu with items which match
   * the propagation path of the event, then open the menu at the mouse
   * position indicated by the event.
   */
  open(event: MouseEvent): boolean {
    // Prior to any DOM modifications update the window data.
    Menu.saveWindowData();

    // Clear the current contents of the context menu.
    this.menu.clearItems();

    // Bail early if there are no items to match.
    if (this._items.length === 0) {
      return false;
    }

    // Find the matching items for the event.
    let items = Private.matchItems(
      this._items,
      event,
      this._groupByTarget,
      this._sortBySelector
    );

    // Bail if there are no matching items.
    if (!items || items.length === 0) {
      return false;
    }

    // Add the filtered items to the menu.
    for (const item of items) {
      this.menu.addItem(item);
    }

    // Open the context menu at the current mouse position.
    this.menu.open(event.clientX, event.clientY);

    // Indicate success.
    return true;
  }

  private _groupByTarget: boolean = true;
  private _idTick = 0;
  private _items: Private.IItem[] = [];
  private _sortBySelector: boolean = true;
}

/**
 * The namespace for the `ContextMenu` class statics.
 */
export namespace ContextMenu {
  /**
   * An options object for initializing a context menu.
   */
  export interface IOptions {
    /**
     * The command registry to use with the context menu.
     */
    commands: CommandRegistry;

    /**
     * A custom renderer for use with the context menu.
     */
    renderer?: Menu.IRenderer;

    /**
     * Whether to sort by selector and rank or only rank.
     *
     * Default true.
     */
    sortBySelector?: boolean;

    /**
     * Whether to group items following the DOM hierarchy.
     *
     * Default true.
     *
     * #### Note
     * If true, when the mouse event occurs on element `span` within `div.top`,
     * the items matching `div.top` will be shown before the ones matching `body`.
     */
    groupByTarget?: boolean;
  }

  /**
   * An options object for creating a context menu item.
   */
  export interface IItemOptions extends Menu.IItemOptions {
    /**
     * The CSS selector for the context menu item.
     *
     * The context menu item will only be displayed in the context menu
     * when the selector matches a node on the propagation path of the
     * contextmenu event. This allows the menu item to be restricted to
     * user-defined contexts.
     *
     * The selector must not contain commas.
     */
    selector: string;

    /**
     * The rank for the item.
     *
     * The rank is used as a tie-breaker when ordering context menu
     * items for display. Items are sorted in the following order:
     *   1. Depth in the DOM tree (deeper is better)
     *   2. Selector specificity (higher is better)
     *   3. Rank (lower is better)
     *   4. Insertion order
     *
     * The default rank is `Infinity`.
     */
    rank?: number;
  }
}

/**
 * The namespace for the module implementation details.
 */
namespace Private {
  /**
   * A normalized item for a context menu.
   */
  export interface IItem extends Menu.IItemOptions {
    /**
     * The selector for the item.
     */
    selector: string;

    /**
     * The rank for the item.
     */
    rank: number;

    /**
     * The tie-breaking id for the item.
     */
    id: number;
  }

  /**
   * Create a normalized context menu item from an options object.
   */
  export function createItem(
    options: ContextMenu.IItemOptions,
    id: number
  ): IItem {
    let selector = validateSelector(options.selector);
    let rank = options.rank !== undefined ? options.rank : Infinity;
    return { ...options, selector, rank, id };
  }

  /**
   * Find the items which match a context menu event.
   *
   * The results are sorted by DOM level, specificity, and rank.
   */
  export function matchItems(
    items: IItem[],
    event: MouseEvent,
    groupByTarget: boolean,
    sortBySelector: boolean
  ): IItem[] | null {
    // Look up the target of the event.
    let target = event.target as Element | null;

    // Bail if there is no target.
    if (!target) {
      return null;
    }

    // Look up the current target of the event.
    let currentTarget = event.currentTarget as Element | null;

    // Bail if there is no current target.
    if (!currentTarget) {
      return null;
    }

    // There are some third party libraries that cause the `target` to
    // be detached from the DOM before lumino can process the event.
    // If that happens, search for a new target node by point. If that
    // node is still dangling, bail.
    if (!currentTarget.contains(target)) {
      target = document.elementFromPoint(event.clientX, event.clientY);
      if (!target || !currentTarget.contains(target)) {
        return null;
      }
    }

    // Set up the result array.
    let result: IItem[] = [];

    // Copy the items array to allow in-place modification.
    let availableItems: Array<IItem | null> = items.slice();

    // Walk up the DOM hierarchy searching for matches.
    while (target !== null) {
      // Set up the match array for this DOM level.
      let matches: IItem[] = [];

      // Search the remaining items for matches.
      for (let i = 0, n = availableItems.length; i < n; ++i) {
        // Fetch the item.
        let item = availableItems[i];

        // Skip items which are already consumed.
        if (!item) {
          continue;
        }

        // Skip items which do not match the element.
        if (!Selector.matches(target, item.selector)) {
          continue;
        }

        // Add the matched item to the result for this DOM level.
        matches.push(item);

        // Mark the item as consumed.
        availableItems[i] = null;
      }

      // Sort the matches for this level and add them to the results.
      if (matches.length !== 0) {
        if (groupByTarget) {
          matches.sort(sortBySelector ? itemCmp : itemCmpRank);
        }
        result.push(...matches);
      }

      // Stop searching at the limits of the DOM range.
      if (target === currentTarget) {
        break;
      }

      // Step to the parent DOM level.
      target = target.parentElement;
    }

    if (!groupByTarget) {
      result.sort(sortBySelector ? itemCmp : itemCmpRank);
    }

    // Return the matched and sorted results.
    return result;
  }

  /**
   * Validate the selector for a menu item.
   *
   * This returns the validated selector, or throws if the selector is
   * invalid or contains commas.
   */
  function validateSelector(selector: string): string {
    if (selector.indexOf(',') !== -1) {
      throw new Error(`Selector cannot contain commas: ${selector}`);
    }
    if (!Selector.isValid(selector)) {
      throw new Error(`Invalid selector: ${selector}`);
    }
    return selector;
  }

  /**
   * A sort comparison function for a context menu item by ranks.
   */
  function itemCmpRank(a: IItem, b: IItem): number {
    // Sort based on rank.
    let r1 = a.rank;
    let r2 = b.rank;
    if (r1 !== r2) {
      return r1 < r2 ? -1 : 1; // Infinity-safe
    }

    // When all else fails, sort by item id.
    return a.id - b.id;
  }

  /**
   * A sort comparison function for a context menu item by selectors and ranks.
   */
  function itemCmp(a: IItem, b: IItem): number {
    // Sort first based on selector specificity.
    let s1 = Selector.calculateSpecificity(a.selector);
    let s2 = Selector.calculateSpecificity(b.selector);
    if (s1 !== s2) {
      return s2 - s1;
    }

    // If specificities are equal
    return itemCmpRank(a, b);
  }
}
