/*jshint node: true, esversion: 6 */
'use strict';

const fs = require('fs'),
    path = require('path'),
    Data = require('./Data'),
    Area = require('./Area'),
    Command = require('./Command'),
    CommandType = require('./CommandType'),
    Item = require('./Item'),
    Npc = require('./Npc'),
    QuestGoal = require('./QuestGoal'),
    QuestReward = require('./QuestReward'),
    Room = require('./Room'),
    Skill = require('./Skill'),
    SkillType = require('./SkillType'),
    Helpfile = require('./Helpfile'),
    Logger = require('./Logger')
;

const { AttributeFormula } = require('./Attribute');

const srcPath = __dirname + '/';
/**
 * Handles loading/parsing/initializing all bundles. AKA where the magic happens
 */
class BundleManager {
  /**
   * @param {GameState} state
   */
  constructor(path, state) {
    if (!path || !fs.existsSync(path)) {
      throw new Error('Invalid bundle path');
    }

    this.state = state;
    this.bundlesPath = path;
    this.areas = [];
    this.loaderRegistry = this.state.EntityLoaderRegistry;
  }

  /**
   * Load in all bundles
   */
  async loadBundles(distribute = true) {
    Logger.verbose('LOAD: BUNDLES');

    const bundles = fs.readdirSync(this.bundlesPath);
    for (const bundle of bundles) {
      const bundlePath = this.bundlesPath + bundle;
      if (fs.statSync(bundlePath).isFile() || bundle === '.' || bundle === '..') {
        continue;
      }

      // only load bundles the user has configured to be loaded
      if (this.state.Config.get('bundles', []).indexOf(bundle) === -1) {
        continue;
      }

      await this.loadBundle(bundle, bundlePath);
    }

    try {
      this.state.AttributeFactory.validateAttributes();
    } catch (err) {
      Logger.error(err.message);
      process.exit(0);
    }

    Logger.verbose('ENDLOAD: BUNDLES');

    if (!distribute) {
      return;
    }

    // Distribution is done after all areas are loaded in case items use areas from each other
    for (const areaRef of this.areas) {
      const area = this.state.AreaFactory.create(areaRef);
      try {
        area.hydrate(this.state);
      } catch (err) {
        Logger.error(err.message);
        process.exit(0);
      }
      this.state.AreaManager.addArea(area);
    }
  }

  /**
   * @param {string} bundle Bundle name
   * @param {string} bundlePath Path to bundle directory
   */
  async loadBundle(bundle, bundlePath) {
    const features = [
      // quest goals/rewards have to be loaded before areas that have quests which use those goals
      { path: 'quest-goals/', fn: 'loadQuestGoals' },
      { path: 'quest-rewards/', fn: 'loadQuestRewards' },

      { path: 'attributes.js', fn: 'loadAttributes' },

      // any entity in an area, including the area itself, can have behaviors so load them first
      { path: 'behaviors/', fn: 'loadBehaviors' },

      { path: 'channels.js', fn: 'loadChannels' },
      { path: 'commands/', fn: 'loadCommands' },
      { path: 'effects/', fn: 'loadEffects' },
      { path: 'input-events/', fn: 'loadInputEvents' },
      { path: 'server-events/', fn: 'loadServerEvents' },
      { path: 'player-events.js', fn: 'loadPlayerEvents' },
      { path: 'skills/', fn: 'loadSkills' },
    ];

    Logger.verbose(`LOAD: BUNDLE [\x1B[1;33m${bundle}\x1B[0m] START`);
    for (const feature of features) {
      const path = bundlePath + '/' + feature.path;
      if (fs.existsSync(path)) {
        this[feature.fn](bundle, path);
      }
    }

    await this.loadAreas(bundle);
    await this.loadHelp(bundle);

    Logger.verbose(`ENDLOAD: BUNDLE [\x1B[1;32m${bundle}\x1B[0m]`);
  }

