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

import * as vscode from 'vscode';
import parse from '@emmetio/html-matcher';
import parseStylesheet from '@emmetio/css-parser';
import { Node as FlatNode, HtmlNode as HtmlFlatNode, Property as FlatProperty, Rule as FlatRule, CssToken as FlatCssToken, Stylesheet as FlatStylesheet } from 'EmmetFlatNode';
import { DocumentStreamReader } from './bufferStream';
import * as EmmetHelper from 'vscode-emmet-helper';
import { TextDocument as LSTextDocument } from 'vscode-languageserver-textdocument';
import { getRootNode } from './parseDocument';

let _emmetHelper: typeof EmmetHelper;
let _currentExtensionsPath: string | undefined = undefined;

let _homeDir: vscode.Uri | undefined;


export function setHomeDir(homeDir: vscode.Uri) {
	_homeDir = homeDir;
}

export function getEmmetHelper() {
	// Lazy load vscode-emmet-helper instead of importing it
	// directly to reduce the start-up time of the extension
	if (!_emmetHelper) {
		_emmetHelper = require('vscode-emmet-helper');
	}
	updateEmmetExtensionsPath();
	return _emmetHelper;
}

/**
 * Update Emmet Helper to use user snippets from the extensionsPath setting
 */
export function updateEmmetExtensionsPath(forceRefresh: boolean = false) {
	if (!_emmetHelper) {
		return;
	}
	let extensionsPath = vscode.workspace.getConfiguration('emmet')['extensionsPath'];
	if (forceRefresh || _currentExtensionsPath !== extensionsPath) {
		_currentExtensionsPath = extensionsPath;
		if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {
			return;
		} else {
			const rootPath = vscode.workspace.workspaceFolders[0].uri;
			const fileSystem = vscode.workspace.fs;
			_emmetHelper.updateExtensionsPath(extensionsPath, fileSystem, rootPath, _homeDir).then(null, (err: string) => vscode.window.showErrorMessage(err));
		}
	}
}

/**
 * Mapping between languages that support Emmet and completion trigger characters
 */
