// Copyright 2021 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 */

/*
 * Copyright (C) 2006, 2007, 2008 Apple Inc.  All rights reserved.
 * Copyright (C) 2007 Matt Lilek (pewtermoose@gmail.com).
 * Copyright (C) 2009 Joseph Pecoraro
 * Copyright (C) 2011 Google Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1.  Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 * 2.  Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 * 3.  Neither the name of Apple Computer, Inc. ("Apple") nor the names of
 *     its contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import './Toolbar.js'; // eslint-disable-line import/no-duplicates

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 VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as Buttons from '../components/buttons/buttons.js';
import {createIcon} from '../kit/kit.js';

import * as ARIAUtils from './ARIAUtils.js';
import {InspectorView} from './InspectorView.js';
import searchableViewStyles from './searchableView.css.js';
import {ToolbarButton, ToolbarText, ToolbarToggle} from './Toolbar.js';  // eslint-disable-line import/no-duplicates
import {createHistoryInput, createTextButton} from './UIUtils.js';
import {VBox} from './Widget.js';

const UIStrings = {
  /**
   * @description Text on a button to replace one instance with input text for the ctrl+F search bar
   */
  replace: 'Replace',
  /**
   * @description Tooltip text on a toggle to enable replacing one instance with input text for the ctrl+F search bar
   */
  enableFindAndReplace: 'Find and replace',
  /**
   * @description Tooltip text on a toggle to disable replacing one instance with input text for the ctrl+F search bar
   */
  disableFindAndReplace: 'Disable find and replace',
  /**
   * @description Text to find an item
   */
  findString: 'Find',
  /**
   * @description Tooltip text on a button to search previous instance for the ctrl+F search bar
   */
  searchPrevious: 'Show previous result',
  /**
   * @description Tooltip text on a button to search next instance for the ctrl+F search bar
   */
  searchNext: 'Show next result',
  /**
   * @description Tooltip text on a toggle to enable/disable search by matching the exact case.
   */
  matchCase: 'Match case',
  /**
   * @description Tooltip text on a toggle to enable/disable search by matching the exact word.
   */
  matchWholeWord: 'Match whole word',
  /**
   * @description Tooltip text on a toggle to enable/disable searching with regular expression.
   */
  useRegularExpression: 'Use regular expression',
  /**
   * @description Tooltip text on a button to close the search bar
   */
  closeSearchBar: 'Close search bar',
  /**
   * @description Text on a button to replace all instances with input text for the ctrl+F search bar
   */
  replaceAll: 'Replace all',
  /**
   * @description Text to indicate the current match index and the total number of matches for the ctrl+F search bar
   * @example {2} PH1
   * @example {3} PH2
   */
  dOfD: '{PH1} of {PH2}',
  /**
   * @description Tooltip text to indicate the current match index and the total number of matches for the ctrl+F search bar
   * @example {2} PH1
   * @example {3} PH2
   */
  accessibledOfD: 'Shows result {PH1} of {PH2}',
  /**
   * @description Text to indicate search result for the ctrl+F search bar
   */
  matchString: '1 match',
  /**
   * @description Text to indicate search result for the ctrl+F search bar
   * @example {2} PH1
   */
  dMatches: '{PH1} matches',
  /**
   * @description Text on a button to search previous instance for the ctrl+F search bar
   */
  clearInput: 'Clear',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/SearchableView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

