/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import { DisposableStore, Disposable, IDisposable, MutableDisposable, combinedDisposable } from '../../../base/common/lifecycle.js';
import { ExtHostContext, ExtHostTerminalServiceShape, MainThreadTerminalServiceShape, MainContext, TerminalLaunchConfig, ITerminalDimensionsDto, ExtHostTerminalIdentifier, TerminalQuickFix, ITerminalCommandDto } from '../common/extHost.protocol.js';
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { URI } from '../../../base/common/uri.js';
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { IProcessProperty, IProcessReadyWindowsPty, IShellLaunchConfig, IShellLaunchConfigDto, ITerminalOutputMatch, ITerminalOutputMatcher, ProcessPropertyType, TerminalExitReason, TerminalLocation } from '../../../platform/terminal/common/terminal.js';
import { TerminalDataBufferer } from '../../../platform/terminal/common/terminalDataBuffering.js';
import { ITerminalEditorService, ITerminalExternalLinkProvider, ITerminalGroupService, ITerminalInstance, ITerminalLink, ITerminalService } from '../../contrib/terminal/browser/terminal.js';
import { TerminalProcessExtHostProxy } from '../../contrib/terminal/browser/terminalProcessExtHostProxy.js';
import { IEnvironmentVariableService } from '../../contrib/terminal/common/environmentVariable.js';
import { deserializeEnvironmentDescriptionMap, deserializeEnvironmentVariableCollection, serializeEnvironmentVariableCollection } from '../../../platform/terminal/common/environmentVariableShared.js';
import { IStartExtensionTerminalRequest, ITerminalProcessExtHostProxy, ITerminalProfileResolverService, ITerminalProfileService } from '../../contrib/terminal/common/terminal.js';
import { IRemoteAgentService } from '../../services/remote/common/remoteAgentService.js';
import { OperatingSystem, OS } from '../../../base/common/platform.js';
import { TerminalEditorLocationOptions } from 'vscode';
import { Promises } from '../../../base/common/async.js';
import { ISerializableEnvironmentDescriptionMap, ISerializableEnvironmentVariableCollection } from '../../../platform/terminal/common/environmentVariable.js';
import { ITerminalLinkProviderService } from '../../contrib/terminalContrib/links/browser/links.js';
import { ITerminalQuickFixService, ITerminalQuickFix, TerminalQuickFixType } from '../../contrib/terminalContrib/quickFix/browser/quickFix.js';
import { TerminalCapability } from '../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalCompletionService } from '../../contrib/terminalContrib/suggest/browser/terminalCompletionService.js';

@extHostNamedCustomer(MainContext.MainThreadTerminalService)
export class MainThreadTerminalService implements MainThreadTerminalServiceShape {

	private readonly _store = new DisposableStore();
	private readonly _proxy: ExtHostTerminalServiceShape;

	/**
	 * Stores a map from a temporary terminal id (a UUID generated on the extension host side)
	 * to a numeric terminal id (an id generated on the renderer side)
	 * This comes in play only when dealing with terminals created on the extension host side
	 */
	private readonly _extHostTerminals = new Map<string, Promise<ITerminalInstance>>();
	private readonly _terminalProcessProxies = new Map<number, ITerminalProcessExtHostProxy>();
	private readonly _profileProviders = new Map<string, IDisposable>();
	private readonly _completionProviders = new Map<string, IDisposable>();
	private readonly _quickFixProviders = new Map<string, IDisposable>();
	private readonly _dataEventTracker = new MutableDisposable<TerminalDataEventTracker>();
	private readonly _sendCommandEventListener = new MutableDisposable();

	/**
	 * A single shared terminal link provider for the exthost. When an ext registers a link
	 * provider, this is registered with the terminal on the renderer side and all links are
	 * provided through this, even from multiple ext link providers. Xterm should remove lower
	 * priority intersecting links itself.
	 */
	private readonly _linkProvider = this._store.add(new MutableDisposable());

	private _os: OperatingSystem = OS;

