import { buildHistoryStore, buildNullHistoryStore } from '../utils/HistoryStore';
import { pick, extend, omit, forEach } from 'underscore';
import {
  IBuildingCallOptionsEventArgs,
  IBuildingQueryEventArgs,
  IDoneBuildingQueryEventArgs,
  IDuringQueryEventArgs,
  IFetchMoreSuccessEventArgs,
  INewQueryEventArgs,
  INoResultsEventArgs,
  IPreprocessResultsEventArgs,
  IQueryErrorEventArgs,
  IQuerySuccessEventArgs,
  QueryEvents
} from '../events/QueryEvents';
import { ModalBox } from '../ExternalModulesShim';
import { Assert } from '../misc/Assert';
import { Defer } from '../misc/Defer';
import { IQuery } from '../rest/Query';
import { IQueryResults } from '../rest/QueryResults';
import { SearchEndpoint } from '../rest/SearchEndpoint';
import { IEndpointCallOptions, ISearchEndpoint } from '../rest/SearchEndpointInterface';
import { SearchEndpointWithDefaultCallOptions } from '../rest/SearchEndpointWithDefaultCallOptions';
import { BaseComponent } from '../ui/Base/BaseComponent';
import { QueryBuilder } from '../ui/Base/QueryBuilder';
import { RootComponent } from '../ui/Base/RootComponent';
import { ISearchInterfaceOptions, SearchInterface } from '../ui/SearchInterface/SearchInterface';
import { $$, Dom } from '../utils/Dom';
import { LocalStorageUtils } from '../utils/LocalStorageUtils';
import { QueryUtils } from '../utils/QueryUtils';
import { UrlUtils } from '../utils/UrlUtils';
import { Utils } from '../utils/Utils';
import { IAnalyticsClient } from '../ui/Analytics/AnalyticsClient';
import { ExecutionPlan } from '../rest/Plan';
import { AnalyticsInformation } from '../ui/Analytics/AnalyticsInformation';
import { QueryboxQueryParameters } from '../ui/Querybox/QueryboxQueryParameters';

/**
 * Possible options when performing a query with the query controller
 */
export interface IQueryOptions {
  /**
   * If the analytics component is enabled in the interface, it will look for any query executed by the query controller for which no analytics event was associated.<br/>
   * By setting this property to true, this will cancel this check when the query is performed
   */
  ignoreWarningSearchEvent?: boolean;
  /**
   * Whether the query to execute is a search-as-you-type. This information will be passed down in the query events for component and external code to determine their behavior
   */
  searchAsYouType?: boolean;
  /**
   * Specify a function that you wish to execute just before the query is executed
   */
  beforeExecuteQuery?: () => void;
  /**
   * Cancel the query
   */
  cancel?: boolean;
  /**
   * The component from which the query originated. For example the pager will set the property to tweak it's behaviour
   */
  origin?: any;

  /**
   * Whether or not to log the query in the user actions history when using the page view script: https://github.com/coveo/coveo.analytics.js.
   * Only the 'q' part of the query will be logged.
   * This option is useful, because it prevents the query to be logged twice when a {@link Recommendation} component is present.
   * It also makes sure that only relevant queries are logged. For exemple, the 'empty' interface load query isn't logged.
   */
  logInActionsHistory?: boolean;
  isFirstQuery?: boolean;
  keepLastSearchUid?: boolean;
  closeModalBox?: boolean;
  shouldRedirectStandaloneSearchbox?: boolean;
}

interface ILastQueryLocalStorage {
  hash: string;
  uid: string;
  expire: number;
}

class DefaultQueryOptions implements IQueryOptions {
  searchAsYouType = false;
  beforeExecuteQuery: () => void;
  cancel = false;
  logInActionsHistory = false;
  shouldRedirectStandaloneSearchbox = true;
}

/**
 * This class is automatically instantiated and bound to the root of your search interface when you initialize the framework.<br/>
 * It is essentially a singleton that wraps the access to the {@link SearchEndpoint} endpoint to execute query, and is in charge of triggering the different query events.<br/>
 * This is what every component of the framework uses internally to execute query or access the endpoint.<br/>
 * When calling <code>Coveo.executeQuery</code> this class is used.
 */
