import { IStringMap } from '../rest/GenericParam';
import { Logger } from '../misc/Logger';
import { Assert } from '../misc/Assert';
import { TimeSpan } from '../utils/TimeSpanUtils';
import { DeviceUtils } from '../utils/DeviceUtils';
import { Utils } from '../utils/Utils';
import { JQueryUtils } from '../utils/JQueryutils';
import * as _ from 'underscore';
import { UrlUtils } from '../utils/UrlUtils';

declare const XDomainRequest;

export interface IEndpointCaller {
  call<T>(params: IEndpointCallParameters): Promise<ISuccessResponse<T>>;
  options: IEndpointCallerOptions;
}

/**
 * Parameters that can be used when calling an {@link EndpointCaller}
 */
export interface IEndpointCallParameters {
  /**
   * Url to target
   */
  url: string;
  /**
   * Array of query string params.<br/>
   * eg: ['foo=1','bar=2']
   */
  queryString: string[];
  /**
   * Body of the request.<br/>
   * key -> value map (JSON)
   */
  requestData: IStringMap<any>;
  /**
   * Request data type.<br/>
   * eg: "application/json", "application/x-www-form-urlencoded; charset=UTF-8"
   */
  requestDataType?: string;
  /**
   * Or HTTP verb : GET, POST, PUT, etc.
   */
  method: string;
  /**
   * responseType of the request.</br>
   * eg: "text", "arraybuffer" etc.
   */
  responseType: string;
  /**
   * Flag to specify if the endpoint should return different type of error as actual 200 success for the browser, but with the error code/message contained in the response.
   */
  errorsAsSuccess: boolean;
}

/**
 * Information about a request
 */
export interface IRequestInfo<T> {
  /**
   * Url that was requested
   */
  url: string;
  /**
   * The query string parameters that were used for this request
   */
  queryString: string[];
  /**
   * The data that was sent for this request
   */
  requestData: IStringMap<T>;
  /**
   * The requestDataType that was used for this request
   */
  requestDataType: string;
  /**
   * The timestamp at which the request started
   */
  begun: Date;
  /**
   * The method that was used for this request
   */
  method: string;
  /**
   * The headers for the request.
   */
  headers?: IStringMap<string>;
}

/**
 * A generic response
 */
export interface IResponse<T> {
  /**
   * Data of the response
   */
  data?: T;
}

/**
 * A generic success response
 */
export interface ISuccessResponse<T> extends IResponse<T> {
  /**
   * The time that the successfull response took to complete
   */
  duration: number;
  /**
   * Data of the response
   */
  data: T;
}

/**
 * An error response
 */
export interface IErrorResponse extends IResponse<IStringMap<any>> {
  /**
   * Status code for the error
   */
  statusCode: number;
  /**
   * Data about the error
   */
  data?: {
    /**
     * Message for the error
     */
    message?: string;
    /**
     * Type of the error
     */
    type?: string;
    /**
     * A report provided by the search api
     */
    executionReport?: string;
    [key: string]: any;
  };
}

/**
 * Possible options when creating a {@link EndpointCaller}
 */
export interface IEndpointCallerOptions {
  /**
   * The access token to use for this endpoint.
   */
  accessToken?: string;
  /**
   * The username to use to log into this endpoint. Used for basic auth.<br/>
   * Not used if accessToken is provided.
   */
  username?: string;
  /**
   * The password to use to log into this endpoint. Used for basic auth.<br/>
   * Not used if accessToken is provided.
   */
  password?: string;
  /**
   * A function which will allow external code to modify all endpoint call parameters before they are sent by the browser.
   *
   * Used in very specific scenario where the network infrastructure require special request headers to be added or removed, for example.
   */
  requestModifier?: (params: IRequestInfo<any>) => IRequestInfo<any>;

  /**
   * The XmlHttpRequest implementation to use instead of the native one.
   * If not specified, the native one is used.
   */
  xmlHttpRequest?: new () => XMLHttpRequest;

  /**
   * Specifies that the request (and the Coveo Search API) does not need any kind of authentication.<br/>
   * This flag is only needed for specific setups when your requests are being blocked by your browser. If your queries are executing correctly, you do not need to bother.<br/>
   * Setting this flag will prevent the withCredentials option to be set on the XMLHttpRequest, allowing performing cross-domain requests on a server that returns * in the Access-Control-Allow-Origin HTTP header.
   */
  anonymous?: boolean;
}

// In ie8, XMLHttpRequest has no status property, so let's use this enum instead
enum XMLHttpRequestStatus {
  OPENED = XMLHttpRequest.OPENED || 1,
  HEADERS_RECEIVED = XMLHttpRequest.HEADERS_RECEIVED || 2,
  DONE = XMLHttpRequest.DONE || 4
}

