'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const EventEmitter = require('events');
const assert = require('assert');
const _ = require('lodash');
const semver = require('semver');
const readPkgUp = require('read-pkg-up');
const chalk = require('chalk');
const minimist = require('minimist');
const runAsync = require('run-async');
const createDebug = require('debug');
const memFsEditor = require('mem-fs-editor');

const packageJson = require('../package.json');
const Storage = require('./util/storage');
const promptSuggestion = require('./util/prompt-suggestion');

const EMPTY = '@@_YEOMAN_EMPTY_MARKER_@@';
const debug = createDebug('yeoman:generator');
const ENV_VER_WITH_VER_API = '2.9.0';

const mixins = [require('./actions/package-json')];

// eslint-disable-next-line unicorn/no-array-reduce
const Base = mixins.reduce((a, b) => b(a), EventEmitter);

// Ensure a prototype method is a candidate run by default
const methodIsValid = function (name) {
  return name.charAt(0) !== '_' && name !== 'constructor';
};

/**
 * Queue options.
 * @typedef {Object} QueueOptions
 * @property {string} [queueName] - Name of the queue.
 * @property {boolean} [once] - Execute only once by namespace and taskName.
 * @property {boolean} [run] - Run the queue if not running yet.
 */

/**
 * Task options.
 * @typedef {QueueOptions} TaskOptions
 * @property {Function} [reject] - Reject callback.
 */

/**
 * Priority object.
 * @typedef {QueueOptions} Priority
 * @property {string} priorityName - Name of the priority.
 * @property {string} [before] - The queue which this priority should be added before.
 */

/**
 * Complete Task object.
 * @typedef {TaskOptions} Task
 * @property {WrappedMethod} method - Function to be queued.
 * @property {string} taskName - Name of the task.
 */

/**
 * RunAsync creates a promise and executes wrappedMethod inside the promise.
 * It replaces async property of the wrappedMethod's context with one RunAsync provides.
 * async() simulates an async function by creating a callback.
 *
 * It supports promises/async and sync functions.
 * - Promises/async: forward resolve/reject from the runAsync promise to the
 *   promise returned by the wrappedMethod.
 * - Sync functions: resolves with the returned value.
 *   Can be a promise for chaining
 * - Sync functions with callback (done = this.async()) calls:
 *   Reject with done(rejectValue) first argument
 *   Resolve with done(undefined, resolveValue) second argument
 * - Callback must called when 'async()' was called inside a sync function.
 * - Callback can be ignored when 'async()' was called inside a async function.
 * @typedef {Function} WrappedMethod
 */

class Generator extends Base {
  // If for some reason environment adds more queues, we should use or own for stability.
  static get queues() {
    return [
      'initializing',
      'prompting',
      'configuring',
      'default',
      'writing',
      'transform',
      'conflicts',
      'install',
      'end'
    ];
  }