	constructor(
		private readonly _extHostContext: IExtHostContext,
		@ITerminalService private readonly _terminalService: ITerminalService,
		@ITerminalLinkProviderService private readonly _terminalLinkProviderService: ITerminalLinkProviderService,
		@ITerminalQuickFixService private readonly _terminalQuickFixService: ITerminalQuickFixService,
		@IInstantiationService private readonly _instantiationService: IInstantiationService,
		@IEnvironmentVariableService private readonly _environmentVariableService: IEnvironmentVariableService,
		@ILogService private readonly _logService: ILogService,
		@ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService,
		@IRemoteAgentService remoteAgentService: IRemoteAgentService,
		@ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService,
		@ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService,
		@ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService,
		@ITerminalCompletionService private readonly _terminalCompletionService: ITerminalCompletionService,
	) {
		this._proxy = _extHostContext.getProxy(ExtHostContext.ExtHostTerminalService);

		// ITerminalService listeners
		this._store.add(_terminalService.onDidCreateInstance((instance) => {
			this._onTerminalOpened(instance);
			this._onInstanceDimensionsChanged(instance);
		}));

		this._store.add(_terminalService.onDidDisposeInstance(instance => this._onTerminalDisposed(instance)));
		this._store.add(_terminalService.onAnyInstanceProcessIdReady(instance => this._onTerminalProcessIdReady(instance)));
		this._store.add(_terminalService.onDidChangeInstanceDimensions(instance => this._onInstanceDimensionsChanged(instance)));
		this._store.add(_terminalService.onAnyInstanceMaximumDimensionsChange(instance => this._onInstanceMaximumDimensionsChanged(instance)));
		this._store.add(_terminalService.onDidRequestStartExtensionTerminal(e => this._onRequestStartExtensionTerminal(e)));
		this._store.add(_terminalService.onDidChangeActiveInstance(instance => this._onActiveTerminalChanged(instance ? instance.instanceId : null)));
		this._store.add(_terminalService.onAnyInstanceTitleChange(instance => instance && this._onTitleChanged(instance.instanceId, instance.title)));
		this._store.add(_terminalService.onAnyInstanceDataInput(instance => this._proxy.$acceptTerminalInteraction(instance.instanceId)));
		this._store.add(_terminalService.onAnyInstanceSelectionChange(instance => this._proxy.$acceptTerminalSelection(instance.instanceId, instance.selection)));
		this._store.add(_terminalService.onAnyInstanceShellTypeChanged(instance => this._onShellTypeChanged(instance.instanceId)));

		// Set initial ext host state
		for (const instance of this._terminalService.instances) {
			this._onTerminalOpened(instance);
			instance.processReady.then(() => this._onTerminalProcessIdReady(instance));
			if (instance.shellType) {
				this._proxy.$acceptTerminalShellType(instance.instanceId, instance.shellType);
			}
		}
		const activeInstance = this._terminalService.activeInstance;
		if (activeInstance) {
			this._proxy.$acceptActiveTerminalChanged(activeInstance.instanceId);
		}
		if (this._environmentVariableService.collections.size > 0) {
			const collectionAsArray = [...this._environmentVariableService.collections.entries()];
			const serializedCollections: [string, ISerializableEnvironmentVariableCollection][] = collectionAsArray.map(e => {
				return [e[0], serializeEnvironmentVariableCollection(e[1].map)];
			});
			this._proxy.$initEnvironmentVariableCollections(serializedCollections);
		}

		remoteAgentService.getEnvironment().then(async env => {
			this._os = env?.os || OS;
			this._updateDefaultProfile();
		});
		this._store.add(this._terminalProfileService.onDidChangeAvailableProfiles(() => this._updateDefaultProfile()));
	}

	public dispose(): void {
		this._store.dispose();
		for (const provider of this._profileProviders.values()) {
			provider.dispose();
		}
		for (const provider of this._quickFixProviders.values()) {
			provider.dispose();
		}
	}

	private async _updateDefaultProfile() {
		const remoteAuthority = this._extHostContext.remoteAuthority ?? undefined;
		const defaultProfile = this._terminalProfileResolverService.getDefaultProfile({ remoteAuthority, os: this._os });
		const defaultAutomationProfile = this._terminalProfileResolverService.getDefaultProfile({ remoteAuthority, os: this._os, allowAutomationShell: true });
		this._proxy.$acceptDefaultProfile(...await Promise.all([defaultProfile, defaultAutomationProfile]));
	}

