/*!
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */

/* eslint-disable max-classes-per-file */

import jqXHR = JQuery.jqXHR;
import MatomoUrl from '../MatomoUrl/MatomoUrl';
import Matomo from '../Matomo/Matomo';
import { setCookie } from '../CookieHelper/CookieHelper';

export interface AjaxOptions {
  withTokenInUrl?: boolean;
  postParams?: QueryParameters;
  headers?: Record<string, string>;
  format?: string;
  createErrorNotification?: boolean;
  abortController?: AbortController;
  returnResponseObject?: boolean;
  errorElement?: HTMLElement|JQuery|string;
  redirectOnSuccess?: QueryParameters|boolean;
  abortable?: boolean;
}

interface ErrorResponse {
  result: string;
  message: string;
}

const { $ } = window;

window.globalAjaxQueue = [] as unknown as GlobalAjaxQueue;
window.globalAjaxQueue.active = 0;

window.globalAjaxQueue.clean = function globalAjaxQueueClean() {
  for (let i = this.length; i >= 0; i -= 1) {
    if (!this[i] || this[i]!.readyState === 4) {
      this.splice(i, 1);
    }
  }
};

window.globalAjaxQueue.push = function globalAjaxQueuePush(...args: (XMLHttpRequest|null)[]) {
  this.active += args.length;

  // cleanup ajax queue
  this.clean();

  // call original array push
  return Array.prototype.push.call(this, ...args);
};

window.globalAjaxQueue.abort = function globalAjaxQueueAbort() {
  // abort all queued requests if possible
  this.forEach((x) => x && x.abort && x.abort());

  // remove all elements from array
  this.splice(0, this.length);

  this.active = 0;
};

type AnyFunction = (...params:any[]) => any; // eslint-disable-line

/**
 * error callback to use by default
 */
function defaultErrorCallback(deferred: XMLHttpRequest, status: string): void {
  // do not display error message if request was aborted
  if (status === 'abort' || !deferred || deferred.status === 0) {
    return;
  }

  if (typeof Piwik_Popover === 'undefined') {
    console.log(`Request failed: ${deferred.responseText}`); // mostly for tests
    return;
  }

  if (Piwik_Popover.isOpen() && deferred && deferred.status === 500) {
    $(document.body).html(piwikHelper.escape(deferred.responseText));
  } else {
    $('#loadingError').show();
  }
}

class ApiResponseError extends Error {}

/**
 * Global ajax helper to handle requests within Matomo
 */
export default class AjaxHelper<T = any> { // eslint-disable-line
  /**
   * Format of response
   */
  format = 'json';

  /**
   * A timeout for the request which will override any global timeout
   */
  timeout: number|null = null;

  /**
   * Callback function to be executed on success
   */
  callback: AnyFunction|null = null;

  /**
   * Use this.callback if an error is returned
   */
  useRegularCallbackInCaseOfError = false;

  /**
   * Callback function to be executed on error
   *
   * @deprecated use the jquery promise API
   */
  errorCallback: AnyFunction|null;

  withToken = false;

  /**
   * Callback function to be executed on complete (after error or success)
   *
   * @deprecated use the jquery promise API
   */
  completeCallback?: AnyFunction;

  /**
   * Params to be passed as GET params
   * @see ajaxHelper.mixinDefaultGetParams
   */
  getParams: QueryParameters = {};

  /**
   * Base URL used in the AJAX request. Can be set by setUrl.
   *
   * It is set to '?' rather than 'index.php?' to increase chances that it works
   * including for users who have an automatic 301 redirection from index.php? to ?
   * POST values are missing when there is such 301 redirection. So by by-passing
   * this 301 redirection, we avoid this issue.
   *
   * @see ajaxHelper.setUrl
   */
  getUrl = '?';

  /**
   * Params to be passed as GET params
   * @see ajaxHelper.mixinDefaultPostParams
   */
  postParams: QueryParameters = {};

  /**
   * Element to be displayed while loading
   */
  loadingElement: HTMLElement|null|JQuery|string = null;

  /**
   * Element to be displayed on error
   */
  errorElement: HTMLElement|JQuery|string = '#ajaxError';

