// Copyright 2009 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 Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {createIcon} from '../kit/kit.js';

import {type Action, Events as ActionEvents} from './ActionRegistration.js';
import {ActionRegistry} from './ActionRegistry.js';
import * as ARIAUtils from './ARIAUtils.js';
import {ContextMenu} from './ContextMenu.js';
import {GlassPane, PointerEventsBehavior} from './GlassPane.js';
import type {Suggestion} from './SuggestBox.js';
import {Events as TextPromptEvents, TextPrompt} from './TextPrompt.js';
import toolbarStyles from './toolbar.css.js';
import {Tooltip} from './Tooltip.js';
import {bindCheckbox, CheckboxLabel, LongClickController} from './UIUtils.js';
import {Widget} from './Widget.js';

const UIStrings = {
  /**
   * @description Announced screen reader message for ToolbarSettingToggle when the setting is toggled on.
   */
  pressed: 'pressed',
  /**
   * @description Announced screen reader message for ToolbarSettingToggle when the setting is toggled off.
   */
  notPressed: 'not pressed',
  /**
   * @description Tooltip shown when the user hovers over the clear icon to empty the text input.
   */
  clearInput: 'Clear',
  /**
   * @description Placeholder for filter bars that shows before the user types in a filter keyword.
   */
  filter: 'Filter',
  /**
   * @description Tooltip shown when the user hovers over the regex icon to toggle regular-expression filtering.
   */
  useRegularExpression: 'Use regular expression',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/Toolbar.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

/**
 * Custom element for toolbars.
 *
 * @property floating - The `"floating"` attribute is reflected as property.
 * @property wrappable - The `"wrappable"` attribute is reflected as property.
 * @attribute floating - If present the toolbar is rendered in columns, with a border
 *                  around it, and a non-transparent background. This is used to
 *                  build vertical toolbars that open with long-click. Defaults
 *                  to `false`.
 * @attribute wrappable - If present the toolbar items will wrap to a new row and the
 *                   toolbar height increases.
 */
export class Toolbar extends HTMLElement {
  #shadowRoot = this.attachShadow({mode: 'open'});
  private items: ToolbarItem[] = [];
  enabled = true;
  private compactLayout = false;

  constructor() {
    super();
    this.#shadowRoot.createChild('style').textContent = toolbarStyles;
    this.#shadowRoot.createChild('slot');
  }

  onItemsChange(mutationList: MutationRecord[]): void {
    for (const mutation of mutationList) {
      for (const element of mutation.removedNodes) {
        if (!(element instanceof HTMLElement)) {
          continue;
        }
        for (const item of this.items) {
          if (item.element === element) {
            this.items.splice(this.items.indexOf(item), 1);
            break;
          }
        }
      }
      for (const element of mutation.addedNodes) {
        if (!(element instanceof HTMLElement)) {
          continue;
        }
        if (this.items.some(item => item.element === element)) {
          continue;
        }
        let item: ToolbarItem;
        if (element instanceof Buttons.Button.Button) {
          item = new ToolbarButton('', undefined, undefined, undefined, element);
        } else if (element instanceof ToolbarInputElement) {
          item = element.item as ToolbarItem;
        } else if (element instanceof HTMLSelectElement) {
          item = new ToolbarComboBox(null, element.title, undefined, undefined, element);
        } else {
          item = new ToolbarItem(element);
        }
        if (item) {
          this.appendToolbarItem(item);
        }
      }
    }
  }

  connectedCallback(): void {
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'toolbar');
    }
  }

  /**
   * Returns whether this toolbar is floating.
   *
   * @returns `true` if the `"floating"` attribute is present on this toolbar,
   *         otherwise `false`.
   */
  get floating(): boolean {
    return this.hasAttribute('floating');
  }

  /**
   * Changes the value of the `"floating"` attribute on this toolbar.
   *
   * @param floating `true` to make the toolbar floating.
   */
  set floating(floating: boolean) {
    this.toggleAttribute('floating', floating);
  }

  /**
   * Returns whether this toolbar is wrappable.
   *
   * @returns `true` if the `"wrappable"` attribute is present on this toolbar,
   *         otherwise `false`.
   */
  get wrappable(): boolean {
    return this.hasAttribute('wrappable');
  }

  /**
   * Changes the value of the `"wrappable"` attribute on this toolbar.
   *
   * @param wrappable `true` to make the toolbar items wrap to a new row and
   *                  have the toolbar height adjust.
   */
  set wrappable(wrappable: boolean) {
    this.toggleAttribute('wrappable', wrappable);
  }

  hasCompactLayout(): boolean {
    return this.compactLayout;
  }

  setCompactLayout(enable: boolean): void {
    if (this.compactLayout === enable) {
      return;
    }
    this.compactLayout = enable;
    for (const item of this.items) {
      item.setCompactLayout(enable);
    }
  }

  static createLongPressActionButton(
      action: Action, toggledOptions: ToolbarButton[], untoggledOptions: ToolbarButton[]): ToolbarButton {
    const button = Toolbar.createActionButton(action);
    const mainButtonClone = Toolbar.createActionButton(action);

    let longClickController: LongClickController|null = null;
    let longClickButtons: ToolbarButton[]|null = null;

    action.addEventListener(ActionEvents.TOGGLED, updateOptions);
    updateOptions();
    return button;

    function updateOptions(): void {
      const buttons = action.toggled() ? (toggledOptions || null) : (untoggledOptions || null);

      if (buttons?.length) {
        if (!longClickController) {
          longClickController = new LongClickController(button.element, showOptions);
          button.setLongClickable(true);
          longClickButtons = buttons;
        }
      } else if (longClickController) {
        longClickController.dispose();
        longClickController = null;
        button.setLongClickable(false);
        longClickButtons = null;
      }
    }

    function showOptions(): void {
      let buttons: ToolbarButton[] = longClickButtons ? longClickButtons.slice() : [];
      buttons.push(mainButtonClone);

      const document = button.element.ownerDocument;
      document.documentElement.addEventListener('mouseup', mouseUp, false);

      const optionsGlassPane = new GlassPane();
      optionsGlassPane.setPointerEventsBehavior(PointerEventsBehavior.BLOCKED_BY_GLASS_PANE);
      optionsGlassPane.show(document);
      const optionsBar = optionsGlassPane.contentElement.createChild('devtools-toolbar');
      optionsBar.floating = true;
      const buttonHeight = 26;

      const hostButtonPosition = button.element.boxInWindow().relativeToElement(GlassPane.container(document));

      const topNotBottom = hostButtonPosition.y + buttonHeight * buttons.length < document.documentElement.offsetHeight;

      if (topNotBottom) {
        buttons = buttons.reverse();
      }

      optionsBar.style.height = (buttonHeight * buttons.length) + 'px';
      if (topNotBottom) {
        optionsBar.style.top = (hostButtonPosition.y - 5) + 'px';
      } else {
        optionsBar.style.top = (hostButtonPosition.y - (buttonHeight * (buttons.length - 1)) - 6) + 'px';
      }
      optionsBar.style.left = (hostButtonPosition.x - 5) + 'px';

      for (let i = 0; i < buttons.length; ++i) {
        buttons[i].element.addEventListener('mousemove', mouseOver, false);
        buttons[i].element.addEventListener('mouseout', mouseOut, false);
        optionsBar.appendToolbarItem(buttons[i]);
      }
      const hostButtonIndex = topNotBottom ? 0 : buttons.length - 1;
      buttons[hostButtonIndex].element.classList.add('emulate-active');

      function mouseOver(e: Event): void {
        if ((e as MouseEvent).which !== 1) {
          return;
        }
        if (e.target instanceof HTMLElement) {
          const buttonElement = e.target.enclosingNodeOrSelfWithClass('toolbar-button');
          buttonElement.classList.add('emulate-active');
        }
      }

      function mouseOut(e: Event): void {
        if ((e as MouseEvent).which !== 1) {
          return;
        }
        if (e.target instanceof HTMLElement) {
          const buttonElement = e.target.enclosingNodeOrSelfWithClass('toolbar-button');
          buttonElement.classList.remove('emulate-active');
        }
      }

      function mouseUp(e: Event): void {
        if ((e as MouseEvent).which !== 1) {
          return;
        }
        optionsGlassPane.hide();
        document.documentElement.removeEventListener('mouseup', mouseUp, false);

        for (let i = 0; i < buttons.length; ++i) {
          if (buttons[i].element.classList.contains('emulate-active')) {
            buttons[i].element.classList.remove('emulate-active');
            buttons[i].clicked(e);
            break;
          }
        }
      }
    }
  }

  static createActionButton(action: Action, options?: ToolbarButtonOptions): ToolbarButton;
  static createActionButton(actionId: string, options?: ToolbarButtonOptions): ToolbarButton;
  static createActionButton(actionOrActionId: Action|string, options: ToolbarButtonOptions = {}): ToolbarButton {
    const action =
        typeof actionOrActionId === 'string' ? ActionRegistry.instance().getAction(actionOrActionId) : actionOrActionId;

    const button = action.toggleable() ? makeToggle() : makeButton();

    if (options.label) {
      button.setText(options.label() || action.title());
    }

    const handler = (): void => {
      void action.execute();
    };
    button.addEventListener(ToolbarButton.Events.CLICK, handler, action);
    action.addEventListener(ActionEvents.ENABLED, enabledChanged);
    button.setEnabled(action.enabled());
    return button;

    function makeButton(): ToolbarButton {
      const button = new ToolbarButton(action.title(), action.icon(), undefined, action.id());
      if (action.title()) {
        Tooltip.installWithActionBinding(button.element, action.title(), action.id());
      }
      return button;
    }

    function makeToggle(): ToolbarToggle {
      const toggleButton = new ToolbarToggle(action.title(), action.icon(), action.toggledIcon(), action.id());
      if (action.toggleWithRedColor()) {
        toggleButton.enableToggleWithRedColor();
      }
      action.addEventListener(ActionEvents.TOGGLED, toggled);
      toggled();
      return toggleButton;

      function toggled(): void {
        toggleButton.setToggled(action.toggled());
        if (action.title()) {
          toggleButton.setTitle(action.title());
          Tooltip.installWithActionBinding(toggleButton.element, action.title(), action.id());
        }
      }
    }

    function enabledChanged(event: Common.EventTarget.EventTargetEvent<boolean>): void {
      button.setEnabled(event.data);
    }
  }

  empty(): boolean {
    return !this.items.length;
  }

  setEnabled(enabled: boolean): void {
    this.enabled = enabled;
    for (const item of this.items) {
      item.applyEnabledState(this.enabled && item.enabled);
    }
  }

  appendToolbarItem(item: ToolbarItem): void {
    this.items.push(item);
    item.toolbar = this;
    item.setCompactLayout(this.hasCompactLayout());
    if (!this.enabled) {
      item.applyEnabledState(false);
    }
    if (item.element.parentElement !== this) {
      const widget = Widget.get(item.element);
      if (widget) {
        widget.show(this);
      } else {
        this.appendChild(item.element);
      }
    }
    this.hideSeparatorDupes();
  }

  hasItem(item: ToolbarItem): boolean {
    return this.items.includes(item);
  }

  prependToolbarItem(item: ToolbarItem): void {
    this.items.unshift(item);
    item.toolbar = this;
    item.setCompactLayout(this.hasCompactLayout());
    if (!this.enabled) {
      item.applyEnabledState(false);
    }
    if (item.element.parentElement !== this) {
      const widget = Widget.get(item.element);
      if (widget) {
        widget.show(this, this.firstChild);
      } else {
        this.prepend(item.element);
      }
    }
    this.hideSeparatorDupes();
  }

  appendSeparator(): void {
    this.appendToolbarItem(new ToolbarSeparator());
  }

  appendSpacer(): void {
    this.appendToolbarItem(new ToolbarSeparator(true));
  }

  appendText(text: string): void {
    this.appendToolbarItem(new ToolbarText(text));
  }

  removeToolbarItem(itemToRemove: ToolbarItem): void {
    const updatedItems = [];
    for (const item of this.items) {
      if (item === itemToRemove) {
        const widget = Widget.get(item.element);
        if (widget) {
          widget.detach();
        } else {
          item.element.remove();
        }
      } else {
        updatedItems.push(item);
      }
    }
    this.items = updatedItems;
  }

  removeToolbarItems(): void {
    for (const item of this.items) {
      item.toolbar = null;
      const widget = Widget.get(item.element);
      if (widget) {
        widget.detach();
      }
    }
    this.items = [];
    this.removeChildren();
  }

  hideSeparatorDupes(): void {
    if (!this.items.length) {
      return;
    }
    // Don't hide first and last separators if they were added explicitly.
    let previousIsSeparator = false;
    let lastSeparator;
    let nonSeparatorVisible = false;
    for (let i = 0; i < this.items.length; ++i) {
      if (this.items[i] instanceof ToolbarSeparator) {
        this.items[i].setVisible(!previousIsSeparator);
        previousIsSeparator = true;
        lastSeparator = this.items[i];
        continue;
      }
      if (this.items[i].visible()) {
        previousIsSeparator = false;
        lastSeparator = null;
        nonSeparatorVisible = true;
      }
    }
    if (lastSeparator && lastSeparator !== this.items[this.items.length - 1]) {
      lastSeparator.setVisible(false);
    }

    this.classList.toggle(
        'hidden',
        lastSeparator !== null && lastSeparator !== undefined && lastSeparator.visible() && !nonSeparatorVisible);
  }

  async appendItemsAtLocation(location: string): Promise<void> {
    const extensions: ToolbarItemRegistration[] = getRegisteredToolbarItems();

    extensions.sort((extension1, extension2) => {
      const order1 = extension1.order || 0;
      const order2 = extension2.order || 0;
      return order1 - order2;
    });

    const filtered = extensions.filter(e => e.location === location);
    const items = await Promise.all(filtered.map(extension => {
      const {separator, actionId, label, loadItem} = extension;
      if (separator) {
        return new ToolbarSeparator();
      }
      if (actionId) {
        return Toolbar.createActionButton(actionId, {label});
      }
      // TODO(crbug.com/1134103) constratint the case checked with this if using TS type definitions once UI is TS-authored.
      if (!loadItem) {
        throw new Error('Could not load a toolbar item registration with no loadItem function');
      }
      return loadItem().then(p => (p).item());
    }));

    for (const item of items) {
      if (item) {
        this.appendToolbarItem(item);
      }
    }
  }
}