/**
 * This class is in charge of calling an endpoint (eg: a {@link SearchEndpoint}).
 *
 * This means it's only uses to execute an XMLHttpRequest (for example), massage the response and check if there are errors.
 *
 * Will execute the call and return a Promise.
 *
 * Call using one of those options :
 *
 * * XMLHttpRequest for recent browser that support CORS, or if the endpoint is on the same origin.
 * * XDomainRequest for older IE browser that do not support CORS.
 * * Jsonp if all else fails, or is explicitly enabled.
 */
export class EndpointCaller implements IEndpointCaller {
  public logger: Logger;

  /**
   * Set this property to true to enable Jsonp call to the endpoint.<br/>
   * Be aware that jsonp is "easier" to setup endpoint wise, but has a lot of drawback and limitation for the client code.<br/>
   * Default to false.
   * @type {boolean}
   */
  public useJsonp = false;

  private static JSONP_ERROR_TIMEOUT = 10000;
  /**
   * Create a new EndpointCaller.
   * @param options Specify the authentication that will be used for this endpoint. Not needed if the endpoint is public and has no authentication
   */
  constructor(public options: IEndpointCallerOptions = {}) {
    this.logger = new Logger(this);
  }

  public static convertJsonToQueryString(json: Record<string, any>): string[] {
    Assert.exists(json);

    return _.chain(json)
      .map((value, key) => {
        if (value != null) {
          const stringValue = _.isObject(value) ? JSON.stringify(value) : value.toString();
          return `${key}=${Utils.safeEncodeURIComponent(stringValue)}`;
        }
        return null;
      })
      .compact()
      .value();
  }

  public static convertJsonToFormBody(json: Record<string, any>): string {
    return this.convertJsonToQueryString(json).join('&');
  }

  /**
   * Generic call to the endpoint using the provided {@link IEndpointCallParameters}.<br/>
   * Internally, will decide which method to use to call the endpoint :<br/>
   * -- XMLHttpRequest for recent browser that support CORS, or if the endpoint is on the same origin.<br/>
   * -- XDomainRequest for older IE browser that do not support CORS.<br/>
   * -- Jsonp if all else fails, or is explicitly enabled.
   * @param params The parameters to use for the call
   * @returns {any} A promise of the given type
   */
  public call<T>(params: IEndpointCallParameters): Promise<ISuccessResponse<T>> {
    let requestInfo: IRequestInfo<T> = {
      url: params.url,
      queryString: params.errorsAsSuccess ? params.queryString.concat(['errorsAsSuccess=1']) : params.queryString,
      requestData: params.requestData,
      requestDataType: params.requestDataType || 'application/x-www-form-urlencoded; charset=UTF-8',
      begun: new Date(),
      method: params.method
    };
    requestInfo.headers = this.buildRequestHeaders(requestInfo);

    if (_.isFunction(this.options.requestModifier)) {
      requestInfo = this.options.requestModifier(requestInfo);
    }

    this.logger.trace('Performing REST request', requestInfo);
    const urlObject = this.parseURL(requestInfo.url);
    // In IE8, hostname and port return "" when we are on the same domain.
    const isLocalHost = window.location.hostname === urlObject.hostname || urlObject.hostname === '';

    const currentPort = window.location.port != '' ? window.location.port : window.location.protocol == 'https:' ? '443' : '80';
    const isSamePort = currentPort == urlObject.port;
    const isCrossOrigin = !(isLocalHost && isSamePort);
    if (!this.useJsonp) {
      if (this.isCORSSupported() || !isCrossOrigin) {
        return this.callUsingXMLHttpRequest(requestInfo, params.responseType);
      } else if (this.isXDomainRequestSupported()) {
        return this.callUsingXDomainRequest(requestInfo);
      } else {
        return this.callUsingAjaxJsonP(requestInfo);
      }
    } else {
      return this.callUsingAjaxJsonP(requestInfo);
    }
  }