	private async _getTerminalInstance(id: ExtHostTerminalIdentifier): Promise<ITerminalInstance | undefined> {
		if (typeof id === 'string') {
			return this._extHostTerminals.get(id);
		}
		return this._terminalService.getInstanceFromId(id);
	}

	public async $createTerminal(extHostTerminalId: string, launchConfig: TerminalLaunchConfig): Promise<void> {
		const shellLaunchConfig: IShellLaunchConfig = {
			name: launchConfig.name,
			executable: launchConfig.shellPath,
			args: launchConfig.shellArgs,
			cwd: typeof launchConfig.cwd === 'string' ? launchConfig.cwd : URI.revive(launchConfig.cwd),
			icon: launchConfig.icon,
			color: launchConfig.color,
			initialText: launchConfig.initialText,
			waitOnExit: launchConfig.waitOnExit,
			ignoreConfigurationCwd: true,
			env: launchConfig.env,
			strictEnv: launchConfig.strictEnv,
			hideFromUser: launchConfig.hideFromUser,
			customPtyImplementation: launchConfig.isExtensionCustomPtyTerminal
				? (id, cols, rows) => new TerminalProcessExtHostProxy(id, cols, rows, this._terminalService)
				: undefined,
			extHostTerminalId,
			forceShellIntegration: launchConfig.forceShellIntegration,
			isFeatureTerminal: launchConfig.isFeatureTerminal,
			isExtensionOwnedTerminal: launchConfig.isExtensionOwnedTerminal,
			useShellEnvironment: launchConfig.useShellEnvironment,
			isTransient: launchConfig.isTransient
		};
		const terminal = Promises.withAsyncBody<ITerminalInstance>(async r => {
			const terminal = await this._terminalService.createTerminal({
				config: shellLaunchConfig,
				location: await this._deserializeParentTerminal(launchConfig.location)
			});
			r(terminal);
		});
		this._extHostTerminals.set(extHostTerminalId, terminal);
		const terminalInstance = await terminal;
		this._store.add(terminalInstance.onDisposed(() => {
			this._extHostTerminals.delete(extHostTerminalId);
		}));
	}

	private async _deserializeParentTerminal(location?: TerminalLocation | TerminalEditorLocationOptions | { parentTerminal: ExtHostTerminalIdentifier } | { splitActiveTerminal: boolean; location?: TerminalLocation }): Promise<TerminalLocation | TerminalEditorLocationOptions | { parentTerminal: ITerminalInstance } | { splitActiveTerminal: boolean } | undefined> {
		if (typeof location === 'object' && 'parentTerminal' in location) {
			const parentTerminal = await this._extHostTerminals.get(location.parentTerminal.toString());
			return parentTerminal ? { parentTerminal } : undefined;
		}
		return location;
	}

	public async $show(id: ExtHostTerminalIdentifier, preserveFocus: boolean): Promise<void> {
		const terminalInstance = await this._getTerminalInstance(id);
		if (terminalInstance) {
			this._terminalService.setActiveInstance(terminalInstance);
			if (terminalInstance.target === TerminalLocation.Editor) {
				await this._terminalEditorService.revealActiveEditor(preserveFocus);
			} else {
				await this._terminalGroupService.showPanel(!preserveFocus);
			}
		}
	}

	public async $hide(id: ExtHostTerminalIdentifier): Promise<void> {
		const instanceToHide = await this._getTerminalInstance(id);
		const activeInstance = this._terminalService.activeInstance;
		if (activeInstance && activeInstance.instanceId === instanceToHide?.instanceId && activeInstance.target !== TerminalLocation.Editor) {
			this._terminalGroupService.hidePanel();
		}
	}

	public async $dispose(id: ExtHostTerminalIdentifier): Promise<void> {
		(await this._getTerminalInstance(id))?.dispose(TerminalExitReason.Extension);
	}

