import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import util from "node:util";

import lint from "@commitlint/lint";
import load, { resolveFromSilent, resolveGlobalSilent } from "@commitlint/load";
import read from "@commitlint/read";
import type {
	Formatter,
	LintOptions,
	LintOutcome,
	ParserPreset,
	QualifiedConfig,
	UserConfig,
} from "@commitlint/types";
import type { Options } from "conventional-commits-parser";
import { x } from "tinyexec";
import yargs, { type Arguments } from "yargs";

import { CliFlags } from "./types.js";

import { CliError, ExitCode } from "./cli-error.js";

const require = createRequire(import.meta.url);

const __dirname = path.resolve(fileURLToPath(import.meta.url), "..");

const dynamicImport = async <T>(id: string): Promise<T> => {
	const imported = await import(
		path.isAbsolute(id) ? pathToFileURL(id).toString() : id
	);
	return ("default" in imported && imported.default) || imported;
};

const pkg: typeof import("../package.json") = require("../package.json");

const gitDefaultCommentChar = "#";

const cli = yargs(process.argv.slice(2))
	.options({
		color: {
			alias: "c",
			default: true,
			description: "toggle colored output",
			type: "boolean",
		},
		config: {
			alias: "g",
			description:
				"path to the config file; result code 9 if config is missing",
			type: "string",
		},
		"print-config": {
			choices: ["", "text", "json"],
			description: "print resolved config",
			type: "string",
		},
		cwd: {
			alias: "d",
			default: process.cwd(),
			defaultDescription: "(Working Directory)",
			description: "directory to execute in",
			type: "string",
		},
		edit: {
			alias: "e",
			description:
				"read last commit message from the specified file or fallbacks to ./.git/COMMIT_EDITMSG",
			type: "string",
		},
		env: {
			alias: "E",
			description:
				"check message in the file at path given by environment variable value",
			type: "string",
		},
		extends: {
			alias: "x",
			description: "array of shareable configurations to extend",
			type: "array",
		},
		"help-url": {
			alias: "H",
			type: "string",
			description: "help url in error message",
		},
		from: {
			alias: "f",
			description:
				"lower end of the commit range to lint; applies if edit=false",
			type: "string",
		},
		"from-last-tag": {
			description:
				"uses the last tag as the lower end of the commit range to lint; applies if edit=false and from is not set",
			type: "boolean",
		},
		"git-log-args": {
			description:
				"additional git log arguments as space separated string, example '--first-parent --cherry-pick'",
			type: "string",
		},
		last: {
			alias: "l",
			description: "just analyze the last commit; applies if edit=false",
			type: "boolean",
		},
		format: {
			alias: "o",
			description: "output format of the results",
			type: "string",
		},
		"parser-preset": {
			alias: "p",
			description:
				"configuration preset to use for conventional-commits-parser",
			type: "string",
		},
		quiet: {
			alias: "q",
			default: false,
			description: "toggle console output",
			type: "boolean",
		},
		to: {
			alias: "t",
			description:
				"upper end of the commit range to lint; applies if edit=false",
			type: "string",
		},
		verbose: {
			alias: "V",
			type: "boolean",
			description: "enable verbose output for reports without problems",
		},
		strict: {
			alias: "s",
			type: "boolean",
			description:
				"enable strict mode; result code 2 for warnings, 3 for errors",
		},
	})
	.version(
		"version",
		"display version information",
		`${pkg.name}@${pkg.version}`,
	)
	.alias("v", "version")
	.help("help")
	.alias("h", "help")
	.config(
		"options",
		"path to a JSON file or Common.js module containing CLI options",
		require,
	)
	.usage(`${pkg.name}@${pkg.version} - ${pkg.description}\n`)
	.usage(
		`[input] reads from stdin if --edit, --env, --from and --to are omitted`,
	)
	.strict();

/**
 * avoid description words to be divided in new lines when there is enough space
 * @see https://github.com/conventional-changelog/commitlint/pull/3850#discussion_r1472251234
 */
cli.wrap(cli.terminalWidth());

main(cli.argv).catch((err) => {
	setTimeout(() => {
		if (err.type === pkg.name) {
			process.exit(err.error_code);
		}
		throw err;
	}, 0);
});

async function stdin() {
	let result = "";

	if (process.stdin.isTTY) {
		return result;
	}

	process.stdin.setEncoding("utf8");

	for await (const chunk of process.stdin) {
		result += chunk;
	}

	return result;
}

type MainArgsObject = {
	[key in keyof Arguments<CliFlags>]: Arguments<CliFlags>[key];
};
type MainArgsPromise = Promise<MainArgsObject>;
type MainArgs = MainArgsObject | MainArgsPromise;