  /**
   * @classdesc The `Generator` class provides the common API shared by all generators.
   * It define options, arguments, file, prompt, log, API, etc.
   *
   * It mixes into its prototype all the methods found in the `actions/` mixins.
   *
   * Every generator should extend this base class.
   *
   * @constructor
   * @augments actions/package-json
   * @mixes actions/help
   * @mixes actions/spawn-command
   * @mixes actions/user
   * @mixes actions/fs
   * @mixes nodejs/EventEmitter
   *
   * @param {string[]} args           - Provide arguments at initialization
   * @param {Object} options          - Provide options at initialization
   * @param {Priority[]} [options.customPriorities] - Custom priorities
   * @property {Object}   env         - the current Environment being run
   * @property {String}   resolved    - the path to the current generator
   * @property {String}   description - Used in `--help` output
   * @property {String}   appname     - The application name
   * @property {Storage}  config      - `.yo-rc` config file manager
   * @property {Object}   fs          - An instance of {@link https://github.com/SBoudrias/mem-fs-editor Mem-fs-editor}
   * @property {Function} log         - Output content through Interface Adapter
   * @param {Object} features         - Provide Generator features information
   * @property {String}   uniqueBy    - The Generator instance unique identifier.
   *                                    The Environment will ignore duplicated identifiers.
   * @property {String}   unique      - uniqueBy calculation method (undefined/argument/namespace)
   * @property {boolean} tasksMatchingPriority - Only queue methods that matches a priority.
   * @property {String}   taskPrefix  - Tasks methods starts with prefix. Allows api methods (non tasks) without prefix.
   * @property {boolean|Function} customInstallTask - Provides a custom install task. Environment >= 3.2.0
   *                                                  Environment built-in task will not be executed
   * @property {boolean|Function} customCommitTask - Provides a custom commit task. Environment >= 3.2.0
   *                                                  Environment built-in task will not be executed
   *
   * @example
   * const Generator = require('yeoman-generator');
   * module.exports = class extends Generator {
   *   writing() {
   *     this.fs.write(this.destinationPath('index.js'), 'const foo = 1;');
   *   }
   * };
   */
  constructor(args, options, features) {
    super();

    if (!Array.isArray(args)) {
      features = options;
      options = args;
      args = [];
    }

    options = options || {};
    this.options = options;
    this._initOptions = _.clone(options);
    this._args = args || [];
    this._options = {};
    this._arguments = [];
    this._prompts = [];
    this._composedWith = [];
    this._namespace = this.options.namespace;
    this._namespaceId = this.options.namespaceId;
    this.yoGeneratorVersion = packageJson.version;
    this.features = features || {unique: this.options.unique};

    this.option('help', {
      type: Boolean,
      alias: 'h',
      description: "Print the generator's options and usage"
    });

    this.option('skip-cache', {
      type: Boolean,
      description: 'Do not remember prompt answers',
      default: false
    });

    this.option('skip-install', {
      type: Boolean,
      description: 'Do not automatically install dependencies',
      default: false
    });

    this.option('force-install', {
      type: Boolean,
      description: 'Fail on install dependencies error',
      default: false
    });

    this.option('ask-answered', {
      type: Boolean,
      description: 'Show prompts for already configured options',
      default: false
    });

    this.env = this.options.env;

    this.resolved = this.options.resolved || __filename;
    this.description = this.description || '';

    if (this.env) {
      // Determine the app root
      this.contextRoot = this.env.cwd;
      this.destinationRoot(this.options.destinationRoot || this.env.cwd);
      // Clear destionationRoot, _destinationRoot will take priority when composing, but not override passed options.
      delete this.options.destinationRoot;

      // Ensure source/destination path, can be configured from subclasses
      this.sourceRoot(path.join(path.dirname(this.resolved), 'templates'));

      this.fs = memFsEditor.create(this.env.sharedFs);
    }

    // Add convenience debug object
    this._debug = createDebug(
      this.options.namespace || 'yeoman:unknownnamespace'
    );

    // Expose utilities for dependency-less generators.
    this._ = _;

    if (this.options.help) {
      return;
    }

    if (this.features.unique && !this.features.uniqueBy) {
      const {namespace} = this.options;
      let uniqueBy;
      if (
        this.features.unique === true ||
        this.features.unique === 'namespace'
      ) {
        uniqueBy = namespace;
      } else if (
        this.features.unique === 'argument' &&
        this._args.length === 1
      ) {
        const namespaceId = this.env
          .requireNamespace(namespace)
          .with({instanceId: this._args[0]});
        uniqueBy = namespaceId.id;
      } else {
        throw new Error(
          `Error generating a uniqueBy value. Uniqueness '${this.features.unique}' not supported by '${this.options.namespace}'`
        );
      }

      this.features.uniqueBy = uniqueBy;
    }

    if (!this.env) {
      throw new Error('This generator requires an environment.');
    }

    // Ensure the environment support features this yeoman-generator version require.
    if (
      !this.env ||
      !this.env.adapter ||
      !this.env.runLoop ||
      !this.env.sharedFs ||
      !this.env.fs
    ) {
      throw new Error(
        "Current environment doesn't provides some necessary feature this generator needs."
      );
    }

    // Mirror the adapter log method on the generator.
    //
    // example:
    // this.log('foo');
    // this.log.error('bar');
    this.log = this.env.adapter && this.env.adapter.log;

    // Place holder for run-async callback.
    this.async = () => () => {};

    this.appname = this.determineAppname();

    // Create config for the generator and instance
    if (this._namespaceId && this._namespaceId.generator) {
      this.generatorConfig = this.config.createStorage(
        `:${this._namespaceId.generator}`
      );
      if (this._namespaceId.instanceId) {
        this.instanceConfig = this.generatorConfig.createStorage(
          `#${this._namespaceId.instanceId}`
        );
      }
    }

    this._globalConfig = this._getGlobalStorage();

    // Queues map: generator's queue name => grouped-queue's queue name (custom name)
    this._queues = {};

    // Add original queues.
    for (const queue of Generator.queues) {
      this._queues[queue] = {priorityName: queue, queueName: queue};
    }

    // Add custom queues
    if (Array.isArray(this.options.customPriorities)) {
      this.registerPriorities(this.options.customPriorities);
    }

    this.compose = this.options.compose;

    // Requires environment 3
    if (!this.options.skipCheckEnv) {
      this.checkEnvironmentVersion('3.0.0');
    }

    this.checkEnvironmentVersion('3.2.0', true);
  }

  /**
   * Configure Generator behaviours.
   *
   * @param {Object} features
   * @param {boolean|string} [features.unique] - Generates a uniqueBy id for the environment
   *                                    Accepts 'namespace' or 'true' for one instance by namespace
   *                                    Accepts 'argument' for one instance by namespace and 1 argument
   *
   */
  setFeatures(features) {
    Object.assign(this.features, features);
  }

  /**
   * Specifications for Environment features.
   *
   * @return {Object}
   */
  getFeatures() {
    return this.features;
  }

