import EventObject from '../../../scripts/core/EventObject.mjs';

const PARAM_PATTERN = /\{[^}]+\}/g;

function addNewline(value) {
	if(value.length > 0 && value.charAt(value.length - 1) !== '\n') {
		return value + '\n';
	}
	return value;
}

function findPos(content, index) {
	let p = 0;
	let line = 0;
	for(;;) {
		const nextLn = content.indexOf('\n', p) + 1;
		if(index < nextLn || nextLn === 0) {
			return {ch: index - p, line};
		}
		p = nextLn;
		++ line;
	}
}

function cmInRange(pos, {from, to}) {
	return !(
		pos.line < from.line || (pos.line === from.line && pos.ch < from.ch) ||
		pos.line > to.line || (pos.line === to.line && pos.ch > to.ch)
	);
}

function findNextToken(block, skip) {
	PARAM_PATTERN.lastIndex = 0;
	for(let m = null; (m = PARAM_PATTERN.exec(block));) {
		if(!skip.includes(m[0])) {
			return m[0];
		}
	}
	return null;
}

function lineWithinRange(doc, ln, start, end) {
	const full = doc.getLine(ln);
	const begin = (ln === start.line) ? start.ch : 0;
	return {
		chOffset: begin,
		line: (ln === end.line) ? full.slice(begin, end.ch) : full.slice(begin),
	};
}

export default class CodeEditor extends EventObject {
	constructor(dom, container, {
		mode = '',
		value = '',
	}) {
		super();

		this.mode = mode;

		this.marker = null;

		this.isAutocompleting = false;
		this.enhanced = false;

		this.code = dom.el('textarea')
			.setClass('editor-simple')
			.val(value)
			.on('input', () => this.trigger('change'))
			.on('focus', () => this.trigger('focus'))
			.attach(container);

		this._enhance();
	}

	markLineHover(ln = null) {
		this.unmarkLineHover();
		if(ln !== null && this.enhanced) {
			this.marker = this.code.markText(
				{ch: 0, line: ln},
				{ch: 0, line: ln + 1},
				{
					className: 'hover',
					clearOnEnter: true,
					inclusiveLeft: false,
					inclusiveRight: false,
				},
			);
		}
	}

	unmarkLineHover() {
		if(this.marker) {
			this.marker.clear();
			this.marker = null;
		}
	}

	selectLine(ln = null) {
		if(ln === null) {
			return;
		}
		if(this.enhanced) {
			this.code.setSelection(
				{ch: 0, line: ln},
				{ch: 0, line: ln + 1},
				{bias: -1, origin: '+focus'},
			);
			this.code.focus();
		}
	}

	enterParams(start, end, block) {
		if(!block.includes('{')) {
			return;
		}
		if(this.cancelParams) {
			this.cancelParams();
		}

		const doc = this.code.getDoc();
		const endBookmark = doc.setBookmark(end);
		const done = [];

		const keydown = (cm, event) => {
			switch(event.keyCode) {
			case 13:
			case 9:
				if(!this.isAutocompleting) {
					event.preventDefault();
				}
				this.advanceParams();
				break;
			case 27:
				if(!this.isAutocompleting) {
					event.preventDefault();
					this.cancelParams();
				}
				break;
			}
		};

		const move = () => {
			if(this.paramMarkers.length === 0) {
				return;
			}
			const m = this.paramMarkers[0].find();
			const [r] = doc.listSelections();
			if(!cmInRange(r.anchor, m) || !cmInRange(r.head, m)) {
				this.cancelParams();
				this.code.setSelection(r.anchor, r.head);
			}
		};

		this.paramMarkers = [];
		this.cancelParams = () => {
			this.code.off('keydown', keydown);
			this.code.off('cursorActivity', move);
			this.paramMarkers.forEach((m) => m.clear());
			this.paramMarkers = null;
			this.code.setCursor(endBookmark.find());
			endBookmark.clear();
			this.cancelParams = null;
			this.advanceParams = null;
		};
		this.advanceParams = () => {
			this.paramMarkers.forEach((m) => m.clear());
			this.paramMarkers.length = 0;
			this.nextParams(start, endBookmark, block, done);
		};

		this.code.on('keydown', keydown);
		this.code.on('cursorActivity', move);

		this.advanceParams();
	}

	nextParams(start, endBookmark, block, done) {
		const tok = findNextToken(block, done);
		if(!tok) {
			this.cancelParams();
			return;
		}
		done.push(tok);

		const doc = this.code.getDoc();
		const ranges = [];
		const end = endBookmark.find();
		for(let ln = start.line; ln <= end.line; ++ ln) {
			const {chOffset, line} = lineWithinRange(doc, ln, start, end);
			for(let p = 0; (p = line.indexOf(tok, p)) !== -1; p += tok.length) {
				const anchor = {ch: chOffset + p, line: ln};
				const head = {ch: chOffset + p + tok.length, line: ln};
				ranges.push({anchor, head});
				this.paramMarkers.push(doc.markText(anchor, head, {
					className: 'param',
					clearWhenEmpty: false,
					inclusiveLeft: true,
					inclusiveRight: true,
				}));
			}
		}

		if(ranges.length > 0) {
			doc.setSelections(ranges, 0);
		} else {
			this.cancelParams();
		}
	}

