/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable eqeqeq */
/* eslint-disable no-case-declarations */
/*---------------------------------------------------------
 * Copyright (C) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License. See LICENSE in the project root for license information.
 *--------------------------------------------------------*/

'use strict';

import cp = require('child_process');
import fs = require('fs');
import path = require('path');
import semver = require('semver');
import { ConfigurationTarget } from 'vscode';
import { extensionInfo, getGoConfig, getGoplsConfig } from './config';
import { toolExecutionEnvironment, toolInstallationEnvironment } from './goEnv';
import { addGoRuntimeBaseToPATH, clearGoRuntimeBaseFromPATH } from './goEnvironmentStatus';
import { GoExtensionContext } from './context';
import { addGoStatus, initGoStatusBar, outputChannel, removeGoStatus } from './goStatus';
import { containsTool, getConfiguredTools, getImportPathWithVersion, getTool, Tool, ToolAtVersion } from './goTools';
import {
	getBinPath,
	getBinPathWithExplanation,
	getCheckForToolsUpdatesConfig,
	getGoVersion,
	getTempFilePath,
	getWorkspaceFolderPath,
	GoVersion,
	rmdirRecursive
} from './util';
import {
	getEnvPath,
	getCurrentGoRoot,
	setCurrentGoRoot,
	correctBinname,
	executableFileExists
} from './utils/pathUtils';
import util = require('util');
import vscode = require('vscode');
import { RestartReason } from './language/goLanguageServer';
import { telemetryReporter } from './goTelemetry';
import { allToolsInformation } from './goToolsInformation';

const STATUS_BAR_ITEM_NAME = 'Go Tools';

// minimum go version required for tools installation.
const MINIMUM_GO_VERSION = '1.21.0';

// declinedUpdates tracks the tools that the user has declined to update.
const declinedUpdates: Tool[] = [];

// declinedUpdates tracks the tools that the user has declined to install.
const declinedInstalls: Tool[] = [];

export interface IToolsManager {
	getMissingTools(filter: (tool: Tool) => boolean): Promise<Tool[]>;
	installTool(tool: Tool, goVersionForInstall: GoVersion, env: NodeJS.Dict<string>): Promise<string | undefined>;
}

export const defaultToolsManager: IToolsManager = {
	getMissingTools,
	installTool: installToolWithGo
};

export async function installAllTools(updateExistingToolsOnly = false) {
	const goVersion = await getGoVersion();
	let allTools = getConfiguredTools(getGoConfig(), getGoplsConfig());

	// exclude tools replaced by alternateTools.
	const alternateTools: { [key: string]: string } = getGoConfig().get('alternateTools') ?? {};
	allTools = allTools.filter((tool) => {
		return !alternateTools[tool.name];
	});

	// Update existing tools by finding all tools the user has already installed.
	if (updateExistingToolsOnly) {
		await installTools(
			allTools.filter((tool) => {
				const toolPath = getBinPath(tool.name);
				return toolPath && path.isAbsolute(toolPath);
			}),
			goVersion
		);
		return;
	}

	// Otherwise, allow the user to select which tools to install or update.
	const selected = await vscode.window.showQuickPick(
		allTools.map((x) => {
			const item: vscode.QuickPickItem = {
				label: `${x.name}@${x.defaultVersion || 'latest'}`,
				description: x.description
			};
			return item;
		}),
		{
			canPickMany: true,
			placeHolder: 'Select the tools to install/update.'
		}
	);
	if (!selected) {
		return;
	}
	await installTools(
		selected.map((x) => getTool(x.label)),
		goVersion
	);
}

// Returns the go version to be used for tools installation.
// If `go.toolsManagement.go` is set, it is preferred. Otherwise, the provided
// goVersion or the default version returned by getGoVersion is returned.
export const getGoVersionForInstall = _getGoVersionForInstall;
async function _getGoVersionForInstall(goVersion?: GoVersion): Promise<GoVersion | undefined> {
	let configuredGoForInstall = getGoConfig().get<string>('toolsManagement.go');
	if (!configuredGoForInstall) {
		// A separate Go for install is not configured. Use the default Go.
		const defaultGoVersion = goVersion ?? (await getGoVersion());
		configuredGoForInstall = defaultGoVersion?.binaryPath;
	}
	try {
		// goVersion may be the version picked based on the the minimum
		// toolchain version requirement specified in go.mod or go.work.
		// Compute the local toolchain version. (GOTOOLCHAIN=local go version)
		const go = await getGoVersion(configuredGoForInstall, 'local');
		if (go) return go;
	} catch (e) {
		outputChannel.error(
			`failed to run "go version" with "${configuredGoForInstall}". Provide a valid path to the Go binary`
		);
	}
	return;
}