export const LANGUAGE_MODES: { [id: string]: string[] } = {
	'html': ['!', '.', '}', ':', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'jade': ['!', '.', '}', ':', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'slim': ['!', '.', '}', ':', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'haml': ['!', '.', '}', ':', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'xml': ['.', '}', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'xsl': ['!', '.', '}', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'css': [':', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'scss': [':', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'sass': [':', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'less': [':', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'stylus': [':', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'javascriptreact': ['!', '.', '}', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
	'typescriptreact': ['!', '.', '}', '*', '$', ']', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
};

export function isStyleSheet(syntax: string): boolean {
	let stylesheetSyntaxes = ['css', 'scss', 'sass', 'less', 'stylus'];
	return (stylesheetSyntaxes.indexOf(syntax) > -1);
}

export function validate(allowStylesheet: boolean = true): boolean {
	let editor = vscode.window.activeTextEditor;
	if (!editor) {
		vscode.window.showInformationMessage('No editor is active');
		return false;
	}
	if (!allowStylesheet && isStyleSheet(editor.document.languageId)) {
		return false;
	}
	return true;
}

export function getMappingForIncludedLanguages(): any {
	// Explicitly map languages that have built-in grammar in VS Code to their parent language
	// to get emmet completion support
	// For other languages, users will have to use `emmet.includeLanguages` or
	// language specific extensions can provide emmet completion support
	const MAPPED_MODES: Object = {
		'handlebars': 'html',
		'php': 'html'
	};

	const finalMappedModes = Object.create(null);
	let includeLanguagesConfig = vscode.workspace.getConfiguration('emmet')['includeLanguages'];
	let includeLanguages = Object.assign({}, MAPPED_MODES, includeLanguagesConfig ? includeLanguagesConfig : {});
	Object.keys(includeLanguages).forEach(syntax => {
		if (typeof includeLanguages[syntax] === 'string' && LANGUAGE_MODES[includeLanguages[syntax]]) {
			finalMappedModes[syntax] = includeLanguages[syntax];
		}
	});
	return finalMappedModes;
}

/**
* Get the corresponding emmet mode for given vscode language mode
* E.g.: jsx for typescriptreact/javascriptreact or pug for jade
* If the language is not supported by emmet or has been excluded via `excludeLanguages` setting,
* then nothing is returned
*
* @param excludedLanguages Array of language ids that user has chosen to exclude for emmet
*/
export function getEmmetMode(language: string, excludedLanguages: string[]): string | undefined {
	if (!language || excludedLanguages.indexOf(language) > -1) {
		return;
	}
	if (/\b(typescriptreact|javascriptreact|jsx-tags)\b/.test(language)) { // treat tsx like jsx
		return 'jsx';
	}
	if (language === 'sass-indented') { // map sass-indented to sass
		return 'sass';
	}
	if (language === 'jade') {
		return 'pug';
	}
	const syntaxes = getSyntaxes();
	if (syntaxes.markup.includes(language) || syntaxes.stylesheet.includes(language)) {
		return language;
	}
	return;
}

const closeBrace = 125;
const openBrace = 123;
const slash = 47;
const star = 42;

/**
 * Traverse the given document backward & forward from given position
 * to find a complete ruleset, then parse just that to return a Stylesheet
 * @param document vscode.TextDocument
 * @param position vscode.Position
 */
export function parsePartialStylesheet(document: vscode.TextDocument, position: vscode.Position): FlatStylesheet | undefined {
	const isCSS = document.languageId === 'css';
	const positionOffset = document.offsetAt(position);
	let startOffset = 0;
	let endOffset = document.getText().length;
	const limitCharacter = positionOffset - 5000;
	const limitOffset = limitCharacter > 0 ? limitCharacter : startOffset;
	const stream = new DocumentStreamReader(document, positionOffset);

	function findOpeningCommentBeforePosition(pos: number): number | undefined {
		const text = document.getText().substring(0, pos);
		let offset = text.lastIndexOf('/*');
		if (offset === -1) {
			return;
		}
		return offset;
	}

	function findClosingCommentAfterPosition(pos: number): number | undefined {
		const text = document.getText().substring(pos);
		let offset = text.indexOf('*/');
		if (offset === -1) {
			return;
		}
		offset += 2 + pos;
		return offset;
	}

	function consumeLineCommentBackwards() {
		const posLineNumber = document.positionAt(stream.pos).line;
		if (!isCSS && currentLine !== posLineNumber) {
			currentLine = posLineNumber;
			const startLineComment = document.lineAt(currentLine).text.indexOf('//');
			if (startLineComment > -1) {
				stream.pos = document.offsetAt(new vscode.Position(currentLine, startLineComment));
			}
		}
	}

	function consumeBlockCommentBackwards() {
		if (stream.peek() === slash) {
			if (stream.backUp(1) === star) {
				stream.pos = findOpeningCommentBeforePosition(stream.pos) ?? startOffset;
			} else {
				stream.next();
			}
		}
	}

	function consumeCommentForwards() {
		if (stream.eat(slash)) {
			if (stream.eat(slash) && !isCSS) {
				const posLineNumber = document.positionAt(stream.pos).line;
				stream.pos = document.offsetAt(new vscode.Position(posLineNumber + 1, 0));
			} else if (stream.eat(star)) {
				stream.pos = findClosingCommentAfterPosition(stream.pos) ?? endOffset;
			}
		}
	}

	// Go forward until we find a closing brace.
	while (!stream.eof() && !stream.eat(closeBrace)) {
		if (stream.peek() === slash) {
			consumeCommentForwards();
		} else {
			stream.next();
		}
	}

	if (!stream.eof()) {
		endOffset = stream.pos;
	}

	stream.pos = positionOffset;
	let openBracesToFind = 1;
	let currentLine = position.line;
	let exit = false;

	// Go back until we found an opening brace. If we find a closing one, consume its pair and continue.
	while (!exit && openBracesToFind > 0 && !stream.sof()) {
		consumeLineCommentBackwards();

		switch (stream.backUp(1)) {
			case openBrace:
				openBracesToFind--;
				break;
			case closeBrace:
				if (isCSS) {
					stream.next();
					startOffset = stream.pos;
					exit = true;
				} else {
					openBracesToFind++;
				}
				break;
			case slash:
				consumeBlockCommentBackwards();
				break;
			default:
				break;
		}

		if (position.line - document.positionAt(stream.pos).line > 100
			|| stream.pos <= limitOffset) {
			exit = true;
		}
	}

	// We are at an opening brace. We need to include its selector.
	currentLine = document.positionAt(stream.pos).line;
	openBracesToFind = 0;
	let foundSelector = false;
	while (!exit && !stream.sof() && !foundSelector && openBracesToFind >= 0) {
		consumeLineCommentBackwards();

		const ch = stream.backUp(1);
		if (/\s/.test(String.fromCharCode(ch))) {
			continue;
		}

		switch (ch) {
			case slash:
				consumeBlockCommentBackwards();
				break;
			case closeBrace:
				openBracesToFind++;
				break;
			case openBrace:
				openBracesToFind--;
				break;
			default:
				if (!openBracesToFind) {
					foundSelector = true;
				}
				break;
		}

		if (!stream.sof() && foundSelector) {
			startOffset = stream.pos;
		}
	}

	try {
		const buffer = ' '.repeat(startOffset) + document.getText().substring(startOffset, endOffset);
		return parseStylesheet(buffer);
	} catch (e) {
		return;
	}
}

/**
 * Returns node corresponding to given position in the given root node
 */
export function getFlatNode(root: FlatNode | undefined, offset: number, includeNodeBoundary: boolean): FlatNode | undefined {
	if (!root) {
		return;
	}

	function getFlatNodeChild(child: FlatNode | undefined): FlatNode | undefined {
		if (!child) {
			return;
		}
		const nodeStart = child.start;
		const nodeEnd = child.end;
		if ((nodeStart < offset && nodeEnd > offset)
			|| (includeNodeBoundary && nodeStart <= offset && nodeEnd >= offset)) {
			return getFlatNodeChildren(child.children) ?? child;
		}
		else if ('close' in <any>child) {
			// We have an HTML node in this case.
			// In case this node is an invalid unpaired HTML node,
			// we still want to search its children
			const htmlChild = <HtmlFlatNode>child;
			if (htmlChild.open && !htmlChild.close) {
				return getFlatNodeChildren(htmlChild.children);
			}
		}
		return;
	}

	function getFlatNodeChildren(children: FlatNode[]): FlatNode | undefined {
		for (let i = 0; i < children.length; i++) {
			const foundChild = getFlatNodeChild(children[i]);
			if (foundChild) {
				return foundChild;
			}
		}
		return;
	}

	return getFlatNodeChildren(root.children);
}

export const allowedMimeTypesInScriptTag = ['text/html', 'text/plain', 'text/x-template', 'text/template', 'text/ng-template'];

/**
 * Finds the HTML node within an HTML document at a given position
 * If position is inside a script tag of type template, then it will be parsed to find the inner HTML node as well
 */
export function getHtmlFlatNode(documentText: string, root: FlatNode | undefined, offset: number, includeNodeBoundary: boolean): HtmlFlatNode | undefined {
	const currentNode: HtmlFlatNode | undefined = <HtmlFlatNode | undefined>getFlatNode(root, offset, includeNodeBoundary);
	if (!currentNode) { return; }

	const isTemplateScript = currentNode.name === 'script' &&
		(currentNode.attributes &&
			currentNode.attributes.some(x => x.name.toString() === 'type'
				&& allowedMimeTypesInScriptTag.includes(x.value.toString())));
	if (isTemplateScript
		&& currentNode.open
		&& offset > currentNode.open.end
		&& (!currentNode.close || offset < currentNode.close.start)) {
		// blank out the rest of the document and search for the node within
		const beforePadding = ' '.repeat(currentNode.open.end);
		const endToUse = currentNode.close ? currentNode.close.start : currentNode.end;
		const scriptBodyText = beforePadding + documentText.substring(currentNode.open.end, endToUse);
		const innerRoot: HtmlFlatNode = parse(scriptBodyText);
		const scriptBodyNode = getHtmlFlatNode(scriptBodyText, innerRoot, offset, includeNodeBoundary);
		if (scriptBodyNode) {
			scriptBodyNode.parent = currentNode;
			currentNode.children.push(scriptBodyNode);
			return scriptBodyNode;
		}
	}
	return currentNode;
}

export function offsetRangeToSelection(document: vscode.TextDocument, start: number, end: number): vscode.Selection {
	const startPos = document.positionAt(start);
	const endPos = document.positionAt(end);
	return new vscode.Selection(startPos, endPos);
}

export function offsetRangeToVsRange(document: vscode.TextDocument, start: number, end: number): vscode.Range {
	const startPos = document.positionAt(start);
	const endPos = document.positionAt(end);
	return new vscode.Range(startPos, endPos);
}

/**
 * Returns the deepest non comment node under given node
 */
export function getDeepestFlatNode(node: FlatNode | undefined): FlatNode | undefined {
	if (!node || !node.children || node.children.length === 0 || !node.children.find(x => x.type !== 'comment')) {
		return node;
	}
	for (let i = node.children.length - 1; i >= 0; i--) {
		if (node.children[i].type !== 'comment') {
			return getDeepestFlatNode(node.children[i]);
		}
	}
	return undefined;
}

export function findNextWord(propertyValue: string, pos: number): [number | undefined, number | undefined] {

	let foundSpace = pos === -1;
	let foundStart = false;
	let foundEnd = false;

	let newSelectionStart;
	let newSelectionEnd;
	while (pos < propertyValue.length - 1) {
		pos++;
		if (!foundSpace) {
			if (propertyValue[pos] === ' ') {
				foundSpace = true;
			}
			continue;
		}
		if (foundSpace && !foundStart && propertyValue[pos] === ' ') {
			continue;
		}
		if (!foundStart) {
			newSelectionStart = pos;
			foundStart = true;
			continue;
		}
		if (propertyValue[pos] === ' ') {
			newSelectionEnd = pos;
			foundEnd = true;
			break;
		}
	}

	if (foundStart && !foundEnd) {
		newSelectionEnd = propertyValue.length;
	}

	return [newSelectionStart, newSelectionEnd];
}

export function findPrevWord(propertyValue: string, pos: number): [number | undefined, number | undefined] {

	let foundSpace = pos === propertyValue.length;
	let foundStart = false;
	let foundEnd = false;

	let newSelectionStart;
	let newSelectionEnd;
	while (pos > -1) {
		pos--;
		if (!foundSpace) {
			if (propertyValue[pos] === ' ') {
				foundSpace = true;
			}
			continue;
		}
		if (foundSpace && !foundEnd && propertyValue[pos] === ' ') {
			continue;
		}
		if (!foundEnd) {
			newSelectionEnd = pos + 1;
			foundEnd = true;
			continue;
		}
		if (propertyValue[pos] === ' ') {
			newSelectionStart = pos + 1;
			foundStart = true;
			break;
		}
	}

	if (foundEnd && !foundStart) {
		newSelectionStart = 0;
	}

	return [newSelectionStart, newSelectionEnd];
}

export function getNodesInBetween(node1: FlatNode, node2: FlatNode): FlatNode[] {
	// Same node
	if (sameNodes(node1, node2)) {
		return [node1];
	}

	// Not siblings
	if (!sameNodes(node1.parent, node2.parent)) {
		// node2 is ancestor of node1
		if (node2.start < node1.start) {
			return [node2];
		}

		// node1 is ancestor of node2
		if (node2.start < node1.end) {
			return [node1];
		}

		// Get the highest ancestor of node1 that should be commented
		while (node1.parent && node1.parent.end < node2.start) {
			node1 = node1.parent;
		}

		// Get the highest ancestor of node2 that should be commented
		while (node2.parent && node2.parent.start > node1.start) {
			node2 = node2.parent;
		}
	}

	const siblings: FlatNode[] = [];
	let currentNode: FlatNode | undefined = node1;
	const position = node2.end;
	while (currentNode && position > currentNode.start) {
		siblings.push(currentNode);
		currentNode = currentNode.nextSibling;
	}
	return siblings;
}

export function sameNodes(node1: FlatNode | undefined, node2: FlatNode | undefined): boolean {
	// return true if they're both undefined
	if (!node1 && !node2) {
		return true;
	}
	// return false if only one of them is undefined
	if (!node1 || !node2) {
		return false;
	}
	return node1.start === node2.start && node1.end === node2.end;
}

export function getEmmetConfiguration(syntax: string) {
	const emmetConfig = vscode.workspace.getConfiguration('emmet');
	const syntaxProfiles = Object.assign({}, emmetConfig['syntaxProfiles'] || {});
	const preferences = Object.assign({}, emmetConfig['preferences'] || {});
	// jsx, xml and xsl syntaxes need to have self closing tags unless otherwise configured by user
	if (syntax === 'jsx' || syntax === 'xml' || syntax === 'xsl') {
		syntaxProfiles[syntax] = syntaxProfiles[syntax] || {};
		if (typeof syntaxProfiles[syntax] === 'object'
			&& !syntaxProfiles[syntax].hasOwnProperty('self_closing_tag') // Old Emmet format
			&& !syntaxProfiles[syntax].hasOwnProperty('selfClosingStyle') // Emmet 2.0 format
		) {
			syntaxProfiles[syntax] = {
				...syntaxProfiles[syntax],
				selfClosingStyle: 'xml'
			};
		}
	}

	return {
		preferences,
		showExpandedAbbreviation: emmetConfig['showExpandedAbbreviation'],
		showAbbreviationSuggestions: emmetConfig['showAbbreviationSuggestions'],
		syntaxProfiles,
		variables: emmetConfig['variables'],
		excludeLanguages: emmetConfig['excludeLanguages'],
		showSuggestionsAsSnippets: emmetConfig['showSuggestionsAsSnippets']
	};
}

/**
 * Itereates by each child, as well as nested child's children, in their order
 * and invokes `fn` for each. If `fn` function returns `false`, iteration stops
 */
export function iterateCSSToken(token: FlatCssToken, fn: (x: any) => any): boolean {
	for (let i = 0, il = token.size; i < il; i++) {
		if (fn(token.item(i)) === false || iterateCSSToken(token.item(i), fn) === false) {
			return false;
		}
	}
	return true;
}

/**
 * Returns `name` CSS property from given `rule`
 */
export function getCssPropertyFromRule(rule: FlatRule, name: string): FlatProperty | undefined {
	return rule.children.find(node => node.type === 'property' && node.name === name) as FlatProperty;
}

/**
 * Returns css property under caret in given editor or `null` if such node cannot
 * be found
 */
export function getCssPropertyFromDocument(editor: vscode.TextEditor, position: vscode.Position): FlatProperty | null {
	const document = editor.document;
	const rootNode = getRootNode(document, true);
	const offset = document.offsetAt(position);
	const node = getFlatNode(rootNode, offset, true);

	if (isStyleSheet(editor.document.languageId)) {
		return node && node.type === 'property' ? <FlatProperty>node : null;
	}

	const htmlNode = <HtmlFlatNode>node;
	if (htmlNode
		&& htmlNode.name === 'style'
		&& htmlNode.open && htmlNode.close
		&& htmlNode.open.end < offset
		&& htmlNode.close.start > offset) {
		const buffer = ' '.repeat(htmlNode.start) +
			document.getText().substring(htmlNode.start, htmlNode.end);
		const innerRootNode = parseStylesheet(buffer);
		const innerNode = getFlatNode(innerRootNode, offset, true);
		return (innerNode && innerNode.type === 'property') ? <FlatProperty>innerNode : null;
	}

	return null;
}


export function getEmbeddedCssNodeIfAny(document: vscode.TextDocument, currentNode: FlatNode | undefined, position: vscode.Position): FlatNode | undefined {
	if (!currentNode) {
		return;
	}
	const currentHtmlNode = <HtmlFlatNode>currentNode;
	if (currentHtmlNode && currentHtmlNode.open && currentHtmlNode.close) {
		const offset = document.offsetAt(position);
		if (currentHtmlNode.open.end <= offset && offset <= currentHtmlNode.close.start) {
			if (currentHtmlNode.name === 'style'
				&& currentHtmlNode.open.end < offset
				&& currentHtmlNode.close.start > offset) {
				const buffer = ' '.repeat(currentHtmlNode.open.end) + document.getText().substring(currentHtmlNode.open.end, currentHtmlNode.close.start);
				return parseStylesheet(buffer);
			}
		}
	}
	return;
}

export function isStyleAttribute(currentNode: FlatNode | undefined, offset: number): boolean {
	if (!currentNode) {
		return false;
	}
	const currentHtmlNode = <HtmlFlatNode>currentNode;
	const index = (currentHtmlNode.attributes || []).findIndex(x => x.name.toString() === 'style');
	if (index === -1) {
		return false;
	}
	const styleAttribute = currentHtmlNode.attributes[index];
	return offset >= styleAttribute.value.start && offset <= styleAttribute.value.end;
}

export function isNumber(obj: any): obj is number {
	return typeof obj === 'number';
}

export function toLSTextDocument(doc: vscode.TextDocument): LSTextDocument {
	return LSTextDocument.create(doc.uri.toString(), doc.languageId, doc.version, doc.getText());
}

export function getPathBaseName(path: string): string {
	const pathAfterSlashSplit = path.split('/').pop();
	const pathAfterBackslashSplit = pathAfterSlashSplit ? pathAfterSlashSplit.split('\\').pop() : '';
	return pathAfterBackslashSplit ?? '';
}

export function getSyntaxes() {
	/**
	 * List of all known syntaxes, from emmetio/emmet
	 */
	return {
		markup: ['html', 'xml', 'xsl', 'jsx', 'js', 'pug', 'slim', 'haml'],
		stylesheet: ['css', 'sass', 'scss', 'less', 'sss', 'stylus']
	};
}