  loadQuestGoals(bundle, goalsDir) {
    Logger.verbose(`\tLOAD: Quest Goals...`);
    const files = fs.readdirSync(goalsDir);

    for (const goalFile of files) {
      const goalPath = goalsDir + goalFile;
      if (!Data.isScriptFile(goalPath, goalFile)) {
        continue;
      }

      const goalName = path.basename(goalFile, path.extname(goalFile));
      const loader = require(goalPath);
      let goalImport = QuestGoal.isPrototypeOf(loader) ? loader : loader(srcPath);
      Logger.verbose(`\t\t${goalName}`);

      this.state.QuestGoalManager.set(goalName, goalImport);
    }

    Logger.verbose(`\tENDLOAD: Quest Goals...`);
  }

  loadQuestRewards(bundle, rewardsDir) {
    Logger.verbose(`\tLOAD: Quest Rewards...`);
    const files = fs.readdirSync(rewardsDir);

    for (const rewardFile of files) {
      const rewardPath = rewardsDir + rewardFile;
      if (!Data.isScriptFile(rewardPath, rewardFile)) {
        continue;
      }

      const rewardName = path.basename(rewardFile, path.extname(rewardFile));
      const loader = require(rewardPath);
      let rewardImport = QuestReward.isPrototypeOf(loader) ? loader : loader(srcPath);
      Logger.verbose(`\t\t${rewardName}`);

      this.state.QuestRewardManager.set(rewardName, rewardImport);
    }

    Logger.verbose(`\tENDLOAD: Quest Rewards...`);
  }

  /**
   * Load attribute definitions
   * @param {string} bundle
   * @param {string} attributesFile
   */
  loadAttributes(bundle, attributesFile) {
    Logger.verbose(`\tLOAD: Attributes...`);

    const attributes = require(attributesFile);
    let error = `\tAttributes file [${attributesFile}] from bundle [${bundle}]`;
    if (!Array.isArray(attributes)) {
      Logger.error(`${error} does not define an array of attributes`);
      return;
    }

    for (const attribute of attributes) {
      if (typeof attribute !== 'object') {
        Logger.error(`${error} not an object`);
        continue;
      }

      if (!('name' in attribute) || !('base' in attribute)) {
        Logger.error(`${error} does not include required properties name and base`);
        continue;
      }

      let formula = null;
      if (attribute.formula) {
        formula = new AttributeFormula(
          attribute.formula.requires,
          attribute.formula.fn,
        );
      }

      Logger.verbose(`\t\t-> ${attribute.name}`);

      this.state.AttributeFactory.add(attribute.name, attribute.base, formula, attribute.metadata);
    }

    Logger.verbose(`\tENDLOAD: Attributes...`);
  }

  /**
   * Load/initialize player. See the {@link http://ranviermud.com/extending/input_events/|Player Event guide}
   * @param {string} bundle
   * @param {string} eventsFile event js file to load
   */
  loadPlayerEvents(bundle, eventsFile) {
    Logger.verbose(`\tLOAD: Player Events...`);

    const loader = require(eventsFile);
    const playerListeners = this._getLoader(loader, srcPath).listeners;

    for (const [eventName, listener] of Object.entries(playerListeners)) {
      Logger.verbose(`\t\tEvent: ${eventName}`);
      this.state.PlayerManager.addListener(eventName, listener(this.state));
    }

    Logger.verbose(`\tENDLOAD: Player Events...`);
  }

  /**
  * @param {string} bundle
  */
  async loadAreas(bundle) {
    Logger.verbose(`\tLOAD: Areas...`);

    const areaLoader = this.loaderRegistry.get('areas');
    areaLoader.setBundle(bundle);
    let areas = [];

    if (!await areaLoader.hasData()) {
      return areas;
    }

    areas = await areaLoader.fetchAll();

    for (const name in areas) {
      const manifest = areas[name];
      this.areas.push(name);
      await this.loadArea(bundle, name, manifest);
    }

    Logger.verbose(`\tENDLOAD: Areas`);
  }

