/**
 * @typedef {object} ContextMenuEntry
 * @property {string} name               The context menu label. Can be localized.
 * @property {string} icon               A string containing an HTML icon element for the menu item
 * @property {string} [classes]          Additional CSS classes to apply to this menu item.
 * @property {string} group              An identifier for a group this entry belongs to.
 * @property {function(jQuery)} callback The function to call when the menu item is clicked. Receives the HTML element
 *                                       of the entry that this context menu is for.
 * @property {ContextMenuCondition|boolean} [condition] A function to call or boolean value to determine if this entry
 *                                                      appears in the menu.
 */

/**
 * @callback ContextMenuCondition
 * @param {jQuery} html  The HTML element of the context menu entry.
 * @returns {boolean}    Whether the entry should be rendered in the context menu.
 */

/**
 * @callback ContextMenuCallback
 * @param {HTMLElement} target  The element that the context menu has been triggered for.
 */

/**
 * Display a right-click activated Context Menu which provides a dropdown menu of options
 * A ContextMenu is constructed by designating a parent HTML container and a target selector
 * An Array of menuItems defines the entries of the menu which is displayed
 */
class ContextMenu {
  /**
   * @param {HTMLElement|jQuery} element                The containing HTML element within which the menu is positioned
   * @param {string} selector                           A CSS selector which activates the context menu.
   * @param {ContextMenuEntry[]} menuItems              An Array of entries to display in the menu
   * @param {object} [options]                          Additional options to configure the context menu.
   * @param {string} [options.eventName="contextmenu"]  Optionally override the triggering event which can spawn the
   *                                                    menu
   * @param {ContextMenuCallback} [options.onOpen]      A function to call when the context menu is opened.
   * @param {ContextMenuCallback} [options.onClose]     A function to call when the context menu is closed.
   */
  constructor(element, selector, menuItems, {eventName="contextmenu", onOpen, onClose}={}) {

    /**
     * The target HTMLElement being selected
     * @type {HTMLElement|jQuery}
     */
    this.element = element;

    /**
     * The target CSS selector which activates the menu
     * @type {string}
     */
    this.selector = selector || element.attr("id");

    /**
     * An interaction event name which activates the menu
     * @type {string}
     */
    this.eventName = eventName;

    /**
     * The array of menu items being rendered
     * @type {ContextMenuEntry[]}
     */
    this.menuItems = menuItems;

    /**
     * A function to call when the context menu is opened.
     * @type {Function}
     */
    this.onOpen = onOpen;

    /**
     * A function to call when the context menu is closed.
     * @type {Function}
     */
    this.onClose = onClose;

    /**
     * Track which direction the menu is expanded in
     * @type {boolean}
     */
    this._expandUp = false;

    // Bind to the current element
    this.bind();
  }

  /**
   * The parent HTML element to which the context menu is attached
   * @type {HTMLElement}
   */
  #target;

  /* -------------------------------------------- */

  /**
   * A convenience accessor to the context menu HTML object
   * @returns {*|jQuery.fn.init|jQuery|HTMLElement}
   */
  get menu() {
    return $("#context-menu");
  }

  /* -------------------------------------------- */

  /**
   * Create a ContextMenu for this Application and dispatch hooks.
   * @param {Application|ApplicationV2} app             The Application this ContextMenu belongs to.
   * @param {JQuery|HTMLElement} html                   The Application's rendered HTML.
   * @param {string} selector                           The target CSS selector which activates the menu.
   * @param {ContextMenuEntry[]} menuItems              The array of menu items being rendered.
   * @param {object} [options]                          Additional options to configure context menu initialization.
   * @param {string} [options.hookName="EntryContext"]  The name of the hook to call.
   * @returns {ContextMenu}
   */
  static create(app, html, selector, menuItems, {hookName="EntryContext", ...options}={}) {
    // FIXME ApplicationV2 does not support these hooks yet
    app._callHooks?.(className => `get${className}${hookName}`, menuItems);
    return new ContextMenu(html, selector, menuItems, options);
  }

  /* -------------------------------------------- */