customElements.define('devtools-toolbar', Toolbar);

export interface ToolbarButtonOptions {
  label?: () => Platform.UIString.LocalizedString;
}

// We need any here because Common.ObjectWrapper.ObjectWrapper is invariant in T.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ToolbarItem<T = any, E extends HTMLElement = HTMLElement> extends Common.ObjectWrapper.ObjectWrapper<T> {
  element: E;
  #visible: boolean;
  enabled: boolean;
  toolbar: Toolbar|null;
  protected title?: string;

  constructor(element: E) {
    super();
    this.element = element;
    this.#visible = true;
    this.enabled = true;

    /**
     * Set by the parent toolbar during appending.
     */
    this.toolbar = null;
  }

  setTitle(title: string, actionId: string|undefined = undefined): void {
    if (this.title === title) {
      return;
    }
    this.title = title;
    ARIAUtils.setLabel(this.element, title);
    if (actionId === undefined) {
      Tooltip.install(this.element, title);
    } else {
      Tooltip.installWithActionBinding(this.element, title, actionId);
    }
  }

  setEnabled(value: boolean): void {
    if (this.enabled === value) {
      return;
    }
    this.enabled = value;
    this.applyEnabledState(this.enabled && (!this.toolbar || this.toolbar.enabled));
  }

  applyEnabledState(enabled: boolean): void {
    // @ts-expect-error: Ignoring in favor of an `instanceof` check for all the different
    //             kind of HTMLElement classes that have a disabled attribute.
    this.element.disabled = !enabled;
  }

  visible(): boolean {
    return this.#visible;
  }

  setVisible(x: boolean): void {
    if (this.#visible === x) {
      return;
    }
    this.element.classList.toggle('hidden', !x);
    this.#visible = x;
    if (this.toolbar && !(this instanceof ToolbarSeparator)) {
      this.toolbar.hideSeparatorDupes();
    }
  }

  setCompactLayout(_enable: boolean): void {
  }

  setMaxWidth(width: number): void {
    this.element.style.maxWidth = width + 'px';
  }

  setMinWidth(width: number): void {
    this.element.style.minWidth = width + 'px';
  }
}

