/* eslint-disable no-async-promise-executor */
/*---------------------------------------------------------
 * Copyright (C) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------*/

import AdmZip = require('adm-zip');
import assert from 'assert';
import * as config from '../../src/config';
import {
	IToolsManager,
	defaultToolsManager,
	inspectGoToolVersion,
	installTools,
	maybeInstallImportantTools
} from '../../src/goInstallTools';
import { Tool, getConfiguredTools, getTool, getToolAtVersion } from '../../src/goTools';
import { getBinPath, getGoVersion, GoVersion, rmdirRecursive } from '../../src/util';
import { correctBinname } from '../../src/utils/pathUtils';
import cp = require('child_process');
import fs = require('fs');
import os = require('os');
import path = require('path');
import sinon = require('sinon');
import url = require('url');
import util = require('util');
import vscode = require('vscode');
import { allToolsInformation } from '../../src/goToolsInformation';
import * as goInstallTools from '../../src/goInstallTools';
import * as utilModule from '../../src/util';

interface installationTestCase {
	name: string;
	versions?: string[];
	wantVersion?: string;
}

suite('Installation Tests', function () {
	// Disable timeout when we are running slow tests.
	let timeout = 60000;
	if (shouldRunSlowTests()) {
		timeout = 0;
	}
	this.timeout(timeout);

	let tmpToolsGopath: string;
	let tmpToolsGopath2: string;
	let sandbox: sinon.SinonSandbox;
	let toolsGopath: string;

	setup(() => {
		// Create a temporary directory in which to install tools.
		tmpToolsGopath = fs.mkdtempSync(path.join(os.tmpdir(), 'install-test'));

		// a temporary directory to be used as the second GOPATH element.
		tmpToolsGopath2 = fs.mkdtempSync(path.join(os.tmpdir(), 'install-test2'));

		toolsGopath = tmpToolsGopath + path.delimiter + tmpToolsGopath2;

		sandbox = sinon.createSandbox();
	});

	teardown(async () => {
		sandbox.restore();

		// Clean up the temporary GOPATH. To delete the module cache, run `go clean -modcache`.
		const goRuntimePath = getBinPath('go');
		for (const p of [tmpToolsGopath, tmpToolsGopath2]) {
			const envForTest = Object.assign({}, process.env);
			envForTest['GOPATH'] = p;
			envForTest['GOMODCACHE'] = path.join(p, 'pkg', 'mod');
			const execFile = util.promisify(cp.execFile);
			try {
				await execFile(goRuntimePath, ['clean', '-x', '-modcache'], {
					env: envForTest
				});
				rmdirRecursive(p);
			} catch (e) {
				console.log(`failed to clean module cache directory: ${e}`);
			}
		}
	});

	// runTest actually executes the logic of the test.
	// If withLocalProxy is true, the test does not require internet.
	// If withGOBIN is true, the test will set GOBIN env var.
	// If withGoVersion is set, the test will run assuming the project requires the version.
	// If goForInstall is set, the test will use the go version for 'go install'.
	// If toolsManager is set, goInstallTools will use it instead of the default tools Manager.
	async function runTest(
		testCases: installationTestCase[],
		withLocalProxy?: boolean,
		withGOBIN?: boolean,
		withGoVersion?: string,
		goVersionForInstall?: GoVersion,
		toolsManager?: goInstallTools.IToolsManager
	) {
		const gobin = withLocalProxy && withGOBIN ? path.join(tmpToolsGopath, 'gobin') : undefined;

		let proxyDir: string | undefined;
		let configStub: sinon.SinonStub;
		if (withLocalProxy) {
			proxyDir = buildFakeProxy(testCases);
			const goConfig = Object.create(vscode.workspace.getConfiguration('go'), {
				toolsEnvVars: {
					value: {
						GOPROXY: url.pathToFileURL(proxyDir),
						GOSUMDB: 'off',
						GOBIN: gobin,
						// Build environment may have GOMODCACHE set. Avoid writing
						// fake data to it.
						GOMODCACHE: path.join(tmpToolsGopath, 'pkg', 'mod')
					}
				},
				gopath: { value: toolsGopath }
			});
			configStub = sandbox.stub(config, 'getGoConfig').returns(goConfig);
		} else {
			const goConfig = Object.create(vscode.workspace.getConfiguration('go'), {
				toolsEnvVars: {
					value: { GOBIN: gobin }
				},
				gopath: { value: toolsGopath }
			});
			configStub = sandbox.stub(config, 'getGoConfig').returns(goConfig);
		}

		const missingTools = testCases.map((tc) => getToolAtVersion(tc.name));
		const goBinary = getBinPath('go');
		const goVersion = withGoVersion
			? /* we want a fake go version, but need the real 'go' binary to run `go install` */
			  new GoVersion(goBinary, `go version ${withGoVersion} linux/amd64`)
			: await getGoVersion();

		sandbox.stub(vscode.commands, 'executeCommand').withArgs('go.languageserver.restart');
		sandbox
			.stub(goInstallTools, 'getGoVersionForInstall')
			.returns(Promise.resolve(goVersionForInstall ?? goVersion));

		const opts = toolsManager ? { toolsManager } : undefined;
		const failures = await installTools(missingTools, goVersion, opts);
		assert(!failures || failures.length === 0, `installTools failed: ${JSON.stringify(failures)}`);

		// Confirm that each expected tool has been installed.
		const checks: Promise<void>[] = [];
		const exists = util.promisify(fs.exists);
		for (const tc of testCases) {
			checks.push(
				new Promise<void>(async (resolve) => {
					// Check that the expect tool has been installed to $GOPATH/bin.
					const bin = gobin
						? path.join(gobin, correctBinname(tc.name))
						: path.join(tmpToolsGopath, 'bin', correctBinname(tc.name));
					const ok = await exists(bin);
					if (!ok) {
						assert.fail(`expected ${bin}, not found`);
					}
					// If wantVersion is set, check if wanted version was installed.
					if (tc.wantVersion !== undefined) {
						const { moduleVersion } = await inspectGoToolVersion(bin);
						assert.strictEqual(
							moduleVersion,
							tc.wantVersion,
							`expected ${tc.name}@${tc.wantVersion}, got ${moduleVersion}`
						);
					}
					return resolve();
				})
			);
		}
		await Promise.all(checks);

		if (withLocalProxy) {
			sandbox.assert.calledWith(configStub);
			// proxyDir should be set when withLocalProxy = true
			assert(proxyDir);
			rmdirRecursive(proxyDir);
		}
	}

	test('Install one tool with a local proxy', async function () {
		// TODO(golang/vscode-go#3454): reenable the test for old go.
		const systemGoVersion = await getGoVersion();
		if (systemGoVersion.lt('1.21')) {
			this.skip();
		}
		await runTest(
			[
				{
					name: 'gopls',
					versions: ['v0.1.0', 'v1.0.0', 'v1.0.1-pre.2'],
					wantVersion: config.extensionInfo.isPreview ? 'v1.0.1-pre.2' : 'v1.0.0'
				}
			],
			true
		);
	});

	test('Install multiple tools with a local proxy', async function () {
		// TODO(golang/vscode-go#3454): reenable the test for old go.
		const systemGoVersion = await getGoVersion();
		if (systemGoVersion.lt('1.21')) {
			this.skip();
		}
		await runTest(
			[
				{ name: 'gopls', versions: ['v0.1.0', 'v1.0.0-pre.1', 'v1.0.0'], wantVersion: 'v1.0.0' },
				{ name: 'dlv', versions: ['v1.0.0', 'v1.8.0'], wantVersion: 'v1.8.0' }
			],
			true
		);
	});

	test('Install multiple tools with a local proxy & GOBIN', async function () {
		// TODO(golang/vscode-go#3454): reenable the test for old go.
		const systemGoVersion = await getGoVersion();
		if (systemGoVersion.lt('1.21')) {
			this.skip();
		}
		await runTest(
			[
				{ name: 'gopls', versions: ['v0.1.0', 'v1.0.0-pre.1', 'v1.0.0'], wantVersion: 'v1.0.0' },
				{ name: 'dlv', versions: ['v1.0.0', 'v1.8.0'], wantVersion: 'v1.8.0' }
			],
			true, // LOCAL PROXY
			true // GOBIN
		);
	});

	test('Try to install with old go', async () => {
		const oldGo = new GoVersion(getBinPath('go'), 'go version go1.20 amd64/linux');
		sandbox.stub(goInstallTools, 'getGoVersionForInstall').returns(Promise.resolve(oldGo));
		const failures = await installTools([getToolAtVersion('gopls')], oldGo);
		assert(failures?.length === 1 && failures[0].tool.name === 'gopls' && failures[0].reason.includes('or newer'));
	});

	const gofumptDefault = allToolsInformation['gofumpt'].defaultVersion!;
	test('Install gofumpt with old go', async () => {
		await runTest(
			[{ name: 'gofumpt', versions: ['v0.5.0', 'v0.6.0', gofumptDefault], wantVersion: 'v0.6.0' }],
			true, // LOCAL PROXY
			true, // GOBIN
			'go1.21' // Go Version
		);
	});
	test('Install gofumpt with new go', async () => {
		await runTest(
			[{ name: 'gofumpt', versions: ['v0.4.7', 'v0.5.0', gofumptDefault], wantVersion: gofumptDefault }],
			true, // LOCAL PROXY
			true, // GOBIN
			'go1.23.0' // Go Version
		);
	});

	test('Install a tool, with go for install', async () => {
		const systemGoVersion = await getGoVersion();
		const wantGoForInstall = new GoVersion(systemGoVersion.binaryPath, 'go version go1.99.0 linux/amd64');
		const tm: IToolsManager = {
			getMissingTools: () => {
				assert.fail('must not be called');
			},
			installTool: (tool, goVersionForInstall, env) => {
				// Assert the go install command is what we expect.
				assert.strictEqual(tool.name, 'gopls');
				assert.strictEqual(goVersionForInstall, wantGoForInstall);
				assert(env['GOTOOLCHAIN'], `go${systemGoVersion.format()}+auto`);
				// runTest checks if the tool build succeeds. So, delegate the remaining
				// build task to the default tools manager's installTool function.
				return defaultToolsManager.installTool(tool, systemGoVersion, env);
			}
		};
		await runTest(
			[{ name: 'gopls', versions: ['v0.1.0', 'v1.0.0'], wantVersion: 'v1.0.0' }],
			true, // LOCAL PROXY
			true, // GOBIN
			'go' + systemGoVersion.format(true), // Go Version
			wantGoForInstall, // Go for install
			tm // stub installTool to
		);
	});

	test('Install all tools via GOPROXY', async () => {
		// Only run this test if we are in CI before a Nightly release.
		if (!shouldRunSlowTests()) {
			return;
		}
		const goVersion = await getGoVersion();
		const tools = Object.keys(allToolsInformation).filter((tool) => {
			const minGoVersion = allToolsInformation[tool].minimumGoVersion;
			return !minGoVersion || goVersion.gt(minGoVersion.format());
		});
		assert(tools.includes('gopls') && tools.includes('dlv'), `selected tools ${JSON.stringify(tools)}`);
		await runTest(
			tools.map((tool) => {
				return { name: tool };
			})
		);
	});
});

