import { Initialization, LazyInitialization, EagerInitialization } from './Initialization';
import { Assert } from '../../misc/Assert';
import { QueryController } from '../../controllers/QueryController';
import { QueryStateModel, setState } from '../../models/QueryStateModel';
import { IQueryResult } from '../../rest/QueryResult';
import { IQueryResults } from '../../rest/QueryResults';
import { IAnalyticsClient } from '../Analytics/AnalyticsClient';
import { InitializationEvents } from '../../events/InitializationEvents';
import { $$ } from '../../utils/Dom';
import { IAnalyticsActionCause } from '../Analytics/AnalyticsActionListMeta';
import { IStringMap } from '../../rest/GenericParam';
import { BaseComponent } from '../Base/BaseComponent';
import { Component } from '../Base/Component';
import { IStandaloneSearchInterfaceOptions, SearchInterface } from '../SearchInterface/SearchInterface';
import { IRecommendationOptions } from '../Recommendation/Recommendation';
import * as _ from 'underscore';
import { PublicPathUtils } from '../../utils/PublicPathUtils';
import { Logger } from '../../misc/Logger';
import { Analytics } from '../Analytics/Analytics';
import { AnalyticsUtils } from '../../utils/AnalyticsUtils';

let registeredNamedMethodsLogger = new Logger('RegisteredNamedMethods');

/**
 * Initialize the framework with a basic search interface. Calls {@link Initialization.initSearchInterface}.
 *
 * If using the jQuery extension, this is called using <code>$('#root').coveo('init');</code>.
 * @param element The root of the interface to initialize.
 * @param options JSON options for the framework (e.g.: <code>{Searchbox : {enableSearchAsYouType : true}}</code>).
 * @returns {Promise<{elem: HTMLElement}>}
 */
export function init(element: HTMLElement, options: any = {}) {
  return Initialization.initializeFramework(element, options, () => {
    return Initialization.initSearchInterface(element, options);
  });
}

Initialization.registerNamedMethod('init', (element: HTMLElement, options: any = {}) => {
  return init(element, options);
});

/**
 * Initialize the framework with a standalone search box. Calls {@link Initialize.initStandaloneSearchInterface}.
 *
 * If using the jQuery extension, this is called using <code>$('#root').coveo('initSearchbox');</code>.
 * @param element The root of the interface to initialize.
 * @param searchPageUri The search page on which to redirect when there is a query.
 * @param options JSON options for the framework (e.g.: <code>{Searchbox : {enableSearchAsYouType : true}}</code>).
 * @returns {Promise<{elem: HTMLElement}>}
 */
export function initSearchbox(element: HTMLElement, searchPageUri: string, options: any = {}) {
  Assert.isNonEmptyString(searchPageUri);
  var searchInterfaceOptions = <IStandaloneSearchInterfaceOptions>{};
  searchInterfaceOptions.searchPageUri = searchPageUri;
  searchInterfaceOptions.autoTriggerQuery = false;
  searchInterfaceOptions.enableHistory = false;
  searchInterfaceOptions = { ...searchInterfaceOptions, ...options.StandaloneSearchInterface };

  options = _.extend({}, options, { StandaloneSearchInterface: searchInterfaceOptions });
  return Initialization.initializeFramework(element, options, () => {
    return Initialization.initStandaloneSearchInterface(element, options);
  });
}

Initialization.registerNamedMethod('initSearchbox', (element: HTMLElement, searchPageUri: string, options: any = {}) => {
  initSearchbox(element, searchPageUri, options);
});

/**
 * Initialize the framework with a recommendation interface. Calls {@link Initialization.initRecommendationInterface}.
 *
 * If using the jQuery extension, this is called using <code>$('#root').coveo('initRecommendation');</code>.
 * @param element The root of the interface to initialize.
 * @param mainSearchInterface The search interface to link with the recommendation interface (see {@link Recommendation}).
 * @param userContext The user context to pass with the query generated in the recommendation interface (see {@link Recommendation}).
 * @param options JSON options for the framework (e.g.: <code>{Searchbox : {enableSearchAsYouType: true}}</code>).
 * @returns {Promise<{elem: HTMLElement}>}
 */
