import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import KeyboardNavigatable from '../utils/KeyboardNavigatable';
import icon from '../../common/helpers/icon';
import DiscussionsSearchSource from './DiscussionsSearchSource';
import UsersSearchSource from './UsersSearchSource';

/**
 * The `Search` component displays a menu of as-you-type results from a variety
 * of sources.
 *
 * The search box will be 'activated' if the app's current controller implements
 * a `searching` method that returns a truthy value. If this is the case, an 'x'
 * button will be shown next to the search field, and clicking it will call the
 * `clearSearch` method on the controller.
 */
export default class Search extends Component {
  init() {
    /**
     * The value of the search input.
     *
     * @type {Function}
     */
    this.value = m.prop('');

    /**
     * Whether or not the search input has focus.
     *
     * @type {Boolean}
     */
    this.hasFocus = false;

    /**
     * An array of SearchSources.
     *
     * @type {SearchSource[]}
     */
    this.sources = null;

    /**
     * The number of sources that are still loading results.
     *
     * @type {Integer}
     */
    this.loadingSources = 0;

    /**
     * A list of queries that have been searched for.
     *
     * @type {Array}
     */
    this.searched = [];

    /**
     * The index of the currently-selected <li> in the results list. This can be
     * a unique string (to account for the fact that an item's position may jump
     * around as new results load), but otherwise it will be numeric (the
     * sequential position within the list).
     *
     * @type {String|Integer}
     */
    this.index = 0;
  }

  view() {
    const currentSearch = this.getCurrentSearch();

    // Initialize search input value in the view rather than the constructor so
    // that we have access to app.current.
    if (typeof this.value() === 'undefined') {
      this.value(currentSearch || '');
    }

    // Initialize search sources in the view rather than the constructor so
    // that we have access to app.forum.
    if (!this.sources) {
      this.sources = this.sourceItems().toArray();
    }

    // Hide the search view if no sources were loaded
    if (!this.sources.length) return <div></div>;

    return (
      <div className={'Search ' + classList({
        open: this.value() && this.hasFocus,
        focused: this.hasFocus,
        active: !!currentSearch,
        loading: !!this.loadingSources
      })}>
        <div className="Search-input">
          <input className="FormControl"
            type="search"
            placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
            value={this.value()}
            oninput={m.withAttr('value', this.value)}
            onfocus={() => this.hasFocus = true}
            onblur={() => this.hasFocus = false}/>
          {this.loadingSources
            ? LoadingIndicator.component({size: 'tiny', className: 'Button Button--icon Button--link'})
            : currentSearch
              ? <button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>{icon('fas fa-times-circle')}</button>
              : ''}
        </div>
        <ul className="Dropdown-menu Search-results">
          {this.value() && this.hasFocus
            ? this.sources.map(source => source.view(this.value()))
            : ''}
        </ul>
      </div>
    );
  }

  config(isInitialized) {
    // Highlight the item that is currently selected.
    this.setIndex(this.getCurrentNumericIndex());

    if (isInitialized) return;

    const search = this;

    this.$('.Search-results')
      .on('mousedown', e => e.preventDefault())
      .on('click', () => this.$('input').blur())

      // Whenever the mouse is hovered over a search result, highlight it.
      .on('mouseenter', '> li:not(.Dropdown-header)', function() {
        search.setIndex(
          search.selectableItems().index(this)
        );
      });

    const $input = this.$('input');

    this.navigator = new KeyboardNavigatable();
    this.navigator
      .onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
      .onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
      .onSelect(this.selectResult.bind(this))
      .onCancel(this.clear.bind(this))
      .bindTo($input);

    // Handle input key events on the search input, triggering results to load.
    $input
      .on('input focus', function() {
        const query = this.value.toLowerCase();

        if (!query) return;

        clearTimeout(search.searchTimeout);
        search.searchTimeout = setTimeout(() => {
          if (search.searched.indexOf(query) !== -1) return;

          if (query.length >= 3) {
            search.sources.map(source => {
              if (!source.search) return;

              search.loadingSources++;

              source.search(query).then(() => {
                search.loadingSources = Math.max(0, search.loadingSources - 1);
                m.redraw();
              });
            });
          }

          search.searched.push(query);
          m.redraw();
        }, 250);
      })

      .on('focus', function() {
        $(this).one('mouseup', e => e.preventDefault()).select();
      });
  }

  /**
   * Get the active search in the app's current controller.
   *
   * @return {String}
   */
  getCurrentSearch() {
    return app.current && typeof app.current.searching === 'function' && app.current.searching();
  }

  /**
   * Navigate to the currently selected search result and close the list.
   */
  selectResult() {
    clearTimeout(this.searchTimeout);
    this.loadingSources = 0;

    if (this.value()) {
      m.route(this.getItem(this.index).find('a').attr('href'));
    } else {
      this.clear();
    }

    this.$('input').blur();
  }

  /**
   * Clear the search input and the current controller's active search.
   */
  clear() {
    this.value('');

    if (this.getCurrentSearch()) {
      app.current.clearSearch();
    } else {
      m.redraw();
    }
  }

  /**
   * Build an item list of SearchSources.
   *
   * @return {ItemList}
   */
  sourceItems() {
    const items = new ItemList();

    if (app.forum.attribute('canViewDiscussions')) items.add('discussions', new DiscussionsSearchSource());
    if (app.forum.attribute('canViewUserList')) items.add('users', new UsersSearchSource());

    return items;
  }

  /**
   * Get all of the search result items that are selectable.
   *
   * @return {jQuery}
   */
  selectableItems() {
    return this.$('.Search-results > li:not(.Dropdown-header)');
  }

  /**
   * Get the position of the currently selected search result item.
   *
   * @return {Integer}
   */
  getCurrentNumericIndex() {
    return this.selectableItems().index(
      this.getItem(this.index)
    );
  }

  /**
   * Get the <li> in the search results with the given index (numeric or named).
   *
   * @param {String} index
   * @return {DOMElement}
   */
  getItem(index) {
    const $items = this.selectableItems();
    let $item = $items.filter(`[data-index="${index}"]`);

    if (!$item.length) {
      $item = $items.eq(index);
    }

    return $item;
  }

  /**
   * Set the currently-selected search result item to the one with the given
   * index.
   *
   * @param {Integer} index
   * @param {Boolean} scrollToItem Whether or not to scroll the dropdown so that
   *     the item is in view.
   */
  setIndex(index, scrollToItem) {
    const $items = this.selectableItems();
    const $dropdown = $items.parent();

    let fixedIndex = index;
    if (index < 0) {
      fixedIndex = $items.length - 1;
    } else if (index >= $items.length) {
      fixedIndex = 0;
    }

    const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');

    this.index = $item.attr('data-index') || fixedIndex;

    if (scrollToItem) {
      const dropdownScroll = $dropdown.scrollTop();
      const dropdownTop = $dropdown.offset().top;
      const dropdownBottom = dropdownTop + $dropdown.outerHeight();
      const itemTop = $item.offset().top;
      const itemBottom = itemTop + $item.outerHeight();

      let scrollTop;
      if (itemTop < dropdownTop) {
        scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
      } else if (itemBottom > dropdownBottom) {
        scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
      }

      if (typeof scrollTop !== 'undefined') {
        $dropdown.stop(true).animate({scrollTop}, 100);
      }
    }
  }
}
