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

import { localize } from 'vs/nls';
import { firstOrDefault } from 'vs/base/common/arrays';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { ILogService } from 'vs/platform/log/common/log';
import { IHostService } from 'vs/workbench/services/host/browser/host';
import { DeferredPromise } from 'vs/base/common/async';
import { ISpeechService, ISpeechProvider, HasSpeechProvider, ISpeechToTextSession, SpeechToTextInProgress, IKeywordRecognitionSession, KeywordRecognitionStatus, SpeechToTextStatus, speechLanguageConfigToLanguage, SPEECH_LANGUAGE_CONFIG, ITextToSpeechSession, TextToSpeechInProgress, TextToSpeechStatus } from 'vs/workbench/contrib/speech/common/speechService';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';

export interface ISpeechProviderDescriptor {
	readonly name: string;
	readonly description?: string;
}

const speechProvidersExtensionPoint = ExtensionsRegistry.registerExtensionPoint<ISpeechProviderDescriptor[]>({
	extensionPoint: 'speechProviders',
	jsonSchema: {
		description: localize('vscode.extension.contributes.speechProvider', 'Contributes a Speech Provider'),
		type: 'array',
		items: {
			additionalProperties: false,
			type: 'object',
			defaultSnippets: [{ body: { name: '', description: '' } }],
			required: ['name'],
			properties: {
				name: {
					description: localize('speechProviderName', "Unique name for this Speech Provider."),
					type: 'string'
				},
				description: {
					description: localize('speechProviderDescription', "A description of this Speech Provider, shown in the UI."),
					type: 'string'
				}
			}
		}
	}
});

export class SpeechService extends Disposable implements ISpeechService {

	readonly _serviceBrand: undefined;

	private readonly _onDidChangeHasSpeechProvider = this._register(new Emitter<void>());
	readonly onDidChangeHasSpeechProvider = this._onDidChangeHasSpeechProvider.event;

	get hasSpeechProvider() { return this.providerDescriptors.size > 0 || this.providers.size > 0; }

	private readonly providers = new Map<string, ISpeechProvider>();
	private readonly providerDescriptors = new Map<string, ISpeechProviderDescriptor>();

	private readonly hasSpeechProviderContext = HasSpeechProvider.bindTo(this.contextKeyService);

	constructor(
		@ILogService private readonly logService: ILogService,
		@IContextKeyService private readonly contextKeyService: IContextKeyService,
		@IHostService private readonly hostService: IHostService,
		@ITelemetryService private readonly telemetryService: ITelemetryService,
		@IConfigurationService private readonly configurationService: IConfigurationService,
		@IExtensionService private readonly extensionService: IExtensionService
	) {
		super();

		this.handleAndRegisterSpeechExtensions();
	}

	private handleAndRegisterSpeechExtensions(): void {
		speechProvidersExtensionPoint.setHandler((extensions, delta) => {
			const oldHasSpeechProvider = this.hasSpeechProvider;

			for (const extension of delta.removed) {
				for (const descriptor of extension.value) {
					this.providerDescriptors.delete(descriptor.name);
				}
			}

			for (const extension of delta.added) {
				for (const descriptor of extension.value) {
					this.providerDescriptors.set(descriptor.name, descriptor);
				}
			}

			if (oldHasSpeechProvider !== this.hasSpeechProvider) {
				this.handleHasSpeechProviderChange();
			}
		});
	}

	registerSpeechProvider(identifier: string, provider: ISpeechProvider): IDisposable {
		if (this.providers.has(identifier)) {
			throw new Error(`Speech provider with identifier ${identifier} is already registered.`);
		}

		const oldHasSpeechProvider = this.hasSpeechProvider;

		this.providers.set(identifier, provider);

		if (oldHasSpeechProvider !== this.hasSpeechProvider) {
			this.handleHasSpeechProviderChange();
		}

		return toDisposable(() => {
			const oldHasSpeechProvider = this.hasSpeechProvider;

			this.providers.delete(identifier);

			if (oldHasSpeechProvider !== this.hasSpeechProvider) {
				this.handleHasSpeechProviderChange();
			}
		});
	}

	private handleHasSpeechProviderChange(): void {
		this.hasSpeechProviderContext.set(this.hasSpeechProvider);

		this._onDidChangeHasSpeechProvider.fire();
	}

	//#region Transcription

	private readonly _onDidStartSpeechToTextSession = this._register(new Emitter<void>());
	readonly onDidStartSpeechToTextSession = this._onDidStartSpeechToTextSession.event;

	private readonly _onDidEndSpeechToTextSession = this._register(new Emitter<void>());
	readonly onDidEndSpeechToTextSession = this._onDidEndSpeechToTextSession.event;

	private _activeSpeechToTextSession: ISpeechToTextSession | undefined = undefined;
	get hasActiveSpeechToTextSession() { return !!this._activeSpeechToTextSession; }

	private readonly speechToTextInProgress = SpeechToTextInProgress.bindTo(this.contextKeyService);