export function initRecommendation(
  element: HTMLElement,
  mainSearchInterface?: HTMLElement,
  userContext?: { [name: string]: any },
  options: any = {}
) {
  var recommendationOptions = <IRecommendationOptions>{};
  recommendationOptions.mainSearchInterface = mainSearchInterface;
  recommendationOptions.userContext = userContext;
  recommendationOptions.enableHistory = false;
  options = _.extend({}, options, { Recommendation: recommendationOptions });

  // Recommendation component is special : It is not explicitely registered like other "basic" components since it's a full search interface.
  // Since it's not done using the "standard" path, we need to register this manually here
  // This ensure that we can always call `getLazyRegisteredComponent`, no matter if it was loaded from eager or lazy mode.
  if (window['Coveo']['Recommendation'] != null) {
    LazyInitialization.registerLazyComponent('Recommendation', () => Promise.resolve(window['Coveo']['Recommendation']));
    EagerInitialization.eagerlyLoadedComponents['Recommendation'] = window['Coveo']['Recommendation'];
  }

  return LazyInitialization.getLazyRegisteredComponent('Recommendation').then(() => {
    return Initialization.initializeFramework(element, options, () => {
      return Initialization.initRecommendationInterface(element, options);
    });
  });
}

Initialization.registerNamedMethod(
  'initRecommendation',
  (element: HTMLElement, mainSearchInterface: HTMLElement, userContext: any = {}, options: any = {}) => {
    initRecommendation(element, mainSearchInterface, userContext, options);
  }
);

/**
 * Execute a standard query. Active component in the interface will react to events/ push data in the query / handle the query success or failure as needed.
 *
 * It triggers a standard query flow for which the standard component will perform their expected behavior.
 *
 * If you wish to only perform a query on the index to retrieve results (without the component reacting), look into {@link SearchInterface} instead.
 *
 * Calling this method is the same as calling {@link QueryController.executeQuery}.
 *
 * @param element The root of the interface to initialize.
 * @returns {Promise<IQueryResults>}
 */
export function executeQuery(element: HTMLElement): Promise<IQueryResults> {
  Assert.exists(element);

  var queryController = <QueryController>Component.resolveBinding(element, QueryController);
  Assert.exists(queryController);
  return queryController.executeQuery();
}

Initialization.registerNamedMethod('executeQuery', (element: HTMLElement) => {
  return executeQuery(element);
});

/**
 * Performs read and write operations on the [`QueryStateModel`]{@link QueryStateModel} instance of the search
 * interface. See [State](https://docs.coveo.com/en/344/).
 *
 * Can perform the following actions:
 *
 * - Get the `QueryStateModel` instance:
 * ```javascript
 * Coveo.state(element);
 * ```
 *
 * - Get the value of a single state attribute from the `QueryStateModel` instance:
 * ```javascript
 * // You can replace `q` with any registered state attribute.
 * Coveo.state(element, "q");
 * ```
 *
 * - Set the value of a single state attribute in the `QueryStateModel` instance:
 * ```javascript
 * // You can replace `q` with any registered state attribute.
 * Coveo.state(element, "q", "my new value");
 * ```
 *
 * - Set multiple state attribute values in the `QueryStateModel` instance:
 * ```javascript
 * // You can replace `q` and `sort` with any registered state attributes.
 * Coveo.state(element, {
 *     "q" : "my new value",
 *     "sort" : "relevancy"
 * });
 * ```
 *
 * **Note:**
 * > When setting one or several state attribute values with this function, you can pass an additional argument to set
 * > the `silent` attribute to `true` in order to prevent the state change from triggering state change events.
 * >
 * > **Example:**
 * > ```javascript
 * > Coveo.state(element, "q", "my new value", { "silent" : true });
 * > ```
 *
 * @param element The root of the interface whose `QueryStateModel` instance the function should interact with.
 * @param args The arguments that determine the action to perform on the `QueryStateModel` instance.
 * @returns {any} Depends on the action performed.
 */
export function state(element: HTMLElement, ...args: any[]): any {
  Assert.exists(element);
  var model = <QueryStateModel>Component.resolveBinding(element, QueryStateModel);
  return setState(model, args);
}

Initialization.registerNamedMethod('state', (element: HTMLElement, ...args: any[]): any => {
  if (args.length != 0) {
    return state.apply(undefined, [element].concat(args));
  } else {
    return state.apply(undefined, [element]);
  }
});