  /**
   * Call the endpoint using XMLHttpRequest. Used internally by {@link EndpointCaller.call}.<br/>
   * Will try internally to handle error if it can.<br/>
   * Promise will otherwise fail with the error type.
   * @param requestInfo The info about the request
   * @param responseType The responseType. Default to text. https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType
   * @returns {Promise<T>|Promise}
   */
  public callUsingXMLHttpRequest<T>(requestInfo: IRequestInfo<T>, responseType = 'text'): Promise<ISuccessResponse<T>> {
    return new Promise((resolve, reject) => {
      const xmlHttpRequest = this.getXmlHttpRequest();

      // Beware, most stuff must be set on the event that says the request is OPENED.
      // Otherwise it'll bork on some browsers. Gotta love standards.

      // This sent variable allowed to remove the second call of onreadystatechange with the state OPENED in IE11
      let sent = false;

      xmlHttpRequest.onreadystatechange = ev => {
        if (xmlHttpRequest.readyState == XMLHttpRequestStatus.OPENED && !sent) {
          sent = true;
          xmlHttpRequest.withCredentials = !this.options.anonymous;

          _.each(requestInfo.headers, (headerValue, headerKey) => {
            xmlHttpRequest.setRequestHeader(headerKey, headerValue);
          });

          if (requestInfo.method == 'GET') {
            xmlHttpRequest.send();
          } else if (requestInfo.requestDataType.indexOf('application/json') === 0) {
            xmlHttpRequest.send(JSON.stringify(requestInfo.requestData));
          } else {
            xmlHttpRequest.send(EndpointCaller.convertJsonToFormBody(requestInfo.requestData));
          }

          // The "responseType" varies if the request is a success or not.
          // Therefore we postpone setting "responseType" until we know if the
          // request is a success or not. Doing so, we avoid this potential
          // error in Chrome:
          //
          //   Uncaught InvalidStateError: Failed to read the 'responseText'
          //   property from 'XMLHttpRequest': The value is only accessible if
          //   the object's 'responseType' is '' or 'text' (was 'document').
          //
        } else if (xmlHttpRequest.readyState == XMLHttpRequestStatus.HEADERS_RECEIVED) {
          const status = xmlHttpRequest.status;

          if (this.isSuccessHttpStatus(status)) {
            xmlHttpRequest.responseType = <XMLHttpRequestResponseType>responseType;
          } else {
            xmlHttpRequest.responseType = 'text';
          }
        } else if (xmlHttpRequest.readyState == XMLHttpRequestStatus.DONE) {
          const status = xmlHttpRequest.status;
          let data;
          switch (responseType) {
            case 'json':
              data = xmlHttpRequest.response;
              // Work around a bug in IE11 where responseType jsonis not supported : the response comes back as a plain string
              // Force the json parse manually
              if (responseType == 'json' && DeviceUtils.getDeviceName() == 'IE') {
                try {
                  data = JSON.parse(data);
                } catch (e) {
                  // Do nothing, it probably means the data was JSON already
                }
              }
              break;
            case 'text':
              data = this.tryParseResponseText(xmlHttpRequest.responseText, xmlHttpRequest.getResponseHeader('Content-Type'));
              break;
            default:
              data = xmlHttpRequest.response;
              break;
          }

          if (data == undefined) {
            data = this.tryParseResponseText(xmlHttpRequest.responseText, xmlHttpRequest.getResponseHeader('Content-Type'));
          }

          if (this.isSuccessHttpStatus(status)) {
            this.handleSuccessfulResponseThatMightBeAnError(requestInfo, data, resolve, reject);
          } else {
            this.handleError(requestInfo, xmlHttpRequest.status, data, reject);
          }
        }
      };

      let queryString = requestInfo.queryString;
      if (requestInfo.method == 'GET') {
        queryString = queryString.concat(EndpointCaller.convertJsonToQueryString(requestInfo.requestData));
      }
      xmlHttpRequest.open(requestInfo.method, this.combineUrlAndQueryString(requestInfo.url, queryString));
    });
  }

  /**
   * Call the endpoint using XDomainRequest https://msdn.microsoft.com/en-us/library/cc288060(v=vs.85).aspx<br/>
   * Used for IE8/9
   * @param requestInfo The info about the request
   * @returns {Promise<T>|Promise}
   */
  public callUsingXDomainRequest<T>(requestInfo: IRequestInfo<T>): Promise<ISuccessResponse<T>> {
    return new Promise((resolve, reject) => {
      let queryString = requestInfo.queryString.concat([]);

      // XDomainRequest don't support including stuff in the header, so we must
      // put the access token in the query string if we have one.
      if (this.options.accessToken) {
        queryString.push('access_token=' + Utils.safeEncodeURIComponent(this.options.accessToken));
      }

      const xDomainRequest = new XDomainRequest();
      if (requestInfo.method == 'GET') {
        queryString = queryString.concat(EndpointCaller.convertJsonToQueryString(requestInfo.requestData));
      }
      xDomainRequest.open(requestInfo.method, this.combineUrlAndQueryString(requestInfo.url, queryString));

      xDomainRequest.onload = () => {
        const data = this.tryParseResponseText(xDomainRequest.responseText, xDomainRequest.contentType);
        this.handleSuccessfulResponseThatMightBeAnError(requestInfo, data, resolve, reject);
      };

      xDomainRequest.onerror = () => {
        const data = this.tryParseResponseText(xDomainRequest.responseText, xDomainRequest.contentType);
        this.handleError(requestInfo, 0, data, reject);
      };

      // We must set those functions otherwise it will sometime fail in IE
      xDomainRequest.ontimeout = () => this.logger.error('Request timeout', xDomainRequest, requestInfo.requestData);
      xDomainRequest.onprogress = () => this.logger.trace('Request progress', xDomainRequest, requestInfo.requestData);

      // We must open the request in a separate thread, for obscure reasons
      _.defer(() => {
        if (requestInfo.method == 'GET') {
          xDomainRequest.send();
        } else {
          xDomainRequest.send(EndpointCaller.convertJsonToFormBody(requestInfo.requestData));
        }
      });
    });
  }