interface installToolsOptions {
	silent?: boolean;
	skipRestartGopls?: boolean;
	toolsManager?: IToolsManager;
}

/**
 * Installs given array of missing tools. If no input is given, the all tools are installed
 *
 * @param missing array of tool names and optionally, their versions to be installed.
 *                If a tool's version is not specified, it will install the latest.
 * @param goVersion version of Go used in the project. (e.g. result of 'go version' from
 *                workspace root). If go used for tools installation
 *                is not configured or misconfigured, this is used as a fallback.
 * @returns a list of tools that failed to install.
 */
export async function installTools(
	missing: ToolAtVersion[],
	goVersion: GoVersion,
	options?: installToolsOptions
): Promise<{ tool: ToolAtVersion; reason: string }[]> {
	// TODO(hyangah): the return value (tool, reason) is not used anywhere
	// other than in tests. Check if we are giving users enough information
	// about failed tools installation.
	if (!missing) {
		return [];
	}
	const { silent, skipRestartGopls } = options || {};
	if (!silent) {
		outputChannel.show();
	}

	const goForInstall = await getGoVersionForInstall(goVersion);
	if (!goForInstall || !goForInstall.isValid()) {
		vscode.window.showErrorMessage('Failed to find a go command needed to install tools.');
		outputChannel.show(); // show error.
		return missing.map((tool) => {
			return { tool: tool, reason: 'failed to find go' };
		});
	}

	if (goForInstall.lt(MINIMUM_GO_VERSION)) {
		vscode.window.showErrorMessage(
			`Failed to find a go command (go${MINIMUM_GO_VERSION} or newer) needed to install tools. ` +
				`The go command (${goForInstall.binaryPath}) is too old (go${goForInstall.svString}). ` +
				`If your project requires a Go version older than go${MINIMUM_GO_VERSION}, please manually install the tools or, use the "go.toolsManagement.go" setting ` +
				`to configure a different go command (go ${MINIMUM_GO_VERSION}+) to be used for tools installation. See https://github.com/golang/vscode-go/issues/3411.`
		);
		return missing.map((tool) => {
			return { tool: tool, reason: `failed to find go (requires go${MINIMUM_GO_VERSION} or newer)` };
		});
	}

	const envForTools = toolInstallationEnvironment();
	if (
		!goVersion.isDevel &&
		goVersion.sv &&
		!goForInstall.isDevel &&
		goForInstall.sv &&
		semver.gt(goVersion.sv, goForInstall.sv)
	) {
		// If goVersion.isDevel === true, for example,
		//    go version go1.23-20240317-RC00 cl/616607620 +0a6f05e30f X:fieldtrack,boringcrypto linux/amd64
		//    go version devel go1.23-cd294f55ca Mon Apr 1 20:28:41 2024 +0000 darwin/amd64
		// we optimisitically assume the go command chosen for install (goForInstall)
		// is new enough (possibly newer than the officially released go version),
		// and don't set GOTOOLCHAIN.
		const version = goVersion.format(true);
		envForTools['GOTOOLCHAIN'] = `go${version}+auto`;
	}

	const toolsGopath = envForTools['GOPATH'];
	let envMsg = `Tools environment: GOPATH=${toolsGopath}`;
	if (envForTools['GOBIN']) {
		envMsg += `, GOBIN=${envForTools['GOBIN']}`;
	}
	if (envForTools['GOTOOLCHAIN']) {
		envMsg += `, GOTOOLCHAIN=${envForTools['GOTOOLCHAIN']}`;
	}
	outputChannel.appendLine(envMsg);

	let installingMsg = `Installing ${missing.length} ${missing.length > 1 ? 'tools' : 'tool'} at `;
	if (envForTools['GOBIN']) {
		installingMsg += `the configured GOBIN: ${envForTools['GOBIN']}`;
	} else {
		const p = toolsGopath
			?.split(path.delimiter)
			.map((e) => path.join(e, 'bin'))
			.join(path.delimiter);
		installingMsg += `${p}`;
	}

	outputChannel.appendLine(installingMsg);
	missing.forEach((missingTool) => {
		let toolName = missingTool.name;
		if (missingTool.version) {
			toolName += '@' + missingTool.version;
		}
		outputChannel.appendLine('  ' + toolName);
	});

	outputChannel.appendLine(''); // Blank line for spacing.

	const failures: { tool: ToolAtVersion; reason: string }[] = [];
	const tm = options?.toolsManager ?? defaultToolsManager;
	for (const tool of missing) {
		const failed = await tm.installTool(tool, goForInstall, envForTools);
		if (failed) {
			failures.push({ tool, reason: failed });
		} else if (tool.name === 'gopls' && !skipRestartGopls) {
			// Restart the language server if a new binary has been installed.
			vscode.commands.executeCommand('go.languageserver.restart', RestartReason.INSTALLATION);
		}
	}

	// Report detailed information about any failures.
	outputChannel.appendLine(''); // blank line for spacing
	if (failures.length === 0) {
		outputChannel.appendLine('All tools successfully installed. You are ready to Go. :)');
	} else {
		// Show the output channel on failures, even if the installation should
		// be silent.
		if (silent) {
			outputChannel.show();
		}
		outputChannel.appendLine(failures.length + ' tools failed to install.\n');
		for (const failure of failures) {
			outputChannel.appendLine(`${failure.tool.name}: ${failure.reason} `);
		}
	}
	if (missing.some((tool) => tool.isImportant)) {
		// if we just installed important tools, update the status bar.
		updateImportantToolsStatus(tm);
	}
	return failures;
}

