import type {
  Column,
  DOMMouseOrTouchEvent,
  GridMenuCommandItemCallbackArgs,
  GridMenuEventWithElementCallbackArgs,
  GridMenuItem,
  GridMenuOption,
  GridOption,
  MenuCommandItem,
  onGridMenuColumnsChangedCallbackArgs
} from '../models/index';
import { BindingEventService as BindingEventService_, SlickEvent as SlickEvent_, Utils as Utils_ } from '../slick.core';
import type { SlickGrid } from '../slick.grid';

// for (iife) load Slick methods from global Slick object, or use imports for (esm)
const BindingEventService = IIFE_ONLY ? Slick.BindingEventService : BindingEventService_;
const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_;
const Utils = IIFE_ONLY ? Slick.Utils : Utils_;

/**
 * A control to add a Grid Menu (hambuger menu on top-right of the grid)
 *
 * USAGE:
 *
 * Add the slick.gridmenu.(js|css) files and register it with the grid.
 *
 * To specify a menu in a column header, extend the column definition like so:
 * let gridMenuControl = new Slick.Controls.GridMenu(columns, grid, options);
 *
 * Available grid options, by defining a gridMenu object:
 *
 *  let options = {
 *    enableCellNavigation: true,
 *    gridMenu: {
 *      commandTitle: "Command List",                // default to empty string
 *      columnTitle: "Columns",                     // default to empty string
 *      iconImage: "some-image.png",                // this is the Grid Menu icon (hamburger icon)
 *      iconCssClass: "fa fa-bars",                 // you can provide iconImage OR iconCssClass
 *      leaveOpen: false,                           // do we want to leave the Grid Menu open after a command execution? (false by default)
 *      menuWidth: 18,                              // width (icon) that will be use to resize the column header container (18 by default)
 *      contentMinWidth: 0,							            // defaults to 0 (auto), minimum width of grid menu content (command, column list)
 *      marginBottom: 15,                           // defaults to 15, margin to use at the bottom of the grid when using max-height (default)
 *      resizeOnShowHeaderRow: false,               // false by default
 *      showButton: true,                           // true by default - it allows the user to control if the
 *                                                          // default gridMenu button (located on the top right corner by default CSS)
 *                                                          // should be created or omitted
 *      useClickToRepositionMenu: true,             // true by default
 *
 *      // the last 2 checkboxes titles
 *      hideForceFitButton: false,                  // show/hide checkbox near the end "Force Fit Columns"
 *      hideSyncResizeButton: false,                // show/hide checkbox near the end "Synchronous Resize"
 *      forceFitTitle: "Force fit columns",         // default to "Force fit columns"
 *      syncResizeTitle: "Synchronous resize",      // default to "Synchronous resize"
 *
 *      commandItems: [
 *        {
 *          // command menu item options
 *        },
 *        {
 *          // command menu item options
 *        }
 *      ]
 *    }
 *  };
 *
 *
 * Available menu options:
 *    hideForceFitButton:         Hide the "Force fit columns" button (defaults to false)
 *    hideSyncResizeButton:       Hide the "Synchronous resize" button (defaults to false)
 *    forceFitTitle:              Text of the title "Force fit columns"
 *    contentMinWidth:						minimum width of grid menu content (command, column list), defaults to 0 (auto)
 *    height:                     Height of the Grid Menu content, when provided it will be used instead of the max-height (defaults to undefined)
 *    menuWidth:                  Grid menu button width (defaults to 18)
 *    resizeOnShowHeaderRow:      Do we want to resize on the show header row event
 *    syncResizeTitle:            Text of the title "Synchronous resize"
 *    useClickToRepositionMenu:   Use the Click offset to reposition the Grid Menu (defaults to true), when set to False it will use the icon offset to reposition the grid menu
 *    menuUsabilityOverride:      Callback method that user can override the default behavior of enabling/disabling the menu from being usable (must be combined with a custom formatter)
 *    marginBottom:               Margin to use at the bottom of the grid menu, only in effect when height is undefined (defaults to 15)
 *    subItemChevronClass:        CSS class that can be added on the right side of a sub-item parent (typically a chevron-right icon)
 *    subMenuOpenByEvent:         defaults to "mouseover", what event type shoud we use to open sub-menu(s), 2 options are available: "mouseover" or "click"
 *
 * Available command menu item options:
 *    action:                     Optionally define a callback function that gets executed when item is chosen (and/or use the onCommand event)
 *    title:                      Menu item text.
 *    divider:                    Whether the current item is a divider, not an actual command.
 *    disabled:                   Whether the item/command is disabled.
 *    hidden:                     Whether the item/command is hidden.
 *    tooltip:                    Item tooltip.
 *    command:                    A command identifier to be passed to the onCommand event handlers.
 *    cssClass:                   A CSS class to be added to the menu item container.
 *    iconCssClass:               A CSS class to be added to the menu item icon.
 *    iconImage:                  A url to the icon image.
 *    textCssClass:               A CSS class to be added to the menu item text.
 *    subMenuTitle:               Optional sub-menu title that will shows up when sub-menu commmands/options list is opened
 *    subMenuTitleCssClass:       Optional sub-menu title CSS class to use with `subMenuTitle`
 *    itemVisibilityOverride:     Callback method that user can override the default behavior of showing/hiding an item from the list
 *    itemUsabilityOverride:      Callback method that user can override the default behavior of enabling/disabling an item from the list
 *
 *
 * The plugin exposes the following events:
 *
 *    onAfterMenuShow:   Fired after the menu is shown.  You can customize the menu or dismiss it by returning false.
 *      * ONLY works with a JS event (as per slick.core code), so we cannot notify when it's a button event (when grid menu is attached to an external button, not the hamburger menu)
 *        Event args:
 *            grid:     Reference to the grid.
 *            column:   Column definition.
 *            menu:     Menu options.  Note that you can change the menu items here.
 *
 *    onBeforeMenuShow:   Fired before the menu is shown.  You can customize the menu or dismiss it by returning false.
 *      * ONLY works with a JS event (as per slick.core code), so we cannot notify when it's a button event (when grid menu is attached to an external button, not the hamburger menu)
 *        Event args:
 *            grid:     Reference to the grid.
 *            column:   Column definition.
 *            menu:     Menu options.  Note that you can change the menu items here.
 *
 *    onMenuClose:      Fired when the menu is closing.
 *        Event args:
 *            grid:     Reference to the grid.
 *            column:   Column definition.
 *            menu:     Menu options.  Note that you can change the menu items here.
 *
 *    onCommand:    Fired on menu item click for buttons with 'command' specified.
 *        Event args:
 *            grid:     Reference to the grid.
 *            column:   Column definition.
 *            command:  Button command identified.
 *            button:   Button options.  Note that you can change the button options in your
 *                      event handler, and the column header will be automatically updated to
 *                      reflect them.  This is useful if you want to implement something like a
 *                      toggle button.
 */

