/**
 * Class Util
 */

import { nanoid } from 'nanoid';
import Dom from './dom';

/**
 * Possible log levels
 */
export enum LogLevels {
  VERBOSE = 'VERBOSE',
  INFO = 'INFO',
  WARN = 'WARN',
  ERROR = 'ERROR',
}

/**
 * Allow to use global VERSION, that will be overwritten by Webpack
 */
declare const VERSION: string;

/**
 * @typedef {object} ChainData
 * @property {object} data - data that will be passed to the success or fallback
 * @property {Function} function - function's that must be called asynchronously
 * @interface ChainData
 */
export interface ChainData {
  data?: object;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  function: (...args: any[]) => any;
}

/**
 * Editor.js utils
 */

/**
 * Returns basic key codes as constants
 *
 * @returns {{}}
 */
export const keyCodes = {
  BACKSPACE: 8,
  TAB: 9,
  ENTER: 13,
  SHIFT: 16,
  CTRL: 17,
  ALT: 18,
  ESC: 27,
  SPACE: 32,
  LEFT: 37,
  UP: 38,
  DOWN: 40,
  RIGHT: 39,
  DELETE: 46,
  META: 91,
  SLASH: 191,
};

/**
 * Return mouse buttons codes
 */
export const mouseButtons = {
  LEFT: 0,
  WHEEL: 1,
  RIGHT: 2,
  BACKWARD: 3,
  FORWARD: 4,
};

/**
 * Custom logger
 *
 * @param {boolean} labeled — if true, Editor.js label is shown
 * @param {string} msg  - message
 * @param {string} type - logging type 'log'|'warn'|'error'|'info'
 * @param {*} [args]      - argument to log with a message
 * @param {string} style  - additional styling to message
 */
function _log(
  labeled: boolean,
  msg: string,
  type = 'log',
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  args?: any,
  style = 'color: inherit'
): void {
  if (!('console' in window) || !window.console[type]) {
    return;
  }

  const isSimpleType = ['info', 'log', 'warn', 'error'].includes(type);
  const argsToPass = [];

  switch (_log.logLevel) {
    case LogLevels.ERROR:
      if (type !== 'error') {
        return;
      }
      break;

    case LogLevels.WARN:
      if (!['error', 'warn'].includes(type)) {
        return;
      }
      break;

    case LogLevels.INFO:
      if (!isSimpleType || labeled) {
        return;
      }
      break;
  }

  if (args) {
    argsToPass.push(args);
  }

  const editorLabelText = `Editor.js ${VERSION}`;
  const editorLabelStyle = `line-height: 1em;
            color: #006FEA;
            display: inline-block;
            font-size: 11px;
            line-height: 1em;
            background-color: #fff;
            padding: 4px 9px;
            border-radius: 30px;
            border: 1px solid rgba(56, 138, 229, 0.16);
            margin: 4px 5px 4px 0;`;

  if (labeled) {
    if (isSimpleType) {
      argsToPass.unshift(editorLabelStyle, style);
      msg = `%c${editorLabelText}%c ${msg}`;
    } else {
      msg = `( ${editorLabelText} )${msg}`;
    }
  }

  try {
    if (!isSimpleType) {
      console[type](msg);
    } else if (args) {
      console[type](`${msg} %o`, ...argsToPass);
    } else {
      console[type](msg, ...argsToPass);
    }
  } catch (ignored) {}
}

/**
 * Current log level
 */
_log.logLevel = LogLevels.VERBOSE;

/**
 * Set current log level
 *
 * @param {LogLevels} logLevel - log level to set
 */
export function setLogLevel(logLevel: LogLevels): void {
  _log.logLevel = logLevel;
}

/**
 * _log method proxy without Editor.js label
 */
export const log = _log.bind(window, false);

/**
 * _log method proxy with Editor.js label
 */
export const logLabeled = _log.bind(window, true);

