#!/usr/bin/env node

const chalk = require('chalk');
const ListrMultilineRenderer = require('listr-multiline-renderer');
const Tasks = require('listr');
const path = require('path');

/**
 * Throw an error if unhandled Promise rejection happens.
 */
process.on('unhandledRejection', (error) => {
  throw error;
});

const { readProjectConfig, getProjectConfig } = require('./utils/config');

/** @type {string | undefined} */
let packagesDir = path.dirname(__dirname);

if (path.basename(packagesDir) !== 'packages') {
  packagesDir = undefined;
}

/**
 * All available `@runforest` packages and their configurations.
 *
 * @const
 * @type {Record<string, { package: string; parallel: boolean; }>}
 */
const configs = {
  sass: {
    package: '@runforest/sass',
    parallel: true,
  },
  less: {
    package: '@runforest/less',
    parallel: true,
  },
  js: {
    package: '@runforest/js',
    parallel: true,
  },
  i18n: {
    package: '@runforest/i18n',
    parallel: true,
  },
  clean: {
    package: '@runforest/fs/clean',
    parallel: false,
  },
  copy: {
    package: '@runforest/fs/copy',
    parallel: true,
  },
  wordpress: {
    package: '@runforest/wordpress',
    parallel: false,
  },
  engineyard: {
    package: '@runforest/engineyard',
    parallel: false,
  },
  html: {
    package: '@runforest/html',
    parallel: true,
  },
  svg: {
    package: '@runforest/svg',
    parallel: true,
  },
  gitdeploy: {
    package: '@runforest/gitdeploy',
    parallel: false,
  },
  laravel: {
    package: '@runforest/laravel',
    parallel: false,
  },
  shipit: {
    package: '@runforest/shipit',
    parallel: false,
  },
  encrypt: {
    package: '@runforest/encrypt',
    parallel: false,
  },
  s3deploy: {
    package: '@runforest/s3deploy',
    parallel: false,
  },
  devcert: {
    package: '@runforest/devcert',
    parallel: true,
  },
};

const taskInvocationRegex = new RegExp(
  // Start
  '^' +
    // 1st capturing group - task identifier (e.g. html, wordpress, assets, ...)
    '([a-z0-9]+)' +
    // optional non-capturing group that starts with `:` character
    '(?:\\:' +
    // 2nd capturing group - destination context (e.g. html, wordpress, default, ...)
    '([a-z0-9]+)' +
    ')?' +
    // optional non-capturing group that starts with `@` character
    '(?:@' +
    // 3rd capturing group - task mode (build or watch)
    '(build|watch)' +
    ')?' +
    // End
    '$',
  // Incase-sensitive matching
  'gi'
);

/**
 * @typedef TaskInvocation
 * @type {Object}
 * @property {string} task    Identifier.
 * @property {string} context Context.
 * @property {string} mode    Mode.
 */

/**
 * Parses task invocation string.
 *
 * @param {string} taskInvocation Task invocation string.
 * @return {TaskInvocation}
 */
function parseTaskInvocation(taskInvocation) {
  const matches = taskInvocationRegex.exec(taskInvocation);

  if (!matches) {
    throw new Error(`Invalid task invocation "${taskInvocation}"`);
  }

  taskInvocationRegex.lastIndex = 0;

  return {
    task: matches[1],
    context: matches[2] || '',
    mode: matches[3] || 'build',
  };
}

/**
 * If the task is labeled as a local task, delegate it to the local task
 * executable bin/build folder
 * @param {string} rawTaskKey
 * @param {string} context
 * @param {string} mode
 */
function runLocalTask(rawTaskKey, context, mode) {
  // TODO: this needs more attention because it doesn't make sense to use context for file name.
  // We should update task invocation regex to address this case.
  return new Tasks([
    {
      title: rawTaskKey,
      task: () =>
        // eslint-disable-next-line import/no-dynamic-require, global-require
        require(`${process.cwd()}/bin/build/${context}`)({
          mode,
        }),
    },
  ]);
}

/**
 * Run task from one of the packages configured above.
 *
 * @param  {string} rawTaskKey Task name defined in the project's config.
 * @return {(Promise<void>|Tasks)} If task is not defined or package responsible for it is not installed then `Promise.reject` is returned. If task is defined but doesn't have any configuration then `Promise.resolve` is returned. Otherwise `Listr` instance is returned.
 */
