let idInc = 0;

const genWrappedFunc = ({
  func,
  smp,
  context,
  timeEventName,
  pluginName,
  endType,
}) => (...args) => {
  const id = idInc++;
  // we don't know if there's going to be a callback applied to a particular
  // call, so we just set it multiple times, letting each one override the last
  const addEndEvent = () =>
    smp.addTimeEvent("plugins", timeEventName, "end", {
      id,
      // we need to allow failure, since webpack can finish compilation and
      // cause our callbacks to fall on deaf ears
      allowFailure: true,
    });

  smp.addTimeEvent("plugins", timeEventName, "start", {
    id,
    name: pluginName,
  });
  // invoke an end event immediately in case the callback here causes webpack
  // to complete compilation. If this gets invoked and not the subsequent
  // call, then our data will be inaccurate, sadly
  addEndEvent();
  const normalArgMap = a => wrap(a, pluginName, smp);
  let ret;
  if (endType === "wrapDone")
    ret = func.apply(
      context,
      args.map(a => wrap(a, pluginName, smp, addEndEvent))
    );
  else if (endType === "async") {
    const argsButLast = args.slice(0, args.length - 1);
    const callback = args[args.length - 1];
    ret = func.apply(
      context,
      argsButLast.map(normalArgMap).concat((...callbackArgs) => {
        addEndEvent();
        callback(...callbackArgs);
      })
    );
  } else if (endType === "promise")
    ret = func.apply(context, args.map(normalArgMap)).then(promiseArg => {
      addEndEvent();
      return promiseArg;
    });
  else ret = func.apply(context, args.map(normalArgMap));
  addEndEvent();

  return ret;
};

const genPluginMethod = (orig, pluginName, smp, type) =>
  function(method, func) {
    const timeEventName = pluginName + "/" + type + "/" + method;
    const wrappedFunc = genWrappedFunc({
      func,
      smp,
      context: this,
      timeEventName,
      pluginName,
      endType: "wrapDone",
    });
    return orig.plugin(method, wrappedFunc);
  };

const wrapTap = (tap, pluginName, smp, type, method) =>
  function(id, func) {
    const timeEventName = pluginName + "/" + type + "/" + method;
    const wrappedFunc = genWrappedFunc({
      func,
      smp,
      context: this,
      timeEventName,
      pluginName,
    });
    return tap.call(this, id, wrappedFunc);
  };

const wrapTapAsync = (tapAsync, pluginName, smp, type, method) =>
  function(id, func) {
    const timeEventName = pluginName + "/" + type + "/" + method;
    const wrappedFunc = genWrappedFunc({
      func,
      smp,
      context: this,
      timeEventName,
      pluginName,
      endType: "async",
    });
    return tapAsync.call(this, id, wrappedFunc);
  };

const wrapTapPromise = (tapPromise, pluginName, smp, type, method) =>
  function(id, func) {
    const timeEventName = pluginName + "/" + type + "/" + method;
    const wrappedFunc = genWrappedFunc({
      func,
      smp,
      context: this,
      timeEventName,
      pluginName,
      endType: "promise",
    });
    return tapPromise.call(this, id, wrappedFunc);
  };

const wrappedHooks = [];
const wrapHooks = (orig, pluginName, smp, type) => {
  const hooks = orig.hooks;
  if (!hooks) return hooks;
  const prevWrapped = wrappedHooks.find(
    w =>
      w.pluginName === pluginName && (w.orig === hooks || w.wrapped === hooks)
  );
  if (prevWrapped) return prevWrapped.wrapped;

  const genProxy = method => {
    const proxy = new Proxy(hooks[method], {
      get: (target, property) => {
        const raw = Reflect.get(target, property);

        if (Object.isFrozen(target)) {
          return raw;
        }

        if (property === "tap" && typeof raw === "function")
          return wrapTap(raw, pluginName, smp, type, method).bind(proxy);
        if (property === "tapAsync" && typeof raw === "function")
          return wrapTapAsync(raw, pluginName, smp, type, method).bind(proxy);
        if (property === "tapPromise" && typeof raw === "function")
          return wrapTapPromise(raw, pluginName, smp, type, method).bind(proxy);

        return raw;
      },
      set: (target, property, value) => {
        return Reflect.set(target, property, value);
      },
      deleteProperty: (target, property) => {
        return Reflect.deleteProperty(target, property);
      },
    });
    return proxy;
  };

  const wrapped = Object.keys(hooks).reduce((acc, method) => {
    acc[method] = genProxy(method);
    return acc;
  }, {});

  wrappedHooks.push({ orig: hooks, wrapped, pluginName });

  return wrapped;
};