	public async $sendText(id: ExtHostTerminalIdentifier, text: string, shouldExecute: boolean): Promise<void> {
		const instance = await this._getTerminalInstance(id);
		await instance?.sendText(text, shouldExecute);
	}

	public $sendProcessExit(terminalId: number, exitCode: number | undefined): void {
		this._terminalProcessProxies.get(terminalId)?.emitExit(exitCode);
	}

	public $startSendingDataEvents(): void {
		if (!this._dataEventTracker.value) {
			this._dataEventTracker.value = this._instantiationService.createInstance(TerminalDataEventTracker, (id, data) => {
				this._onTerminalData(id, data);
			});
			// Send initial events if they exist
			for (const instance of this._terminalService.instances) {
				for (const data of instance.initialDataEvents || []) {
					this._onTerminalData(instance.instanceId, data);
				}
			}
		}
	}

	public $stopSendingDataEvents(): void {
		this._dataEventTracker.clear();
	}

	public $startSendingCommandEvents(): void {
		if (this._sendCommandEventListener.value) {
			return;
		}

		const multiplexer = this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CommandDetection, capability => capability.onCommandFinished);
		const sub = multiplexer.event(e => {
			this._onDidExecuteCommand(e.instance.instanceId, {
				commandLine: e.data.command,
				// TODO: Convert to URI if possible
				cwd: e.data.cwd,
				exitCode: e.data.exitCode,
				output: e.data.getOutput()
			});
		});
		this._sendCommandEventListener.value = combinedDisposable(multiplexer, sub);
	}

	public $stopSendingCommandEvents(): void {
		this._sendCommandEventListener.clear();
	}

	public $startLinkProvider(): void {
		this._linkProvider.value = this._terminalLinkProviderService.registerLinkProvider(new ExtensionTerminalLinkProvider(this._proxy));
	}

	public $stopLinkProvider(): void {
		this._linkProvider.clear();
	}

	public $registerProcessSupport(isSupported: boolean): void {
		this._terminalService.registerProcessSupport(isSupported);
	}

	public $registerCompletionProvider(id: string, extensionIdentifier: string, ...triggerCharacters: string[]): void {
		this._completionProviders.set(id, this._terminalCompletionService.registerTerminalCompletionProvider(extensionIdentifier, id, {
			id,
			provideCompletions: async (commandLine, cursorPosition, allowFallbackCompletions, token) => {
				const completions = await this._proxy.$provideTerminalCompletions(id, { commandLine, cursorPosition, allowFallbackCompletions }, token);
				return {
					items: completions?.items.map(c => ({ ...c, provider: id })),
					resourceRequestConfig: completions?.resourceRequestConfig
				};
			}
		}, ...triggerCharacters));
	}

	public $unregisterCompletionProvider(id: string): void {
		this._completionProviders.get(id)?.dispose();
		this._completionProviders.delete(id);
	}

	public $registerProfileProvider(id: string, extensionIdentifier: string): void {
		// Proxy profile provider requests through the extension host
		this._profileProviders.set(id, this._terminalProfileService.registerTerminalProfileProvider(extensionIdentifier, id, {
			createContributedTerminalProfile: async (options) => {
				return this._proxy.$createContributedProfileTerminal(id, options);
			}
		}));
	}

	public $unregisterProfileProvider(id: string): void {
		this._profileProviders.get(id)?.dispose();
		this._profileProviders.delete(id);
	}

	public async $registerQuickFixProvider(id: string, extensionId: string): Promise<void> {
		this._quickFixProviders.set(id, this._terminalQuickFixService.registerQuickFixProvider(id, {
			provideTerminalQuickFixes: async (terminalCommand, lines, options, token) => {
				if (token.isCancellationRequested) {
					return;
				}
				if (options.outputMatcher?.length && options.outputMatcher.length > 40) {
					options.outputMatcher.length = 40;
					this._logService.warn('Cannot exceed output matcher length of 40');
				}
				const commandLineMatch = terminalCommand.command.match(options.commandLineMatcher);
				if (!commandLineMatch || !lines) {
					return;
				}
				const outputMatcher = options.outputMatcher;
				let outputMatch;
				if (outputMatcher) {
					outputMatch = getOutputMatchForLines(lines, outputMatcher);
				}
				if (!outputMatch) {
					return;
				}
				const matchResult = { commandLineMatch, outputMatch, commandLine: terminalCommand.command };

				if (matchResult) {
					const result = await this._proxy.$provideTerminalQuickFixes(id, matchResult, token);
					if (result && Array.isArray(result)) {
						return result.map(r => parseQuickFix(id, extensionId, r));
					} else if (result) {
						return parseQuickFix(id, extensionId, result);
					}
				}
				return;
			}
		}));
	}

	public $unregisterQuickFixProvider(id: string): void {
		this._quickFixProviders.get(id)?.dispose();
		this._quickFixProviders.delete(id);
	}

	private _onActiveTerminalChanged(terminalId: number | null): void {
		this._proxy.$acceptActiveTerminalChanged(terminalId);
	}

	private _onTerminalData(terminalId: number, data: string): void {
		this._proxy.$acceptTerminalProcessData(terminalId, data);
	}

	private _onDidExecuteCommand(terminalId: number, command: ITerminalCommandDto): void {
		this._proxy.$acceptDidExecuteCommand(terminalId, command);
	}

	private _onTitleChanged(terminalId: number, name: string): void {
		this._proxy.$acceptTerminalTitleChange(terminalId, name);
	}

	private _onShellTypeChanged(terminalId: number): void {
		const terminalInstance = this._terminalService.getInstanceFromId(terminalId);
		if (terminalInstance) {
			this._proxy.$acceptTerminalShellType(terminalId, terminalInstance.shellType);
		}
	}

	private _onTerminalDisposed(terminalInstance: ITerminalInstance): void {
		this._proxy.$acceptTerminalClosed(terminalInstance.instanceId, terminalInstance.exitCode, terminalInstance.exitReason ?? TerminalExitReason.Unknown);
	}

	private _onTerminalOpened(terminalInstance: ITerminalInstance): void {
		const extHostTerminalId = terminalInstance.shellLaunchConfig.extHostTerminalId;
		const shellLaunchConfigDto: IShellLaunchConfigDto = {
			name: terminalInstance.shellLaunchConfig.name,
			executable: terminalInstance.shellLaunchConfig.executable,
			args: terminalInstance.shellLaunchConfig.args,
			cwd: terminalInstance.shellLaunchConfig.cwd,
			env: terminalInstance.shellLaunchConfig.env,
			hideFromUser: terminalInstance.shellLaunchConfig.hideFromUser,
			tabActions: terminalInstance.shellLaunchConfig.tabActions
		};
		this._proxy.$acceptTerminalOpened(terminalInstance.instanceId, extHostTerminalId, terminalInstance.title, shellLaunchConfigDto);
	}

	private _onTerminalProcessIdReady(terminalInstance: ITerminalInstance): void {
		if (terminalInstance.processId === undefined) {
			return;
		}
		this._proxy.$acceptTerminalProcessId(terminalInstance.instanceId, terminalInstance.processId);
	}

	private _onInstanceDimensionsChanged(instance: ITerminalInstance): void {
		this._proxy.$acceptTerminalDimensions(instance.instanceId, instance.cols, instance.rows);
	}

	private _onInstanceMaximumDimensionsChanged(instance: ITerminalInstance): void {
		this._proxy.$acceptTerminalMaximumDimensions(instance.instanceId, instance.maxCols, instance.maxRows);
	}

	private _onRequestStartExtensionTerminal(request: IStartExtensionTerminalRequest): void {
		const proxy = request.proxy;
		this._terminalProcessProxies.set(proxy.instanceId, proxy);

		// Note that onResize is not being listened to here as it needs to fire when max dimensions
		// change, excluding the dimension override
		const initialDimensions: ITerminalDimensionsDto | undefined = request.cols && request.rows ? {
			columns: request.cols,
			rows: request.rows
		} : undefined;

		this._proxy.$startExtensionTerminal(
			proxy.instanceId,
			initialDimensions
		).then(request.callback);

		proxy.onInput(data => this._proxy.$acceptProcessInput(proxy.instanceId, data));
		proxy.onShutdown(immediate => this._proxy.$acceptProcessShutdown(proxy.instanceId, immediate));
		proxy.onRequestCwd(() => this._proxy.$acceptProcessRequestCwd(proxy.instanceId));
		proxy.onRequestInitialCwd(() => this._proxy.$acceptProcessRequestInitialCwd(proxy.instanceId));
	}

	public $sendProcessData(terminalId: number, data: string): void {
		this._terminalProcessProxies.get(terminalId)?.emitData(data);
	}

	public $sendProcessReady(terminalId: number, pid: number, cwd: string, windowsPty: IProcessReadyWindowsPty | undefined): void {
		this._terminalProcessProxies.get(terminalId)?.emitReady(pid, cwd, windowsPty);
	}

	public $sendProcessProperty(terminalId: number, property: IProcessProperty<any>): void {
		if (property.type === ProcessPropertyType.Title) {
			const instance = this._terminalService.getInstanceFromId(terminalId);
			instance?.rename(property.value);
		}
		this._terminalProcessProxies.get(terminalId)?.emitProcessProperty(property);
	}

	$setEnvironmentVariableCollection(extensionIdentifier: string, persistent: boolean, collection: ISerializableEnvironmentVariableCollection | undefined, descriptionMap: ISerializableEnvironmentDescriptionMap): void {
		if (collection) {
			const translatedCollection = {
				persistent,
				map: deserializeEnvironmentVariableCollection(collection),
				descriptionMap: deserializeEnvironmentDescriptionMap(descriptionMap)
			};
			this._environmentVariableService.set(extensionIdentifier, translatedCollection);
		} else {
			this._environmentVariableService.delete(extensionIdentifier);
		}
	}
}