  /**
   * Call the endpoint using Jsonp https://en.wikipedia.org/wiki/JSONP<br/>
   * Should be used for dev only, or for very special setup as using jsonp has a lot of drawbacks.
   * @param requestInfo The info about the request
   * @returns {Promise<T>|Promise}
   */
  public callUsingAjaxJsonP<T>(requestInfo: IRequestInfo<T>): Promise<ISuccessResponse<T>> {
    let jQuery = JQueryUtils.getJQuery();
    Assert.check(jQuery, 'Using jsonp without having included jQuery is not supported.');
    return new Promise((resolve, reject) => {
      const queryString = requestInfo.queryString.concat(EndpointCaller.convertJsonToQueryString(requestInfo.requestData));

      // JSONP don't support including stuff in the header, so we must
      // put the access token in the query string if we have one.
      if (this.options.accessToken) {
        queryString.push('access_token=' + Utils.safeEncodeURIComponent(this.options.accessToken));
      }

      queryString.push('callback=?');

      jQuery.ajax({
        url: this.combineUrlAndQueryString(requestInfo.url, queryString),
        dataType: 'jsonp',
        success: (data: any) => this.handleSuccessfulResponseThatMightBeAnError(requestInfo, data, resolve, reject),
        timeout: EndpointCaller.JSONP_ERROR_TIMEOUT,
        error: () => this.handleError(requestInfo, 0, undefined, reject)
      });
    });
  }

  private parseURL(url: string) {
    const urlObject = document.createElement('a');
    urlObject.href = url;
    return urlObject;
  }

  private getXmlHttpRequest(): XMLHttpRequest {
    const newXmlHttpRequest = this.options.xmlHttpRequest || XMLHttpRequest;
    return new newXmlHttpRequest();
  }

  private handleSuccessfulResponseThatMightBeAnError<T>(requestInfo: IRequestInfo<T>, data: any, success, error) {
    if (this.isErrorResponseBody(data)) {
      this.handleError(requestInfo, data.statusCode, data, error);
    } else {
      this.handleSuccess(requestInfo, data, success);
    }
  }

  private handleSuccess<T>(requestInfo: IRequestInfo<T>, data: T, success) {
    const querySuccess: ISuccessResponse<T> = {
      duration: TimeSpan.fromDates(requestInfo.begun, new Date()).getMilliseconds(),
      data
    };

    this.logger.trace('REST request successful', data, requestInfo);
    success(querySuccess);
  }

  private handleError<T>(requestInfo: IRequestInfo<T>, status: number, data: any, error) {
    const queryError: IErrorResponse = {
      statusCode: status,
      data: data
    };
    this.logger.error('REST request failed', status, data, requestInfo);
    error(queryError);
  }

  private combineUrlAndQueryString(url: string, queryString: string[]): string {
    return UrlUtils.normalizeAsString({
      paths: [url],
      queryAsString: queryString
    });
  }

  private isXDomainRequestSupported(): boolean {
    return 'XDomainRequest' in window;
  }

  private isCORSSupported(): boolean {
    return 'withCredentials' in this.getXmlHttpRequest();
  }

  private isSuccessHttpStatus(status: number): boolean {
    return (status >= 200 && status < 300) || status === 304;
  }

  private tryParseResponseText(json: string, contentType: string): any {
    if (contentType != null && contentType.indexOf('application/json') != -1) {
      if (Utils.isNonEmptyString(json)) {
        try {
          return JSON.parse(json);
        } catch (ex) {
          return undefined;
        }
      } else {
        return undefined;
      }
    } else {
      return json;
    }
  }

  private isErrorResponseBody(data: any): boolean {
    if (data && data.statusCode) {
      return !this.isSuccessHttpStatus(data.statusCode);
    } else {
      return false;
    }
  }

  private buildRequestHeaders<T>(requestInfo: IRequestInfo<T>): IStringMap<string> {
    let headers: IStringMap<string> = {};
    if (this.options.accessToken) {
      headers['Authorization'] = `Bearer ${this.options.accessToken}`;
    } else if (this.options.username && this.options.password) {
      headers['Authorization'] = `Basic ${btoa(this.options.username + ':' + this.options.password)}`;
    }

    if (requestInfo.method == 'GET') {
      return headers;
    }

    if (requestInfo.requestDataType.indexOf('application/json') === 0) {
      headers['Content-Type'] = 'application/json; charset=UTF-8';
    } else {
      headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
    }

    return headers;
  }
}
