var fs = require('fs');
var util = require('util');
var path = require('path');
var events = require('events');
var spawn = require('child_process').spawn;
var glob = require('glob');
var chalk = require('chalk');
var _ = require('lodash');
var log = process.logging('generators');
var engines = require('./util/engines');

var debug = require('debug')('generators:environment');

var Base = require('./base');

// TODO(mklabs):
//
// - path manipulation methods ({append,prepend}{Path,Lookup}) can be dryed out.
// - if it gets too huge (more than 200loc), split this into mulitple mixins.
// - register() method too long, split logic for namespace etc. elsewhere
// - flesh out Error handling, consider emitting error instead of throwing,
//   even for synchronous operation.

/**
 * `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.
 *
 * @param {String|Array} args
 * @param {Object} opts
 */

var Environment = module.exports = function Environment(args, opts) {
  events.EventEmitter.call(this);

  args = args || [];
  this.arguments = Array.isArray(args) ? args : args.split(' ');
  this.options = opts || {};

  this.cwd = this.options.cwd || process.cwd();
  this.generators = {};
  this.aliases = [];

  this.lookups = [];
  this.paths = [];
  this.appendLookup('.');
  this.appendLookup('generators');

  // DEPRECATED: use the path `generators` instead
  this.appendLookup('lib/generators');

  this.appendPath('.');

  // adds support for global generators. ensures support across all OS since the
  // global node_modules dir always is four levels up from env.js
  this.appendPath(path.join(__dirname, '../../../..'));

  this._prefixReg = null;
  this._prefixes = this._prefixes || [];
  this._suffix = this._suffix || '';
  this.prefix(this.options.prefix || 'generator-');
  this.suffix(this.options.suffix || '*/index.js');

  this.plugins('node_modules');
};

util.inherits(Environment, events.EventEmitter);

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

Environment.prototype.error = function error(err) {
  err = err instanceof Error ? err : new Error(err);
  if (!this.emit('error', err)) {
    throw err;
  }

  return this;
};

/**
 * Configures the `engine` to use for this environment.
 *
 * @param {String} engine
 */

Environment.prototype.engine = function _engine(engine) {
  if (typeof engine === 'function') {
    this._engine = engine;
    return this;
  }

  if (!engines[engine]) {
    return this.error(new Error('Wrong engine (' + engine + '). Available: ' + Object.keys(engines).join(' ')));
  }

  this._engine = engines[engine];
  return this;
};

/**
 * Appends a `filepath` to the list of loadpaths.
 *
 * @param {String} filepath
 */

Environment.prototype.appendPath = function appendPath(filepath) {
  if (!filepath) {
    return this.error(new Error('Missing filepath.'));
  }

  this.paths.push(filepath);
  return this;
};

/**
 * Appends a new `filepath` to the list of lookups path. This should be a
 * relative filepath, like `support/scaffold`. Environments are created with
 * `lib/generators` as a lookup path by default.
 *
 * @param {String} filepath
 */

Environment.prototype.appendLookup = function appendLookup(filepath) {
  if (!filepath) {
    return this.error(new Error('Missing filepath.'));
  }

  this.lookups.push(filepath);
  return this;
};

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

