import { parseArgs } from 'node:util';
import { createConsoleLogger } from '../../packages/angular_devkit/core/node';
import colors from 'ansi-colors';
import glob from 'fast-glob';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { getGlobalVariable, setGlobalVariable } from './e2e/utils/env';
import { gitClean } from './e2e/utils/git';
import { createNpmRegistry } from './e2e/utils/registry';
import { launchTestProcess } from './e2e/utils/process';
import { delimiter, dirname, join } from 'node:path';
import { findFreePort } from './e2e/utils/network';
import { extractFile } from './e2e/utils/tar';
import { realpathSync } from 'node:fs';
import { PkgInfo } from './e2e/utils/packages';
import { rm } from 'node:fs/promises';

Error.stackTraceLimit = Infinity;

// tslint:disable:no-global-tslint-disable no-console

/**
 * Here's a short description of those flags:
 *   --debug           If a test fails, block the thread so the temporary directory isn't deleted.
 *   --noproject       Skip creating a project or using one.
 *   --noglobal        Skip linking your local @angular/cli directory. Can save a few seconds.
 *   --nosilent        Never silence ng commands.
 *   --ng-tag=TAG      Use a specific tag for build snapshots. Similar to ng-snapshots but point to a
 *                     tag instead of using the latest `main`.
 *   --ng-snapshots    Install angular snapshot builds in the test project.
 *   --glob            Run tests matching this glob pattern (relative to tests/e2e/).
 *   --ignore          Ignore tests matching this glob pattern.
 *   --reuse=/path     Use a path instead of create a new project. That project should have been
 *                     created, and npm installed. Ideally you want a project created by a previous
 *                     run of e2e.
 *   --nb-shards       Total number of shards that this is part of. Default is 2 if --shard is
 *                     passed in.
 *   --shard           Index of this processes' shard.
 *   --tmpdir=path     Override temporary directory to use for new projects.
 *   --package-manager Package manager to use.
 *   --package=path    An npm package to be published before running tests
 *
 * If unnamed flags are passed in, the list of tests will be filtered to include only those passed.
 */
const parsed = parseArgs({
  allowPositionals: true,
  options: {
    'debug': { type: 'boolean', default: !!process.env.BUILD_WORKSPACE_DIRECTORY },
    'esbuild': { type: 'boolean' },
    'glob': { type: 'string', default: 'tests/**/*.js' },
    'ignore': { type: 'string', multiple: true },
    'ng-snapshots': { type: 'boolean' },
    'ng-tag': { type: 'string' },
    'ng-version': { type: 'string' },
    'noglobal': { type: 'boolean' },
    'noproject': { type: 'boolean' },
    'nosilent': { type: 'boolean' },
    'package': { type: 'string', multiple: true, default: ['./dist/_*.tgz'] },
    'package-manager': { type: 'string', default: 'npm' },
    'reuse': { type: 'string' },
    'tmpdir': { type: 'string' },
    'verbose': { type: 'boolean' },

    'nb-shards': { type: 'string' },
    'shard': { type: 'string' },
  },
});

const argv = {
  ...parsed.values,
  _: parsed.positionals,
  'nb-shards':
    parsed.values['nb-shards'] ??
    (Number(process.env.E2E_SHARD_TOTAL ?? 1) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) || 1),
  shard:
    parsed.values.shard ??
    (process.env.E2E_SHARD_INDEX === undefined && process.env.TEST_SHARD_INDEX === undefined
      ? undefined
      : Number(process.env.E2E_SHARD_INDEX ?? 0) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) +
        Number(process.env.TEST_SHARD_INDEX ?? 0)),
};

// Indicate sharding support for Bazel.
if (process.env['TEST_SHARD_STATUS_FILE']) {
  fs.writeFileSync(process.env['TEST_SHARD_STATUS_FILE'], '', 'utf8');
}

/**
 * Set the error code of the process to 255.  This is to ensure that if something forces node
 * to exit without finishing properly, the error code will be 255. Right now that code is not used.
 *
 * - 1 When tests succeed we already call `process.exit(0)`, so this doesn't change any correct
 * behaviour.
 *
 * One such case that would force node <= v6 to exit with code 0, is a Promise that doesn't resolve.
 */
process.exitCode = 255;

/**
 * Mark this process as the main e2e_runner
 */
process.env.LEGACY_CLI_RUNNER = '1';

/**
 * Add external git toolchain onto PATH
 */
if (process.env.GIT_BIN) {
  process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.GIT_BIN!);
}

/**
 * Add external browser toolchains onto PATH
 */
if (process.env.CHROME_BIN) {
  process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.CHROME_BIN!);
}

const logger = createConsoleLogger(argv.verbose, process.stdout, process.stderr, {
  info: (s) => s,
  debug: (s) => s,
  warn: (s) => colors.bold.yellow(s),
  error: (s) => colors.bold.red(s),
  fatal: (s) => colors.bold.red(s),
});

