const path = require('path');
const chalk = require('chalk');

// Fix rollup watcher chokidar path issue.
const tmpCWD = process.cwd();
process.chdir(__dirname);

// Require rollup.
const rollup = require('rollup');

// Revert rollup watcher chokidar path issue fix.
process.chdir(tmpCWD);

// Rollup dependencies.
const { nodeResolve } = require('@rollup/plugin-node-resolve');
/** @type {import('@rollup/plugin-commonjs')['default']} */
// @ts-ignore
const rollupCommonJS = require('@rollup/plugin-commonjs');
const { babel } = require('@rollup/plugin-babel');
/**
 * @type {import('@rollup/plugin-alias')['default']}
 */
// @ts-ignore
const rollupAlias = require('@rollup/plugin-alias');
/**
 * @type {import('@rollup/plugin-replace')['default']}
 */
// @ts-ignore
const rollupReplace = require('@rollup/plugin-replace');
/**
 * @type {import('@rollup/plugin-typescript')['default']}
 */
// @ts-ignore
const rollupTypescript = require('@rollup/plugin-typescript');
const Observable = require('@runforest/run/utils/observable');
const minify = require('./rollup-minify');

/**
 * @typedef {{
 *   source: string;
 *   destination: string;
 *   globals?: Record<string, string>;
 *   external?: string[];
 *   compress: boolean;
 *   babelExclude?: (string|RegExp)[];
 *   useTypescript?: boolean;
 *   mode: 'build' | 'watch';
 * }} ValidatedConfig
 * @typedef {Partial<Omit<ValidatedConfig, 'mode' | 'babelExclude'>
 * & { mode: string; babelExclude: string[]; }>} Config
 */

/**
 * Returns current local time string.
 *
 * @returns {String}
 */
function currentLocalTime() {
  return new Date().toLocaleTimeString();
}

function logPrefix() {
  return chalk`{gray ${currentLocalTime()}} {bold [JS]}`;
}

/**
 * @param {string | Error | import('rollup').RollupError | Error & { formatted?: string }} error
 * @returns {string}
 */
function formatError(error) {
  if (typeof error === 'string') {
    return error;
  }

  if ('formatted' in error && error.formatted) {
    return chalk`{red ${error.formatted}}`;
  }

  if (error.message) {
    return chalk`{red ${error.message}}`;
  }

  return error.toString();
}

/**
 * @param {Config} options
 * @returns {Promise<ValidatedConfig>}
 */
async function validate(options) {
  const {
    source,
    destination,
    globals,
    external,
    compress = true,
    babelExclude,
    mode = 'build',
    useTypescript = false,
  } = options;

  if (!source) {
    throw chalk`${logPrefix()} {red Source is required!}`;
  }

  if (!destination) {
    throw chalk`${logPrefix()} {red Destination is required!}`;
  }

  return {
    source,
    destination,
    globals,
    external,
    mode: mode !== 'build' ? 'watch' : 'build',
    compress,
    babelExclude,
    useTypescript,
  };
}

/**
 * @param {ValidatedConfig} config
 * @returns {(import('rollup').Plugin)[]}
 */