export const enum ToolbarItemWithCompactLayoutEvents {
  COMPACT_LAYOUT_UPDATED = 'CompactLayoutUpdated',
}

interface ToolbarItemWithCompactLayoutEventTypes {
  [ToolbarItemWithCompactLayoutEvents.COMPACT_LAYOUT_UPDATED]: boolean;
}

export class ToolbarItemWithCompactLayout extends ToolbarItem<ToolbarItemWithCompactLayoutEventTypes> {
  override setCompactLayout(enable: boolean): void {
    this.dispatchEventToListeners(ToolbarItemWithCompactLayoutEvents.COMPACT_LAYOUT_UPDATED, enable);
  }
}

export class ToolbarText extends ToolbarItem<void, HTMLElement> {
  constructor(text = '') {
    const element = document.createElement('div');
    element.classList.add('toolbar-text');
    super(element);
    this.setText(text);
  }

  text(): string {
    return this.element.textContent;
  }

  setText(text: string): void {
    this.element.textContent = text;
  }
}

export class ToolbarButton extends ToolbarItem<ToolbarButton.EventTypes, Buttons.Button.Button> {
  private button: Buttons.Button.Button;
  private text?: string;
  private adorner?: HTMLElement;

  constructor(title: string, glyph?: string, text?: string, jslogContext?: string, button?: Buttons.Button.Button) {
    if (!button) {
      button = new Buttons.Button.Button();
      if (glyph && !text) {
        button.data = {variant: Buttons.Button.Variant.ICON, iconName: glyph};
      } else {
        button.variant = Buttons.Button.Variant.TEXT;
        button.reducedFocusRing = true;
        if (glyph) {
          button.iconName = glyph;
        }
      }
    }
    super(button);
    this.button = button;
    button.classList.add('toolbar-button');
    this.element.addEventListener('click', this.clicked.bind(this), false);
    button.textContent = text || '';
    this.setTitle(title);
    if (jslogContext) {
      button.jslogContext = jslogContext;
    }
  }