function createClearButton(jslogContext: string): Buttons.Button.Button {
  const button = new Buttons.Button.Button();
  button.data = {
    variant: Buttons.Button.Variant.ICON,
    size: Buttons.Button.Size.SMALL,
    jslogContext,
    title: i18nString(UIStrings.clearInput),
    iconName: 'cross-circle-filled',
  };
  button.ariaLabel = i18nString(UIStrings.clearInput);
  button.classList.add('clear-button');
  button.tabIndex = -1;
  return button;
}
export class SearchableView extends VBox {
  protected searchProvider: Searchable;
  private replaceProvider: Replaceable|null;
  // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private setting: Common.Settings.Setting<any>|null;
  private replaceable: boolean;
  private readonly footerElementContainer: HTMLElement;
  private readonly footerElement: HTMLElement;
  private replaceToggleButton: ToolbarToggle;
  private searchInputElement: HTMLInputElement;
  private matchesElement: HTMLElement;
  private matchesElementValue: HTMLElement;
  private searchNavigationPrevElement: ToolbarButton;
  private searchNavigationNextElement: ToolbarButton;
  private readonly replaceInputElement: HTMLInputElement;
  private caseSensitiveButton: Buttons.Button.Button|undefined;
  private wholeWordButton: Buttons.Button.Button|undefined;
  private regexButton: Buttons.Button.Button|undefined;
  private replaceButtonElement: Buttons.Button.Button;
  private replaceAllButtonElement: Buttons.Button.Button;
  private minimalSearchQuerySize: number;
  private searchIsVisible?: boolean;
  private currentQuery?: string;
  private valueChangedTimeoutId?: number;