async function resolveArgs(args: MainArgs): Promise<MainArgsObject> {
	return typeof args.then === "function" ? await args : args;
}

async function main(args: MainArgs): Promise<void> {
	const options = await resolveArgs(args);
	if (typeof options.edit === "undefined") {
		options.edit = false;
	}

	const raw = options._;
	const flags = normalizeFlags(options);

	if (typeof options["print-config"] === "string") {
		const loaded = await load(getSeed(flags), {
			cwd: flags.cwd,
			file: flags.config,
		});

		switch (options["print-config"]) {
			case "json":
				console.log(JSON.stringify(loaded));
				return;

			case "text":
			default:
				console.log(util.inspect(loaded, false, null, options.color));
				return;
		}
	}

	const fromStdin = checkFromStdin(raw, flags);

	if (
		Object.hasOwn(flags, "last") &&
		(Object.hasOwn(flags, "from") || Object.hasOwn(flags, "to") || flags.edit)
	) {
		const err = new CliError(
			"Please use the --last flag alone. The --last flag should not be used with --to or --from or --edit.",
			pkg.name,
		);
		cli.showHelp("log");
		console.log(err.message);
		throw err;
	}

	const input = await (fromStdin
		? stdin()
		: read({
				to: flags.to,
				from: flags.from,
				fromLastTag: flags["from-last-tag"],
				last: flags.last,
				edit: flags.edit,
				cwd: flags.cwd,
				gitLogArgs: flags["git-log-args"],
			}));

	const messages = (Array.isArray(input) ? input : [input])
		.filter((message) => typeof message === "string")
		.filter((message) => message.trim() !== "")
		.filter(Boolean);

	if (messages.length === 0 && !checkFromRepository(flags)) {
		const err = new CliError(
			"[input] is required: supply via stdin, or --env or --edit or --last or --from and --to",
			pkg.name,
		);
		cli.showHelp("log");
		console.log(err.message);
		throw err;
	}

	const loaded = await load(getSeed(flags), {
		cwd: flags.cwd,
		file: flags.config,
	});
	const parserOpts = selectParserOpts(loaded.parserPreset);
	const opts: LintOptions & { parserOpts: Options } = {
		parserOpts: {},
		plugins: {},
		ignores: [],
		defaultIgnores: true,
	};
	if (parserOpts) {
		opts.parserOpts = parserOpts;
	}
	if (loaded.plugins) {
		opts.plugins = loaded.plugins;
	}
	if (loaded.ignores) {
		opts.ignores = loaded.ignores;
	}
	if (loaded.defaultIgnores === false) {
		opts.defaultIgnores = false;
	}
	const format = await loadFormatter(loaded, flags);

	// If reading from `.git/COMMIT_EDIT_MSG`, strip comments using
	// core.commentChar from git configuration, falling back to '#'.
	if (flags.edit) {
		const result = x("git", ["config", "core.commentChar"]);
		const output = await result;

		if (result.exitCode && result.exitCode > 1) {
			console.warn(
				"Could not determine core.commentChar git configuration",
				output.stderr,
			);
			opts.parserOpts.commentChar = gitDefaultCommentChar;
		} else {
			opts.parserOpts.commentChar =
				output.stdout.trim() || gitDefaultCommentChar;
		}
	}

	const results = await Promise.all(
		messages.map((message) => lint(message, loaded.rules, opts)),
	);

	let isRulesEmpty = false;
	if (Object.keys(loaded.rules).length === 0) {
		let input = "";

		if (results.length !== 0) {
			input = results[0].input;
		}

		results.splice(0, results.length, {
			valid: false,
			errors: [
				{
					level: 2,
					valid: false,
					name: "empty-rules",
					message: [
						"Please add rules to your `commitlint.config.js`",
						"    - Getting started guide: https://commitlint.js.org/guides/getting-started",
						"    - Example config: https://github.com/conventional-changelog/commitlint/blob/master/%40commitlint/config-conventional/src/index.ts",
					].join("\n"),
				},
			],
			warnings: [],
			input,
		});

		isRulesEmpty = true;
	}

	const report = results.reduce<{
		valid: boolean;
		errorCount: number;
		warningCount: number;
		results: LintOutcome[];
	}>(
		(info, result) => {
			info.valid = result.valid ? info.valid : false;
			info.errorCount += result.errors.length;
			info.warningCount += result.warnings.length;
			info.results.push(result);

			return info;
		},
		{
			valid: true,
			errorCount: 0,
			warningCount: 0,
			results: [],
		},
	);

	const helpUrl = flags["help-url"]?.trim() || loaded.helpUrl;

	const output = format(report, {
		color: flags.color,
		verbose: flags.verbose,
		helpUrl,
	});

	if (!flags.quiet && output !== "") {
		console.log(output);
	}

	if (flags.strict) {
		if (report.errorCount > 0) {
			throw new CliError(output, pkg.name, ExitCode.CommitLintError);
		}
		if (report.warningCount > 0) {
			throw new CliError(output, pkg.name, ExitCode.CommitLintWarning);
		}
	}
	if (isRulesEmpty) {
		throw new CliError(output, pkg.name, ExitCode.CommitlintInvalidArgument);
	}
	if (!report.valid) {
		throw new CliError(output, pkg.name);
	}
}

