const fs = require('fs');
const path = require('path');
const resolve = require('resolve');
const debug = require('debug')('talk:plugins');
const Joi = require('joi');
const amp = require('app-module-path');

const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON;

// Add the current path to the module root.
amp.addPath(__dirname);

let pluginsPath;
let plugins = {};

// Try to parse the plugins.json file, logging out an error if the plugins.json
// file isn't loaded, but continuing. Else, like a parsing error, throw it and
// crash the program.
try {
  let envPlugins = path.join(__dirname, 'plugins.env.js');
  let customPlugins = path.join(__dirname, 'plugins.json');
  let defaultPlugins = path.join(__dirname, 'plugins.default.json');

  if (PLUGINS_JSON && PLUGINS_JSON.length > 0) {
    debug('Now using TALK_PLUGINS_JSON environment variable for plugins');
    pluginsPath = envPlugins;
  } else if (fs.existsSync(customPlugins)) {
    debug(`Now using ${customPlugins} for plugins`);
    pluginsPath = customPlugins;
  } else {
    debug(`Now using ${defaultPlugins} for plugins`);
    pluginsPath = defaultPlugins;
  }

  plugins = require(pluginsPath);
} catch (err) {
  if (err.code === 'ENOENT') {
    console.error('plugins.json and plugins.default.json not found, plugins will not be active');
  } else {
    throw err;
  }
}

/**
 * All the hooks from plugins must match the schema defined here.
 */
const hookSchemas = {
  passport: Joi.func().arity(1),
  router: Joi.func().arity(1),
  context: Joi.object().pattern(/\w/, Joi.func().maxArity(1)),
  hooks: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.object({
    pre: Joi.func(),
    post: Joi.func()
  }))),
  loaders: Joi.func().maxArity(1),
  mutators: Joi.func().maxArity(1),
  resolvers: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.func())),
  typeDefs: Joi.string()
};

/**
 * isInternal checks to see if a given plugin is internal, and returns true
 * if it is.
 *
 * @param {String} name
 * @returns {Boolean}
 */
function isInternal(name) {
  const internalPluginPath = path.join(__dirname, 'plugins', name);

  // Check to see if this plugin exists internally, because if it doesn't, it is
  // external.
  return fs.existsSync(internalPluginPath);
}

/**
 * Returns the plugin path for the given plugin name.
 *
 * @param {any} name
 * @returns
 */
function pluginPath(name) {
  if (isInternal(name)) {
    try {
      return resolve.sync(name, {moduleDirectory: 'plugins', basedir: process.cwd()});
    } catch (e) {
      console.warn(e);
      return undefined;
    }
  }

  try {
    return resolve.sync(name, {basedir: process.cwd()});
  } catch (e) {
    return undefined;
  }
}

class Plugin {
  constructor(entry) {

    // This checks to see if the structure for this entry is an object:
    //
    // {"people": "^1.2.0"}
    //
    // otherwise it's checked whether it matches the local version:
    //
    // "people"
    //
    if (typeof entry === 'object') {
      this.name = Object.keys(entry).find((name) => name !== null);
      this.version = entry[this.name];
    } else if (typeof entry === 'string') {
      this.name = entry;
      this.version = `file:./plugins/${this.name}`;
    } else {
      throw new Error(`plugins.json is malformed, refer to PLUGINS.md for formatting, expected a string or an object for a plugin entry, found a ${typeof entry}`);
    }

    // Get the path for the plugin.
    this.path = pluginPath(this.name);
  }

  require() {
    if (typeof this.path === 'undefined') {
      throw new Error(`plugin '${this.name}' is not local and is not resolvable, plugin reconsiliation may be required`);
    }

    try {
      this.module = require(this.path);
    } catch (e) {
      if (e && e.code && e.code === 'MODULE_NOT_FOUND' && isInternal(this.name)) {
        console.error(new Error(`plugin '${this.name}' could not be loaded due to missing dependencies, plugin reconsiliation may be required`));
        throw e;
      }

      console.error(new Error(`plugin '${this.name}' could not be required from '${this.path}': ${e.message}`));
      throw e;
    }
  }
}

/**
 * Itterates over the plugins and gets the plugin path's, version, and name.
 *
 * @param {Array<Object|String>} plugins
 * @returns {Array<Object>}
 */
const itteratePlugins = (plugins) => plugins.map((p) => new Plugin(p));

// Add each plugin folder to the allowed import path so that they can import our
// internal dependancies.
Object.keys(plugins).forEach((type) => itteratePlugins(plugins[type]).forEach((plugin) => {

  // The plugin may be remote, and therefore not installed. We check here if the
  // plugin path is available before trying to monkeypatch it's require path.
  if (plugin.path) {
    amp.enableForDir(path.dirname(plugin.path));
  }
}));

/**
 * Stores a reference to a section for a section of Plugins.
 */
class PluginSection {
  constructor(plugins) {
    this.required = false;
    this.plugins = itteratePlugins(plugins);
  }

  require() {
    if (this.required) {
      return;
    }
    
    this.required = true;
    this.plugins.forEach((plugin) => {

      // Load the plugin.
      plugin.require();

      if (isInternal(plugin.name)) {
        debug(`loading internal plugin '${plugin.name}' from '${plugin.path}'`);
      } else {
        debug(`loading external plugin '${plugin.name}' from '${plugin.path}'`);
      }

      return plugin;
    });
  }

  /**
   * This itterates over the section to provide all plugin hooks that are
   * available.
   */
  hook(hook) {

    // Load the plugin source if we haven't already.
    this.require();

    return this.plugins
      .filter(({module}) => hook in module)
      .filter((plugin) => {

        // Validate the hook.
        if (hook in hookSchemas) {
          Joi.assert(plugin.module[hook], hookSchemas[hook], `Plugin '${plugin.name}' failed validation for the '${hook}' hook`);
        }

        return true;
      })
      .map((plugin) => ({
        plugin,
        [hook]: plugin.module[hook]
      }));
  }
}

const NullPluginSection = new PluginSection([]);

/**
 * Stores references to all the plugins available on the application.
 */
class PluginManager {
  constructor(plugins) {
    this.sections = {};

    for (let section in plugins) {
      this.sections[section] = new PluginSection(plugins[section]);
    }
  }

  /**
   * Utility function which combines the Plugins.section and PluginSection.hook
   * calls.
   */
  get(section, hook) {
    return this.section(section).hook(hook);
  }

  /**
   * Returns the named section if it exists, otherwise it returns an empty
   * plugin section.
   */
  section(section) {
    if (section in this.sections) {
      return this.sections[section];
    }

    return NullPluginSection;
  }
}

module.exports = {
  plugins,
  pluginsPath,
  PluginManager,
  isInternal,
  pluginPath,
  itteratePlugins
};
