/*******************************************************************************
 * © Apployees Inc., 2019
 * All Rights Reserved.
 ******************************************************************************/
import path, { resolve } from "path";
import fs from "fs-extra";
import chalk from "chalk";
import { BuilderContext, createBuilder } from "@angular-devkit/architect";
import { JsonObject } from "@angular-devkit/core";
import { DevServerBuildOutput, runWebpack, runWebpackDevServer } from "@angular-devkit/build-webpack";
import { forkJoin, from, Observable, of } from "rxjs";
import { concatMap, map, switchMap } from "rxjs/operators";
import {
  getSourceRoot,
  loadEnvironmentVariables,
  OUT_FILENAME,
  WebpackBuildEvent,
  writePackageJson,
} from "@apployees-nx/common-build-utils";
import { IBuildWebserverBuilderOptions } from "../../utils/common/webserver-types";
import { normalizeBuildOptions } from "../../utils/common/normalize";
import { getServerConfig } from "../../utils/server/server-config";
import { getClientConfig } from "../../utils/client/client-config";
import { checkBrowsers } from "react-dev-utils/browsersHelper";
import FileSizeReporter from "react-dev-utils/FileSizeReporter";
import { choosePort, createCompiler, prepareUrls, printInstructions } from "../../utils/client/WebpackDevServerUtils";
import errorOverlayMiddleware from "react-dev-utils/errorOverlayMiddleware";
import evalSourceMapMiddleware from "react-dev-utils/evalSourceMapMiddleware";
import _ from "lodash";
import webpack, { Configuration } from "webpack";
import escape from "escape-string-regexp";
import WebpackDevServer from "webpack-dev-server";
import noopServiceWorkerMiddleware from "react-dev-utils/noopServiceWorkerMiddleware";

(process as NodeJS.EventEmitter).on("uncaughtException", async (thrown: any) => {
  console.error("Uncaught error:", thrown);

  process.exit(1);
});

const measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;

// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;

const isInteractive = process.stdout.isTTY;

try {
  require("dotenv").config();
} catch (e) {
  console.error("Error while loading dotenv config.");
  console.error(e);
}

export default createBuilder<JsonObject & IBuildWebserverBuilderOptions>(run);

interface IWebpackDevServerReference {
  server: WebpackDevServer & { sockWrite: Function; sockets: any };
}