export class QueryController extends RootComponent {
  static ID = 'QueryController';
  public historyStore: CoveoAnalytics.HistoryStore;
  public firstQuery: boolean;
  public modalBox = ModalBox;
  public closeModalBox = true;

  private currentPendingQuery: Promise<IQueryResults>;
  private lastQueryBuilder: QueryBuilder;
  private lastQueryHash: string;
  private lastQuery: IQuery;
  private lastSearchUid: string;
  private lastQueryResults: IQueryResults;
  private currentError: any;
  private createdOneQueryBuilder: boolean;
  private showingExecutingQueryAnimation = false;
  private overrideEndpoint: SearchEndpoint;

  private localStorage = new LocalStorageUtils<ILastQueryLocalStorage>('lastQueryHash');

  /**
   * Create a new query controller
   * @param element
   * @param options
   * @param usageAnalytics **Deprecated.** Since the [October 2019 Release (v2.7219)](https://docs.coveo.com/en/3084/), the class retrieves and uses the {@link AnalyticsClient} from its `searchInterface` constructor parameter.
   * @param searchInterface
   */
  constructor(
    element: HTMLElement,
    public options: ISearchInterfaceOptions,
    usageAnalytics: IAnalyticsClient,
    public searchInterface: SearchInterface
  ) {
    super(element, QueryController.ID);
    Assert.exists(element);
    Assert.exists(options);
    this.firstQuery = true;
    this.initializeActionsHistory();
  }

  public get usageAnalytics(): IAnalyticsClient {
    return this.searchInterface.usageAnalytics;
  }

  /**
   * Set the {@link SearchEndpoint} that the query controller should use to execute query
   * @param endpoint
   */
  public setEndpoint(endpoint: SearchEndpoint) {
    this.overrideEndpoint = endpoint;
    this.logger.debug('Endpoint set', endpoint);
  }

  /**
   * Get the {@link SearchEndpoint} that is currently used by the query controller to execute query
   * @returns {SearchEndpoint}
   */
  public getEndpoint(): ISearchEndpoint {
    let endpoint = this.overrideEndpoint || this.options.endpoint;

    // We must wrap the endpoint in a decorator that'll add the call options
    // we obtain by firing the proper event. Those are used for authentication
    // providers, and I guess other stuff later on.
    return new SearchEndpointWithDefaultCallOptions(endpoint, this.getCallOptions());
  }

  /**
   * Return the last query that was performed by the query controller
   * @returns {IQuery|Query}
   */
  public getLastQuery() {
    return this.lastQuery || new QueryBuilder().build();
  }

  /**
   * Return the last query results set.
   * @returns {IQueryResults}
   */
  public getLastResults() {
    return this.lastQueryResults;
  }

  /**
   * Returns the plan of execution of a search request, without executing it.
   * @returns {ExecutionPlan}
   */
  public async fetchQueryExecutionPlan(): Promise<ExecutionPlan> {
    const query = this.createQueryBuilder(new DefaultQueryOptions()).build();
    this.logger.debug('Fetching query execution plan');
    try {
      return await this.getEndpoint().plan(query);
    } catch (error) {
      return null;
    }
  }