async function tmpDirForToolInstallation() {
	// Install tools in a temporary directory, to avoid altering go.mod files.
	const mkdtemp = util.promisify(fs.mkdtemp);
	const toolsTmpDir = await mkdtemp(getTempFilePath('go-tools-'));
	// Write a temporary go.mod file to avoid version conflicts.
	const tmpGoModFile = path.join(toolsTmpDir, 'go.mod');
	const writeFile = util.promisify(fs.writeFile);
	await writeFile(tmpGoModFile, 'module tools');

	return toolsTmpDir;
}

// installTool is used by goEnvironmentStatus.ts.
// TODO(hyangah): replace the callsite to use defaultToolsManager and remove this.
export async function installTool(tool: ToolAtVersion): Promise<string | undefined> {
	const goVersionForInstall = await getGoVersionForInstall();
	if (!goVersionForInstall) {
		return 'failed to find "go" for install';
	}
	const envForTools = toolInstallationEnvironment();

	return await installToolWithGo(tool, goVersionForInstall, envForTools);
}

async function installToolWithGo(
	tool: ToolAtVersion,
	goVersionForInstall: GoVersion, // go version used to install the tool.
	envForTools: NodeJS.Dict<string>
): Promise<string | undefined> {
	const env = Object.assign({}, envForTools);

	let version: semver.SemVer | string | undefined | null = tool.version;
	if (!version && tool.usePrereleaseInPreviewMode && extensionInfo.isPreview) {
		version = await latestToolVersion(tool, true);
	}
	// TODO(hyangah): should we allow to choose a different version of the tool
	// depending on the project's go version (i.e. getGoVersion())? For example,
	// if a user is using go1.20 for their project, should we pick gopls@v0.15
	// instead? In that case, we should pass getGoVersion().
	const importPath = getImportPathWithVersion(tool, version, goVersionForInstall);

	try {
		await installToolWithGoInstall(goVersionForInstall, env, importPath);
		const toolInstallPath = getBinPath(tool.name);
		outputChannel.appendLine(`Installing ${importPath} (${toolInstallPath}) SUCCEEDED`);
	} catch (e) {
		outputChannel.appendLine(`Installing ${importPath} FAILED`);
		outputChannel.appendLine(`${JSON.stringify(e, null, 1)}`);
		return `failed to install ${tool.name}(${importPath}): ${e}`;
	}
}

async function installToolWithGoInstall(goVersion: GoVersion, env: NodeJS.Dict<string>, importPath: string) {
	// Unlike installToolWithGoGet, `go install` in module mode
	// can run in the current directory safely. So, use the user-specified go tool path.
	const goBinary = goVersion?.binaryPath || getBinPath('go');
	const opts = {
		env,
		cwd: getWorkspaceFolderPath()
	};

	const execFile = util.promisify(cp.execFile);
	outputChannel.trace(`${goBinary} install -v ${importPath} (cwd: ${opts.cwd})`);
	await execFile(goBinary, ['install', '-v', importPath], opts);
}

export function declinedToolInstall(toolName: string) {
	const tool = getTool(toolName);

	// If user has declined to install this tool, don't prompt for it.
	return !!containsTool(declinedInstalls, tool);
}