/**
 * Return string representation of the object type
 *
 * @param {*} object - object to get type
 * @returns {string}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function typeOf(object: any): string {
  return Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/)[1].toLowerCase();
}

/**
 * Check if passed variable is a function
 *
 * @param {*} fn - function to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isFunction(fn: any): fn is (...args: any[]) => any {
  return typeOf(fn) === 'function' || typeOf(fn) === 'asyncfunction';
}

/**
 * Checks if passed argument is an object
 *
 * @param {*} v - object to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isObject(v: any): v is object {
  return typeOf(v) === 'object';
}

/**
 * Checks if passed argument is a string
 *
 * @param {*} v - variable to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isString(v: any): v is string {
  return typeOf(v) === 'string';
}

/**
 * Checks if passed argument is boolean
 *
 * @param {*} v - variable to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isBoolean(v: any): v is boolean {
  return typeOf(v) === 'boolean';
}

/**
 * Checks if passed argument is number
 *
 * @param {*} v - variable to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isNumber(v: any): v is number {
  return typeOf(v) === 'number';
}

/**
 * Checks if passed argument is undefined
 *
 * @param {*} v - variable to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isUndefined(v: any): v is undefined {
  return typeOf(v) === 'undefined';
}

/**
 * Check if passed function is a class
 *
 * @param {Function} fn - function to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isClass(fn: any): boolean {
  return isFunction(fn) && /^\s*class\s+/.test(fn.toString());
}

/**
 * Checks if object is empty
 *
 * @param {object} object - object to check
 * @returns {boolean}
 */
export function isEmpty(object: object): boolean {
  if (!object) {
    return true;
  }

  return Object.keys(object).length === 0 && object.constructor === Object;
}

/**
 * Check if passed object is a Promise
 *
 * @param  {*}  object - object to check
 * @returns {boolean}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isPromise(object: any): object is Promise<any> {
  return Promise.resolve(object) === object;
}

/* eslint-disable @typescript-eslint/no-magic-numbers */
/**
 * Returns true if passed key code is printable (a-Z, 0-9, etc) character.
 *
 * @param {number} keyCode - key code
 * @returns {boolean}
 */
export function isPrintableKey(keyCode: number): boolean {
  return (keyCode > 47 && keyCode < 58) || // number keys
    keyCode === 32 || keyCode === 13 || // Space bar & return key(s)
    keyCode === 229 || // processing key input for certain languages — Chinese, Japanese, etc.
    (keyCode > 64 && keyCode < 91) || // letter keys
    (keyCode > 95 && keyCode < 112) || // Numpad keys
    (keyCode > 185 && keyCode < 193) || // ;=,-./` (in order)
    (keyCode > 218 && keyCode < 223); // [\]' (in order)
}
/* eslint-enable @typescript-eslint/no-magic-numbers */

/**
 * Fires a promise sequence asynchronously
 *
 * @param {ChainData[]} chains - list or ChainData's
 * @param {Function} success - success callback
 * @param {Function} fallback - callback that fires in case of errors
 * @returns {Promise}
 * @deprecated use PromiseQueue.ts instead
 */
export async function sequence(
  chains: ChainData[],
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  success: (data: object) => void = (): void => {},
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  fallback: (data: object) => void = (): void => {}
): Promise<void> {
  /**
   * Decorator
   *
   * @param {ChainData} chainData - Chain data
   * @param {Function} successCallback - success callback
   * @param {Function} fallbackCallback - fail callback
   * @returns {Promise}
   */
  async function waitNextBlock(
    chainData: ChainData,
    successCallback: (data: object) => void,
    fallbackCallback: (data: object) => void
  ): Promise<void> {
    try {
      await chainData.function(chainData.data);
      await successCallback(!isUndefined(chainData.data) ? chainData.data : {});
    } catch (e) {
      fallbackCallback(!isUndefined(chainData.data) ? chainData.data : {});
    }
  }

  /**
   * pluck each element from queue
   * First, send resolved Promise as previous value
   * Each plugins "prepare" method returns a Promise, that's why
   * reduce current element will not be able to continue while can't get
   * a resolved Promise
   */
  return chains.reduce(async (previousValue, currentValue) => {
    await previousValue;

    return waitNextBlock(currentValue, success, fallback);
  }, Promise.resolve());
}

/**
 * Make array from array-like collection
 *
 * @param {ArrayLike} collection - collection to convert to array
 * @returns {Array}
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function array(collection: ArrayLike<any>): any[] {
  return Array.prototype.slice.call(collection);
}

/**
 * Delays method execution
 *
 * @param {Function} method - method to execute
 * @param {number} timeout - timeout in ms
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function delay(method: (...args: any[]) => any, timeout: number) {
  return function (): void {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const context = this,
        // eslint-disable-next-line prefer-rest-params
        args = arguments;

    window.setTimeout(() => method.apply(context, args), timeout);
  };
}

/**
 * Get file extension
 *
 * @param {File} file - file
 * @returns {string}
 */
export function getFileExtension(file: File): string {
  return file.name.split('.').pop();
}

/**
 * Check if string is MIME type
 *
 * @param {string} type - string to check
 * @returns {boolean}
 */
export function isValidMimeType(type: string): boolean {
  return /^[-\w]+\/([-+\w]+|\*)$/.test(type);
}

