var util = require('util');
var path = require('path');
var EE = require('events').EventEmitter;

var extend = require('extend');
var resolve = require('resolve');
var flaggedRespawn = require('flagged-respawn');
var isPlainObject = require('is-plain-object').isPlainObject;
var fined = require('fined');

var findCwd = require('./lib/find_cwd');
var arrayFind = require('./lib/array_find');
var findConfig = require('./lib/find_config');
var fileSearch = require('./lib/file_search');
var needsLookup = require('./lib/needs_lookup');
var parseOptions = require('./lib/parse_options');
var silentRequire = require('./lib/silent_require');
var buildConfigName = require('./lib/build_config_name');
var registerLoader = require('./lib/register_loader');
var getNodeFlags = require('./lib/get_node_flags');

function isString(val) {
  return typeof val === 'string';
}

function Liftoff(opts) {
  EE.call(this);
  extend(this, parseOptions(opts));
}
util.inherits(Liftoff, EE);

Liftoff.prototype.requireLocal = function (moduleName, basedir) {
  try {
    this.emit('preload:before', moduleName);
    var result = require(resolve.sync(moduleName, { basedir: basedir }));
    this.emit('preload:success', moduleName, result);
    return result;
  } catch (e) {
    this.emit('preload:failure', moduleName, e);
  }
};

Liftoff.prototype.buildEnvironment = function (opts) {
  opts = opts || {};

  // get modules we want to preload
  var preload = opts.preload || [];

  // ensure items to preload is an array
  if (!Array.isArray(preload)) {
    preload = [preload];
  }

  // make a copy of search paths that can be mutated for this run
  var searchPaths = this.searchPaths.slice();
  // store the instance configName to use in closures without access to `this`
  var configName = this.configName;

  // calculate current cwd
  var cwd = findCwd(opts);

  var exts = this.extensions;
  var eventEmitter = this;

  function findAndRegisterLoader(pathObj, defaultObj) {
    var found = fined(pathObj, defaultObj);
    if (!found) {
      return null;
    }
    if (isPlainObject(found.extension)) {
      registerLoader(eventEmitter, found.extension, found.path, cwd);
    }
    return found.path;
  }

  function getModulePath(cwd, xtends) {
    // If relative, we need to use fined to look up the file. If not, assume a node_module
    if (needsLookup(xtends)) {
      var defaultObj = { cwd: cwd, extensions: exts };
      // Using `xtends` like this should allow people to use a string or any object that fined accepts
      var foundPath = findAndRegisterLoader(xtends, defaultObj);
      if (!foundPath) {
        var name;
        if (typeof xtends === 'string') {
          name = xtends;
        } else {
          name = xtends.path || xtends.name;
        }
        var msg = 'Unable to locate one of your extends.';
        if (name) {
          msg += ' Looking for file: ' + path.resolve(cwd, name);
        }
        throw new Error(msg);
      }
      return foundPath;
    }

    return xtends;
  }

  var visited = {};
  function loadConfig(cwd, xtends, preferred) {
    var configFilePath = getModulePath(cwd, xtends);

    if (visited[configFilePath]) {
      throw new Error(
        'We encountered a circular extend for file: ' +
          configFilePath +
          '. Please remove the recursive extends.'
      );
    }
    var configFile;
    try {
      configFile = require(configFilePath);
    } catch (e) {
      // TODO: Consider surfacing the `require` error
      throw new Error(
        'Encountered error when loading config file: ' + configFilePath
      );
    }

    // resolve something like `{ gulpfile: "./abc.xyz" }` to the absolute path
    // based on the path of the configFile
    if (Object.prototype.hasOwnProperty.call(configFile, configName)) {
      if (isString(configFile[configName])) {
        configFile[configName] = path.resolve(path.dirname(configFilePath), configFile[configName]);
      }
    }

    visited[configFilePath] = true;
    if (configFile && configFile.extends) {
      var nextCwd = path.dirname(configFilePath);
      return loadConfig(nextCwd, configFile.extends, configFile);
    }
    // Always extend into an empty object so we can call `delete` on `config.extends`
    var config = extend(true /* deep */, {}, configFile, preferred);
    delete config.extends;
    return config;
  }

  var configFiles = [];
  if (Array.isArray(this.configFiles)) {
    configFiles = this.configFiles.map(function (pathObj) {
      var defaultObj = { cwd: cwd, extensions: exts };

      return findAndRegisterLoader(pathObj, defaultObj);
    });
  }

  var config = configFiles.map(function (startingLocation) {
    var defaultConfig = {};
    if (!startingLocation) {
      return defaultConfig;
    }

    return loadConfig(cwd, startingLocation, defaultConfig);
  });

  var configPathOverride = arrayFind(config, function (cfg) {
    if (Object.prototype.hasOwnProperty.call(cfg, configName)) {
      if (isString(cfg[configName])) {
        return cfg[configName];
      }
    }
  });

  var additionPreloads = arrayFind(config, function (cfg) {
    if (Object.prototype.hasOwnProperty.call(cfg, 'preload')) {
      if (Array.isArray(cfg.preload)) {
        if (cfg.preload.every(isString)) {
          return cfg.preload;
        }
      }
      if (isString(cfg.preload)) {
        return cfg.preload;
      }
    }
  });

  // if cwd was provided explicitly, only use it for searching config
  if (opts.cwd) {
    searchPaths = [cwd];
  } else {
    // otherwise just search in cwd first
    searchPaths.unshift(cwd);
  }

  // calculate the regex to use for finding the config file
  var configNameSearch = buildConfigName({
    configName: configName,
    extensions: Object.keys(this.extensions),
  });

  // calculate configPath
  var configPath = findConfig({
    configNameSearch: configNameSearch,
    searchPaths: searchPaths,
    configPath: opts.configPath || configPathOverride,
  });

  // if we have a config path, save the directory it resides in.
  var configBase;
  if (configPath) {
    configBase = path.dirname(configPath);
    // if cwd wasn't provided explicitly, it should match configBase
    if (!opts.cwd) {
      cwd = configBase;
    }
  }

  // TODO: break this out into lib/
  // locate local module and package next to config or explicitly provided cwd
  var modulePath;
  var modulePackage;
  try {
    var delim = path.delimiter;
    var paths = process.env.NODE_PATH ? process.env.NODE_PATH.split(delim) : [];
    modulePath = resolve.sync(this.moduleName, {
      basedir: configBase || cwd,
      paths: paths,
    });
    modulePackage = silentRequire(fileSearch('package.json', [modulePath]));
  } catch (e) {}

  // if we have a configuration but we failed to find a local module, maybe
  // we are developing against ourselves?
  if (!modulePath && configPath) {
    // check the package.json sibling to our config to see if its `name`
    // matches the module we're looking for
    var modulePackagePath = fileSearch('package.json', [configBase]);
    modulePackage = silentRequire(modulePackagePath);
    if (modulePackage && modulePackage.name === this.moduleName) {
      // if it does, our module path is `main` inside package.json
      modulePath = path.join(
        path.dirname(modulePackagePath),
        modulePackage.main || 'index.js'
      );
      cwd = configBase;
    } else {
      // clear if we just required a package for some other project
      modulePackage = {};
    }
  }

  return {
    cwd: cwd,
    preload: preload.concat(additionPreloads || []),
    completion: opts.completion,
    configNameSearch: configNameSearch,
    configPath: configPath,
    configBase: configBase,
    modulePath: modulePath,
    modulePackage: modulePackage || {},
    configFiles: configFiles,
    config: config,
  };
};

