// CodeMirror 6 commands that modify markdown formatting (e.g. toggleBold).

import { EditorView, Command } from '@codemirror/view';

import { ListType } from '../../types';
import {
	SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
} from '@codemirror/state';
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
import {
	RegionSpec, growSelectionToNode, renumberSelectedLists,
	toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
	isIndentationEquivalent, stripBlockquote, tabsToSpaces,
} from './markdownReformatter';
import intersectsSyntaxNode from '../util/isInSyntaxNode';

const startingSpaceRegex = /^(\s*)/;

export const toggleBolded: Command = (view: EditorView): boolean => {
	const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' });
	const changes = toggleInlineFormatGlobally(view.state, spec);

	view.dispatch(changes);
	return true;
};

export const toggleItalicized: Command = (view: EditorView): boolean => {
	let handledBoldItalicRegion = false;

	// Bold-italic regions' starting and ending patterns are similar to italicized regions.
	// Thus, we need additional logic to convert bold regions to bold-italic regions.
	view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
		const changes: ChangeSpec[] = [];

		// Only handle cursors (empty selections)
		if (sel.empty) {
			const doc = view.state.doc;
			const selLine = doc.lineAt(sel.from);

			const selStartLineIdx = sel.from - selLine.from;
			const selEndLineIdx = sel.to - selLine.from;
			const beforeSel = selLine.text.substring(0, selStartLineIdx);
			const afterSel = selLine.text.substring(selEndLineIdx);

			const isBolded = beforeSel.endsWith('**') && afterSel.startsWith('**');

			// If at the end of a bold-italic region, exit the region.
			if (afterSel.startsWith('***')) {
				sel = EditorSelection.cursor(sel.to + 3);
				handledBoldItalicRegion = true;
			} else if (isBolded) {
				// Create a bold-italic region.
				changes.push({
					from: sel.from,
					to: sel.to,
					insert: '**',
				});

				// Move to the center of the bold-italic region (**|**** -> ***|***)
				sel = EditorSelection.cursor(sel.to + 1);
				handledBoldItalicRegion = true;
			}
		}

		return {
			changes,
			range: sel,
		};
	}));

	if (!handledBoldItalicRegion) {
		const changes = toggleInlineFormatGlobally(view.state, {
			nodeName: 'Emphasis',

			template: { start: '*', end: '*' },
			matcher: { start: /[_*]/g, end: /[_*]/g },
		});
		view.dispatch(changes);
	}

	return true;
};

