import 'styling/_Result';
import 'styling/_ResultFrame';
import 'styling/_ResultList';
import { compact, contains, each, flatten, map, without, uniqueId, find, unique, chain } from 'underscore';
import {
  IBuildingQueryEventArgs,
  IDuringQueryEventArgs,
  IFetchMoreSuccessEventArgs,
  INewQueryEventArgs,
  IQueryErrorEventArgs,
  IQuerySuccessEventArgs,
  QueryEvents
} from '../../events/QueryEvents';
import { IResultLayoutPopulateArgs, ResultLayoutEvents } from '../../events/ResultLayoutEvents';
import {
  IChangeLayoutEventArgs,
  IDisplayedNewResultEventArgs,
  IDisplayedNewResultsEventArgs,
  ResultListEvents
} from '../../events/ResultListEvents';
import { exportGlobally } from '../../GlobalExports';
import { Assert } from '../../misc/Assert';
import { Defer } from '../../misc/Defer';
import { MODEL_EVENTS } from '../../models/Model';
import { QUERY_STATE_ATTRIBUTES } from '../../models/QueryStateModel';
import { IQueryResult } from '../../rest/QueryResult';
import { IQueryResults } from '../../rest/QueryResults';
import { DeviceUtils } from '../../utils/DeviceUtils';
import { $$, Doc, Win } from '../../utils/Dom';
import { DomUtils } from '../../utils/DomUtils';
import { Utils } from '../../utils/Utils';
import { analyticsActionCauseList, IAnalyticsNoMeta } from '../Analytics/AnalyticsActionListMeta';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { IInitializationParameters, IInitResult, Initialization } from '../Base/Initialization';
import { InitializationPlaceholder } from '../Base/InitializationPlaceholder';
import { TemplateComponentOptions } from '../Base/TemplateComponentOptions';
import { ResponsiveDefaultResultTemplate } from '../ResponsiveComponents/ResponsiveDefaultResultTemplate';
import { ValidLayout, RendererValidLayout } from '../ResultLayoutSelector/ValidLayout';
import { CoreHelpers } from '../Templates/CoreHelpers';
import { DefaultRecommendationTemplate } from '../Templates/DefaultRecommendationTemplate';
import { DefaultResultTemplate } from '../Templates/DefaultResultTemplate';
import { TableTemplate } from '../Templates/TableTemplate';
import { Template } from '../Templates/Template';
import { TemplateCache } from '../Templates/TemplateCache';
import { TemplateList } from '../Templates/TemplateList';
import { ResultContainer } from './ResultContainer';
import { ResultListCardRenderer } from './ResultListCardRenderer';
import { ResultListRenderer } from './ResultListRenderer';
import { ResultListTableRenderer } from './ResultListTableRenderer';
import ResultLayoutSelectorModule = require('../ResultLayoutSelector/ResultLayoutSelector');
import { IResultListOptions } from './ResultListOptions';
import { ResultListUtils } from '../../utils/ResultListUtils';
import { TemplateToHtml, ITemplateToHtml } from '../Templates/TemplateToHtml';
import { IQuery } from '../../rest/Query';

CoreHelpers.exportAllHelpersGlobally(window['Coveo']);

type WaitAnimation = 'fade' | 'spinner' | 'none';

/**
 * The `ResultList` component is responsible for displaying query results by applying one or several result templates
 * (see [Result Templates](https://docs.coveo.com/en/413/)).
 *
 * It is possible to include multiple `ResultList` components along with a single `ResultLayout`
 * component in a search page to provide different result layouts (see
 * [Result Layouts](https://docs.coveo.com/en/360/)).
 *
 * This component supports infinite scrolling (see the
 * [`enableInfiniteScroll`]{@link ResultList.options.enableInfiniteScroll} option).
 */
export class ResultList extends Component {
  private static getDefaultTemplate(e: HTMLElement): Template {
    const template = ResultList.loadTemplatesFromCache();
    if (template != null) {
      return template;
    }

    const component = <ResultList>Component.get(e);
    if (Coveo['Recommendation'] && component.searchInterface instanceof Coveo['Recommendation']) {
      return new DefaultRecommendationTemplate();
    }
    return new DefaultResultTemplate();
  }

  private static loadTemplatesFromCache(): Template {
    var pageTemplateNames = TemplateCache.getResultListTemplateNames();
    if (pageTemplateNames.length > 0) {
      return new TemplateList(compact(map(pageTemplateNames, templateName => TemplateCache.getTemplate(templateName))));
    }

    return null;
  }

  static ID = 'ResultList';