  /**
   * Register priorities for this generator
   *
   * @param  {Object[]} priorities - Priorities
   * @param  {String} priorities.priorityName - Priority name
   * @param  {String} [priorities.before] - The new priority will be queued before the `before` priority. Required for new priorities.
   * @param  {String} [priorities.queueName] - Name to be used at grouped-queue
   * @param  {boolean} [priorities.edit] - Edit a priority
   * @param  {boolean} [priorities.skip] - Queued manually only
   * @param  {Object[]|function} [priorities.args] - Arguments to pass to tasks
   */
  registerPriorities(priorities) {
    priorities = priorities.filter((priority) => {
      if (priority.edit) {
        const queue = this._queues[priority.priorityName];
        if (!queue) {
          throw new Error(
            `Error editing priority ${priority.priorityName}, not found`
          );
        }

        Object.assign(queue, {...priority, edit: undefined});
      }

      return !priority.edit;
    });
    const customPriorities = priorities.map((customPriority) => {
      // Keep backward compatibility with name
      const newPriority = {
        priorityName: customPriority.name,
        ...customPriority
      };
      delete newPriority.name;
      return newPriority;
    });

    // Sort customPriorities, a referenced custom queue must be added before the one that reference it.
    customPriorities.sort((a, b) => {
      if (a.priorityName === b.priorityName) {
        throw new Error(`Duplicate custom queue ${a.name}`);
      }

      if (a.priorityName === b.before) {
        return -1;
      }

      if (b.priorityName === a.before) {
        return 1;
      }

      return 0;
    });

    // Add queue to runLoop
    for (const customQueue of customPriorities) {
      customQueue.queueName =
        customQueue.queueName ||
        `${this.options.namespace}#${customQueue.priorityName}`;
      debug(`Registering custom queue ${customQueue.queueName}`);
      this._queues[customQueue.priorityName] = customQueue;

      if (this.env.runLoop.queueNames.includes(customQueue.queueName)) {
        continue;
      }

      const beforeQueue = customQueue.before
        ? this._queues[customQueue.before].queueName
        : undefined;
      this.env.runLoop.addSubQueue(customQueue.queueName, beforeQueue);
    }
  }

  checkEnvironmentVersion(packageDependency, version, warning = false) {
    if (typeof version === 'boolean') {
      warning = version;
      version = undefined;
    }

    if (version === undefined) {
      version = packageDependency;
      packageDependency = 'yeoman-environment';
    }

    version = version || ENV_VER_WITH_VER_API;
    const returnError = (currentVersion) => {
      return new Error(
        `This generator (${this.options.namespace}) requires ${packageDependency} at least ${version}, current version is ${currentVersion}, try reinstalling latest version of 'yo' or use '--ignore-version-check' option`
      );
    };

    if (!this.env.getVersion) {
      if (!this.options.ignoreVersionCheck && !warning) {
        throw returnError(`less than ${ENV_VER_WITH_VER_API}`);
      }

      console.warn(
        `It's not possible to check version with running Environment less than ${ENV_VER_WITH_VER_API}`
      );
      console.warn('Some features may be missing');
      if (semver.lte(version, '2.8.1')) {
        return undefined;
      }

      return false;
    }

    const runningVersion = this.env.getVersion(packageDependency);
    if (runningVersion !== undefined && semver.lte(version, runningVersion)) {
      return true;
    }

    if (this.options.ignoreVersionCheck || warning) {
      console.warn(
        `Current ${packageDependency} is not compatible with current generator, min required: ${version} current version: ${runningVersion}. Some features may be missing, try updating reinstalling 'yo'.`
      );
      return false;
    }

    throw returnError(runningVersion);
  }

  /**
   * Convenience debug method
   *
   * @param  {any} args parameters to be passed to debug
   */
  debug(...args) {
    this._debug(...args);
  }

  /**
   * Register stored config prompts and optional option alternative.
   *
   * @param {Inquirer|Inquirer[]} questions - Inquirer question or questions.
   * @param {Object|Boolean} [questions.exportOption] - Additional data to export this question as an option.
   * @param {Storage|String} [question.storage=this.config] - Storage to store the answers.
   */
  registerConfigPrompts(questions) {
    questions = Array.isArray(questions) ? questions : [questions];
    const getOptionTypeFromInquirerType = (type) => {
      if (type === 'number') {
        return Number;
      }

      if (type === 'confirm') {
        return Boolean;
      }

      if (type === 'checkbox') {
        return Array;
      }

      return String;
    };

    for (const q of questions) {
      const question = {...q};
      if (q.exportOption) {
        const option =
          typeof q.exportOption === 'boolean' ? {} : q.exportOption;
        this.option({
          name: q.name,
          type: getOptionTypeFromInquirerType(q.type),
          description: q.message,
          ...option,
          storage: q.storage || this.config
        });
      }

      this._prompts.push(question);
    }
  }

  /**
   * Prompt user to answer questions. The signature of this method is the same as {@link https://github.com/SBoudrias/Inquirer.js Inquirer.js}
   *
   * On top of the Inquirer.js API, you can provide a `{store: true}` property for
   * every question descriptor. When set to true, Yeoman will store/fetch the
   * user's answers as defaults.
   *
   * @param  {object|object[]} questions  Array of question descriptor objects. See {@link https://github.com/SBoudrias/Inquirer.js/blob/master/README.md Documentation}
   * @param  {Storage|String} [questions.storage] Storage object or name (generator property) to be used by the question to store/fetch the response.
   * @param  {Storage|String} [storage] Storage object or name (generator property) to be used by default to store/fetch responses.
   * @return {Promise} prompt promise
   */
  prompt(questions, storage) {
    const checkInquirer = () => {
      if (this.inquireSupportsPrefilled === undefined) {
        this.checkEnvironmentVersion();
        this.inquireSupportsPrefilled = this.checkEnvironmentVersion(
          'inquirer',
          '7.1.0'
        );
      }
    };

    if (storage !== undefined) {
      checkInquirer();
    }

    const storageForQuestion = {};

    const getAnswerFromStorage = (question) => {
      let questionStorage = question.storage || storage;
      questionStorage =
        typeof questionStorage === 'string'
          ? this[questionStorage]
          : questionStorage;
      if (questionStorage) {
        checkInquirer();

        const {name} = question;
        storageForQuestion[name] = questionStorage;
        const value = questionStorage.getPath(name);
        if (value !== undefined) {
          question.default = (answers) => answers[name];
          return [name, value];
        }
      }

      return undefined;
    };

    if (!Array.isArray(questions)) {
      questions = [questions];
    }

    // Shows the prompt even if the answer already exists.
    for (const question of questions) {
      if (question.askAnswered === undefined) {
        question.askAnswered = this.options.askAnswered === true;
      }
    }

    questions = promptSuggestion.prefillQuestions(
      this._globalConfig,
      questions
    );
    questions = promptSuggestion.prefillQuestions(this.config, questions);
    const answers = Object.fromEntries(
      questions.map(getAnswerFromStorage).filter(Boolean)
    );

    return this.env.adapter.prompt(questions, answers).then((answers) => {
      Object.entries(storageForQuestion).forEach(([name, questionStorage]) => {
        const answer = answers[name] === undefined ? null : answers[name];
        questionStorage.setPath(name, answer);
      });

      if (!this.options.skipCache) {
        promptSuggestion.storeAnswers(
          this._globalConfig,
          questions,
          answers,
          false
        );
        if (!this.options.skipLocalCache) {
          promptSuggestion.storeAnswers(this.config, questions, answers, true);
        }
      }

      return answers;
    });
  }