/**
 * Get the component bound on the given `HTMLElement`.
 * @param element The `HTMLElement` for which to get the component instance.
 * @param componentClass If multiple components are bound to a single `HTMLElement`, you need to specify which components you wish to get.
 * @param noThrow By default, the GET method will throw if there is no component bound, or if there are multiple component and no `componentClass` is specified. This deletes the error if set to true.
 * @returns {Component}
 */
export function get(element: HTMLElement, componentClass?, noThrow?: boolean): BaseComponent {
  Assert.exists(element);
  return Component.get(element, componentClass, noThrow);
}

Initialization.registerNamedMethod(
  'get',
  (element: HTMLElement, componentClass?: any, noThrow?: boolean): BaseComponent => {
    return get(element, componentClass, noThrow);
  }
);

export function result(element: HTMLElement, noThrow?: boolean): IQueryResult {
  Assert.exists(element);
  return Component.getResult(element, noThrow);
}

Initialization.registerNamedMethod(
  'result',
  (element: HTMLElement, noThrow?: boolean): IQueryResult => {
    return result(element, noThrow);
  }
);

function getCoveoAnalyticsClient(element: HTMLElement): IAnalyticsClient {
  var analytics = getCoveoAnalytics(element);
  if (analytics) {
    return analytics.client;
  } else {
    return undefined;
  }
}

function getCoveoAnalytics(element: HTMLElement) {
  var analyticsElement = element.classList.contains(Component.computeCssClassNameForType('Analytics'))
    ? element
    : $$(element).find(Component.computeSelectorForType(`Analytics`));
  if (analyticsElement) {
    return <Analytics>Component.resolveBinding(analyticsElement, Analytics);
  } else {
    return undefined;
  }
}

/**
 * Finds the [`Analytics`]{@link Analytics} component instance, and uses it to log a `Custom` usage analytics event.
 *
 * You can use `Custom` events to create custom reports, or to track events which are neither queries nor item
 * views.
 *
 * @param element The root of the search interface which contains the [`Analytics`]{@link Analytics} component.
 * @param customEventCause The cause of the event.
 * @param metadata The metadata you want to use to create custom dimensions. Metadata can contain as many key-value
 * pairs as you need. Each key must contain only alphanumeric characters and underscores. The Coveo Usage Analytics
 * service automatically converts white spaces to underscores, and uppercase characters to lowercase characters in key
 * names. Each value must be a simple string. If you do not need to log metadata, you can simply pass an empty JSON
 * ( `{}` ).
 * @param result The query result that relates to the custom event, if applicable.
 */
export function logCustomEvent<TMeta extends IStringMap<any>>(
  element: HTMLElement,
  customEventCause: IAnalyticsActionCause,
  metadata: TMeta,
  result?: IQueryResult
) {
  var client = getCoveoAnalyticsClient(element);
  if (client) {
    client.logCustomEvent<TMeta>(customEventCause, metadata, element, result);
  }
}

Initialization.registerNamedMethod(
  'logCustomEvent',
  (element: HTMLElement, customEventCause: IAnalyticsActionCause, metadata: any, result?: IQueryResult) => {
    logCustomEvent(element, customEventCause, metadata, result);
  }
);

/**
 * Finds the [`Analytics`]{@link Analytics} component instance, and uses it to log a `Search` usage analytics event.
 *
 * A `Search` event is actually sent to the Coveo Usage Analytics service only after the query successfully returns (not
 * immediately after calling this method). Therefore, it is important to call this method **before** executing the
 * query. Otherwise, the `Search` event will not be logged, and you will get a warning message in the console. See
 * [Logging Your Own Search Events](https://docs.coveo.com/en/2726/#logging-your-own-search-events).
 *
 * @param element The root of the search interface which contains the [`Analytics`]{@link Analytics} component.
 * @param searchEventCause The cause of the event.
 * @param metadata The metadata you want to use to create custom dimensions. Metadata can contain as many key-value
 * pairs as you need. Each key must contain only alphanumeric characters and underscores. The Coveo Usage Analytics
 * service automatically converts white spaces to underscores, and uppercase characters to lowercase characters in key
 * names. Each value must be a simple string. If you do not need to log metadata, you can simply pass an empty JSON
 * ( `{}` ).
 */