  /**
   * Execute a query and return a Promise of IQueryResults.<br/>
   * This will execute the normal query flow, triggering all the necessary query events (newQuery <br/>
   * All components present in the interface will act accordingly (modify the query and render results if needed).
   * @param options
   * @returns {Promise<IQueryResults>}
   */
  public executeQuery(options?: IQueryOptions): Promise<IQueryResults> {
    options = <IQueryOptions>extend(new DefaultQueryOptions(), options);

    this.closeModalBoxIfNeeded(options ? options.closeModalBox : undefined);

    this.logger.debug('Executing new query');

    this.cancelAnyCurrentPendingQuery();

    if (options.beforeExecuteQuery != null) {
      options.beforeExecuteQuery();
    }

    if (!options.ignoreWarningSearchEvent) {
      this.usageAnalytics.warnAboutSearchEvent();
    }

    this.showExecutingQueryAnimation();

    let dataToSendOnNewQuery: INewQueryEventArgs = {
      searchAsYouType: options.searchAsYouType,
      cancel: options.cancel,
      origin: options.origin,
      shouldRedirectStandaloneSearchbox: options.shouldRedirectStandaloneSearchbox
    };

    this.newQueryEvent(dataToSendOnNewQuery);

    if (dataToSendOnNewQuery.cancel) {
      this.cancelQuery();
      return;
    }

    let queryBuilder = this.createQueryBuilder(options);

    // The query was canceled
    if (!queryBuilder) {
      return;
    }

    let query = queryBuilder.build();
    if (options.logInActionsHistory) {
      this.logQueryInActionsHistory(query);
    }

    let endpointToUse = this.getEndpoint();

    let promise = (this.currentPendingQuery = endpointToUse.search(query, { analyticsInformation: this.getAnalyticsInformation() }));
    promise
      .then(queryResults => {
        Assert.exists(queryResults);
        if (this.firstQuery) {
          this.firstQuery = false;
        }
        // If our promise is no longer the current one, then the query
        // has been cancel. We should do nothing here.
        if (promise !== this.currentPendingQuery) {
          return;
        }

        this.logger.debug('Query results received', query, queryResults);

        if (this.keepLastSearchUid(query, queryResults, options)) {
          queryResults.searchUid = this.getLastSearchUid();
          queryResults._reusedSearchUid = true;
          QueryUtils.setPropertyOnResults(queryResults, 'queryUid', this.getLastSearchUid());
        } else {
          this.lastQueryHash = this.queryHash(query, queryResults);
          this.lastSearchUid = queryResults.searchUid;
        }

        this.lastQuery = query;
        this.lastQueryResults = queryResults;
        this.currentError = null;

        let dataToSendOnPreprocessResult: IPreprocessResultsEventArgs = {
          queryBuilder: queryBuilder,
          query: query,
          results: queryResults,
          searchAsYouType: options.searchAsYouType
        };
        this.preprocessResultsEvent(dataToSendOnPreprocessResult);

        let dataToSendOnNoResult: INoResultsEventArgs = {
          queryBuilder: queryBuilder,
          query: query,
          results: queryResults,
          searchAsYouType: options.searchAsYouType,
          retryTheQuery: false
        };
        if (queryResults.results.length == 0) {
          this.noResultEvent(dataToSendOnNoResult);
        }

        if (dataToSendOnNoResult.retryTheQuery) {
          // When retrying the query, we must forward the results to the deferred we
          // initially returned, in case someone is listening on it.
          return this.executeQuery();
        } else {
          this.lastQueryBuilder = queryBuilder;
          this.currentPendingQuery = undefined;

          let dataToSendOnSuccess: IQuerySuccessEventArgs = {
            queryBuilder: queryBuilder,
            query: query,
            results: queryResults,
            searchAsYouType: options.searchAsYouType
          };
          this.querySuccessEvent(dataToSendOnSuccess);

          Defer.defer(() => {
            this.deferredQuerySuccessEvent(dataToSendOnSuccess);
            this.hideExecutingQueryAnimation();
          });
          return queryResults;
        }
      })
      .catch((error?: any) => {
        // If our deferred is no longer the current one, then the query
        // has been cancel. We should do nothing here.
        if (promise !== this.currentPendingQuery) {
          return;
        }

        this.logger.error('Query triggered an error', query, error);

        this.currentPendingQuery = undefined;
        let dataToSendOnError: IQueryErrorEventArgs = {
          queryBuilder: queryBuilder,
          endpoint: endpointToUse,
          query: query,
          error: error,
          searchAsYouType: options.searchAsYouType
        };

        this.lastQuery = query;
        this.lastQueryResults = null;
        this.currentError = error;
        this.queryError(dataToSendOnError);

        this.hideExecutingQueryAnimation();
      });

    let dataToSendDuringQuery: IDuringQueryEventArgs = {
      queryBuilder: queryBuilder,
      query: query,
      searchAsYouType: options.searchAsYouType,
      promise: promise
    };
    this.duringQueryEvent(dataToSendDuringQuery);

    return this.currentPendingQuery;
  }