const construcNamesToWrap = [
  "Compiler",
  "Compilation",
  "MainTemplate",
  "Parser",
  "NormalModuleFactory",
  "ContextModuleFactory",
];

const wrappedObjs = [];
const findWrappedObj = (orig, pluginName) => {
  const prevWrapped = wrappedObjs.find(
    w => w.pluginName === pluginName && (w.orig === orig || w.wrapped === orig)
  );
  if (prevWrapped) return prevWrapped.wrapped;
};
const wrap = (orig, pluginName, smp, addEndEvent) => {
  if (!orig) return orig;
  const prevWrapped = findWrappedObj(orig, pluginName);
  if (prevWrapped) return prevWrapped;

  const getOrigConstrucName = target =>
    target && target.constructor && target.constructor.name;
  const getShouldWrap = target => {
    const origConstrucName = getOrigConstrucName(target);
    return construcNamesToWrap.includes(origConstrucName);
  };
  const shouldWrap = getShouldWrap(orig);
  const shouldSoftWrap = Object.keys(orig)
    .map(k => orig[k])
    .some(getShouldWrap);

  let wrappedReturn;

  if (!shouldWrap && !shouldSoftWrap) {
    const vanillaFunc = orig.name === "next";
    wrappedReturn =
      vanillaFunc && addEndEvent
        ? function() {
            // do this before calling the callback, since the callback can start
            // the next plugin step
            addEndEvent();

            return orig.apply(this, arguments);
          }
        : orig;
  } else {
    const proxy = new Proxy(orig, {
      get: (target, property) => {
        const raw = Reflect.get(target, property);

        if (shouldWrap && property === "plugin")
          return genPluginMethod(
            target,
            pluginName,
            smp,
            getOrigConstrucName(target)
          ).bind(proxy);

        if (shouldWrap && property === "hooks")
          return wrapHooks(
            target,
            pluginName,
            smp,
            getOrigConstrucName(target)
          );

        if (shouldWrap && property === "compiler") {
          const prevWrapped = findWrappedObj(raw, pluginName);
          if (prevWrapped) {
            return prevWrapped;
          }
        }

        if (typeof raw === "function") {
          const ret = raw.bind(proxy);
          if (property === "constructor")
            Object.defineProperty(ret, "name", {
              value: raw.name,
            });
          return ret;
        }

        return raw;
      },
      set: (target, property, value) => {
        return Reflect.set(target, property, value);
      },
      deleteProperty: (target, property) => {
        return Reflect.deleteProperty(target, property);
      },
    });

    wrappedReturn = proxy;
  }

  wrappedObjs.push({ pluginName, orig, wrapped: wrappedReturn });
  return wrappedReturn;
};

module.exports.clear = () => {
  wrappedObjs.length = 0;
  wrappedHooks.length = 0;
};

module.exports.WrappedPlugin = class WrappedPlugin {
  constructor(plugin, pluginName, smp) {
    this._smp_plugin = plugin;
    this._smp_pluginName = pluginName;
    this._smp = smp;

    this.apply = this.apply.bind(this);

    const wp = this;
    return new Proxy(plugin, {
      get(target, property) {
        if (property === "apply") {
          return wp.apply;
        }
        return target[property];
      },
      set: (target, property, value) => {
        return Reflect.set(target, property, value);
      },
      deleteProperty: (target, property) => {
        return Reflect.deleteProperty(target, property);
      },
    });
  }

  apply(compiler) {
    return this._smp_plugin.apply(
      wrap(compiler, this._smp_pluginName, this._smp)
    );
  }
};