function run(
  options: JsonObject & IBuildWebserverBuilderOptions,
  context: BuilderContext,
): Observable<WebpackBuildEvent> {
  const nodeEnv: string = options.dev ? "development" : "production";
  // do this otherwise our bootstrapped @apployees-nx/node actually replaces this
  // to "development" or "production" at build time.
  const nodeEnvKey = "NODE_ENV";
  const babelEnvKey = "BABEL_ENV";
  process.env[nodeEnvKey] = nodeEnv;
  process.env[babelEnvKey] = nodeEnv;

  const devServer: IWebpackDevServerReference = { server: null };
  const devSocket = {
    warnings: (warnings) => {
      devServer.server.sockWrite(devServer.server.sockets, "warnings", warnings);
    },
    errors: (errors) => {
      devServer.server.sockWrite(devServer.server.sockets, "errors", errors);
    },
  };
  let yarnExists;
  let devClientFirstTimeComplete = false,
    devServerFirstTimeComplete = false;

  return from(getSourceRoot(context)).pipe(
    map((sourceRoot) => normalizeBuildOptions(options, context, sourceRoot)),
    switchMap((options: IBuildWebserverBuilderOptions) =>
      checkBrowsers(path.resolve(options.root, options.sourceRoot), isInteractive).then(() => options),
    ),
    switchMap((options: IBuildWebserverBuilderOptions) => {
      yarnExists = fs.existsSync(path.resolve(options.root, "yarn.lock"));
      loadEnvironmentVariables(options, context);

      if (options.dev) {
        return choosePort(options.devHost, options.devAppPort).then((appPort) => {
          if (_.isNil(appPort)) {
            throw new Error("Could not start because we could not find a port for app server.");
          }

          options.devAppPort = appPort;
          process.env.PORT = appPort;

          return choosePort(options.devHost, options.devWebpackPort).then((webpackPort) => {
            if (_.isNil(webpackPort)) {
              throw new Error("Could not start because we could not find a port for the webpack server.");
            }

            options.devWebpackPort = webpackPort;
            process.env.DEV_PORT = webpackPort;
            const protocol = options.devHttps ? "https" : "http";

            options.assetsUrl = `${protocol}://${options.devHost}:${webpackPort}/`;
            // eslint-disable-next-line @typescript-eslint/camelcase
            options.devUrls_calculated = prepareUrls(protocol, options.devHost, appPort);

            return options;
          });
        });
      } else {
        return Promise.resolve(options);
      }
    }),
    switchMap((options: IBuildWebserverBuilderOptions) => {
      if (!options.dev) {
        return measureFileSizesBeforeBuild(
          options.publicOutputFolder_calculated,
        ).then((previousFileSizesForPublicFolder) => [options, previousFileSizesForPublicFolder]);
      } else {
        return Promise.resolve([options, null]);
      }
    }),
    map(([options, previousFileSizesForPublicFolder]) => {
      // Remove all content but keep the directory so that
      // if you're in it, you don't end up in Trash
      fs.emptyDirSync(options.outputPath);

      return [options, previousFileSizesForPublicFolder];
    }),
    map(([options, previousFileSizesForPublicFolder]) => {
      if (!fs.existsSync(options.appHtml) || !fs.existsSync(options.clientMain) || !fs.existsSync(options.serverMain)) {
        throw new Error("One of appHtml, clientMain, or serverMain is not specified.");
      }

      let serverConfig = getServerConfig(options, context, true);
      if (options.serverWebpackConfig) {
        serverConfig = __non_webpack_require__(options.serverWebpackConfig)(serverConfig, {
          options,
          configuration: context.target.configuration,
        });
      }

      let clientConfig = getClientConfig(options, context, false);
      if (options.clientWebpackConfig) {
        clientConfig = __non_webpack_require__(options.clientWebpackConfig)(clientConfig, {
          options,
          configuration: context.target.configuration,
        });
      }

      // remove the output directory before we go further

      return [options, serverConfig, clientConfig, previousFileSizesForPublicFolder];
    }),
    concatMap(
      ([options, serverConfig, clientConfig, previousFileSizesForPublicFolder]: [
        IBuildWebserverBuilderOptions,
        Configuration,
        Configuration,
        object,
      ]) => {
        if (options.dev) {
          /**
           * Run the webpack for server and webpack dev server for client.
           */

          return forkJoin(
            runWebpack(serverConfig, context, {
              logging: (stats) => {
                context.logger.info(stats.toString(serverConfig.stats));
                devServerFirstTimeComplete = true;
                if (devClientFirstTimeComplete && devServerFirstTimeComplete) {
                  printInstructions(context.target.project, options.devUrls_calculated, yarnExists);
                }
              },
              webpackFactory: (config: Configuration) =>
                of(
                  createCompiler({
                    webpack: webpack,
                    config: serverConfig,
                    appName: context.target.project + " - Server",
                    useYarn: yarnExists,
                    tscCompileOnError: true,
                    useTypeScript: true,
                    devSocket: devSocket,
                    urls: options.devUrls_calculated,
                  }),
                ),
            }),

            runWebpackDevServer(clientConfig, context, {
              logging: (stats) => {
                context.logger.info(stats.toString(clientConfig.stats));
                devClientFirstTimeComplete = true;
                if (devClientFirstTimeComplete && devServerFirstTimeComplete) {
                  printInstructions(context.target.project, options.devUrls_calculated, yarnExists);
                }
              },
              devServerConfig: createWebpackServerOptions(options, context, devServer),
              webpackFactory: (config: webpack.Configuration) =>
                of(
                  createCompiler({
                    webpack: webpack,
                    config: clientConfig,
                    appName: context.target.project + " - Client",
                    useYarn: yarnExists,
                    tscCompileOnError: true,
                    useTypeScript: true,
                    devSocket: devSocket,
                    urls: options.devUrls_calculated,
                  }) as webpack.Compiler,
                ),
            }).pipe(
              map((output) => {
                output.baseUrl = options.devUrls_calculated.localUrlForBrowser;
                return output;
              }),
            ),

            of(options),
          );
        } else {
          /**
           * Run the webpack for server and webpack for client.
           */
          return forkJoin(
            runWebpack(serverConfig, context, {
              logging: (stats) => {
                context.logger.info(stats.toString(serverConfig.stats));
              },
            }),

            runWebpack(clientConfig, context, {
              logging: (stats) => {
                context.logger.info(stats.toString(clientConfig.stats));

                console.log(previousFileSizesForPublicFolder);
                context.logger.info("\n\nFile sizes of files in /public after gzip:\n");
                printFileSizesAfterBuild(
                  stats,
                  previousFileSizesForPublicFolder,
                  options.publicOutputFolder_calculated,
                  WARN_AFTER_BUNDLE_GZIP_SIZE,
                  WARN_AFTER_CHUNK_GZIP_SIZE,
                );
              },
            }),

            of(options),
          );
        }
      },
    ),
    map(
      ([serverBuildEvent, clientBuildEventOrDevServerBuildOutput, options]: [
        WebpackBuildEvent,
        WebpackBuildEvent | DevServerBuildOutput,
        IBuildWebserverBuilderOptions,
      ]) => {
        if (!options.dev) {
          serverBuildEvent.success = serverBuildEvent.success && clientBuildEventOrDevServerBuildOutput.success;
          serverBuildEvent.error = serverBuildEvent.error && clientBuildEventOrDevServerBuildOutput.error;
          serverBuildEvent.outfile = resolve(context.workspaceRoot, options.outputPath, OUT_FILENAME);
          return [serverBuildEvent as WebpackBuildEvent, options];
        } else {
          return [clientBuildEventOrDevServerBuildOutput as DevServerBuildOutput, options];
        }
      },
    ),
    map(
      ([clientBuildEventOrDevServerBuildOutput, options]: [
        WebpackBuildEvent & DevServerBuildOutput,
        IBuildWebserverBuilderOptions,
      ]) => {
        // we only consider server external dependencies and libraries because it is the server
        // code that is run by node, not the browser code.

        if (!options.dev) {
          writePackageJson(options, context, options.serverExternalDependencies, options.serverExternalLibraries);

          printHostingInstructions(options);

          return clientBuildEventOrDevServerBuildOutput;
        } else {
          printInstructions(context.target.project, options.devUrls_calculated, yarnExists);
          return clientBuildEventOrDevServerBuildOutput;
        }
      },
    ),
  );
}