const logStack = [logger];
function lastLogger() {
  return logStack[logStack.length - 1];
}

// Under bazel the compiled file (.js) and types (.d.ts) are available.
const SRC_FILE_EXT_RE = /\.js$/;
const testGlob = (process.env.TESTBRIDGE_TEST_ONLY ?? argv.glob).replace(/\.ts$/, '.js');

const e2eRoot = path.join(__dirname, 'e2e');
const allSetups = glob.sync(`setup/**/*.js`, { cwd: e2eRoot }).sort();
const allInitializers = glob.sync(`initialize/**/*.js`, { cwd: e2eRoot }).sort();

const allTests = glob
  .sync(testGlob, { cwd: e2eRoot, ignore: argv.ignore })
  // Replace windows slashes.
  .map((name) => name.replace(/\\/g, '/'))
  .filter((name) => {
    if (name.endsWith('/setup.js')) {
      return false;
    }
    if (!SRC_FILE_EXT_RE.test(name)) {
      return false;
    }

    // The below is to exclude specific tests that are not intented to run for the current package manager.
    // This is also important as without the trickery the tests that take the longest ex: update.ts (2.5mins)
    // will be executed on the same shard.
    const fileName = path.basename(name);
    if (
      (fileName.startsWith('yarn-') && argv['package-manager'] !== 'yarn') ||
      (fileName.startsWith('npm-') && argv['package-manager'] !== 'npm')
    ) {
      return false;
    }

    return true;
  })
  .sort();

const shardId = argv['shard'] !== undefined ? Number(argv['shard']) : null;
const nbShards = shardId === null ? 1 : Number(argv['nb-shards']);
const tests = allTests.filter((name) => {
  // Check for naming tests on command line.
  if (argv._.length == 0) {
    return true;
  }

  return argv._.some((argName) => {
    return (
      path.join(process.cwd(), argName + '') == path.join(__dirname, 'e2e', name) ||
      argName == name ||
      argName == name.replace(SRC_FILE_EXT_RE, '')
    );
  });
});

console.log(`Running with shard configuration:`);
console.log(`Total shards: ${nbShards}, current shard: ${shardId}`);

// Remove tests that are not part of this shard.
const testsToRun = tests.filter((name, i) => shardId === null || i % nbShards == shardId);

if (testsToRun.length === 0) {
  if (shardId !== null && tests.length <= shardId) {
    console.log(`No tests to run on shard ${shardId}, exiting`);
    console.log(`Without sharding, there were ${tests.length} tests found.`);
    process.exit(0);
  } else {
    console.log(`No tests would be ran, aborting.`);
    process.exit(1);
  }
}

if (shardId !== null) {
  console.log(`Running shard ${shardId} of ${nbShards}`);
}

/**
 * Load all the files from the e2e, filter and sort them and build a promise of their default
 * export.
 */
if (testsToRun.length == allTests.length) {
  console.log(`Running ${testsToRun.length} tests`);
} else {
  console.log(`Running ${testsToRun.length} tests (${allTests.length} total)`);
}

console.log(['Tests:', ...testsToRun].join('\n '));

setGlobalVariable('argv', argv);
setGlobalVariable('package-manager', argv['package-manager']);

// Use the chrome supplied by bazel or the puppeteer chrome and webdriver-manager driver outside.
// This is needed by karma-chrome-launcher, protractor etc.
// https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer
//
// Resolve from relative paths to absolute paths within the bazel runfiles tree
// so subprocesses spawned in a different working directory can still find them.
process.env.CHROME_BIN = path.resolve(process.env.CHROME_BIN!);
process.env.CHROME_PATH = path.resolve(process.env.CHROME_PATH!);
process.env.CHROMEDRIVER_BIN = path.resolve(process.env.CHROMEDRIVER_BIN!);

Promise.all([findFreePort(), findFreePort(), findPackageTars()])
  .then(async ([httpPort, httpsPort, packageTars]) => {
    setGlobalVariable('package-registry', 'http://localhost:' + httpPort);
    setGlobalVariable('package-secure-registry', 'http://localhost:' + httpsPort);
    setGlobalVariable('package-tars', packageTars);

    // NPM registries for the lifetime of the test execution
    const registryProcess = await createNpmRegistry(httpPort, httpPort);
    const secureRegistryProcess = await createNpmRegistry(httpPort, httpsPort, true);

    try {
      await runSteps(runSetup, allSetups, 'setup');
      await runSteps(runInitializer, allInitializers, 'initializer');
      await runSteps(runTest, testsToRun, 'test');

      if (shardId !== null) {
        console.log(colors.green(`Done shard ${shardId} of ${nbShards}.`));
      } else {
        console.log(colors.green('Done.'));
      }

      process.exitCode = 0;
    } catch (err) {
      if (err instanceof Error) {
        console.log('\n');
        console.error(colors.red(err.message));
        if (err.stack) {
          console.error(colors.red(err.stack));
        }
      } else {
        console.error(colors.red(String(err)));
      }

      if (argv.debug) {
        console.log(`Current Directory: ${process.cwd()}`);
        console.log('Will loop forever while you debug... CTRL-C to quit.');

        // Wait forever until user explicitly cancels.
        await new Promise(() => {});
      }

      process.exitCode = 1;
    } finally {
      registryProcess.kill();
      secureRegistryProcess.kill();
    }
  })
  .catch((err) => {
    console.error(colors.red(`Unkown Error: ${err}`));
    process.exitCode = 1;
  });

