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

import { parse as jsonParse, getNodeType } from 'vs/base/common/json';
import { localize } from 'vs/nls';
import { extname, basename } from 'vs/base/common/path';
import { SnippetParser, Variable, Placeholder, Text } from 'vs/editor/contrib/snippet/browser/snippetParser';
import { KnownSnippetVariableNames } from 'vs/editor/contrib/snippet/browser/snippetVariables';
import { URI } from 'vs/base/common/uri';
import { IFileService } from 'vs/platform/files/common/files';
import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { IdleValue } from 'vs/base/common/async';
import { IExtensionResourceLoaderService } from 'vs/platform/extensionResourceLoader/common/extensionResourceLoader';
import { relativePath } from 'vs/base/common/resources';
import { isObject } from 'vs/base/common/types';
import { tail } from 'vs/base/common/arrays';
import { Iterable } from 'vs/base/common/iterator';

class SnippetBodyInsights {

	readonly codeSnippet: string;

	/** The snippet uses bad placeholders which collide with variable names */
	readonly isBogous: boolean;

	/** The snippet has no placeholder of the final placeholder is at the end */
	readonly isTrivial: boolean;

	readonly usesClipboardVariable: boolean;
	readonly usesSelectionVariable: boolean;

	constructor(body: string) {

		// init with defaults
		this.isBogous = false;
		this.isTrivial = false;
		this.usesClipboardVariable = false;
		this.usesSelectionVariable = false;
		this.codeSnippet = body;

		// check snippet...
		const textmateSnippet = new SnippetParser().parse(body, false);

		const placeholders = new Map<string, number>();
		let placeholderMax = 0;
		for (const placeholder of textmateSnippet.placeholders) {
			placeholderMax = Math.max(placeholderMax, placeholder.index);
		}

		// mark snippet as trivial when there is no placeholders or when the only
		// placeholder is the final tabstop and it is at the very end.
		if (textmateSnippet.placeholders.length === 0) {
			this.isTrivial = true;
		} else if (placeholderMax === 0) {
			const last = tail(textmateSnippet.children);
			this.isTrivial = last instanceof Placeholder && last.isFinalTabstop;
		}

		const stack = [...textmateSnippet.children];
		while (stack.length > 0) {
			const marker = stack.shift()!;
			if (marker instanceof Variable) {

				if (marker.children.length === 0 && !KnownSnippetVariableNames[marker.name]) {
					// a 'variable' without a default value and not being one of our supported
					// variables is automatically turned into a placeholder. This is to restore
					// a bug we had before. So `${foo}` becomes `${N:foo}`
					const index = placeholders.has(marker.name) ? placeholders.get(marker.name)! : ++placeholderMax;
					placeholders.set(marker.name, index);

					const synthetic = new Placeholder(index).appendChild(new Text(marker.name));
					textmateSnippet.replace(marker, [synthetic]);
					this.isBogous = true;
				}

				switch (marker.name) {
					case 'CLIPBOARD':
						this.usesClipboardVariable = true;
						break;
					case 'SELECTION':
					case 'TM_SELECTED_TEXT':
						this.usesSelectionVariable = true;
						break;
				}

			} else {
				// recurse
				stack.push(...marker.children);
			}
		}

		if (this.isBogous) {
			this.codeSnippet = textmateSnippet.toTextmateString();
		}

	}
}

export class Snippet {

	private readonly _bodyInsights: IdleValue<SnippetBodyInsights>;

	readonly prefixLow: string;

	constructor(
		readonly isFileTemplate: boolean,
		readonly scopes: string[],
		readonly name: string,
		readonly prefix: string,
		readonly description: string,
		readonly body: string,
		readonly source: string,
		readonly snippetSource: SnippetSource,
		readonly snippetIdentifier: string,
		readonly extensionId?: ExtensionIdentifier,
	) {
		this.prefixLow = prefix.toLowerCase();
		this._bodyInsights = new IdleValue(() => new SnippetBodyInsights(this.body));
	}

	get codeSnippet(): string {
		return this._bodyInsights.value.codeSnippet;
	}

	get isBogous(): boolean {
		return this._bodyInsights.value.isBogous;
	}

	get isTrivial(): boolean {
		return this._bodyInsights.value.isTrivial;
	}

	get needsClipboard(): boolean {
		return this._bodyInsights.value.usesClipboardVariable;
	}

	get usesSelection(): boolean {
		return this._bodyInsights.value.usesSelectionVariable;
	}
}


interface JsonSerializedSnippet {
	isFileTemplate?: boolean;
	body: string | string[];
	scope?: string;
	prefix: string | string[] | undefined;
	description: string;
}

function isJsonSerializedSnippet(thing: any): thing is JsonSerializedSnippet {
	return isObject(thing) && Boolean((<JsonSerializedSnippet>thing).body);
}

