text: 'use strict';
  const ora = require('ora');
  const omit = require('lodash/omit');
  const chalk = require('chalk');
  const execa = require('execa');
  const isNil = require('lodash/isNil');
  const Listr = require('listr');
  const Table = require('cli-table3');
  const Promise = require('bluebird');
  const inquirer = require('inquirer');
  const isObject = require('lodash/isObject');
  const stripAnsi = require('strip-ansi');
  const ListrError = require('listr/lib/listr-error');
  const logSymbols = require('log-symbols');
  const isFunction = require('lodash/isFunction');
  
  const errors = require('../errors');
  const createRendererClass = require('./renderer');
  
  const defaultOptions = {
      stdin: process.stdin,
      stdout: process.stdout,
      stderr: process.stderr,
      verbose: false,
      allowPrompt: true,
      auto: false
  };
  
  function hasDefault(prompt = {}) {
      return isObject(prompt) && !isNil(prompt.default);
  }
  
  function getDefault(prompt = {}) {
      if (prompt.choices && prompt.choices.length) {
          const defaultChoice = prompt.choices[prompt.default];
          return isObject(defaultChoice) ? defaultChoice.value : defaultChoice;
      }
  
      return prompt.default;
  }
  
  /**
   * UI class. Handles all interaction with the user via the terminal
   *
   * @class UI
   */
  class UI {
      /**
       * Creates the UI instance
       * @param {Object} options
       */
      constructor(options) {
          this.options = Object.assign({}, defaultOptions, options);
  
          // Set up i/o streams
          this.stdin = this.options.stdin;
          this.stdout = this.options.stdout;
          this.stderr = this.options.stderr;
          this.verbose = this.options.verbose;
          this.allowPrompt = this.options.allowPrompt && Boolean(this.stdout.isTTY);
          this.auto = this.options.auto;
  
          // Add custom prompt module that uses the
          // specified streams
          this.inquirer = inquirer.createPromptModule({
              input: this.stdin,
              output: this.stdout
          });
      }
  
      get Renderer() {
          // istanbul ignore else
          if (!this._RendererClass) {
              this._RendererClass = createRendererClass(this);
          }
  
          return this._RendererClass;
      }
  
      /**
       * Runs a spinner while a promise is pending
       *
       * @param {Promise|Function} promiseOrFunction A promise or a function to run with a spinner
       * @param {string} name Text to output alongside the spinner
       * @param {Object} options Options to override the ora spinner
       * @return Promise<any>
       *
       * @method run
       * @public
       */
      run(promiseOrFunction, name, options = {}) {
          const {
              text = name,
              spinner = 'hamburger',
              stream = this.stdout,
              clear = false,
              quiet = false
          } = options;
  
          const fn = () => Promise.resolve(isFunction(promiseOrFunction) ? promiseOrFunction() : promiseOrFunction);
  
          // The --quiet flag will skip outputting anything to the UI.
          if (quiet) {
              return fn();
          }
  
          this.spinner = ora({text, spinner, stream}).start();
  
          return fn().then((result) => {
              this.spinner[clear ? 'stop' : 'succeed']();
              return Promise.resolve(result);
          }).catch((error) => {
              this.spinner.fail();
              return Promise.reject(error);
          }).finally(() => {
              this.spinner = null;
          });
      }
  
      /**
       * Outputs a table
       *
       * @param {Array} head Column titles
       * @param {Array} body Array of table rows
       * @param {Object} options Options to pass to cli-table3
       *
       * @method table
       * @public
       */
      table(head, body, options) {
          const table = new Table(Object.assign({head: head}, options || {}));
  
          table.push.apply(table, body);
          this.log(table.toString());
      }
  
      /**
       * Prompts a user for input. Uses inquirer to handle prompts
       *
       * @param {Array|Object} prompts Prompts to run
       * @return Promise<Object> Promise with results of prompts
       *
       * @method prompt
       * @public
       */
      prompt(prompts) {
          if (!this.allowPrompt) {
              throw new errors.SystemError('Prompts have been disabled, all options must be provided via command line flags');
          }
  
          if (this.auto) {
              if (Array.isArray(prompts)) {
                  const defaultedPrompts = prompts.filter(hasDefault).reduce(
                      (obj, prompt) => Object.assign({}, obj, {[prompt.name]: getDefault(prompt)}),
                      {}
                  );
                  const promptsToAsk = prompts.filter(prompt => !hasDefault(prompt));
  
                  if (!promptsToAsk.length) {
                      return Promise.resolve(defaultedPrompts);
                  }
  
                  return this.noSpin(() => this.inquirer(promptsToAsk))
                      .then(answers => Object.assign(answers, defaultedPrompts));
              }
  
              /* istanbul ignore else */
              if (hasDefault(prompts)) {
                  return Promise.resolve({[prompts.name]: prompts.default});
              }
          }
  
          return this.noSpin(() => this.inquirer(prompts));
      }
  
      /**
       * Shorthand helper to ask a simple yes/no question
       *
       * @param {string} question Question to ask
       * @param {bool} defaultAnswer Default answer (true or false)
       *
       * @method confirm
       * @public
       */
      confirm(question, defaultAnswer, options = {}) {
          if (!this.allowPrompt || this.auto) {
              return Promise.resolve(defaultAnswer);
          }
  
          return this.prompt({
              type: 'confirm',
              name: 'yes',
              message: question,
              default: defaultAnswer,
              prefix: options.prefix
          }).then(answer => answer.yes);
      }
  
      /**
       * Runs a set of Listr tasks. Uses Listr under the hood
       *
       * @param {Array} tasks Listr Task objects
       * @param {Object|bool} context Context to run the tasks with
       * @param {Object} options Additional Listr options
       * @return Listr|Promise<void> Listr object if context === false, otherwise
       *                             a promise that resolves once the tasks have
       *                             completed
       *
       * @method listr
       * @public
       */
      listr(tasks, context, options) {
          const listrOpts = Object.assign({
              renderer: this.verbose ? 'verbose' : this.Renderer,
              exitOnError: true
          }, options || {});
  
          const listr = new Listr(tasks, listrOpts);
          return context === false ? listr : listr.run(Object.assign(context || {}, {ui: this, listr: listr}));
      }
  
      /**
       * Runs a sudo command on the system. Outputs the command
       * that it's running.
       *
       * Code inspired by https://github.com/calmh/node-sudo/blob/master/lib/sudo.js
       *
       * @param {string} command Command to run
       * @param {Object} options Options to pass to execa
       * @return Promise<Object> Result of execa command
       *
       * @method sudo
       * @public
       */
      sudo(command, options) {
          options = options || {};
          this.log(`+ sudo ${command}`, 'gray');
  
          const prompt = '#node-sudo-passwd#';
          const cmd = command.replace(/^ghost/, process.argv.slice(0, 2).join(' '));
          const cp = execa.shell(`sudo -S -p '${prompt}' ${options.sudoArgs || ''} ${cmd}`, omit(options, ['sudoArgs']));
  
          cp.stderr.on('data', (data) => {
              const lines = data.toString('utf8').split('\n');
  
              /* istanbul ignore else */
              // If sudo has prompted for a password, then it will be the last line output
              if (lines[lines.length - 1] === prompt) {
                  this.prompt({
                      type: 'password',
                      name: 'password',
                      message: 'Sudo Password'
                  }).then((answers) => {
                      cp.stdin.write(`${answers.password}\n`);
                  });
              }
          });
  
          return cp;
      }
  
      /**
       * Ensure that no spinner runs whilst a command is executing. Useful for
       * prompts, or commands that require prompts
       *
       * @param {Promise|Function} promiseOrFunc
       * @return Promise
       *
       * @method noSpin
       * @public
       */
      noSpin(promiseOrFunc) {
          if (this.spinner) {
              this.spinner.stop();
              this.spinner.paused = true;
          }
  
          return Promise.resolve(isFunction(promiseOrFunc) ? promiseOrFunc() : promiseOrFunc).then((result) => {
              if (this.spinner) {
                  this.spinner.start();
                  this.spinner.paused = false;
              }
  
              return result;
          });
      }
  
      /**
       * Like console.log, but with color and can output to stderr. Also
       * ensures no spinner covers up the message
       *
       * @param {string} message Message to log
       * @param {string} color Color to log message in
       * @param {bool} stderr If true, output to stderr rather than stdout
       *
       * @method log
       * @public
       */
      log(message, color, stderr) {
          if (arguments.length > 3) {
              this._logHelp.apply(this, arguments);
              return;
          }
  
          if (color) {
              message = chalk[color](message);
          }
  
          if (this.spinner) {
              this.spinner.stop();
              this.spinner.paused = true;
          }
  
          const stream = stderr ? 'stderr' : 'stdout';
          this[stream].write(`${message}\n`);
  
          if (this.spinner) {
              this.spinner.start();
              this.spinner.paused = false;
          }
      }
  
      /**
       * Same options as log, but only logs when `verbose` is set to true
       *
       * @param {string} message
       * @param {string} color
       * @param {bool} stderr
       */
      logVerbose(message, color, stderr) {
          if (this.verbose) {
              return this.log(message, color, stderr);
          }
      }
  
      /**
       * Shorthand helper to output a help message, which displays a hint indented on the next line
       *
       * Don't call me directly, call ui.log with 4 args.
       *
       * @param {string} message Main message to output
       * @param {string} hint Hint line to output e.g. a command to fix things
       * @param {string} msgColor Main color of the message
       * @param {string} hintType One of link or cmd
       * @param {boolean} solo Is this message output as the last thing?
       */
      _logHelp(message, hint, msgColor, hintType, last) {
          let hintColor = hintType === 'link' ? 'cyan' : 'yellow';
          this.log(`\n${message}: \n\n    ${chalk[hintColor](hint)}${last ? '\n' : ''}`, msgColor);
      }
  
      /**
       * Shorthand helper to output a green message
       *
       * @param {string} message Message to output
       *
       * @method success
       * @public
       */
      success(message) {
          return this.log(`${logSymbols.success} ${message}`);
      }
  
      /**
       * Shorthand helper method to output a red message
       *
       * @param {string} message Message to output
       *
       * @method fail
       * @public
       */
      fail(message) {
          return this.log(`${logSymbols.error} ${message}`);
      }
  
      /**
       * Error handler for the CLI. Takes a given error and outputs a formated and more human-readable
       * error to the specified out stream.
       *
       * @param {Error|Object} error Error to handle
       * @param {System} System object
       *
       * @method error
       * @public
       */
      error(error, system) {
          const debugInfo = this._formatDebug(system);
  
          if (error instanceof errors.CliError) {
              if (error.logMessageOnly) {
                  return this.fail(error.message);
              }
  
              // Error is one that is generated by CLI usage (as in, the CLI itself
              // manually generates this error)
              this.log(`A ${error.type} occurred.\n`, 'red');
  
              // We always want to output the verbose error to the logfile, so we go ahead and get it now
              const verboseOutput = error.toString(true);
  
              // Log the verbose error if verbose is set, otherwise log the non-verbose error output
              this.log(this.verbose ? verboseOutput : error.toString(false), null, true);
              this.log(debugInfo, 'yellow');
  
              if (error.logToFile()) {
                  const logLocation = system.writeErrorLog(stripAnsi(`${debugInfo}\n${verboseOutput}`));
                  this.log(`\nAdditional log info available in: ${logLocation}`);
              }
  
              this.log(`\nTry running ${(chalk.cyan('ghost doctor'))} to check your system for known issues.`);
  
              this.log('\n' + error.help, 'blue');
          } else if (error instanceof ListrError) {
              // Listr errors have an array of errors appended to them
              this.log('One or more errors occurred.', 'red');
  
              const logOutput = [];
  
              error.errors.forEach((err, index) => {
                  const verboseOutput = err.toString(true);
  
                  this.log(`\n${index + 1}) ${err.options && err.options.task ? err.options.task : err.type}\n`, 'red', true);
                  this.log(this.verbose ? verboseOutput : err.toString(false), null, true);
  
                  if (err.logToFile && err.logToFile()) {
                      logOutput.push(stripAnsi(verboseOutput));
                  }
              });
  
              this.log(debugInfo, 'yellow');
  
              if (logOutput.length) {
                  const logLocation = system.writeErrorLog(stripAnsi(`${debugInfo}\n${logOutput.join('\n\n')}`));
                  this.log(`\nAdditional log info available in: ${logLocation}`);
              }
  
              this.log(`\nTry running ${(chalk.cyan('ghost doctor'))} to check your system for known issues.`);
              this.log('\nYou can always refer to https://ghost.org/docs/api/ghost-cli/ for troubleshooting.', 'blue');
          } else if (error instanceof Error) {
              // System errors or regular old errors go here.
              let output = `An error occurred.\n${chalk.yellow('Message:')} '${error.message}'\n\n`;
  
              if (!this.verbose) {
                  this.log(output, 'red', true);
              }
  
              if (error.stack) {
                  output += `${chalk.yellow('Stack:')} ${error.stack}\n`;
              }
  
              if (error.code) {
                  output += `${chalk.yellow('Code:')} ${error.code}\n`;
              }
  
              if (error.path) {
                  output += `${chalk.yellow('Path:')} ${error.path}\n`;
              }
  
              if (this.verbose) {
                  this.log(output, 'red', true);
              }
  
              this.log(debugInfo, 'yellow');
              const logLocation = system.writeErrorLog(stripAnsi(`${debugInfo}\n${output}`));
              this.log(`\nAdditional log info available in: ${logLocation}`);
              this.log(`\nTry running ${(chalk.cyan('ghost doctor'))} to check your system for known issues.`);
              this.log('\nYou can always refer to https://ghost.org/docs/api/ghost-cli/ for troubleshooting.', 'blue');
          } else if (isObject(error)) {
              // TODO: find better way to handle object errors?
              this.log(JSON.stringify(error), null, true);
              this.log(`\nTry running ${(chalk.cyan('ghost doctor'))} to check your system for known issues.`);
          } else if (error !== false) {
              // If the error is false, we're just exiting (makes the promise chains easier)
              this.log('An unknown error occured.', null, true);
              this.log(`\nTry running ${(chalk.cyan('ghost doctor'))} to check your system for known issues.`);
          }
      }
  
      /**
       * Helper method to format the debug information whenever an error occurs
       *
       * @param {System} system System instance
       * @return string Formated debug info
       *
       * @method _formatString
       * @private
       */
      _formatDebug(system) {
          const {version} = system.getInstance();
          const ghostVersion = version ? `    Ghost Version: ${version}\n` : '';
  
          return 'Debug Information:\n' +
              `    OS: ${system.operatingSystem.distro}, v${system.operatingSystem.release}\n` +
              `    Node Version: ${process.version}\n` +
              ghostVersion +
              `    Ghost-CLI Version: ${system.cliVersion}\n` +
              `    Environment: ${system.environment}\n` +
              `    Command: 'ghost ${process.argv.slice(2).join(' ')}'`;
      }
  }
  
  module.exports = UI;
  