  static doExport = () => {
    exportGlobally({
      ResultList: ResultList
    });
  };

  /**
   * The options for the ResultList
   * @componentOptions
   */
  static options: IResultListOptions = {
    /**
     * The element inside which to insert the rendered result templates.
     *
     * Performing a new query clears the content of this element.
     *
     * You can change the container by specifying its selector (e.g.,
     * `data-result-container-selector='#someCssSelector'`).
     *
     * If you specify no value for this option, a `div` element will be dynamically created and appended to the result
     * list. This element will then be used as a result container.
     */
    resultsContainer: ComponentOptions.buildChildHtmlElementOption({ alias: 'resultContainerSelector' }),
    resultTemplate: TemplateComponentOptions.buildTemplateOption({ defaultFunction: ResultList.getDefaultTemplate }),

    /**
     * The type of animation to display while waiting for a query to return.
     *
     * The possible values are:
     * - `fade`: Fades out the current list of results while the query is executing.
     * - `spinner`: Shows a spinning animation while the query is executing.
     * - `none`: Use no animation during queries.
     *
     * See also the [`waitAnimationContainer`]{@link ResultList.options.waitAnimationContainer} option.
     *
     * @examples spinner
     */
    waitAnimation: ComponentOptions.buildStringOption<WaitAnimation>({ defaultValue: 'none' }),

    /**
     * The element inside which to display the [`waitAnimation`]{@link ResultList.options.waitAnimation}.
     *
     * You can change this by specifying a CSS selector (e.g.,
     * `data-wait-animation-container-selector='#someCssSelector'`).
     *
     * Defaults to the value of the [`resultsContainer`]{@link ResultList.options.resultsContainer} option.
     */
    waitAnimationContainer: ComponentOptions.buildChildHtmlElementOption({
      postProcessing: (value, options: IResultListOptions) => value || options.resultsContainer
    }),

    /**
     * Whether to automatically retrieve an additional page of results and append it to the
     * results that the `ResultList` is currently displaying when the user scrolls down to the bottom of the
     * [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer}.
     *
     * See also the [`infiniteScrollPageSize`]{@link ResultList.options.infiniteScrollPageSize} and
     * [`enableInfiniteScrollWaitingAnimation`]{@link ResultList.options.enableInfiniteScrollWaitingAnimation} options.
     *
     * It is important to specify the `infiniteScrollContainer` option manually if you want the scrolling element to be
     * something else than the default `window` element. Otherwise, you might find yourself in a strange state where the
     * framework rapidly triggers multiple successive query.
     */
    enableInfiniteScroll: ComponentOptions.buildBooleanOption({ defaultValue: false }),

    /**
     * The number of additional results to fetch when the user scrolls down to the bottom of the
     * [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer}.
     *
     * @examples 5
     */
    infiniteScrollPageSize: ComponentOptions.buildNumberOption({
      defaultValue: 10,
      min: 1,
      depend: 'enableInfiniteScroll'
    }),

    /**
     * The element that triggers fetching additional results when the end user scrolls down to its bottom.
     *
     * You can change the container by specifying its selector (e.g.,
     * `data-infinite-scroll-container-selector='#someCssSelector'`).
     *
     * By default, the framework uses the first vertically scrollable parent element it finds, starting from the
     * `ResultList` element itself. A vertically scrollable element is an element whose CSS `overflow-y` attribute is
     * `scroll`.
     *
     * This implies that if the framework can find no scrollable parent, it uses the `window` itself as a scrollable
     * container.
     *
     * This heuristic is not perfect, for technical reasons. There are always some corner case CSS combination which the
     * framework will not be able to correctly detect as 'scrollable'.
     *
     * It is highly recommended that you manually set this option if you wish something else than the `window` to be the
     * scrollable element.
     */
    infiniteScrollContainer: ComponentOptions.buildChildHtmlElementOption({
      depend: 'enableInfiniteScroll',
      defaultFunction: element => ComponentOptions.findParentScrolling(element)
    }),

    /**
     * Whether to display the [`waitingAnimation`]{@link ResultList.options.waitAnimation} while fetching additional
     * results.
     */
    enableInfiniteScrollWaitingAnimation: ComponentOptions.buildBooleanOption({
      depend: 'enableInfiniteScroll',
      defaultValue: true
    }),
    mobileScrollContainer: ComponentOptions.buildSelectorOption({
      defaultFunction: () => <HTMLElement>document.querySelector('.coveo-results-column')
    }),

    /**
     * Whether the `ResultList` should scan its result templates to discover which fields it must request to
     * be able to render all results.
     *
     * Setting this option to `true` ensures that the Coveo Search API does not return fields that are unnecessary for
     * the UI to function.
     *
     * If you set this option to `true`, the fields referenced in your result folding templates won't be automatically resolved.
     * In such a case, you must manually specify those fields using the [`fieldsToInclude`]{@link ResultList.options.fieldsToInclude} option.
     *
     * **Notes:**
     *
     * - Many interfaces created with the JavaScript Search Interface Editor explicitly set this option to `true`.
     * - You cannot set this option to `true` in the Coveo for Sitecore integration.
     */
    autoSelectFieldsToInclude: ComponentOptions.buildBooleanOption({ defaultValue: false }),

    /**
     * A list of fields to include in the query results.
     *
     * If you set the [`autoSelectFieldsToInclude`]{@link ResultList.options.autoSelectFieldsToInclude} option to
     * `true`, the Coveo Search API returns the fields you specify for this option (if those fields are available) in
     * addition to the fields which the `ResultList` automatically requests. Note that the `autoSelectFieldsToInclude`
     * option doesn't automatically request fields for folding templates; in such a case, you must manually specify
     * fields using this option to avoid empty results.
     *
     * If you set the [`autoSelectFieldsToInclude`]{@link ResultList.options.autoSelectFieldsToInclude} option to
     * `false`, the Coveo Search API only returns the fields you specify for this option (if those fields are
     * available).
     *
     * If you set the [`autoSelectFieldsToInclude`]{@link ResultList.options.autoSelectFieldsToInclude} option to
     * `false` and leave this option undefined, the Coveo Search API returns all available fields.
     */
    fieldsToInclude: ComponentOptions.buildFieldsOption({ includeInResults: true }),

    /**
     * Specifies the layout to use when displaying results in this `ResultList` (see
     * [Result Layouts](https://docs.coveo.com/en/360/)). Specifying a value for this option automatically
     * populates a [`ResultLayout`]{@link ResultLayout} component with a switcher for the layout.
     *
     * For example, if there are two `ResultList` components in the page, one with its `layout` set to `list` and the
     * other with the same option set to `card`, then the `ResultLayout` component will render two buttons respectively
     * entitled **List** and **Card**.
     *
     * See the [`ValidLayout`]{@link ValidLayout} type for the list of possible values.
     *
     * @examples card
     */
    layout: ComponentOptions.buildStringOption<ValidLayout>({
      defaultValue: 'list',
      required: true
    }),

    /**
     * Whether to scroll back to the top of the page when the end-user:
     *  - interacts with a facet when [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer} is set to `true`
     *  - interacts with the [`Pager`]{@link Pager} component when [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer} is set to `false`
     *
     * **Note:** Setting this option to `false` has no effect on dynamic facets. To disable this behavior on a `DynamicFacet` component, you must set its own [`enableScrollToTop`]{@link DynamicFacet.options.enableScrollToTop} option to `false`.
     *
     * @availablesince [July 2019 Release (v2.6459)](https://docs.coveo.com/en/2938/)
     */
    enableScrollToTop: ComponentOptions.buildBooleanOption({
      defaultValue: true
    })
  };

