const path = require("path");
const fs = require("fs");
const chalk = require("chalk");
const { WrappedPlugin, clear } = require("./WrappedPlugin");
const {
  getModuleName,
  getLoaderNames,
  prependLoader,
  tap,
} = require("./utils");
const {
  getHumanOutput,
  getMiscOutput,
  getPluginsOutput,
  getLoadersOutput,
  smpTag,
} = require("./output");

const NS = path.dirname(fs.realpathSync(__filename));

module.exports = class SpeedMeasurePlugin {
  constructor(options) {
    this.options = options || {};

    this.timeEventData = {};
    this.smpPluginAdded = false;

    this.wrap = this.wrap.bind(this);
    this.getOutput = this.getOutput.bind(this);
    this.addTimeEvent = this.addTimeEvent.bind(this);
    this.apply = this.apply.bind(this);
    this.provideLoaderTiming = this.provideLoaderTiming.bind(this);
  }

  wrap(config) {
    if (this.options.disable) return config;
    if (Array.isArray(config)) return config.map(this.wrap);
    if (typeof config === "function")
      return (...args) => this.wrap(config(...args));

    config.plugins = (config.plugins || []).map(plugin => {
      const pluginName =
        Object.keys(this.options.pluginNames || {}).find(
          pluginName => plugin === this.options.pluginNames[pluginName]
        ) ||
        (plugin.constructor && plugin.constructor.name) ||
        "(unable to deduce plugin name)";
      return new WrappedPlugin(plugin, pluginName, this);
    });

    if (config.optimization && config.optimization.minimizer) {
      config.optimization.minimizer = config.optimization.minimizer.map(
        plugin => {
          return new WrappedPlugin(plugin, plugin.constructor.name, this);
        }
      );
    }

    if (config.module && this.options.granularLoaderData) {
      config.module = prependLoader(config.module);
    }

    if (!this.smpPluginAdded) {
      config.plugins = config.plugins.concat(this);
      this.smpPluginAdded = true;
    }

    return config;
  }

  getOutput() {
    const outputObj = {};
    if (this.timeEventData.misc)
      outputObj.misc = getMiscOutput(this.timeEventData.misc);
    if (this.timeEventData.plugins)
      outputObj.plugins = getPluginsOutput(this.timeEventData.plugins);
    if (this.timeEventData.loaders)
      outputObj.loaders = getLoadersOutput(this.timeEventData.loaders);

    if (this.options.outputFormat === "json")
      return JSON.stringify(outputObj, null, 2);
    if (typeof this.options.outputFormat === "function")
      return this.options.outputFormat(outputObj);
    return getHumanOutput(outputObj, {
      verbose: this.options.outputFormat === "humanVerbose",
    });
  }

  addTimeEvent(category, event, eventType, data = {}) {
    const allowFailure = data.allowFailure;
    delete data.allowFailure;

    const tED = this.timeEventData;
    if (!tED[category]) tED[category] = {};
    if (!tED[category][event]) tED[category][event] = [];
    const eventList = tED[category][event];
    const curTime = new Date().getTime();

    if (eventType === "start") {
      data.start = curTime;
      eventList.push(data);
    } else if (eventType === "end") {
      const matchingEvent = eventList.find(e => {
        const allowOverwrite = !e.end || !data.fillLast;
        const idMatch = e.id !== undefined && e.id === data.id;
        const nameMatch =
          !data.id && e.name !== undefined && e.name === data.name;
        return allowOverwrite && (idMatch || nameMatch);
      });
      const eventToModify =
        matchingEvent || (data.fillLast && eventList.find(e => !e.end));
      if (!eventToModify) {
        console.error(
          "Could not find a matching event to end",
          category,
          event,
          data
        );
        if (allowFailure) return;
        throw new Error("No matching event!");
      }

      eventToModify.end = curTime;
    }
  }

  apply(compiler) {
    if (this.options.disable) return;

    tap(compiler, "compile", () => {
      this.addTimeEvent("misc", "compile", "start", { watch: false });
    });
    tap(compiler, "done", () => {
      clear();
      this.addTimeEvent("misc", "compile", "end", { fillLast: true });

      const outputToFile = typeof this.options.outputTarget === "string";
      chalk.enabled = !outputToFile;
      const output = this.getOutput();
      chalk.enabled = true;
      if (outputToFile) {
        const writeMethod = fs.existsSync(this.options.outputTarget)
          ? fs.appendFileSync
          : fs.writeFileSync;
        writeMethod(this.options.outputTarget, output + "\n");
        console.log(
          smpTag() + "Outputted timing info to " + this.options.outputTarget
        );
      } else {
        const outputFunc = this.options.outputTarget || console.log;
        outputFunc(output);
      }

      this.timeEventData = {};
    });

    tap(compiler, "compilation", compilation => {
      tap(compilation, "normal-module-loader", loaderContext => {
        loaderContext[NS] = this.provideLoaderTiming;
      });

      tap(compilation, "build-module", module => {
        const name = getModuleName(module);
        if (name) {
          this.addTimeEvent("loaders", "build", "start", {
            name,
            fillLast: true,
            loaders: getLoaderNames(module.loaders),
          });
        }
      });

      tap(compilation, "succeed-module", module => {
        const name = getModuleName(module);
        if (name) {
          this.addTimeEvent("loaders", "build", "end", {
            name,
            fillLast: true,
          });
        }
      });
    });
  }

  provideLoaderTiming(info) {
    const infoData = { id: info.id };
    if (info.type !== "end") {
      infoData.loader = info.loaderName;
      infoData.name = info.module;
    }

    this.addTimeEvent("loaders", "build-specific", info.type, infoData);
  }
};