/**
 * Debouncing method
 * Call method after passed time
 *
 * Note that this method returns Function and declared variable need to be called
 *
 * @param {Function} func - function that we're throttling
 * @param {number} wait - time in milliseconds
 * @param {boolean} immediate - call now
 * @returns {Function}
 */
export function debounce(func: (...args: unknown[]) => void, wait?: number, immediate?: boolean): () => void {
  let timeout;

  return (...args: unknown[]): void => {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const context = this;

    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
    const later = () => {
      timeout = null;
      if (!immediate) {
        func.apply(context, args);
      }
    };

    const callNow = immediate && !timeout;

    window.clearTimeout(timeout);
    timeout = window.setTimeout(later, wait);
    if (callNow) {
      func.apply(context, args);
    }
  };
}

/**
 * Returns a function, that, when invoked, will only be triggered at most once during a given window of time.
 *
 * @param func - function to throttle
 * @param wait - function will be called only once for that period
 * @param options - Normally, the throttled function will run as much as it can
 *                  without ever going more than once per `wait` duration;
 *                  but if you'd like to disable the execution on the leading edge, pass
 *                  `{leading: false}`. To disable execution on the trailing edge, ditto.
 */
export function throttle(func, wait, options: {leading?: boolean; trailing?: boolean} = undefined): () => void {
  let context, args, result;
  let timeout = null;
  let previous = 0;

  if (!options) {
    options = {};
  }

  const later = function (): void {
    previous = options.leading === false ? 0 : Date.now();
    timeout = null;
    result = func.apply(context, args);

    if (!timeout) {
      context = args = null;
    }
  };

  return function (): unknown {
    const now = Date.now();

    if (!previous && options.leading === false) {
      previous = now;
    }

    const remaining = wait - (now - previous);

    // eslint-disable-next-line @typescript-eslint/no-this-alias
    context = this;

    // eslint-disable-next-line prefer-rest-params
    args = arguments;

    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      result = func.apply(context, args);

      if (!timeout) {
        context = args = null;
      }
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(later, remaining);
    }

    return result;
  };
}

/**
 * Copies passed text to the clipboard
 *
 * @param text - text to copy
 */
export function copyTextToClipboard(text): void {
  const el = Dom.make('div', 'codex-editor-clipboard', {
    innerHTML: text,
  });

  document.body.appendChild(el);

  const selection = window.getSelection();
  const range = document.createRange();

  range.selectNode(el);

  window.getSelection().removeAllRanges();
  selection.addRange(range);

  document.execCommand('copy');
  document.body.removeChild(el);
}

/**
 * Returns object with os name as key and boolean as value. Shows current user OS
 */
export function getUserOS(): {[key: string]: boolean} {
  const OS = {
    win: false,
    mac: false,
    x11: false,
    linux: false,
  };

  const userOS = Object.keys(OS).find((os: string) => window.navigator.appVersion.toLowerCase().indexOf(os) !== -1);

  if (userOS) {
    OS[userOS] = true;

    return OS;
  }

  return OS;
}

/**
 * Capitalizes first letter of the string
 *
 * @param {string} text - text to capitalize
 * @returns {string}
 */
export function capitalize(text: string): string {
  return text[0].toUpperCase() + text.slice(1);
}

/**
 * Merge to objects recursively
 *
 * @param {object} target - merge target
 * @param {object[]} sources - merge sources
 * @returns {object}
 */
export function deepMerge<T extends object>(target, ...sources): T {
  if (!sources.length) {
    return target;
  }
  const source = sources.shift();

  if (isObject(target) && isObject(source)) {
    for (const key in source) {
      if (isObject(source[key])) {
        if (!target[key]) {
          Object.assign(target, { [key]: {} });
        }

        deepMerge(target[key], source[key]);
      } else {
        Object.assign(target, { [key]: source[key] });
      }
    }
  }

  return deepMerge(target, ...sources);
}

/**
 * Return true if current device supports touch events
 *
 * Note! This is a simple solution, it can give false-positive results.
 * To detect touch devices more carefully, use 'touchstart' event listener
 *
 * @see http://www.stucox.com/blog/you-cant-detect-a-touchscreen/
 * @returns {boolean}
 */
export const isTouchSupported: boolean = 'ontouchstart' in document.documentElement;

/**
 * Make shortcut command more human-readable
 *
 * @param {string} shortcut — string like 'CMD+B'
 */