  /**
   * Using the same parameters as the last successful query, fetch another batch of results. Particularly useful for infinite scrolling, for example.
   * @param count
   * @returns {any}
   */
  public fetchMore(count: number): Promise<IQueryResults> {
    if (this.currentPendingQuery != undefined) {
      return undefined;
    }
    // Send all pending events (think : search as you type)
    // This allows us to get the real search id for the results when the query returns
    this.usageAnalytics.sendAllPendingEvents();

    let queryBuilder = new QueryBuilder();
    this.continueLastQueryBuilder(queryBuilder, count);
    let query = queryBuilder.build();
    let endpointToUse = this.getEndpoint();
    let promise: any = (this.currentPendingQuery = endpointToUse.search(query));
    let dataToSendDuringQuery: IDuringQueryEventArgs = {
      queryBuilder: queryBuilder,
      query: query,
      searchAsYouType: false,
      promise: promise
    };
    $$(this.element).trigger(QueryEvents.duringFetchMoreQuery, dataToSendDuringQuery);
    this.lastQueryBuilder = queryBuilder;
    this.lastQuery = query;
    promise.then((results?: IQueryResults) => {
      // We re-use the search id from the initial search here, even though the
      // server provided us with a new one. 'Fetch mores' are considered to be
      // the same query from an analytics point of view.

      this.currentPendingQuery = undefined;

      if (this.lastQueryResults == null) {
        this.lastQueryResults = results;
      } else {
        forEach(results.results, result => {
          this.lastQueryResults.results.push(result);
        });
      }

      let dataToSendOnPreprocessResult: IPreprocessResultsEventArgs = {
        queryBuilder: queryBuilder,
        query: query,
        results: results,
        searchAsYouType: false
      };
      this.preprocessResultsEvent(dataToSendOnPreprocessResult);
      QueryUtils.setIndexAndUidOnQueryResults(query, results, this.getLastSearchUid(), results.pipeline, results.splitTestRun);
      let dataToSendOnFetchMoreSuccess: IFetchMoreSuccessEventArgs = {
        query: query,
        results: results,
        queryBuilder: queryBuilder,
        searchAsYouType: false
      };
      this.fetchMoreSuccessEvent(dataToSendOnFetchMoreSuccess);
    });
    return this.currentPendingQuery;
  }

  /**
   * Cancel any pending query
   */
  public cancelQuery(): void {
    this.cancelAnyCurrentPendingQuery();
    this.hideExecutingQueryAnimation();
  }

  public deferExecuteQuery(options?: IQueryOptions) {
    this.showExecutingQueryAnimation();
    Defer.defer(() => this.executeQuery(options));
  }

  public ensureCreatedQueryBuilder() {
    if (!this.createdOneQueryBuilder) {
      this.createQueryBuilder(new DefaultQueryOptions());
    }
  }

  public createQueryBuilder(options: IQueryOptions): QueryBuilder {
    Assert.exists(options);

    QueryboxQueryParameters.allowDuplicateQuery();
    this.createdOneQueryBuilder = true;

    let queryBuilder = new QueryBuilder();

    // Default values, components will probably override them if they exists
    queryBuilder.locale = <string>String['locale'];
    queryBuilder.firstResult = queryBuilder.firstResult || 0;

    // Allow outside code to customize the query builder. We provide two events,
    // to allow someone to have a peep at the query builder after the first phase
    // and add some stuff depending on what was put in there. The facets are using
    // this mechanism to generate query overrides.
    let dataToSendDuringBuildingQuery: IBuildingQueryEventArgs = {
      queryBuilder: queryBuilder,
      searchAsYouType: options.searchAsYouType,
      cancel: options.cancel
    };
    this.buildingQueryEvent(dataToSendDuringBuildingQuery);

    let dataToSendDuringDoneBuildingQuery: IDoneBuildingQueryEventArgs = {
      queryBuilder: queryBuilder,
      searchAsYouType: options.searchAsYouType,
      cancel: options.cancel
    };
    this.doneBuildingQueryEvent(dataToSendDuringDoneBuildingQuery);

    if (dataToSendDuringBuildingQuery.cancel || dataToSendDuringDoneBuildingQuery.cancel) {
      this.cancelQuery();
      return;
    }

    let pipeline = this.getPipelineInUrl();
    if (pipeline) {
      queryBuilder.pipeline = pipeline;
    }

    return queryBuilder;
  }

