import { jsonp } from './jsonp';
import { DeferredPromise } from '../types';

('use strict');

const _Number = {
  /**
   * @param {number} n - A certain number
   * @returns {boolean} - If the input is a number
   */
  isNumber: function (n: number): boolean {
    return Number(n) === n;
  },

  /**
   * @param {number} n - A certain number
   * @returns {boolean} - If the input is an integer
   */
  isInt: function (n: number): boolean {
    return this.isNumber(n) && n % 1 === 0;
  },

  /**
   * @param {number} n - A certain number
   * @returns {boolean} - If the input is a float
   */
  isFloat: function (n: number): boolean {
    return this.isNumber(n) && n % 1 !== 0;
  }
};

const _String = {
  /**
   * Uppercase the first letter of a string
   * @param  {String} string - String to be uppercased
   * @return {String} - The uppercased string
   * @public
   * @method toTitleCase
   */
  capitlize: function (string: string): string {
    if (typeof string !== 'string') {
      return string;
    }
    return string.charAt(0).toUpperCase() + string.slice(1);
  },

  /**
   * @param {string} string - Certain string
   * @param {string} searchString - Certain string
   * @returns {boolean} - Whether the string: string is ending with string: searchString
   */
  endsWith: function (string: string, searchString: string): boolean {
    if (typeof string !== 'string' || typeof searchString !== 'string') {
      return false;
    }
    return string.indexOf(searchString, string.length - searchString.length) !== -1;
  }
};

const _Object = {
  /**
   * @param {Array<Object>} objects - The objects to merge
   * @returns {Object} - The merged object.
   */
  merge: function (objects: Array<any>): any {
    const target = {};
    for (const obj of objects) {
      Object.assign(target, obj);
    }
    return target;
  },

  /**
   * @param {any} item - The item to check.
   * @returns {boolean} - Whether the item is an object.
   */
  isObject: function (item: any): boolean {
    return item && typeof item === 'object' && !Array.isArray(item);
  },

  /**
   * @param {any} item - The item to check if it's class
   * @returns {boolean} - Whether the item is a class
   */
  isClassInstance: function (item: any): boolean {
    return item && item.constructor && item.constructor.name && item.constructor.name !== 'Object';
  },

  /**
   * @param {any} target - The target object.
   * @param {any} sources - The objects to merge.
   * @returns {Object} - The merged object.
   */
  mergeDeep: function (target: any, ...sources: any): any {
    if (!sources.length) {
      return target;
    }
    const source = sources.shift();
    if (this.isObject(target) && this.isObject(source)) {
      for (const key in source) {
        if (this.isObject(source[key]) && !this.isClassInstance(source[key])) {
          if (!target[key]) Object.assign(target, { [key]: {} });
          this.mergeDeep(target[key], source[key]);
        } else {
          Object.assign(target, { [key]: source[key] });
        }
      }
    }
    return this.mergeDeep(target, ...sources);
  },

  /**
   * @param {any} data - The data to copy.
   * @returns {any} - The copied data.
   */
  copyDeep: function (data: any): any {
    let node;
    if (Array.isArray(data)) {
      node = data.length > 0 ? data.slice(0) : [];
      node.forEach((e, i) => {
        if (typeof e === 'object' || (Array.isArray(e) && e.length > 0)) {
          node[i] = this.copyDeep(e);
        }
      });
    } else if (data !== null && typeof data === 'object') {
      if (data.clone && typeof data.clone === 'function') {
        node = data.clone();
      } else {
        node = Object.assign({ __proto__: data.__proto__ }, data);
        Object.keys(node).forEach((key) => {
          if (typeof node[key] === 'object' || (Array.isArray(node[key]) && node[key].length > 0)) {
            node[key] = this.copyDeep(node[key]);
          }
        });
      }
    } else {
      node = data;
    }
    return node;
  },

  /**
   * Checks if an object is an empy object.
   * @param {Object} obj - The object to check
   * @returns {boolean} - Whether the object is empty.
   */
  isEmptyObject: function (obj: object): boolean {
    for (const key in obj) {
      if (Object.hasOwnProperty.call(obj, key)) return false;
    }
    return true;
  },

  /**
   * Checks for nested object properties.
   * @param {Object} obj - The object to check.
   * @param {string} propertyPath - The path to check.
   * @returns {boolean} - The value in this path.
   */
  getPropertyPath: function (obj: object, propertyPath: string): any {
    // eslint-disable-next-line
    return propertyPath.split('.').reduce(function (o, x) {
      return typeof o === 'undefined' || o === null ? o : o[x];
    }, obj);
  },

  /**
   * Checks for nested object properties.
   * @param {Object} obj - The object to check.
   * @param {string} propertyPath - The path to check.
   * @returns {boolean} - Whether the path exists in the object.
   */
  hasPropertyPath: function (obj: object, propertyPath: string): boolean {
    if (!propertyPath) {
      return false;
    }
    const properties = propertyPath.split('.');
    for (let i = 0; i < properties.length; i++) {
      const prop = properties[i];
      if (!obj || !Object.hasOwnProperty.call(obj, prop)) {
        return false;
      } else {
        obj = obj[prop];
      }
    }
    return true;
  },

  /**
   * Create an object with a given property path.
   * @param {Object} obj - The object to create on.
   * @param {string} path - The path to create in the object.
   * @param {any} value - The value to set in the path.
   * @returns {Object} - The result object.
   */
  createPropertyPath: function (obj: object, path: string, value: any = null): any {
    let pathArray = path.split('.');
    let current = obj;
    while (pathArray.length > 1) {
      const [head, ...tail] = pathArray;
      pathArray = tail;
      if (current[head] === undefined) {
        current[head] = {};
      }
      current = current[head];
    }
    current[pathArray[0]] = value;
    return obj;
  },

  /**
   * Deleted a property path from an object.
   * @param {Object} obj - The object to delete the property path from.
   * @param {string} path - The path to delete in the object.
   * @returns {void}
   */
  deletePropertyPath: function (obj: object, path: string): void {
    if (!obj || !path) {
      return;
    }
    const pathArray = path.split('.');
    for (let i = 0; i < pathArray.length - 1; i++) {
      obj = obj[pathArray[i]];
      if (typeof obj === 'undefined') {
        return;
      }
    }
    // @ts-expect-error - 'undefined' cannot be used as an index type
    delete obj[pathArray.pop()];
  },

  /**
   * Creates deferred promise which can resolved/rejected outside the promise scope.
   * @returns {DeferredPromise} - The promise with resolve and reject props.
   */
  defer: function (): DeferredPromise {
    let res, rej;
    const promise = new Promise<any>((resolve, reject) => {
      res = resolve;
      rej = reject;
    });
    promise['resolve'] = res;
    promise['reject'] = rej;
    return promise as unknown as DeferredPromise;
  },

  /**
   * Binds an handler to a desired context.
   * @param {any} thisObj - The handler context.
   * @param {Function} fn - The handler.
   * @returns {Function} - The new bound function.
   * @public
   */
  bind: function (thisObj: any, fn: (...args: any[]) => any): (...args: any[]) => any {
    return function () {
      // eslint-disable-next-line  prefer-rest-params
      fn.apply(thisObj, arguments);
    };
  }
};

