import { spawn, StdioOptions } from 'child_process';
import * as fs from 'fs';
import * as getPort from 'get-port';
import * as pathUtils from 'path';
import { rimraf } from 'rimraf';
import * as tsconfig from 'tsconfig-extends';
import { constants } from './config/constants';
import { isArray, isDate, isEqual, isFunction, isNil, isObject, isString, trimEnd } from 'lodash';

/**
 * General utilities
 */
export class Utils {
  private tscCompilerOptionsParams: string[];

  /**
   * Promisified setTimeout() utility function.
   *
   * @param ms The number of milliseconds to sleep for.
   */
  public async sleep(ms: number): Promise<void> {
    await new Promise((resolve, reject) => {
      setTimeout(resolve, ms);
    });
  }

  /**
   * Checks if a String is null, undefined,
   * empty, or contains only whitespaces.
   */
  public isBlank(str: string): boolean {
    if (str === null || str === undefined) {
      return true;
    }

    if (!(typeof str === 'string')) {
      return false;
    }

    return str.trim() === '';
  }

  /**
   * A better version of "isNaN()".
   * For example, an empty string is NOT considered as a
   * number.
   */
  public isNaNSafe(value: any): boolean {
    if (isNaN(value) || this.isBlank(value)) {
      return true;
    }

    const type = typeof value;
    if (type !== 'string' && type !== 'number') {
      return true;
    }

    return false;
  }

  /**
   * If the "el" is not undefined, returns it as is.
   * If the el is undefined, returns NULL.
   *
   * This is useful when undefined is not acceptable
   * but null is.
   */
  public getDefinedOrNull(el: any): any {
    if (el === undefined) {
      return null;
    }
    return el;
  }

  /**
   * Checks if a value is an integer.
   *
   * If you want to use the includeZero parameter without
   * using the positiveOnly parameter, we suggest to pass
   * undefined as a second parameter.
   *
   * After a positive check, we suggest to
   * pass the value with the Number object (Number(value))
   * to "clean" it, e.g., getting rid of unmeaningful
   * decimal zeros or whitespaces.
   */
  public isIntegerValue(value: any, positiveOnly = false, includeZero = true): boolean {
    if (this.isNaNSafe(value)) {
      return false;
    }

    // Convert to Number, if not already one
    const asNumber = Number(value);

    if (positiveOnly && asNumber < 0) {
      return false;
    }

    if (!includeZero && asNumber === 0) {
      return false;
    }

    // Busts integer safe limits
    if (asNumber > Number.MAX_SAFE_INTEGER || asNumber < Number.MIN_SAFE_INTEGER) {
      return false;
    }

    // If there were decimals but "0" only, it is
    // still considered as an Integer, and Number(value)
    // still have stripped those decimals....
    if ((asNumber + '').indexOf('.') > -1) {
      return false;
    }

    return true;
  }

  /**
   * Converts a string to a boolean.
   *
   * The string is TRUE only if it is
   * "true" (case insensitive) or "1"
   * (the *number* 1 is also accepted)
   *
   * Otherwise, it is considered as FALSE.
   */
  public stringToBoolean(str: string): boolean {
    if (str === null || str === undefined) {
      return false;
    }
    let strClean = str;

    if (typeof strClean === 'number') {
      strClean = str + '';
    } else if (typeof strClean !== 'string') {
      return false;
    }

    strClean = strClean.toLowerCase();
    if (strClean === 'true' || strClean === '1') {
      return true;
    }
    return false;
  }