  /**
   * Adds an option to the set of generator expected options, only used to
   * generate generator usage. By default, generators get all the cli options
   * parsed by nopt as a `this.options` hash object.
   *
   * @param {String} [name] - Option name
   * @param {Object} config - Option options
   * @param {any} config.type - Either Boolean, String or Number
   * @param {string} [config.description] - Description for the option
   * @param {any} [config.default] - Default value
   * @param {any} [config.alias] - Option name alias (example `-h` and --help`)
   * @param {any} [config.hide] - Boolean whether to hide from help
   * @param {Storage} [config.storage] - Storage to persist the option
   * @return {this} This generator
   */
  option(name, config) {
    if (Array.isArray(name)) {
      for (const option of name) {
        this.option(option);
      }

      return;
    }

    if (typeof name === 'object') {
      config = name;
      name = config.name;
    }

    config = config || {};

    // Alias default to defaults for backward compatibility.
    if ('defaults' in config) {
      config.default = config.defaults;
    }

    config.description = config.description || config.desc;

    _.defaults(config, {
      name,
      description: 'Description for ' + name,
      type: Boolean,
      hide: false
    });

    // Check whether boolean option is invalid (starts with no-)
    const boolOptionRegex = /^no-/;
    if (config.type === Boolean && boolOptionRegex.test(name)) {
      const simpleName = name.replace(boolOptionRegex, '');
      throw new Error(
        [
          `Option name ${chalk.yellow(name)} cannot start with ${chalk.red(
            'no-'
          )}\n`,
          `Option name prefixed by ${chalk.yellow(
            '--no'
          )} are parsed as implicit`,
          ` boolean. To use ${chalk.yellow('--' + name)} as an option, use\n`,
          chalk.cyan(`  this.option('${simpleName}', {type: Boolean})`)
        ].join('')
      );
    }

    if (this._options[name] === null || this._options[name] === undefined) {
      this._options[name] = config;
    }

    if (!this.options.skipParseOptions) {
      this.parseOptions();
    }

    if (config.storage && this.options[name] !== undefined) {
      const storage =
        typeof config.storage === 'string'
          ? this[config.storage]
          : config.storage;
      storage.set(name, this.options[name]);
    }

    return this;
  }

  /**
   * Adds an argument to the class and creates an attribute getter for it.
   *
   * Arguments are different from options in several aspects. The first one
   * is how they are parsed from the command line, arguments are retrieved
   * based on their position.
   *
   * Besides, arguments are used inside your code as a property (`this.argument`),
   * while options are all kept in a hash (`this.options`).
   *
   *
   * @param {String} name - Argument name
   * @param {Object} config - Argument options
   * @param {any} config.type - String, Number, Array, or Object
   * @param {string} [config.description] - Description for the argument
   * @param {boolean} [config.required] - required` Boolean whether it is required
   * @param {boolean} [config.optional] - Boolean whether it is optional
   * @param {any} [config.default] - Default value for this argument
   * @return {this} This generator
   */
  argument(name, config) {
    config = config || {};

    // Alias default to defaults for backward compatibility.
    if ('defaults' in config) {
      config.default = config.defaults;
    }

    config.description = config.description || config.desc;

    _.defaults(config, {
      name,
      required: config.default === null || config.default === undefined,
      type: String
    });

    this._arguments.push(config);

    if (!this.options.skipParseOptions) {
      this.parseOptions();
    }

    return this;
  }