export function beautifyShortcut(shortcut: string): string {
  const OS = getUserOS();

  shortcut = shortcut
    .replace(/shift/gi, '⇧')
    .replace(/backspace/gi, '⌫')
    .replace(/enter/gi, '⏎')
    .replace(/up/gi, '↑')
    .replace(/left/gi, '→')
    .replace(/down/gi, '↓')
    .replace(/right/gi, '←')
    .replace(/escape/gi, '⎋')
    .replace(/insert/gi, 'Ins')
    .replace(/delete/gi, '␡')
    .replace(/\+/gi, ' + ');

  if (OS.mac) {
    shortcut = shortcut.replace(/ctrl|cmd/gi, '⌘').replace(/alt/gi, '⌥');
  } else {
    shortcut = shortcut.replace(/cmd/gi, 'Ctrl').replace(/windows/gi, 'WIN');
  }

  return shortcut;
}

/**
 * Returns valid URL. If it is going outside and valid, it returns itself
 * If url has `one slash`, then it concatenates with window location origin
 * or when url has `two lack` it appends only protocol
 *
 * @param {string} url - url to prettify
 */
export function getValidUrl(url: string): string {
  try {
    const urlObject = new URL(url);

    return urlObject.href;
  } catch (e) {
    // do nothing but handle below
  }

  if (url.substring(0, 2) === '//') {
    return window.location.protocol + url;
  } else {
    return window.location.origin + url;
  }
}

/**
 * Create a block id
 *
 * @returns {string}
 */
export function generateBlockId(): string {
  const idLen = 10;

  return nanoid(idLen);
}

/**
 * Opens new Tab with passed URL
 *
 * @param {string} url - URL address to redirect
 */
export function openTab(url: string): void {
  window.open(url, '_blank');
}

/**
 * Returns random generated identifier
 *
 * @param {string} prefix - identifier prefix
 * @returns {string}
 */
export function generateId(prefix = ''): string {
  // eslint-disable-next-line @typescript-eslint/no-magic-numbers
  return `${prefix}${(Math.floor(Math.random() * 1e8)).toString(16)}`;
}

/**
 * Common method for printing a warning about the usage of deprecated property or method.
 *
 * @param condition - condition for deprecation.
 * @param oldProperty - deprecated property.
 * @param newProperty - the property that should be used instead.
 */
export function deprecationAssert(condition: boolean, oldProperty: string, newProperty: string): void {
  const message = `«${oldProperty}» is deprecated and will be removed in the next major release. Please use the «${newProperty}» instead.`;

  if (condition) {
    logLabeled(message, 'warn');
  }
}

/**
 * Decorator which provides ability to cache method or accessor result
 *
 * @param target - target instance or constructor function
 * @param propertyKey - method or accessor name
 * @param descriptor - property descriptor
 */
export function cacheable<Target, Value, Arguments extends unknown[] = unknown[]>(
  target: Target,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const propertyToOverride = descriptor.value ? 'value' : 'get';
  const originalMethod = descriptor[propertyToOverride];
  const cacheKey = `#${propertyKey}Cache`;

  /**
   * Override get or value descriptor property to cache return value
   *
   * @param args - method args
   */
  descriptor[propertyToOverride] = function (...args: Arguments): Value {
    /**
     * If there is no cache, create it
     */
    if (this[cacheKey] === undefined) {
      this[cacheKey] = originalMethod.apply(this, ...args);
    }

    return this[cacheKey];
  };

  /**
   * If get accessor has been overridden, we need to override set accessor to clear cache
   *
   * @param value - value to set
   */
  if (propertyToOverride === 'get' && descriptor.set) {
    const originalSet = descriptor.set;

    descriptor.set = function (value: unknown): void {
      delete target[cacheKey];

      originalSet.apply(this, value);
    };
  }

  return descriptor;
}

/**
 * All screens below this width will be treated as mobile;
 */
export const mobileScreenBreakpoint = 650;

/**
 * True if screen has mobile size
 */
export function isMobileScreen(): boolean {
  return window.matchMedia(`(max-width: ${mobileScreenBreakpoint}px)`).matches;
}

/**
 * True if current device runs iOS
 */
export const isIosDevice =
  typeof window !== 'undefined' &&
  window.navigator &&
  window.navigator.platform &&
  (/iP(ad|hone|od)/.test(window.navigator.platform) ||
    (window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1));

/**
 * Compares two values with unknown type
 *
 * @param var1 - value to compare
 * @param var2 - value to compare with
 * @returns {boolean} true if they are equal
 */
export function equals(var1: unknown, var2: unknown): boolean {
  const isVar1NonPrimitive = Array.isArray(var1) || isObject(var1);
  const isVar2NonPrimitive = Array.isArray(var2) || isObject(var2);

  if (isVar1NonPrimitive || isVar2NonPrimitive) {
    return JSON.stringify(var1) === JSON.stringify(var2);
  }

  return var1 === var2;
}