export async function promptForMissingTool(toolName: string) {
	const tool = getTool(toolName);
	if (!tool) {
		vscode.window.showWarningMessage(
			`${toolName} is not found. Please make sure it is installed and available in the PATH ${getEnvPath()}`
		);
		return;
	}

	// If user has declined to install this tool, don't prompt for it.
	if (declinedToolInstall(toolName)) {
		return;
	}

	const goVersion = await getGoVersion();
	if (!goVersion) {
		return;
	}

	// Show error messages for outdated tools or outdated Go versions.
	if (tool.minimumGoVersion && goVersion.lt(tool.minimumGoVersion.format())) {
		vscode.window.showInformationMessage(
			`You are using go${goVersion.format()}, but ${
				tool.name
			} requires at least go${tool.minimumGoVersion.format()}.`
		);
		return;
	}

	const installOptions = ['Install'];
	let missing = await getMissingTools();
	if (!containsTool(missing, tool)) {
		// If this function has been called, we want to display the prompt whether
		// it appears in missing or not.
		missing.push(tool);
	}
	missing = missing.filter((x) => x === tool || tool.isImportant);
	if (missing.length > 1) {
		// Offer the option to install all tools.
		installOptions.push('Install All');
	}
	const cmd = `go install -v ${getImportPathWithVersion(tool, undefined, goVersion)}`;
	const selected = await vscode.window.showErrorMessage(
		`The "${tool.name}" command is not available. Run "${cmd}" to install.`,
		...installOptions
	);
	switch (selected) {
		case 'Install':
			await installTools([tool], goVersion);
			break;
		case 'Install All':
			await installTools(missing, goVersion);
			removeGoStatus(STATUS_BAR_ITEM_NAME);
			break;
		default:
			// The user has declined to install this tool.
			declinedInstalls.push(tool);
			break;
	}
}

export async function promptForUpdatingTool(
	toolName: string,
	newVersion?: semver.SemVer,
	crashed?: boolean,
	message?: string
) {
	const tool = getTool(toolName);
	if (!tool) {
		return; // not a tool known to us.
	}
	const toolVersion = { ...tool, version: newVersion }; // ToolWithVersion

	// If user has declined to update, then don't prompt.
	if (containsTool(declinedUpdates, tool)) {
		return;
	}

	// Adjust the prompt if it occurred because the tool crashed.
	let updateMsg: string;
	if (message) {
		updateMsg = message;
	} else if (crashed === true) {
		updateMsg = `${tool.name} has crashed, but you are using an outdated version. Please update to the latest version of ${tool.name}.`;
	} else if (newVersion) {
		updateMsg = `A new version of ${tool.name} (v${newVersion}) is available. Please update for an improved experience.`;
	} else {
		updateMsg = `Your version of ${tool.name} appears to be out of date. Please update for an improved experience.`;
	}

	let choices: string[] = ['Update'];
	if (toolName === 'gopls') {
		choices = ['Always Update', 'Update Once', 'Release Notes'];
	}
	if (toolName === 'dlv') {
		choices = ['Always Update', 'Update Once'];
	}

	const goVersion = await getGoVersion();

	while (choices.length > 0) {
		const selected = await vscode.window.showInformationMessage(updateMsg, ...choices);
		switch (selected) {
			case 'Always Update':
				// Update the user's settings to enable auto updates. They can
				// always opt-out in their settings.
				const goConfig = getGoConfig();
				await goConfig.update('toolsManagement.autoUpdate', true, ConfigurationTarget.Global);

				// And then install the tool.
				choices = [];
				await installTools([toolVersion], goVersion);
				break;
			case 'Update Once':
				choices = [];
				await installTools([toolVersion], goVersion);
				break;
			case 'Update':
				choices = [];
				await installTools([toolVersion], goVersion);
				break;
			case 'Release Notes':
				choices = choices.filter((value) => value !== 'Release Notes');
				vscode.commands.executeCommand(
					'vscode.open',
					vscode.Uri.parse(`https://github.com/golang/tools/releases/tag/${tool.name}/v${newVersion}`)
				);
				break;
			default:
				choices = [];
				declinedUpdates.push(tool);
				break;
		}
	}
}