// buildFakeProxy creates a fake file-based proxy used for testing. The code is
// mostly adapted from golang.org/x/tools/internal/proxydir/proxydir.go.
function buildFakeProxy(testCases: installationTestCase[]) {
	const proxyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxydir'));
	for (const tc of testCases) {
		const tool = getTool(tc.name);
		const module = tool.modulePath;
		const pathInModule =
			tool.modulePath === tool.importPath ? '' : tool.importPath.slice(tool.modulePath.length + 1) + '/';
		const versions = tc.versions ?? ['v1.0.0']; // hardcoded for now
		const dir = path.join(proxyDir, module, '@v');
		fs.mkdirSync(dir, { recursive: true });

		// Write the list file.
		fs.writeFileSync(path.join(dir, 'list'), `${versions.join('\n')}\n`);

		versions.map((version) => {
			if (!version.match(/^v\d+\.\d+\.\d+/)) {
				// for tools that retrieve the versions from a revision (commit hash)
				const resolvedVersion = tool.latestVersion?.toString() || '1.0.0';
				const infoPath = path.join(dir, `${version}.info`);
				version = `v${resolvedVersion}`;
				fs.writeFileSync(infoPath, `{ "Version": "${version}", "Time": "2020-04-07T14:45:07Z" } `);
			}

			// Write the go.mod file.
			fs.writeFileSync(path.join(dir, `${version}.mod`), `module ${module}\n`);
			// Write the info file.
			fs.writeFileSync(
				path.join(dir, `${version}.info`),
				`{ "Version": "${version}", "Time": "2020-04-07T14:45:07Z" } `
			);

			// Write the zip file.
			const zip = new AdmZip();
			const content = 'package main; func main() {};';
			zip.addFile(`${module}@${version}/${pathInModule}main.go`, Buffer.alloc(content.length, content));
			zip.writeZip(path.join(dir, `${version}.zip`));
		});
	}
	return proxyDir;
}