	hasSelection() {
		const from = this.code.getCursor('from');
		const to = this.code.getCursor('to');
		return from.line !== to.line || from.ch !== to.ch;
	}

	internalAddSurroundCode(block) {
		if(this.enhanced) {
			if(this.hasSelection()) {
				this.code.replaceSelection(
					block.replace(/\{.*\}/, this.code.getSelection()),
					'end',
					'library',
				);
			} else {
				const cur = this.code.getCursor('head');
				this.code.replaceSelection(block, null, 'library');
				const end = {ch: cur.ch + block.length, line: cur.line};
				this.enterParams(cur, end, block);
			}
		} else {
			const value = this.value();
			const s1 = this.code.element.selectionStart;
			const s2 = this.code.element.selectionEnd;
			const wrapped = block.replace(/\{.*\}/, value.substring(s1, s2));
			this.code
				.val(value.substr(0, s1) + wrapped + value.substr(s2))
				.select(s1 + wrapped.length);
			this.trigger('change');
		}
	}

	internalAddIndependentCode(block) {
		if(this.enhanced) {
			const cur = this.code.getCursor('head');
			const pos = {ch: 0, line: cur.line + ((cur.ch > 0) ? 1 : 0)};
			let replaced = addNewline(block);
			if(pos.line >= this.code.lineCount()) {
				replaced = '\n' + replaced;
			}
			this.code.replaceRange(replaced, pos, null, 'library');
			const lines = block.split('\n').length;
			const end = {ch: 0, line: pos.line + lines};
			this.enterParams(pos, end, block);
		} else {
			const value = this.value();
			const cur = this.code.element.selectionStart;
			const pos = ('\n' + value + '\n').indexOf('\n', cur);
			const replaced = (
				addNewline(value.substr(0, pos)) +
				addNewline(block)
			);
			this.code
				.val(replaced + value.substr(pos))
				.select(replaced.length);
			this.trigger('change');
		}
	}

	addCodeBlock(block, surround = false) {
		this.code.focus();

		if(surround) {
			this.internalAddSurroundCode(block);
		} else {
			this.internalAddIndependentCode(block);
		}
	}

	value() {
		if(this.enhanced) {
			return this.code.getDoc().getValue();
		} else {
			return this.code.element.value;
		}
	}

	setValue(code) {
		if(this.enhanced) {
			const doc = this.code.getDoc();
			doc.setValue(code);
			doc.clearHistory();
		} else {
			this.code.val(code);
		}
	}

	_enhance() {
		// Load on demand for progressive enhancement
		// (failure to load will not block functionality)
		import('../ext/codemirror-editor.mjs').then(({CodeMirror}) => {
			const CM = globalThis.overrideCodeMirror || CodeMirror;
			const globals = {};
			this.trigger('enhance', [CM, globals]);

			const oldCode = this.code;

			const {selectionStart, selectionEnd, value} = oldCode.element;
			const focussed = oldCode.focussed();

			const code = new CM(oldCode.element.parentNode, {
				extraKeys: {
					'Cmd-/': (cm) => cm.toggleComment({padding: ''}),
					'Cmd-Enter': 'autocomplete',
					'Ctrl-/': (cm) => cm.toggleComment({padding: ''}),
					'Ctrl-Enter': 'autocomplete',
					'Ctrl-Space': 'autocomplete',
					'Shift-Tab': (cm) => cm.execCommand('indentLess'),
					'Tab': (cm) => cm.execCommand('indentMore'),
				},
				globals,
				lineNumbers: true,
				mode: this.mode,
				showTrailingSpace: true,
				value,
			});
			oldCode.detach();

			code.getDoc().setSelection(
				findPos(value, selectionStart),
				findPos(value, selectionEnd),
			);

			let lastKey = 0;
			code.on('keydown', (cm, event) => {
				lastKey = event.keyCode;
			});

			code.on('change', (cm, change) => {
				this.trigger('change');

				if(change.origin === '+input') {
					if(lastKey === 13) {
						lastKey = 0;
						return;
					}
				} else if(change.origin !== 'complete') {
					return;
				}
				CM.commands.autocomplete(cm, null, {
					completeSingle: false,
				});
			});

			code.on('focus', () => this.trigger('focus'));

			code.on('cursorActivity', () => {
				const from = code.getCursor('from');
				const to = code.getCursor('to');
				this.trigger('cursorActivity', [from, to]);
			});

			/*
				* See https://github.com/codemirror/CodeMirror/issues/3092
				* startCompletion will fire even if there are no completions, so
				* we cannot rely on it. Instead we hack the hints function to
				* propagate 'shown' as 'hint-shown', which we pick up here
				*/
			code.on('hint-shown', () => {
				this.isAutocompleting = true;
			});

			code.on('endCompletion', () => {
				this.isAutocompleting = false;
			});

			if(focussed) {
				code.focus();
			}

			this.code = code;
			this.enhanced = true;
		}).catch(console.warn);
	}
}