function runTask(rawTaskKey) {
  const projectConfig = getProjectConfig();
  const { task: taskKey, context, mode } = parseTaskInvocation(rawTaskKey);

  if (rawTaskKey.indexOf('local') > -1) {
    return runLocalTask(rawTaskKey, context, mode);
  }

  if (Object.keys(configs).indexOf(taskKey) < 0) {
    return Promise.reject(new Error(`Config ${taskKey} doesn't exist in this version of @runforest/run!`));
  }

  /** @type {string} */
  let packageRequireId;

  try {
    packageRequireId = require.resolve(configs[taskKey].package);
  } catch (error) {
    try {
      if (!packagesDir) {
        throw error;
      }

      packageRequireId = require.resolve(
        path.resolve(packagesDir, configs[taskKey].package.replace(/^@runforest\//, ''))
      );
    } catch (requireLernaPackageError) {
      return Promise.reject(
        new Error(
          `Required package ${configs[taskKey].package} is not installed. ` +
            `Please install it via npm install ${configs[taskKey].package}`
        )
      );
    }
  }

  if (!context) {
    return Promise.reject(new Error('Task should be definied in task:key format.'));
  }

  if (!projectConfig[taskKey] || !projectConfig[taskKey][context]) {
    return Promise.reject(new Error(`${taskKey}:${context} task is not configured!`));
  }

  if (!projectConfig[taskKey][context].length) {
    return Promise.resolve();
  }

  return new Tasks(
    projectConfig[taskKey][context].map((/** @type {any} */ taskConfig) => ({
      title: rawTaskKey,
      // eslint-disable-next-line import/no-dynamic-require, global-require
      task: () => require(packageRequireId)(Object.assign(taskConfig, { mode })),
    })),
    { concurrent: configs[taskKey].parallel }
  );
}

/**
 * @typedef {string|parallelJobs} job
 * @typedef {{parallel: job[]}} parallelJobs
 * @typedef {job[]} syncJobs
 */

/**
 * Walks through job definition and constructs parallels and sequences.
 *
 * @param {syncJobs|parallelJobs|string} group Job group definition
 * @return {Tasks|Promise<void>}
 */
function processJobGroup(group) {
  if (typeof group === 'string') {
    return runTask(group);
  }

  let parallel = false;
  /** @type {syncJobs} */
  let tasksList = [];

  if (!Array.isArray(group)) {
    if ('parallel' in group && typeof group.parallel === 'object' && Array.isArray(group.parallel)) {
      parallel = true;
      tasksList = group.parallel;
    }
  } else {
    tasksList = group;
  }

  const tasks = new Tasks([], { concurrent: parallel });

  tasksList.forEach((task) => {
    let title = '';

    if (typeof task === 'string') {
      title = task;
    }

    tasks.add({
      title,
      task: () => processJobGroup(task),
    });
  });

  return tasks;
}

// eslint-disable-next-line no-unused-expressions
require('yargs')
  .command(
    '*',
    false,
    () => {},
    (argv) => {
      let job = 'build';
      let renderer = ListrMultilineRenderer;

      if (argv._.length) {
        job = String(argv._[0]);
      }

      if (argv.verbose) {
        renderer = 'verbose';
      }

      const jobTitle = job
        .replace(/[^a-z0-9]+/i, ' ')
        .split(' ')
        .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
        .join(' ');

      const tasks = new Tasks(
        [
          {
            title: 'Read config',
            task: () =>
              readProjectConfig().then(() => {
                const config = getProjectConfig();

                if (!config.jobs || !config.jobs[job]) {
                  throw new Error(chalk`{red Job {bold ${job}}} ` + chalk`{red doesn't exist in project config.}`);
                }
              }),
          },
          {
            title: jobTitle,
            task() {
              const config = getProjectConfig();

              return processJobGroup(config.jobs[job]);
            },
          },
        ],
        {
          // @ts-ignore
          collapse: false,
          renderer,
        }
      );

      tasks.run().catch((error) => {
        // eslint-disable-next-line no-console
        console.log(chalk`\n\n{red ERRORS:}`);
        // eslint-disable-next-line no-console
        console.error(error);
        process.exitCode = 1;
      });
    }
  )
  .demandCommand(1, 'You need at least one command before moving on.')
  .boolean('verbose')
  .alias('v', 'verbose')
  .default('verbose', false)
  .help().argv;