  parseOptions() {
    const minimistDef = {
      string: [],
      boolean: [],
      alias: {},
      default: {}
    };

    _.each(this._options, (option) => {
      if (option.type === Boolean) {
        minimistDef.boolean.push(option.name);
        if (!('default' in option) && !option.required) {
          minimistDef.default[option.name] = EMPTY;
        }
      } else {
        minimistDef.string.push(option.name);
      }

      if (option.alias) {
        minimistDef.alias[option.alias] = option.name;
      }

      // Only apply default values if we don't already have a value injected from
      // the runner
      if (option.name in this._initOptions) {
        minimistDef.default[option.name] = this._initOptions[option.name];
      } else if (option.alias && option.alias in this._initOptions) {
        minimistDef.default[option.name] = this._initOptions[option.alias];
      } else if ('default' in option) {
        minimistDef.default[option.name] = option.default;
      }
    });

    const parsedOptions = minimist(this._args, minimistDef);

    // Parse options to the desired type
    _.each(parsedOptions, (option, name) => {
      // Manually set value as undefined if it should be.
      if (option === EMPTY) {
        delete parsedOptions[name];
        return;
      }

      if (this._options[name] && option !== undefined) {
        parsedOptions[name] = this._options[name].type(option);
      }
    });

    // Parse positional arguments to valid options
    for (const [index, config] of this._arguments.entries()) {
      let value;
      if (index >= parsedOptions._.length) {
        if (config.name in this._initOptions) {
          value = this._initOptions[config.name];
        } else if ('default' in config) {
          value = config.default;
        } else {
          continue;
        }
      } else if (config.type === Array) {
        value = parsedOptions._.slice(index, parsedOptions._.length);
      } else {
        value = config.type(parsedOptions._[index]);
      }

      parsedOptions[config.name] = value;
    }

    // Make the parsed options available to the instance
    Object.assign(this.options, parsedOptions);
    this.args = parsedOptions._;
    this.arguments = parsedOptions._;

    // Make sure required args are all present
    this.checkRequiredArgs();
  }

  checkRequiredArgs() {
    // If the help option was provided, we don't want to check for required
    // arguments, since we're only going to print the help message anyway.
    if (this.options.help) {
      return;
    }

    // Bail early if it's not possible to have a missing required arg
    if (this.args.length > this._arguments.length) {
      return;
    }

    for (const [position, config] of this._arguments.entries()) {
      // If the help option was not provided, check whether the argument was
      // required, and whether a value was provided.
      if (config.required && position >= this.args.length) {
        throw new Error(
          `Did not provide required argument ${chalk.bold(config.name)}!`
        );
      }
    }
  }

  /**
   * Schedule methods on a run queue.
   *
   * @param {Function|Object} method: Method to be scheduled or object with function properties.
   * @param {String} [methodName]: Name of the method (task) to be scheduled.
   * @param {String} [queueName]: Name of the queue to be scheduled on.
   * @param {Function} [reject]: Reject callback.
   */
  queueMethod(method, methodName, queueName, reject) {
    if (typeof queueName === 'function') {
      reject = queueName;
      queueName = 'default';
    } else {
      queueName = queueName || 'default';
    }

    if (!_.isFunction(method)) {
      if (typeof methodName === 'function') {
        reject = methodName;
        methodName = undefined;
      }

      this.queueTaskGroup(method, {
        queueName: methodName,
        reject
      });
      return;
    }

    this.queueTask({
      method,
      taskName: methodName,
      queueName,
      reject
    });
  }

  /**
   * Schedule tasks from a group on a run queue.
   *
   * @param {Object}          taskGroup: Object containing tasks.
   * @param {TaskOptions} [taskOptions]: options.
   */
  queueTaskGroup(taskGroup, taskOptions) {
    this.extractTasksFromGroup(taskGroup, taskOptions).forEach((task) => {
      this.queueTask(task);
    });
  }

  /**
   * @private
   * Extract tasks from a priority.
   *
   * @param {String}  name: The method name to schedule.
   * @param {TaskOptions} [taskOptions]: options.
   */
  extractTasksFromPriority(name, taskOptions = {}) {
    const priority = this._queues[name];
    taskOptions = {
      ...priority,
      cancellable: true,
      run: false,
      ...taskOptions
    };

    if (taskOptions.auto && priority && priority.skip) {
      return [];
    }

    const {taskPrefix = this.features.taskPrefix || ''} = taskOptions;
    const propertyName = taskPrefix ? `${taskPrefix}${name}` : name;
    const property = Object.getOwnPropertyDescriptor(
      taskOptions.taskOrigin || Object.getPrototypeOf(this),
      propertyName
    );
    if (!property) return [];

    const item = property.value ? property.value : property.get.call(this);

    // Name points to a function; single task
    if (typeof item === 'function') {
      return [{...taskOptions, taskName: name, method: item}];
    }

    if (!item || !priority) {
      return [];
    }

    return this.extractTasksFromGroup(item, taskOptions);
  }

  /**
   * @private
   * Extract tasks from group.
   *
   * @param {Object}  group: Task group.
   * @param {TaskOptions} [taskOptions]: options.
   */
  extractTasksFromGroup(group, taskOptions) {
    return Object.entries(group)
      .map(([taskName, method]) => {
        if (typeof method !== 'function' || !methodIsValid(taskName)) return;
        return {
          ...taskOptions,
          method,
          taskName
        };
      })
      .filter(Boolean);
  }

  /**
   * @private
   * Schedule a generator's method on a run queue.
   *
   * @param {String}  name: The method name to schedule.
   * @param {TaskOptions} [taskOptions]: options.
   */
  queueOwnTask(name, taskOptions) {
    this.extractTasksFromPriority(name, taskOptions).forEach((task) =>
      this.queueTask(task)
    );
  }