export class SlickGridMenu {
  // --
  // public API
  onAfterMenuShow = new SlickEvent<GridMenuEventWithElementCallbackArgs>('onAfterMenuShow');
  onBeforeMenuShow = new SlickEvent<GridMenuEventWithElementCallbackArgs>('onBeforeMenuShow');
  onMenuClose = new SlickEvent<GridMenuEventWithElementCallbackArgs>('onMenuClose');
  onCommand = new SlickEvent<GridMenuCommandItemCallbackArgs>('onCommand');
  onColumnsChanged = new SlickEvent<onGridMenuColumnsChangedCallbackArgs>('onColumnsChanged');

  // --
  // protected props
  protected _bindingEventService: BindingEventService_;
  protected _gridOptions: GridOption;
  protected _gridUid: string;
  protected _isMenuOpen = false;
  protected _columnCheckboxes: HTMLInputElement[] = [];
  protected _columnTitleElm!: HTMLElement;
  protected _commandTitleElm!: HTMLElement;
  protected _commandListElm!: HTMLDivElement;
  protected _headerElm: HTMLDivElement | null = null;
  protected _listElm!: HTMLElement;
  protected _buttonElm!: HTMLElement;
  protected _menuElm!: HTMLElement;
  protected _subMenuParentId = '';
  protected _gridMenuOptions: GridMenuOption | null = null;
  protected _defaults: GridMenuOption = {
    showButton: true,
    hideForceFitButton: false,
    hideSyncResizeButton: false,
    forceFitTitle: 'Force fit columns',
    marginBottom: 15,
    menuWidth: 18,
    contentMinWidth: 0,
    resizeOnShowHeaderRow: false,
    subMenuOpenByEvent: 'mouseover',
    syncResizeTitle: 'Synchronous resize',
    useClickToRepositionMenu: true,
    headerColumnValueExtractor: (columnDef: Column) => Utils.getHtmlStringOutput(columnDef.name || '', 'innerHTML'),
  };

  constructor(protected columns: Column[], protected readonly grid: SlickGrid, gridOptions: GridOption) {
    this._gridUid = grid.getUID();
    this._gridOptions = gridOptions;
    this._gridMenuOptions = Utils.extend({}, this._defaults, gridOptions.gridMenu);
    this._bindingEventService = new BindingEventService();

    // when a grid optionally changes from a regular grid to a frozen grid, we need to destroy & recreate the grid menu
    // we do this change because the Grid Menu is on the left container for a regular grid, it is however on the right container for a frozen grid
    grid.onSetOptions.subscribe((_e, args) => {
      if (args && args.optionsBefore && args.optionsAfter) {
        const switchedFromRegularToFrozen = args.optionsBefore.frozenColumn! >= 0 && args.optionsAfter.frozenColumn === -1;
        const switchedFromFrozenToRegular = args.optionsBefore.frozenColumn === -1 && args.optionsAfter.frozenColumn! >= 0;
        if (switchedFromRegularToFrozen || switchedFromFrozenToRegular) {
          this.recreateGridMenu();
        }
      }
    });
    this.init(this.grid);
  }

  init(grid: SlickGrid) {
    this._gridOptions = grid.getOptions();
    Utils.addSlickEventPubSubWhenDefined(grid.getPubSubService(), this);
    this.createGridMenu();

    if (this._gridMenuOptions?.customItems || this._gridMenuOptions?.customTitle) {
      console.warn('[SlickGrid] Grid Menu "customItems" and "customTitle" were deprecated to align with other Menu plugins, please use "commandItems" and "commandTitle" instead.');
    }

    // subscribe to the grid, when it's destroyed, we should also destroy the Grid Menu
    grid.onBeforeDestroy.subscribe(this.destroy.bind(this));
  }