  public static resultCurrentlyBeingRendered: IQueryResult = null;
  public currentlyDisplayedResults: IQueryResult[] = [];
  private fetchingMoreResults: Promise<IQueryResults>;
  private reachedTheEndOfResults = false;
  private disableLayoutChange = false;
  private renderer: ResultListRenderer;
  private resultContainer: ResultContainer;

  // This variable serves to block some setup where the framework fails to correctly identify the "real" scrolling container.
  // Since it's not technically feasible to correctly identify the scrolling container in every possible scenario without some very complex logic, we instead try to add some kind of mechanism to
  // block runaway requests where UI will keep asking more results in the index, eventually bringing the browser to it's knee.
  // Those successive request are needed in "displayMoreResults" to ensure we fill the scrolling container correctly.
  // Since the container is not identified correctly, it is never "full", so we keep asking for more.
  // It is reset every time the user actually scroll the container manually.
  private successiveScrollCount = 0;
  private static MAX_AMOUNT_OF_SUCESSIVE_REQUESTS = 5;

  /**
   * Creates a new `ResultList` component. Binds various event related to queries (e.g., on querySuccess ->
   * renderResults). Binds scroll event if the [`enableInfiniteScroll`]{@link ResultList.options.enableInfiniteScroll}
   * option is `true`.
   * @param element The HTMLElement on which to instantiate the component.
   * @param options The options for the `ResultList` component.
   * @param bindings The bindings that the component requires to function normally. If not set, these will be
   * automatically resolved (with a slower execution time).
   * @param elementClassId The class that this component should instantiate. Components that extend the base ResultList
   * use this. Default value is `CoveoResultList`.
   */
  constructor(
    public element: HTMLElement,
    public options?: IResultListOptions,
    public bindings?: IComponentBindings,
    elementClassId: string = ResultList.ID
  ) {
    super(element, elementClassId, bindings);
    this.options = ComponentOptions.initComponentOptions(element, ResultList, options);

    Assert.exists(element);
    Assert.exists(this.options);
    Assert.exists(this.options.resultTemplate);
    Assert.exists(this.options.infiniteScrollContainer);

    this.showOrHideElementsDependingOnState(false, false);

    this.addListeners();

    this.resultContainer = this.initResultContainer();
    Assert.exists(this.options.resultsContainer);

    this.initWaitAnimationContainer();
    Assert.exists(this.options.waitAnimationContainer);

    this.setupTemplatesVersusLayouts();
    $$(this.root).on(ResultLayoutEvents.populateResultLayout, (e, args: IResultLayoutPopulateArgs) =>
      args.layouts.push(this.options.layout)
    );
    this.setupRenderer();
    this.makeElementFocusable();
    this.ensureHasId();
  }