  /**
   * @private
   * Get task names.
   *
   * @return {string[]}
   */
  getTaskNames() {
    const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
    let validMethods = methods.filter(methodIsValid);
    const {taskPrefix} = this.features;

    if (taskPrefix) {
      validMethods = validMethods
        .filter((method) => method.startsWith(taskPrefix))
        .map((method) => method.slice(taskPrefix.length));
    } else {
      validMethods = validMethods.filter((method) => method.charAt(0) !== '#');
    }

    if (this.features.tasksMatchingPriority) {
      const queueNames = Object.keys(this._queues);
      validMethods = validMethods.filter((method) =>
        queueNames.includes(method)
      );
    }

    return validMethods;
  }

  /**
   * @private
   * Schedule every generator's methods on a run queue.
   *
   * @param {TaskOptions} [taskOptions]: options.
   */
  queueOwnTasks(taskOptions) {
    this._running = true;
    this._taskStatus = {cancelled: false, timestamp: new Date()};

    const validMethods = this.getTaskNames();
    if (validMethods.length === 0 && this._prompts.length === 0) {
      throw new Error(
        'This Generator is empty. Add at least one method for it to run.'
      );
    }

    if (this._prompts.length > 0) {
      this.queueTask({
        method: () => this.prompt(this._prompts, this.config),
        taskName: 'Prompt registered questions',
        queueName: 'prompting',
        cancellable: true
      });

      if (validMethods.length === 0) {
        this.queueTask({
          method: () => {
            this.renderTemplate();
          },
          taskName: 'Empty generator: copy templates',
          queueName: 'writing',
          cancellable: true
        });
      }
    }

    for (const methodName of validMethods)
      this.queueOwnTask(methodName, taskOptions);

    this.emit('queueOwnTasks');
  }

  /**
   * Schedule tasks on a run queue.
   *
   * @param {Task} task: Task to be queued.
   */
  queueTask(task) {
    const {queueName = 'default', taskName: methodName, run, once} = task;

    const {runLoop} = this.env;
    const {_taskStatus: taskStatus, options = {}} = this;
    const {namespace = ''} = options;

    debug(
      `Queueing ${namespace}#${methodName} with options %o`,
      _.omit(task, ['method'])
    );
    runLoop.add(
      queueName,
      // Run-queue's done(continue), pause
      (continueQueue) => {
        this.executeTask(task, undefined, taskStatus).then(continueQueue);
      },
      {once: once ? methodName : undefined, run}
    );
  }

  /**
   * @private
   * Execute a task.
   *
   * @param {Task} task: Task to be executed.
   * @param {string[]} args: Task arguments.
   * @param {Object} taskStatus.
   * @return Promise
   */
  executeTask(
    task,
    args = task.args || this.args,
    taskStatus = this._taskStatus || {}
  ) {
    return new Promise((resolve) => {
      const {
        reject,
        queueName = 'default',
        taskName: methodName,
        method
      } = task;
      const {namespace = ''} = this.options || {};

      const priority = Object.entries(this._queues).find(
        ([_, options]) => options.queueName === queueName
      );
      const priorityName = priority ? priority[0] : undefined;

      debug(`Running ${namespace}#${methodName}`);
      this.emit(`method:${methodName}`);
      const taskCancelled = task.cancellable && taskStatus.cancelled;
      if (taskCancelled) {
        resolve();
        return;
      }

      const generator = this;

      runAsync(function () {
        args = typeof args === 'function' ? args(generator) : args;
        generator.async = () => this.async();
        generator.runningState = {namespace, queueName, methodName};
        return method.apply(generator, args);
      })()
        .then(() => {
          delete this.runningState;
          const eventName = `done$${
            namespace || 'unknownnamespace'
          }#${methodName}`;
          debug(`Done event ${eventName}`);
          this.env.emit(eventName, {
            namespace,
            generator: this,
            queueName,
            priorityName
          });
          resolve();
        })
        .catch((error) => {
          const errorMessage = `An error occured while running ${namespace}#${methodName}`;
          if (this.log.error) {
            this.log.error(errorMessage);
          } else {
            debug(errorMessage);
          }

          if (reject) {
            debug('Rejecting task promise, queue will continue normally');
            reject(error);
            resolve();
            return;
          }

          delete this.runningState;
          try {
            this.env.emit('error', error);
          } catch (error) {
            setImmediate(() => {
              throw error;
            });
          }
        });
    });
  }

  /**
   * Generator config Storage.
   */
  get config() {
    if (!this._config) {
      this._config = this._getStorage();
    }

    return this._config;
  }

  /**
   * Package.json Storage resolved to `this.destinationPath('package.json')`.
   *
   * Environment watches for package.json changes at `this.env.cwd`, and triggers an package manager install if it has been committed to disk.
   * If package.json is at a different folder, like a changed generator root, propagate it to the Environment like `this.env.cwd = this.destinationPath()`.
   *
   * @example
   * this.packageJson.merge({
   *   scripts: {
   *     start: 'webpack --serve',
   *   },
   *   dependencies: {
   *     ...
   *   },
   *   peerDependencies: {
   *     ...
   *   },
   * });
   */
  get packageJson() {
    if (!this._packageJson) {
      this._packageJson = this.createStorage('package.json');
    }

    return this._packageJson;
  }

  /**
   * Ignore cancellable tasks.
   */
  cancelCancellableTasks() {
    this._running = false;
    // Task status references is registered at each running task
    this._taskStatus.cancelled = true;
    // Create a new task status.
    delete this._taskStatus;
  }

  /**
   * Start the generator again.
   *
   * @param {Object} [options]: options.
   */
  startOver(options = {}) {
    this.cancelCancellableTasks();
    Object.assign(this.options, options);
    this.queueOwnTasks({auto: true});
  }