	async createSpeechToTextSession(token: CancellationToken, context: string = 'speech'): Promise<ISpeechToTextSession> {
		const provider = await this.getProvider();

		const language = speechLanguageConfigToLanguage(this.configurationService.getValue<unknown>(SPEECH_LANGUAGE_CONFIG));
		const session = this._activeSpeechToTextSession = provider.createSpeechToTextSession(token, typeof language === 'string' ? { language } : undefined);

		const sessionStart = Date.now();
		let sessionRecognized = false;
		let sessionError = false;
		let sessionContentLength = 0;

		const disposables = new DisposableStore();

		const onSessionStoppedOrCanceled = () => {
			if (session === this._activeSpeechToTextSession) {
				this._activeSpeechToTextSession = undefined;
				this.speechToTextInProgress.reset();
				this._onDidEndSpeechToTextSession.fire();

				type SpeechToTextSessionClassification = {
					owner: 'bpasero';
					comment: 'An event that fires when a speech to text session is created';
					context: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Context of the session.' };
					sessionDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Duration of the session.' };
					sessionRecognized: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If speech was recognized.' };
					sessionError: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If speech resulted in error.' };
					sessionContentLength: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Length of the recognized text.' };
					sessionLanguage: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Configured language for the session.' };
				};
				type SpeechToTextSessionEvent = {
					context: string;
					sessionDuration: number;
					sessionRecognized: boolean;
					sessionError: boolean;
					sessionContentLength: number;
					sessionLanguage: string;
				};
				this.telemetryService.publicLog2<SpeechToTextSessionEvent, SpeechToTextSessionClassification>('speechToTextSession', {
					context,
					sessionDuration: Date.now() - sessionStart,
					sessionRecognized,
					sessionError,
					sessionContentLength,
					sessionLanguage: language
				});
			}

			disposables.dispose();
		};

		disposables.add(token.onCancellationRequested(() => onSessionStoppedOrCanceled()));
		if (token.isCancellationRequested) {
			onSessionStoppedOrCanceled();
		}

		disposables.add(session.onDidChange(e => {
			switch (e.status) {
				case SpeechToTextStatus.Started:
					if (session === this._activeSpeechToTextSession) {
						this.speechToTextInProgress.set(true);
						this._onDidStartSpeechToTextSession.fire();
					}
					break;
				case SpeechToTextStatus.Recognizing:
					sessionRecognized = true;
					break;
				case SpeechToTextStatus.Recognized:
					if (typeof e.text === 'string') {
						sessionContentLength += e.text.length;
					}
					break;
				case SpeechToTextStatus.Stopped:
					onSessionStoppedOrCanceled();
					break;
				case SpeechToTextStatus.Error:
					this.logService.error(`Speech provider error in speech to text session: ${e.text}`);
					sessionError = true;
					break;
			}
		}));

		return session;
	}

	private async getProvider(): Promise<ISpeechProvider> {

		// Send out extension activation to ensure providers can register
		await this.extensionService.activateByEvent('onSpeech');

		const provider = firstOrDefault(Array.from(this.providers.values()));
		if (!provider) {
			throw new Error(`No Speech provider is registered.`);
		} else if (this.providers.size > 1) {
			this.logService.warn(`Multiple speech providers registered. Picking first one: ${provider.metadata.displayName}`);
		}

		return provider;
	}

	//#endregion

	//#region Synthesizer

	private readonly _onDidStartTextToSpeechSession = this._register(new Emitter<void>());
	readonly onDidStartTextToSpeechSession = this._onDidStartTextToSpeechSession.event;

	private readonly _onDidEndTextToSpeechSession = this._register(new Emitter<void>());
	readonly onDidEndTextToSpeechSession = this._onDidEndTextToSpeechSession.event;

	private _activeTextToSpeechSession: ITextToSpeechSession | undefined = undefined;
	get hasActiveTextToSpeechSession() { return !!this._activeTextToSpeechSession; }

	private readonly textToSpeechInProgress = TextToSpeechInProgress.bindTo(this.contextKeyService);