  private addListeners() {
    this.bind.onRootElement<INewQueryEventArgs>(QueryEvents.newQuery, (args: INewQueryEventArgs) => this.handleNewQuery());
    this.bind.onRootElement<IBuildingQueryEventArgs>(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) =>
      this.handleBuildingQuery(args)
    );
    this.bind.onRootElement<IQuerySuccessEventArgs>(QueryEvents.querySuccess, (args: IQuerySuccessEventArgs) =>
      this.handleQuerySuccess(args)
    );
    this.bind.onRootElement<IFetchMoreSuccessEventArgs>(QueryEvents.fetchMoreSuccess, (args: IFetchMoreSuccessEventArgs) =>
      this.handleFetchMoreSuccess(args)
    );
    this.bind.onRootElement<IDuringQueryEventArgs>(QueryEvents.duringQuery, (args: IDuringQueryEventArgs) => this.handleDuringQuery());
    this.bind.onRootElement<IQueryErrorEventArgs>(QueryEvents.queryError, (args: IQueryErrorEventArgs) => this.handleQueryError());
    $$(this.root).on(ResultListEvents.changeLayout, (e: Event, args: IChangeLayoutEventArgs) => this.handleChangeLayout(args));

    if (this.options.enableInfiniteScroll) {
      this.addInfiniteScrollListeners();
    } else {
      this.addPagerListeners();
    }
  }

  private addInfiniteScrollListeners() {
    this.bind.on(<HTMLElement>this.options.infiniteScrollContainer, 'scroll', () => {
      this.successiveScrollCount = 0;
      this.handleScrollOfResultList();
    });
    this.bind.onRootElement(QueryEvents.deferredQuerySuccess, () => {
      setTimeout(() => {
        this.scrollToTopIfEnabled();
      }, 0);
    });
  }

  private addPagerListeners() {
    this.bind.onQueryState(MODEL_EVENTS.CHANGE_ONE, QUERY_STATE_ATTRIBUTES.FIRST, () => {
      this.bind.oneRootElement(QueryEvents.deferredQuerySuccess, () => {
        setTimeout(() => {
          this.scrollToTopIfEnabled();
        }, 0);
      });
    });
  }

  /**
   * Get the fields needed to be automatically included in the query for this result list.
   * @returns {string[]}
   */
  public getAutoSelectedFieldsToInclude(): string[] {
    return chain(this.options.resultTemplate.getFields()).concat(this.getMinimalFieldsToInclude()).compact().unique().value();
  }

  private setupTemplatesVersusLayouts() {
    const layoutClassToAdd = `coveo-${this.options.layout}-layout-container`;
    this.resultContainer.addClass(layoutClassToAdd);

    if (this.options.layout === 'table') {
      this.options.resultTemplate = new TableTemplate((<TemplateList>this.options.resultTemplate).templates || []);
    }

    // A TemplateList is the scenario where the result template are directly embedded inside the ResultList
    // This is the typical scenario when a page gets created by the interface editor, for example.
    // In that case, we try to stick closely that what is actually configured inside the page, and do no "special magic".
    // Stick to the "hardcoded" configuration present in the page.
    // We only add the correct layout options if it has not been set manually.
    if (this.options.resultTemplate instanceof TemplateList) {
      each((<TemplateList>this.options.resultTemplate).templates, (tmpl: Template) => {
        if (!tmpl.layout) {
          tmpl.layout = <ValidLayout>this.options.layout;
        }
      });
    } else if (this.options.resultTemplate instanceof DefaultResultTemplate && this.options.layout == 'list') {
      ResponsiveDefaultResultTemplate.init(this.root, this, {});
    }
  }

  /**
   * Empties the current result list content and appends the given array of HTMLElement.
   *
   * Can append to existing elements in the result list, or replace them.
   *
   * Triggers the `newResultsDisplayed` and `newResultDisplayed` events.
   * @param resultsElement
   * @param append
   */
  public renderResults(resultElements: HTMLElement[], append = false): Promise<void> {
    if (!append) {
      this.resultContainer.empty();
    }

    return this.renderer
      .renderResults(resultElements, append, this.triggerNewResultDisplayed.bind(this))
      .then(() => this.triggerNewResultsDisplayed());
  }

  /**
   * Builds and returns an array of HTMLElement with the given result set.
   * @param results the result set to build an array of HTMLElement from.
   */
  public buildResults(results: IQueryResults): Promise<HTMLElement[]> {
    const layout = <RendererValidLayout>this.options.layout;
    return this.templateToHtml.buildResults(results, layout, this.currentlyDisplayedResults);
  }

  /**
   * Builds and returns an HTMLElement for the given result.
   * @param result the result to build an HTMLElement from.
   * @returns {HTMLElement}
   */
  public buildResult(result: IQueryResult): Promise<HTMLElement> {
    const layout = <RendererValidLayout>this.options.layout;
    return this.templateToHtml.buildResult(result, layout, this.currentlyDisplayedResults);
  }

  /**
   * Executes a query to fetch new results. After the query returns, renders the new results.
   *
   * Asserts that there are more results to display by verifying whether the last query has returned as many results as
   * requested.
   *
   * Asserts that the `ResultList` is not currently fetching results.
   * @param count The number of results to fetch and display.
   */
  public displayMoreResults(count: number) {
    Assert.isLargerOrEqualsThan(1, count);

    if (this.isCurrentlyFetchingMoreResults()) {
      this.logger.warn("Ignoring request to display more results since we're already doing so");
      return;
    }
    if (!this.hasPotentiallyMoreResultsToDisplay()) {
      this.logger.warn("Ignoring request to display more results since we know there aren't more to display");
      return;
    }

    if (this.options.enableInfiniteScrollWaitingAnimation) {
      this.showWaitingAnimationForInfiniteScrolling();
    }

    return this.fetchAndRenderMoreResults(count);
  }

  public get templateToHtml() {
    const templateToHtmlArgs: ITemplateToHtml = {
      resultTemplate: this.options.resultTemplate,
      searchInterface: this.searchInterface,
      queryStateModel: this.queryStateModel
    };
    return new TemplateToHtml(templateToHtmlArgs);
  }

  /**
   * Gets the list of currently displayed result.
   * @returns {IQueryResult[]}
   */
  public getDisplayedResults(): IQueryResult[] {
    return this.currentlyDisplayedResults;
  }

  /**
   * Gets the list of currently displayed result HTMLElement.
   * @returns {HTMLElement[]}
   */
  public getDisplayedResultsElements(): HTMLElement[] {
    return this.resultContainer.getResultElements();
  }

  public enable(): void {
    this.disableLayoutChange = false;
    if (this.resultLayoutSelectors.length > 0) {
      this.enableBasedOnActiveLayouts();
    } else {
      super.enable();
      $$(this.element).removeClass('coveo-hidden');
    }
  }

  public disable(): void {
    super.disable();
    const otherLayoutsStillActive = map(this.otherResultLists, otherResultList => otherResultList.options.layout);
    if (!contains(otherLayoutsStillActive, this.options.layout) && !this.disableLayoutChange) {
      each(this.resultLayoutSelectors, resultLayoutSelector => {
        resultLayoutSelector.disableLayouts([this.options.layout] as ValidLayout[]);
      });
    }
    this.disableLayoutChange = false;
    $$(this.element).addClass('coveo-hidden');
  }

  protected autoCreateComponentsInsideResult(element: HTMLElement, result: IQueryResult): IInitResult {
    return this.templateToHtml.autoCreateComponentsInsideResult(element, result);
  }

  protected triggerNewResultDisplayed(result: IQueryResult, resultElement: HTMLElement) {
    const args: IDisplayedNewResultEventArgs = {
      result: result,
      item: resultElement
    };
    $$(this.element).trigger(ResultListEvents.newResultDisplayed, args);
  }

  protected triggerNewResultsDisplayed() {
    const args: IDisplayedNewResultsEventArgs = {
      isInfiniteScrollEnabled: this.options.enableInfiniteScroll
    };
    $$(this.element).trigger(ResultListEvents.newResultsDisplayed, args);
  }

  private async fetchAndRenderMoreResults(count: number): Promise<IQueryResults> {
    this.fetchingMoreResults = this.queryController.fetchMore(count);

    try {
      const data = await this.fetchingMoreResults;
      Assert.exists(data);
      this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>(analyticsActionCauseList.pagerScrolling, {}, this.element);

      this.renderNewResults(data);

      this.resetStateAfterFetchingMoreResults();

      return data;
    } catch (e) {
      this.resetStateAfterFetchingMoreResults();
      return Promise.reject(e);
    }
  }

  private async renderNewResults(data: IQueryResults) {
    const elements = await this.buildResults(data);
    this.renderResults(elements, true);
    this.currentlyDisplayedResults.push(...data.results);
  }

  private resetStateAfterFetchingMoreResults() {
    this.hideWaitingAnimationForInfiniteScrolling();
    this.fetchingMoreResults = undefined;
    Defer.defer(() => {
      this.successiveScrollCount++;
      if (this.successiveScrollCount <= ResultList.MAX_AMOUNT_OF_SUCESSIVE_REQUESTS) {
        this.handleScrollOfResultList();
      } else {
        this.logger.info(
          `Result list has triggered 5 consecutive queries to try and fill up the scrolling container, but it is still unable to do so.
          Try explicitly setting the 'data-infinite-scroll-container-selector' option on the result list. See: https://coveo.github.io/search-ui/components/resultlist.html#options.infinitescrollcontainer`
        );
      }
    });
  }

  private handleDuringQuery() {
    this.logger.trace('Emptying the result container');
    this.cancelFetchingMoreResultsIfNeeded();
    this.showWaitingAnimation();
    this.showOrHideElementsDependingOnState(false, false);
  }

  private handleQueryError() {
    this.hideWaitingAnimation();
    this.resultContainer.empty();
    this.currentlyDisplayedResults = [];
    this.reachedTheEndOfResults = true;
  }

  private handleQuerySuccess(data: IQuerySuccessEventArgs) {
    Assert.exists(data);
    Assert.exists(data.results);
    const results = data.results;
    this.logger.trace('Received query results from new query', results);
    this.hideWaitingAnimation();

    ResultList.resultCurrentlyBeingRendered = undefined;

    this.currentlyDisplayedResults = [];
    this.buildResults(data.results).then(async (elements: HTMLElement[]) => {
      await this.renderResults(elements);

      this.showOrHideElementsDependingOnState(true, this.currentlyDisplayedResults.length != 0);

      if (DeviceUtils.isMobileDevice() && this.options.mobileScrollContainer != undefined) {
        this.options.mobileScrollContainer.scrollTop = 0;
      }

      if (this.options.enableInfiniteScroll && results.results.length == data.queryBuilder.numberOfResults) {
        // This will check right away if we need to add more results to make the scroll container full & scrolling.
        this.scrollToTopIfEnabled();
        this.handleScrollOfResultList();
      }
    });
    this.handleQueryOrFetchMoreSuccess(data.query, results);
  }

  private handleFetchMoreSuccess(data: IFetchMoreSuccessEventArgs) {
    this.handleQueryOrFetchMoreSuccess(data.query, data.results);
  }

  private handleQueryOrFetchMoreSuccess(query: IQuery, results: IQueryResults) {
    const firstResultOfNextPage = (query.firstResult || 0) + results.results.length;
    this.reachedTheEndOfResults = firstResultOfNextPage >= results.totalCountFiltered;
  }

  private handleScrollOfResultList() {
    if (this.isCurrentlyFetchingMoreResults() || !this.options.enableInfiniteScroll) {
      return;
    }
    if (this.isScrollingOfResultListAlmostAtTheBottom() && this.hasPotentiallyMoreResultsToDisplay()) {
      this.displayMoreResults(this.options.infiniteScrollPageSize);
    }
  }

  private scrollToTopIfEnabled() {
    if (!this.options.enableScrollToTop) {
      return;
    }

    ResultListUtils.scrollToTop(this.root);
  }

  private handleNewQuery() {
    $$(this.element).removeClass('coveo-hidden');
    ResultList.resultCurrentlyBeingRendered = undefined;
  }

  private get otherResultLists() {
    const allResultLists = this.searchInterface.getComponents(ResultList.ID) as ResultList[];
    return without(allResultLists, this);
  }

  private get resultLayoutSelectors() {
    return this.searchInterface.getComponents('ResultLayoutSelector') as ResultLayoutSelectorModule.ResultLayoutSelector[];
  }

  protected handleBuildingQuery(args: IBuildingQueryEventArgs) {
    args.queryBuilder.addFieldsToInclude(this.getMinimalFieldsToInclude());
    if (this.options.fieldsToInclude != null) {
      // remove the @
      args.queryBuilder.addFieldsToInclude(map(this.options.fieldsToInclude, field => field.substr(1)));
    }
    if (this.options.autoSelectFieldsToInclude) {
      const otherFields = flatten(
        map(this.otherResultLists, otherResultList => {
          return otherResultList.getAutoSelectedFieldsToInclude();
        })
      );

      args.queryBuilder.addRequiredFields(unique(otherFields.concat(this.getAutoSelectedFieldsToInclude())));
      args.queryBuilder.includeRequiredFields = true;
    }
  }

  protected handleChangeLayout(args: IChangeLayoutEventArgs) {
    if (args.layout === this.options.layout) {
      this.disableLayoutChange = false;
      this.enable();
      this.options.resultTemplate.layout = <ValidLayout>this.options.layout;
      if (args.results) {
        // Prevent flickering when switching to a new layout that is empty
        // add a temporary placeholder, the same that is used on initialization
        if (this.resultContainer.isEmpty()) {
          new InitializationPlaceholder(this.root).withVisibleRootElement().withPlaceholderForResultList();
        }
        Defer.defer(async () => {
          const elements = await this.buildResults(args.results);
          this.renderResults(elements);
          this.showOrHideElementsDependingOnState(true, this.currentlyDisplayedResults.length !== 0);
        });
      }
    } else {
      this.disableLayoutChange = true;
      this.disable();
      this.resultContainer.empty();
    }
  }

  private isCurrentlyFetchingMoreResults(): boolean {
    return Utils.exists(this.fetchingMoreResults);
  }

  private getMinimalFieldsToInclude() {
    // these fields are needed for analytics click event
    return ['author', 'language', 'urihash', 'objecttype', 'collection', 'source', 'language', 'permanentid'];
  }

  private isScrollingOfResultListAlmostAtTheBottom(): boolean {
    // this is in a try catch because the unit test fail otherwise (Window does not exist for phantom js in the console)
    let isWindow;
    try {
      isWindow = this.options.infiniteScrollContainer instanceof Window;
    } catch (e) {
      isWindow = false;
    }
    return isWindow ? this.isScrollAtBottomForWindowElement() : this.isScrollAtBottomForHtmlElement();
  }

  private isScrollAtBottomForWindowElement() {
    const win = new Win(window);
    const windowHeight = win.height();
    const scrollTop = win.scrollY();
    const bodyHeight = new Doc(document).height();
    return bodyHeight - (windowHeight + scrollTop) < windowHeight / 2;
  }

  private isScrollAtBottomForHtmlElement() {
    const el = <HTMLElement>this.options.infiniteScrollContainer;
    const elementHeight = el.clientHeight;
    const scrollHeight = el.scrollHeight;
    const bottomPosition = el.scrollTop + elementHeight;
    return scrollHeight - bottomPosition < elementHeight / 2;
  }

  public hasPotentiallyMoreResultsToDisplay(): boolean {
    return this.currentlyDisplayedResults.length > 0 && !this.reachedTheEndOfResults;
  }

  private cancelFetchingMoreResultsIfNeeded() {
    if (this.isCurrentlyFetchingMoreResults()) {
      this.logger.trace('Cancelling fetching more results');
      Promise.reject(this.fetchingMoreResults);
      this.fetchingMoreResults = undefined;
    }
  }

  private showOrHideElementsDependingOnState(hasQuery: boolean, hasResults: boolean) {
    const showIfQuery = $$(this.element).findAll('.coveo-show-if-query');
    const showIfNoQuery = $$(this.element).findAll('.coveo-show-if-no-query');
    const showIfResults = $$(this.element).findAll('.coveo-show-if-results');
    const showIfNoResults = $$(this.element).findAll('.coveo-show-if-no-results');

    each(showIfQuery, (s: HTMLElement) => {
      $$(s).toggle(hasQuery);
    });
    each(showIfNoQuery, (s: HTMLElement) => {
      $$(s).toggle(!hasQuery);
    });
    each(showIfResults, (s: HTMLElement) => {
      $$(s).toggle(hasQuery && hasResults);
    });
    each(showIfNoResults, (s: HTMLElement) => {
      $$(s).toggle(hasQuery && !hasResults);
    });
  }

  private get waitAnimation() {
    return this.options.waitAnimation.toLowerCase() as WaitAnimation;
  }

  private showWaitingAnimation() {
    switch (this.waitAnimation) {
      case 'fade':
        $$(this.options.waitAnimationContainer).addClass('coveo-fade-out');
        break;
      case 'spinner':
        this.resultContainer.hideChildren();
        if ($$(this.options.waitAnimationContainer).find('.coveo-wait-animation') == undefined) {
          this.options.waitAnimationContainer.appendChild(DomUtils.getBasicLoadingAnimation());
        }
        break;
    }
  }

  private hideWaitingAnimation() {
    switch (this.waitAnimation) {
      case 'fade':
        $$(this.options.waitAnimationContainer).removeClass('coveo-fade-out');
        break;
      case 'spinner':
        const spinner = $$(this.options.waitAnimationContainer).find('.coveo-loading-spinner');
        if (spinner) {
          $$(spinner).detach();
        }
        break;
    }
  }

  private showWaitingAnimationForInfiniteScrolling() {
    const spinner = DomUtils.getLoadingSpinner();
    if (this.options.layout == 'card' && this.options.enableInfiniteScroll) {
      const previousSpinnerContainer = $$(this.options.waitAnimationContainer).findAll('.coveo-loading-spinner-container');
      each(previousSpinnerContainer, previousSpinner => $$(previousSpinner).remove());
      const spinnerContainer = $$('div', {
        className: 'coveo-loading-spinner-container'
      });
      spinnerContainer.append(spinner);
      this.options.waitAnimationContainer.appendChild(spinnerContainer.el);
    } else {
      this.options.waitAnimationContainer.appendChild(DomUtils.getLoadingSpinner());
    }
  }

  private hideWaitingAnimationForInfiniteScrolling() {
    const spinners = $$(this.options.waitAnimationContainer).findAll('.coveo-loading-spinner');
    const containers = $$(this.options.waitAnimationContainer).findAll('.coveo-loading-spinner-container');
    each(spinners, spinner => $$(spinner).remove());
    each(containers, container => $$(container).remove());
  }

  private initResultContainer(): ResultContainer {
    if (!this.options.resultsContainer) {
      const elemType = this.options.layout === 'table' ? 'table' : 'div';
      this.options.resultsContainer = $$(elemType, { className: 'coveo-result-list-container' }).el;
      this.initResultContainerAddToDom();
    }
    return new ResultContainer(this.options.resultsContainer, this.searchInterface);
  }

  protected initResultContainerAddToDom() {
    this.element.appendChild(this.options.resultsContainer);
  }

  private initWaitAnimationContainer() {
    if (!this.options.waitAnimationContainer) {
      this.options.waitAnimationContainer = this.resultContainer.el;
    }
  }

  private setupRenderer() {
    const initParameters: IInitializationParameters = {
      options: this.searchInterface.options.originalOptionsObject,
      bindings: this.bindings
    };

    const autoCreateComponentsFn = (elem: HTMLElement) => Initialization.automaticallyCreateComponentsInside(elem, initParameters);

    switch (this.options.layout) {
      case 'card':
        this.renderer = new ResultListCardRenderer(this.options, autoCreateComponentsFn);
        break;
      case 'table':
        this.renderer = new ResultListTableRenderer(this.options, autoCreateComponentsFn);
        break;
      case 'list':
      default:
        this.renderer = new ResultListRenderer(this.options, autoCreateComponentsFn);
        break;
    }
  }

  private makeElementFocusable() {
    $$(this.element).setAttribute('tabindex', '-1');
  }

  private ensureHasId() {
    const currentId = this.element.id;
    if (currentId === '') {
      this.element.id = uniqueId('coveo-result-list');
    }
  }

  private enableBasedOnActiveLayouts() {
    // We should try to enable a result list only when the result layout selector currently allow that result list layout to be displayed.
    each(this.resultLayoutSelectors, resultLayoutSelector => {
      const found = find(resultLayoutSelector.activeLayouts, (activeLayout, activeLayoutKey) => activeLayoutKey == this.options.layout);
      if (found) {
        super.enable();
        resultLayoutSelector.enableLayouts([this.options.layout] as ValidLayout[]);
        $$(this.element).removeClass('coveo-hidden');
      }
    });
  }
}

Initialization.registerAutoCreateComponent(ResultList);