  constructor(searchable: Searchable, replaceable: Replaceable|null, settingName?: string, element?: HTMLElement) {
    super(element, {useShadowDom: true});
    this.registerRequiredCSS(searchableViewStyles);
    searchableViewsByElement.set(this.element, this);

    this.searchProvider = searchable;
    this.replaceProvider = replaceable;
    this.setting = settingName ? Common.Settings.Settings.instance().createSetting(settingName, {}) : null;
    this.replaceable = false;

    this.contentElement.createChild('slot');
    this.footerElementContainer = this.contentElement.createChild('div', 'search-bar hidden');
    this.footerElementContainer.style.order = '100';
    this.footerElement = this.footerElementContainer.createChild('div', 'toolbar-search');
    this.footerElement.setAttribute('jslog', `${VisualLogging.toolbar('search').track({resize: true})}`);

    const replaceToggleToolbar = this.footerElement.createChild('devtools-toolbar', 'replace-toggle-toolbar');
    this.replaceToggleButton =
        new ToolbarToggle(i18nString(UIStrings.enableFindAndReplace), 'replace', undefined, 'replace');
    ARIAUtils.setLabel(this.replaceToggleButton.element, i18nString(UIStrings.enableFindAndReplace));
    this.replaceToggleButton.addEventListener(ToolbarButton.Events.CLICK, this.toggleReplace, this);
    replaceToggleToolbar.appendToolbarItem(this.replaceToggleButton);

    // Elements within `searchInputElements` are added according to their expected tab order.
    const searchInputElements = this.footerElement.createChild('div', 'search-inputs');
    const iconAndInput = searchInputElements.createChild('div', 'icon-and-input');
    const searchIcon = createIcon('search');
    iconAndInput.appendChild(searchIcon);

    this.searchInputElement = createHistoryInput('search', 'search-replace search');
    this.searchInputElement.id = 'search-input-field';
    this.searchInputElement.autocomplete = 'off';
    this.searchInputElement.placeholder = i18nString(UIStrings.findString);
    this.searchInputElement.setAttribute(
        'jslog',
        `${VisualLogging.textField('search').track({change: true, keydown: 'ArrowUp|ArrowDown|Enter|Escape'})}`);
    this.searchInputElement.addEventListener('keydown', this.onSearchKeyDown.bind(this), true);
    this.searchInputElement.addEventListener('input', this.onInput.bind(this), false);
    iconAndInput.appendChild(this.searchInputElement);

    const replaceInputElements = searchInputElements.createChild('div', 'replace-element input-line');
    this.replaceInputElement = replaceInputElements.createChild('input', 'search-replace');
    this.replaceInputElement.addEventListener('keydown', this.onReplaceKeyDown.bind(this), true);
    this.replaceInputElement.placeholder = i18nString(UIStrings.replace);
    this.replaceInputElement.setAttribute(
        'jslog', `${VisualLogging.textField('replace').track({change: true, keydown: 'Enter'})}`);

    const replaceInputClearButton = createClearButton('clear-replace-input');
    replaceInputClearButton.addEventListener('click', () => {
      this.replaceInputElement.value = '';
      this.replaceInputElement.focus();
    });
    replaceInputElements.appendChild(replaceInputClearButton);

    const searchConfigButtons = searchInputElements.createChild('div', 'search-config-buttons');
    const clearButton = createClearButton('clear-search-input');
    clearButton.addEventListener('click', () => {
      this.searchInputElement.value = '';
      this.clearSearch();
      this.searchInputElement.focus();
    });
    searchConfigButtons.appendChild(clearButton);

    const saveSettingAndPerformSearch = (): void => {
      this.saveSetting();
      this.performSearch(false, true);
    };

    if (this.searchProvider.supportsCaseSensitiveSearch()) {
      const iconName = 'match-case';
      this.caseSensitiveButton = new Buttons.Button.Button();
      this.caseSensitiveButton.data = {
        variant: Buttons.Button.Variant.ICON_TOGGLE,
        size: Buttons.Button.Size.SMALL,
        iconName,
        toggledIconName: iconName,
        toggled: false,
        toggleType: Buttons.Button.ToggleType.PRIMARY,
        title: i18nString(UIStrings.matchCase),
        jslogContext: iconName,
      };
      ARIAUtils.setLabel(this.caseSensitiveButton, i18nString(UIStrings.matchCase));
      this.caseSensitiveButton.addEventListener('click', saveSettingAndPerformSearch);
      searchConfigButtons.appendChild(this.caseSensitiveButton);
    }

    if (this.searchProvider.supportsWholeWordSearch()) {
      const iconName = 'match-whole-word';
      this.wholeWordButton = new Buttons.Button.Button();
      this.wholeWordButton.data = {
        variant: Buttons.Button.Variant.ICON_TOGGLE,
        size: Buttons.Button.Size.SMALL,
        iconName,
        toggledIconName: iconName,
        toggled: false,
        toggleType: Buttons.Button.ToggleType.PRIMARY,
        title: i18nString(UIStrings.matchWholeWord),
        jslogContext: iconName,
      };
      ARIAUtils.setLabel(this.wholeWordButton, i18nString(UIStrings.matchWholeWord));
      this.wholeWordButton.addEventListener('click', saveSettingAndPerformSearch);
      searchConfigButtons.appendChild(this.wholeWordButton);
    }

    if (this.searchProvider.supportsRegexSearch()) {
      const iconName = 'regular-expression';
      this.regexButton = new Buttons.Button.Button();
      this.regexButton.data = {
        variant: Buttons.Button.Variant.ICON_TOGGLE,
        size: Buttons.Button.Size.SMALL,
        iconName,
        toggledIconName: iconName,
        toggleType: Buttons.Button.ToggleType.PRIMARY,
        toggled: false,
        jslogContext: iconName,
        title: i18nString(UIStrings.useRegularExpression),
      };
      ARIAUtils.setLabel(this.regexButton, i18nString(UIStrings.useRegularExpression));
      this.regexButton.addEventListener('click', saveSettingAndPerformSearch);
      searchConfigButtons.appendChild(this.regexButton);
    }

    // Introduce a separate element for the background of the `Find` input line (instead of
    // grouping together the `Find` input together with all search config option buttons
    // and styling the parent's background).
    // This allows for a tabbing order that can jump from the `Find` input, to
    // the `Replace` input, and back to all search config option buttons.
    searchInputElements.createChild('div', 'input-line search-input-background');

    const buttonsContainer = this.footerElement.createChild('div', 'toolbar-search-buttons');
    const firstRowButtons = buttonsContainer.createChild('div', 'first-row-buttons');

    const toolbar = firstRowButtons.createChild('devtools-toolbar', 'toolbar-search-options');
    this.searchNavigationPrevElement =
        new ToolbarButton(i18nString(UIStrings.searchPrevious), 'chevron-up', undefined, 'select-previous');
    this.searchNavigationPrevElement.addEventListener(ToolbarButton.Events.CLICK, () => this.onPrevButtonSearch());
    toolbar.appendToolbarItem(this.searchNavigationPrevElement);
    ARIAUtils.setLabel(this.searchNavigationPrevElement.element, i18nString(UIStrings.searchPrevious));

    this.searchNavigationNextElement =
        new ToolbarButton(i18nString(UIStrings.searchNext), 'chevron-down', undefined, 'select-next');
    this.searchNavigationNextElement.addEventListener(ToolbarButton.Events.CLICK, () => this.onNextButtonSearch());
    ARIAUtils.setLabel(this.searchNavigationNextElement.element, i18nString(UIStrings.searchNext));
    toolbar.appendToolbarItem(this.searchNavigationNextElement);

    const matchesText = new ToolbarText();
    this.matchesElement = matchesText.element;
    this.matchesElement.style.fontVariantNumeric = 'tabular-nums';
    this.matchesElement.style.color = 'var(--sys-color-on-surface-subtle)';
    this.matchesElement.style.padding = '0 var(--sys-size-3)';
    this.matchesElement.classList.add('search-results-matches');
    ARIAUtils.markAsPoliteLiveRegion(this.matchesElement, false);
    this.matchesElementValue = this.matchesElement.createChild('span');
    ARIAUtils.setHidden(this.matchesElementValue, true);
    toolbar.appendToolbarItem(matchesText);

    const cancelButtonElement = new Buttons.Button.Button();
    cancelButtonElement.data = {
      variant: Buttons.Button.Variant.TOOLBAR,
      size: Buttons.Button.Size.REGULAR,
      iconName: 'cross',
      title: i18nString(UIStrings.closeSearchBar),
      jslogContext: 'close-search',
    };

    cancelButtonElement.classList.add('close-search-button');
    cancelButtonElement.addEventListener('click', () => this.closeSearch());
    firstRowButtons.appendChild(cancelButtonElement);

    const secondRowButtons = buttonsContainer.createChild('div', 'second-row-buttons replace-element');

    this.replaceButtonElement = createTextButton(i18nString(UIStrings.replace), this.replace.bind(this), {
      className: 'search-action-button',
      jslogContext: 'replace',
    });
    this.replaceButtonElement.disabled = true;
    secondRowButtons.appendChild(this.replaceButtonElement);

    this.replaceAllButtonElement = createTextButton(i18nString(UIStrings.replaceAll), this.replaceAll.bind(this), {
      className: 'search-action-button',
      jslogContext: 'replace-all',
    });

    secondRowButtons.appendChild(this.replaceAllButtonElement);
    this.replaceAllButtonElement.disabled = true;

    this.minimalSearchQuerySize = 3;
    this.loadSetting();
  }