  /**
   * Extra headers to add to the request.
   */
  headers?: Record<string, string> = {
    'X-Requested-With': 'XMLHttpRequest',
  };

  /**
   * Handle for current request
   */
  requestHandle: JQuery.jqXHR|null = null;

  abortController: AbortController|null = null;

  abortable = true;

  defaultParams = ['idSite', 'period', 'date', 'segment'];

  resolveWithHelper = false;

  // helper method entry point
  static fetch<R = any>( // eslint-disable-line
    params: QueryParameters|QueryParameters[],
    options: AjaxOptions = {},
  ): Promise<R> {
    const helper = new AjaxHelper<R>();
    if (options.withTokenInUrl) {
      helper.withTokenInUrl();
    }
    if (options.errorElement) {
      helper.setErrorElement(options.errorElement);
    }
    if (options.redirectOnSuccess) {
      helper.redirectOnSuccess(
        options.redirectOnSuccess !== true ? options.redirectOnSuccess : undefined,
      );
    }
    helper.setFormat(options.format || 'json');
    if (Array.isArray(params)) {
      helper.setBulkRequests(...(params as QueryParameters[]));
    } else {
      Object.keys(params).forEach((key) => {
        if (/password/i.test(key)) {
          throw new Error(`Password parameters are not allowed to be sent as GET parameter. Please send ${key} as POST parameter instead.`);
        }
      });

      helper.addParams({
        module: 'API',
        format: options.format || 'json',
        ...params,
        // ajax helper does not encode the segment parameter assuming it is already encoded. this is
        // probably for pre-angularjs code, so we don't want to do this now, but just treat segment
        // as a normal query parameter input (so it will have double encoded values in input params
        // object, then naturally triple encoded in the URL after a $.param call), however we need
        // to support any existing uses of the old code, so instead we do a manual encode here. new
        // code that uses .fetch() will not need to pre-encode the parameter, while old code
        // can pre-encode it.
        segment: params.segment ? encodeURIComponent(params.segment as string) : undefined,
      }, 'get');
    }
    if (options.postParams) {
      helper.addParams(options.postParams, 'post');
    }
    if (options.headers) {
      helper.headers = { ...helper.headers, ...options.headers };
    }

    let createErrorNotification = true;
    if (typeof options.createErrorNotification !== 'undefined'
      && !options.createErrorNotification
    ) {
      helper.useCallbackInCaseOfError();
      helper.setErrorCallback(null);
      createErrorNotification = false;
    }

    if (options.abortController) {
      helper.abortController = options.abortController;
    }

    if (options.returnResponseObject) {
      helper.resolveWithHelper = true;
    }

    if (options.abortable === false) {
      helper.abortable = false;
    }

    return helper.send().then((result: R | ErrorResponse | AjaxHelper) => {
      const data = result instanceof AjaxHelper ? result.requestHandle!.responseJSON : result;

      // check for error if not using default notification behavior
      const results = helper.postParams.method === 'API.getBulkRequest' && Array.isArray(data) ? data : [data];
      const errors = results.filter((r) => r.result === 'error').map((r) => r.message as string);

      if (errors.length) {
        throw new ApiResponseError(errors.filter((e) => e.length).join('\n'));
      }

      return result as R;
    }).catch((xhr: jqXHR) => {
      if (createErrorNotification || xhr instanceof ApiResponseError) {
        throw xhr;
      }

      let message = 'Something went wrong';
      if (xhr.status === 504) {
        message = 'Request was possibly aborted';
      }
      if (xhr.status === 429) {
        message = 'Rate Limit was exceed';
      }
      throw new Error(message);
    });
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  static post<R = any>(
    params: QueryParameters,
    // eslint-disable-next-line
    postParams: any = {},
    options: AjaxOptions = {},
  ): Promise<R> {
    return AjaxHelper.fetch<R>(params, { ...options, postParams });
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  static oneAtATime<R = any>(
    method: string,
    options?: AjaxOptions,
  ): (params: QueryParameters, postParams?: QueryParameters) => Promise<R> {
    let abortController: AbortController|null = null;

    return (params: QueryParameters, postParams?: QueryParameters) => {
      if (abortController) {
        abortController.abort();
      }

      abortController = new AbortController();
      return AjaxHelper.post<R>(
        {
          ...params,
          method,
        },
        postParams,
        {
          ...options,
          abortController,
        },
      ).finally(() => {
        abortController = null;
      });
    };
  }

  constructor() {
    this.errorCallback = defaultErrorCallback;
  }

  /**
   * Adds params to the request.
   * If params are given more then once, the latest given value is used for the request
   *
   * @param  initialParams
   * @param  type  type of given parameters (POST or GET)
   * @return {void}
   */
  addParams(initialParams: QueryParameters|string, type: string): void {
    const params: QueryParameters = typeof initialParams === 'string'
      ? window.broadcast.getValuesFromUrl(initialParams) : initialParams;

    const arrayParams = ['compareSegments', 'comparePeriods', 'compareDates'];
    Object.keys(params).forEach((key) => {
      let value = params[key];
      if (arrayParams.indexOf(key) !== -1
        && !value
      ) {
        return;
      }

      if (typeof value === 'boolean') {
        value = value ? 1 : 0;
      }

      if (type.toLowerCase() === 'get') {
        this.getParams[key] = value;
      } else if (type.toLowerCase() === 'post') {
        this.postParams[key] = value;
      }
    });
  }

  withTokenInUrl(): void {
    this.withToken = true;
  }

  /**
   * Sets the base URL to use in the AJAX request.
   */
  setUrl(url: string): void {
    this.addParams(broadcast.getValuesFromUrl(url), 'GET');
  }

  /**
   * Gets this helper instance ready to send a bulk request. Each argument to this
   * function is a single request to use.
   */
  setBulkRequests(...urls: Array<string|QueryParameters>): void {
    const urlsProcessed = urls.map((u) => (typeof u === 'string' ? u : $.param(u)));

    this.addParams({
      module: 'API',
      method: 'API.getBulkRequest',
      urls: urlsProcessed,
      format: 'json',
    }, 'post');
  }

  /**
   * Set a timeout (in milliseconds) for the request. This will override any global timeout.
   *
   * @param timeout  Timeout in milliseconds
   */
  setTimeout(timeout: number): void {
    this.timeout = timeout;
  }

  /**
   * Sets the callback called after the request finishes
   *
   * @param callback  Callback function
   * @deprecated use the jquery promise API
   */
  setCallback(callback: AnyFunction): void {
    this.callback = callback;
  }

  /**
   * Set that the callback passed to setCallback() should be used if an application error (i.e. an
   * Exception in PHP) is returned.
   */
  useCallbackInCaseOfError(): void {
    this.useRegularCallbackInCaseOfError = true;
  }

  /**
   * Set callback to redirect on success handler
   * &update=1(+x) will be appended to the current url
   *
   * @param [params] to modify in redirect url
   * @return {void}
   */
  redirectOnSuccess(params?: QueryParameters): void {
    this.setCallback(() => {
      piwikHelper.redirect(params);
    });
  }

  /**
   * Sets the callback called in case of an error within the request
   *
   * @deprecated use the jquery promise API
   */
  setErrorCallback(callback: AnyFunction|null): void {
    this.errorCallback = callback;
  }

  /**
   * Sets the complete callback which is called after an error or success callback.
   *
   * @deprecated use the jquery promise API
   */
  setCompleteCallback(callback: AnyFunction): void {
    this.completeCallback = callback;
  }

  /**
   * Sets the response format for the request
   *
   * @param format  response format (e.g. json, html, ...)
   */
  setFormat(format: string): void {
    this.format = format;
  }

  /**
   * Set the div element to show while request is loading
   *
   * @param [element]  selector for the loading element
   */
  setLoadingElement(element: string|HTMLElement|JQuery): void {
    this.loadingElement = element || '#ajaxLoadingDiv';
  }

  /**
   * Set the div element to show on error
   *
   * @param element  selector for the error element
   */
  setErrorElement(element: HTMLElement|JQuery|string): void {
    if (!element) {
      return;
    }
    this.errorElement = element;
  }

  /**
   * Detect whether are allowed to use the given default parameter or not
   */
  private useGETDefaultParameter(parameter: string): boolean {
    if (parameter && this.defaultParams) {
      for (let i = 0; i < this.defaultParams.length; i += 1) {
        if (this.defaultParams[i] === parameter) {
          return true;
        }
      }
    }

    return false;
  }

  /**
   * Removes a default parameter that is usually send automatically along the request.
   *
   * @param parameter  A name such as "period", "date", "segment".
   */
  removeDefaultParameter(parameter: string): void {
    if (parameter && this.defaultParams) {
      for (let i = 0; i < this.defaultParams.length; i += 1) {
        if (this.defaultParams[i] === parameter) {
          this.defaultParams.splice(i, 1);
        }
      }
    }
  }

  /**
   * Send the request
   */
  send(): Promise<T | ErrorResponse> {
    if ($(this.errorElement).length) {
      $(this.errorElement).hide();
    }

    if (this.loadingElement) {
      $(this.loadingElement).fadeIn();
    }

    this.requestHandle = this.buildAjaxCall();
    if (this.abortable) {
      window.globalAjaxQueue.push(this.requestHandle);
    }

    if (this.abortController) {
      this.abortController.signal.addEventListener('abort', () => {
        if (this.requestHandle) {
          this.requestHandle.abort();
        }
      });
    }

    const result = new Promise<T | ErrorResponse>((resolve, reject) => {
      this.requestHandle!.then((data: unknown) => {
        if (this.resolveWithHelper) {
          // NOTE: we can't resolve w/ the jquery xhr, because it's a promise, and will
          // just result in following the promise chain back to 'data'
          resolve(this as unknown as (T | ErrorResponse)); // casting hack here
        } else {
          resolve(data as (T | ErrorResponse)); // ignoring textStatus/jqXHR
        }
      }).fail((xhr: jqXHR) => {
        if (xhr.status === 429) {
          console.log(`Warning: the '${$.param(this.getParams)}' request was rate limited!`);
          reject(xhr);
          return;
        }

        if (xhr.statusText === 'abort' || xhr.status === 0) {
          return;
        }

        const isInApp = !document.querySelector('#login_form');
        const sessionTimedOut = xhr.getResponseHeader('X-Matomo-Session-Timed-Out') === '1';

        if (sessionTimedOut && isInApp) {
          setCookie('matomo_session_timed_out', '1', 60 * 1000);
          Matomo.helper.refreshAfter(0);
          return;
        }

        console.log(`Warning: the ${$.param(this.getParams)} request failed!`);

        reject(xhr);
      });
    });

    return result;
  }

  /**
   * Aborts the current request if it is (still) running
   */
  abort(): void {
    if (this.requestHandle && typeof this.requestHandle.abort === 'function') {
      this.requestHandle.abort();
      this.requestHandle = null;
    }
  }

  /**
   * Builds and sends the ajax requests
   */
  private buildAjaxCall(): JQuery.jqXHR {
    const self = this;
    const parameters = this.mixinDefaultGetParams(this.getParams);

    let url = this.getUrl;
    if (url[url.length - 1] !== '?') {
      url += '&';
    }

    // we took care of encoding &segment properly already, so we don't use $.param for it ($.param
    // URL encodes the values)
    if (parameters.segment) {
      url = `${url}segment=${parameters.segment}&`;
      delete parameters.segment;
    }
    if (parameters.date) {
      url = `${url}date=${decodeURIComponent(parameters.date.toString())}&`;
      delete parameters.date;
    }
    url += $.param(parameters);
    const ajaxCall = {
      type: 'POST',
      async: true,
      url,
      dataType: this.format || 'json',
      complete: this.completeCallback,
      headers: this.headers ? this.headers : undefined,
      error: function errorCallback(...args: any[]) { // eslint-disable-line
        if (self.abortable) {
          window.globalAjaxQueue.active -= 1;
        }

        if (self.errorCallback) {
          self.errorCallback.apply(this, args);
        }
      },
      success: (response: any, status: string, request: jqXHR) => { // eslint-disable-line
        if (this.loadingElement) {
          $(this.loadingElement).hide();
        }

        const results = this.postParams.method === 'API.getBulkRequest' && Array.isArray(response) ? response : [response];
        const errors = results.filter((r) => r.result === 'error')
          .map((r) => r.message as string)
          .filter((e) => e.length)
          // count occurrences of error messages
          .reduce((acc: Record<string, number>, e: string) => {
            acc[e] = (acc[e] || 0) + 1;
            return acc;
          }, {});

        if (errors && Object.keys(errors).length && !this.useRegularCallbackInCaseOfError) {
          let errorMessage = '';
          Object.keys(errors).forEach((error) => {
            if (errorMessage.length) {
              errorMessage += '<br />';
            }
            // append error count if it occured more than once
            if (errors[error] > 1) {
              errorMessage += `${error} (${errors[error]}x)`;
            } else {
              errorMessage += error;
            }
          });
          let placeAt = null;
          let type: string|null = 'toast';
          if ($(this.errorElement).length && errorMessage.length) {
            $(this.errorElement).show();
            placeAt = this.errorElement;
            type = null;
          }

          const isLoggedIn = !document.querySelector('#login_form');
          if (errorMessage && isLoggedIn) {
            const UI = window['require']('piwik/UI'); // eslint-disable-line
            const notification = new UI.Notification();
            notification.show(errorMessage, {
              placeat: placeAt,
              context: 'error',
              type,
              id: 'ajaxHelper',
            });
            notification.scrollToNotification();
          }
        } else if (this.callback) {
          this.callback(response, status, request);
        }

        if (self.abortable) {
          window.globalAjaxQueue.active -= 1;
        }
        if (Matomo.ajaxRequestFinished) {
          Matomo.ajaxRequestFinished();
        }
      },
      data: this.mixinDefaultPostParams(this.postParams),
      timeout: this.timeout !== null ? this.timeout : undefined,
    };

    return $.ajax(ajaxCall);
  }

  private isRequestToApiMethod() {
    return (this.getParams && this.getParams.module === 'API' && this.getParams.method)
      || (this.postParams && this.postParams.module === 'API' && this.postParams.method);
  }

  isWidgetizedRequest(): boolean {
    return (broadcast.getValueFromUrl('module') === 'Widgetize');
  }

  private getDefaultPostParams() {
    if (this.withToken || this.isRequestToApiMethod() || Matomo.shouldPropagateTokenAuth) {
      return {
        token_auth: Matomo.token_auth,
        // When viewing a widgetized report there won't be any session that can be used, so don't
        // force session usage
        force_api_session: broadcast.isWidgetizeRequestWithoutSession() ? 0 : 1,
      };
    }

    return {};
  }

  /**
   * Mixin the default parameters to send as POST
   *
   * @param params   parameter object
   */
  private mixinDefaultPostParams(params: QueryParameters): QueryParameters {
    const defaultParams = this.getDefaultPostParams();

    const mergedParams = {
      ...defaultParams,
      ...params,
    };

    return mergedParams;
  }

  /**
   * Mixin the default parameters to send as GET
   *
   * @param   params   parameter object
   */
  public mixinDefaultGetParams(originalParams: QueryParameters): QueryParameters {
    const segment = MatomoUrl.getSearchParam('segment');

    const defaultParams: Record<string, string> = {
      idSite: Matomo.idSite ? Matomo.idSite.toString() : broadcast.getValueFromUrl('idSite'),
      period: Matomo.period || broadcast.getValueFromUrl('period'),
      segment,
    };

    const params = originalParams;

    // never append token_auth to url
    if (params.token_auth) {
      params.token_auth = null;
      delete params.token_auth;
    }

    Object.keys(defaultParams).forEach((key) => {
      if (this.useGETDefaultParameter(key)
        && (params[key] === null || typeof params[key] === 'undefined' || params[key] === '')
        && (this.postParams[key] === null
          || typeof this.postParams[key] === 'undefined'
          || this.postParams[key] === '')
        && defaultParams[key]
      ) {
        params[key] = defaultParams[key];
      }
    });

    // handle default date & period if not already set
    if (this.useGETDefaultParameter('date') && !params.date && !this.postParams.date) {
      params.date = Matomo.currentDateString;
    }

    return params;
  }

  getRequestHandle(): jqXHR|null {
    return this.requestHandle;
  }
}