Environment.prototype.help = function help(name) {
  name = name || 'init';

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

  var ns = this.namespaces();

  var groups = {};
  ns.sort().forEach(function (namespace) {
    var base = namespace.split(':')[0];

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

    groups[base] = groups[base].concat(namespace);
  });

  Object.keys(groups).forEach(function (key) {
    var group = groups[key];

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

    groups[key].forEach(function (ns) {
      out.push('  ' + ns);
    });
  });

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

/**
 * Registers a specific `generator` to this environment. A generator can be a
 * simple function or an object extending from `Generators.Base`. The later
 * method is favored as it allows you to specify options/arguments for
 * self-documented generator with `USAGE:` and so on.
 *
 * In case of a simple function, the generator does show up in the `--help`
 * output, but without any kind of arguments/options. You must document them
 * manually with your `USAGE` file.
 *
 * In any case, the API available in generators is the same. Raw functions are
 * executed within the context of a `new Generators.Base`.
 *
 * `register()` can also take Strings, in which case it is considered a
 * filepath to `require()`.
 *
 * @param {String|Function} name
 * @param {String} namespace
 */

Environment.prototype.register = function register(name, namespace) {
  if (!name) {
    return this.error(new Error('You must provide a generator to register.'));
  }

  var cachePath = _.isString(name);
  var generator = cachePath ? function () {} : name;

  var isRaw = function (generator) {
    generator = Object.getPrototypeOf(generator.prototype);
    var methods = ['option', 'argument', 'hookFor', 'run'];
    return methods.filter(function (method) {
      return !!generator[method];
    }).length !== methods.length;
  };

  var filepath;
  var ns;

  if (cachePath) {
    filepath = name;

    if (filepath.charAt(0) === '.') {
      filepath = path.resolve(filepath);
    }

    if (filepath.charAt(0) === '~') {
      filepath = process.env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME'] + filepath.slice(1);
    }

    generator.resolved = require.resolve(filepath);
    generator.namespace = this.namespace(generator.resolved);
    generator.__register = function () {
      var invokedGenerator = require(generator.resolved);
      invokedGenerator.resolved = generator.resolved;
      this.register(invokedGenerator, generator.namespace);
    }.bind(this);
  }

  ns = namespace || generator.namespace || this.namespace(name);

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

  // normalize what we got, at this point if we were unable to find a
  // namespace, we just consider the directory name under which this generator
  // is found.
  if (ns.indexOf('/') !==  -1) {
    ns = path.basename(path.dirname(ns));
  }

  generator.namespace = ns;
  generator.raw = isRaw(generator);
  generator.resolved = generator.resolved || ns;
  this.generators[ns] = generator;

  debug('Registered %s (%s)', generator.namespace, generator.resolved);
  return this;
};

/**
 * Returns the list of registered namespace.
 */

Environment.prototype.namespaces = function namespaces() {
  return Object.keys(this.generators);
};

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

Environment.prototype.get = function get(namespace) {
  if (!namespace) {
    return;
  }

  var get = function (namespace) {
    var generator = this.generators[namespace];
    if (generator) {
      if (generator.__register) {
        generator.__register();
        generator = get(namespace);
      }
      return generator;
    }
  }.bind(this);

  return get(namespace)
    || get(this.alias(namespace))
    || this.get(namespace.split(':').slice(0, -1).join(':'));
};

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

Environment.prototype.create = function create(namespace, options) {
  options = options || {};

  var names = namespace.split(':');
  var name = names.slice(-1)[0];

  var generator = this.get(namespace);

  var args = options.arguments || options.args || this.arguments;
  args = Array.isArray(args) ? args : args.split(' ');

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

  if (!generator) {
    return this.error(
      new Error(
        'You don\'t seem to have a generator with the name ' + namespace + ' installed.\n' +
        chalk.bold('You can see available generators with ' + 'npm search yeoman-generator') +
        chalk.bold(' and then install them with ' + 'npm install [name]') + '.\n' +
        'To see the ' + this.namespaces().length + ' registered generators run yo with the `--help` option.'
      )
    );
  }

  // case of raw functions, we create a brand new `Base` object and attach this
  // raw function as one of the prototype method. this effectively standardize
  // the interface for running generators, while allowing less boilerplate for
  // generators authors.
  var Generator = generator;
  if (generator.raw) {
    Generator = function () {
      Base.apply(this, arguments);
    };
    util.inherits(Generator, Base);
    Generator.prototype.exec = generator;
  }

  opts.env = this;
  opts.name = name;
  opts.resolved = generator.resolved;
  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
 */

Environment.prototype.run = function 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 : args.split(' ');
  options = options || this.options;

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

  // is it a remote? any generator with a `/` in it is considered
  // potential remote, and we simply proxy over `npm install` command to get
  // the generator locally.
  if (/\//.test(name)) {
    return this.remote(name, done);
  }

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

  if (typeof done === 'function') {
    generator.on('end', done);
  }

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

  generator.on('start', this.emit.bind(this, 'generators:start'));
  generator.on('start', this.emit.bind(this, name + ':start'));

  var self = this;
  generator.on('method', function (method) {
    self.emit(name + ':' + method);
  });

  generator.on('end', this.emit.bind(this, name + ':end'));
  generator.on('end', this.emit.bind(this, 'generators:end'));

  return generator.run();
};

/**
 * Receives namespaces in an array and tries to find matching generators in the
 * load paths.
 *
 * We lookup namespaces in several places, namely `this.lookups`
 * list of relatives directory path. A `generator-` prefix is added if a
 * namespace wasn't `require()`-able directly, matching `generator-*` kind of
 * pattern in npm installed package.
 *
 * You can also lookup using glob-like star pattern, eg. `angular:*` gets
 * expanded to `angular\*\index.js`.
 *
 * The default alias `generator-$1` lookup is added automatically.
 *
 * ### Examples:
 *
 *     // search for all angular generators in the load path
 *     env.lookup('angular:*');
 *
 *     // register any valid set of generator in the load paths
 *     env.lookup('*:*');
 *
 * @param {String|Array} namespaces
 * @param {String} lookupdir
 */

Environment.prototype.lookup = function lookup(namespaces, lookupdir) {
  namespaces = Array.isArray(namespaces) ? namespaces : namespaces.split(' ');

  debug('Lookup %s', namespaces);
  namespaces.forEach(function (ns) {
    var filepath = path.join.apply(path, this.alias(ns).split(':'));

    this.paths.forEach(function (base) {
      debug('Looking in %s with filepath %s', base, filepath);

      // no glob pattern
      if (!~filepath.indexOf('*')) {
        try {
          debug('Attempt to register with direct filepath %s', filepath);
          this.register(filepath);
        } catch (e) {
          // silent fail unless not a loadpath error
          if (e.message.indexOf(filepath) === -1) {
            console.error('Unable to register %s (Error: %s)', ns, e.message);
          }
        }

        return;
      }

      this.lookups.forEach(function (lookupdir) {
        var depth = lookupdir && /^\.\/?$/.test(lookupdir) ? '*' : '**';

        var prefixes = this._prefixes.filter(function (prefix) {
          return !(/\//).test(prefix);
        });

        var pattern = filepath
          .replace(/^\*+/, '+(' + prefixes.join('|') + ')*')
          .replace(/\*+$/g, path.join(lookupdir, depth, 'index.js'))
          .replace(/^\*\//, '');

        debug('Globing for generator %s with pattern %s (cwd: %s)', ns, pattern, base);
        glob.sync(pattern, { cwd: base }).forEach(function (filename) {
          // now register, warn on failed require
          try {
            debug('found %s, trying to register', filename);
            this.register(path.resolve(base, filename));
          } catch (e) {
            console.error('Unable to register %s (Error: %s)', filename, e.message);
          }
        }, this);
      }, this);
    }, this);
  }, this);

  return this;
};

/**
 * Get or create an alias.
 *
 * Alias allows the `get()` and `lookup()` methods to search in alternate
 * filepath for a given namespaces. It's used for example to map `generator-*`
 * npm package to their namespace equivalent (without the generator- prefix),
 * or to default a single namespace like `angular` to `angular:app` or
 * `angular:all`.
 *
 * Given a single argument, this method acts as a getter. When both name and
 * value are provided, acts as a setter and registers that new alias.
 *
 * If multiple alias are defined, then the replacement is recursive, replacing
 * each alias in reverse order.
 *
 * An alias can be a single String or a Regular Expression. The finding is done
 * based on .match().
 *
 * ### Examples:
 *
 *     env.alias(/^([a-zA-Z0-9:\*]+)$/, 'generator-$1');
 *     env.alias(/^([^:]+)$/, '$1:app');
 *     env.alias(/^([^:]+)$/, '$1:all');
 *     env.alias('foo');
 *     // => generator-foo:all
 *
 * @param {String|RegExp} match
 * @param {String} value
 */

Environment.prototype.alias = function alias(match, value) {
  if (match && value) {
    this.aliases.push({
      match: match instanceof RegExp ? match : new RegExp('^' + match + '$'),
      value: value
    });
    return this;
  }

  var aliases = this.aliases.slice(0).reverse();

  var matcher = aliases.filter(function (alias) {
    return alias.match.test(match);
  });

  return aliases.reduce(function (res, alias) {
    if (!alias.match.test(res)) {
      return res;
    }

    return res.replace(alias.match, alias.value);
  }, match);
};

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

Environment.prototype.namespace = function namespace(filepath) {
  if (!filepath) {
    throw new Error('Missing namespace');
  }

  var self = this;

  // cleanup extension and normalize path for differents OS
  var ns = path.normalize(filepath.replace(path.extname(filepath), ''));

  // extend every lookups folders with searchable file system paths
  var lookups = _(this.lookups).map(function (lookup) {
    return _.map(self.paths, function (filepath) {
      return path.join(filepath, lookup);
    });
  }).flatten().sortBy('length').value().reverse();

  // if `ns` contain a lookup dir in it's path, remove it.
  ns = lookups.reduce(function (ns, lookup) {
    return ns.replace(lookup, '');
  }, ns);

  // cleanup `ns` from unwanted parts and then normalize slashes to `:`
  ns = ns
    .replace(/[\/\\]?node_modules[\/\\]?/, '') // remove `/node_modules/`
    .replace(/[\/\\](index|main)$/, '') // remove `/index` or `/main`
    .replace(/\.+/g, '') // remove `.`
    .replace(/^[\/\\]+/, '') // remove leading `/`
    .replace(/[\/\\]+/g, ':'); // replace slashes by `:`

  // if we still have prefix match at this point, then remove anything before
  // that match, this would catch symlinked package with a name begining with
  // `generator-*` (one of the configured prefix)
  ns = this._prefixes.reduce(function (ns, prefix) {
    var pos = ns.lastIndexOf(prefix);

    if (pos < 0) {
      return ns;
    }

    return ns.slice(pos + prefix.length);
  }, ns);

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

  return ns;
};

/**
 * Adds the namespace prefix to this environment, such as `generator-*`,
 * used when resolving namespace, replacing the leading `*` in the
 * namespace by the configured prefix(es).
 *
 * ### Examples:
 *
 *     this.prefix('generator-');
 *
 * @param {String} prefix
 */

Environment.prototype.prefix = function _prefix(prefix) {
  if (!prefix) {
    throw new Error('Missing prefix');
  }

  this._prefixes.push(prefix);
  this._prefixReg = new RegExp('^(' + this._prefixes.join('|') + ')');

  return this;
};

/**
 * Get or set the namespace suffix to this environment, such as `*\index.js`,
 * used when resolving namespace, replacing the last `*` in the
 * namespace by the configured suffix.
 *
 * ### Examples:
 *
 *     this.suffix('*\index.js');
 *     this.suffix();
 *     // => '*\index.js'
 *
 * @param {String} suffix
 */

Environment.prototype.suffix = function _suffix(suffix) {
  this._suffix = this._suffix || '';

  if (!suffix) {
    return this._suffix;
  }

  this._suffix = suffix;
  return this;
};

/**
 * Walk up the filesystem looking for a `node_modules` folder, and add it if
 * found to the load path.
 *
 * @param {String} filename
 * @param {String} basedir
 */

Environment.prototype.plugins = function plugins(filename, basedir) {
  filename = filename || 'node_modules';
  basedir = basedir || process.cwd();

  var filepath = path.join(basedir, filename);

  if (fs.existsSync(filepath)) {
    this.appendPath(filepath);
    return this;
  }

  if (basedir === path.resolve('/')) {
    return this;
  }

  return this.plugins(filename, path.join(basedir, '..'));
};

/**
 * Install an npm package locally, expanding github like user/repo pattern to
 * the remote tarball for master.
 *
 * It is taking care of potential remote packages (or local on the current file
 * system) by delegating the groundwork of getting the package to npm.
 *
 * @param {String} name
 * @param {Function} done
 */

Environment.prototype.remote = function remote(name, done) {
  var self = this;
  log.write().info('Installing remote package %s', name).write();
  var npm = spawn('npm', ['install', name, '--save-dev']);
  npm.stdout.pipe(process.stdout);
  npm.stderr.pipe(process.stderr);
  done = done || function () {};
  npm.on('exit', function (code) {
    if (code !== 0) {
      return self.error(new Error('Error initing from remote: ' + name + '. Code: ' + code));
    }

    log.ok('Installed %s package', name).write();
    log.info('You should see additional generators available').write()
    .write(self.help()).write();
    done();
  });
};