export function updateGoVarsFromConfig(goCtx: GoExtensionContext): Promise<void> {
	// TODO(hyangah): can we avoid modifying process.env? The `go env` output
	// can be cached in memory and queried when functions in goEnv.ts are called
	// instead of mutating process.env.
	const { binPath, why } = getBinPathWithExplanation('go', false);
	const goRuntimePath = binPath;

	outputChannel.debug(`updateGoVarsFromConfig: found 'go' in ${goRuntimePath}`);
	if (!goRuntimePath || !path.isAbsolute(goRuntimePath)) {
		// getBinPath returns the absolute path to the tool if it exists.
		// Otherwise, it may return the tool name (e.g. 'go').
		suggestDownloadGo();
		return Promise.reject();
	}

	return new Promise<void>((resolve, reject) => {
		const env = toolExecutionEnvironment();
		const cwd = getWorkspaceFolderPath();

		cp.execFile(
			goRuntimePath,
			// -json is supported since go1.9
			['env', '-json', 'GOPATH', 'GOROOT', 'GOPROXY', 'GOBIN', 'GOMODCACHE'],
			{ env: env, cwd: cwd },
			(err, stdout, stderr) => {
				if (err) {
					outputChannel.append(
						`Failed to run '${goRuntimePath} env' (cwd: ${getWorkspaceFolderPath()}): ${err}\n${stderr}`
					);
					outputChannel.show();

					vscode.window.showErrorMessage(
						`Failed to run '${goRuntimePath} env. The config change may not be applied correctly.`
					);
					return reject();
				}
				if (stderr) {
					// 'go env' may output warnings about potential misconfiguration.
					// Show the messages to users but keep processing the stdout.
					outputChannel.append(`'${goRuntimePath} env': ${stderr}`);
					outputChannel.show();
				}
				outputChannel.trace(`${goRuntimePath} env ...:\n${stdout}`);
				const envOutput = JSON.parse(stdout);
				if (envOutput.GOROOT && envOutput.GOROOT.trim()) {
					outputChannel.debug('setCurrentGOROOT:', envOutput.GOROOT);
					setCurrentGoRoot(envOutput.GOROOT.trim());
					delete envOutput.GOROOT;
				}
				for (const envName in envOutput) {
					if (!process.env[envName] && envOutput[envName] && envOutput[envName].trim()) {
						process.env[envName] = envOutput[envName].trim();
					}
				}

				// cgo, gopls, and other underlying tools will inherit the environment and attempt
				// to locate 'go' from the PATH env var.
				// Update the PATH only if users configured to use a different
				// version of go than the system default found from PATH (or Path).
				if (why !== 'path') {
					addGoRuntimeBaseToPATH(path.join(getCurrentGoRoot(), 'bin'));
				} else {
					// clear pre-existing terminal PATH mutation logic set up by this extension.
					clearGoRuntimeBaseFromPATH();
				}
				initGoStatusBar(goCtx);
				// TODO: restart language server or synchronize with language server update.

				return resolve();
			}
		);
	});
}

// maybeInstallImportantTools checks whether important tools are installed
// and they meet the version requirement.
// Then it tries to auto-install them if missing.
export async function maybeInstallImportantTools(
	alternateTools: { [key: string]: string } | undefined,
	tm: IToolsManager = defaultToolsManager
): Promise<vscode.LanguageStatusItem> {
	const statusBarItem = addGoStatus(STATUS_BAR_ITEM_NAME);
	statusBarItem.name = STATUS_BAR_ITEM_NAME;
	statusBarItem.text = 'Analysis tools';

	try {
		statusBarItem.busy = true;
		let missing = await tm.getMissingTools((tool: Tool) => {
			return tool.isImportant;
		}); // expect gopls and a linter.

		// Initial install.
		if (missing.length > 0) {
			outputChannel.show(); // Best effort: make it visible so users can see the progress.
			statusBarItem.detail = missing.map((tool) => tool.name).join(', ');
			const goVersion = await getGoVersion();
			// filter out tools with 'alternateTools' setting. updateImportantToolsStatus will
			// recompute missing tools and recognize tools still missing.
			if (alternateTools) {
				missing = missing
					.map((tool) => {
						if (alternateTools[tool.name]) {
							outputChannel.appendLine(
								`skip installing ${
									tool.name
								} because the 'alternateTools' setting is configured to use ${
									alternateTools[tool.name]
								} instead.`
							);
						}
						return tool;
					})
					.filter((tool) => !alternateTools[tool.name]);
			}
			await installTools(missing, goVersion, { toolsManager: tm, skipRestartGopls: true });
			// installTools will update ImportantToolsStatus.
		} else {
			// no essential tools to be installed.
			await updateImportantToolsStatus(tm);
		}
	} catch (e) {
		outputChannel.appendLine('install missing tools failed - ' + JSON.stringify(e));
	} finally {
		statusBarItem.busy = false;
	}
	return statusBarItem;
}

