import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import url from 'url';
import chalk from 'chalk';
import enquirer from 'enquirer';
import { execa } from 'execa';
import { glob } from 'glob';
import Listr from 'listr';
import UpdateRenderer from 'listr-update-renderer';
import VerboseRenderer from 'listr-verbose-renderer';
import { getInstallArgs, projectDir } from '../helpers/install.js';
import { readComposerJson, readPackageJson } from '../helpers/json.js';
import { coerceConcurrency } from '../helpers/normalizeArgv.js';
import PrefixStream from '../helpers/prefix-stream.js';
import { allProjects } from '../helpers/projectHelpers.js';
import promptForProject from '../helpers/promptForProject.js';

export const command = 'test [test] [project...]';
export const describe = 'Run tests on a monorepo project';

/**
 * Options definition for the test subcommand.
 *
 * @param {object} yargs - The Yargs dependency.
 * @return {object} Yargs with the test commands defined.
 */
export async function builder( yargs ) {
	return yargs
		.positional( 'test', {
			describe:
				'Test to run. Typically "js", "php", "coverage", or "typecheck", but available tests depend on the project.',
			type: 'string',
		} )
		.positional( 'project', {
			describe: 'Project in the form of type/name, e.g. plugins/jetpack.',
			type: 'string',
		} )
		.option( 'all', {
			alias: 'a',
			type: 'boolean',
			description: 'Run tests on everything.',
		} )
		.option( 'no-use-uncommitted-composer-lock', {
			type: 'boolean',
			description: "Don't use uncommitted composer.lock files.",
		} )
		.option( 'concurrency', {
			type: 'number',
			description: 'Maximum number of test tasks to run at once. Ignored with `--verbose`.',
			default: os.cpus().length,
			coerce: coerceConcurrency,
		} )
		.option( 'no-html', {
			type: 'boolean',
			description: 'For coverage tests, do not generate HTML reports.',
		} )
		.option( 'html-dir', {
			type: 'string',
			description:
				// prettier-ignore
				`For coverage tests, write HTML reports to this path instead of creating a new directory in ${ os.tmpdir() }.`,
		} )
		.option( 'artifacts-dir', {
			type: 'string',
			description: 'Directory for any artifacts generated by the tests.',
		} )
		.check( argv => {
			if ( argv.v ) {
				argv.concurrency = false;
			}
			return true;
		} );
}

/**
 * Entry point for the CLI.
 *
 * @param {object} argv - The argv for the command line.
 */
export async function handler( argv ) {
	const opts = {};
	let rmartifacts = false,
		rmhtmldir = false;

	if ( argv.artifactsDir ) {
		opts.ARTIFACTS_DIR = path.resolve( argv.artifactsDir );
		await fs.mkdir( argv.artifactsDir, { recursive: true } );
	} else {
		opts.ARTIFACTS_DIR = await fs.mkdtemp( path.join( os.tmpdir(), 'jetpack-tests-' ) );
		rmartifacts = true;
	}

	if ( argv.test === 'coverage' && argv.html !== false ) {
		if ( argv.htmlDir ) {
			opts.HTML_DIR = path.resolve( argv.htmlDir );
			await fs.mkdir( argv.htmlDir, { recursive: true } );
		} else {
			opts.HTML_DIR = await fs.mkdtemp( path.join( os.tmpdir(), 'jetpack-coverage-' ) );
			rmhtmldir = true;
		}
	}

	try {
		await runTests( argv, opts );
	} finally {
		if ( rmartifacts ) {
			await fs.rm( opts.ARTIFACTS_DIR, { recursive: true } );
		}

		if ( opts.PHP_HTML_INDEX ) {
			console.log( `PHP coverage report is at ${ opts.PHP_HTML_INDEX }` );
			rmhtmldir = false;
		}
		if ( opts.JS_HTML_INDEX ) {
			console.log( `JS coverage report is at ${ opts.JS_HTML_INDEX }` );
			rmhtmldir = false;
		}

		if ( rmhtmldir ) {
			console.log( 'No coverage was generated' );
			await fs.rm( opts.HTML_DIR, { recursive: true } );
		}
	}
}

/**
 * Run the tests.
 *
 * @param {object} argv - The argv for the command line.
 * @param {object} opts - additional options.
 */