// Check if VSCODEGO_BEFORE_RELEASE_TESTS is set to true. This environment
// variable is set by the CI system that releases the Nightly extension,
// allowing us to opt-in to more rigorous testing only before releases.
function shouldRunSlowTests(): boolean {
	return !!process.env['VSCODEGO_BEFORE_RELEASE_TESTS'];
}

suite('getConfiguredTools', () => {
	test('require gopls when using language server', async () => {
		const configured = getConfiguredTools({ useLanguageServer: true }, {});
		const got = configured.map((tool) => tool.name) ?? [];
		assert(got.includes('gopls'), `omitted 'gopls': ${JSON.stringify(got)}`);
	});

	test('do not require gopls when not using language server', async () => {
		const configured = getConfiguredTools({ useLanguageServer: false }, {});
		const got = configured.map((tool) => tool.name) ?? [];
		assert(!got.includes('gopls'), `suggested 'gopls': ${JSON.stringify(got)}`);
	});
});

function fakeGoVersion(versionStr: string) {
	return new GoVersion('/path/to/go', versionStr);
}

suite('listOutdatedTools', () => {
	let sandbox: sinon.SinonSandbox;
	setup(() => {
		sandbox = sinon.createSandbox();
	});
	teardown(() => sandbox.restore());

	// goVersion: go toolchain's version
	// tools: toolname -> go toolchain version used to compile the tool
	async function runTest(goVersion: string | undefined, tools: { [key: string]: string | undefined }) {
		const binPathStub = sandbox.stub(utilModule, 'getBinPath');
		const versionStub = sandbox.stub(goInstallTools, 'inspectGoToolVersion');
		for (const tool in tools) {
			binPathStub.withArgs(tool).returns(`/bin/${tool}`);
			versionStub.withArgs(`/bin/${tool}`).returns(Promise.resolve({ goVersion: tools[tool] }));
		}

		const toolsToCheck = Object.keys(tools).map((tool) => getToolAtVersion(tool));

		const configuredGoVersion = goVersion ? fakeGoVersion(goVersion) : undefined;
		const toolsToUpdate = await goInstallTools.listOutdatedTools(configuredGoVersion, toolsToCheck);
		return toolsToUpdate.map((tool) => tool.name).filter((tool) => !!tool);
	}

	test('minor version difference requires updates', async () => {
		const x = await runTest('go version go1.23.0 linux/amd64', {
			gopls: 'go1.22.2',
			dlv: 'go1.21.0',
			staticcheck: 'go1.23.0',
			gotests: 'go1.24.1'
		});
		assert.deepStrictEqual(x, ['gopls', 'dlv']);
	});
	test('patch version difference does not require updates', async () => {
		const x = await runTest('go version go1.23.1 linux/amd64', {
			gopls: 'go1.23.0', // 1.16 < 1.16.1
			dlv: 'go1.23.1', // 1.16.1 == 1.16.1
			staticcheck: 'go1.23.2', // 1.16.2 > 1.16.1
			gotests: 'go1.23rc1' // go1.23rc1 != go1.23.0
		});
		assert.deepStrictEqual(x, ['gotests']);
	});
	test('go is rc version', async () => {
		const x = await runTest('go version go1.23rc2 linux/amd64', {
			gopls: 'go1.22.3', // go1.22.3 < go1.23rc2
			dlv: 'go1.23rc1', // go1.23rc1 < go1.23rc2
			staticcheck: 'go1.23rc2', // same
			gotests: 'go1.23.0' // go1.23rc2 < go1.23.0
		});
		assert.deepStrictEqual(x, ['gopls', 'dlv']);
	});
	test('go is dev version - skip version check', async () => {
		const x = await runTest('go version devel go1.24-41f485b9a7 linux/amd64', {
			gopls: 'go1.13.1',
			dlv: 'go1.24rc1',
			staticcheck: 'go1.23.0',
			gotests: 'go1.24.0'
		});
		assert.deepStrictEqual(x, []);
	});
	test('go is unknown version - skip version check', async () => {
		const x = await runTest('', {
			gopls: 'go1.23.1'
		});
		assert.deepStrictEqual(x, []);
	});
	test('tools are unknown versions', async () => {
		const x = await runTest('go version go1.22.0 linux/amd64', {
			gopls: undefined, // this can be because gopls was compiled with go1.18 or it's too old.
			dlv: 'go1.20'
		});
		assert.deepStrictEqual(x, ['gopls', 'dlv']);
	});
});