export function logSearchEvent(element: HTMLElement, searchEventCause: IAnalyticsActionCause, metadata: IStringMap<string>) {
  var client = getCoveoAnalyticsClient(element);
  if (client) {
    client.logSearchEvent<any>(searchEventCause, metadata);
  }
}

Initialization.registerNamedMethod(
  'logSearchEvent',
  (element: HTMLElement, searchEventCause: IAnalyticsActionCause, metadata: IStringMap<string>) => {
    logSearchEvent(element, searchEventCause, metadata);
  }
);

/**
 * Finds the [`Analytics`]{@link Analytics} component instance, and uses it to log a `SearchAsYouType` usage analytics
 * event.
 *
 * This function is very similar to the `logSearchEvent` function, except that `logSearchAsYouTypeEvent` should, by
 * definition, be called more frequently. Consequently, in order to avoid logging every single partial query, the
 * `PendingSearchAsYouTypeEvent` takes care of logging only the "relevant" last event: an event that occurs after 5
 * seconds have elapsed without any event being logged, or an event that occurs after another part of the interface
 * triggers a search event.
 *
 * It is important to call this method **before** executing the query. Otherwise, no `SearchAsYouType` event will be
 * logged, and you will get a warning message in the console. See
 * [Logging Your Own Search Events](https://docs.coveo.com/en/2726/#logging-your-own-search-events).
 *
 * @param element The root of the search interface which contains the [`Analytics`]{@link Analytics} component.
 * @param searchAsYouTypeEventCause The cause of the event.
 * @param metadata The metadata you want to use to create custom dimensions. Metadata can contain as many key-value
 * pairs as you need. Each key must contain only alphanumeric characters and underscores. The Coveo Usage Analytics
 * service automatically converts white spaces to underscores, and uppercase characters to lowercase characters in key
 * names. Each value must be a simple string. If you do not need to log metadata, you can simply pass an empty JSON
 * ( `{}` ).
 */
export function logSearchAsYouTypeEvent<TMeta extends IStringMap<any>>(
  element: HTMLElement,
  searchAsYouTypeEventCause: IAnalyticsActionCause,
  metadata: TMeta
) {
  var client = getCoveoAnalyticsClient(element);
  if (client) {
    client.logSearchAsYouType<TMeta>(searchAsYouTypeEventCause, metadata);
  }
}

Initialization.registerNamedMethod(
  'logSearchAsYouTypeEvent',
  (element: HTMLElement, searchAsYouTypeEventCause: IAnalyticsActionCause, metadata: IStringMap<string>) => {
    logSearchAsYouTypeEvent(element, searchAsYouTypeEventCause, metadata);
  }
);

/**
 * Finds the [`Analytics`]{@link Analytics} component instance, and uses it to log a `Click` usage analytics event.
 *
 * A `Click` event corresponds to an item view (e.g., clicking on a {@link ResultLink} or opening a {@link Quickview}).
 *
 * `Click` events are immediately sent to the Coveo Usage Analytics service.
 *
 * @param element The root of the search interface which contains the [`Analytics`]{@link Analytics} component.
 * @param clickEventCause The cause of the event.
 * @param metadata The metadata you want to use to create custom dimensions. Metadata can contain as many key-value
 * pairs as you need. Each key must contain only alphanumeric characters and underscores. The Coveo Usage Analytics
 * service automatically converts white spaces to underscores, and uppercase characters to lowercase characters in key
 * names. Each value must be a simple string. If you do not need to log metadata, you can simply pass an empty JSON
 * ( `{}` ).
 * @param result The result that was clicked.
 */
export function logClickEvent<TMeta extends IStringMap<any>>(
  element: HTMLElement,
  clickEventCause: IAnalyticsActionCause,
  metadata: TMeta,
  result: IQueryResult
) {
  var client = getCoveoAnalyticsClient(element);
  if (client) {
    client.logClickEvent<TMeta>(clickEventCause, metadata, result, element);
  }
}

Initialization.registerNamedMethod(
  'logClickEvent',
  (element: HTMLElement, clickEventCause: IAnalyticsActionCause, metadata: IStringMap<string>, result: IQueryResult) => {
    logClickEvent(element, clickEventCause, metadata, result);
  }
);