function printHostingInstructions(options: IBuildWebserverBuilderOptions) {
  const assetsPath = options.assetsUrl;
  // eslint-disable-next-line @typescript-eslint/camelcase
  const publicOutputFolder_calculated = options.publicOutputFolder_calculated;
  const buildFolder = options.outputPath;

  console.log(
    `\n\n\n\nThe project was built assuming all static assets are served from the path '${chalk.green(assetsPath)}'.`,
  );
  console.log();
  if (assetsPath.startsWith("/")) {
    console.log(
      `All of your static assets will be served from the rendering server (specifically from ${chalk.green(
        publicOutputFolder_calculated,
      )}).`,
    );
    console.log("\nWe recommend serving static assets from a CDN in production.");
    console.log(
      `\nYou can control this with the ${chalk.cyan(
        "ASSETS_URL",
      )} environment variable and set its value to the CDN URL for your next build.`,
    );
    console.log();
  }
  console.log(`The ${chalk.cyan(buildFolder)} folder is ready to be deployed.`);
  console.log();
  console.log("You may run the app with node:");
  console.log();
  console.log(` ${chalk.cyan("node")} ${buildFolder}`);
  console.log();
}

function createWebpackServerOptions(
  options: IBuildWebserverBuilderOptions,
  context: BuilderContext,
  serverReference: IWebpackDevServerReference,
) {
  const config: WebpackDevServer.Configuration & { logLevel?: string } = {
    // this needs to remain disabled because our webpackdevserver runs on a
    // different port than the server app.
    disableHostCheck: true,
    // Enable gzip compression of generated files.
    compress: true,
    // Enable hot reloading server. It will provide /sockjs-node/ endpoint
    // for the WebpackDevServer client so it can learn when the files were
    // updated. The WebpackDevServer client is included as an entry point
    // in the Webpack development configuration. Note that only changes
    // to CSS are currently hot reloaded. JS changes will refresh the browser.
    hot: true,
    // It is important to tell WebpackDevServer to use the same "root" path
    // as we specified in the config. In development, we always serve from /.
    publicPath: process.env.ASSETS_URL || options.assetsUrl,
    // WebpackDevServer is noisy by default so we emit custom message instead
    // by listening to the compiler events with `compiler.hooks[...].tap` calls above.
    quiet: true,
    // Silence WebpackDevServer's own logs since they're generally not useful.
    // It will still show compile warnings and errors with this setting.
    logLevel: "warn",
    // Reportedly, this avoids CPU overload on some systems.
    // https://github.com/facebook/create-react-app/issues/293
    // src/node_modules is not ignored to support absolute imports
    // https://github.com/facebook/create-react-app/issues/1065
    watchOptions: {
      ignored: ignoredFiles(path.resolve(options.root, options.sourceRoot)),
    },
    host: options.devHost,
    port: options.devWebpackPort,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
      "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization",
    },
    overlay: false,
    historyApiFallback: {
      // Paths with dots should still use the history fallback.
      // See https://github.com/facebook/create-react-app/issues/387.
      disableDotRule: true,
    },
    public: options.devUrls_calculated.lanUrlForConfig,
    https: options.devHttps,
    before(app, server) {
      serverReference.server = server as any;

      // This lets us fetch source contents from webpack for the error overlay
      app.use(evalSourceMapMiddleware(server));
      // This lets us open files from the runtime error overlay.
      app.use(errorOverlayMiddleware());

      // This service worker file is effectively a 'no-op' that will reset any
      // previous service worker registered for the same host:port combination.
      // We do this in development to avoid hitting the production cache if
      // it used the same host and port.
      // https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
      app.use(noopServiceWorkerMiddleware());
    },
  };

  return config;
}

function ignoredFiles(appSrc) {
  return new RegExp(`^(?!${escape(path.normalize(appSrc + "/").replace(/[\\]+/g, "/"))}).+/node_modules/`, "g");
}

// eslint-disable-next-line @typescript-eslint/camelcase
declare function __non_webpack_require__(string): any;