function getPluginsOption({ source, compress, babelExclude, useTypescript }) {
  /**
   * @type {(import('rollup').Plugin)[]}
   */
  const plugins = [];

  if (useTypescript) {
    plugins.push(rollupTypescript());
  }

  plugins.push(
    rollupAlias({
      entries: [{ find: 'core-js', replacement: path.join(__dirname, 'node_modules', 'core-js') }],
    })
  );

  // Use the Node.js resolution algorithm with Rollup
  plugins.push(
    nodeResolve({
      mainFields: ['module', 'browser', 'main'],
    })
  );

  // Convert CommonJS modules to ES2015
  plugins.push(
    rollupCommonJS({
      include: /node_modules/,
    })
  );

  plugins.push(
    rollupReplace({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ? process.env.NODE_ENV : 'production'),
      preventAssignment: true,
    })
  );

  const exclude = [/\/core-js\//].concat(
    Array.isArray(babelExclude)
      ? babelExclude.filter((rule) => typeof rule === 'string').map((rule) => new RegExp(rule))
      : []
  );

  // Babel is a toolchain that is mainly used to convert ECMAScript 2015+
  // code into a backwards compatible version of JavaScript in current
  // and older browsers or environments
  plugins.push(
    babel({
      exclude,
      babelHelpers: 'bundled',
      presets: [
        [
          // @babel/preset-env is a smart preset that allows you to use
          // the latest JavaScript without needing to micromanage which
          // syntax transforms (and optionally, browser polyfills) are
          // needed by your target environment(s).
          // @ts-ignore
          // eslint-disable-next-line global-require
          require('@babel/preset-env'),
          {
            // The starting point where the config search for
            // browserslist will start, and ascend to the system root
            // until found
            configPath: source,
            // Disable transformation of ES6 module syntax because that
            // is what rollup do
            modules: false,
            // @babel/polyfill insertion is handled by the plugin above
            useBuiltIns: 'usage',
            corejs: 3,
          },
        ],
      ],
    })
  );

  if (compress) {
    // ECMAScript 2015+ minifier
    plugins.push(minify());
  }

  return plugins;
}

/**
 * @param {ValidatedConfig} config
 * @returns {import('rollup').InputOptions}
 */
function getInputOptions(config) {
  const { source, external } = config;

  return {
    input: source,
    external,
    plugins: getPluginsOption(config),
  };
}

/**
 * @param {ValidatedConfig['source']} source
 * @param {ValidatedConfig['destination']} destination
 * @param {ValidatedConfig['globals']} globals
 * @returns {import('rollup').OutputOptions}
 */
function getOutputOptions(source, destination, globals) {
  return {
    format: 'iife',
    file: path.resolve(destination, path.basename(source)),
    globals,
    sourcemap: true,
  };
}

/**
 * @param {Config} config
 */
module.exports = function jsTask(config) {
  return new Observable((observer) => {
    validate(config)
      .then(
        (validatedConfig) =>
          new Promise((resolve, reject) => {
            const { mode } = validatedConfig;

            if (mode !== 'watch') {
              resolve(validatedConfig);
              return;
            }

            const { source, destination, globals } = validatedConfig;

            const watcher = rollup.watch({
              ...getInputOptions(validatedConfig),
              output: getOutputOptions(source, destination, globals),
              watch: {
                exclude: 'node_modules/**',
              },
            });

            /**
             * @type {NodeJS.Timeout}
             */
            let waitingMessageTimeout;

            watcher.on('event', (event) => {
              clearTimeout(waitingMessageTimeout);

              switch (event.code) {
                case 'START':
                  observer.next('Start compiling...');

                  break;

                case 'BUNDLE_START':
                  observer.next(chalk`Compiling {blue ${event.input}}...`);
                  break;

                case 'BUNDLE_END':
                  // eslint-disable-next-line no-case-declarations
                  const destinations = event.output.map((dest) => path.relative(process.cwd(), dest)).join(', ');

                  observer.next(chalk`{blue ${event.input}} is compiled to {green ${destinations}}.`);
                  break;

                case 'END':
                  waitingMessageTimeout = setTimeout(() => {
                    observer.next('Waiting...');
                  }, 2000);
                  break;

                case 'ERROR':
                  if (event.error) {
                    observer.next(formatError(event.error));
                  } else {
                    observer.next(
                      // @ts-ignore
                      chalk`{red Unknown error!}\n` + formatError(event)
                    );
                  }
                  break;

                default:
                  watcher.close();
                  // @ts-ignore
                  if (event.error) {
                    // @ts-ignore
                    reject(event.error);
                  } else {
                    reject(new Error(chalk`{red Unknown unrecoverable error!}\n` + formatError(event)));
                  }

                  break;
              }
            });
          })
      )
      .then(async (validatedConfig) => {
        const { mode } = validatedConfig;

        if (mode !== 'build') {
          return validatedConfig;
        }

        const { source, destination, globals } = validatedConfig;

        const bundle = await rollup.rollup(getInputOptions(validatedConfig));
        return bundle.write(getOutputOptions(source, destination, globals));
      })
      .then(() => {
        observer.complete();
      })
      .catch((error) => {
        observer.error(error);
      });
  });
};
