text: // Copyright 2020 The Chromium Authors. All rights reserved.
  // Use of this source code is governed by a BSD-style license that can be
  // found in the LICENSE file.
  
  import 'chrome://resources/cr_elements/shared_vars_css.m.js';
  import 'chrome://resources/cr_elements/mwb_shared_style.js';
  import 'chrome://resources/cr_elements/mwb_shared_vars.js';
  import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
  import 'chrome://resources/polymer/v3_0/iron-iconset-svg/iron-iconset-svg.js';
  import './infinite_list.js';
  import './tab_search_item.js';
  import './tab_search_search_field.js';
  import './strings.m.js';
  
  import {assert} from 'chrome://resources/js/assert.m.js';
  import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
  import {listenOnce} from 'chrome://resources/js/util.m.js';
  import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js';
  import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
  
  import {fuzzySearch} from './fuzzy_search.js';
  import {InfiniteList, NO_SELECTION, selectorNavigationKeys} from './infinite_list.js';
  import {ariaLabel, TabData, TabItemType, tokenToString} from './tab_data.js';
  import {Tab, TabGroup, Window} from './tab_search.mojom-webui.js';
  import {TabSearchApiProxy, TabSearchApiProxyImpl} from './tab_search_api_proxy.js';
  import {TitleItem} from './title_item.js';
  
  // The minimum number of list items we allow viewing regardless of browser
  // height. Includes a half row that hints to the user the capability to scroll.
  /** @type {number} */
  const MINIMUM_AVAILABLE_HEIGHT_LIST_ITEM_COUNT = 5.5;
  
  export class TabSearchAppElement extends PolymerElement {
    static get is() {
      return 'tab-search-app';
    }
  
    static get template() {
      return html`{__html_template__}`;
    }
  
    static get properties() {
      return {
        /** @private {string} */
        searchText_: {
          type: String,
          value: '',
        },
  
        /** @private {!Array<!TabData>}*/
        openTabs_: {
          type: Array,
          value: [],
        },
  
        /** @private {!Array<!TabData>} */
        recentlyClosedTabs_: {
          type: Array,
          value: [],
        },
  
        /** @private {number} */
        availableHeight_: Number,
  
        /** @private {!Array<!TabData>} */
        filteredOpenTabs_: {
          type: Array,
          value: [],
        },
  
        /** @private {!Array<!TabData>} */
        filteredRecentlyClosedTabs_: {
          type: Array,
          value: [],
        },
  
        /**
         * Options for fuzzy search.
         * @private {!Object}
         */
        fuzzySearchOptions_: {
          type: Object,
          value: {
            includeScore: true,
            includeMatches: true,
            ignoreLocation: true,
            threshold: 0.0,
            distance: 200,
            keys: [
              {
                name: 'tab.title',
                weight: 2,
              },
              {
                name: 'hostname',
                weight: 1,
              }
            ],
          },
        },
  
        /** @private {boolean} */
        moveActiveTabToBottom_: {
          type: Boolean,
          value: () => loadTimeData.getBoolean('moveActiveTabToBottom'),
        },
  
        recentlyClosedDefaultItemDisplayCount_: {
          type: Number,
          value: () =>
              /** @type {number} */ (
                  loadTimeData.getValue('recentlyClosedDefaultItemDisplayCount')),
        },
  
        /** @private */
        searchResultText_: {type: String, value: ''}
      };
    }
  
    constructor() {
      super();
  
      /** @private {!TabSearchApiProxy} */
      this.apiProxy_ = TabSearchApiProxyImpl.getInstance();
  
      /** @private {!Array<number>} */
      this.listenerIds_ = [];
  
      /** @private {!Map<string, !TabGroup>} */
      this.tabGroupsMap_ = new Map();
  
      /** @private {!Function} */
      this.visibilityChangedListener_ = () => {
        // Refresh Tab Search's tab data when transitioning into a visible state.
        if (document.visibilityState === 'visible') {
          this.updateTabs_();
        } else {
          this.onDocumentHidden_();
        }
      };
  
      /** @private {!TitleItem} */
      this.openTabsTitleItem_ =
          new TitleItem(loadTimeData.getStringF('openTabs'));
  
      /** @private {!TitleItem} */
      this.recentlyClosedTabsTitleItem_ = new TitleItem(
          loadTimeData.getStringF('recentlyClosedTabs'));
    }
  
    /** @override */
    ready() {
      super.ready();
      this.addEventListener('keydown', (e) => {
        this.onKeyDown_(/** @type {!KeyboardEvent} */ (e));
      });
  
      // Update option values for fuzzy search from feature params.
      this.fuzzySearchOptions_ = Object.assign({}, this.fuzzySearchOptions_, {
        ignoreLocation: loadTimeData.getBoolean('searchIgnoreLocation'),
        threshold: loadTimeData.getValue('searchThreshold'),
        distance: loadTimeData.getInteger('searchDistance'),
        keys: [
          {
            name: 'tab.title',
            weight: loadTimeData.getValue('searchTitleToHostnameWeightRatio'),
          },
          {
            name: 'hostname',
            weight: 1,
          }
        ],
      });
    }
  
    /** @override */
    connectedCallback() {
      super.connectedCallback();
  
      document.addEventListener(
          'visibilitychange', this.visibilityChangedListener_);
  
      const callbackRouter = this.apiProxy_.getCallbackRouter();
      this.listenerIds_.push(
          callbackRouter.tabsChanged.addListener(
              profileData => this.tabsChanged_(
                  profileData.windows, profileData.recentlyClosedTabs,
                  profileData.tabGroups)),
          callbackRouter.tabUpdated.addListener(tab => this.onTabUpdated_(tab)),
          callbackRouter.tabsRemoved.addListener(
              tabIds => this.onTabsRemoved_(tabIds)));
  
      // If added in a visible state update current tabs.
      if (document.visibilityState === 'visible') {
        this.updateTabs_();
      }
    }
  
    /** @override */
    disconnectedCallback() {
      super.disconnectedCallback();
  
      this.listenerIds_.forEach(
          id => this.apiProxy_.getCallbackRouter().removeListener(id));
  
      document.removeEventListener(
          'visibilitychange', this.visibilityChangedListener_);
    }
  
    /**
     * @param {string} name A property whose value is specified in pixels.
     * @return {number}
     */
    getStylePropertyPixelValue_(name) {
      const pxValue = getComputedStyle(this).getPropertyValue(name);
      assert(pxValue);
  
      return Number.parseInt(pxValue.trim().slice(0, -2), 10);
    }
  
    /**
     * Calculate the list's available height by subtracting the height used by
     * the search and feedback fields.
     *
     * @param {number} height
     * @return {number}
     * @private
     */
    listMaxHeight_(height) {
      return Math.max(
          height - this.$.searchField.offsetHeight,
          Math.round(
              MINIMUM_AVAILABLE_HEIGHT_LIST_ITEM_COUNT *
              this.getStylePropertyPixelValue_('--mwb-item-height')));
    }
  
    /** @private */
    onDocumentHidden_() {
      this.$.tabsList.scrollTop = 0;
      this.$.tabsList.selected = NO_SELECTION;
  
      this.$.searchField.setValue('');
      this.$.searchField.getSearchInput().focus();
    }
  
    /** @private */
    updateTabs_() {
      const getTabsStartTimestamp = Date.now();
      this.apiProxy_.getProfileData().then(({profileData}) => {
        chrome.metricsPrivate.recordTime(
            'Tabs.TabSearch.WebUI.TabListDataReceived',
            Math.round(Date.now() - getTabsStartTimestamp));
  
        // The infinite-list produces viewport-filled events whenever a data or
        // scroll position change triggers the the viewport fill logic.
        listenOnce(this.$.tabsList, 'viewport-filled', () => {
          // Push showUI() to the event loop to allow reflow to occur following
          // the DOM update.
          setTimeout(() => this.apiProxy_.showUI(), 0);
        });
  
        this.availableHeight_ = profileData.windows.find((t) => t.active).height;
        this.tabsChanged_(
            profileData.windows, profileData.recentlyClosedTabs,
            profileData.tabGroups);
      });
    }
  
    /**
     * @param {!Tab} updatedTab
     * @private
     */
    onTabUpdated_(updatedTab) {
      // Replace the tab with the same tabId and trigger rerender.
      for (let i = 0; i < this.openTabs_.length; ++i) {
        if (this.openTabs_[i].tab.tabId === updatedTab.tabId) {
          this.openTabs_[i] = this.tabData_(
              updatedTab, this.openTabs_[i].inActiveWindow, TabItemType.OPEN,
              this.tabGroupsMap_);
          this.updateFilteredTabs_(this.openTabs_, this.recentlyClosedTabs_);
          return;
        }
      }
    }
  
    /**
     * @param {!Array<number>} tabIds
     * @private
     */
    onTabsRemoved_(tabIds) {
      if (this.openTabs_.length === 0) {
        return;
      }
  
      const ids = new Set(tabIds);
      // Splicing in descending index order to avoid affecting preceding indices
      // that are to be removed.
      for (let i = this.openTabs_.length - 1; i >= 0; i--) {
        if (ids.has(this.openTabs_[i].tab.tabId)) {
          this.openTabs_.splice(i, 1);
        }
      }
  
      this.filteredOpenTabs_ =
          this.filteredOpenTabs_.filter(tabData => !ids.has(tabData.tab.tabId));
    }
  
    /**
     * The seleted item's index, or -1 if no item selected.
     * @return {number}
     */
    getSelectedIndex() {
      return /** @type {!InfiniteList} */ (this.$.tabsList).selected;
    }
  
    /**
     * @param {!CustomEvent<string>} e
     * @private
     */
    onSearchChanged_(e) {
      this.searchText_ = e.detail;
  
      this.updateFilteredTabs_(this.openTabs_, this.recentlyClosedTabs_);
      // Reset the selected item whenever a search query is provided.
      /** @type {!InfiniteList} */ (this.$.tabsList).selected =
          (this.filteredOpenTabs_.length +
           this.filteredRecentlyClosedTabs_.length) > 0 ?
          0 :
          NO_SELECTION;
  
      this.$.searchField.announce(this.getA11ySearchResultText_());
    }
  
    /**
     * @return {string}
     * @private
     */
    getA11ySearchResultText_() {
      // TODO(romanarora): Screen readers' list item number announcement will
      // not match as it counts the title items too. Investigate how to
      // programmatically control announcements to avoid this.
      const length =
          this.filteredOpenTabs_.length + this.filteredRecentlyClosedTabs_.length;
      let text;
      if (this.searchText_.length > 0) {
        text = loadTimeData.getStringF(
            length == 1 ? 'a11yFoundTabFor' : 'a11yFoundTabsFor', length,
            this.searchText_);
      } else {
        text = loadTimeData.getStringF(
            length == 1 ? 'a11yFoundTab' : 'a11yFoundTabs', length);
      }
      return text;
    }
  
    /**
     * @param {!Event} e
     * @private
     */
    onItemClick_(e) {
      const tabData = /** @type {!TabData} */ (e.model.item);
      this.tabItemAction_(
          tabData.type, tabData.tab.tabId, /** @type {number} */ (e.model.index));
    }
  
    /**
     * Trigger the click/press action associated with the given Tab item type for
     * the given Tab Id.
     * @param {!TabItemType} type
     * @param {number} tabId
     * @param {number} tabIndex
     * @private
     */
    tabItemAction_(type, tabId, tabIndex) {
      if (type === TabItemType.OPEN) {
        this.apiProxy_.switchToTab({tabId}, !!this.searchText_, tabIndex);
      } else {
        this.apiProxy_.openRecentlyClosedTab(tabId);
      }
    }
  
    /**
     * @param {!Event} e
     * @private
     */
    onItemClose_(e) {
      performance.mark('close_tab:benchmark_begin');
      const tabId = e.model.item.tab.tabId;
      this.apiProxy_.closeTab(
          tabId, !!this.searchText_,
          /** @type {number} */ (e.model.index));
      this.announceA11y_(loadTimeData.getString('a11yTabClosed'));
      listenOnce(this.$.tabsList, 'iron-items-changed', () => {
        performance.mark('close_tab:benchmark_end');
      });
    }
  
    /**
     * @param {!Event} e
     * @private
     */
    onItemKeyDown_(e) {
      if (e.key !== 'Enter' && e.key !== ' ') {
        return;
      }
  
      e.stopPropagation();
      e.preventDefault();
  
      const tabData = /** @type {!TabData} */ (e.model.item);
      this.tabItemAction_(
          tabData.type, tabData.tab.tabId, /** @type {number} */ (e.model.index));
    }
  
    /**
     * @param {!Array<!Window>} newOpenWindows
     * @param {!Array<!Tab>} recentlyClosedTabs
     * @param {!Array<!TabGroup>} tabGroups
     * @private
     */
    tabsChanged_(newOpenWindows, recentlyClosedTabs, tabGroups) {
      this.tabGroupsMap_ = tabGroups.reduce((map, tabGroup) => {
        map.set(tokenToString(tabGroup.id), tabGroup);
        return map;
      }, new Map());
      this.openTabs_ = newOpenWindows.reduce(
          (acc, {active, tabs}) => acc.concat(tabs.map(
              tab => this.tabData_(
                  tab, active, TabItemType.OPEN, this.tabGroupsMap_))),
          []);
      this.recentlyClosedTabs_ = recentlyClosedTabs.map(
          tab => this.tabData_(
              tab, false, TabItemType.RECENTLY_CLOSED, this.tabGroupsMap_));
      this.updateFilteredTabs_(this.openTabs_, this.recentlyClosedTabs_);
  
      // If there was no previously selected index, set the first item as
      // selected; else retain the currently selected index. If the list
      // shrunk above the selected index, select the last index in the list.
      // If there are no matching results, set the selected index value to none.
      const tabsList = /** @type {!InfiniteList} */ (this.$.tabsList);
      tabsList.selected = Math.min(
          Math.max(this.getSelectedIndex(), 0),
          this.filteredOpenTabs_.length - 1);
    }
  
    /**
     * @param {!Event} e
     * @private
     */
    onItemFocus_(e) {
      // Ensure that when a TabSearchItem receives focus, it becomes the selected
      // item in the list.
      /** @type {!InfiniteList} */ (this.$.tabsList).selected =
          /** @type {number} */ (e.model.index);
    }
  
    /** @private */
    onSearchFocus_() {
      const tabsList = /** @type {!InfiniteList} */ (this.$.tabsList);
      if (tabsList.selected === NO_SELECTION &&
          this.filteredOpenTabs_.length > 0) {
        tabsList.selected = 0;
      }
    }
  
    /**
     * @param {!KeyboardEvent} e
     * @private
     */
    onKeyDown_(e) {
      if (e.key === 'Escape') {
        e.stopPropagation();
        e.preventDefault();
        this.apiProxy_.closeUI();
      }
    }
  
    /**
     * Handles key events when the search field has focus.
     * @param {!KeyboardEvent} e
     * @private
     */
    onSearchKeyDown_(e) {
      // In the event the search field has focus and the first item in the list is
      // selected and we receive a Shift+Tab navigation event, ensure All DOM
      // items are available so that the focus can transfer to the last item in
      // the list.
      if (e.shiftKey && e.key === 'Tab' &&
          /** @type {!InfiniteList} */ (this.$.tabsList).selected === 0) {
        /** @type {!InfiniteList} */ (this.$.tabsList)
            .ensureAllDomItemsAvailable();
        return;
      }
  
      // Do not interfere with the search field's management of text selection
      // that relies on the Shift key.
      if (e.shiftKey) {
        return;
      }
  
      if (this.getSelectedIndex() === -1) {
        // No tabs matching the search text criteria.
        return;
      }
  
      if (selectorNavigationKeys.includes(e.key)) {
        /** @type {!InfiniteList} */ (this.$.tabsList).navigate(e.key);
  
        e.stopPropagation();
        e.preventDefault();
  
        // TODO(tluk): Fix this to use aria-activedescendant when it's updated to
        // work with ShadowDOM elements.
        this.$.searchField.announce(ariaLabel(this.$.tabsList.selectedItem));
      } else if (e.key === 'Enter') {
        const tabData = /** @type {!TabData} */ (this.$.tabsList.selectedItem);
        this.tabItemAction_(
            tabData.type, tabData.tab.tabId, this.getSelectedIndex());
        e.stopPropagation();
      }
    }
  
    /** @param {string} text */
    announceA11y_(text) {
      IronA11yAnnouncer.requestAvailability();
      this.dispatchEvent(new CustomEvent(
          'iron-announce', {bubbles: true, composed: true, detail: {text}}));
    }
  
    /**
     * @param {!TabData} tabData
     * @return {string}
     * @private
     */
    ariaLabel_(tabData) {
      return ariaLabel(tabData);
    }
  
    /**
     * @param {!Array<!TabData>} filteredOpenTabs
     * @param {!Array<!TabData>} filteredRecentlyClosedTabs
     * @return {number}
     * @private
     */
    filteredTabItemsCount_(filteredOpenTabs, filteredRecentlyClosedTabs) {
      return filteredOpenTabs.length + filteredRecentlyClosedTabs.length;
    }
  
    /**
     * @param {!Array<!TabData>} filteredOpenTabs
     * @param {!Array<!TabData>} filteredRecentlyClosedTabs
     * @return {!Array<!TabData|!TitleItem>}
     * @private
     */
    listItems_(filteredOpenTabs, filteredRecentlyClosedTabs) {
      const items = [];
      if (filteredOpenTabs.length !== 0) {
        items.push(this.openTabsTitleItem_, ...filteredOpenTabs);
      }
  
      if (filteredRecentlyClosedTabs.length !== 0) {
        items.push(
            this.recentlyClosedTabsTitleItem_, ...filteredRecentlyClosedTabs);
      }
  
      return items;
    }
  
    /**
     * @param {!Tab} tab
     * @param {boolean} inActiveWindow
     * @param {!TabItemType} type
     * @param {!Map<string, !TabGroup>} tabGroupsMap
     * @return {!TabData}
     * @private
     */
    tabData_(tab, inActiveWindow, type, tabGroupsMap) {
      const tabData = new TabData();
      try {
        tabData.hostname = new URL(tab.url).hostname;
      } catch (e) {
        // TODO(crbug.com/1186409): Remove this after we root cause the issue
        console.error(`Error parsing URL on Tab Search: url=${tab.url}`);
        tabData.hostname = '';
      }
      tabData.inActiveWindow = inActiveWindow;
      tabData.tab = tab;
  
      if (tab.groupId) {
        tabData.tabGroup = tabGroupsMap.get(tokenToString(tab.groupId));
      }
  
      tabData.type = type;
      tabData.a11yTypeText = loadTimeData.getStringF(
          type === TabItemType.OPEN ? 'a11yOpenTab' : 'a11yRecentlyClosedTab');
      return tabData;
    }
  
    /**
     * @param {!Array<!TabData>} openTabs
     * @param {!Array<!TabData>} recentlyClosedTabs
     * @private
     */
    updateFilteredTabs_(openTabs, recentlyClosedTabs) {
      openTabs.sort((a, b) => {
        // Move the active tab to the bottom of the list
        // because it's not likely users want to click on it.
        if (this.moveActiveTabToBottom_) {
          if (a.inActiveWindow && a.tab.active) {
            return 1;
          }
          if (b.inActiveWindow && b.tab.active) {
            return -1;
          }
        }
        return (b.tab.lastActiveTimeTicks && a.tab.lastActiveTimeTicks) ?
            Number(
                b.tab.lastActiveTimeTicks.internalValue -
                a.tab.lastActiveTimeTicks.internalValue) :
            0;
      });
  
      this.filteredOpenTabs_ =
          fuzzySearch(this.searchText_, openTabs, this.fuzzySearchOptions_);
      const filteredRecentlyClosedTabs = fuzzySearch(
          this.searchText_, recentlyClosedTabs, this.fuzzySearchOptions_);
      this.filteredRecentlyClosedTabs_ = this.searchText_.length ?
          filteredRecentlyClosedTabs :
          filteredRecentlyClosedTabs.slice(
              0, this.recentlyClosedDefaultItemDisplayCount_);
      this.searchResultText_ = this.getA11ySearchResultText_();
    }
  
    /** @return {!Tab} */
    getSelectedTab_() {
      return this.filteredOpenTabs_[this.getSelectedIndex()].tab;
    }
  
    /** @return {string} */
    getSearchTextForTesting() {
      return this.searchText_;
    }
  }
  
  customElements.define(TabSearchAppElement.is, TabSearchAppElement);
  