  /**
   * Make sure a file is safe to delete, that is:
   * - It is truly
   * - It is not the path of a root directory or file
   */
  public isSafeToDelete(path: string): boolean {
    if (!path) {
      return false;
    }

    let pathClean = path;

    pathClean = pathUtils.normalize(pathClean);

    pathClean = pathClean.replace(/\\/g, '/');
    pathClean = trimEnd(pathClean, '/ ');

    return (pathClean.match(/\//g) || []).length > 1;
  }

  /**
   * Checks if a path points to an existing directory.
   *
   * @returns true if the path points to an existing
   * directory (not a file).
   */
  public isDir(dirPath: string): boolean {
    if (!dirPath || !fs.existsSync(dirPath)) {
      return false;
    }

    return fs.lstatSync(dirPath).isDirectory();
  }

  /**
   * Checks if a directory is empty.
   *
   * @returns true if the directory is empty or
   * doesn't exist. Returns false if the path
   * points to a *file* or to a directory that
   * is not empty.
   */
  public isDirEmpty(dirPath: string): boolean {
    if (fs.existsSync(dirPath)) {
      if (this.isDir(dirPath)) {
        const files = fs.readdirSync(dirPath);
        return !files || files.length === 0;
      }
      return false;
    }
    return true;
  }

  /**
   * Deletes a file, promisified and in a
   * solid way.
   *
   * You can't delete a root file using this function.
   */
  public deleteFile(filePath: string) {
    if (!this.isSafeToDelete(filePath)) {
      throw new Error("Unsafe file to delete. A file to delete can't be at the root.");
    }

    return rimraf(filePath);
  }

  /**
   * Deletes a directory, promisified and in a
   * solid way.
   *
   * You can't delete a root directory using this function.
   */
  public async deleteDir(dirPath: string) {
    if (!this.isSafeToDelete(dirPath)) {
      throw new Error("Unsafe dir to delete. A dir to delete can't be at the root.");
    }

    try {
      return rimraf(dirPath);
    } catch (err) {
      // ==========================================
      // Try recursively as rimraf may sometimes
      // fail in infrequent situations...
      // ==========================================
      await this.clearDir(dirPath);
      return rimraf(dirPath);
    }
  }

  /**
   * Clears a directory, promisified and in a
   * solid way.
   *
   * You can't clear a root directory using this function.
   */
  public async clearDir(dirPath: string) {
    if (!this.isSafeToDelete(dirPath)) {
      throw new Error("Unsafe dir to clear. A dir to clear can't be at the root.");
    }
    // NOTE: I had to replace the globby module with fs.readdir, because globby was not
    // listing the folders any more!
    return new Promise<void>((resolve, reject) => {
      fs.readdir(dirPath, async (err, paths: string[]) => {
        if (err) {
          reject(err);
          return;
        }
        for (const path of paths) {
          const filePath = pathUtils.join(dirPath, path);
          if (fs.lstatSync(filePath).isDirectory()) {
            await this.deleteDir(filePath);
          } else {
            await this.deleteFile(filePath);
          }
        }
        resolve();
      });
    });
  }

  protected get tscCompilerOptions(): string[] {
    if (!this.tscCompilerOptionsParams) {
      this.tscCompilerOptionsParams = [];
      const compilerOptions = tsconfig.load_file_sync(constants.appRoot + '/tsconfig.json');

      for (const key of Object.keys(compilerOptions)) {
        // ==========================================
        // TS6064: Options 'plugins', 'composite' can only be specified in 'tsconfig.json' file.
        // ==========================================
        if (['plugins', 'composite'].includes(key)) {
          continue;
        }

        // ==========================================
        // "--forceConsistentCasingInFileNames" sometimes
        // causes problems when running tests inside VSCode :
        // http://stackissue.com/Microsoft/vscode/lower-case-drive-letter-in-open-new-command-prompt-command-on-windows-9448.html
        // ==========================================
        if (key === 'forceConsistentCasingInFileNames' && process.env.ide === 'true') {
          compilerOptions[key] = false;
        }

        this.tscCompilerOptionsParams.push('--' + key);
        this.tscCompilerOptionsParams.push(compilerOptions[key]);
      }
    }
    return this.tscCompilerOptionsParams;
  }

  /**
   * Runs the "tsc" command on specific files
   * using the same options than the ones found
   * in the "tsconfig.json" file of the project.
   *
   * @param files the absolute paths of the files to compile.
   * @outdir allows us to redirect the output directory
   */
  public async tsc(files: string[]): Promise<void> {
    if (!files) {
      return;
    }

    const cmd = 'node';
    const tscCmd = constants.findModulePath('node_modules/typescript/lib/tsc.js');
    const args = [tscCmd].concat(this.tscCompilerOptions).concat(files);

    await this.execPromisified(cmd, args);
  }

  /**
   * Creates a "range", an array of continuous integers, from
   * "start" to "end".
   * Both "start" and "end" are inclusive.
   */
  public range = (start: number, end: number): number[] => {
    return [...Array(1 + end - start).keys()].map((v) => start + v);
  };

  /**
   * Returns a free port.
   */
  public async findFreePort(): Promise<number> {
    return await getPort();
  }

  /**
   * Validates if the object is of type Date and
   * is valid. Deserializing an invalid string
   * to a Date may result in a Date, but which is invalid.
   * Then loadash's isDate(d) is not enough to detect
   * if the result is valid or not. This function is.
   */
  public isValidDate(date: any): boolean {
    if (this.isBlank(date)) {
      return false;
    }
    return Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date.getTime());
  }

  /**
   * To be used as a "@Transform" decorator on a
   * Date field, when using the "class-transformer"
   * library. For example :
   *
   * @Transform(utils.dateTransformer)
   * public created: Date;
   *
   * When using what class-transformer provides
   * by default ("@Type(() => Date)") there are cases
   * that are problematic :
   * - "123" and "true" :result in valid dates!
   *
   * Note : *only* use this decorator on the Date field, not
   * in addition to "@Type(() => Date)"!
   */
  public dateTransformer = (value: any): Date => {
    let date: Date;

    if (isNil(value)) {
      return null;
    }

    if (isDate(value)) {
      date = value;
    } else if (!isString(value) || utils.isBlank(value)) {
      // ==========================================
      // Makes sure it's an invalid date!
      // Because by default, true and 123 are accepted,
      // and are transform to valid dates!
      // ==========================================
      date = new Date(`invalid!`);
    } else {
      date = new Date(value);
    }

    return date;
  };

  /**
   * Throws an Error for the specified element as type "never".
   *
   * To be used in the "default" section of a switch/case statement.
   * This allows the validation at compile time that all elements of the
   * swtiched element are managed (as long as it is a discret type such
   * as an enum).
   */
  public throwNotManaged = (messagePrefix: string, element: never): void => {
    throw new Error(`${messagePrefix}: ${element}`);
  };

  /**
   * Returns TRUE if the parameter is an object but is not an array,
   * a Date or a function
   * By default, _.isObject(x) from Lodash also returns TRUE for
   * an Array, for a Date and a function.
   *
   * Returns FALSE for null/undefined.
   */
  public isObjectStrict = (val: any): boolean => {
    if (!val) {
      return false;
    }
    return isObject(val) && !isArray(val) && !isDate(val) && !isFunction(val);
  };

  /**
   * Returns TRUE if the specified "array" contains at least one object
   * that has the specified "key" and if the value associated with that key is
   * strictly equals to the specified "value".
   */
  public arrayContainsObjectWithKeyEqualsTo = (array: any[], key: string, value: any): boolean => {
    if (!array || !isArray(array) || array.length < 1) {
      return false;
    }

    for (const obj of array) {
      if (this.isObjectStrict(obj) && isEqual(obj[key], value)) {
        return true;
      }
    }

    return false;
  };

  /**
   * @deprecated Use `exec()` instead.
   */
  public execPromisified(
    command: string,
    args: string[],
    dataHandler: (stdoutData: string, stderrData: string) => void = null,
    useShellOption = false,
  ): Promise<void> {
    return this.exec(command, args, {
      outputHandler: dataHandler,
      useShellOption,
      disableConsoleOutputs: !dataHandler,
    }).then((_val: number) => {
      // nothing, returns void
    });
  }

  /**
   * Execute a shell command.
   *
   * This function is a promisified version of Node's `spawn()`
   * with extra options added
   * ( https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options ).
   *
   * @param bin The executable program to call.
   *
   * @param args The arguments for the program.
   *
   * @param options.successExitCodes The acceptable codes the
   *   process must exit with to be considered as a success.
   *   Defaults to [0].
   *
   * @param options.outputHandler A function that will receive
   *   the output of the process (stdOut and stdErr).
   *   This allows you to capture this output and manipulate it.
   *   No handler by default.
   *
   * @param options.disableConsoleOutputs Set to `true` in order
   *   to disable outputs in the current parent process
   *   (you can still capture them using a `options.dataHandler`).
   *   Defaults to `false`.
   *
   * @param options.stdio See https://nodejs.org/api/child_process.html#child_process_options_stdio
   *   Defaults to `['inherit', 'pipe', 'pipe']`.
   *
   * @param options.useShellOption See the "shell" option:
   *   https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options
   *   Defaults to `true`.
   *
   * @returns The exit code
   *
   * @throws Will fail with a `ExecError` error if the process returns
   * a code different than `options.successExitCodes` ("0" by default).
   * The exit code would then be available in the generated Error:
   * `err.exitCode`.
   */
  public async exec(
    bin: string,
    args: string[] = [],
    options?: {
      successExitCodes?: number | number[];
      outputHandler?: (stdoutOutput: string, stderrOutput: string) => void;
      disableConsoleOutputs?: boolean;
      stdio?: StdioOptions;
      useShellOption?: boolean;
    },
  ): Promise<number> {
    const optionsClean = options ?? {};
    optionsClean.useShellOption = optionsClean.useShellOption ?? true;
    optionsClean.successExitCodes = optionsClean.successExitCodes
      ? isArray(optionsClean.successExitCodes)
        ? optionsClean.successExitCodes
        : [optionsClean.successExitCodes]
      : [0];
    optionsClean.stdio = optionsClean.stdio ?? ['inherit', 'pipe', 'pipe'];
    optionsClean.disableConsoleOutputs = optionsClean.disableConsoleOutputs ?? false;

    if (this.isBlank(bin)) {
      throw new ExecError(`The "bin" argument is required`, 1);
    }

    return new Promise<number>((resolve, reject) => {
      const spawnedProcess = spawn(bin, args, {
        detached: false,
        stdio: optionsClean.stdio,
        shell: optionsClean.useShellOption,
        windowsVerbatimArguments: false,
      });

      spawnedProcess.on('close', (code: number) => {
        const successExitCodes = optionsClean.successExitCodes as number[];
        if (!successExitCodes.includes(code)) {
          reject(
            new ExecError(
              `Expected success codes were "${successExitCodes.toString()}", but the process exited with "${code}".`,
              code,
            ),
          );
        } else {
          resolve(code);
        }
      });

      spawnedProcess.stdout.on('data', (output: string) => {
        const outputClean = output ? output.toString() : '';
        if (optionsClean.outputHandler) {
          optionsClean.outputHandler(outputClean, null);
        }

        if (!optionsClean.disableConsoleOutputs) {
          process.stdout.write(outputClean);
        }
      });

      spawnedProcess.stderr.on('data', (output: string) => {
        const outputClean = output ? output.toString() : '';
        if (optionsClean.outputHandler) {
          optionsClean.outputHandler(null, outputClean);
        }

        if (!optionsClean.disableConsoleOutputs) {
          process.stderr.write(outputClean);
        }
      });
    });
  }
}

/**
 * Error thrown when a process launched with `exec()` fails.
 */
// tslint:disable-next-line: max-classes-per-file
export class ExecError extends Error {
  constructor(
    message: string,
    public exitCode: number,
  ) {
    super(message);
  }
}

export function getValueDescription(value: any): string {
  return `« ${JSON.stringify(value)} »`;
}

export function getValueDescriptionWithType(value: any): string {
  const valueType = isObject(value) ? value.constructor.name : typeof value;
  return getValueDescription(value) + ` (${valueType})`;
}

export const utils: Utils = new Utils();