  static fromElement(element: Element|null): SearchableView|null {
    let view: (SearchableView|null)|null = null;
    while (element && !view) {
      view = searchableViewsByElement.get(element) || null;
      element = element.parentElementOrShadowHost();
    }
    return view;
  }

  private toggleReplace(): void {
    const replaceEnabled = this.replaceToggleButton.isToggled();
    const label =
        replaceEnabled ? i18nString(UIStrings.disableFindAndReplace) : i18nString(UIStrings.enableFindAndReplace);
    ARIAUtils.setLabel(this.replaceToggleButton.element, label);
    this.replaceToggleButton.element.title = label;
    this.updateSecondRowVisibility();
  }

  private saveSetting(): void {
    if (!this.setting) {
      return;
    }
    const settingValue = this.setting.get() || {};
    if (this.caseSensitiveButton) {
      settingValue.caseSensitive = this.caseSensitiveButton.toggled;
    }
    if (this.wholeWordButton) {
      settingValue.wholeWord = this.wholeWordButton.toggled;
    }
    if (this.regexButton) {
      settingValue.isRegex = this.regexButton.toggled;
    }
    this.setting.set(settingValue);
  }

  private loadSetting(): void {
    const settingValue = this.setting ? (this.setting.get() || {}) : {};
    if (this.caseSensitiveButton) {
      this.caseSensitiveButton.toggled = Boolean(settingValue.caseSensitive);
    }
    if (this.wholeWordButton) {
      this.wholeWordButton.toggled = Boolean(settingValue.wholeWord);
    }
    if (this.regexButton) {
      this.regexButton.toggled = Boolean(settingValue.isRegex);
    }
  }