function checkFromStdin(input: (string | number)[], flags: CliFlags): boolean {
	return input.length === 0 && !checkFromRepository(flags);
}

function checkFromRepository(flags: CliFlags): boolean {
	return checkFromHistory(flags) || checkFromEdit(flags);
}

function checkFromEdit(flags: CliFlags): boolean {
	return Boolean(flags.edit) || Boolean(flags.env);
}

function checkFromHistory(flags: CliFlags): boolean {
	return (
		typeof flags.from === "string" ||
		typeof flags["from-last-tag"] === "boolean" ||
		typeof flags.to === "string" ||
		typeof flags.last === "boolean"
	);
}

function normalizeFlags(flags: CliFlags): CliFlags {
	const edit = getEditValue(flags);
	return {
		...flags,
		edit,
	};
}

function getEditValue(flags: CliFlags) {
	if (flags.env) {
		if (!(flags.env in process.env)) {
			throw new Error(
				`Received '${flags.env}' as value for -E | --env, but environment variable '${flags.env}' is not available globally`,
			);
		}
		return process.env[flags.env];
	}
	const { edit } = flags;
	// If the edit flag is set but empty (i.e '-e') we default
	// to .git/COMMIT_EDITMSG
	if (edit === "") {
		return true;
	}
	if (typeof edit === "boolean") {
		return edit;
	}
	// The recommended method to specify -e with husky was `commitlint -e $HUSKY_GIT_PARAMS`
	// This does not work properly with win32 systems, where env variable declarations
	// use a different syntax
	// See https://github.com/conventional-changelog/commitlint/issues/103 for details
	// This has been superceded by the `-E GIT_PARAMS` / `-E HUSKY_GIT_PARAMS`
	const isGitParams = edit === "$GIT_PARAMS" || edit === "%GIT_PARAMS%";
	const isHuskyParams =
		edit === "$HUSKY_GIT_PARAMS" || edit === "%HUSKY_GIT_PARAMS%";

	if (isGitParams || isHuskyParams) {
		console.warn(`Using environment variable syntax (${edit}) in -e |\
--edit is deprecated. Use '{-E|--env} HUSKY_GIT_PARAMS instead'`);

		if (isGitParams && "GIT_PARAMS" in process.env) {
			return process.env.GIT_PARAMS;
		}
		if ("HUSKY_GIT_PARAMS" in process.env) {
			return process.env.HUSKY_GIT_PARAMS;
		}
		throw new Error(
			`Received ${edit} as value for -e | --edit, but GIT_PARAMS or HUSKY_GIT_PARAMS are not available globally.`,
		);
	}
	return edit;
}

function getSeed(flags: CliFlags): UserConfig {
	const n = (flags.extends || []).filter(
		(i): i is string => typeof i === "string",
	);
	return n.length > 0
		? { extends: n, parserPreset: flags["parser-preset"] }
		: { parserPreset: flags["parser-preset"] };
}

function selectParserOpts(parserPreset: ParserPreset | undefined) {
	if (typeof parserPreset !== "object") {
		return undefined;
	}

	if (typeof parserPreset.parserOpts !== "object") {
		return undefined;
	}

	return parserPreset.parserOpts;
}

function loadFormatter(
	config: QualifiedConfig,
	flags: CliFlags,
): Promise<Formatter> {
	const moduleName = flags.format || config.formatter || "@commitlint/format";
	const modulePath =
		resolveFromSilent(moduleName, __dirname) ||
		resolveFromSilent(moduleName, flags.cwd) ||
		resolveGlobalSilent(moduleName);

	if (modulePath) {
		return dynamicImport<Formatter>(modulePath);
	}

	throw new Error(`Using format ${moduleName}, but cannot find the module.`);
}

// Catch unhandled rejections globally
process.on("unhandledRejection", (reason, promise) => {
	console.log("Unhandled Rejection at: Promise ", promise, " reason: ", reason);
	throw reason;
});