const _Generator = {
  /**
   * Generates unique id.
   * @param {number} length - The length of the id.
   * @returns {string} - The generated id.
   */
  uniqueId: function (length?: number): string {
    const from = 2;
    const to = from + (!length || length < 0 ? 0 : length - 2);
    return '_' + Math.random().toString(36).slice(from, from + to);
  },

  /**
   * Generates GUID.
   * @return {string} - GUID
   * @private
   */
  guid: function (): string {
    const S4 = () : string => {
      return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
    };
    return S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4();
  }
};

const _Dom = {
  /**
   * Adds class name to an element
   * @param {Element} element - an HTML element
   * @param {string} className - a class name
   * @returns {void}
   */
  addClassName(element: HTMLElement, className: string): void {
    if (element) {
      if (element.classList) {
        element.classList.add(className);
      } else {
        if (!_Dom.hasClassName(element, className)) {
          element.className += className;
        }
      }
    }
  },

  /**
   * Removes class name from an element
   * @param {Element} element - an HTML element
   * @param {string} className - a class name
   * @returns {void}
   */
  removeClassName(element: HTMLElement, className: string): void {
    if (element) {
      if (element.classList) {
        element.classList.remove(className);
      } else {
        if (_Dom.hasClassName(element, className)) {
          element.className = element.className.replace(new RegExp('(\\s|^)' + className + '(\\s|$)'), ' ').replace(/^\s+|\s+$/g, '');
        }
      }
    }
  },
  /**
   * Checks if an element has a class name
   * @param {Element} element - an HTML element
   * @param {string} className - a class name
   * @returns {boolean} - weather an element contains a class name
   */
  hasClassName(element: HTMLElement, className: string): boolean {
    return (element && element.className && new RegExp('(^|\\s)' + className + '(\\s|$)').test(element.className)) as boolean;
  },
  /**
   * Add element attribute
   * @param {Element} element - an HTML element
   * @param {string} name - attribute name
   * @param {string} value - attribute value
   * @returns {void}
   */
  setAttribute(element: HTMLElement, name: string, value: string): void {
    element.setAttribute(name, value);
  },
  /**
   * Remove element attribute
   * @param {Element} element - an HTML element
   * @param {string} name - attribute name
   * @returns {void}
   */
  removeAttribute(element: HTMLElement, name: string): void {
    element.removeAttribute(name);
  },
  /**
   * Set element style
   * @param {Element} element - an HTML element
   * @param {string} name - style name
   * @param {string} value - style value
   * @returns {void}
   */
  setStyle(element: HTMLElement, name: string, value: string): void {
    if (element.style.getPropertyValue(name) !== undefined) {
      element.style.setProperty(name, value);
    }
  },
  /**
   * Adds a node to the end of the list of children of a specified parent node.
   * @param {Element} parent - The parent node.
   * @param {Element} child - The child node.
   * @returns {void}
   */
  appendChild(parent?: HTMLElement, child?: HTMLElement): void {
    if (parent && child && parent.appendChild) {
      parent.appendChild(child);
    }
  },
  /**
   * Removes an element from his parent node.
   * @param {Element} parent - The parent node.
   * @param {Element} child - The child node.
   * @returns {void}
   */
  removeChild(parent?: Node | null, child?: HTMLElement): void {
    if (parent && child && parent.removeChild) {
      parent.removeChild(child);
    }
  },
  /**
   * Prepend HTML element
   * @param {HTMLElement} child - the child to prepend
   * @param {HTMLElement} parent - the parent to preprend to
   * @returns {void}
   */
  prependTo(child: HTMLElement, parent: HTMLElement): void {
    if (parent.firstChild) {
      parent.insertBefore(child, parent.firstChild);
    } else {
      parent.appendChild(child);
    }
  },
  /**
   * Returns a reference to the element by its ID.
   * @param {string} id - The desired id.
   * @returns {Element} - The element with the desired id.
   */
  getElementById(id: string): any {
    return document.getElementById(id);
  },
  /**
   * Returns a live HTMLCollection of elements with the given tag name.
   * @param {string} tagName - The desired tag name.
   * @returns {Element} - The elements with the desired tag name.
   */
  getElementsByTagName(tagName: string): any {
    return document.getElementsByTagName(tagName);
  },
  /**
   * Creates the HTML element specified by tagName.
   * @param {string} tagName - The tag name.
   * @returns {Element} - The element just created.
   */
  createElement(tagName: string): any {
    return document.createElement(tagName);
  },
  /**
   * Loads an external style sheet asynchronously.
   * @param {string} url - The css url to load.
   * @return {Promise} - The loading promise.
   * @public
   */
  loadStyleSheetAsync(url: string): Promise<HTMLLinkElement> {
    return new Promise((resolve, reject) => {
      let resolved = false;
      const cssLinkElement = this.createElement('link');
      cssLinkElement.type = 'text/css';
      cssLinkElement.rel = 'stylesheet';
      cssLinkElement.href = url;
      cssLinkElement.async = true;
      cssLinkElement.onload = cssLinkElement.onreadystatechange = function (): void {
        if (!resolved && (!this.readyState || this.readyState === 'complete')) {
          resolved = true;
          resolve(this);
        }
      };
      cssLinkElement.onerror = cssLinkElement.onabort = reject;
      _Dom.appendChild(document.head, cssLinkElement);
    });
  },
  /**
   * Loads script asynchronously.
   * @param {string} url - The url to load.
   * @return {Promise} - The loading promise.
   * @public
   */
  loadScriptAsync(url: string): Promise<any> {
    return new Promise((resolve, reject) => {
      let r = false;
      const t = document.getElementsByTagName('script')[0];
      const s = this.createElement('script');
      s.type = 'text/javascript';
      s.src = url;
      s.async = true;
      s.onload = s.onreadystatechange = function (): void {
        if (!r && (!this.readyState || this.readyState === 'complete')) {
          r = true;
          resolve(this);
        }
      };
      s.onerror = s.onabort = reject;
      if (t && t.parentNode) {
        t.parentNode.insertBefore(s, t);
      }
    });
  },
  /**
   * Returns the first element that matches a specified CSS selector(s) in the document.
   * @param {string} selector - One or more CSS selectors to match the element.
   * @returns {Element} - The first element that matches a specified CSS selector(s) in the document.
   */
  getElementBySelector(selector: string): any {
    try {
      return document.querySelector(selector);
    } catch (e) {
      return;
    }
  },
  /**
   * Inserts a node as a child, right before an existing child.
   * @param {HTMLElement} parent -  The parent node object.
   * @param {HTMLElement} newChild -  The node object to insert.
   * @param {?HTMLElement} existingChild - The child node to insert the new node before. If set to null, the insertBefore method will insert the newChild at the end.
   * @returns {Element} - The first element that matches a specified CSS selector(s) in the document.
   */
  insertBefore(parent: HTMLElement, newChild: HTMLElement, existingChild: HTMLElement | null = null): HTMLElement | null {
    try {
      return parent.insertBefore(newChild, existingChild);
    } catch (e) {
      return null;
    }
  }
};