  setMinimalSearchQuerySize(minimalSearchQuerySize: number): void {
    this.minimalSearchQuerySize = minimalSearchQuerySize;
  }

  setPlaceholder(placeholder: string, ariaLabel?: string): void {
    this.searchInputElement.placeholder = placeholder;
    if (ariaLabel) {
      ARIAUtils.setLabel(this.searchInputElement, ariaLabel);
    }
  }

  setReplaceable(replaceable: boolean): void {
    this.replaceable = replaceable;
  }

  updateSearchMatchesCount(matches: number): void {
    if (this.searchProvider.currentSearchMatches === matches) {
      return;
    }
    this.searchProvider.currentSearchMatches = matches;
    this.updateSearchMatchesCountAndCurrentMatchIndex(this.searchProvider.currentQuery ? matches : 0, -1);
  }

  updateCurrentMatchIndex(currentMatchIndex: number): void {
    if (!this.searchProvider.currentSearchMatches) {
      return;
    }

    this.updateSearchMatchesCountAndCurrentMatchIndex(this.searchProvider.currentSearchMatches, currentMatchIndex);
  }

  closeSearch(): void {
    this.cancelSearch();
    if (this.footerElementContainer.hasFocus()) {
      this.focus();
    }

    this.searchProvider.onSearchClosed?.();
  }

  private toggleSearchBar(toggled: boolean): void {
    this.footerElementContainer.classList.toggle('hidden', !toggled);
    this.doResize();
  }

  cancelSearch(): void {
    if (!this.searchIsVisible) {
      return;
    }
    this.resetSearch();
    delete this.searchIsVisible;
    this.toggleSearchBar(false);
  }

  resetSearch(): void {
    this.clearSearch();
    this.updateReplaceVisibility();
    this.matchesElementValue.textContent = '';
  }

  refreshSearch(): void {
    if (!this.searchIsVisible) {
      return;
    }
    this.resetSearch();
    this.performSearch(false, false);
  }

  handleFindNextShortcut(): boolean {
    if (!this.searchIsVisible) {
      return false;
    }
    this.searchProvider.jumpToNextSearchResult();
    return true;
  }

  handleFindPreviousShortcut(): boolean {
    if (!this.searchIsVisible) {
      return false;
    }
    this.searchProvider.jumpToPreviousSearchResult();
    return true;
  }

  handleFindShortcut(): boolean {
    this.showSearchField();
    return true;
  }

  handleCancelSearchShortcut(): boolean {
    if (!this.searchIsVisible) {
      return false;
    }
    this.closeSearch();
    return true;
  }

  private updateSearchNavigationButtonState(enabled: boolean): void {
    this.replaceButtonElement.disabled = !enabled;
    this.replaceAllButtonElement.disabled = !enabled;
    if (this.searchProvider.supportsMatchCounts?.() === true) {
      this.searchNavigationPrevElement.setEnabled(enabled);
      this.searchNavigationNextElement.setEnabled(enabled);
    }
  }

  private updateSearchMatchesCountAndCurrentMatchIndex(matches: number, currentMatchIndex: number): void {
    if (!this.currentQuery) {
      this.matchesElementValue.textContent = '';
    } else if (matches === 0 || currentMatchIndex >= 0) {
      this.matchesElementValue.textContent = i18nString(UIStrings.dOfD, {PH1: currentMatchIndex + 1, PH2: matches});
      ARIAUtils.setLabel(
          this.matchesElement, i18nString(UIStrings.accessibledOfD, {PH1: currentMatchIndex + 1, PH2: matches}));
    } else if (matches === 1) {
      this.matchesElementValue.textContent = i18nString(UIStrings.matchString);
      ARIAUtils.setLabel(this.matchesElement, i18nString(UIStrings.matchString));
    } else {
      this.matchesElementValue.textContent = i18nString(UIStrings.dMatches, {PH1: matches});
      ARIAUtils.setLabel(this.matchesElement, i18nString(UIStrings.dMatches, {PH1: matches}));
    }
    this.updateSearchNavigationButtonState(matches > 0);
  }