  /**
   * Attach a ContextMenu instance to an HTML selector
   */
  bind() {
    const element = this.element instanceof HTMLElement ? this.element : this.element[0];
    element.addEventListener(this.eventName, event => {
      const matching = event.target.closest(this.selector);
      if ( !matching ) return;
      event.preventDefault();
      const priorTarget = this.#target;
      this.#target = matching;
      const menu = this.menu;

      // Remove existing context UI
      const prior = document.querySelector(".context");
      prior?.classList.remove("context");
      if ( this.#target.contains(menu[0]) ) return this.close();

      // If the menu is already open, call its close handler on its original target.
      ui.context?.onClose?.(priorTarget);

      // Render a new context menu
      event.stopPropagation();
      ui.context = this;
      this.onOpen?.(this.#target);
      return this.render($(this.#target), { event });
    });
  }

  /* -------------------------------------------- */

  /**
   * Closes the menu and removes it from the DOM.
   * @param {object} [options]                Options to configure the closing behavior.
   * @param {boolean} [options.animate=true]  Animate the context menu closing.
   * @returns {Promise<void>}
   */
  async close({animate=true}={}) {
    if ( animate ) await this._animateClose(this.menu);
    this._close();
  }

  /* -------------------------------------------- */

  _close() {
    for ( const item of this.menuItems ) {
      delete item.element;
    }
    this.menu.remove();
    $(".context").removeClass("context");
    delete ui.context;
    this.onClose?.(this.#target);
  }

  /* -------------------------------------------- */

  async _animateOpen(menu) {
    menu.hide();
    return new Promise(resolve => menu.slideDown(200, resolve));
  }

  /* -------------------------------------------- */

  async _animateClose(menu) {
    return new Promise(resolve => menu.slideUp(200, resolve));
  }

  /* -------------------------------------------- */

  /**
   * Render the Context Menu by iterating over the menuItems it contains.
   * Check the visibility of each menu item, and only render ones which are allowed by the item's logical condition.
   * Attach a click handler to each item which is rendered.
   * @param {jQuery} target                 The target element to which the context menu is attached
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The event that triggered the context menu opening.
   * @returns {Promise<jQuery>|void}        A Promise that resolves when the open animation has completed.
   */
  render(target, options={}) {
    const existing = $("#context-menu");
    let html = existing.length ? existing : $('<nav id="context-menu"></nav>');
    let ol = $('<ol class="context-items"></ol>');
    html.html(ol);

    if ( !this.menuItems.length ) return;

    const groups = this.menuItems.reduce((acc, entry) => {
      const group = entry.group ?? "_none";
      acc[group] ??= [];
      acc[group].push(entry);
      return acc;
    }, {});

    for ( const [group, entries] of Object.entries(groups) ) {
      let parent = ol;
      if ( group !== "_none" ) {
        const groupItem = $(`<li class="context-group" data-group-id="${group}"><ol></ol></li>`);
        ol.append(groupItem);
        parent = groupItem.find("ol");
      }
      for ( const item of entries ) {
        // Determine menu item visibility (display unless false)
        let display = true;
        if ( item.condition !== undefined ) {
          display = ( item.condition instanceof Function ) ? item.condition(target) : item.condition;
        }
        if ( !display ) continue;

        // Construct and add the menu item
        const name = game.i18n.localize(item.name);
        const classes = ["context-item", item.classes].filterJoin(" ");
        const li = $(`<li class="${classes}">${item.icon}${name}</li>`);
        li.children("i").addClass("fa-fw");
        parent.append(li);

        // Record a reference to the item
        item.element = li[0];
      }
    }

    // Bail out if there are no children
    if ( ol.children().length === 0 ) return;

    // Append to target
    this._setPosition(html, target, options);

    // Apply interactivity
    if ( !existing.length ) this.activateListeners(html);

    // Deactivate global tooltip
    game.tooltip.deactivate();

    // Animate open the menu
    return this._animateOpen(html);
  }

  /* -------------------------------------------- */

  /**
   * Set the position of the context menu, taking into consideration whether the menu should expand upward or downward
   * @param {jQuery} html                   The context menu element.
   * @param {jQuery} target                 The element that the context menu was spawned on.
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The event that triggered the context menu opening.
   * @protected
   */
  _setPosition(html, target, { event }={}) {
    const container = target[0].parentElement;

    // Append to target and get the context bounds
    target.css("position", "relative");
    html.css("visibility", "hidden");
    target.append(html);
    const contextRect = html[0].getBoundingClientRect();
    const parentRect = target[0].getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();

    // Determine whether to expand upwards
    const contextTop = parentRect.top - contextRect.height;
    const contextBottom = parentRect.bottom + contextRect.height;
    const canOverflowUp = (contextTop > containerRect.top) || (getComputedStyle(container).overflowY === "visible");

    // If it overflows the container bottom, but not the container top
    const containerUp = ( contextBottom > containerRect.bottom ) && ( contextTop >= containerRect.top );
    const windowUp = ( contextBottom > window.innerHeight ) && ( contextTop > 0 ) && canOverflowUp;
    this._expandUp = containerUp || windowUp;

    // Display the menu
    html.toggleClass("expand-up", this._expandUp);
    html.toggleClass("expand-down", !this._expandUp);
    html.css("visibility", "");
    target.addClass("context");
  }

  /* -------------------------------------------- */

  /**
   * Local listeners which apply to each ContextMenu instance which is created.
   * @param {jQuery} html
   */
  activateListeners(html) {
    html.on("click", "li.context-item", this.#onClickItem.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on context menu items.
   * @param {PointerEvent} event      The click event
   */
  #onClickItem(event) {
    event.preventDefault();
    event.stopPropagation();
    const li = event.currentTarget;
    const item = this.menuItems.find(i => i.element === li);
    item?.callback($(this.#target));
    this.close();
  }

  /* -------------------------------------------- */

  /**
   * Global listeners which apply once only to the document.
   */
  static eventListeners() {
    document.addEventListener("click", ev => {
      if ( ui.context ) ui.context.close();
    });
  }
}

/* -------------------------------------------- */