  /**
   * Runs the generator, scheduling prototype methods on a run queue. Method names
   * will determine the order each method is run. Methods without special names
   * will run in the default queue.
   *
   * Any method named `constructor` and any methods prefixed by a `_` won't be scheduled.
   *
   * @return {Promise} Resolved once the process finish
   */
  run() {
    return this.env.runGenerator(this);
  }

  /**
   * Queue generator tasks.
   *
   * @return {Promise}
   */
  queueTasks() {
    const beforeQueueCallback =
      (this.features.taskPrefix && this.beforeQueue) || this._beforeQueue;
    if (beforeQueueCallback) {
      const maybePromise = beforeQueueCallback.call(this);
      if (maybePromise && maybePromise.then) {
        this.checkEnvironmentVersion('3.5.0');
        return maybePromise.then(() => this._queueTasks());
      }
    }

    return this._queueTasks();
  }

  _queueTasks() {
    debug(
      `Queueing generator ${this.options.namespace} with generator version ${this.yoGeneratorVersion}`
    );
    this.queueOwnTasks({auto: true});

    if (this._composedWith.some((generator) => generator.then)) {
      return Promise.all(this._composedWith).then(async (generators) => {
        for (const generator of generators) {
          // eslint-disable-next-line no-await-in-loop
          await this.env.queueGenerator(await generator, false);
        }
      });
    }

    let promise;
    for (const generator of this._composedWith) {
      if (promise) {
        promise.then(() => this.env.queueGenerator(generator, false));
      } else {
        const maybePromise = this.env.queueGenerator(generator, false);
        if (maybePromise.then) {
          promise = maybePromise;
        }
      }
    }

    this._composedWith = [];
    return promise;
  }

  /**
   * Compose this generator with another one.
   * @param  {String|Object|Array} generator  The path to the generator module or an object (see examples)
   * @param  {Array}  [args]       Arguments passed to the Generator
   * @param  {Object}  [options]   The options passed to the Generator
   * @param  {boolean}  [immediately] Boolean whether to queue the Generator immediately
   * @return {Generator}    The composed generator
   *
   * @example <caption>Using a peerDependency generator</caption>
   * this.composeWith('bootstrap', { sass: true });
   *
   * @example <caption>Using a direct dependency generator</caption>
   * this.composeWith(require.resolve('generator-bootstrap/app/main.js'), { sass: true });
   *
   * @example <caption>Passing a Generator class</caption>
   * this.composeWith({ Generator: MyGenerator, path: '../generator-bootstrap/app/main.js' }, { sass: true });
   */
  composeWith(generator, args, options, immediately = false) {
    if (typeof args === 'boolean') {
      args = [];
    } else if (!Array.isArray(args) && typeof args === 'object') {
      options = args;
      args = options.arguments || options.args || [];
    }

    options = typeof options === 'boolean' ? {} : options || {};

    let instantiatedGenerator;

    if (Array.isArray(generator)) {
      return generator.map((gen) => this.composeWith(gen, args, options));
    }

    // Pass down the default options so they're correctly mirrored down the chain.
    options = {
      destinationRoot: this._destinationRoot,
      ...options,
      skipInstall: this.options.skipInstall,
      skipCache: this.options.skipCache,
      forceInstall: this.options.forceInstall,
      skipLocalCache: this.options.skipLocalCache
    };

    const instantiate = (Generator, path) => {
      if (path === 'unknown') {
        Generator.resolved = path;
      } else {
        Generator.resolved = require.resolve(path);
      }

      Generator.namespace = this.env.namespace(path);

      return this.env.instantiate(Generator, args, options);
    };

    if (typeof generator === 'string') {
      try {
        // Allows to run a local generator without namespace.
        const GeneratorImport = require(generator);
        const Generator =
          typeof GeneratorImport.default === 'function'
            ? GeneratorImport.default
            : GeneratorImport;

        instantiatedGenerator = instantiate(Generator, generator);
      } catch {
        instantiatedGenerator = this.env.create(generator, args, options);
      }
    } else {
      const {Generator, path} = generator;
      assert(
        Generator,
        `${chalk.red('Missing Generator property')}
When passing an object to Generator${chalk.cyan(
          '#composeWith'
        )} include the generator class to run in the ${chalk.cyan(
          'Generator'
        )} property

this.composeWith({
  ${chalk.yellow('Generator')}: MyGenerator,
  ...\n
});`
      );
      assert(
        typeof path === 'string',
        `${chalk.red('path property is not a string')}
When passing an object to Generator${chalk.cyan(
          '#composeWith'
        )} include the path to the generators files in the ${chalk.cyan(
          'path'
        )} property

this.composeWith({
  ${chalk.yellow('path')}: '../my-generator',
  ...
});`
      );
      instantiatedGenerator = instantiate(Generator, path);
    }

    if (!instantiatedGenerator) {
      return instantiatedGenerator;
    }

    if (this._running || immediately) {
      if (instantiatedGenerator.then) {
        return instantiatedGenerator.then((generator) => {
          this.env.queueGenerator(generator);
          return generator;
        });
      }

      this.env.queueGenerator(instantiatedGenerator);
    } else {
      this._composedWith.push(instantiatedGenerator);
    }

    return instantiatedGenerator;
  }