/**
 * Pass options to the framework, before it is initialized ({@link init}).<br/>
 * All the options passed with this calls will be merged together on initialization.
 * @param element The root of the interface for which you wish to set options.
 * @param optionsToSet JSON options for the framework (e.g.: <code>{Searchbox : {enableSearchAsYouType: true}}</code>).
 */
export function options(element: HTMLElement, optionsToSet: any = {}) {
  Initialization.registerDefaultOptions(element, optionsToSet);
}

Initialization.registerNamedMethod('options', (element: HTMLElement, optionsToSet: any = {}) => {
  options(element, optionsToSet);
});

/**
 * Patch the given `methodName` on an instance of a component bound to an `HTMLElement` with a new handler.
 * @param element
 * @param methodName
 * @param handler
 */
export function patch(element: HTMLElement, methodName: string, handler: (...args: any[]) => any) {
  Initialization.monkeyPatchComponentMethod(methodName, element, handler);
}

Initialization.registerNamedMethod('patch', (element?: HTMLElement, methodName?: string, handler?: (...args: any[]) => any) => {
  patch(element, methodName, handler);
});

export function initBox(element: HTMLElement, ...args: any[]) {
  var type,
    options: any = {},
    injectMarkup;
  // This means : initBox, no type (no injection) and no options
  if (args.length == 0) {
    type = 'Standard';
    injectMarkup = false;
  } else if (args.length == 1) {
    // 1 arg, might be options or type
    // This mean a type (with injection) and no options
    if (typeof args[0] == 'string') {
      type = args[0];
      injectMarkup = true;
    } else if (typeof args[0] == 'object') {
      // This means no type(no injection) and with options
      type = 'Standard';
      injectMarkup = false;
      options = args[0];
    } else {
      Assert.fail('Invalid parameters to init a box');
    }
  } else if (args.length == 2) {
    // 2 args means both options and type (with injection);
    type = args[0];
    options = args[1];
    injectMarkup = true;
  }
  var merged: any = {};
  merged[type || 'Container'] = _.extend({}, options.SearchInterface, options[type]);
  options = _.extend({}, options, merged);
  Initialization.initializeFramework(element, options, () => {
    return Initialization.initBoxInterface(element, options, type, injectMarkup);
  });
}

Initialization.registerNamedMethod('initBox', (element?: HTMLElement, ...args: any[]) => {
  initBox(element, args);
});

export function nuke(element: HTMLElement) {
  $$(element).trigger(InitializationEvents.nuke);
}

Initialization.registerNamedMethod('nuke', (element: HTMLElement) => {
  nuke(element);
});

/**
 * Sets the path from where the chunks used for lazy loading will be loaded. In some cases, in IE11, we cannot automatically detect it, use this instead.
 * @param path This should be the path of the Coveo script. It should also have a trailing slash.
 */
export function configureResourceRoot(path: string) {
  PublicPathUtils.configureResourceRoot(path);
}

Initialization.registerNamedMethod('configureResourceRoot', (path: string) => {
  configureResourceRoot(path);
});

/**
 * Re-enables an [`Analytics`]{@link Analytics} component if it was previously disabled.
 * @param searchRoot
 * The element to scan for an Analytics component.
 * This can be an element onto which a component instance is bound
 * (e.g., document.querySelector(".CoveoAnalytics"),
 * or an ancestor of such an element (e.g., document.getElementById("search").
 *
 * @availablesince [October 2019 Release (v2.7219)](https://docs.coveo.com/en/3084/)
 */
export function enableAnalytics(searchRoot = document.querySelector(Component.computeSelectorForType(SearchInterface.ID)) as HTMLElement) {
  const analytics = getCoveoAnalytics(searchRoot) as Analytics;
  if (!analytics) {
    return registeredNamedMethodsLogger.warn(
      'Could not enable analytics because no Analytics component could be found in the specified searchRoot.'
    );
  }
  analytics.enable();
}

Initialization.registerNamedMethod('enableAnalytics', () => {
  enableAnalytics();
});

/**
 * Removes all session information stored in the browser (e.g., analytics visitor cookies, action history, etc.)
 * @param searchRoot
 * The element to scan for an Analytics component.
 * This can be an element onto which a component instance is bound
 * (e.g., document.querySelector(".CoveoAnalytics"),
 * or an ancestor of such an element (e.g., document.getElementById("search").
 */