async function updateImportantToolsStatus(tm: IToolsManager = defaultToolsManager): Promise<void> {
	const statusBarItem = addGoStatus(STATUS_BAR_ITEM_NAME);
	let missing: Tool[] | null = null;
	try {
		missing = await tm.getMissingTools((tool: Tool) => {
			return tool.isImportant;
		}); // expect gopls and a linter.
	} catch (e) {
		// ignore.
	}

	if (missing === null) {
		statusBarItem.severity = vscode.LanguageStatusSeverity.Error;
		statusBarItem.detail = 'failed to compute missing tools';
		statusBarItem.command = {
			command: 'go.tools.install',
			title: 'Retry',
			tooltip: 'Install/Update Tools'
		};
		return;
	} else if (missing.length === 0) {
		statusBarItem.severity = vscode.LanguageStatusSeverity.Information;
		statusBarItem.detail = 'no missing tools';
		statusBarItem.command = {
			command: 'go.tools.install',
			title: 'Update',
			tooltip: 'Install/Update Tools'
		};
		return;
	} else {
		statusBarItem.severity = vscode.LanguageStatusSeverity.Error;
		statusBarItem.detail = `missing ${missing.map((tool) => tool.name).join(',')}`;
		statusBarItem.command = {
			command: 'go.tools.install',
			arguments: missing.map((tool) => tool.name),
			title: 'Install missing tools',
			tooltip: `Install ${missing.join(',')}`
		};
	}
}

// getMissingTools returns missing tools.
// If matcher is provided, only the tools that match the filter will be checked.
function getMissingTools(matcher?: (value: Tool) => boolean): Promise<Tool[]> {
	let keys = getConfiguredTools(getGoConfig(), getGoplsConfig());
	if (matcher) {
		keys = keys.filter(matcher);
	}
	return Promise.all(
		keys.map(
			(tool) =>
				new Promise<Tool | null>((resolve, reject) => {
					const toolPath = getBinPath(tool.name);
					resolve(path.isAbsolute(toolPath) ? null : tool);
				})
		)
	).then((res) => {
		return res.filter((x): x is Tool => x != null);
	});
}

let suggestedDownloadGo = false;

async function suggestDownloadGo() {
	const msg =
		`Failed to find the "go" binary in either GOROOT(${getCurrentGoRoot()}) or PATH(${getEnvPath()}). ` +
		'Check PATH, or Install Go and reload the window. ' +
		"If PATH isn't what you expected, see https://github.com/golang/vscode-go/issues/971";

	if (suggestedDownloadGo) {
		vscode.window.showErrorMessage(msg);
		return;
	}

	const choice = await vscode.window.showErrorMessage(msg, 'Go to Download Page');
	if (choice === 'Go to Download Page') {
		vscode.env.openExternal(vscode.Uri.parse('https://golang.org/dl/'));
	}
	suggestedDownloadGo = true;
}

// ListVersionsOutput is the output of `go list -m -versions -json`.
interface ListVersionsOutput {
	Version: string; // module version
	Versions?: string[]; // available module versions (with -versions)
}

// latestToolVersion returns the latest version of the tool.
export async function latestToolVersion(tool: Tool, includePrerelease?: boolean): Promise<semver.SemVer | null> {
	const goCmd = getBinPath('go');
	const tmpDir = await tmpDirForToolInstallation();
	const execFile = util.promisify(cp.execFile);

	let ret: semver.SemVer | null = null;

	try {
		const env = toolInstallationEnvironment();
		env['GO111MODULE'] = 'on';
		// Run go list in a temp directory to avoid altering go.mod
		// when using older versions of go (<1.16).
		const version = 'latest'; // TODO(hyangah): use 'master' for delve-dap.
		const { stdout } = await execFile(
			goCmd,
			['list', '-m', '--versions', '-json', `${tool.modulePath}@${version}`],
			{
				env,
				cwd: tmpDir
			}
		);
		const m = <ListVersionsOutput>JSON.parse(stdout);
		// Versions field is a list of all known versions of the module,
		// ordered according to semantic versioning, earliest to latest.
		const latest = includePrerelease && m.Versions && m.Versions.length > 0 ? m.Versions.pop() : m.Version;
		ret = semver.parse(latest);
	} catch (e) {
		console.log(`failed to retrieve the latest tool ${tool.name} version: ${e}`);
	} finally {
		rmdirRecursive(tmpDir);
	}
	return ret;
}