  public isStandaloneSearchbox(): boolean {
    return Utils.isNonEmptyString(this.options.searchPageUri);
  }

  public saveLastQuery() {
    this.localStorage.save({
      expire: new Date().getTime() + 1000 * 60 * 30, // Psoucy rolled dice and said 30 mins was the magic number
      hash: this.lastQueryHash,
      uid: this.lastSearchUid
    });
  }

  // This field is exposed for components rendered in the results or on-demand which
  // need access to the entire query. For example, the QuickviewDocument need to pass
  // the entire query to the Search API. For other components, QueryStateModel or
  // listening to events like 'doneBuildingQuery' is the way to go.
  public getLastQueryHash(): string {
    if (this.lastQueryHash) {
      return this.lastQueryHash;
    }
    this.loadLastQueryHash();
    return this.lastQueryHash || this.queryHash(new QueryBuilder().build());
  }

  public resetHistory() {
    this.historyStore.clear();
  }

  public enableHistory() {
    this.historyStore = buildHistoryStore();
  }

  public disableHistory() {
    this.historyStore = buildNullHistoryStore();
  }

  private initializeActionsHistory() {
    this.enableHistory();

    if (this.usageAnalytics.isActivated()) {
      return;
    }

    this.historyStore.clear();
    this.disableHistory();
  }

  private closeModalBoxIfNeeded(needed?: boolean) {
    if (needed != undefined ? needed : this.closeModalBox) {
      this.modalBox.close(true);
    }
  }

  private getLastSearchUid(): string {
    if (this.lastSearchUid != null) {
      return this.lastSearchUid;
    }
    this.loadLastQueryHash();
    return this.lastSearchUid;
  }

  private loadLastQueryHash() {
    let lastQuery = this.localStorage.load();
    if (lastQuery != null && new Date().getTime() <= lastQuery.expire) {
      this.lastQueryHash = lastQuery.hash;
      this.lastSearchUid = lastQuery.uid;
      this.localStorage.remove();
    }
  }

  private continueLastQueryBuilder(queryBuilder: QueryBuilder, count: number) {
    extend(queryBuilder, this.lastQueryBuilder);
    queryBuilder.firstResult = queryBuilder.firstResult + queryBuilder.numberOfResults;
    queryBuilder.numberOfResults = count;
  }

  private getPipelineInUrl() {
    return UrlUtils.getUrlParameter('pipeline');
  }

  private cancelAnyCurrentPendingQuery() {
    if (Utils.exists(this.currentPendingQuery)) {
      this.logger.debug('Cancelling current pending query');
      Promise.reject('Cancelling current pending query');
      this.currentPendingQuery = undefined;
      return true;
    }
    return false;
  }

  private showExecutingQueryAnimation() {
    if (!this.showingExecutingQueryAnimation) {
      $$(this.element).addClass('coveo-executing-query');
      this.showingExecutingQueryAnimation = true;
    }
  }

  private hideExecutingQueryAnimation() {
    if (this.showingExecutingQueryAnimation) {
      $$(this.element).removeClass('coveo-executing-query');
      this.showingExecutingQueryAnimation = false;
    }
  }

  private keepLastSearchUid(query: IQuery, queryResults: IQueryResults, options: IQueryOptions) {
    if (options.keepLastSearchUid === true) {
      return true;
    }

    const enableHistory = !!(this.searchInterface && this.searchInterface.options && this.searchInterface.options.enableHistory);
    return enableHistory && this.compareWithLastQueryHash(this.queryHash(query, queryResults));
  }

  private compareWithLastQueryHash(queryHash: string) {
    const lastParams = JSON.parse(this.getLastQueryHash());
    const newParams = JSON.parse(queryHash);
    return Utils.objectEqual(lastParams, newParams);
  }

  private queryHash(query: IQuery, queryResults?: IQueryResults): string {
    const queryKeys: (keyof IQuery)[] = [
      'q',
      'aq',
      'cq',
      'dq',
      'lq',
      'searchHub',
      'tab',
      'pipeline',
      'sortCriteria',
      'recommendation',
      'commerce',
      'facets'
    ];

    const queryParams = pick(query, ...queryKeys);

    if (queryResults) {
      queryParams.pipeline = queryResults.pipeline;
    }

    return JSON.stringify(queryParams);
  }