Liftoff.prototype.handleFlags = function (cb) {
  if (typeof this.v8flags === 'function') {
    this.v8flags(function (err, flags) {
      if (err) {
        cb(err);
      } else {
        cb(null, flags);
      }
    });
  } else {
    process.nextTick(
      function () {
        cb(null, this.v8flags);
      }.bind(this)
    );
  }
};

Liftoff.prototype.prepare = function (opts, fn) {
  if (typeof fn !== 'function') {
    throw new Error('You must provide a callback function.');
  }

  process.title = this.processTitle;

  var env = this.buildEnvironment(opts);

  fn.call(this, env);
};

Liftoff.prototype.execute = function (env, forcedFlags, fn) {
  var completion = env.completion;
  if (completion && this.completions) {
    return this.completions(completion);
  }

  if (typeof forcedFlags === 'function') {
    fn = forcedFlags;
    forcedFlags = undefined;
  }
  if (typeof fn !== 'function') {
    throw new Error('You must provide a callback function.');
  }

  this.handleFlags(
    function (err, flags) {
      if (err) {
        throw err;
      }
      flags = flags || [];

      flaggedRespawn(flags, process.argv, forcedFlags, execute.bind(this));

      function execute(ready, child, argv) {
        if (child !== process) {
          var execArgv = getNodeFlags.fromReorderedArgv(argv);
          this.emit('respawn', execArgv, child);
        }
        if (ready) {
          preloadModules(this, env);
          registerLoader(this, this.extensions, env.configPath, env.cwd);
          fn.call(this, env, argv);
        }
      }
    }.bind(this)
  );
};

function preloadModules(inst, env) {
  var basedir = env.cwd;
  env.preload.filter(toUnique).forEach(function (module) {
    inst.requireLocal(module, basedir);
  });
}

function toUnique(elem, index, array) {
  return array.indexOf(elem) === index;
}

module.exports = Liftoff;
