'use strict';
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const chalk = require('chalk');
const _ = require('lodash');
const GroupedQueue = require('grouped-queue');
const escapeStrRe = require('escape-string-regexp');
const untildify = require('untildify');
const memFs = require('mem-fs');
const debug = require('debug')('yeoman:environment');
const isScoped = require('is-scoped');
const Store = require('./store');
const resolver = require('./resolver');
const TerminalAdapter = require('./adapter');

/**
 * Two-step argument splitting function that first splits arguments in quotes,
 * and then splits up the remaining arguments if they are not part of a quote.
 */
function splitArgsFromString(argsString) {
  let result = [];
  const quoteSeparatedArgs = argsString.split(/(\x22[^\x22]*\x22)/).filter(x => x);
  quoteSeparatedArgs.forEach(arg => {
    if (arg.match('\x22')) {
      result.push(arg.replace(/\x22/g, ''));
    } else {
      result = result.concat(arg.trim().split(' '));
    }
  });
  return result;
}

/**
 * `Environment` object is responsible of handling the lifecyle and bootstrap
 * of generators in a specific environment (your app).
 *
 * It provides a high-level API to create and run generators, as well as further
 * tuning where and how a generator is resolved.
 *
 * An environment is created using a list of `arguments` and a Hash of
 * `options`. Usually, this is the list of arguments you get back from your CLI
 * options parser.
 *
 * An optional adapter can be passed to provide interaction in non-CLI environment
 * (e.g. IDE plugins), otherwise a `TerminalAdapter` is instantiated by default
 *
 * @constructor
 * @mixes env/resolver
 * @param {String|Array} args
 * @param {Object} opts
 * @param {TerminalAdapter} [adaper] - A TerminalAdapter instance or another object
 *                                     implementing this adapter interface. This is how
 *                                     you'd interface Yeoman with a GUI or an editor.
 */
class Environment extends EventEmitter {
  static get queues() {
    return [
      'initializing',
      'prompting',
      'configuring',
      'default',
      'writing',
      'conflicts',
      'install',
      'end'
    ];
  }

  /**
   * Make sure the Environment present expected methods if an old version is
   * passed to a Generator.
   * @param  {Environment} env
   * @return {Environment} The updated env
   */
  static enforceUpdate(env) {
    if (!env.adapter) {
      env.adapter = new TerminalAdapter();
    }

    if (!env.runLoop) {
      env.runLoop = new GroupedQueue([
        'initializing',
        'prompting',
        'configuring',
        'default',
        'writing',
        'conflicts',
        'install',
        'end'
      ]);
    }

    if (!env.sharedFs) {
      env.sharedFs = memFs.create();
    }

    return env;
  }

  /**
   * Factory method to create an environment instance. Take same parameters as the
   * Environment constructor.
   *
   * @see This method take the same arguments as {@link Environment} constructor
   *
   * @return {Environment} a new Environment instance
   */
  static createEnv(args, opts, adapter) {
    return new Environment(args, opts, adapter);
  }

  /**
   * Convert a generators namespace to its name
   *
   * @param  {String} namespace
   * @return {String}
   */
  static namespaceToName(namespace) {
    return namespace.split(':')[0];
  }

  constructor(args, opts, adapter) {
    super();

    args = args || [];
    this.arguments = Array.isArray(args) ? args : splitArgsFromString(args);
    this.options = opts || {};
    this.adapter = adapter || new TerminalAdapter();
    this.cwd = this.options.cwd || process.cwd();
    this.store = new Store();

    this.runLoop = new GroupedQueue(Environment.queues);
    this.sharedFs = memFs.create();

    // Each composed generator might set listeners on these shared resources. Let's make sure
    // Node won't complain about event listeners leaks.
    this.runLoop.setMaxListeners(0);
    this.sharedFs.setMaxListeners(0);

    this.lookups = ['.', 'generators', 'lib/generators'];
    this.aliases = [];

    this.alias(/^([^:]+)$/, '$1:app');
  }

  /**
   * Error handler taking `err` instance of Error.
   *
   * The `error` event is emitted with the error object, if no `error` listener
   * is registered, then we throw the error.
   *
   * @param  {Object} err
   * @return {Error}  err
   */
  error(err) {
    err = err instanceof Error ? err : new Error(err);

    if (!this.emit('error', err)) {
      throw err;
    }

    return err;
  }

  /**
   * Outputs the general help and usage. Optionally, if generators have been
   * registered, the list of available generators is also displayed.
   *
   * @param {String} name
   */
  help(name) {
    name = name || 'init';

    const out = [
      'Usage: :binary: GENERATOR [args] [options]',
      '',
      'General options:',
      '  --help       # Print generator\'s options and usage',
      '  -f, --force  # Overwrite files that already exist',
      '',
      'Please choose a generator below.',
      ''
    ];

    const ns = this.namespaces();

    const groups = {};
    for (const namespace of ns) {
      const base = namespace.split(':')[0];

      if (!groups[base]) {
        groups[base] = [];
      }

      groups[base].push(namespace);
    }

    for (const key of Object.keys(groups).sort()) {
      const group = groups[key];

      if (group.length >= 1) {
        out.push('', key.charAt(0).toUpperCase() + key.slice(1));
      }

      for (const ns of groups[key]) {
        out.push(`  ${ns}`);
      }
    }

    return out.join('\n').replace(/:binary:/g, name);
  }