  /**
   * @param {string} bundle
   * @param {string} areaName
   * @param {string} areaPath
   */
  async loadArea(bundle, areaName, manifest) {
    const definition = {
      bundle,
      manifest,
      quests: [],
      items: [],
      npcs: [],
      rooms: [],
    };

    const scriptPath = this._getAreaScriptPath(bundle, areaName);

    if (manifest.script) {
      const areaScriptPath = `${scriptPath}/${manifest.script}.js`;
      if (!fs.existsSync(areaScriptPath)) {
        Logger.warn(`\t\t\t[${areaName}] has non-existent script "${manifest.script}"`);
      }

      Logger.verbose(`\t\t\tLoading Area Script for [${areaName}]: ${manifest.script}`);
      this.loadEntityScript(this.state.AreaFactory, areaName, areaScriptPath);
    }

    Logger.verbose(`\t\tLOAD: Quests...`);
    definition.quests = await this.loadQuests(bundle, areaName);
    Logger.verbose(`\t\tLOAD: Items...`);
    definition.items  = await this.loadEntities(bundle, areaName, 'items', this.state.ItemFactory);
    Logger.verbose(`\t\tLOAD: NPCs...`);
    definition.npcs   = await this.loadEntities(bundle, areaName, 'npcs', this.state.MobFactory);
    Logger.verbose(`\t\tLOAD: Rooms...`);
    definition.rooms  = await this.loadEntities(bundle, areaName, 'rooms', this.state.RoomFactory);
    Logger.verbose('\t\tDone.');

    for (const npcRef of definition.npcs) {
      const npc = this.state.MobFactory.getDefinition(npcRef);
      if (!npc.quests) {
        continue;
      }

      // Update quest definitions with their questor
      // TODO: This currently means a given quest can only have a single questor, perhaps not optimal
      for (const qid of npc.quests) {
        const quest = this.state.QuestFactory.get(qid);
        if (!quest) {
          Logger.error(`\t\t\tError: NPC is questor for non-existent quest [${qid}]`);
          continue;
        }
        quest.npc = npcRef;
        this.state.QuestFactory.set(qid, quest);
      }
    }

    this.state.AreaFactory.setDefinition(areaName, definition);
  }

  /**
   * Load an entity (item/npc/room) from file
   * @param {string} bundle
   * @param {string} areaName
   * @param {string} type
   * @param {EntityFactory} factory
   * @return {Array<entityReference>}
   */
  async loadEntities(bundle, areaName, type, factory) {
    const loader = this.loaderRegistry.get(type);
    loader.setBundle(bundle);
    loader.setArea(areaName);

    if (!await loader.hasData()) {
      return [];
    }

    const entities = await loader.fetchAll();
    if (!entities) {
      Logger.warn(`\t\t\t${type} has an invalid value [${entities}]`);
      return [];
    }

    const scriptPath = this._getAreaScriptPath(bundle, areaName);

    return entities.map(entity => {
      const entityRef = factory.createEntityRef(areaName, entity.id);
      factory.setDefinition(entityRef, entity);
      if (entity.script) {
        const entityScript = `${scriptPath}/${type}/${entity.script}.js`;
        if (!fs.existsSync(entityScript)) {
          Logger.warn(`\t\t\t[${entityRef}] has non-existent script "${entity.script}"`);
        } else {
          Logger.verbose(`\t\t\tLoading Script [${entityRef}] ${entity.script}`);
          this.loadEntityScript(factory, entityRef, entityScript);
        }
      }

      return entityRef;
    });
  }

  /**
   * @param {EntityFactory} factory Instance of EntityFactory that the item/npc will be loaded into
   * @param {EntityReference} entityRef
   * @param {string} scriptPath
   */
  loadEntityScript(factory, entityRef, scriptPath) {
    const loader = require(scriptPath);
    const scriptListeners = this._getLoader(loader, srcPath).listeners;

    for (const [eventName, listener] of Object.entries(scriptListeners)) {
      Logger.verbose(`\t\t\t\tEvent: ${eventName}`);
      factory.addScriptListener(entityRef, eventName, listener(this.state));
    }
  }

  /**
   * @param {string} areaName
   * @param {string} questsFile
   * @return {Promise<Array<entityReference>>}
   */
  async loadQuests(bundle, areaName) {
    const loader = this.loaderRegistry.get('quests');
    loader.setBundle(bundle);
    loader.setArea(areaName);
    let quests = [];
    try {
       quests = await loader.fetchAll();
    } catch (err) {}

    return quests.map(quest => {
      Logger.verbose(`\t\t\tLoading Quest [${areaName}:${quest.id}]`);
      this.state.QuestFactory.add(areaName, quest.id, quest);
      return this.state.QuestFactory.makeQuestKey(areaName, quest.id);
    });
  }