export function clearLocalData(searchRoot = document.querySelector(Component.computeSelectorForType(SearchInterface.ID)) as HTMLElement) {
  const analytics = getCoveoAnalytics(searchRoot) as Analytics;
  if (!analytics) {
    return registeredNamedMethodsLogger.warn(
      'Could not clear local data because no Analytics component could be found in the specified searchRoot.'
    );
  }
  analytics.clearLocalData();
}

Initialization.registerNamedMethod('clearLocalData', () => {
  clearLocalData();
});

/**
 * Disables an [`Analytics`]{@link Analytics} component and clears local data.
 * @param searchRoot
 * The element to scan for an Analytics component.
 * This can be an element onto which a component instance is bound
 * (e.g., document.querySelector(".CoveoAnalytics"),
 * or an ancestor of such an element (e.g., document.getElementById("search").
 *
 * @availablesince [October 2019 Release (v2.7219)](https://docs.coveo.com/en/3084/)
 */
export function disableAnalytics(searchRoot = document.querySelector(Component.computeSelectorForType(SearchInterface.ID)) as HTMLElement) {
  const analytics = getCoveoAnalytics(searchRoot) as Analytics;
  if (!analytics) {
    return registeredNamedMethodsLogger.warn(
      'Could not disable analytics because no Analytics component could be found in the specified searchRoot.'
    );
  }
  analytics.disable();
}

Initialization.registerNamedMethod('disableAnalytics', () => {
  disableAnalytics();
});

/**
 * Adds a new analytics action cause to the ActionCauseList.
 * Adding a new actionCause allows to specify a custom user-action when triggering a search event.
 * @param newActionCause
 * (e.g.,
 * {
 *  Name: "newActionCause",
 *  Type: "exampleType"
 * },
 */
export function addActionCauseToList(newActionCause: IAnalyticsActionCause) {
  AnalyticsUtils.addActionCauseToList(newActionCause);
}

Initialization.registerNamedMethod('addActionCauseToList', (newActionCause: IAnalyticsActionCause) => {
  addActionCauseToList(newActionCause);
});

/**
 * Removes an actionCause from the ActionCauseList.
 * @param actionCauseToRemoveName
 */
export function removeActionCauseFromList(actionCauseToRemoveName: string) {
  AnalyticsUtils.removeActionCauseFromList(actionCauseToRemoveName);
}

Initialization.registerNamedMethod('removeActionCauseFromList', (actionCauseToRemoveName: string) => {
  removeActionCauseFromList(actionCauseToRemoveName);
});

/**
 * Asynchronously loads a module, or chunk.
 *
 * This is especially useful when you want to extend a base component, and make sure the lazy component loading process
 * recognizes it (see [Lazy Versus Eager Component Loading](https://docs.coveo.com/en/295/)).
 *
 * **Example:**
 *
 * ```typescript
 * export function lazyCustomFacet() {
 *   return Coveo.load<Facet>('Facet').then((Facet) => {
 *     class CustomFacet extends Facet {
 *       [ ... ]
 *     };
 *     Coveo.Initialization.registerAutoCreateComponent(CustomFacet);
 *     return CustomFacet;
 *   });
 * };
 *
 * Coveo.LazyInitialization.registerLazyComponent('CustomFacet', lazyCustomFacet);
 * ```
 *
 * You can also use this function to assert a component is fully loaded in your page before executing any code relating
 * to it.
 *
 * **Example:**
 *
 * > You could do `Coveo.load('Searchbox').then((Searchbox) => {})` to load the [`Searchbox`]{@link Searchbox}
 * > component, if it is not already loaded in your search page.
 *
 * @param id The identifier of the module you wish to load. In the case of components, this identifier is the component
 * name (e.g., `Facet`, `Searchbox`).
 * @returns {Promise} A Promise of the module, or chunk.
 */
export function load<T>(id: string): Promise<T> {
  if (LazyInitialization.lazyLoadedComponents[id] != null) {
    return <Promise<T>>(<any>LazyInitialization.getLazyRegisteredComponent(id));
  } else if (LazyInitialization.lazyLoadedModule[id] != null) {
    return <Promise<T>>LazyInitialization.getLazyRegisteredModule(id);
  } else {
    return Promise.reject(`Module ${id} is not available`);
  }
}

Initialization.registerNamedMethod('require', (modules: string) => {
  return load(modules);
});