  setOptions(newOptions: GridMenuOption) {
    this._gridMenuOptions = Utils.extend({}, this._gridMenuOptions, newOptions);
  }

  protected createGridMenu() {
    const gridMenuWidth = (this._gridMenuOptions?.menuWidth) || this._defaults.menuWidth;
    if (this._gridOptions && this._gridOptions.hasOwnProperty('frozenColumn') && this._gridOptions.frozenColumn! >= 0) {
      this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-right`);
    } else {
      this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-left`);
    }
    this._headerElm!.style.width = `calc(100% - ${gridMenuWidth}px)`;

    // if header row is enabled, we need to resize its width also
    const enableResizeHeaderRow = (Utils.isDefined(this._gridMenuOptions?.resizeOnShowHeaderRow)) ? this._gridMenuOptions!.resizeOnShowHeaderRow : this._defaults.resizeOnShowHeaderRow;
    if (enableResizeHeaderRow && this._gridOptions.showHeaderRow) {
      const headerRow = document.querySelector<HTMLDivElement>(`.${this._gridUid}.slick-headerrow`);
      if (headerRow) {
        headerRow.style.width = `calc(100% - ${gridMenuWidth}px)`;
      }
    }

    const showButton = (this._gridMenuOptions?.showButton !== undefined) ? this._gridMenuOptions.showButton : this._defaults.showButton;
    if (showButton) {
      this._buttonElm = document.createElement('button');
      this._buttonElm.className = 'slick-gridmenu-button';
      this._buttonElm.ariaLabel = 'Grid Menu';

      if (this._gridMenuOptions?.iconCssClass) {
        this._buttonElm.classList.add(...Utils.classNameToList(this._gridMenuOptions.iconCssClass));
      } else {
        const iconImageElm = document.createElement('img');
        iconImageElm.src = (this._gridMenuOptions?.iconImage) ? this._gridMenuOptions.iconImage : '../images/drag-handle.png';
        this._buttonElm.appendChild(iconImageElm);
      }

      this._headerElm!.parentElement!.insertBefore(this._buttonElm, this._headerElm!.parentElement!.firstChild);

      // add on click handler for the Grid Menu itself
      this._bindingEventService.bind(this._buttonElm, 'click', this.showGridMenu.bind(this) as EventListener);
    }

    this._menuElm = this.createMenu(0);
    this.populateColumnPicker();
    document.body.appendChild(this._menuElm);

