const {
	EditorState,
	EditorView,
	Extension,
	StateEffect,
	StateField,
	SyntaxNode,
	Tree,
	Tooltip,
	codeFolding,
	ensureSyntaxTree,
	foldEffect,
	foldedRanges,
	keymap,
	showTooltip,
	syntaxTree,
	unfoldAll,
	unfoldEffect
} = require( 'ext.CodeMirror.v6.lib' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' );
const { matchTag } = require( './codemirror.mediawiki.matchTag.js' );

const updateSelection = ( pos, { to } ) => Math.max( pos, to ),
	updateAll = ( pos, { from, to } ) => from <= pos && to > pos ? to : pos;

/**
 * Check if a SyntaxNode is among the specified components
 *
 * @param {string[]} keys
 * @return {Function}
 * @private
 */
const isComponent = ( keys ) => (
		/**
		 * @param {SyntaxNode} node
		 * @return {boolean}
		 */
		( node ) => {
			const names = node.name.split( '_' );
			return keys.some( ( key ) => names.includes( mwModeConfig.tags[ key ] ) );
		}
	),
	/**
	 * Check if a SyntaxNode is a template bracket (`{{` or `}}`)
	 *
	 * @param {SyntaxNode} node The SyntaxNode to check
	 * @return {boolean}
	 * @private
	 */
	isTemplateBracket = isComponent( [ 'templateBracket', 'parserFunctionBracket' ] ),
	/**
	 * Check if a SyntaxNode is a template delimiter (`|` or `:`)
	 *
	 * @param {SyntaxNode} node The SyntaxNode to check
	 * @return {boolean}
	 * @private
	 */
	isDelimiter = isComponent( [ 'templateDelimiter', 'parserFunctionDelimiter' ] ),
	/**
	 * Check if a SyntaxNode is an extension tag bracket (`<` or `>`)
	 *
	 * @param {SyntaxNode} node The SyntaxNode to check
	 * @return {boolean}
	 * @private
	 */
	isExtBracket = isComponent( [ 'extTagBracket' ] );

/**
 * Check if a SyntaxNode is part of a template, except for the brackets
 *
 * @param {SyntaxNode} node The SyntaxNode to check
 * @return {boolean}
 * @private
 */
const isTemplate = ( node ) => /-(?:template|ext)[a-z\d-]+ground/.test( node.name ) &&
		!isTemplateBracket( node ),
	/**
	 * Check if a SyntaxNode is part of an extension tag
	 *
	 * @param {SyntaxNode} node The SyntaxNode to check
	 * @return {boolean}
	 * @private
	 */
	isExt = ( node ) => node.name.includes( 'mw-tag-' ) ||
		node.name.split( '_' ).includes( mwModeConfig.tags.extTag );

/**
 * Update the stack of opening (+) or closing (-) braces
 *
 * @param {EditorState} state EditorState instance
 * @param {SyntaxNode} node The SyntaxNode of the brace
 * @return {number[]}
 * @private
 */
const braceStackUpdate = ( state, node ) => {
	const braces = state.sliceDoc( node.from, node.to );
	return [ braces.split( '{{' ).length - 1, 1 - braces.split( '}}' ).length ];
};

/**
 * If the node is a template, find the range of the template parameters
 * If the node is an extension tag, find the range of the tag content
 *
 * @param {EditorState} state EditorState instance
 * @param {number|SyntaxNode} posOrNode Position or node
 * @param {Tree|null} [tree] Syntax tree
 * @return {{from: number, to: number}|false}
 * @private
 */
const foldable = ( state, posOrNode, tree ) => {
	if ( typeof posOrNode === 'number' ) {
		tree = ensureSyntaxTree( state, posOrNode );
	}
	if ( !tree ) {
		return false;
	}
	/** @type {SyntaxNode} */
	let node, nextSibling, prevSibling;
	if ( typeof posOrNode === 'number' ) {
		// Find the initial template node on both sides of the position
		node = tree.resolve( posOrNode, -1 );
		if ( !isTemplate( node ) && !isExt( node ) ) {
			node = tree.resolve( posOrNode, 1 );
		}
	} else {
		node = posOrNode;
	}
	if ( !isTemplate( node ) ) {
		// Not a template
		if ( isExt( node ) ) {
			( { nextSibling } = node );
			while ( nextSibling && !( isExtBracket( nextSibling ) &&
				state.sliceDoc( nextSibling.from, nextSibling.from + 2 ) === '</' ) ) {
				( { nextSibling } = nextSibling );
			}
			if ( nextSibling ) { // The closing bracket of the extension tag
				return { from: matchTag( state, nextSibling.to ).end.to, to: nextSibling.from };
			}
		}
		return false;
	}
	( { prevSibling, nextSibling } = node );
	/** The stack of opening (+) or closing (-) brackets */
	let stack = 1,
		/** The first delimiter */
		delimiter = isDelimiter( node ) ? node : null,
		/** The start of the closing bracket */
		to = 0;
	while ( nextSibling ) {
		if ( isTemplateBracket( nextSibling ) ) {
			const [ lbrace, rbrace ] = braceStackUpdate( state, nextSibling );
			stack += rbrace;
			if ( stack <= 0 ) {
				// The closing bracket of the current template
				to = nextSibling.from +
					state.sliceDoc( nextSibling.from, nextSibling.to ).split( '}}' )
						.slice( 0, stack - 1 ).join( '}}' ).length;
				break;
			}
			stack += lbrace;
		} else if ( !delimiter && stack === 1 && isDelimiter( nextSibling ) ) {
			// The first delimiter of the current template so far
			delimiter = nextSibling;
		}
		( { nextSibling } = nextSibling );
	}
	if ( !nextSibling ) {
		// The closing bracket of the current template is missing
		return false;
	}
	stack = -1;
	while ( prevSibling ) {
		if ( isTemplateBracket( prevSibling ) ) {
			const [ lbrace, rbrace ] = braceStackUpdate( state, prevSibling );
			stack += lbrace;
			if ( stack >= 0 ) {
				// The opening bracket of the current template
				break;
			}
			stack += rbrace;
		} else if ( stack === -1 && isDelimiter( prevSibling ) ) {
			// The first delimiter of the current template so far
			delimiter = prevSibling;
		}
		( { prevSibling } = prevSibling );
	}
	/** The end of the first delimiter */
	const from = delimiter && delimiter.to;
	if ( from && from < to ) {
		return { from, to };
	}
	return false;
};

/**
 * Create a tooltip for code folding
 *
 * @param {EditorState} state EditorState instance
 * @return {Tooltip|null}
 * @private
 */
const create = ( state ) => {
	const { selection: { main: { head } } } = state,
		range = foldable( state, head );
	if ( range ) {
		const { from, to } = range;
		let folded = false;
		// Check if the range is already folded
		foldedRanges( state ).between( from, to, ( i, j ) => {
			if ( i === from && j === to ) {
				folded = true;
			}
		} );
		return folded ?
			null :
			{
				pos: head,
				above: true,
				create( view ) {
					const dom = document.createElement( 'div' );
					dom.className = 'cm-tooltip-fold';
					dom.textContent = '\uff0d';
					dom.title = mw.msg( 'codemirror-fold' );
					dom.onclick = () => {
						view.dispatch( {
							effects: foldEffect.of( { from, to } ),
							selection: { anchor: to }
						} );
						dom.remove();
					};
					return { dom };
				}
			};
	}
	return null;
};

/**
 * Execute the folding effect
 *
 * @param {EditorView} view EditorView instance
 * @param {StateEffect[]} effects StateEffects
 * @param {number} anchor Cursor position
 * @return {boolean}
 * @private
 */
const execute = ( view, effects, anchor ) => {
	if ( effects.length > 0 ) {
		const tooltip = view.dom.querySelector( '.cm-tooltip-fold' );
		if ( tooltip ) {
			tooltip.remove();
		}
		// Fold and update the cursor position
		view.dispatch( { effects, selection: { anchor } } );
		return true;
	}
	return false;
};

/**
 * The rightmost position of all selections, to be updated with folding
 *
 * @param {EditorState} state EditorState instance
 * @return {number}
 * @private
 */
const getAnchor = ( state ) => Math.max( ...state.selection.ranges.map( ( { to } ) => to ) );

/**
 * Fold all
 *
 * @param {EditorState} state EditorState instance
 * @param {Tree} tree
 * @param {StateEffect[]} effects
 * @param {SyntaxNode} node
 * @param {number} end
 * @param {number} anchor
 * @param {Function} update
 * @return {number}
 * @private
 */
const traverse = ( state, tree, effects, node, end, anchor, update ) => {
	while ( node && node.from <= end ) {
		const range = foldable( state, node, tree );
		if ( range ) {
			effects.push( foldEffect.of( range ) );
			node = tree.resolve( range.to, 1 );
			// Update the anchor with the end of the last folded range
			anchor = update( anchor, range );
			continue;
		}
		node = node.nextSibling;
	}
	return anchor;
};

/**
 * Keymap for folding templates.
 *
 * @type {CodeMirrorKeyBinding[]}
 * @memberof module:CodeMirrorCodeFolding
 */
const foldKeymap = [
	{
		// Fold the code at the selection/cursor
		key: 'Ctrl-Shift-[',
		mac: 'Cmd-Alt-[',
		run( view ) {
			const { state } = view,
				tree = syntaxTree( state ),
				effects = [];
			let anchor = getAnchor( state );
			for ( const { from, to, empty } of state.selection.ranges ) {
				let node;
				if ( empty ) {
					// No selection, try both sides of the cursor position
					node = tree.resolve( from, -1 );
				}
				if ( !node || node.name === 'Document' ) {
					node = tree.resolve( from, 1 );
				}
				anchor = traverse( state, tree, effects, node, to, anchor, updateSelection );
			}
			return execute( view, effects, anchor );
		}
	},
	{
		// Unfold the code at the selection/cursor
		key: 'Ctrl-Shift-]',
		mac: 'Cmd-Alt-]',
		run( view ) {
			const { state } = view,
				{ selection } = state,
				effects = [],
				folded = foldedRanges( state );
			for ( const { from, to } of selection.ranges ) {
				// Unfold any folded range at the selection
				folded.between( from, to, ( i, j ) => {
					effects.push( unfoldEffect.of( { from: i, to: j } ) );
				} );
			}
			if ( effects.length > 0 ) {
				// Unfold the code and redraw the selections
				view.dispatch( { effects, selection } );
				return true;
			}
			return false;
		}
	},
	{
		// Fold all code in the document
		key: 'Ctrl-Alt-[',
		run( view ) {
			const { state } = view,
				tree = syntaxTree( state ),
				effects = [],
				anchor = traverse(
					state,
					tree,
					effects,
					tree.topNode.firstChild,
					Infinity,
					getAnchor( state ),
					updateAll
				);
			return execute( view, effects, anchor );
		}
	},
	{ key: 'Ctrl-Alt-]', run: unfoldAll }
];

/**
 * CodeMirror extension providing
 * [code folding](https://www.mediawiki.org/wiki/Help:Extension:CodeMirror#Code_folding)
 * for the MediaWiki mode. This automatically applied when using {@link CodeMirrorModeMediaWiki}.
 *
 * @module CodeMirrorCodeFolding
 * @type {Extension}
 */
const codeFoldingExtension = [
	codeFolding( {
		placeholderDOM( view ) {
			const element = document.createElement( 'span' );
			element.textContent = '…';
			element.setAttribute( 'aria-label', mw.msg( 'codemirror-folded-code' ) );
			element.title = mw.msg( 'codemirror-unfold' );
			element.className = 'cm-foldPlaceholder';
			element.onclick = ( { target } ) => {
				const pos = view.posAtDOM( target ),
					{ state } = view,
					{ selection } = state;
				foldedRanges( state ).between( pos, pos, ( from, to ) => {
					if ( from === pos ) {
						// Unfold the code and redraw the selections
						view.dispatch( { effects: unfoldEffect.of( { from, to } ), selection } );
					}
				} );
			};
			return element;
		}
	} ),
	/** @see https://codemirror.net/examples/tooltip/ */
	StateField.define( {
		create,
		update( tooltip, { state, docChanged, selection } ) {
			if ( docChanged ) {
				return null;
			}
			return selection ? create( state ) : tooltip;
		},
		provide( f ) {
			return showTooltip.from( f );
		}
	} ),
	keymap.of( foldKeymap )
];

module.exports = { codeFoldingExtension };