const _Http = {
  protocol: /^(https?:)/i.test(document.location.protocol) ? document.location.protocol : 'https:',
  execute: function (url: string, params: any, method: string = 'POST', headers?: Map<string, string>, timeout?: number, ontimeout?: (...args: any[]) => any): Promise<any> {
    const request = new XMLHttpRequest();
    return new Promise((resolve, reject) => {
      request.onreadystatechange = function (): void {
        if (request.readyState === 4) {
          if (request.status === 200) {
            try {
              const jsonResponse = JSON.parse(request.responseText);
              resolve(jsonResponse);
            } catch (e) {
              resolve(request.responseText);
            }
          } else {
            reject(request.responseText);
          }
        }
      };
      request.open(method, url);
      if (headers) {
        headers.forEach((value, key) => {
          request.setRequestHeader(key, value);
        });
      }
      if (timeout) {
        request.timeout = timeout;
      }
      if (ontimeout) {
        request.ontimeout = (e): void => {
          ontimeout();
          reject(e);
        };
      }
      request.send(params);
    });
  },
  jsonp,
  convertHeadersToDictionary: function (headerRow: string): { [header: string]: string } {
    const headerMap = {};
    try {
      // Convert the header string into an array of individual headers
      const arr = headerRow.trim().split(/[\r\n]+/);

      arr.forEach((line) => {
        const parts = line.split(': ');
        // @ts-expect-error - Object is possibly 'undefined'
        const header = parts.shift().toLowerCase();
        const value = parts.join(': ');
        headerMap[header] = value;
      });
    } catch (e) {
      // do nothing
    }
    return headerMap;
  }
};