  focus(): void {
    this.element.focus();
  }

  checked(checked: boolean): void {
    this.button.checked = checked;
  }

  toggleOnClick(toggleOnClick: boolean): void {
    this.button.toggleOnClick = toggleOnClick;
  }

  isToggled(): boolean {
    return this.button.toggled;
  }

  toggled(toggled: boolean): void {
    this.button.toggled = toggled;
  }

  setToggleType(type: Buttons.Button.ToggleType): void {
    this.button.toggleType = type;
  }

  setLongClickable(longClickable: boolean): void {
    this.button.longClickable = longClickable;
  }

  setSize(size: Buttons.Button.Size): void {
    this.button.size = size;
  }

  setReducedFocusRing(): void {
    this.button.reducedFocusRing = true;
  }

  setText(text: string): void {
    if (this.text === text) {
      return;
    }
    this.button.textContent = text;
    this.button.variant = Buttons.Button.Variant.TEXT;
    this.button.reducedFocusRing = true;
    this.text = text;
  }

  setAdorner(adorner: HTMLElement): void {
    if (this.adorner) {
      this.adorner.replaceWith(adorner);
    } else {
      this.element.prepend(adorner);
    }
    this.adorner = adorner;
  }

  setGlyph(iconName: string): void {
    this.button.iconName = iconName;
  }

  setToggledIcon(toggledIconName: string): void {
    this.button.variant = Buttons.Button.Variant.ICON_TOGGLE;
    this.button.toggledIconName = toggledIconName;
  }

  setBackgroundImage(iconURL: string): void {
    this.element.style.backgroundImage = 'url(' + iconURL + ')';
  }

  setSecondary(): void {
    this.element.classList.add('toolbar-button-secondary');
  }

  setDarkText(): void {
    this.element.classList.add('dark-text');
  }

  clicked(event: Event): void {
    if (!this.enabled) {
      return;
    }
    this.dispatchEventToListeners(ToolbarButton.Events.CLICK, event);
    event.consume();
  }
}

export namespace ToolbarButton {
  export const enum Events {
    CLICK = 'Click',
  }

  export interface EventTypes {
    [Events.CLICK]: Event;
  }
}

export class ToolbarInput extends ToolbarItem<ToolbarInput.EventTypes> {
  private prompt: TextPrompt;
  private readonly proxyElement: Element;
  constructor(
      placeholder: string, accessiblePlaceholder?: string, growFactor?: number, shrinkFactor?: number, tooltip?: string,
      completions?: ((arg0: string, arg1: string, arg2?: boolean|undefined) => Promise<Suggestion[]>),
      dynamicCompletions?: boolean, jslogContext?: string, element?: HTMLElement) {
    if (!element) {
      element = document.createElement('div');
    }
    element.classList.add('toolbar-input');
    super(element);

    const internalPromptElement = this.element.createChild('div', 'toolbar-input-prompt');
    ARIAUtils.setLabel(internalPromptElement, accessiblePlaceholder || placeholder);
    internalPromptElement.addEventListener('focus', () => this.element.classList.add('focused'));
    internalPromptElement.addEventListener('blur', () => this.element.classList.remove('focused'));

    this.prompt = new TextPrompt();
    this.prompt.jslogContext = jslogContext;
    this.proxyElement = this.prompt.attach(internalPromptElement);
    this.proxyElement.classList.add('toolbar-prompt-proxy');
    this.proxyElement.addEventListener('keydown', (event: Event) => this.onKeydownCallback(event as KeyboardEvent));
    this.prompt.initialize(
        completions || (() => Promise.resolve([])),
        ' ',
        dynamicCompletions,
    );
    if (tooltip) {
      this.prompt.setTitle(tooltip);
    }
    this.prompt.setPlaceholder(placeholder, accessiblePlaceholder);
    this.prompt.addEventListener(TextPromptEvents.TEXT_CHANGED, this.onChangeCallback.bind(this));

    if (growFactor) {
      this.element.style.flexGrow = String(growFactor);
    }
    if (shrinkFactor) {
      this.element.style.flexShrink = String(shrinkFactor);
    }

    const clearButtonText = i18nString(UIStrings.clearInput);
    const clearButton = new Buttons.Button.Button();
    clearButton.data = {
      variant: Buttons.Button.Variant.ICON,
      iconName: 'cross-circle-filled',
      size: Buttons.Button.Size.SMALL,
      title: clearButtonText,
    };
    clearButton.className = 'toolbar-input-clear-button';
    clearButton.setAttribute('jslog', `${VisualLogging.action('clear').track({click: true}).parent('mapped')}`);
    VisualLogging.setMappedParent(clearButton, internalPromptElement);
    clearButton.variant = Buttons.Button.Variant.ICON;
    clearButton.size = Buttons.Button.Size.SMALL;
    clearButton.iconName = 'cross-circle-filled';
    clearButton.title = clearButtonText;
    clearButton.ariaLabel = clearButtonText;
    clearButton.tabIndex = -1;

    clearButton.addEventListener('click', () => {
      this.setValue('', true);
      this.prompt.focus();
    });

    this.element.appendChild(clearButton);
    this.updateEmptyStyles();
  }