  /**
   * Registers a specific `generator` to this environment. This generator is stored under
   * provided namespace, or a default namespace format if none if available.
   *
   * @param  {String} name      - Filepath to the a generator or a npm package name
   * @param  {String} namespace - Namespace under which register the generator (optional)
   * @return {String} namespace - Namespace assigned to the registered generator
   */
  register(name, namespace) {
    if (typeof name !== 'string') {
      return this.error(new Error('You must provide a generator name to register.'));
    }

    const modulePath = this.resolveModulePath(name);
    namespace = namespace || this.namespace(modulePath);

    if (!namespace) {
      return this.error(new Error('Unable to determine namespace.'));
    }

    this.store.add(namespace, modulePath);

    debug('Registered %s (%s)', namespace, modulePath);
    return this;
  }

  /**
   * Register a stubbed generator to this environment. This method allow to register raw
   * functions under the provided namespace. `registerStub` will enforce the function passed
   * to extend the Base generator automatically.
   *
   * @param  {Function} Generator  - A Generator constructor or a simple function
   * @param  {String}   namespace  - Namespace under which register the generator
   * @param  {String}   [resolved] - The file path to the generator
   * @return {this}
   */
  registerStub(Generator, namespace, resolved) {
    if (typeof Generator !== 'function') {
      return this.error(new Error('You must provide a stub function to register.'));
    }

    if (typeof namespace !== 'string') {
      return this.error(new Error('You must provide a namespace to register.'));
    }

    this.store.add(namespace, Generator, resolved);

    return this;
  }

  /**
   * Returns the list of registered namespace.
   * @return {Array}
   */
  namespaces() {
    return this.store.namespaces();
  }

  /**
   * Returns stored generators meta
   * @return {Object}
   */
  getGeneratorsMeta() {
    return this.store.getGeneratorsMeta();
  }

  /**
   * Get registered generators names
   *
   * @return {Array}
   */
  getGeneratorNames() {
    return _.uniq(Object.keys(this.getGeneratorsMeta()).map(Environment.namespaceToName));
  }

  /**
   * Get a single generator from the registered list of generators. The lookup is
   * based on generator's namespace, "walking up" the namespaces until a matching
   * is found. Eg. if an `angular:common` namespace is registered, and we try to
   * get `angular:common:all` then we get `angular:common` as a fallback (unless
   * an `angular:common:all` generator is registered).
   *
   * @param  {String} namespaceOrPath
   * @return {Generator|null} - the generator registered under the namespace
   */
  get(namespaceOrPath) {
    // Stop the recursive search if nothing is left
    if (!namespaceOrPath) {
      return;
    }

    let namespace = namespaceOrPath;

    // Legacy yeoman-generator `#hookFor()` function is passing the generator path as part
    // of the namespace. If we find a path delimiter in the namespace, then ignore the
    // last part of the namespace.
    const parts = namespaceOrPath.split(':');
    const maybePath = _.last(parts);
    if (parts.length > 1 && /[/\\]/.test(maybePath)) {
      parts.pop();

      // We also want to remove the drive letter on windows
      if (maybePath.indexOf('\\') >= 0 && _.last(parts).length === 1) {
        parts.pop();
      }

      namespace = parts.join(':');
    }

    return this.store.get(namespace) ||
      this.store.get(this.alias(namespace)) ||
      // Namespace is empty if namespaceOrPath contains a win32 absolute path of the form 'C:\path\to\generator'.
      // for this reason we pass namespaceOrPath to the getByPath function.
      this.getByPath(namespaceOrPath);
  }

  /**
   * Get a generator by path instead of namespace.
   * @param  {String} path
   * @return {Generator|null} - the generator found at the location
   */
  getByPath(path) {
    if (fs.existsSync(path)) {
      const namespace = this.namespace(path);
      this.register(path, namespace);

      return this.get(namespace);
    }
  }

  /**
   * Create is the Generator factory. It takes a namespace to lookup and optional
   * hash of options, that lets you define `arguments` and `options` to
   * instantiate the generator with.
   *
   * An error is raised on invalid namespace.
   *
   * @param {String} namespace
   * @param {Object} options
   */
  create(namespace, options) {
    options = options || {};

    const Generator = this.get(namespace);

    if (typeof Generator !== 'undefined' && typeof Generator.default === 'function') {
      return this.instantiate(Generator.default, options);
    }

    if (typeof Generator !== 'function') {
      let generatorHint = '';
      if (isScoped(namespace)) {
        const splitName = namespace.split('/');
        generatorHint = `${splitName[0]}/generator-${splitName[1]}`;
      } else {
        generatorHint = `generator-${namespace}`;
      }

      return this.error(
        new Error(
          chalk.red('You don\'t seem to have a generator with the name “' + namespace + '” installed.') + '\n' +
          'But help is on the way:\n\n' +
          'You can see available generators via ' +
          chalk.yellow('npm search yeoman-generator') + ' or via ' + chalk.yellow('http://yeoman.io/generators/') + '. \n' +
          'Install them with ' + chalk.yellow(`npm install ${generatorHint}`) + '.\n\n' +
          'To see all your installed generators run ' + chalk.yellow('yo') + ' without any arguments. ' +
          'Adding the ' + chalk.yellow('--help') + ' option will also show subgenerators. \n\n' +
          'If ' + chalk.yellow('yo') + ' cannot find the generator, run ' + chalk.yellow('yo doctor') + ' to troubleshoot your system.'
        )
      );
    }

    return this.instantiate(Generator, options);
  }