const _VERSION = {
  /**
   * Compares two software version numbers (e.g. "1.7.1" or "1.2b").
   *
   * @param {string} v1 The first version to be compared.
   * @param {string} v2 The second version to be compared.
   * @param {object} [options] Optional flags that affect comparison behavior:
   * lexicographical: (true/[false]) compares each part of the version strings lexicographically instead of naturally;
   *                  this allows suffixes such as "b" or "dev" but will cause "1.10" to be considered smaller than "1.2".
   * zeroExtend: ([true]/false) changes the result if one version string has less parts than the other. In
   *             this case the shorter string will be padded with "zero" parts instead of being considered smaller.
   *
   * @returns {number|NaN}
   * - 0 if the versions are equal
   * - a negative integer iff v1 < v2
   * - a positive integer iff v1 > v2
   * - NaN if either version string is in the wrong format
   */

  compare: function (v1: string, v2: string, options: any = {}): number {
    options = _Object.merge([{ lexicographical: false, zeroExtend: true }, options]);
    const lexicographical = options.lexicographical;
    const zeroExtend = options.zeroExtend;
    let v1parts: (string | number)[] = (v1 || '0').split('.');
    let v2parts: (string | number)[] = (v2 || '0').split('.');

    const isValidPart = (x): boolean => {
      return (lexicographical ? /^\d+[A-Za-zαß]*$/ : /^\d+[A-Za-zαß]?$/).test(x);
    };

    const mapParts = (parts: Array<string>): number[] => {
      return parts.map((x) => {
        const match = /[A-Za-zαß]/.exec(x);
        return Number(match ? x.replace(match[0], '.' + x.charCodeAt(match.index)) : x);
      });
    };

    if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) {
      return NaN;
    }

    if (zeroExtend) {
      while (v1parts.length < v2parts.length) v1parts.push('0');
      while (v2parts.length < v1parts.length) v2parts.push('0');
    }

    if (!lexicographical) {
      v1parts = mapParts(v1parts as string[]);
      v2parts = mapParts(v2parts as string[]);
    }

    for (let i = 0; i < v1parts.length; ++i) {
      if (v2parts.length === i) {
        return 1;
      }

      if (v1parts[i] === v2parts[i]) {
        continue;
      }
      // $FlowFixMe
      else if (v1parts[i] > v2parts[i]) {
        return 1;
      } else {
        return -1;
      }
    }

    if (v1parts.length !== v2parts.length) {
      return -1;
    }

    return 0;
  }
};

export { _Number as Number, _String as String, _Object as Object, _Generator as Generator, _Dom as Dom, _Http as Http, _VERSION as VERSION };