/**
 * Encapsulates temporary tracking of data events from terminal instances, once disposed all
 * listeners are removed.
 */
class TerminalDataEventTracker extends Disposable {
	private readonly _bufferer: TerminalDataBufferer;

	constructor(
		private readonly _callback: (id: number, data: string) => void,
		@ITerminalService private readonly _terminalService: ITerminalService
	) {
		super();

		this._register(this._bufferer = new TerminalDataBufferer(this._callback));

		for (const instance of this._terminalService.instances) {
			this._registerInstance(instance);
		}
		this._register(this._terminalService.onDidCreateInstance(instance => this._registerInstance(instance)));
		this._register(this._terminalService.onDidDisposeInstance(instance => this._bufferer.stopBuffering(instance.instanceId)));
	}

	private _registerInstance(instance: ITerminalInstance): void {
		// Buffer data events to reduce the amount of messages going to the extension host
		this._register(this._bufferer.startBuffering(instance.instanceId, instance.onData));
	}
}

class ExtensionTerminalLinkProvider implements ITerminalExternalLinkProvider {
	constructor(
		private readonly _proxy: ExtHostTerminalServiceShape
	) {
	}

	async provideLinks(instance: ITerminalInstance, line: string): Promise<ITerminalLink[] | undefined> {
		const proxy = this._proxy;
		const extHostLinks = await proxy.$provideLinks(instance.instanceId, line);
		return extHostLinks.map(dto => ({
			id: dto.id,
			startIndex: dto.startIndex,
			length: dto.length,
			label: dto.label,
			activate: () => proxy.$activateLink(instance.instanceId, dto.id)
		}));
	}
}

export function getOutputMatchForLines(lines: string[], outputMatcher: ITerminalOutputMatcher): ITerminalOutputMatch | undefined {
	const match: RegExpMatchArray | null | undefined = lines.join('\n').match(outputMatcher.lineMatcher);
	return match ? { regexMatch: match, outputLines: lines } : undefined;
}

function parseQuickFix(id: string, source: string, fix: TerminalQuickFix): ITerminalQuickFix {
	let type = TerminalQuickFixType.TerminalCommand;
	if ('uri' in fix) {
		fix.uri = URI.revive(fix.uri);
		type = TerminalQuickFixType.Opener;
	} else if ('id' in fix) {
		type = TerminalQuickFixType.VscodeCommand;
	}
	return { id, type, source, ...fix };
}