    // Hide the menu on outside click.
    this._bindingEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener);

    // destroy the picker if user leaves the page
    this._bindingEventService.bind(document.body, 'beforeunload', this.destroy.bind(this));
  }

  /** Create the menu or sub-menu(s) but without the column picker which is a separate single process */
  createMenu(level = 0, item?: GridMenuItem | MenuCommandItem | 'divider') {
    // create a new cell menu
    const maxHeight = isNaN(this._gridMenuOptions?.maxHeight as number) ? this._gridMenuOptions?.maxHeight : `${this._gridMenuOptions?.maxHeight ?? 0}px`;
    const width = isNaN(this._gridMenuOptions?.width as number) ? this._gridMenuOptions?.width : `${this._gridMenuOptions?.maxWidth ?? 0}px`;

    // to avoid having multiple sub-menu trees opened,
    // we need to somehow keep trace of which parent menu the tree belongs to
    // and we should keep ref of only the first sub-menu parent, we can use the command name (remove any whitespaces though)
    const subMenuCommand = (item as GridMenuItem)?.command;
    let subMenuId = (level === 1 && subMenuCommand) ? subMenuCommand.replaceAll(' ', '') : '';
    if (subMenuId) {
      this._subMenuParentId = subMenuId;
    }
    if (level > 1) {
      subMenuId = this._subMenuParentId;
    }

    const menuClasses = `slick-gridmenu slick-menu-level-${level} ${this._gridUid}`;
    const bodyMenuElm = document.body.querySelector<HTMLDivElement>(`.slick-gridmenu.slick-menu-level-${level}${this.getGridUidSelector()}`);

    // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all
    if (bodyMenuElm) {
      if (bodyMenuElm.dataset.subMenuParent === subMenuId) {
        return bodyMenuElm;
      }
      this.destroySubMenus();
    }

    const menuElm = document.createElement('div');
    menuElm.role = 'menu';
    menuElm.className = menuClasses;
    if (level > 0) {
      menuElm.classList.add('slick-submenu');
      if (subMenuId) {
        menuElm.dataset.subMenuParent = subMenuId;
      }
    }
    menuElm.ariaLabel = level > 1 ? 'SubMenu' : 'Grid Menu';

    if (width) {
      menuElm.style.width = width as string;
    }
    if (maxHeight) {
      menuElm.style.maxHeight = maxHeight as string;
    }

    menuElm.style.display = 'none';

    let closeButtonElm: HTMLButtonElement | null = null;
    if (level === 0) {
      closeButtonElm = document.createElement('button');
      closeButtonElm.type = 'button';
      closeButtonElm.className = 'close';
      closeButtonElm.dataset.dismiss = 'slick-gridmenu';
      closeButtonElm.ariaLabel = 'Close';

      const spanCloseElm = document.createElement('span');
      spanCloseElm.className = 'close';
      spanCloseElm.ariaHidden = 'true';
      spanCloseElm.textContent = '×';
      closeButtonElm.appendChild(spanCloseElm);
      menuElm.appendChild(closeButtonElm);
    }

    // -- Command List section
    this._commandListElm = document.createElement('div');
    this._commandListElm.className = `slick-gridmenu-custom slick-gridmenu-command-list slick-menu-level-${level}`;
    this._commandListElm.role = 'menu';
    menuElm.appendChild(this._commandListElm);

    const commandItems =
      (item as GridMenuItem)?.commandItems
      ?? (item as GridMenuItem)?.customItems
      ?? this._gridMenuOptions?.commandItems
      ?? this._gridMenuOptions?.customItems
      ?? [];

    if (commandItems.length > 0) {

      // when creating sub-menu add its sub-menu title when exists
      if (item && level > 0) {
        this.addSubMenuTitleWhenExists(item, this._commandListElm); // add sub-menu title when exists
      }
    }
    this.populateCommandsMenu(commandItems, this._commandListElm, { grid: this.grid, level });

    // increment level for possible next sub-menus if exists
    level++;

    return menuElm;
  }

  /** Destroy the plugin by unsubscribing every events & also delete the menu DOM elements */
  destroy() {
    this.onAfterMenuShow.unsubscribe();
    this.onBeforeMenuShow.unsubscribe();
    this.onMenuClose.unsubscribe();
    this.onCommand.unsubscribe();
    this.onColumnsChanged.unsubscribe();
    this.grid.onColumnsReordered.unsubscribe(this.updateColumnOrder.bind(this));
    this.grid.onBeforeDestroy.unsubscribe();
    this.grid.onSetOptions.unsubscribe();
    this._bindingEventService.unbindAll();
    this._menuElm?.remove();
    this.deleteMenu();
  }

  /** Delete the menu DOM element but without unsubscribing any events */
  deleteMenu() {
    this._bindingEventService.unbindAll();
    const gridMenuElm = document.querySelector<HTMLDivElement>(`div.slick-gridmenu.${this._gridUid}`);
    if (gridMenuElm) {
      gridMenuElm.style.display = 'none';
    }
    if (this._headerElm) {
      // put back original width (fixes width and frozen+gridMenu on left header)
      this._headerElm.style.width = '100%';
    }
    this._buttonElm?.remove();
    this._menuElm?.remove();
  }

  /** Close and destroy all previously opened sub-menus */
  destroySubMenus() {
    this._bindingEventService.unbindAll('sub-menu');
    document.querySelectorAll(`.slick-gridmenu.slick-submenu${this.getGridUidSelector()}`)
      .forEach(subElm => subElm.remove());
  }

  /** Construct the custom command menu items. */
  protected populateCommandsMenu(commandItems: Array<GridMenuItem | MenuCommandItem | 'divider'>, commandListElm: HTMLElement, args: { grid: SlickGrid, level: number }) {
    // user could pass a title on top of the custom section
    const level = args?.level || 0;
    const isSubMenu = level > 0;
    if (!isSubMenu && (this._gridMenuOptions?.commandTitle || this._gridMenuOptions?.customTitle)) {
      this._commandTitleElm = document.createElement('div');
      this._commandTitleElm.className = 'title';
      this.grid.applyHtmlCode(this._commandTitleElm, this.grid.sanitizeHtmlString((this._gridMenuOptions.commandTitle || this._gridMenuOptions.customTitle) as string));
      commandListElm.appendChild(this._commandTitleElm);
    }

    for (let i = 0, ln = commandItems.length; i < ln; i++) {
      let addClickListener = true;
      const item = commandItems[i];
      const callbackArgs = {
        grid: this.grid,
        menu: this._menuElm,
        columns: this.columns,
        visibleColumns: this.getVisibleColumns()
      };

      // run each override functions to know if the item is visible and usable
      const isItemVisible = this.runOverrideFunctionWhenExists<typeof callbackArgs>((item as GridMenuItem).itemVisibilityOverride, callbackArgs);
      const isItemUsable = this.runOverrideFunctionWhenExists<typeof callbackArgs>((item as GridMenuItem).itemUsabilityOverride, callbackArgs);

      // if the result is not visible then there's no need to go further
      if (!isItemVisible) {
        continue;
      }

      // when the override is defined, we need to use its result to update the disabled property
      // so that "handleMenuItemClick" has the correct flag and won't trigger a command clicked event
      if (Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) {
        (item as GridMenuItem).disabled = isItemUsable ? false : true;
      }

      const liElm = document.createElement('div');
      liElm.className = 'slick-gridmenu-item';
      liElm.role = 'menuitem';

      if ((item as GridMenuItem).divider || item === 'divider') {
        liElm.classList.add('slick-gridmenu-item-divider');
        addClickListener = false;
      }
      if ((item as GridMenuItem).disabled) {
        liElm.classList.add('slick-gridmenu-item-disabled');
      }

      if ((item as GridMenuItem).hidden) {
        liElm.classList.add('slick-gridmenu-item-hidden');
      }

      if ((item as GridMenuItem).cssClass) {
        liElm.classList.add(...Utils.classNameToList((item as GridMenuItem).cssClass));
      }

      if ((item as GridMenuItem).tooltip) {
        liElm.title = (item as GridMenuItem).tooltip || '';
      }

      const iconElm = document.createElement('div');
      iconElm.className = 'slick-gridmenu-icon';

      liElm.appendChild(iconElm);

      if ((item as GridMenuItem).iconCssClass) {
        iconElm.classList.add(...Utils.classNameToList((item as GridMenuItem).iconCssClass));
      }

      if ((item as GridMenuItem).iconImage) {
        iconElm.style.backgroundImage = `url(${(item as GridMenuItem).iconImage})`;
      }

      const textElm = document.createElement('span');
      textElm.className = 'slick-gridmenu-content';
      this.grid.applyHtmlCode(textElm, this.grid.sanitizeHtmlString((item as GridMenuItem).title || ''));

      liElm.appendChild(textElm);

      if ((item as GridMenuItem).textCssClass) {
        textElm.classList.add(...Utils.classNameToList((item as GridMenuItem).textCssClass));
      }

      commandListElm.appendChild(liElm);

      if (addClickListener) {
        const eventGroup = isSubMenu ? 'sub-menu' : 'parent-menu';
        this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item, level) as EventListener, undefined, eventGroup);
      }

      // optionally open sub-menu(s) by mouseover
      if (this._gridMenuOptions?.subMenuOpenByEvent === 'mouseover') {
        this._bindingEventService.bind(liElm, 'mouseover', ((e: DOMMouseOrTouchEvent<HTMLDivElement>) => {
          if ((item as GridMenuItem).commandItems || (item as GridMenuItem).customItems) {
            this.repositionSubMenu(item, level, e);
          } else if (!isSubMenu) {
            this.destroySubMenus();
          }
        }) as EventListener);
      }

      // the option/command item could be a sub-menu if it has another list of commands/options
      if ((item as GridMenuItem).commandItems || (item as GridMenuItem).customItems) {
        const chevronElm = document.createElement('span');
        chevronElm.className = 'sub-item-chevron';
        if (this._gridMenuOptions?.subItemChevronClass) {
          chevronElm.classList.add(...Utils.classNameToList(this._gridMenuOptions.subItemChevronClass));
        } else {
          chevronElm.textContent = '⮞'; // ⮞ or ▸
        }

        liElm.classList.add('slick-submenu-item');
        liElm.appendChild(chevronElm);
        continue;
      }
    }
  }

  /** Build the column picker, the code comes almost untouched from the file "slick.columnpicker.js" */
  protected populateColumnPicker() {
    this.grid.onColumnsReordered.subscribe(this.updateColumnOrder.bind(this));

    // user could pass a title on top of the columns list
    if (this._gridMenuOptions?.columnTitle) {
      this._columnTitleElm = document.createElement('div');
      this._columnTitleElm.className = 'title';
      this.grid.applyHtmlCode(this._columnTitleElm, this.grid.sanitizeHtmlString(this._gridMenuOptions.columnTitle));
      this._menuElm.appendChild(this._columnTitleElm);
    }

    this._bindingEventService.bind(this._menuElm, 'click', this.updateColumn.bind(this) as EventListener);
    this._listElm = document.createElement('span');
    this._listElm.className = 'slick-gridmenu-list';
    this._listElm.role = 'menu';
  }

  /** Delete and then Recreate the Grid Menu (for example when we switch from regular to a frozen grid) */
  recreateGridMenu() {
    this.deleteMenu();
    this.init(this.grid);
  }

  showGridMenu(e: DOMMouseOrTouchEvent<HTMLButtonElement>) {
    const targetEvent = e.touches ? e.touches[0] : e;
    e.preventDefault();

    // empty both the picker list & the command list
    Utils.emptyElement(this._listElm);
    Utils.emptyElement(this._commandListElm);

    const commandItems = this._gridMenuOptions?.commandItems ?? this._gridMenuOptions?.customItems ?? [];
    this.populateCommandsMenu(commandItems, this._commandListElm, { grid: this.grid, level: 0 });
    this.updateColumnOrder();
    this._columnCheckboxes = [];

    const callbackArgs = {
      grid: this.grid,
      menu: this._menuElm,
      allColumns: this.columns,
      visibleColumns: this.getVisibleColumns()
    };

    // run the override function (when defined), if the result is false it won't go further
    if (this._gridMenuOptions && !this.runOverrideFunctionWhenExists<typeof callbackArgs>(this._gridMenuOptions.menuUsabilityOverride, callbackArgs)) {
      return;
    }

    // notify of the onBeforeMenuShow only works when
    // this mean that we cannot notify when the grid menu is attach to a button event
    if (typeof e.stopPropagation === 'function') {
      if (this.onBeforeMenuShow.notify(callbackArgs, e, this).getReturnValue() === false) {
        return;
      }
    }

    let columnId, columnLabel, excludeCssClass;
    for (let i = 0; i < this.columns.length; i++) {
      columnId = this.columns[i].id;
      excludeCssClass = this.columns[i].excludeFromGridMenu ? 'hidden' : '';
      const colName: string = this.columns[i].name instanceof HTMLElement
        ? (this.columns[i].name as HTMLElement).innerHTML
        : (this.columns[i].name || '') as string;

      const liElm = document.createElement('li');
      liElm.className = excludeCssClass;
      liElm.ariaLabel = colName;

      const checkboxElm = document.createElement('input');
      checkboxElm.type = 'checkbox';
      checkboxElm.id = `${this._gridUid}-gridmenu-colpicker-${columnId}`;
      checkboxElm.dataset.columnid = String(this.columns[i].id);
      liElm.appendChild(checkboxElm);

      if (Utils.isDefined(this.grid.getColumnIndex(this.columns[i].id)) && !this.columns[i].hidden) {
        checkboxElm.checked = true;
      }

      this._columnCheckboxes.push(checkboxElm);

      // get the column label from the picker value extractor (user can optionally provide a custom extractor)
      columnLabel = (this._gridMenuOptions?.headerColumnValueExtractor)
        ? this._gridMenuOptions.headerColumnValueExtractor(this.columns[i], this._gridOptions)
        : this._defaults.headerColumnValueExtractor!(this.columns[i]);

      const labelElm = document.createElement('label');
      labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-${columnId}`;
      this.grid.applyHtmlCode(labelElm, this.grid.sanitizeHtmlString(Utils.getHtmlStringOutput(columnLabel || '')));
      liElm.appendChild(labelElm);
      this._listElm.appendChild(liElm);
    }

    if (this._gridMenuOptions && (!this._gridMenuOptions.hideForceFitButton || !this._gridMenuOptions.hideSyncResizeButton)) {
      this._listElm.appendChild(document.createElement('hr'));
    }

    if (!(this._gridMenuOptions?.hideForceFitButton)) {
      const forceFitTitle = (this._gridMenuOptions?.forceFitTitle) || this._defaults.forceFitTitle as string;

      const liElm = document.createElement('li');
      liElm.ariaLabel = forceFitTitle;
      liElm.role = 'menuitem';
      this._listElm.appendChild(liElm);

      const forceFitCheckboxElm = document.createElement('input');
      forceFitCheckboxElm.type = 'checkbox';
      forceFitCheckboxElm.id = `${this._gridUid}-gridmenu-colpicker-forcefit`;
      forceFitCheckboxElm.dataset.option = 'autoresize';
      liElm.appendChild(forceFitCheckboxElm);

      const labelElm = document.createElement('label');
      labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-forcefit`;
      labelElm.textContent = forceFitTitle;
      liElm.appendChild(labelElm);

      if (this.grid.getOptions().forceFitColumns) {
        forceFitCheckboxElm.checked = true;
      }
    }

    if (!(this._gridMenuOptions?.hideSyncResizeButton)) {
      const syncResizeTitle = (this._gridMenuOptions?.syncResizeTitle) || this._defaults.syncResizeTitle as string;

      const liElm = document.createElement('li');
      liElm.ariaLabel = syncResizeTitle;
      this._listElm.appendChild(liElm);

      const syncResizeCheckboxElm = document.createElement('input');
      syncResizeCheckboxElm.type = 'checkbox';
      syncResizeCheckboxElm.id = `${this._gridUid}-gridmenu-colpicker-syncresize`;
      syncResizeCheckboxElm.dataset.option = 'syncresize';
      liElm.appendChild(syncResizeCheckboxElm);

      const labelElm = document.createElement('label');
      labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-syncresize`;
      labelElm.textContent = syncResizeTitle;
      liElm.appendChild(labelElm);

      if (this.grid.getOptions().syncColumnCellResize) {
        syncResizeCheckboxElm.checked = true;
      }
    }

    let buttonElm = (e.target.nodeName === 'BUTTON' ? e.target : e.target.querySelector('button')) as HTMLButtonElement; // get button element
    if (!buttonElm) {
      buttonElm = e.target.parentElement as HTMLButtonElement; // external grid menu might fall in this last case if wrapped in a span/div
    }

    // we need to display the menu to properly calculate its width but we can however make it invisible
    this._menuElm.style.display = 'block';
    this._menuElm.style.opacity = '0';

    this.repositionMenu(e, this._menuElm, buttonElm);

    // set "height" when defined OR ELSE use the "max-height" with available window size and optional margin bottom
    const menuMarginBottom = (this._gridMenuOptions?.marginBottom !== undefined) ? this._gridMenuOptions.marginBottom : this._defaults.marginBottom as number;
    if (this._gridMenuOptions?.height !== undefined) {
      this._menuElm.style.height = `${this._gridMenuOptions.height}px`;
    } else {
      this._menuElm.style.maxHeight = `${window.innerHeight - targetEvent.clientY - menuMarginBottom}px`;
    }

    this._menuElm.style.display = 'block';
    this._menuElm.style.opacity = '1'; // restore menu visibility
    this._menuElm.appendChild(this._listElm);
    this._isMenuOpen = true;

    if (typeof e.stopPropagation === 'function') {
      if (this.onAfterMenuShow.notify(callbackArgs, e, this).getReturnValue() === false) {
        return;
      }
    }
  }

  protected getGridUidSelector() {
    const gridUid = this.grid.getUID() || '';
    return gridUid ? `.${gridUid}` : '';
  }

  protected handleBodyMouseDown(e: DOMMouseOrTouchEvent<HTMLElement>) {
    // did we click inside the menu or any of its sub-menu(s)
    let isMenuClicked = false;
    if (this._menuElm?.contains(e.target)) {
      isMenuClicked = true;
    }
    if (!isMenuClicked) {
      document
        .querySelectorAll(`.slick-gridmenu.slick-submenu${this.getGridUidSelector()}`)
        .forEach(subElm => {
          if (subElm.contains(e.target)) {
            isMenuClicked = true;
          }
        });
    }

    if ((this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented && this._isMenuOpen) || e.target.className === 'close') {
      this.hideMenu(e);
    }
  }

  protected handleMenuItemClick(item: GridMenuItem | MenuCommandItem | 'divider', level = 0, e: DOMMouseOrTouchEvent<HTMLButtonElement | HTMLDivElement>) {
    if (item !== 'divider' && !item.disabled && !item.divider) {
      const command = item.command || '';

      if (Utils.isDefined(command) && !item.commandItems && !(item as GridMenuItem).customItems) {
        const callbackArgs: GridMenuCommandItemCallbackArgs = {
          grid: this.grid,
          command,
          item,
          allColumns: this.columns,
          visibleColumns: this.getVisibleColumns()
        };
        this.onCommand.notify(callbackArgs, e, this);

        // execute action callback when defined
        if (typeof item.action === 'function') {
          (item as MenuCommandItem).action!.call(this, e, callbackArgs);
        }

        // does the user want to leave open the Grid Menu after executing a command?
        const leaveOpen = !!(this._gridMenuOptions?.leaveOpen);
        if (!leaveOpen && !e.defaultPrevented) {
          this.hideMenu(e);
        }

        // Stop propagation so that it doesn't register as a header click event.
        e.preventDefault();
        e.stopPropagation();
      } else if (item.commandItems || (item as GridMenuItem).customItems) {
        this.repositionSubMenu(item, level, e);
      } else {
        this.destroySubMenus();
      }
    }
  }

  hideMenu(e: DOMMouseOrTouchEvent<HTMLElement>) {
    if (this._menuElm) {
      const callbackArgs = {
        grid: this.grid,
        menu: this._menuElm,
        allColumns: this.columns,
        visibleColumns: this.getVisibleColumns()
      };
      if (this._isMenuOpen && this.onMenuClose.notify(callbackArgs, e, this).getReturnValue() === false) {
        return;
      }
      this._isMenuOpen = false;
      Utils.hide(this._menuElm);
    }
    this.destroySubMenus();
  }

  /** Update the Titles of each sections (command, commandTitle, ...) */
  updateAllTitles(gridMenuOptions: GridMenuOption) {
    if (this._commandTitleElm) {
      this.grid.applyHtmlCode(this._commandTitleElm, this.grid.sanitizeHtmlString(gridMenuOptions.commandTitle || gridMenuOptions.customTitle || ''));
    }
    if (this._columnTitleElm) {
      this.grid.applyHtmlCode(this._columnTitleElm, this.grid.sanitizeHtmlString(gridMenuOptions.columnTitle || ''));
    }
  }

  protected addSubMenuTitleWhenExists(item: GridMenuItem | MenuCommandItem | 'divider', commandOrOptionMenu: HTMLDivElement) {
    if (item !== 'divider' && item?.subMenuTitle) {
      const subMenuTitleElm = document.createElement('div');
      subMenuTitleElm.className = 'slick-menu-title';
      subMenuTitleElm.textContent = item.subMenuTitle as string;
      const subMenuTitleClass = item.subMenuTitleCssClass as string;
      if (subMenuTitleClass) {
        subMenuTitleElm.classList.add(...Utils.classNameToList(subMenuTitleClass));
      }

      commandOrOptionMenu.appendChild(subMenuTitleElm);
    }
  }

  protected repositionSubMenu(item: GridMenuItem | MenuCommandItem | 'divider', level: number, e: DOMMouseOrTouchEvent<HTMLButtonElement | HTMLDivElement>) {
    // when we're clicking a grid cell OR our last menu type (command/option) differs then we know that we need to start fresh and close any sub-menus that might still be open
    if (e.target.classList.contains('slick-cell')) {
      this.destroySubMenus();
    }

    // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show
    const subMenuElm = this.createMenu(level + 1, item);
    subMenuElm.style.display = 'block';
    document.body.appendChild(subMenuElm);
    this.repositionMenu(e, subMenuElm);
  }

  /**
   * Reposition the menu drop (up/down) and the side (left/right)
   * @param {*} event
   */
  protected repositionMenu(e: DOMMouseOrTouchEvent<HTMLButtonElement | HTMLDivElement>, menuElm: HTMLElement, buttonElm?: HTMLButtonElement) {
    const targetEvent = e.touches ? e.touches[0] : e;
    const isSubMenu = menuElm.classList.contains('slick-submenu');
    const parentElm = isSubMenu
      ? e.target.closest('.slick-gridmenu-item') as HTMLDivElement
      : targetEvent.target as HTMLElement;

    const menuIconOffset = Utils.offset(buttonElm || this._buttonElm); // get button offset position
    const menuWidth = menuElm.offsetWidth;
    const useClickToRepositionMenu = (this._gridMenuOptions?.useClickToRepositionMenu !== undefined) ? this._gridMenuOptions.useClickToRepositionMenu : this._defaults.useClickToRepositionMenu;
    const contentMinWidth = (this._gridMenuOptions?.contentMinWidth) ? this._gridMenuOptions.contentMinWidth : this._defaults.contentMinWidth as number;
    const currentMenuWidth = (contentMinWidth > menuWidth) ? contentMinWidth : menuWidth + 5;
    let menuOffsetTop = (useClickToRepositionMenu && targetEvent.pageY > 0) ? targetEvent.pageY : menuIconOffset!.top + 10;
    let menuOffsetLeft = (useClickToRepositionMenu && targetEvent.pageX > 0) ? targetEvent.pageX : menuIconOffset!.left + 10;

    if (isSubMenu && parentElm) {
      const parentOffset = Utils.offset(parentElm);
      menuOffsetLeft = parentOffset?.left ?? 0;
      menuOffsetTop = parentOffset?.top ?? 0;
      const gridPos = this.grid.getGridPosition();
      let subMenuPosCalc = menuOffsetLeft + Number(menuWidth); // calculate coordinate at caller element far right
      if (isSubMenu) {
        subMenuPosCalc += parentElm.clientWidth;
      }
      const browserWidth = document.documentElement.clientWidth;
      const dropSide = (subMenuPosCalc >= gridPos.width || subMenuPosCalc >= browserWidth) ? 'left' : 'right';
      if (dropSide === 'left') {
        menuElm.classList.remove('dropright');
        menuElm.classList.add('dropleft');
        menuOffsetLeft -= menuWidth;
      } else {
        menuElm.classList.remove('dropleft');
        menuElm.classList.add('dropright');
        if (isSubMenu) {
          menuOffsetLeft += parentElm.offsetWidth;
        }
      }
    } else {
      menuOffsetTop += 10;
      menuOffsetLeft = menuOffsetLeft - currentMenuWidth + 10;
    }

    menuElm.style.top = `${menuOffsetTop}px`;
    menuElm.style.left = `${menuOffsetLeft}px`;

    if (contentMinWidth > 0) {
      this._menuElm.style.minWidth = `${contentMinWidth}px`;
    }
  }

  protected updateColumnOrder() {
    // Because columns can be reordered, we have to update the `columns`
    // to reflect the new order, however we can't just take `grid.getColumns()`,
    // as it does not include columns currently hidden by the picker.
    // We create a new `columns` structure by leaving currently-hidden
    // columns in their original ordinal position and interleaving the results
    // of the current column sort.
    const current = this.grid.getColumns().slice(0);
    const ordered = new Array(this.columns.length);
    for (let i = 0; i < ordered.length; i++) {
      if (this.grid.getColumnIndex(this.columns[i].id) === undefined) {
        // If the column doesn't return a value from getColumnIndex,
        // it is hidden. Leave it in this position.
        ordered[i] = this.columns[i];
      } else {
        // Otherwise, grab the next visible column.
        ordered[i] = current.shift();
      }
    }
    this.columns = ordered;
  }

  protected updateColumn(e: DOMMouseOrTouchEvent<HTMLInputElement>) {
    if (e.target.dataset.option === 'autoresize') {
      // when calling setOptions, it will resize with ALL Columns (even the hidden ones)
      // we can avoid this problem by keeping a reference to the visibleColumns before setOptions and then setColumns after
      const previousVisibleColumns = this.getVisibleColumns();
      const isChecked = e.target.checked;
      this.grid.setOptions({ forceFitColumns: isChecked });
      this.grid.setColumns(previousVisibleColumns);
      return;
    }

    if (e.target.dataset.option === 'syncresize') {
      this.grid.setOptions({ syncColumnCellResize: !!(e.target.checked) });
      return;
    }

    if (e.target.type === 'checkbox') {
      const isChecked = e.target.checked;
      const columnId = e.target.dataset.columnid || '';
      const visibleColumns: Column[] = [];
      this._columnCheckboxes.forEach((columnCheckbox, idx) => {
        if (columnCheckbox.checked) {
          if (this.columns[idx].hidden) { this.columns[idx].hidden = false; }
          visibleColumns.push(this.columns[idx]);
        }
      });

      if (!visibleColumns.length) {
        e.target.checked = true;
        return;
      }

      const callbackArgs = {
        columnId,
        showing: isChecked,
        grid: this.grid,
        allColumns: this.columns,
        columns: visibleColumns,
        visibleColumns: this.getVisibleColumns()
      };
      this.grid.setColumns(visibleColumns);
      this.onColumnsChanged.notify(callbackArgs, e, this);
    }
  }

  getAllColumns() {
    return this.columns;
  }

  /** visible columns, we can simply get them directly from the grid */
  getVisibleColumns() {
    return this.grid.getColumns();
  }

  /**
   * Method that user can pass to override the default behavior.
   * In order word, user can choose or an item is (usable/visible/enable) by providing his own logic.
   * @param overrideFn: override function callback
   * @param args: multiple arguments provided to the override (cell, row, columnDef, dataContext, grid)
   */
  protected runOverrideFunctionWhenExists<T = any>(overrideFn: ((args: any) => boolean) | undefined, args: T): boolean {
    if (typeof overrideFn === 'function') {
      return overrideFn.call(this, args);
    }
    return true;
  }
}

// extend Slick namespace on window object when building as iife
if (IIFE_ONLY && window.Slick) {
  window.Slick.Controls = window.Slick.Controls || {};
  window.Slick.Controls.GridMenu = SlickGridMenu;
}