  /**
   * Instantiate a Generator with metadatas
   *
   * @param {String}       namespace
   * @param {Object}       options
   * @param {Array|String} options.arguments  Arguments to pass the instance
   * @param {Object}       options.options    Options to pass the instance
   */
  instantiate(Generator, options) {
    options = options || {};

    let args = options.arguments || options.args || _.clone(this.arguments);
    args = Array.isArray(args) ? args : splitArgsFromString(args);

    const opts = options.options || _.clone(this.options);

    opts.env = this;
    opts.resolved = Generator.resolved || 'unknown';
    opts.namespace = Generator.namespace;
    return new Generator(args, opts);
  }

  /**
   * Tries to locate and run a specific generator. The lookup is done depending
   * on the provided arguments, options and the list of registered generators.
   *
   * When the environment was unable to resolve a generator, an error is raised.
   *
   * @param {String|Array} args
   * @param {Object}       options
   * @param {Function}     done
   */
  run(args, options, done) {
    args = args || this.arguments;

    if (typeof options === 'function') {
      done = options;
      options = this.options;
    }

    if (typeof args === 'function') {
      done = args;
      options = this.options;
      args = this.arguments;
    }

    args = Array.isArray(args) ? args : splitArgsFromString(args);
    options = options || this.options;

    const name = args.shift();
    if (!name) {
      return this.error(new Error('Must provide at least one argument, the generator namespace to invoke.'));
    }

    const generator = this.create(name, {
      args,
      options
    });

    if (generator instanceof Error) {
      return generator;
    }

    if (options.help) {
      return console.log(generator.help());
    }

    return generator.run(done);
  }

  /**
   * Given a String `filepath`, tries to figure out the relative namespace.
   *
   * ### Examples:
   *
   *     this.namespace('backbone/all/index.js');
   *     // => backbone:all
   *
   *     this.namespace('generator-backbone/model');
   *     // => backbone:model
   *
   *     this.namespace('backbone.js');
   *     // => backbone
   *
   *     this.namespace('generator-mocha/backbone/model/index.js');
   *     // => mocha:backbone:model
   *
   * @param {String} filepath
   */
  namespace(filepath) {
    if (!filepath) {
      throw new Error('Missing namespace');
    }

    // Cleanup extension and normalize path for differents OS
    let ns = path.normalize(filepath.replace(new RegExp(escapeStrRe(path.extname(filepath)) + '$'), ''));

    // Sort lookups by length so biggest are removed first
    const lookups = _(this.lookups.concat(['..'])).map(path.normalize).sortBy('length').value().reverse();

    // If `ns` contains a lookup dir in its path, remove it.
    ns = lookups.reduce((ns, lookup) => {
      // Only match full directory (begin with leading slash or start of input, end with trailing slash)
      lookup = new RegExp(`(?:\\\\|/|^)${escapeStrRe(lookup)}(?=\\\\|/)`, 'g');
      return ns.replace(lookup, '');
    }, ns);

    const folders = ns.split(path.sep);
    const scope = _.findLast(folders, folder => folder.indexOf('@') === 0);

    // Cleanup `ns` from unwanted parts and then normalize slashes to `:`
    ns = ns
      .replace(/(.*generator-)/, '') // Remove before `generator-`
      .replace(/[/\\](index|main)$/, '') // Remove `/index` or `/main`
      .replace(/^[/\\]+/, '') // Remove leading `/`
      .replace(/[/\\]+/g, ':'); // Replace slashes by `:`

    if (scope) {
      ns = `${scope}/${ns}`;
    }

    debug('Resolve namespaces for %s: %s', filepath, ns);

    return ns;
  }

  /**
   * Resolve a module path
   * @param  {String} moduleId - Filepath or module name
   * @return {String}          - The resolved path leading to the module
   */
  resolveModulePath(moduleId) {
    if (moduleId[0] === '.') {
      moduleId = path.resolve(moduleId);
    }

    if (path.extname(moduleId) === '') {
      moduleId += path.sep;
    }

    return require.resolve(untildify(moduleId));
  }
}

Object.assign(Environment.prototype, resolver);

/**
 * Expose the utilities on the module
 * @see {@link env/util}
 */
Environment.util = require('./util/util');

module.exports = Environment;