  /**
   * @param {string} bundle
   * @param {string} commandsDir
   */
  loadCommands(bundle, commandsDir) {
    Logger.verbose(`\tLOAD: Commands...`);
    const files = fs.readdirSync(commandsDir);

    for (const commandFile of files) {
      const commandPath = commandsDir + commandFile;
      if (!Data.isScriptFile(commandPath, commandFile)) {
        continue;
      }

      const commandName = path.basename(commandFile, path.extname(commandFile));
      const command = this.createCommand(commandPath, commandName, bundle);
      this.state.CommandManager.add(command);
    }

    Logger.verbose(`\tENDLOAD: Commands...`);
  }

  /**
   * @param {string} commandPath
   * @param {string} commandName
   * @param {string} bundle
   * @return {Command}
   */
  createCommand(commandPath, commandName, bundle) {
    const loader = require(commandPath);
    let cmdImport = this._getLoader(loader, srcPath, this.bundlesPath);
    cmdImport.command = cmdImport.command(this.state);


    return new Command(
      bundle,
      commandName,
      cmdImport,
      commandPath
    );
  }

  /**
   * @param {string} bundle
   * @param {string} channelsFile
   */
  loadChannels(bundle, channelsFile) {
    Logger.verbose(`\tLOAD: Channels...`);

    const loader = require(channelsFile);
    let channels = this._getLoader(loader, srcPath);

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

    channels.forEach(channel => {
      channel.bundle = bundle;
      this.state.ChannelManager.add(channel);
    });

    Logger.verbose(`\tENDLOAD: Channels...`);
  }

  /**
   * @param {string} bundle
   * @param {string} helpDir
   */
  async loadHelp(bundle) {
    Logger.verbose(`\tLOAD: Help...`);
    const loader = this.loaderRegistry.get('help');
    loader.setBundle(bundle);

    if (!await loader.hasData()) {
      return;
    }

    const records = await loader.fetchAll();
    for (const helpName in records) {
      try {
        const hfile = new Helpfile(
          bundle,
          helpName,
          records[helpName]
        );

        this.state.HelpManager.add(hfile);
      } catch (e) {
        Logger.warn(`\t\t${e.message}`);
        continue;
      }
    }

    Logger.verbose(`\tENDLOAD: Help...`);
  }

  /**
   * @param {string} bundle
   * @param {string} inputEventsDir
   */
  loadInputEvents(bundle, inputEventsDir) {
    Logger.verbose(`\tLOAD: Events...`);
    const files = fs.readdirSync(inputEventsDir);

    for (const eventFile of files) {
      const eventPath = inputEventsDir + eventFile;
      if (!Data.isScriptFile(eventPath, eventFile)) {
        continue;
      }

      const eventName = path.basename(eventFile, path.extname(eventFile));
      const loader = require(eventPath);
      const eventImport = this._getLoader(loader, srcPath);

      if (typeof eventImport.event !== 'function') {
        throw new Error(`Bundle ${bundle} has an invalid input event '${eventName}'. Expected a function, got: `, eventImport.event);
      }

      this.state.InputEventManager.add(eventName, eventImport.event(this.state));
    }

    Logger.verbose(`\tENDLOAD: Events...`);
  }

  /**
   * @param {string} bundle
   * @param {string} behaviorsDir
   */
  loadBehaviors(bundle, behaviorsDir) {
    Logger.verbose(`\tLOAD: Behaviors...`);

    const loadEntityBehaviors = (type, manager, state) => {
      let typeDir = behaviorsDir + type + '/';

      if (!fs.existsSync(typeDir)) {
        return;
      }

      Logger.verbose(`\t\tLOAD: BEHAVIORS [${type}]...`);
      const files = fs.readdirSync(typeDir);

      for (const behaviorFile of files) {
        const behaviorPath = typeDir + behaviorFile;
        if (!Data.isScriptFile(behaviorPath, behaviorFile)) {
          continue;
        }

        const behaviorName = path.basename(behaviorFile, path.extname(behaviorFile));
        Logger.verbose(`\t\t\tLOAD: BEHAVIORS [${type}] ${behaviorName}...`);
        const loader = require(behaviorPath);
        const behaviorListeners = this._getLoader(loader, srcPath).listeners;

        for (const [eventName, listener] of Object.entries(behaviorListeners)) {
          manager.addListener(behaviorName, eventName, listener(state));
        }
      }
    };

    loadEntityBehaviors('area', this.state.AreaBehaviorManager, this.state);
    loadEntityBehaviors('npc', this.state.MobBehaviorManager, this.state);
    loadEntityBehaviors('item', this.state.ItemBehaviorManager, this.state);
    loadEntityBehaviors('room', this.state.RoomBehaviorManager, this.state);

    Logger.verbose(`\tENDLOAD: Behaviors...`);
  }

