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

/* eslint-disable @devtools/no-imperative-dom-api */

import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import {createIcon} from '../../ui/kit/kit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';

import * as ARIAUtils from './ARIAUtils.js';
import {AnchorBehavior, GlassPane, MarginBehavior, PointerEventsBehavior, SizeBehavior} from './GlassPane.js';
import {InspectorView} from './InspectorView.js';
import softContextMenuStyles from './softContextMenu.css.js';
import {Tooltip} from './Tooltip.js';
import {createTextChild, ElementFocusRestorer} from './UIUtils.js';

const UIStrings = {
  /**
   * @description Text exposed to screen readers on checked items.
   */
  checked: 'checked',
  /**
   * @description Accessible text exposed to screen readers when the screen reader encounters an unchecked checkbox.
   */
  unchecked: 'unchecked',
  /**
   * @description Accessibility label for checkable SoftContextMenuItems with shortcuts
   * @example {Open File} PH1
   * @example {Ctrl + P} PH2
   * @example {checked} PH3
   */
  sSS: '{PH1}, {PH2}, {PH3}',
  /**
   * @description Generic text with two placeholders separated by a comma
   * @example {1 613 680} PH1
   * @example {44 %} PH2
   */
  sS: '{PH1}, {PH2}',
  /**
   * @description Accessible text exposed to screen readers appended to menu items that have a new badge.
   */
  newFeature: 'This is a new feature',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/SoftContextMenu.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export class SoftContextMenu {
  private items: SoftContextMenuDescriptor[];
  private itemSelectedCallback: (arg0: number) => void;
  private parentMenu: SoftContextMenu|undefined;
  private highlightedMenuItemElement: HTMLElement|null;
  detailsForElementMap: WeakMap<HTMLElement, ElementMenuDetails>;
  private document?: Document;
  private glassPane?: GlassPane;
  private contextMenuElement?: HTMLElement;
  private focusRestorer?: ElementFocusRestorer;
  private hideOnUserMouseDownUnlessInMenu?: ((event: Event) => void);
  private activeSubMenuElement?: HTMLElement;
  private subMenu?: SoftContextMenu;
  private onMenuClosed?: () => void;
  private focusOnTheFirstItem = true;
  private keepOpen: boolean;
  private loggableParent: Element|null;

  constructor(
      items: SoftContextMenuDescriptor[], itemSelectedCallback: (arg0: number) => void, keepOpen: boolean,
      parentMenu?: SoftContextMenu, onMenuClosed?: () => void, loggableParent?: Element|null) {
    this.items = items;
    this.itemSelectedCallback = itemSelectedCallback;
    this.parentMenu = parentMenu;
    this.highlightedMenuItemElement = null;

    this.detailsForElementMap = new WeakMap();
    this.onMenuClosed = onMenuClosed;
    this.keepOpen = keepOpen;
    this.loggableParent = loggableParent || null;
  }

  getItems(): SoftContextMenuDescriptor[] {
    return this.items;
  }

  show(document: Document, anchorBox: AnchorBox): void {
    if (!this.items.length) {
      return;
    }

    this.document = document;

    this.glassPane = new GlassPane();
    this.glassPane.setPointerEventsBehavior(
        this.parentMenu ? PointerEventsBehavior.PIERCE_GLASS_PANE : PointerEventsBehavior.BLOCKED_BY_GLASS_PANE);
    this.glassPane.registerRequiredCSS(softContextMenuStyles);
    this.glassPane.setContentAnchorBox(anchorBox);
    this.glassPane.setSizeBehavior(SizeBehavior.MEASURE_CONTENT);
    this.glassPane.setMarginBehavior(MarginBehavior.NO_MARGIN);
    this.glassPane.setAnchorBehavior(this.parentMenu ? AnchorBehavior.PREFER_RIGHT : AnchorBehavior.PREFER_BOTTOM);

    this.contextMenuElement = this.glassPane.contentElement.createChild('div', 'soft-context-menu');
    this.contextMenuElement.setAttribute('jslog', `${VisualLogging.menu().track({resize: true}).parent('mapped').track({
                                           keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|Space|Escape',
                                         })}`);
    if (this.loggableParent) {
      VisualLogging.setMappedParent(this.contextMenuElement, this.loggableParent);
    }
    this.contextMenuElement.tabIndex = -1;
    ARIAUtils.markAsMenu(this.contextMenuElement);
    this.contextMenuElement.addEventListener('mouseup', e => e.consume(), false);
    this.contextMenuElement.addEventListener('keydown', this.menuKeyDown.bind(this), false);

    const menuContainsCheckbox = this.items.find(item => item.type === 'checkbox') ? true : false;
    for (let i = 0; i < this.items.length; ++i) {
      this.contextMenuElement.appendChild(this.createMenuItem(this.items[i], menuContainsCheckbox));
    }

    this.glassPane.show(document);
    this.focusRestorer = new ElementFocusRestorer(this.contextMenuElement);

    if (!this.parentMenu) {
      this.hideOnUserMouseDownUnlessInMenu = (event: Event) => {
        // If a user clicks on any submenu, prevent the menu system from closing.
        let subMenu: (SoftContextMenu|undefined) = this.subMenu;
        while (subMenu) {
          if (subMenu.contextMenuElement === event.composedPath()[0]) {
            return;
          }
          subMenu = subMenu.subMenu;
        }

        this.discard();
        event.consume(true);
      };

      this.document.body.addEventListener('mousedown', this.hideOnUserMouseDownUnlessInMenu, false);

      // To reliably get resize events when 1) the browser window is resized,
      // 2) DevTools is undocked and resized and 3) DevTools is docked &
      // resized, we have to use ResizeObserver.
      const devToolsElem = InspectorView.maybeGetInspectorViewInstance()?.element;
      if (devToolsElem) {
        // The resize-observer will fire immediately upon starting observation.
        // So we have to ignore that first fire, and then the moment we get a
        // second, we know that it's been resized so we can act accordingly.
        let firedOnce = false;
        const observer = new ResizeObserver(() => {
          if (firedOnce) {
            observer.disconnect();
            this.discard();
            return;
          }
          firedOnce = true;
        });
        observer.observe(devToolsElem);
      }

      // focus on the first menu item
      if (this.contextMenuElement.children && this.focusOnTheFirstItem) {
        const focusElement = this.contextMenuElement.children[0] as HTMLElement;
        this.highlightMenuItem(focusElement, /* scheduleSubMenu */ false);
      }
    }
  }

  setContextMenuElementLabel(label: string): void {
    if (this.contextMenuElement) {
      ARIAUtils.setLabel(this.contextMenuElement, label);
    }
  }

  discard(): void {
    if (this.subMenu) {
      this.subMenu.discard();
    }
    if (this.focusRestorer) {
      this.focusRestorer.restore();
    }
    if (this.glassPane) {
      this.glassPane.hide();
      delete this.glassPane;
      if (this.hideOnUserMouseDownUnlessInMenu) {
        if (this.document) {
          this.document.body.removeEventListener('mousedown', this.hideOnUserMouseDownUnlessInMenu, false);
        }
        delete this.hideOnUserMouseDownUnlessInMenu;
      }
    }
    if (this.parentMenu) {
      delete this.parentMenu.subMenu;
      if (this.parentMenu.activeSubMenuElement) {
        ARIAUtils.setExpanded(this.parentMenu.activeSubMenuElement, false);
        delete this.parentMenu.activeSubMenuElement;
      }
    }
    this.onMenuClosed?.();
  }

  private createMenuItem(item: SoftContextMenuDescriptor, menuContainsCheckbox: boolean): HTMLElement {
    if (item.type === 'separator') {
      return this.createSeparator();
    }

    if (item.type === 'subMenu') {
      return this.createSubMenu(item, menuContainsCheckbox);
    }

    const menuItemElement = document.createElement('div');
    menuItemElement.classList.add('soft-context-menu-item');
    menuItemElement.tabIndex = -1;
    ARIAUtils.markAsMenuItem(menuItemElement);
    if (item.checked) {
      menuItemElement.setAttribute('checked', '');
    }
    if (item.id !== undefined) {
      menuItemElement.setAttribute('data-action-id', item.id.toString());
    }

    // If the menu contains a checkbox, add checkbox space in front of the label to align the items
    if (menuContainsCheckbox) {
      const checkMarkElement = createIcon('checkmark', 'checkmark');
      menuItemElement.appendChild(checkMarkElement);
    }
    if (item.tooltip) {
      Tooltip.install(menuItemElement, item.tooltip);
    }
    const detailsForElement: ElementMenuDetails = {};

    // Only add a jslog context if the item has a label. Menu items without a
    // label are containers for custom elements, which are responsible for adding
    // their own `jslog` attributes.
    if (item.jslogContext && item.label) {
      if (item.type === 'checkbox') {
        menuItemElement.setAttribute(
            'jslog', `${VisualLogging.toggle().track({click: true}).context(item.jslogContext)}`);
      } else {
        menuItemElement.setAttribute(
            'jslog', `${VisualLogging.action().track({click: true}).context(item.jslogContext)}`);
      }
    }
    if (item.element && !item.label) {
      const wrapper = menuItemElement.createChild('div', 'soft-context-menu-custom-item');
      wrapper.appendChild(item.element);
      if (item.element?.classList.contains('location-menu')) {
        const label = item.element.ariaLabel || '';
        item.element.ariaLabel = '';
        ARIAUtils.setLabel(menuItemElement, label);
      }
      detailsForElement.customElement = (item.element as HTMLElement);
      this.detailsForElementMap.set(menuItemElement, detailsForElement);
      return menuItemElement;
    }

    if (!item.enabled) {
      menuItemElement.classList.add('soft-context-menu-disabled');
    }
    createTextChild(menuItemElement, item.label || '');
    if (item.element) {
      menuItemElement.appendChild(item.element);
    }
    menuItemElement.createChild('span', 'soft-context-menu-shortcut').textContent = item.shortcut || '';

    menuItemElement.addEventListener('mousedown', this.menuItemMouseDown.bind(this), false);
    menuItemElement.addEventListener('mouseup', this.menuItemMouseUp.bind(this), false);

    // Manually manage hover highlight since :hover does not work in case of click-and-hold menu invocation.
    menuItemElement.addEventListener('mouseover', this.menuItemMouseOver.bind(this), false);
    menuItemElement.addEventListener('mouseleave', (this.menuItemMouseLeave.bind(this) as EventListener), false);

    detailsForElement.actionId = item.id;

    let accessibleName: Platform.UIString.LocalizedString|string = item.label || '';

    if (item.type === 'checkbox') {
      const checkedState = item.checked ? i18nString(UIStrings.checked) : i18nString(UIStrings.unchecked);
      if (item.shortcut) {
        accessibleName = i18nString(UIStrings.sSS, {PH1: String(item.label), PH2: item.shortcut, PH3: checkedState});
      } else {
        accessibleName = i18nString(UIStrings.sS, {PH1: String(item.label), PH2: checkedState});
      }
    } else if (item.shortcut) {
      accessibleName = i18nString(UIStrings.sS, {PH1: String(item.label), PH2: item.shortcut});
    }
    if (item.element?.className === 'new-badge') {
      accessibleName = i18nString(UIStrings.sS, {PH1: String(item.label), PH2: i18nString(UIStrings.newFeature)});
    }
    ARIAUtils.setLabel(menuItemElement, accessibleName);

    if (item.isExperimentalFeature) {
      const experimentIcon = createIcon('experiment');
      menuItemElement.appendChild(experimentIcon);
    }

    this.detailsForElementMap.set(menuItemElement, detailsForElement);

    return menuItemElement;
  }

  private createSubMenu(item: SoftContextMenuDescriptor, menuContainsCheckbox: boolean): HTMLElement {
    const menuItemElement = document.createElement('div');
    menuItemElement.classList.add('soft-context-menu-item');
    menuItemElement.tabIndex = -1;
    ARIAUtils.markAsMenuItemSubMenu(menuItemElement);
    this.detailsForElementMap.set(menuItemElement, {
      subItems: item.subItems,
    });

    // If the menu contains a checkbox, add checkbox space in front of the label to align the items
    if (menuContainsCheckbox) {
      const checkMarkElement = createIcon('checkmark', 'checkmark soft-context-menu-item-checkmark');
      menuItemElement.appendChild(checkMarkElement);
    }

    createTextChild(menuItemElement, item.label || '');
    ARIAUtils.setExpanded(menuItemElement, false);

    const subMenuArrowElement = createIcon('keyboard-arrow-right', 'soft-context-menu-item-submenu-arrow');
    menuItemElement.appendChild(subMenuArrowElement);

    menuItemElement.addEventListener('mousedown', this.menuItemMouseDown.bind(this), false);
    menuItemElement.addEventListener('mouseup', this.menuItemMouseUp.bind(this), false);

    // Manually manage hover highlight since :hover does not work in case of click-and-hold menu invocation.
    menuItemElement.addEventListener('mouseover', this.menuItemMouseOver.bind(this), false);
    menuItemElement.addEventListener('mouseleave', (this.menuItemMouseLeave.bind(this) as EventListener), false);

    if (item.jslogContext) {
      menuItemElement.setAttribute(
          'jslog', `${VisualLogging.item(item.jslogContext).track({click: true, resize: true})}`);
    }
    return menuItemElement;
  }

  private createSeparator(): HTMLElement {
    const separatorElement = document.createElement('div');
    separatorElement.classList.add('soft-context-menu-separator');
    this.detailsForElementMap.set(separatorElement, {
      isSeparator: true,
    });
    separatorElement.createChild('div', 'separator-line');
    return separatorElement;
  }

  private menuItemMouseDown(event: Event): void {
    // Do not let separator's mouse down hit menu's handler - we need to receive mouse up!
    event.consume(true);
  }

  private menuItemMouseUp(event: Event): void {
    this.triggerAction((event.target as HTMLElement), event);
    void VisualLogging.logClick(event.target as HTMLElement, event);
    event.consume();
  }

  private root(): SoftContextMenu {
    let root: SoftContextMenu = this;
    while (root.parentMenu) {
      root = root.parentMenu;
    }
    return root;
  }

  setChecked(item: SoftContextMenuDescriptor, checked: boolean): void {
    item.checked = checked;
    const element = this.contextMenuElement?.querySelector(`[data-action-id="${item.id}"]`);
    if (!element) {
      return;
    }
    if (checked) {
      element.setAttribute('checked', '');
    } else {
      element.removeAttribute('checked');
    }

    const checkedState = item.checked ? i18nString(UIStrings.checked) : i18nString(UIStrings.unchecked);
    const accessibleName = item.shortcut ?
        i18nString(UIStrings.sSS, {PH1: String(item.label), PH2: item.shortcut, PH3: checkedState}) :
        i18nString(UIStrings.sS, {PH1: String(item.label), PH2: checkedState});
    ARIAUtils.setLabel(element, accessibleName);
  }

  private triggerAction(menuItemElement: HTMLElement, event: Event): void {
    const detailsForElement = this.detailsForElementMap.get(menuItemElement);
    if (!detailsForElement || detailsForElement.subItems) {
      this.showSubMenu(menuItemElement);
      event.consume();
      return;
    }
    if (this.keepOpen) {
      event.consume(true);
      const item = this.items.find(item => item.id === detailsForElement.actionId);
      if (item?.id !== undefined) {
        this.setChecked(item, !item.checked);
        this.itemSelectedCallback(item.id);
      }
      return;
    }
    this.root().discard();
    event.consume(true);
    if (typeof detailsForElement.actionId !== 'undefined') {
      this.itemSelectedCallback(detailsForElement.actionId);
      delete detailsForElement.actionId;
    }
    return;
  }

  private showSubMenu(menuItemElement: HTMLElement): void {
    const detailsForElement = this.detailsForElementMap.get(menuItemElement);
    if (!detailsForElement) {
      return;
    }
    if (detailsForElement.subMenuTimer) {
      window.clearTimeout(detailsForElement.subMenuTimer);
      delete detailsForElement.subMenuTimer;
    }
    if (this.subMenu || !this.document) {
      return;
    }

    this.activeSubMenuElement = menuItemElement;
    ARIAUtils.setExpanded(menuItemElement, true);
    if (!detailsForElement.subItems) {
      return;
    }
    this.subMenu = new SoftContextMenu(detailsForElement.subItems, this.itemSelectedCallback, false, this);
    const anchorBox = menuItemElement.boxInWindow();
    // Adjust for padding.
    anchorBox.y -= 9;
    anchorBox.x += 3;
    anchorBox.width -= 6;
    anchorBox.height += 18;
    this.subMenu.show(this.document, anchorBox);
  }

  private menuItemMouseOver(event: Event): void {
    this.highlightMenuItem((event.target as HTMLElement), true);
  }

  private menuItemMouseLeave(event: MouseEvent): void {
    if (!this.subMenu || !event.relatedTarget) {
      this.highlightMenuItem(null, true);
      return;
    }

    const relatedTarget = event.relatedTarget;
    if (relatedTarget === this.contextMenuElement) {
      this.highlightMenuItem(null, true);
    }
  }

  private highlightMenuItem(menuItemElement: HTMLElement|null, scheduleSubMenu: boolean): void {
    if (this.highlightedMenuItemElement === menuItemElement) {
      return;
    }
    if (this.subMenu) {
      this.subMenu.discard();
    }
    if (this.highlightedMenuItemElement) {
      const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
      this.highlightedMenuItemElement.classList.remove('force-white-icons');
      this.highlightedMenuItemElement.classList.remove('soft-context-menu-item-mouse-over');
      if (detailsForElement?.subItems && detailsForElement.subMenuTimer) {
        window.clearTimeout(detailsForElement.subMenuTimer);
        delete detailsForElement.subMenuTimer;
      }
    }

    this.highlightedMenuItemElement = menuItemElement;
    if (this.highlightedMenuItemElement) {
      this.highlightedMenuItemElement.classList.add('force-white-icons');
      this.highlightedMenuItemElement.classList.add('soft-context-menu-item-mouse-over');
      const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
      if (detailsForElement?.customElement && !detailsForElement.customElement.classList.contains('location-menu')) {
        detailsForElement.customElement.focus();
      } else {
        this.highlightedMenuItemElement.focus();
      }
      if (scheduleSubMenu && detailsForElement?.subItems && !detailsForElement.subMenuTimer) {
        detailsForElement.subMenuTimer =
            window.setTimeout(this.showSubMenu.bind(this, this.highlightedMenuItemElement), 150);
      }
    }

    if (this.contextMenuElement) {
      ARIAUtils.setActiveDescendant(this.contextMenuElement, menuItemElement);
    }
  }

  private highlightPrevious(): void {
    let menuItemElement: (ChildNode|null) = this.highlightedMenuItemElement ?
        this.highlightedMenuItemElement.previousSibling :
        this.contextMenuElement ? this.contextMenuElement.lastChild :
                                  null;
    let menuItemDetails: (ElementMenuDetails|undefined) =
        menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
    while (menuItemElement && menuItemDetails &&
           (menuItemDetails.isSeparator ||
            (menuItemElement as HTMLElement).classList.contains('soft-context-menu-disabled'))) {
      menuItemElement = menuItemElement.previousSibling;
      menuItemDetails = menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
    }
    if (menuItemElement) {
      this.highlightMenuItem((menuItemElement as HTMLElement), false);
    }
  }

  private highlightNext(): void {
    let menuItemElement: (ChildNode|null) = this.highlightedMenuItemElement ?
        this.highlightedMenuItemElement.nextSibling :
        this.contextMenuElement ? this.contextMenuElement.firstChild :
                                  null;
    let menuItemDetails: (ElementMenuDetails|undefined) =
        menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
    while (menuItemElement &&
           (menuItemDetails?.isSeparator ||
            (menuItemElement as HTMLElement).classList.contains('soft-context-menu-disabled'))) {
      menuItemElement = menuItemElement.nextSibling;
      menuItemDetails = menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
    }
    if (menuItemElement) {
      this.highlightMenuItem((menuItemElement as HTMLElement), false);
    }
  }

  private menuKeyDown(keyboardEvent: KeyboardEvent): void {
    function onEnterOrSpace(this: SoftContextMenu): void {
      if (!this.highlightedMenuItemElement) {
        return;
      }
      const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
      if (!detailsForElement || detailsForElement.customElement) {
        // The custom element will handle the event, so return early and do not consume it.
        return;
      }
      VisualLogging.logClick(this.highlightedMenuItemElement, keyboardEvent);
      this.triggerAction(this.highlightedMenuItemElement, keyboardEvent);
      if (detailsForElement.subItems && this.subMenu) {
        this.subMenu.highlightNext();
      }
      keyboardEvent.consume(true);
    }

    switch (keyboardEvent.key) {
      case 'ArrowUp':
        this.highlightPrevious();
        keyboardEvent.consume(true);
        break;
      case 'ArrowDown':
        this.highlightNext();
        keyboardEvent.consume(true);
        break;
      case 'ArrowLeft':
        if (this.parentMenu) {
          this.highlightMenuItem(null, false);
          this.discard();
        }
        keyboardEvent.consume(true);
        break;
      case 'ArrowRight': {
        if (!this.highlightedMenuItemElement) {
          break;
        }
        const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
        if (detailsForElement?.subItems) {
          this.showSubMenu(this.highlightedMenuItemElement);
          if (this.subMenu) {
            this.subMenu.highlightNext();
          }
        }
        if (detailsForElement?.customElement?.classList.contains('location-menu')) {
          detailsForElement.customElement.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight'}));
          this.highlightMenuItem(null, true);
        }
        keyboardEvent.consume(true);
        break;
      }
      case 'Escape':
        this.discard();
        keyboardEvent.consume(true);
        break;
      /**
       * Important: we don't consume the event by default for `Enter` or `Space`
       * key events, as if there's a custom sub menu we pass the event onto
       * that.
       */
      case 'Enter':
        if (!(keyboardEvent.key === 'Enter')) {
          return;
        }
        onEnterOrSpace.call(this);
        break;
      case ' ':
        onEnterOrSpace.call(this);
        break;
      default:
        keyboardEvent.consume(true);
    }
  }

  markAsMenuItemCheckBox(): void {
    if (!this.contextMenuElement) {
      return;
    }
    for (const child of this.contextMenuElement.children) {
      if (child.className !== 'soft-context-menu-separator') {
        ARIAUtils.markAsMenuItemCheckBox(child);
      }
    }
  }

  setFocusOnTheFirstItem(focusOnTheFirstItem: boolean): void {
    this.focusOnTheFirstItem = focusOnTheFirstItem;
  }
}
export interface SoftContextMenuDescriptor {
  type: 'checkbox'|'item'|'separator'|'subMenu';
  id?: number;
  label?: string;
  accelerator?: {keyCode: number, modifiers: number};
  isExperimentalFeature?: boolean;
  enabled?: boolean;
  checked?: boolean;
  isDevToolsPerformanceMenuItem?: boolean;
  subItems?: SoftContextMenuDescriptor[];
  element?: Element;
  shortcut?: string;
  tooltip?: Platform.UIString.LocalizedString;
  jslogContext?: string;
  /** A no-op. For native context menus, feature name will request showing a new badge. */
  featureName?: string;
}
interface ElementMenuDetails {
  customElement?: HTMLElement;
  isSeparator?: boolean;
  subMenuTimer?: number;
  subItems?: SoftContextMenuDescriptor[];
  actionId?: number;
}