// inspectGoToolVersion reads the go version and module version
// of the given go tool using `go version -m` command.
export const inspectGoToolVersion = defaultInspectGoToolVersion;
async function defaultInspectGoToolVersion(
	binPath: string
): Promise<{ goVersion?: string; moduleVersion?: string; debugInfo?: string }> {
	const goCmd = getBinPath('go');
	const execFile = util.promisify(cp.execFile);
	let debugInfo = 'go version -m failed';
	try {
		const { stdout } = await execFile(goCmd, ['version', '-m', binPath]);
		debugInfo = stdout;
		/* The output format will look like this

		   if the binary was built in module mode.
			/Users/hakim/go/bin/gopls: go1.16
			path    golang.org/x/tools/gopls
			mod     golang.org/x/tools/gopls        v0.6.6  h1:GmCsAKZMEb1BD1BTWnQrMyx4FmNThlEsmuFiJbLBXio=
			dep     github.com/BurntSushi/toml      v0.3.1  h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=

		   if the binary was built in GOPATH mode => the following code will throw an error which will be handled.
			/Users/hakim/go/bin/gopls: go1.16

		   if the binary was built in dev branch, in module mode => the following code will not throw an error,
		   and return (devel) as the moduleVersion.
			/Users/hakim/go/bin/gopls: go1.16
			path    golang.org/x/tools/gopls
			mod     golang.org/x/tools/gopls        (devel)
			dep     github.com/BurntSushi/toml      v0.3.1  h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=

		   if the binary was built with a dev version of go, in module mode.
			/Users/hakim/go/bin/gopls: devel go1.21-41f485b9a7 Mon Jan 31 13:43:52 2022 +0000
			path    golang.org/x/tools/gopls
			mod     golang.org/x/tools/gopls        v0.8.0-pre.1    h1:6iHi9bCJ8XndQtBEFFG/DX+eTJrf2lKFv4GI3zLeDOo=
			...
		*/
		const lines = stdout.split('\n', 3);
		const goVersion = lines[0] && lines[0].match(/\s+(go\d+.\d+\S*)/)?.[1];
		const moduleVersion = lines[2].split(/\s+/)[3];
		return { goVersion, moduleVersion };
	} catch (e) {
		// either go version failed (e.g. the tool was compiled with a more recent version of go)
		// or stdout is not in the expected format.
		return { debugInfo };
	}
}

export async function shouldUpdateTool(tool: Tool, toolPath: string): Promise<boolean> {
	if (!tool.latestVersion) {
		return false;
	}

	const checkForUpdates = getCheckForToolsUpdatesConfig(getGoConfig());
	if (checkForUpdates === 'off') {
		return false;
	}

	const { moduleVersion } = await inspectGoToolVersion(toolPath);
	if (!moduleVersion) {
		return false; // failed to inspect the tool version.
	}

	const localVersion = semver.parse(moduleVersion, { includePrerelease: true });
	if (!localVersion) {
		// local version can't be determined. e.g. (devel)
		return false;
	}
	return semver.lt(localVersion, tool.latestVersion);
	// update only if the local version is older than the desired version.

	// TODO(hyangah): figure out when to check if a version newer than
	// tool.latestVersion is released when checkForUpdates === 'proxy'
}

export async function suggestUpdates() {
	const configuredGoVersion = await getGoVersionForInstall();
	if (!configuredGoVersion || configuredGoVersion.lt(MINIMUM_GO_VERSION)) {
		// User is using an old or dev version of go.
		// Don't suggest updates.
		return;
	}

	const allTools = getConfiguredTools(getGoConfig(), getGoplsConfig());
	const toolsToUpdate = await listOutdatedTools(configuredGoVersion, allTools);
	if (toolsToUpdate.length === 0) {
		return;
	}

	// If the user has opted in to automatic tool updates, we can update
	// without prompting.
	const toolsManagementConfig = getGoConfig()['toolsManagement'];
	if (toolsManagementConfig && toolsManagementConfig['autoUpdate'] === true) {
		installTools(toolsToUpdate, configuredGoVersion, { silent: true });
	} else {
		const updateToolsCmdText = 'Update tools';
		const selected = await vscode.window.showWarningMessage(
			`Tools (${toolsToUpdate.map((tool) => tool.name).join(', ')}) need recompiling to work with ${
				configuredGoVersion.version
			}`,
			updateToolsCmdText
		);
		if (selected === updateToolsCmdText) {
			installTools(toolsToUpdate, configuredGoVersion);
		}
	}
}

