// Copyright IBM Corp. 2014,2016. All Rights Reserved.
// Node module: loopback-boot
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

var _ = require('lodash');
var assert = require('assert');
var async = require('async');
var utils = require('./utils');
var path = require('path');
var pluginLoader = require('./plugin-loader');
var debug = require('debug')('loopback:boot:bootstrapper');
var Promise = require('bluebird');
var arrayToObject = require('./utils').arrayToObject;

module.exports = Bootstrapper;

function createPromiseCallback() {
  var cb;
  var promise = new Promise(function(resolve, reject) {
    cb = function(err, data) {
      if (err) return reject(err);
      return resolve(data);
    };
  });
  cb.promise = promise;
  return cb;
}

var builtinPlugins = [
  'application', 'datasource', 'model', 'mixin',
  'middleware', 'component', 'boot-script', 'swagger',
];

var builtinPhases = [
  'load', 'compile', 'starting', 'start', 'started',
];

function loadAndRegisterPlugins(bootstrapper, options) {
  var loader = pluginLoader(options);
  var loaderContext = {};
  loader.load(loaderContext);
  loader.compile(loaderContext);

  for (var i in loaderContext.instructions.pluginScripts) {
    bootstrapper.use('/boot/' + i, loaderContext.instructions.pluginScripts[i]);
  }
}

/**
 * Create a new Bootstrapper with options
 * @param options
 * @constructor
 */
function Bootstrapper(options) {
  this.plugins = [];
  options = options || {};

  if (typeof options === 'string') {
    options = { appRootDir: options };
  }

  // For setting properties without modifying the original object
  options = Object.create(options);

  var appRootDir = options.appRootDir = options.appRootDir || process.cwd();
  var env = options.env || process.env.NODE_ENV || 'development';
  var scriptExtensions = options.scriptExtensions ?
    arrayToObject(options.scriptExtensions) :
    require.extensions;

  var appConfigRootDir = options.appConfigRootDir || appRootDir;

  options.rootDir = appConfigRootDir;
  options.env = env;
  options.scriptExtensions = scriptExtensions;
  this.options = options;

  this.phases = options.phases || builtinPhases;
  this.builtinPlugins = options.plugins || builtinPlugins;
  assert(Array.isArray(this.phases), 'Invalid phases: ' + this.phases);
  assert(Array.isArray(this.plugins), 'Invalid plugins: ' +
    this.builtinPlugins);

  var self = this;
  self.builtinPlugins.forEach(function(p) {
    var factory = require('./plugins/' + p);
    self.use('/boot/' + p, factory(options));
  });

  try {
    loadAndRegisterPlugins(self, options);
  } catch (err) {
    debug('Cannot load & register plugins: %s', err.stack || err);
  }
}

/**
 * Register a handler to a given path
 * @param {String} path
 * @param {Function} handler
 */
Bootstrapper.prototype.use = function(path, handler) {
  var plugin = {
    path: path,
    handler: handler,
  };
  this.plugins.push(plugin);
};

/**
 * Get a list of plugins for the given path
 * @param {String} path
 * @returns {*}
 */
Bootstrapper.prototype.getPlugins = function(path) {
  if (path[path.length - 1] !== '/') {
    path = path + '/';
  }
  return this.plugins.filter(function(p) {
    return p.path.indexOf(path) === 0;
  });
};

/**
 * Get a list of extensions for the given path
 * @param {String} path
 * @returns {*}
 */
Bootstrapper.prototype.getExtensions = function(path) {
  if (path[path.length - 1] !== '/') {
    path = path + '/';
  }
  return this.plugins.filter(function(p) {
    if (p.path.indexOf(path) === -1) return false;
    var name = p.path.substring(path.length);
    return name && name.indexOf('/') === -1;
  });
};

/**
 * Add more phases. The order of phases is decided by the sequence of phase
 * names
 * @param {String[]} phases An array of phase names
 * @returns {String[]} New list of phases
 */
Bootstrapper.prototype.addPhases = function(phases) {
  this.phases = utils.mergePhaseNameLists(this.phases, phases || []);
  return this.phases;
};

function pluginIteratorFactory(context, phase) {
  return function executePluginPhase(plugin, done) {
    var result;
    if (typeof plugin.handler[phase] !== 'function') {
      debug('Skipping %s.%s', plugin.handler.name, phase);
      return done();
    }
    debug('Invoking %s.%s', plugin.handler.name, phase);
    try {
      if (plugin.handler[phase].length === 2) {
        plugin.handler[phase](context, done);
      } else {
        result = plugin.handler[phase](context);
        Promise.resolve(result)
          .then(function onPluginPhaseResolved(value) {
            done(null, value);
          }, function onPluginPhaseRejected(err) {
            debug('Unable to invoke %s.%s()', plugin.name, phase, err);
            done(err);
          });
      }
    } catch (err) {
      debug('Unable to invoke %s.%s()', plugin.name, phase, err);
      done(err);
    }
  };
}

/**
 * Invoke the plugins phase by phase with the given context
 * @param {Object} context Context object
 * @param {Function} done Callback function. If not provided, a promise will be
 * returned
 * @returns {*}
 */
Bootstrapper.prototype.run = function(context, done) {
  if (!done) {
    done = createPromiseCallback();
  }
  var options = this.options;
  var appRootDir = options.appRootDir = options.appRootDir || process.cwd();
  var env = options.env || process.env.NODE_ENV || 'development';

  var appConfigRootDir = options.appConfigRootDir || appRootDir;

  options.rootDir = appConfigRootDir;
  options.env = env;

  context = context || {};

  var phases = context.phases || this.phases;
  var bootPlugins = this.getExtensions('/boot');
  async.eachSeries(phases, function(phase, done) {
    debug('Phase %s', phase);
    async.eachSeries(bootPlugins, pluginIteratorFactory(context, phase), done);
  }, function(err) {
    return done(err, context);
  });
  return done.promise;
};