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

/**
 * 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<void>((resolve) => {
      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....
    // eslint-disable-next-line @/prefer-template
    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') {
      // eslint-disable-next-line @/prefer-template
      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 async deleteFile(filePath: string): Promise<void> {
    if (!this.isSafeToDelete(filePath)) {
      throw new Error("Unsafe file to delete. A file to delete can't be at the root.");
    }

    await fs.promises.rm(filePath, { force: true });
  }

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

    try {
      await fs.promises.rm(dirPath, { recursive: true, force: true });
    } catch {
      // ==========================================
      // Try recursively as fs.promises.rm may sometimes
      // fail in infrequent situations...
      // ==========================================
      await this.clearDir(dirPath);
      await fs.promises.rm(dirPath, { recursive: true, force: true });
    }
  }

  /**
   * Clears a directory, promisified and in a
   * solid way.
   *
   * You can't clear a root directory using this function.
   */
  public async clearDir(dirPath: string): Promise<void> {
    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!
    const paths = await fs.promises.readdir(dirPath);
    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);
      }
    }
  }

  protected get tscCompilerOptions(): string[] {
    if (!this.tscCompilerOptionsParams) {
      this.tscCompilerOptionsParams = [];
      const compilerOptions = tsconfig.load_file_sync(
        path.join(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, ...this.tscCompilerOptions, ...files];

    await this.exec(cmd, args, { useShellOption: false });
  }

  /**
   * 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;
  };

  public shellescape(args: string[]) {
    const isWindows = process.platform === 'win32';
    if (isWindows) {
      return this.shellescapeForWindowsCmd(args);
    } else {
      return this.shellescapeForLinuxShell(args);
    }
  }

  public shellescapeForLinuxShell(args: string[]) {
    return args.map((x) => this.shellescapeArgumentForLinuxShell(x)).join(' ');
  }

  public shellescapeForWindowsCmd(args: string[]) {
    return args.map((x) => this.shellescapeArgumentForWindowsCmd(x)).join(' ');
  }

  public shellescapeArgumentForLinuxShell(a: string) {
    // Function inspired from: https://github.com/xxorax/node-shell-escape/blob/master/shell-escape.js

    if (/[^A-Za-z0-9_/.:=-]/.test(a)) {
      a = a.replace(/\\\\/g, '\\');
      a = a.replace(/\\/g, '\\\\');
      a = `'${a.replace(/'/g, "'\\''")}'`;
      a = a
        .replace(/^(?:'')+/g, '') // unduplicate single-quote at the beginning
        .replace(/\\'''/g, "\\'") // remove non-escaped single-quote if there are enclosed between 2 escaped
        .replace('\n', '\\n') // handle new lines
        .replace('\t', '\\t'); // handle tabs
    }
    return a;
  }

  public shellescapeArgumentForWindowsCmd(a: string) {
    if (/[^A-Za-z0-9_/\\.$:=-]/.test(a)) {
      a = `"${a.replace(/"/g, '""')}"`;
      a = a
        .replace('\n', '\\n') // handle new lines
        .replace('\t', '\\t'); // handle tabs
    }
    return a;
  }

  /**
   * @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(() => {
      // 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`.
   *
   * @param options.escapeArgs will automatically escape the submitted args.
   *   Defaults to `false` to avoid any breaking changes.
   *
   * @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;
      escapeArgs?: boolean;
    },
  ): Promise<number> {
    const optionsClean = options ?? {};
    optionsClean.useShellOption = optionsClean.useShellOption ?? true;
    optionsClean.escapeArgs = optionsClean.escapeArgs ?? false;
    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) => {
      let spawnedProcess: ChildProcess;
      if (optionsClean.useShellOption && optionsClean.escapeArgs) {
        const cmd = this.shellescape([bin, ...args]);
        spawnedProcess = spawn(cmd, {
          detached: false,
          stdio: optionsClean.stdio,
          shell: optionsClean.useShellOption,
          windowsVerbatimArguments: false,
        });
      } else {
        spawnedProcess = spawn(bin, args, {
          detached: false,
          stdio: optionsClean.stdio,
          shell: optionsClean.useShellOption,
          windowsVerbatimArguments: false,
        });
      }

      spawnedProcess.on('error', (err: Error) => {
        reject(new ExecError(`Error while executing command: ${err.message}`, 1));
      });
      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.
 */
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();