	async createTextToSpeechSession(token: CancellationToken, context: string = 'speech'): Promise<ITextToSpeechSession> {
		const provider = await this.getProvider();

		const session = this._activeTextToSpeechSession = provider.createTextToSpeechSession(token);

		const sessionStart = Date.now();
		let sessionError = false;

		const disposables = new DisposableStore();

		const onSessionStoppedOrCanceled = () => {
			if (session === this._activeTextToSpeechSession) {
				this._activeTextToSpeechSession = undefined;
				this.textToSpeechInProgress.reset();
				this._onDidEndTextToSpeechSession.fire();

				type TextToSpeechSessionClassification = {
					owner: 'bpasero';
					comment: 'An event that fires when a text to speech session is created';
					context: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Context of the session.' };
					sessionDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Duration of the session.' };
					sessionError: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If speech resulted in error.' };
				};
				type TextToSpeechSessionEvent = {
					context: string;
					sessionDuration: number;
					sessionError: boolean;
				};
				this.telemetryService.publicLog2<TextToSpeechSessionEvent, TextToSpeechSessionClassification>('textToSpeechSession', {
					context,
					sessionDuration: Date.now() - sessionStart,
					sessionError
				});
			}

			disposables.dispose();
		};

		disposables.add(token.onCancellationRequested(() => onSessionStoppedOrCanceled()));
		if (token.isCancellationRequested) {
			onSessionStoppedOrCanceled();
		}

		disposables.add(session.onDidChange(e => {
			switch (e.status) {
				case TextToSpeechStatus.Started:
					if (session === this._activeTextToSpeechSession) {
						this.textToSpeechInProgress.set(true);
						this._onDidStartTextToSpeechSession.fire();
					}
					break;
				case TextToSpeechStatus.Stopped:
					onSessionStoppedOrCanceled();
					break;
				case TextToSpeechStatus.Error:
					this.logService.error(`Speech provider error in text to speech session: ${e.text}`);
					sessionError = true;
					break;
			}
		}));

		return session;
	}

	//#endregion

	//#region Keyword Recognition

	private readonly _onDidStartKeywordRecognition = this._register(new Emitter<void>());
	readonly onDidStartKeywordRecognition = this._onDidStartKeywordRecognition.event;

	private readonly _onDidEndKeywordRecognition = this._register(new Emitter<void>());
	readonly onDidEndKeywordRecognition = this._onDidEndKeywordRecognition.event;

	private _activeKeywordRecognitionSession: IKeywordRecognitionSession | undefined = undefined;
	get hasActiveKeywordRecognition() { return !!this._activeKeywordRecognitionSession; }

	async recognizeKeyword(token: CancellationToken): Promise<KeywordRecognitionStatus> {
		const result = new DeferredPromise<KeywordRecognitionStatus>();

		// Send out extension activation to ensure providers can register
		await this.extensionService.activateByEvent('onSpeech');

		const disposables = new DisposableStore();
		disposables.add(token.onCancellationRequested(() => {
			disposables.dispose();
			result.complete(KeywordRecognitionStatus.Canceled);
		}));

		const recognizeKeywordDisposables = disposables.add(new DisposableStore());
		let activeRecognizeKeywordSession: Promise<void> | undefined = undefined;
		const recognizeKeyword = () => {
			recognizeKeywordDisposables.clear();

			const cts = new CancellationTokenSource(token);
			recognizeKeywordDisposables.add(toDisposable(() => cts.dispose(true)));
			const currentRecognizeKeywordSession = activeRecognizeKeywordSession = this.doRecognizeKeyword(cts.token).then(status => {
				if (currentRecognizeKeywordSession === activeRecognizeKeywordSession) {
					result.complete(status);
				}
			}, error => {
				if (currentRecognizeKeywordSession === activeRecognizeKeywordSession) {
					result.error(error);
				}
			});
		};

		disposables.add(this.hostService.onDidChangeFocus(focused => {
			if (!focused && activeRecognizeKeywordSession) {
				recognizeKeywordDisposables.clear();
				activeRecognizeKeywordSession = undefined;
			} else if (!activeRecognizeKeywordSession) {
				recognizeKeyword();
			}
		}));

		if (this.hostService.hasFocus) {
			recognizeKeyword();
		}

		let status: KeywordRecognitionStatus;
		try {
			status = await result.p;
		} finally {
			disposables.dispose();
		}

		type KeywordRecognitionClassification = {
			owner: 'bpasero';
			comment: 'An event that fires when a speech keyword detection is started';
			keywordRecognized: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If the keyword was recognized.' };
		};
		type KeywordRecognitionEvent = {
			keywordRecognized: boolean;
		};
		this.telemetryService.publicLog2<KeywordRecognitionEvent, KeywordRecognitionClassification>('keywordRecognition', {
			keywordRecognized: status === KeywordRecognitionStatus.Recognized
		});

		return status;
	}

	private async doRecognizeKeyword(token: CancellationToken): Promise<KeywordRecognitionStatus> {
		const provider = await this.getProvider();

		const session = this._activeKeywordRecognitionSession = provider.createKeywordRecognitionSession(token);
		this._onDidStartKeywordRecognition.fire();

		const disposables = new DisposableStore();

		const onSessionStoppedOrCanceled = () => {
			if (session === this._activeKeywordRecognitionSession) {
				this._activeKeywordRecognitionSession = undefined;
				this._onDidEndKeywordRecognition.fire();
			}

			disposables.dispose();
		};

		disposables.add(token.onCancellationRequested(() => onSessionStoppedOrCanceled()));
		if (token.isCancellationRequested) {
			onSessionStoppedOrCanceled();
		}

		disposables.add(session.onDidChange(e => {
			if (e.status === KeywordRecognitionStatus.Stopped) {
				onSessionStoppedOrCanceled();
			}
		}));

		try {
			return (await Event.toPromise(session.onDidChange)).status;
		} finally {
			onSessionStoppedOrCanceled();
		}
	}

	//#endregion
}