async function runSteps(
  run: (name: string) => Promise<void> | void,
  steps: string[],
  type: 'setup' | 'test' | 'initializer',
) {
  const capsType = type[0].toUpperCase() + type.slice(1);

  for (const [stepIndex, relativeName] of steps.entries()) {
    // Make sure this is a windows compatible path.
    let absoluteName = path.join(e2eRoot, relativeName).replace(SRC_FILE_EXT_RE, '');
    if (/^win/.test(process.platform)) {
      absoluteName = absoluteName.replace(/\\/g, path.posix.sep);
    }

    const name = relativeName.replace(SRC_FILE_EXT_RE, '');
    const start = Date.now();

    printHeader(name, stepIndex, steps.length, type);

    // Run the test function with the current file on the logStack.
    logStack.push(lastLogger().createChild(absoluteName));
    try {
      await run(absoluteName);
    } catch (e) {
      console.log('\n');
      console.error(colors.red(`${capsType} "${name}" failed...`));

      throw e;
    } finally {
      logStack.pop();
    }

    console.log('----');
    printFooter(name, type, start);
  }
}

function runSetup(absoluteName: string): Promise<void> {
  const module = require(absoluteName);

  return (typeof module === 'function' ? module : module.default)();
}

/**
 * Run a file from the projects root directory in a subprocess via launchTestProcess().
 */
function runInitializer(absoluteName: string): Promise<void> {
  process.chdir(getGlobalVariable('projects-root'));

  return launchTestProcess(absoluteName);
}

/**
 * Run a file from the main 'test-project' directory in a subprocess via launchTestProcess().
 */
async function runTest(absoluteName: string): Promise<void> {
  process.chdir(join(getGlobalVariable('projects-root'), 'test-project'));

  await launchTestProcess(absoluteName);
  await cleanTestProject();
}

async function cleanTestProject() {
  await gitClean();

  const testProject = join(getGlobalVariable('projects-root'), 'test-project');

  // Note: Dist directory is not cleared between tests, as `git clean`
  // doesn't delete it.
  await rm(join(testProject, 'dist/'), { recursive: true, force: true });
}

function printHeader(
  testName: string,
  testIndex: number,
  count: number,
  type: 'setup' | 'initializer' | 'test',
) {
  const text = `${testIndex + 1} of ${count}`;
  const fullIndex = testIndex * nbShards + (shardId ?? 0) + 1;
  const shard =
    shardId === null || type !== 'test'
      ? ''
      : colors.yellow(` [${shardId}:${nbShards}]` + colors.bold(` (${fullIndex}/${tests.length})`));
  console.log(
    colors.green(
      `Running ${type} "${colors.bold.blue(testName)}" (${colors.bold.white(text)}${shard})...`,
    ),
  );
}

function printFooter(testName: string, type: 'setup' | 'initializer' | 'test', startTime: number) {
  const capsType = type[0].toUpperCase() + type.slice(1);

  // Round to hundredth of a second.
  const t = Math.round((Date.now() - startTime) / 10) / 100;
  console.log(
    colors.green(`${capsType} "${colors.bold.blue(testName)}" took `) +
      colors.bold.blue('' + t) +
      colors.green('s...'),
  );
  console.log('');
}

// Collect the packages passed as arguments and return as {package-name => pkg-path}
async function findPackageTars(): Promise<{ [pkg: string]: PkgInfo }> {
  const pkgs: string[] = (getGlobalVariable('argv').package as string[]).flatMap((p) =>
    glob.sync(p),
  );

  const pkgJsons = await Promise.all(
    pkgs.map((pkg) => realpathSync(pkg)).map((pkg) => extractFile(pkg, './package.json')),
  );

  return pkgs.reduce(
    (all, pkg, i) => {
      const json = pkgJsons[i].toString('utf8');
      const { name, version } = JSON.parse(json) as { name: string; version: string };
      if (!name) {
        throw new Error(`Package ${pkg} - package.json name/version not found`);
      }

      all[name] = { path: realpathSync(pkg), name, version };
      return all;
    },
    {} as { [pkg: string]: PkgInfo },
  );
}