export async function runTests( argv, opts ) {
	const basedir = process.cwd();
	const genv = {};

	if ( argv.project.length === 1 && argv.project[ 0 ] !== 'monorepo' ) {
		if ( argv.project[ 0 ].indexOf( '/' ) < 0 ) {
			argv.type = argv.project[ 0 ];
			argv.project = [];
		}
	}

	if ( argv.all ) {
		argv.project = allProjects();
		argv.project.unshift( 'monorepo' );
	}

	if ( argv.project.length === 0 ) {
		argv.project = '';
		argv = await promptForProject( argv );
		argv.project = [ argv.project ];

		if ( ! argv.test ) {
			argv = await promptForTest( argv );
		}

		const response = await enquirer.prompt( [
			{
				type: 'confirm',
				name: 'verbose',
				message: 'See output from the test runner?',
				initial: argv.test !== 'coverage',
			},
		] );
		argv.v = response.verbose;
	} else if ( ! argv.v ) {
		console.log(
			chalk.green(
				'Only a summary of test results will be output! To see full details, run with --verbose'
			)
		);
	}

	if ( argv.test === 'coverage' ) {
		try {
			await execa(
				'php',
				[
					'-r',
					'exit( extension_loaded( "pcov" ) ? 42 : ( extension_loaded( "xdebug" ) ? 41 : 40 ) );',
				],
				{
					stdio: 'ignore',
				}
			);
		} catch ( e ) {
			if ( e.exitCode === 40 ) {
				console.error(
					chalk.red(
						'To generate PHP coverage, either the pcov or xdebug extensions must be installed.'
					)
				);
				console.error(
					chalk.red(
						'You should install pcov; see the Code coverage section in docs/monorepo.md for details.'
					)
				);
				const response = await enquirer.prompt( [
					{
						type: 'confirm',
						name: 'ok',
						message: 'Continue without PHP coverage?',
						initial: true,
					},
				] );
				if ( ! response.ok ) {
					process.exit( 1 );
				}
			} else if ( e.exitCode === 41 ) {
				genv.XDEBUG_MODE = 'coverage';
			} else if ( e.exitCode !== 42 ) {
				throw e;
			}
		}
	}
	genv.FORCE_COLOR = argv.v ? chalk.level : 0;
	genv.GITHUB_WORKSPACE = basedir;

	argv.useUncommittedComposerLock = argv.useUncommittedComposerLock !== false;

	const tasks = [];
	const projects = new Set( argv.project );

	// Avoid a node warning about too many event listeners.
	if ( argv.v ) {
		process.stdout.setMaxListeners( projects.size + 10 );
		process.stderr.setMaxListeners( projects.size + 10 );
	}

	const promises = [];
	for ( const project of projects ) {
		const cwd = projectDir( project );

		// Does the project even exist?
		if ( ( await fs.access( path.join( cwd, 'composer.json' ) ).catch( () => false ) ) === false ) {
			console.error( chalk.red( `Project ${ project } does not exist!` ) );
			continue;
		}

		let sstdout = process.stdout,
			sstderr = process.stderr;
		if ( argv.v && argv.concurrency > 1 && projects.size > 1 ) {
			const streamArgs = { prefix: project, time: true };
			sstdout = new PrefixStream( streamArgs );
			sstderr = new PrefixStream( streamArgs );
			sstdout.pipe( process.stdout, { end: false } );
			sstderr.pipe( process.stderr, { end: false } );
		}

		const { promise, resolve } = Promise.withResolvers();
		promises.push( promise );

		let skip;
		if ( argv.test === 'typecheck' ) {
			skip = async () => {
				const packageJson = await readPackageJson( project );
				if ( ! packageJson?.scripts?.typecheck ) {
					resolve();
					return `No typecheck script in package.json`;
				}
				return false;
			};
		} else {
			skip = async () => {
				const composerJson = await readComposerJson( project );
				if ( ! composerJson?.scripts?.[ `test-${ argv.test }` ] ) {
					resolve();
					return `No test-${ argv.test } script in composer.json`;
				}
				return false;
			};
		}

		// Composer install.
		tasks.push( {
			title: `Checking ${ project }`,
			skip,
			task: async () => {
				const subtasks = [];

				const args = await getInstallArgs( project, 'composer', argv );
				subtasks.push( {
					title: 'Installing composer dependencies',
					task: async () => {
						const proc = execa( 'composer', args, {
							cwd,
							stdio: [ 'ignore', argv.v ? 'pipe' : 'ignore', argv.v ? 'pipe' : 'ignore' ],
						} );
						if ( argv.v ) {
							proc.stdout.pipe( sstdout, { end: false } );
							proc.stderr.pipe( sstderr, { end: false } );
						}
						await proc;
					},
				} );

				subtasks.push( {
					title: 'Running tests',
					task: async () => {
						const env = { ...genv };
						env.ARTIFACTS_DIR = path.join( opts.ARTIFACTS_DIR, project );
						await fs.mkdir( env.ARTIFACTS_DIR, { recursive: true } );
						if ( argv.test === 'coverage' ) {
							env.COVERAGE_DIR = path.join( opts.ARTIFACTS_DIR, 'coverage', project );
							await fs.mkdir( env.COVERAGE_DIR, { recursive: true } );
						}

						let cmd;
						if ( argv.test === 'typecheck' ) {
							if ( argv.v ) {
								sstdout.write( `Executing pnpm run typecheck\n` );
							}
							cmd = [ 'pnpm', [ 'run', 'typecheck' ] ];
						} else {
							if ( argv.v ) {
								sstdout.write( `Executing composer run test-${ argv.test }\n` );
							}
							cmd = [ 'composer', [ 'run', '--timeout=0', `test-${ argv.test }` ] ];
						}
						cmd[ 2 ] = {
							cwd,
							stdio: [ 'ignore', argv.v ? 'pipe' : 'ignore', argv.v ? 'pipe' : 'ignore' ],
							env,
						};
						const proc = execa( ...cmd );
						if ( argv.v ) {
							proc.stdout.pipe( sstdout, { end: false } );
							proc.stderr.pipe( sstderr, { end: false } );
						}
						await proc;
					},
				} );

				const ret = new Listr( subtasks, {
					concurrent: false,
					renderer: argv.v ? VerboseRenderer : UpdateRenderer,
					exitOnError: true,
				} );

				// Hack the sub-Listr to resolve our promise when it finishes.
				const oldrun = ret.run;
				ret.run = async function ( ...runargs ) {
					try {
						return await oldrun.apply( this, runargs );
					} finally {
						resolve();
					}
				};

				return ret;
			},
		} );
	}

	if ( argv.test === 'coverage' && argv.html !== false ) {
		const scriptDir = path.join( basedir, '.github/files/coverage-munger' );
		const coverageDir = path.join( opts.ARTIFACTS_DIR, 'coverage' );
		let phpFiles, jsFiles;

		tasks.push( {
			title: 'Generate HTML coverage reports',
			skip: async () => {
				await Promise.all( promises );

				phpFiles = await glob( '**/*.cov', { cwd: coverageDir } );
				jsFiles = await glob( '**/*.json', { cwd: coverageDir, absolute: true } );

				return phpFiles.length === 0 && jsFiles.length === 0 ? 'No coverage data generated' : false;
			},
			task: async () => {
				let sstdout = process.stdout,
					sstderr = process.stderr;
				if ( argv.v && argv.concurrency > 1 && projects.size > 1 ) {
					const streamArgs = { prefix: 'html', time: true };
					sstdout = new PrefixStream( streamArgs );
					sstderr = new PrefixStream( streamArgs );
					sstdout.pipe( process.stdout, { end: false } );
					sstderr.pipe( process.stderr, { end: false } );
				}

				const subtasks = [];

				if ( phpFiles.length > 0 ) {
					subtasks.push( {
						title: 'Generate PHP coverage report',
						task: async () => {
							const proc1 = execa( 'composer', [ 'update' ], {
								cwd: scriptDir,
								stdio: [ 'ignore', argv.v ? 'pipe' : 'ignore', argv.v ? 'pipe' : 'ignore' ],
							} );
							if ( argv.v ) {
								proc1.stdout.pipe( sstdout, { end: false } );
								proc1.stderr.pipe( sstderr, { end: false } );
							}
							await proc1;

							const dir = path.join( opts.HTML_DIR, 'php' );
							if ( argv.v ) {
								sstdout.write(
									// prettier-ignore
									`Executing ${ path.join( scriptDir, 'vendor/bin/phpcov' ) } merge --html ${ dir } ${ coverageDir }\n`
								);
							}
							const proc2 = execa(
								path.join( scriptDir, 'vendor/bin/phpcov' ),
								[ 'merge', '--html', dir, coverageDir ],
								{
									stdio: [ 'ignore', argv.v ? 'pipe' : 'ignore', argv.v ? 'pipe' : 'ignore' ],
								}
							);
							if ( argv.v ) {
								proc2.stdout.pipe( sstdout, { end: false } );
								proc2.stderr.pipe( sstderr, { end: false } );
							}
							await proc2;
							opts.PHP_HTML_INDEX = url.pathToFileURL( path.join( dir, 'index.html' ) );
						},
					} );
				}

				if ( jsFiles.length > 0 ) {
					subtasks.push( {
						title: 'Generate JS coverage report',
						task: async () => {
							const tmpdir = path.join( opts.ARTIFACTS_DIR, 'tmp' );
							await fs.mkdir( tmpdir, { recursive: true } );

							try {
								// nyc can merge files itself, but needs them all in one directory (not in subdirs).
								for ( let i = 0; i < jsFiles.length; i++ ) {
									await fs.cp( jsFiles[ i ], path.join( tmpdir, `${ i + 10000 }.json` ) );
								}

								const dir = path.join( opts.HTML_DIR, 'js' );
								if ( argv.v ) {
									sstdout.write(
										// prettier-ignore
										`Executing ${ path.join( scriptDir, 'node_modules/.bin/nyc' ) } report --no-exclude-after-remap --report-dir=${ dir } --temp-dir=${ tmpdir } --reporter=html-spa\n`
									);
								}
								const proc = execa(
									path.join( scriptDir, 'node_modules/.bin/nyc' ),
									[
										'report',
										'--no-exclude-after-remap',
										`--report-dir=${ dir }`,
										`--temp-dir=${ tmpdir }`,
										'--reporter=html-spa',
									],
									{
										stdio: [ 'ignore', argv.v ? 'pipe' : 'ignore', argv.v ? 'pipe' : 'ignore' ],
									}
								);
								if ( argv.v ) {
									proc.stdout.pipe( sstdout, { end: false } );
									proc.stderr.pipe( sstderr, { end: false } );
								}
								await proc;
								opts.JS_HTML_INDEX = url.pathToFileURL( path.join( dir, 'index.html' ) );
							} finally {
								await fs.rm( tmpdir, { recursive: true } ).catch( () => {} );
							}
						},
					} );
				}

				return new Listr( subtasks, {
					concurrent: argv.concurrency,
					renderer: argv.v ? VerboseRenderer : UpdateRenderer,
					exitOnError: false,
				} );
			},
		} );
	}

	const listr = new Listr( tasks, {
		concurrent: argv.concurrency,
		renderer: argv.v ? VerboseRenderer : UpdateRenderer,
		exitOnError: false,
	} );
	await listr.run().catch( () => {
		process.exitCode = 1;
	} );
}

/**
 * Prompts for the test we want to run.
 *
 * @param {argv} argv - the arguments passed.
 * @return {object} argv
 */
export async function promptForTest( argv ) {
	const project = argv.project[ 0 ];
	const composerJson = await readComposerJson( project );
	const packageJson = await readPackageJson( project );
	const tests = Object.keys( composerJson.scripts ?? {} )
		.filter( test => test.startsWith( 'test-' ) )
		.map( test => test.substring( 5 ) );
	if ( packageJson?.scripts?.typecheck ) {
		tests.push( 'typecheck' );
	}
	if ( tests.length === 0 ) {
		console.log( chalk.red( `No tests found in ${ project }'s composer.json file!` ) );
		process.exit( 1 );
	}
	const response = await enquirer.prompt( [
		{
			type: 'select',
			name: 'test',
			message: 'What test are you trying to run?',
			choices: tests,
		},
	] );
	argv.test = response.test;
	return argv;
}