// exported for testing
export async function listOutdatedTools(configuredGoVersion: GoVersion | undefined, allTools: Tool[]): Promise<Tool[]> {
	if (!configuredGoVersion || !configuredGoVersion.sv) {
		return [];
	}

	const { major, minor } = configuredGoVersion.sv;

	const oldTools = await Promise.all(
		allTools.map(async (tool) => {
			const toolPath = getBinPath(tool.name);
			if (!path.isAbsolute(toolPath)) {
				return;
			}
			const m = await inspectGoToolVersion(toolPath);
			const { goVersion } = m || {};
			if (!goVersion) {
				// The tool was compiled with a newer version of go
				// or a very old go (<go1.18)
				// or compiled in an unconventional way.
				// Suggest to reinstall the tool anyway.
				return tool;
			}
			const toolGoVersion = new GoVersion('', `go version ${goVersion} os/arch`);
			if (!toolGoVersion || !toolGoVersion.sv) {
				return tool;
			}
			if (
				major > toolGoVersion.sv.major ||
				(major === toolGoVersion.sv.major && minor > toolGoVersion.sv.minor)
			) {
				return tool;
			}
			// special case: if the tool was compiled with beta or rc, and the current
			// go version is a stable version, let's ask to recompile.
			if (
				major === toolGoVersion.sv.major &&
				minor === toolGoVersion.sv.minor &&
				(goVersion.includes('beta') || goVersion.includes('rc')) &&
				// We assume tools compiled with different rc/beta need to be recompiled.
				// We test the inequality by checking whether the exact beta or rc version
				// appears in the `go version` output. e.g.,
				//   configuredGoVersion.version      	goVersion(tool)		update
				//   'go version go1.21 ...'    		'go1.21rc1'		Yes
				//   'go version go1.21rc1 ...'		'go1.21rc1'		No
				//   'go version go1.21rc2 ...'		'go1.21rc1'		Yes
				//   'go version go1.21rc1 ...'			'go1.21rc1'		Yes
				//   'go version go1.21rc1 ...'			'go1.21'			No
				//   'go version devel go1.21-deadbeef ...'	'go1.21rc1'	No (* rare)
				!configuredGoVersion.version.includes(goVersion)
			) {
				return tool;
			}
			return;
		})
	);
	return oldTools.filter((tool): tool is Tool => !!tool);
}

// maybeInstallVSCGO is a special program released and installed with the Go extension.
// Unlike other tools, it is installed under the extension path (which is cleared
// when a new version is installed).
export async function maybeInstallVSCGO(
	extensionMode: vscode.ExtensionMode,
	extensionId: string,
	extensionVersion: string,
	extensionPath: string,
	isPreview: boolean
): Promise<string> {
	// golang.go stable stable -> install once per version.
	// golang.go pre-release/dev through launch.json -> install every time.
	const progPath = path.join(extensionPath, 'bin', correctBinname('vscgo'));

	if (extensionMode === vscode.ExtensionMode.Production && executableFileExists(progPath)) {
		return progPath; // reuse existing executable.
	}
	telemetryReporter.add('vscgo_install', 1);
	const mkdir = util.promisify(fs.mkdir);
	await mkdir(path.dirname(progPath), { recursive: true });
	const execFile = util.promisify(cp.execFile);

	const cwd = path.join(extensionPath);
	const env = toolInstallationEnvironment();
	env['GOBIN'] = path.dirname(progPath);

	const importPath = allToolsInformation['vscgo'].importPath;
	const version =
		extensionMode !== vscode.ExtensionMode.Production
			? ''
			: extensionId !== 'golang.go' || isPreview
			? '@master'
			: `@v${extensionVersion}`;
	// build from source acquired from the module proxy if this is a non-preview version.
	try {
		const goForInstall = await getGoVersionForInstall();
		const goBinary = goForInstall?.binaryPath;
		if (!goBinary) {
			throw new Error('"go" binary is not found');
		}
		const args = ['install', '-trimpath', `${importPath}${version}`];
		console.log(`installing vscgo: ${args.join(' ')}`);
		await execFile(goBinary, args, { cwd, env });
		return progPath;
	} catch (e) {
		telemetryReporter.add('vscgo_install_fail', 1);
		return Promise.reject(`failed to install vscgo - ${e}`);
	}
}