suite('maybeInstallImportantTools tests', () => {
	let sandbox: sinon.SinonSandbox;

	setup(() => {
		sandbox = sinon.createSandbox();
	});

	teardown(async () => sandbox.restore());

	// runTest actually executes the logic of the test using a fake proxy.
	async function runTest(
		toolsManager: goInstallTools.IToolsManager,
		alternateToolsCfg: { [key: string]: string } = {},
		wantMissingTools: string[] = []
	) {
		// maybeInstallImportantTools shouldn't trigger gopls restart.
		// The caller ('activate') will explicitly start it.
		sandbox
			.stub(vscode.commands, 'executeCommand')
			.withArgs('go.languageserver.restart')
			.throws(new Error('gopls shouldn not be restarted'));

		try {
			const statusBarItem = await maybeInstallImportantTools(alternateToolsCfg, toolsManager);
			assert.strictEqual(statusBarItem.busy, false);
			assert.strictEqual(
				statusBarItem.severity,
				wantMissingTools && wantMissingTools.length > 0
					? vscode.LanguageStatusSeverity.Error
					: vscode.LanguageStatusSeverity.Information,
				statusBarItem.text
			);
			if (wantMissingTools && wantMissingTools.length > 0) {
				assert.strictEqual(statusBarItem.command?.title, 'Install missing tools');
				for (const tool of wantMissingTools) {
					assert(statusBarItem.detail!.includes(tool), statusBarItem.detail + ' does not contain ' + tool);
				}
			} else {
				assert.strictEqual(statusBarItem.command?.title, 'Update');
				assert.strictEqual(statusBarItem.detail, 'no missing tools');
			}
		} catch (e) {
			assert.fail(`maybeInstallImportantTools failed: ${e}`);
		}
	}

	test('Successfully install gopls & linter', async () => {
		await runTest(
			toolsManagerForTest(),
			{}, // emtpry alternateTools.
			[] // no missing tools after run.
		);
	});

	test('Do not install alternate tools', async () => {
		await runTest(toolsManagerForTest(), { gopls: 'fork.example.com/gopls' }, ['gopls']);
	});

	test('Recover when installation fails', async () => {
		const tm = toolsManagerForTest();
		tm.installTool = () => {
			return Promise.resolve('failed');
		};
		await runTest(tm, {}, ['gopls', 'staticcheck']);
	});

	test('Recover when installation crashes', async () => {
		const tm = toolsManagerForTest();
		tm.installTool = () => {
			throw new Error('crash');
		};
		await runTest(tm, {}, ['gopls', 'staticcheck']);
	});

	function toolsManagerForTest() {
		const installed: string[] = [];
		const toolsManager: goInstallTools.IToolsManager = {
			getMissingTools: function (matcher: (tool: Tool) => boolean): Promise<Tool[]> {
				let tools = getConfiguredTools(config.getGoConfig(), {});
				// apply user's filter;
				tools = matcher ? tools.filter(matcher) : tools;
				// remove tools that are installed.
				tools = tools.filter((tool) => !installed.includes(tool.name));
				return Promise.resolve(tools);
			},
			installTool: function (tool: Tool): Promise<string | undefined> {
				installed.push(tool.name);
				return Promise.resolve(undefined); // no error.
			}
		};
		return toolsManager;
	}
});
