/* eslint-disable @typescript-eslint/camelcase, global-require, @typescript-eslint/no-var-requires */
/* eslint-disable no-magic-numbers, no-case-declarations, no-fallthrough */
import Fs = require("fs");
import _ = require("lodash");
import Path = require("path");

const pluginName = "SubAppPlugin";

const findWebpackVersion = (): number => {
  const webpackPkg = JSON.parse(
    Fs.readFileSync(require.resolve("webpack/package.json")).toString()
  );
  const webpackVersion = parseInt(webpackPkg.version.split(".")[0]);
  return webpackVersion;
};

const assert = (ok: boolean, fail: string | Function) => {
  if (!ok) {
    const x = typeof fail === "function" ? fail() : fail;
    if (typeof x === "string") {
      throw new Error(x);
    }
    throw x;
  }
};

const SHIM_parseCommentOptions = Symbol("parseCommentOptions");

/**
 * This plugin will look for `declareSubApp` calls and do these:
 *
 * 1. instruct webpack to name the dynamic import bundle as `subapp-<name>`
 * 2. collect the subapp meta info and save them as `subapps.json`
 *
 */
export class SubAppWebpackPlugin {
  _declareApiNames: string[];
  _subApps: Record<string, any>;
  _wVer: number;
  _makeIdentifierBEE: Function;
  _tapAssets: Function;
  _assetsFile: string;

  /**
   *
   * @param options - subapp plugin options
   */
  constructor({
    declareApiName = ["declareSubApp", "createDynamicComponent"],
    webpackVersion = findWebpackVersion(),
    assetsFile = "subapps.json"
  }: {
    /**
     * The API names for declaring subapp and components
     */
    declareApiName?: string | string[];
    /**
     * Webpack version (4, 5, etc)
     *
     * minimum 4
     */
    webpackVersion?: number;
    /**
     * Filename to output the subapp assets JSON file
     * **default**: `subapps.json`
     */
    assetsFile?: string;
  } = {}) {
    this._declareApiNames = [].concat(declareApiName);
    this._subApps = {};
    this._wVer = webpackVersion;

    const { makeIdentifierBEE, tapAssets } = this[`initWebpackVer${this._wVer}`]();

    this._makeIdentifierBEE = makeIdentifierBEE;
    this._tapAssets = tapAssets;
    this._assetsFile = assetsFile;
  }

  initWebpackVer4() {
    const BEE = require("webpack/lib/BasicEvaluatedExpression");
    return {
      BasicEvaluatedExpression: BEE,
      makeIdentifierBEE: expr => {
        return new BEE().setIdentifier(expr.name).setRange(expr.range);
      },
      tapAssets: compiler => {
        compiler.hooks.emit.tap(pluginName, compilation => this.updateAssets(compilation.assets));
      }
    };
  }

  initWebpackVer5() {
    const BEE = require("webpack/lib/javascript/BasicEvaluatedExpression");
    return {
      BasicEvaluatedExpression: BEE,
      makeIdentifierBEE: expr => {
        return new BEE()
          .setIdentifier(expr.name, {}, () => [])
          .setRange(expr.range)
          .setExpression(expr);
      },
      tapAssets: compiler => {
        compiler.hooks.compilation.tap(pluginName, compilation => {
          compilation.hooks.processAssets.tap(pluginName, assets => this.updateAssets(assets));
        });
      }
    };
  }

  updateAssets(assets) {
    let subappMeta = {};
    const keys = Object.keys(this._subApps);
    if (keys.length > 0) {
      subappMeta = keys.reduce(
        (acc, k) => {
          acc[k] = _.pick(this._subApps[k], ["name", "source", "module"]);
          return acc;
        },
        {
          "//about": "Subapp meta information collected during webpack compile",
          "//count": keys.length
        }
      );
      const subapps = JSON.stringify(subappMeta, null, 2) + "\n";
      assets[this._assetsFile] = {
        source: () => subapps,
        size: () => subapps.length
      };
    }
  }

