import { chmod, mkdir, readFile, writeFile } from 'fs/promises';
import { dirname, resolve, sep } from 'path';
import { Uri } from 'vscode';
import { run } from '@gitlens/utils/env/node/exec.js';
import { maybeStartScopedLogger } from '@gitlens/utils/logger.scoped.js';
import { PromiseCache } from '@gitlens/utils/promiseCache.js';
import { joinUriPath } from '@gitlens/utils/uri.js';
import { Container } from '../../../../container.js';
import { configuration } from '../../../../system/-webview/configuration.js';
import { loadChunk } from '../../../../system/-webview/loadChunk.js';
import { exists } from '../../../../system/-webview/vscode/uris.js';
import { getPlatform, isWindows } from '../../platform.js';

/**
 * Extracts a zip file to a destination directory using the fflate library.
 * This is a cross-platform alternative to using OS-specific unzip commands.
 *
 * @param zipPath - The path to the zip file to extract
 * @param destPath - The destination directory where files will be extracted
 * @param options - Optional extraction options
 * @param options.filter - Optional filter function to select which files to extract. Return true to extract the file.
 * @throws Error if extraction fails or if path traversal is detected
 */
export async function extractZipFile(
	zipPath: string,
	destPath: string,
	options?: { filter?: (filename: string) => boolean },
): Promise<void> {
	// Dynamically import fflate to avoid bundling it when not needed
	const { unzip } = await loadChunk(() => import(/* webpackChunkName: "lib-unzip" */ 'fflate'));

	// Read the zip file (returns a Buffer, which extends Uint8Array in Node.js)
	const zipData = await readFile(zipPath);

	// Unzip asynchronously (runs in worker thread, doesn't block main thread)
	// Use fflate's built-in filter to avoid decompressing unwanted files
	const filter = options?.filter;
	const unzipped = await new Promise<Record<string, Uint8Array>>((resolve, reject) => {
		unzip(
			zipData,
			{
				filter: filter
					? file => {
							// Skip directory entries (they end with /)
							if (file.name.endsWith('/')) return false;
							// Apply user filter
							return filter(file.name);
						}
					: undefined,
			},
			(err, result) => {
				if (err) {
					reject(err);
				} else {
					resolve(result);
				}
			},
		);
	});

	// Extract the files
	for (const [filename, data] of Object.entries(unzipped)) {
		// Skip directory entries (they end with /)
		if (filename.endsWith('/')) continue;

		// Resolve the full path and ensure it's within the destination (prevents path traversal)
		const filePath = resolve(destPath, filename);
		const resolvedDest = resolve(destPath);
		if (!filePath.startsWith(resolvedDest + sep) && filePath !== resolvedDest) {
			throw new Error(`Path traversal detected in zip file: ${filename}`);
		}

		await mkdir(dirname(filePath), { recursive: true });
		await writeFile(filePath, data);
		// Make 'gk' executable on Unix systems
		if (getPlatform() !== 'windows' && (filename === 'gk' || filename.endsWith('/gk'))) {
			await chmod(filePath, 0o755);
		}
	}
}

export function getDevCLILocalPath(): string | undefined {
	if (!Container.instance.debugging) return undefined;

	return configuration.get('gitkraken.cli.localPath') ?? undefined;
}

export function getCLIExecutable(cliPath?: string | null): Uri {
	const localPath = getDevCLILocalPath();
	if (localPath != null) return Uri.file(localPath);

	return joinUriPath(
		Uri.file(cliPath ?? Container.instance.context.globalStorageUri.fsPath),
		getPlatform() === 'windows' ? 'gk.exe' : 'gk',
	);
}

const resolvedExecutablesCache = new PromiseCache<string, Uri | undefined>({ capacity: 10 });

export function clearResolvedCLIExecutableCache(): void {
	resolvedExecutablesCache.clear();
}

export function resolveCLIExecutable(cliPath?: string | null): Promise<Uri | undefined> {
	const uri = getCLIExecutable(cliPath);
	return resolvedExecutablesCache.getOrCreate(uri.toString(), async () => {
		return (await exists(uri)) ? uri : undefined;
	});
}

/**
 * Resolves the effective "insiders CLI" enabled state. When the setting is unset (`null`), it is
 * auto-enabled in pre-release and debugging builds; an explicit `true`/`false` always wins.
 */
export function isInsidersCLIEnabled(): boolean {
	return configuration.get('gitkraken.cli.insiders.enabled') ?? Container.instance.prereleaseOrDebugging;
}

export async function runCLICommand(args: string[], options?: { cwd?: string }): Promise<string> {
	const cwd = options?.cwd ?? Container.instance.context.globalStorageUri.fsPath;

	// Centrally inject --insiders for all CLI commands when insiders is enabled
	if (isInsidersCLIEnabled()) {
		args = [...args, '--insiders'];
	}

	const command = (await resolveCLIExecutable(cwd))?.fsPath;
	using scope = maybeStartScopedLogger(
		`${command} ${args[0] === 'auth' ? '[auth]' : args.join(' ')}`,
		{ level: 'trace' },
		{ scopeLabel: 'CLI' },
	);

	if (command == null) {
		scope?.setFailed('CLI is not installed');
		throw new Error('CLI is not installed');
	}

	// await is needed for the scope to be properly disposed
	return await run(command, args, 'utf8', { cwd: cwd });
}

const CLIVersionOutputs = {
	core: /CLI Core: (\d+\.\d+\.\d+(?:-\S+)?)/,
	installer: /CLI Installer: (\d+\.\d+\.\d+(?:-\S+)?)/,
} as const;

export async function getCLIVersions(cliPath?: string): Promise<{ proxy: string; core: string } | undefined> {
	try {
		const output = await runCLICommand(['version'], cliPath ? { cwd: cliPath } : undefined);
		const coreMatch = output.match(CLIVersionOutputs.core)?.[1];
		const installerMatch = output.match(CLIVersionOutputs.installer)?.[1];
		if (!coreMatch || !installerMatch) throw new Error(`Unexpected CLI version output: ${output}`);

		return { core: coreMatch, proxy: installerMatch };
	} catch (ex) {
		if (ex instanceof Error && ex.message.includes('CLI is not installed')) {
			return undefined;
		}

		debugger;
		throw ex;
	}
}

/** Windows-only: detects EBUSY/EPERM when overwriting a running executable. macOS/Linux allow replacing an open executable, so this is never relevant there. */
export function isLockedBinaryError(ex: unknown): boolean {
	if (!isWindows) return false;

	const code = (ex as NodeJS.ErrnoException | null | undefined)?.code;
	return code === 'EBUSY' || code === 'EPERM';
}