// If the selected region is an empty inline code block, it will be converted to
// a block (fenced) code block.
export const toggleCode: Command = (view: EditorView): boolean => {
	const codeFenceRegex = /^```\w*\s*$/;
	const inlineRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode' });
	const blockRegionSpec: RegionSpec = {
		nodeName: 'FencedCode',
		template: { start: '```', end: '```' },
		matcher: { start: codeFenceRegex, end: codeFenceRegex },
	};

	const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
	view.dispatch(changes);

	return true;
};

export const toggleMath: Command = (view: EditorView): boolean => {
	const blockStartRegex = /^\$\$/;
	const blockEndRegex = /\$\$\s*$/;
	const inlineRegionSpec = RegionSpec.of({ nodeName: 'InlineMath', template: '$' });
	const blockRegionSpec = RegionSpec.of({
		nodeName: 'BlockMath',
		template: '$$',
		matcher: {
			start: blockStartRegex,
			end: blockEndRegex,
		},
	});

	const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
	view.dispatch(changes);

	return true;
};

export const toggleList = (listType: ListType): Command => {
	return (view: EditorView): boolean => {
		let state = view.state;
		let doc = state.doc;

		const orderedListTag = 'OrderedList';
		const unorderedListTag = 'BulletList';

		// RegExps for different list types. The regular expressions MUST
		// be mutually exclusive.
		// `(?!\[[ xX]+\]\s?)` means "not followed by [x] or [ ]".
		const bulletedRegex = /^\s*([-*])(?!\s\[[ xX]+\])\s?/;
		const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/;
		const numberedRegex = /^\s*\d+\.\s?/;

		const listRegexes: Record<ListType, RegExp> = {
			[ListType.OrderedList]: numberedRegex,
			[ListType.CheckList]: checklistRegex,
			[ListType.UnorderedList]: bulletedRegex,
		};

		const getContainerType = (line: Line): ListType|null => {
			const lineContent = stripBlockquote(line);

			// Determine the container's type.
			const checklistMatch = lineContent.match(checklistRegex);
			const bulletListMatch = lineContent.match(bulletedRegex);
			const orderedListMatch = lineContent.match(numberedRegex);

			if (checklistMatch) {
				return ListType.CheckList;
			} else if (bulletListMatch) {
				return ListType.UnorderedList;
			} else if (orderedListMatch) {
				return ListType.OrderedList;
			}

			return null;
		};

		const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
			const changes: ChangeSpec[] = [];
			let containerType: ListType|null = null;

			// Total number of characters added (deleted if negative)
			let charsAdded = 0;

			const originalSel = sel;
			let fromLine: Line;
			let toLine: Line;
			let firstLineIndentation: string;
			let firstLineInBlockQuote: boolean;
			let fromLineContent: string;
			const computeSelectionProps = () => {
				fromLine = doc.lineAt(sel.from);
				toLine = doc.lineAt(sel.to);
				fromLineContent = stripBlockquote(fromLine);
				firstLineIndentation = fromLineContent.match(startingSpaceRegex)[0];
				firstLineInBlockQuote = (fromLineContent !== fromLine.text);

				containerType = getContainerType(fromLine);
			};
			computeSelectionProps();

			const origFirstLineIndentation = firstLineIndentation;
			const origContainerType = containerType;

			// Grow `sel` to the smallest containing list, unless the
			// cursor is on an empty line, in which case, the user
			// probably wants to add a list item (and not select the entire
			// list).
			if (sel.empty && fromLine.text.trim() !== '') {
				sel = growSelectionToNode(state, sel, [orderedListTag, unorderedListTag]);
				computeSelectionProps();
			}

			// Reset the selection if it seems likely the user didn't want the selection
			// to be expanded
			const isIndentationDiff =
				!isIndentationEquivalent(state, firstLineIndentation, origFirstLineIndentation);
			if (isIndentationDiff) {
				const expandedRegionIndentation = firstLineIndentation;
				sel = originalSel;
				computeSelectionProps();

				// Use the indentation level of the expanded region if it's greater.
				// This makes sense in the case where unindented text is being converted to
				// the same type of list as its container. For example,
				//     1. Foobar
				// unindented text
				// that should be made a part of the above list.
				//
				// becoming
				//
				//     1. Foobar
				//     2. unindented text
				//     3. that should be made a part of the above list.
				const wasGreaterIndentation = (
					tabsToSpaces(state, expandedRegionIndentation).length
						> tabsToSpaces(state, firstLineIndentation).length
				);
				if (wasGreaterIndentation) {
					firstLineIndentation = expandedRegionIndentation;
				}
			} else if (
				(origContainerType !== containerType && (origContainerType ?? null) !== null)
					|| containerType !== getContainerType(toLine)
			) {
				// If the container type changed, this could be an artifact of checklists/bulleted
				// lists sharing the same node type.
				// Find the closest range of the same type of list to the original selection
				let newFromLineNo = doc.lineAt(originalSel.from).number;
				let newToLineNo = doc.lineAt(originalSel.to).number;
				let lastFromLineNo;
				let lastToLineNo;

				while (newFromLineNo !== lastFromLineNo || newToLineNo !== lastToLineNo) {
					lastFromLineNo = newFromLineNo;
					lastToLineNo = newToLineNo;

					if (lastFromLineNo - 1 >= 1) {
						const testFromLine = doc.line(lastFromLineNo - 1);
						if (getContainerType(testFromLine) === origContainerType) {
							newFromLineNo --;
						}
					}

					if (lastToLineNo + 1 <= doc.lines) {
						const testToLine = doc.line(lastToLineNo + 1);
						if (getContainerType(testToLine) === origContainerType) {
							newToLineNo ++;
						}
					}
				}

				sel = EditorSelection.range(
					doc.line(newFromLineNo).from,
					doc.line(newToLineNo).to,
				);
				computeSelectionProps();
			}

			// Determine whether the expanded selection should be empty
			if (originalSel.empty && fromLine.number === toLine.number) {
				sel = EditorSelection.cursor(toLine.to);
			}

			// Select entire lines (if not just a cursor)
			if (!sel.empty) {
				sel = EditorSelection.range(fromLine.from, toLine.to);
			}

			// Number of the item in the list (e.g. 2 for the 2nd item in the list)
			let listItemCounter = 1;
			for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum ++) {
				const line = doc.line(lineNum);
				const lineContent = stripBlockquote(line);
				const lineContentFrom = line.to - lineContent.length;
				const inBlockQuote = (lineContent !== line.text);
				const indentation = lineContent.match(startingSpaceRegex)[0];

				const wrongIndentation = !isIndentationEquivalent(state, indentation, firstLineIndentation);

				// If not the right list level,
				if (inBlockQuote !== firstLineInBlockQuote || wrongIndentation) {
					// We'll be starting a new list
					listItemCounter = 1;
					continue;
				}

				// Don't add list numbers to otherwise empty lines (unless it's the first line)
				if (lineNum !== fromLine.number && line.text.trim().length === 0) {
					// Do not reset the counter -- the markdown renderer doesn't!
					continue;
				}

				const deleteFrom = lineContentFrom;
				let deleteTo = deleteFrom + indentation.length;

				// If we need to remove an existing list,
				const currentContainer = getContainerType(line);
				if (currentContainer !== null) {
					const containerRegex = listRegexes[currentContainer];
					const containerMatch = lineContent.match(containerRegex);
					if (!containerMatch) {
						throw new Error(
							'Assertion failed: container regex does not match line content.',
						);
					}

					deleteTo = lineContentFrom + containerMatch[0].length;
				}

				let replacementString;

				if (listType === containerType) {
					// Delete the existing list if it's the same type as the current
					replacementString = '';
				} else if (listType === ListType.OrderedList) {
					replacementString = `${firstLineIndentation}${listItemCounter}. `;
				} else if (listType === ListType.CheckList) {
					replacementString = `${firstLineIndentation}- [ ] `;
				} else {
					replacementString = `${firstLineIndentation}- `;
				}

				changes.push({
					from: deleteFrom,
					to: deleteTo,
					insert: replacementString,
				});
				charsAdded -= deleteTo - deleteFrom;
				charsAdded += replacementString.length;
				listItemCounter++;
			}

			// Don't change cursors to selections
			if (sel.empty) {
				// Position the cursor at the end of the last line modified
				sel = EditorSelection.cursor(toLine.to + charsAdded);
			} else {
				sel = EditorSelection.range(
					sel.from,
					sel.to + charsAdded,
				);
			}

			return {
				changes,
				range: sel,
			};
		});
		view.dispatch(changes);
		state = view.state;
		doc = state.doc;

		// Renumber the list
		view.dispatch(renumberSelectedLists(state));

		return true;
	};
};

export const toggleHeaderLevel = (level: number): Command => {
	return (view: EditorView): boolean => {
		let headerStr = '';
		for (let i = 0; i < level; i++) {
			headerStr += '#';
		}

		const matchEmpty = true;
		// Remove header formatting for any other level
		let changes = toggleSelectedLinesStartWith(
			view.state,
			new RegExp(
				// Check all numbers of #s lower than [level]
				`${level - 1 >= 1 ? `(?:^[#]{1,${level - 1}}\\s)|` : ''

				// Check all number of #s higher than [level]
				}(?:^[#]{${level + 1},}\\s)`,
			),
			'',
			matchEmpty,
		);
		view.dispatch(changes);

		// Set to the proper header level
		changes = toggleSelectedLinesStartWith(
			view.state,
			// We want exactly [level] '#' characters.
			new RegExp(`^[#]{${level}} `),
			`${headerStr} `,
			matchEmpty,
		);
		view.dispatch(changes);

		return true;
	};
};