  private getCallOptions(): IEndpointCallOptions {
    let args: IBuildingCallOptionsEventArgs = {
      options: {
        authentication: []
      }
    };

    $$(this.element).trigger(QueryEvents.buildingCallOptions, args);
    return args.options;
  }

  private newQueryEvent(args) {
    $$(this.element).trigger(QueryEvents.newQuery, args);
  }

  private buildingQueryEvent(args) {
    $$(this.element).trigger(QueryEvents.buildingQuery, args);
  }

  private doneBuildingQueryEvent(args) {
    $$(this.element).trigger(QueryEvents.doneBuildingQuery, args);
  }

  private duringQueryEvent(args) {
    $$(this.element).trigger(QueryEvents.duringQuery, args);
  }

  private querySuccessEvent(args) {
    $$(this.element).trigger(QueryEvents.querySuccess, args);
  }

  private fetchMoreSuccessEvent(args) {
    $$(this.element).trigger(QueryEvents.fetchMoreSuccess, args);
  }

  private deferredQuerySuccessEvent(args) {
    $$(this.element).trigger(QueryEvents.deferredQuerySuccess, args);
  }

  private queryError(args) {
    $$(this.element).trigger(QueryEvents.queryError, args);
  }

  private preprocessResultsEvent(args) {
    $$(this.element).trigger(QueryEvents.preprocessResults, args);
  }

  private noResultEvent(args) {
    $$(this.element).trigger(QueryEvents.noResults, args);
  }

  public debugInfo() {
    let info: any = {
      query: this.lastQuery
    };

    if (this.lastQueryResults != null) {
      info.queryDuration = () => this.buildQueryDurationSection(this.lastQueryResults);
      info.results = () => omit(this.lastQueryResults, 'results');
    }

    if (this.currentError != null) {
      info.error = () => this.currentError;
    }

    return info;
  }

  private buildQueryDurationSection(queryResults: IQueryResults) {
    let dom = Dom.createElement('div', { className: 'coveo-debug-queryDuration' });
    let graph = Dom.createElement('div', { className: 'coveo-debug-durations' });
    let debugRef = BaseComponent.getComponentRef('Debug');
    dom.appendChild(graph);
    forEach(debugRef.durationKeys, (key: string) => {
      let duration = queryResults[key];
      if (duration != null) {
        graph.appendChild(
          Dom.createElement('div', {
            className: 'coveo-debug-duration',
            style: `width:${duration}px`,
            'data-id': key
          })
        );
        let legend = Dom.createElement('div', { className: 'coveo-debug-duration-legend', 'data-id': key });
        dom.appendChild(legend);
        let keyDom = Dom.createElement('span', { className: 'coveo-debug-duration-label' });
        keyDom.appendChild(document.createTextNode(key));
        legend.appendChild(keyDom);
        let durationDom = Dom.createElement('span', { className: 'coveo-debug-duration-value' });
        durationDom.appendChild(document.createTextNode(duration));
        legend.appendChild(durationDom);
      }
    });
    return dom;
  }

  private logQueryInActionsHistory(query: IQuery) {
    let queryElement: CoveoAnalytics.HistoryQueryElement = {
      name: 'Query',
      value: query.q,
      time: JSON.stringify(new Date())
    };
    this.historyStore.addElement(queryElement);
  }

  private getAnalyticsInformation(): AnalyticsInformation {
    const analyticsInfo = new AnalyticsInformation();
    analyticsInfo.pendingSearchEvent = this.usageAnalytics.getPendingSearchEvent();

    // add fallback for custom code that incorrectly implement the interface.
    if (this.usageAnalytics.getOriginContext) {
      analyticsInfo.originContext = this.usageAnalytics.getOriginContext();
    } else {
      analyticsInfo.originContext = 'Search';
    }
    if (this.usageAnalytics.getUserDisplayName) {
      analyticsInfo.userDisplayName = this.usageAnalytics.getUserDisplayName();
    } else {
      analyticsInfo.userDisplayName = undefined;
    }

    return analyticsInfo;
  }
}