  protected insertTrailingElement(element: Element): void {
    this.element.appendChild(element);
  }

  override applyEnabledState(enabled: boolean): void {
    if (enabled) {
      this.element.classList.remove('disabled');
    } else {
      this.element.classList.add('disabled');
    }

    this.prompt.setEnabled(enabled);
  }

  setValue(value: string, notify?: boolean): void {
    this.prompt.setText(value);
    if (notify) {
      this.onChangeCallback();
    }
    this.updateEmptyStyles();
  }

  value(): string {
    return this.prompt.textWithCurrentSuggestion();
  }

  valueWithoutSuggestion(): string {
    return this.prompt.text();
  }

  clearAutocomplete(): void {
    this.prompt.clearAutocomplete();
  }

  focus(): void {
    this.prompt.focus();
  }

  private onKeydownCallback(event: KeyboardEvent): void {
    if (event.key === 'Enter' && this.prompt.text()) {
      this.dispatchEventToListeners(ToolbarInput.Event.ENTER_PRESSED, this.prompt.text());
    }
    if (!Platform.KeyboardUtilities.isEscKey(event) || !this.prompt.text()) {
      return;
    }
    this.setValue('', true);
    event.consume(true);
  }

  private onChangeCallback(): void {
    this.updateEmptyStyles();
    this.dispatchEventToListeners(ToolbarInput.Event.TEXT_CHANGED, this.prompt.text());
  }

  private updateEmptyStyles(): void {
    this.element.classList.toggle('toolbar-input-empty', !this.prompt.text());
  }
}

export class ToolbarFilter extends ToolbarInput {
  constructor(
      filterBy?: Common.UIString.LocalizedString, growFactor?: number, shrinkFactor?: number, tooltip?: string,
      completions?: ((arg0: string, arg1: string, arg2?: boolean|undefined) => Promise<Suggestion[]>),
      dynamicCompletions?: boolean, jslogContext?: string, element?: HTMLElement, showRegexToggle?: boolean,
      onRegexToggle?: () => void) {
    const filterPlaceholder = filterBy ? filterBy : i18nString(UIStrings.filter);
    super(
        filterPlaceholder, filterPlaceholder, growFactor, shrinkFactor, tooltip, completions, dynamicCompletions,
        jslogContext || 'filter', element);

    const filterIcon = createIcon('filter');
    this.element.prepend(filterIcon);
    this.element.classList.add('toolbar-filter');

    if (showRegexToggle) {
      const regexIconName = 'regular-expression';
      const regexButton = new Buttons.Button.Button();
      regexButton.data = {
        variant: Buttons.Button.Variant.ICON_TOGGLE,
        size: Buttons.Button.Size.SMALL,
        iconName: regexIconName,
        toggledIconName: regexIconName,
        toggleType: Buttons.Button.ToggleType.PRIMARY,
        toggled: false,
        title: i18nString(UIStrings.useRegularExpression),
        jslogContext: regexIconName,
      };
      ARIAUtils.setLabel(regexButton, i18nString(UIStrings.useRegularExpression));
      regexButton.addEventListener('click', () => {
        onRegexToggle?.();
      });
      this.insertTrailingElement(regexButton);
    }
  }
}

export class ToolbarInputElement extends HTMLElement {
  static observedAttributes = ['value', 'disabled', 'regex'];

  item?: ToolbarInput;
  datalist: HTMLDataListElement|null = null;
  #value: string|undefined = undefined;
  #disabled = false;
  connectedCallback(): void {
    if (this.item) {
      return;
    }
    const list = this.getAttribute('list');
    if (list) {
      this.datalist = (this.getRootNode() as ShadowRoot | Document).querySelector(`datalist[id="${list}"]`);
    }
    const placeholder = this.getAttribute('placeholder') || '';
    const accessiblePlaceholder = this.getAttribute('aria-placeholder') ?? undefined;
    const tooltip = this.getAttribute('title') ?? undefined;
    const jslogContext = this.id ?? undefined;
    const isFilter = this.getAttribute('type') === 'filter';
    if (isFilter) {
      this.item = new ToolbarFilter(
          placeholder as Platform.UIString.LocalizedString, /* growFactor=*/ undefined,
          /* shrinkFactor=*/ undefined, tooltip, this.datalist ? this.#onAutocomplete.bind(this) : undefined,
          /* dynamicCompletions=*/ undefined, jslogContext || 'filter', this, this.hasAttribute('regex'),
          this.#onRegexToggle.bind(this));
    } else {
      this.item = new ToolbarInput(
          placeholder, accessiblePlaceholder, /* growFactor=*/ undefined,
          /* shrinkFactor=*/ undefined, tooltip, this.datalist ? this.#onAutocomplete.bind(this) : undefined,
          /* dynamicCompletions=*/ undefined, jslogContext, this);
    }
    if (this.#value) {
      this.item.setValue(this.#value);
    }
    if (this.#disabled) {
      this.item.setEnabled(false);
    }
    this.item.addEventListener(ToolbarInput.Event.TEXT_CHANGED, event => {
      this.dispatchEvent(new CustomEvent('change', {detail: event.data}));
    });
    this.item.addEventListener(ToolbarInput.Event.ENTER_PRESSED, event => {
      this.dispatchEvent(new CustomEvent('submit', {detail: event.data}));
    });
  }

  override focus(): void {
    this.item?.focus();
  }

  #onRegexToggle(): void {
    this.dispatchEvent(new CustomEvent('regextoggle'));
  }

  async #onAutocomplete(expression: string, prefix: string, force?: boolean): Promise<Suggestion[]> {
    if (!prefix && !force && expression || !this.datalist) {
      return [];
    }

    const options = this.datalist.options;
    return [...options].map((({value}) => value)).filter(value => value.startsWith(prefix)).map(text => ({text}));
  }

  attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
    if (name === 'value') {
      if (this.item && this.item.value() !== newValue) {
        this.item.setValue(newValue, true);
      } else {
        this.#value = newValue;
      }
    } else if (name === 'disabled') {
      this.#disabled = typeof newValue === 'string';
      if (this.item) {
        this.item.setEnabled(!this.#disabled);
      }
    }
  }

  get value(): string {
    return this.item ? this.item.value() : (this.#value ?? '');
  }

  set value(value: string) {
    this.setAttribute('value', value);
  }

  set disabled(disabled: boolean) {
    if (disabled) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  get disabled(): boolean {
    return this.hasAttribute('disabled');
  }
}
customElements.define('devtools-toolbar-input', ToolbarInputElement);

export namespace ToolbarInput {
  export const enum Event {
    TEXT_CHANGED = 'TextChanged',
    ENTER_PRESSED = 'EnterPressed',
  }

  export interface EventTypes {
    [Event.TEXT_CHANGED]: string;
    [Event.ENTER_PRESSED]: string;
  }
}

export class ToolbarToggle extends ToolbarButton {
  private readonly toggledGlyph: string|undefined;

  constructor(title: string, glyph?: string, toggledGlyph?: string, jslogContext?: string, toggleOnClick?: boolean) {
    super(title, glyph, '');
    this.toggledGlyph = toggledGlyph ? toggledGlyph : glyph;
    this.setToggledIcon(this.toggledGlyph || '');
    this.setToggleType(Buttons.Button.ToggleType.PRIMARY);
    this.toggled(false);

    if (jslogContext) {
      this.element.setAttribute('jslog', `${VisualLogging.toggle().track({click: true}).context(jslogContext)}`);
    }
    if (toggleOnClick !== undefined) {
      this.setToggleOnClick(toggleOnClick);
    }
  }

  setToggleOnClick(toggleOnClick: boolean): void {
    this.toggleOnClick(toggleOnClick);
  }

  setToggled(toggled: boolean): void {
    this.toggled(toggled);
  }

  setChecked(checked: boolean): void {
    this.checked(checked);
  }

  enableToggleWithRedColor(): void {
    this.setToggleType(Buttons.Button.ToggleType.RED);
  }
}

export class ToolbarMenuButton extends ToolbarItem<ToolbarButton.EventTypes> {
  private textElement?: HTMLElement;
  private text?: string;
  private iconName?: string;
  private adorner?: HTMLElement;
  private readonly contextMenuHandler: (arg0: ContextMenu) => void;
  private readonly useSoftMenu: boolean;
  private readonly keepOpen: boolean;
  private readonly isIconDropdown: boolean;
  private triggerTimeoutId?: number;
  #triggerDelay = 200;

  constructor(
      contextMenuHandler: (arg0: ContextMenu) => void, isIconDropdown?: boolean, useSoftMenu?: boolean,
      jslogContext?: string, iconName?: string, keepOpen?: boolean) {
    let element;
    if (iconName) {
      element = new Buttons.Button.Button();
      element.data = {variant: Buttons.Button.Variant.ICON, iconName};
    } else {
      element = document.createElement('button');
    }
    element.classList.add('toolbar-button');
    super(element);
    this.element.addEventListener('click', this.clicked.bind(this), false);

    this.iconName = iconName;

    this.setTitle('');
    this.title = '';
    if (!isIconDropdown) {
      this.element.classList.add('toolbar-has-dropdown');
      const dropdownArrowIcon = createIcon('triangle-down', 'toolbar-dropdown-arrow');
      this.element.appendChild(dropdownArrowIcon);
    }
    if (jslogContext) {
      this.element.setAttribute('jslog', `${VisualLogging.dropDown().track({click: true}).context(jslogContext)}`);
    }
    this.element.addEventListener('mousedown', this.mouseDown.bind(this), false);
    this.contextMenuHandler = contextMenuHandler;
    this.useSoftMenu = Boolean(useSoftMenu);
    this.keepOpen = Boolean(keepOpen);
    this.isIconDropdown = Boolean(isIconDropdown);
    ARIAUtils.markAsMenuButton(this.element);
  }

  setText(text: string): void {
    if (this.text === text || this.iconName) {
      return;
    }
    if (!this.textElement) {
      this.textElement = document.createElement('div');
      this.textElement.classList.add('toolbar-text', 'hidden');
      const dropDownArrow = this.element.querySelector('.toolbar-dropdown-arrow');
      this.element.insertBefore(this.textElement, dropDownArrow);
    }
    this.textElement.textContent = text;
    this.textElement.classList.toggle('hidden', !text);
    this.text = text;
  }

  setAdorner(adorner: HTMLElement): void {
    if (this.iconName) {
      return;
    }
    if (!this.adorner) {
      this.adorner = adorner;
    } else {
      adorner.replaceWith(adorner);
      if (this.element.firstChild) {
        this.element.removeChild(this.element.firstChild);
      }
    }
    this.element.prepend(adorner);
  }

  setDarkText(): void {
    this.element.classList.add('dark-text');
  }

  turnShrinkable(): void {
    this.element.classList.add('toolbar-has-dropdown-shrinkable');
  }

  setTriggerDelay(x: number): void {
    this.#triggerDelay = x;
  }

  mouseDown(event: MouseEvent): void {
    if (!this.enabled) {
      return;
    }
    if (event.buttons !== 1) {
      return;
    }

    if (!this.triggerTimeoutId) {
      this.triggerTimeoutId = window.setTimeout(this.trigger.bind(this, event), this.#triggerDelay);
    }
  }

  private trigger(event: Event): void {
    delete this.triggerTimeoutId;

    const horizontalPosition =
        this.isIconDropdown ? this.element.getBoundingClientRect().right : this.element.getBoundingClientRect().left;
    const contextMenu = new ContextMenu(event, {
      useSoftMenu: this.useSoftMenu,
      keepOpen: this.keepOpen,
      x: horizontalPosition,
      y: this.element.getBoundingClientRect().top + this.element.offsetHeight,
      // Without adding a delay, pointer events will be un-ignored too early, and a single click causes
      // the context menu to be closed and immediately re-opened on Windows (https://crbug.com/339560549).
      onSoftMenuClosed: () => setTimeout(() => this.element.removeAttribute('aria-expanded'), 50),
    });
    this.contextMenuHandler(contextMenu);
    this.element.setAttribute('aria-expanded', 'true');
    void contextMenu.show();
  }

  clicked(event: Event): void {
    if (this.triggerTimeoutId) {
      clearTimeout(this.triggerTimeoutId);
    }
    this.trigger(event);
  }
}

export class ToolbarSettingToggle extends ToolbarToggle {
  private readonly defaultTitle: string;
  private readonly setting: Common.Settings.Setting<boolean>;
  private willAnnounceState: boolean;

  constructor(
      setting: Common.Settings.Setting<boolean>, glyph: string, title: string, toggledGlyph?: string,
      jslogContext?: string) {
    super(title, glyph, toggledGlyph, jslogContext);
    this.defaultTitle = title;
    this.setting = setting;
    this.settingChanged();
    this.setting.addChangeListener(this.settingChanged, this);

    // Determines whether the toggle state will be announced to a screen reader
    this.willAnnounceState = false;
  }

  private settingChanged(): void {
    const toggled = this.setting.get();
    this.setToggled(toggled);
    const toggleAnnouncement = toggled ? i18nString(UIStrings.pressed) : i18nString(UIStrings.notPressed);
    if (this.willAnnounceState) {
      ARIAUtils.LiveAnnouncer.alert(toggleAnnouncement);
    }
    this.willAnnounceState = false;
    this.setTitle(this.defaultTitle);
  }

  override clicked(event: Event): void {
    this.willAnnounceState = true;
    this.setting.set(this.isToggled());
    super.clicked(event);
  }
}

export class ToolbarSeparator extends ToolbarItem<void> {
  constructor(spacer?: boolean) {
    const element = document.createElement('div');
    element.classList.add(spacer ? 'toolbar-spacer' : 'toolbar-divider');
    super(element);
  }
}

export interface Provider {
  item(): ToolbarItem|null;
}

export interface ItemsProvider {
  toolbarItems(): ToolbarItem[];
}

export class ToolbarComboBox extends ToolbarItem<void, HTMLSelectElement> {
  constructor(
      changeHandler: ((arg0: Event) => void)|null, title: string, className?: string, jslogContext?: string,
      element?: HTMLSelectElement) {
    if (!element) {
      element = document.createElement('select');
    }
    super(element);
    if (changeHandler) {
      this.element.addEventListener('change', changeHandler, false);
    }
    ARIAUtils.setLabel(this.element, title);
    super.setTitle(title);
    if (className) {
      this.element.classList.add(className);
    }
    if (jslogContext) {
      this.element.setAttribute('jslog', `${VisualLogging.dropDown().track({change: true}).context(jslogContext)}`);
    }
  }

  turnShrinkable(): void {
    this.element.classList.add('toolbar-has-dropdown-shrinkable');
  }

  size(): number {
    return this.element.childElementCount;
  }

  options(): HTMLOptionElement[] {
    return Array.prototype.slice.call(this.element.children, 0);
  }

  addOption(option: Element): void {
    this.element.appendChild(option);
  }

  createOption(label: string, value?: string, jslogContext?: string): HTMLOptionElement {
    const option = this.element.createChild('option');
    option.text = label;
    if (typeof value !== 'undefined') {
      option.value = value;
    }
    if (!jslogContext) {
      jslogContext = value ? Platform.StringUtilities.toKebabCase(value) : undefined;
    }
    option.setAttribute('jslog', `${VisualLogging.item(jslogContext).track({click: true})}`);
    return option;
  }

  override applyEnabledState(enabled: boolean): void {
    super.applyEnabledState(enabled);
    this.element.disabled = !enabled;
  }

  removeOption(option: Element): void {
    this.element.removeChild(option);
  }

  removeOptions(): void {
    this.element.removeChildren();
  }

  selectedOption(): HTMLOptionElement|null {
    if (this.element.selectedIndex >= 0) {
      return this.element[this.element.selectedIndex] as HTMLOptionElement;
    }
    return null;
  }

  select(option: Element): void {
    this.element.selectedIndex = Array.prototype.indexOf.call(this.element, option);
  }

  setSelectedIndex(index: number): void {
    this.element.selectedIndex = index;
  }

  selectedIndex(): number {
    return this.element.selectedIndex;
  }
}

export interface Option {
  value: string;
  label: string;
}

export class ToolbarSettingComboBox extends ToolbarComboBox {
  #options: Option[];
  private readonly setting: Common.Settings.Setting<string>;
  private muteSettingListener?: boolean;
  constructor(options: Option[], setting: Common.Settings.Setting<string>, accessibleName: string) {
    super(null, accessibleName, undefined, setting.name);
    this.#options = options;
    this.setting = setting;
    this.element.addEventListener('change', this.onSelectValueChange.bind(this), false);
    this.setOptions(options);
    setting.addChangeListener(this.onDevToolsSettingChanged, this);
  }

  setOptions(options: Option[]): void {
    this.#options = options;
    this.element.removeChildren();
    for (let i = 0; i < options.length; ++i) {
      const dataOption = options[i];
      const option = this.createOption(dataOption.label, dataOption.value);
      this.element.appendChild(option);
      if (this.setting.get() === dataOption.value) {
        this.setSelectedIndex(i);
      }
    }
  }

  value(): string {
    return this.#options[this.selectedIndex()].value;
  }

  override select(option: Element): void {
    const index = Array.prototype.indexOf.call(this.element, option);
    this.setSelectedIndex(index);
  }

  override setSelectedIndex(index: number): void {
    super.setSelectedIndex(index);
    const option = this.#options.at(index);
    if (option) {
      this.setTitle(option.label);
    }
  }

  /**
   * Note: wondering why there are two event listeners and what the difference is?
   * It is because this combo box <select> is backed by a Devtools setting and
   * at any time there could be multiple instances of these elements that are
   * backed by the same setting. So they have to listen to two things:
   * 1. When the setting is changed via a different method.
   * 2. When the value of the select is changed, triggering a change to the setting.
   */

  /**
   * Runs when the DevTools setting is changed
   */
  private onDevToolsSettingChanged(): void {
    if (this.muteSettingListener) {
      return;
    }

    const value = this.setting.get();
    for (let i = 0; i < this.#options.length; ++i) {
      if (value === this.#options[i].value) {
        this.setSelectedIndex(i);
        break;
      }
    }
  }

  /**
   * Run when the user interacts with the <select> element.
   */
  private onSelectValueChange(_event: Event): void {
    const option = this.#options[this.selectedIndex()];
    this.muteSettingListener = true;
    this.setting.set(option.value);
    this.muteSettingListener = false;
    // Because we mute the DevTools setting change listener, we need to
    // manually update the title here.
    this.setTitle(option.label);
  }
}

export class ToolbarCheckbox extends ToolbarItem<void> {
  #checkboxLabel: CheckboxLabel;
  constructor(
      text: Common.UIString.LocalizedString, tooltip?: Common.UIString.LocalizedString,
      listener?: ((arg0: MouseEvent) => void), jslogContext?: string) {
    // Pass tooltip to CheckboxLabel.create so it's set on the inner input/text elements,
    // rather than installing it on the wrapper element which causes screen readers to
    // incorrectly announce it as a group name.
    const checkboxLabel = CheckboxLabel.create(text, undefined, undefined, jslogContext, undefined, tooltip);
    super(checkboxLabel);
    if (listener) {
      this.element.addEventListener('click', listener, false);
    }

    this.#checkboxLabel = checkboxLabel;
  }

  checked(): boolean {
    return (this.element as CheckboxLabel).checked;
  }

  setChecked(value: boolean): void {
    (this.element as CheckboxLabel).checked = value;
  }

  override applyEnabledState(enabled: boolean): void {
    super.applyEnabledState(enabled);
    (this.element as CheckboxLabel).disabled = !enabled;
  }

  setIndeterminate(indeterminate: boolean): void {
    (this.element as CheckboxLabel).indeterminate = indeterminate;
  }

  /**
   * Sets the user visible text shown alongside the checkbox.
   * If you want to update the title/aria-label, use setTitle.
   */
  setLabelText(content: Common.UIString.LocalizedString): void {
    this.#checkboxLabel.setLabelText(content);
  }
}

export class ToolbarSettingCheckbox extends ToolbarCheckbox {
  constructor(
      setting: Common.Settings.Setting<boolean>, tooltip?: Common.UIString.LocalizedString,
      alternateTitle?: Common.UIString.LocalizedString) {
    super(alternateTitle || setting.title(), tooltip, undefined, setting.name);
    bindCheckbox(this.element as CheckboxLabel, setting);
  }
}

const registeredToolbarItems: ToolbarItemRegistration[] = [];

export function registerToolbarItem(registration: ToolbarItemRegistration): void {
  registeredToolbarItems.push(registration);
}

function getRegisteredToolbarItems(): ToolbarItemRegistration[] {
  return registeredToolbarItems.filter(
      item => Root.Runtime.Runtime.isDescriptorEnabled({experiment: item.experiment, condition: item.condition}));
}

export interface ToolbarItemRegistration {
  order?: number;
  location: ToolbarItemLocation;
  separator?: boolean;
  label?: () => Platform.UIString.LocalizedString;
  actionId?: string;
  condition?: Root.Runtime.Condition;
  loadItem?: (() => Promise<Provider>);
  experiment?: string;
  jslog?: string;
}

export const enum ToolbarItemLocation {
  FILES_NAVIGATION_TOOLBAR = 'files-navigator-toolbar',
  MAIN_TOOLBAR_RIGHT = 'main-toolbar-right',
  MAIN_TOOLBAR_LEFT = 'main-toolbar-left',
  STYLES_SIDEBARPANE_TOOLBAR = 'styles-sidebarpane-toolbar',
}

declare global {
  interface HTMLElementTagNameMap {
    'devtools-toolbar': Toolbar;
    'devtools-toolbar-input': ToolbarInputElement;
  }
}