interface JsonSerializedSnippets {
	[name: string]: JsonSerializedSnippet | { [name: string]: JsonSerializedSnippet };
}

export const enum SnippetSource {
	User = 1,
	Workspace = 2,
	Extension = 3,
}

export class SnippetFile {

	readonly data: Snippet[] = [];
	readonly isGlobalSnippets: boolean;
	readonly isUserSnippets: boolean;

	private _loadPromise?: Promise<this>;

	constructor(
		readonly source: SnippetSource,
		readonly location: URI,
		public defaultScopes: string[] | undefined,
		private readonly _extension: IExtensionDescription | undefined,
		private readonly _fileService: IFileService,
		private readonly _extensionResourceLoaderService: IExtensionResourceLoaderService,
	) {
		this.isGlobalSnippets = extname(location.path) === '.code-snippets';
		this.isUserSnippets = !this._extension;
	}

	select(selector: string, bucket: Snippet[]): void {
		if (this.isGlobalSnippets || !this.isUserSnippets) {
			this._scopeSelect(selector, bucket);
		} else {
			this._filepathSelect(selector, bucket);
		}
	}

	private _filepathSelect(selector: string, bucket: Snippet[]): void {
		// for `fooLang.json` files all snippets are accepted
		if (selector + '.json' === basename(this.location.path)) {
			bucket.push(...this.data);
		}
	}

	private _scopeSelect(selector: string, bucket: Snippet[]): void {
		// for `my.code-snippets` files we need to look at each snippet
		for (const snippet of this.data) {
			const len = snippet.scopes.length;
			if (len === 0) {
				// always accept
				bucket.push(snippet);

			} else {
				for (let i = 0; i < len; i++) {
					// match
					if (snippet.scopes[i] === selector) {
						bucket.push(snippet);
						break; // match only once!
					}
				}
			}
		}

		const idx = selector.lastIndexOf('.');
		if (idx >= 0) {
			this._scopeSelect(selector.substring(0, idx), bucket);
		}
	}

	private async _load(): Promise<string> {
		if (this._extension) {
			return this._extensionResourceLoaderService.readExtensionResource(this.location);
		} else {
			const content = await this._fileService.readFile(this.location);
			return content.value.toString();
		}
	}

	load(): Promise<this> {
		if (!this._loadPromise) {
			this._loadPromise = Promise.resolve(this._load()).then(content => {
				const data = <JsonSerializedSnippets>jsonParse(content);
				if (getNodeType(data) === 'object') {
					for (const [name, scopeOrTemplate] of Object.entries(data)) {
						if (isJsonSerializedSnippet(scopeOrTemplate)) {
							this._parseSnippet(name, scopeOrTemplate, this.data);
						} else {
							for (const [name, template] of Object.entries(scopeOrTemplate)) {
								this._parseSnippet(name, template, this.data);
							}
						}
					}
				}
				return this;
			});
		}
		return this._loadPromise;
	}

	reset(): void {
		this._loadPromise = undefined;
		this.data.length = 0;
	}

	private _parseSnippet(name: string, snippet: JsonSerializedSnippet, bucket: Snippet[]): void {

		let { isFileTemplate, prefix, body, description } = snippet;

		if (!prefix) {
			prefix = '';
		}

		if (Array.isArray(body)) {
			body = body.join('\n');
		}
		if (typeof body !== 'string') {
			return;
		}

		if (Array.isArray(description)) {
			description = description.join('\n');
		}

		let scopes: string[];
		if (this.defaultScopes) {
			scopes = this.defaultScopes;
		} else if (typeof snippet.scope === 'string') {
			scopes = snippet.scope.split(',').map(s => s.trim()).filter(Boolean);
		} else {
			scopes = [];
		}

		let source: string;
		if (this._extension) {
			// extension snippet -> show the name of the extension
			source = this._extension.displayName || this._extension.name;

		} else if (this.source === SnippetSource.Workspace) {
			// workspace -> only *.code-snippets files
			source = localize('source.workspaceSnippetGlobal', "Workspace Snippet");
		} else {
			// user -> global (*.code-snippets) and language snippets
			if (this.isGlobalSnippets) {
				source = localize('source.userSnippetGlobal', "Global User Snippet");
			} else {
				source = localize('source.userSnippet', "User Snippet");
			}
		}

		for (const _prefix of Iterable.wrap(prefix)) {
			bucket.push(new Snippet(
				Boolean(isFileTemplate),
				scopes,
				name,
				_prefix,
				description,
				body,
				source,
				this.source,
				this._extension ? `${relativePath(this._extension.extensionLocation, this.location)}/${name}` : `${basename(this.location.path)}/${name}`,
				this._extension?.identifier,
			));
		}
	}
}