  showSearchField(): void {
    if (this.searchIsVisible) {
      this.cancelSearch();
    }

    let queryCandidate;
    if (!this.searchInputElement.hasFocus()) {
      const selection = InspectorView.instance().element.window().getSelection();
      if (selection?.rangeCount) {
        queryCandidate = selection.toString().replace(/\r?\n.*/, '');
      }
    }

    this.toggleSearchBar(true);
    this.updateReplaceVisibility();
    if (queryCandidate) {
      this.searchInputElement.value = queryCandidate;
    }
    this.performSearch(false, false);
    this.searchInputElement.focus();
    this.searchInputElement.select();
    this.searchIsVisible = true;
  }

  private updateReplaceVisibility(): void {
    this.replaceToggleButton.setVisible(this.replaceable);
    if (!this.replaceable) {
      this.replaceToggleButton.setToggled(false);
      this.updateSecondRowVisibility();
    }
  }

  private onSearchKeyDown(event: KeyboardEvent): void {
    if (Platform.KeyboardUtilities.isEscKey(event)) {
      this.closeSearch();
      event.consume(true);
      return;
    }
    if (!(event.key === 'Enter')) {
      return;
    }

    if (!this.currentQuery) {
      this.performSearch(true, true, event.shiftKey);
    } else {
      this.jumpToNextSearchResult(event.shiftKey);
    }
  }

  private onReplaceKeyDown(event: KeyboardEvent): void {
    if (event.key === 'Enter') {
      this.replace();
    }
  }

  private jumpToNextSearchResult(isBackwardSearch?: boolean): void {
    if (!this.currentQuery) {
      return;
    }

    if (isBackwardSearch) {
      this.searchProvider.jumpToPreviousSearchResult();
    } else {
      this.searchProvider.jumpToNextSearchResult();
    }
  }

  private onNextButtonSearch(): void {
    this.jumpToNextSearchResult();
  }

  private onPrevButtonSearch(): void {
    this.jumpToNextSearchResult(true);
  }

  private clearSearch(): void {
    this.currentQuery = undefined;
    if (Boolean(this.searchProvider.currentQuery)) {
      this.searchProvider.currentQuery = undefined;
      this.searchProvider.onSearchCanceled();
    }
    this.updateSearchMatchesCountAndCurrentMatchIndex(0, -1);
  }

  private performSearch(forceSearch: boolean, shouldJump: boolean, jumpBackwards?: boolean): void {
    const query = this.searchInputElement.value;
    if (!query || (!forceSearch && query.length < this.minimalSearchQuerySize && !this.currentQuery)) {
      this.clearSearch();
      return;
    }

    this.currentQuery = query;
    this.searchProvider.currentQuery = query;

    const searchConfig = this.currentSearchConfig();
    this.searchProvider.performSearch(searchConfig, shouldJump, jumpBackwards);
  }

  private currentSearchConfig(): SearchConfig {
    const query = this.searchInputElement.value;
    const caseSensitive = this.caseSensitiveButton ? this.caseSensitiveButton.toggled : false;
    const wholeWord = this.wholeWordButton ? this.wholeWordButton.toggled : false;
    const isRegex = this.regexButton ? this.regexButton.toggled : false;
    return new SearchConfig(query, caseSensitive, wholeWord, isRegex);
  }

  private updateSecondRowVisibility(): void {
    const secondRowVisible = this.replaceToggleButton.isToggled();
    this.footerElementContainer.classList.toggle('replaceable', secondRowVisible);

    if (secondRowVisible) {
      this.replaceInputElement.focus();
    } else {
      this.searchInputElement.focus();
    }
    this.doResize();
  }