  /**
   * @param {string} bundle
   * @param {string} effectsDir
   */
  loadEffects(bundle, effectsDir) {
    Logger.verbose(`\tLOAD: Effects...`);
    const files = fs.readdirSync(effectsDir);

    for (const effectFile of files) {
      const effectPath = effectsDir + effectFile;
      if (!Data.isScriptFile(effectPath, effectFile)) {
        continue;
      }

      const effectName = path.basename(effectFile, path.extname(effectFile));
      const loader = require(effectPath);

      Logger.verbose(`\t\t${effectName}`);
      this.state.EffectFactory.add(effectName, this._getLoader(loader, srcPath), this.state);
    }

    Logger.verbose(`\tENDLOAD: Effects...`);
  }

  /**
   * @param {string} bundle
   * @param {string} skillsDir
   */
  loadSkills(bundle, skillsDir) {
    Logger.verbose(`\tLOAD: Skills...`);
    const files = fs.readdirSync(skillsDir);

    for (const skillFile of files) {
      const skillPath = skillsDir + skillFile;
      if (!Data.isScriptFile(skillPath, skillFile)) {
        continue;
      }

      const skillName = path.basename(skillFile, path.extname(skillFile));
      const loader = require(skillPath);
      let skillImport = this._getLoader(loader, srcPath);
      if (skillImport.run) {
        skillImport.run = skillImport.run(this.state);
      }

      Logger.verbose(`\t\t${skillName}`);
      const skill = new Skill(skillName, skillImport, this.state);

      if (skill.type === SkillType.SKILL) {
        this.state.SkillManager.add(skill);
      } else {
        this.state.SpellManager.add(skill);
      }
    }

    Logger.verbose(`\tENDLOAD: Skills...`);
  }

  /**
   * @param {string} bundle
   * @param {string} serverEventsDir
   */
  loadServerEvents(bundle, serverEventsDir) {
    Logger.verbose(`\tLOAD: Server Events...`);
    const files = fs.readdirSync(serverEventsDir);

    for (const eventsFile of files) {
      const eventsPath = serverEventsDir + eventsFile;
      if (!Data.isScriptFile(eventsPath, eventsFile)) {
        continue;
      }

      const eventsName = path.basename(eventsFile, path.extname(eventsFile));
      Logger.verbose(`\t\t\tLOAD: SERVER-EVENTS ${eventsName}...`);
      const loader = require(eventsPath);
      const eventsListeners = this._getLoader(loader, srcPath).listeners;

      for (const [eventName, listener] of Object.entries(eventsListeners)) {
        this.state.ServerEventManager.add(eventName, listener(this.state));
      }
    }

    Logger.verbose(`\tENDLOAD: Server Events...`);
  }

  /**
   * For a given bundle js file require check if it needs to be backwards compatibly loaded with a loader(srcPath)
   * or can just be loaded on its own
   * @private
   * @param {function (string)|object|array} loader
   * @return {loader}
   */
  _getLoader(loader, ...args) {
    if (typeof loader === 'function') {
      // backwards compatible for old module loader(srcPath)
      return loader(...args);
    }

    return loader;
  }

  /**
   * @private
   * @param {string} bundle
   * @param {string} areaName
   * @return {string}
   */
  _getAreaScriptPath(bundle, areaName) {
    return `${this.bundlesPath}/${bundle}/areas/${areaName}/scripts`;
  }
}

module.exports = BundleManager;