  /**
   * Determine the root generator name (the one who's extending Generator).
   * @return {String} The name of the root generator
   */
  rootGeneratorName() {
    const {packageJson: {name = '*'} = {}} =
      readPkgUp.sync({cwd: this.resolved}) || {};
    return name;
  }

  /**
   * Determine the root generator version (the one who's extending Generator).
   * @return {String} The version of the root generator
   */
  rootGeneratorVersion() {
    const {packageJson: {version = '0.0.0'} = {}} =
      readPkgUp.sync({cwd: this.resolved}) || {};
    return version;
  }

  /**
   * Return a storage instance.
   * @param  {String} storePath  The path of the json file
   * @param  {String} [path] The name in which is stored inside the json
   * @param  {boolean|Object} [options] Treat path as an lodash path
   * @return {Storage} json storage
   */
  createStorage(storePath, path, options) {
    if (typeof path === 'object') {
      options = path;
      path = undefined;
    } else if (typeof options === 'boolean') {
      options = {lodashPath: options};
    }

    storePath = this.destinationPath(storePath);
    return new Storage(path, this.fs, storePath, options);
  }

  /**
   * Return a storage instance.
   * @param  {String} [rootName] The rootName in which is stored inside .yo-rc.json
   * @param  {object} [options] Storage options
   * @return {Storage} Generator storage
   * @private
   */
  _getStorage(rootName = this.rootGeneratorName(), options) {
    if (typeof rootName === 'object') {
      options = rootName;
      rootName = this.rootGeneratorName();
    }

    const storePath = path.join(this.destinationRoot(), '.yo-rc.json');
    return new Storage(rootName, this.fs, storePath, options);
  }

  /**
   * Setup a globalConfig storage instance.
   * @return {Storage} Global config storage
   * @private
   */
  _getGlobalStorage() {
    // When localConfigOnly === true simulate a globalConfig at local dir
    const globalStorageDir = this.options.localConfigOnly
      ? this.destinationRoot()
      : os.homedir();
    const storePath = path.join(globalStorageDir, '.yo-rc-global.json');
    const storeName = `${this.rootGeneratorName()}:${this.rootGeneratorVersion()}`;
    return new Storage(storeName, this.fs, storePath);
  }

  /**
   * Change the generator destination root directory.
   * This path is used to find storage, when using a file system helper method (like
   * `this.write` and `this.copy`)
   * @param  {String} rootPath new destination root path
   * @return {String}          destination root path
   */
  destinationRoot(rootPath) {
    if (typeof rootPath === 'string') {
      this._destinationRoot = path.resolve(rootPath);

      if (!fs.existsSync(this._destinationRoot)) {
        fs.mkdirSync(this._destinationRoot, {recursive: true});
      }

      // Reset the storage
      this._config = undefined;
      // Reset packageJson
      this._packageJson = undefined;
    }

    return this._destinationRoot || this.env.cwd;
  }

  /**
   * Change the generator source root directory.
   * This path is used by multiples file system methods like (`this.read` and `this.copy`)
   * @param  {String} rootPath new source root path
   * @return {String}          source root path
   */
  sourceRoot(rootPath) {
    if (typeof rootPath === 'string') {
      this._sourceRoot = path.resolve(rootPath);
    }

    return this._sourceRoot;
  }

  /**
   * Join a path to the source root.
   * @param  {...String} dest - path parts
   * @return {String}    joined path
   */
  templatePath(...dest) {
    let filepath = path.join.apply(path, dest);

    if (!path.isAbsolute(filepath)) {
      filepath = path.join(this.sourceRoot(), filepath);
    }

    return filepath;
  }

  /**
   * Join a path to the destination root.
   * @param  {...String} dest - path parts
   * @return {String}    joined path
   */
  destinationPath(...dest) {
    let filepath = path.join.apply(path, dest);

    if (!path.isAbsolute(filepath)) {
      filepath = path.join(this.destinationRoot(), filepath);
    }

    return filepath;
  }

  /**
   * Determines the name of the application.
   *
   * First checks for name in bower.json.
   * Then checks for name in package.json.
   * Finally defaults to the name of the current directory.
   * @return {String} The name of the application
   */
  determineAppname() {
    let appname = this.fs.readJSON(this.destinationPath('bower.json'), {}).name;

    if (!appname) {
      appname = this.fs.readJSON(this.destinationPath('package.json'), {}).name;
    }

    if (!appname) {
      appname = path.basename(this.destinationRoot());
    }

    return appname.replace(/[^\w\s]+?/g, ' ');
  }

  /**
   * Add a transform stream to the commit stream.
   *
   * Most usually, these transform stream will be Gulp plugins.
   *
   * @param  {stream.Transform|stream.Transform[]} streams An array of Transform stream
   * or a single one.
   * @param {any} options Environment.applyTransforms options parameter
   * @return {this} This generator
   */
  queueTransformStream(transformStreams, options) {
    assert(
      transformStreams,
      'expected to receive a transform stream as parameter'
    );

    this.queueTask({
      method() {
        return this.env.applyTransforms(transformStreams, options);
      },
      taskName: 'transformStream',
      queueName: 'transform'
    });
    return this;
  }
}

// Mixin the actions modules
_.extend(Generator.prototype, require('./actions/help'));
_.extend(Generator.prototype, require('./actions/spawn-command'));
_.extend(Generator.prototype, require('./actions/fs'));
_.extend(Generator.prototype, require('./actions/package-json'));
Generator.prototype.user = require('./actions/user');

module.exports = Generator;