  findImportCall(ast) {
    switch (ast.type) {
      case "CallExpression":
        const arg = _.get(ast, "arguments[0]", {});
        if (ast.callee.type === "Import" && arg.type === "Literal") {
          return arg.value;
        }
      case "ReturnStatement":
        return this.findImportCall(ast.argument);
      case "BlockStatement":
        for (const n of ast.body) {
          const res = this.findImportCall(n);
          if (res) {
            return res;
          }
        }
    }
    return undefined;
  }

  apply(compiler) {
    this._tapAssets(compiler);

    const findGetModule = props => {
      const prop = props.find(p => p.key.name === "getModule");
      const funcBody = prop.value.body;
      return funcBody;
    };

    compiler.hooks.normalModuleFactory.tap(pluginName, factory => {
      factory.hooks.parser.for("javascript/auto").tap(pluginName, (parser, options) => {
        parser[SHIM_parseCommentOptions] = parser.parseCommentOptions;

        assert(
          parser.parseCommentOptions,
          `webpack parser doesn't have method 'parseCommentOptions' - not compatible with this plugin`
        );

        const xl = parser.parseCommentOptions.length;
        assert(
          xl === 1,
          `webpack parser.parseCommentOptions takes ${xl} arguments - but expecting 1 so not compatible with this plugin`
        );

        parser.parseCommentOptions = range => {
          for (const k in this._subApps) {
            const subapp = this._subApps[k];
            const gmod = subapp.getModule;
            if (range[0] >= gmod.range[0] && gmod.range[1] >= range[1]) {
              const name = subapp.name.toLowerCase().replace(/ /g, "_");
              return {
                options: { webpackChunkName: `subapp-${name}` },
                errors: []
              };
            }
          }
          return parser[SHIM_parseCommentOptions](range);
        };

        const noCwd = x => x.replace(process.cwd(), ".");

        const where = (source, loc) => {
          return `${source}:${loc.start.line}:${loc.start.column + 1}`;
        };

        const parseForSubApp = (expression, apiName) => {
          const currentSource = _.get(parser, "state.current.resource", "");
          const props = _.get(expression, "arguments[0].properties");
          const cw = () => where(noCwd(currentSource), expression.loc);

          if (!props && apiName === "createDynamicComponent") {
            return;
          }

          assert(props, () => `${cw()}: you must pass an Object literal as argument to ${apiName}`);

          const nameProp = props.find(p => p.key.name === "name");
          assert(nameProp, () => `${cw()}: argument for ${apiName} doesn't have a name property`);

          const nameVal = nameProp.value.value;
          assert(
            nameVal && typeof nameVal === "string",
            () => `${cw()}: subapp name must be specified as an inlined literal string`
          );
          // the following breaks hot recompiling in dev mode
          // const exist = this._subApps[nameVal];
          // assert(
          //   !exist,
          //   () =>
          //     `${cw()}: subapp '${nameVal}' is already declared at ${where(
          //       noCwd(exist.source),
          //       exist.loc
          //     )}`
          // );
          const gm = findGetModule(props);

          // try to figure out the module that's being imported for this subapp
          // getModule function: () => import("./subapp-module")
          // getModule function: function () { return import("./subapp-module") }
          const mod = this.findImportCall(gm);

          this._subApps[nameVal] = {
            name: nameVal,
            source: Path.relative(process.cwd(), currentSource),
            loc: expression.loc,
            range: expression.range,
            getModule: gm,
            module: mod
          };
        };

        const apiNames = [].concat(this._declareApiNames);

        [].concat(apiNames).forEach(apiName => {
          parser.hooks.call.for(apiName).tap(pluginName, expr => parseForSubApp(expr, apiName));
        });

        parser.hooks.evaluate
          .for("CallExpression")
          .tap({ name: pluginName, before: "Parser" }, expression => {
            const calleeName = _.get(expression, "callee.property.name");
            if (apiNames.includes(calleeName)) {
              return parseForSubApp(expression, calleeName);
            }

            return undefined;
          });

        parser.hooks.evaluate
          .for("Identifier")
          .tap({ name: pluginName, before: "Parser" }, expression => {
            if (apiNames.includes(expression.name)) {
              return this._makeIdentifierBEE(expression);
            }

            return undefined;
          });
      });
    });
  }
}