  private replace(): void {
    if (!this.replaceProvider) {
      throw new Error('No \'replaceable\' provided to SearchableView!');
    }
    const searchConfig = this.currentSearchConfig();
    this.replaceProvider.replaceSelectionWith(searchConfig, this.replaceInputElement.value);
    this.currentQuery = undefined;
    this.performSearch(true, true);
  }

  private replaceAll(): void {
    if (!this.replaceProvider) {
      throw new Error('No \'replaceable\' provided to SearchableView!');
    }
    const searchConfig = this.currentSearchConfig();
    this.replaceProvider.replaceAllWith(searchConfig, this.replaceInputElement.value);
  }

  private onInput(): void {
    if (!Common.Settings.Settings.instance().moduleSetting('search-as-you-type').get()) {
      this.clearSearch();
      return;
    }

    clearTimeout(this.valueChangedTimeoutId);
    const timeout = this.searchInputElement.value.length < 3 ? 200 : 0;
    this.valueChangedTimeoutId = window.setTimeout(this.onValueChanged.bind(this), timeout);
  }

  private onValueChanged(): void {
    if (!this.searchIsVisible) {
      return;
    }
    this.valueChangedTimeoutId = undefined;
    this.performSearch(false, true);
  }
}

const searchableViewsByElement = new WeakMap<Element, SearchableView>();

export interface Searchable {
  supportsMatchCounts?(): boolean;
  currentQuery?: string;
  currentSearchMatches?: number;
  onSearchCanceled(): void;
  // Called when the search toolbar is closed
  onSearchClosed?: () => void;
  performSearch(searchConfig: SearchConfig, shouldJump: boolean, jumpBackwards?: boolean): void;
  jumpToNextSearchResult(): void;
  jumpToPreviousSearchResult(): void;
  supportsCaseSensitiveSearch(): boolean;
  supportsWholeWordSearch(): boolean;
  supportsRegexSearch(): boolean;
}

export interface Replaceable {
  replaceSelectionWith(searchConfig: SearchConfig, replacement: string): void;
  replaceAllWith(searchConfig: SearchConfig, replacement: string): void;
}

export interface SearchRegexResult {
  regex: RegExp;
  fromQuery: boolean;
}

export class SearchConfig {
  query: string;
  caseSensitive: boolean;
  wholeWord: boolean;
  isRegex: boolean;

  constructor(query: string, caseSensitive: boolean, wholeWord: boolean, isRegex: boolean) {
    this.query = query;
    this.caseSensitive = caseSensitive;
    this.wholeWord = wholeWord;
    this.isRegex = isRegex;
  }

  toSearchRegex(global?: boolean): SearchRegexResult {
    let modifiers = this.caseSensitive ? '' : 'i';
    if (global) {
      modifiers += 'g';
    }

    // Check if query is surrounded by forward slashes
    const isRegexFormatted = this.query.startsWith('/') && this.query.endsWith('/');
    const query = this.isRegex && !isRegexFormatted ? '/' + this.query + '/' : this.query;
    let regex: RegExp|undefined;
    let fromQuery = false;

    // First try creating regex if user knows the / / hint.
    try {
      if (/^\/.+\/$/.test(query) && this.isRegex) {
        regex = new RegExp(query.substring(1, query.length - 1), modifiers);
        fromQuery = true;
      }
    } catch {
      // Silent catch.
    }

    // Otherwise just do a plain text search.
    if (!regex) {
      regex = Platform.StringUtilities.createPlainTextSearchRegex(query, modifiers);
    }

    if (this.wholeWord) {
      let {source} = regex;
      if (!source.startsWith('^') && !source.startsWith('\\b')) {
        source = '\\b' + source;
      }
      if (!source.endsWith('$') && !source.endsWith('\\b')) {
        source = source + '\\b';
      }
      regex = new RegExp(source, regex.flags);
    }

    return {
      regex,
      fromQuery,
    };
  }
}