// Prepends the given editor's indentUnit to all lines of the current selection
// and re-numbers modified ordered lists (if any).
export const increaseIndent: Command = (view: EditorView): boolean => {
	const matchEmpty = true;
	const matchNothing = /$ ^/;
	const indentUnit = indentString(view.state, getIndentUnit(view.state));

	const changes = toggleSelectedLinesStartWith(
		view.state,
		// Delete nothing
		matchNothing,
		// ...and thus always add indentUnit.
		indentUnit,
		matchEmpty,
	);
	view.dispatch(changes);

	// Fix any lists
	view.dispatch(renumberSelectedLists(view.state));

	return true;
};

// Like `increaseIndent`, but may insert tabs, rather than
// indenting, in some instances.
export const insertOrIncreaseIndent: Command = (view: EditorView): boolean => {
	const selection = view.state.selection;
	const mainSelection = selection.main;
	if (selection.ranges.length !== 1 || !mainSelection.empty) {
		return increaseIndent(view);
	}


	if (intersectsSyntaxNode(view.state, mainSelection, 'ListItem')) {
		return increaseIndent(view);
	}

	const indentUnit = indentString(view.state, getIndentUnit(view.state));
	view.dispatch(view.state.changeByRange(selection => {
		return {
			// Move the selection to after the inserted text
			range: EditorSelection.cursor(selection.from + indentUnit.length),
			changes: {
				from: selection.from,
				insert: indentUnit,
			},
		};
	}));

	return true;
};

export const decreaseIndent: Command = (view: EditorView): boolean => {
	const matchEmpty = true;
	const changes = toggleSelectedLinesStartWith(
		view.state,
		// Assume indentation is either a tab or in units
		// of n spaces.
		new RegExp(`^(?:[\\t]|[ ]{1,${getIndentUnit(view.state)}})`),
		// Don't add new text
		'',
		matchEmpty,
	);

	view.dispatch(changes);

	// Fix any lists
	view.dispatch(renumberSelectedLists(view.state));

	return true;
};

export const updateLink = (label: string, url: string): Command => {
	// Empty label? Just include the URL.
	const linkText = label === '' ? url : `[${label}](${url})`;

	return (editor: EditorView): boolean => {
		const transaction = editor.state.changeByRange((sel: SelectionRange) => {
			const changes = [];

			// Search for a link that overlaps [sel]
			let linkFrom: number | null = null;
			let linkTo: number | null = null;
			syntaxTree(editor.state).iterate({
				from: sel.from, to: sel.to,
				enter: node => {
					const haveFoundLink = (linkFrom !== null && linkTo !== null);

					if (node.name === 'Link' || (node.name === 'URL' && !haveFoundLink)) {
						linkFrom = node.from;
						linkTo = node.to;
					}
				},
			});

			linkFrom ??= sel.from;
			linkTo ??= sel.to;

			changes.push({
				from: linkFrom, to: linkTo,
				insert: linkText,
			});

			return {
				changes,
				range: EditorSelection.range(linkFrom, linkFrom + linkText.length),
			};
		});

		editor.dispatch(transaction);
		return true;
	};
};
