'use strict';

let bililiteRange; // create one global variable

(function () {

	const datakey = Symbol(); // use as the key to modify elements.

	bililiteRange = function (el) {
		var ret;
		if (el.setSelectionRange) {
			// Element is an input or textarea 
			// note that some input elements do not allow selections
			try {
				el.selectionStart = el.selectionStart;
				ret = new InputRange();
			} catch (e) {
				ret = new NothingRange();
			}
		} else {
			// Standards, with any other kind of element
			ret = new W3CRange();
		}
		ret._el = el;
		// determine parent document, as implemented by John McLear <john@mclear.co.uk>
		ret._doc = el.ownerDocument;
		ret._win = ret._doc.defaultView;
		ret._bounds = [0, ret.length];

		// selection tracking. We want clicks to set the selection to the clicked location but tabbing in or element.focus() should restore
		// the selection to what it was.
		// There's no good way to do this. I just assume that a mousedown (or a drag and drop
		// into the element) within 100 ms of the focus event must have caused the focus, and
		// therefore we should not restore the selection.
		if (!(el[datakey])) { // we haven't processed this element yet	
			const data = createDataObject(el);
			startupHooks.forEach(hook => hook(el, ret, data));
		}
		return ret;
	}

	bililiteRange.version = 3.2;

	const startupHooks = new Set();
	bililiteRange.addStartupHook = fn => startupHooks.add(fn);
	startupHooks.add(trackSelection);
	startupHooks.add(fixInputEvents);
	startupHooks.add(correctNewlines);

	function trackSelection(element, range, data) {
		data.selection = [0, 0];
		range.listen('focusout', evt => data.selection = range._nativeSelection());
		range.listen('mousedown', evt => data.mousetime = evt.timeStamp);
		range.listen('drop', evt => data.mousetime = evt.timeStamp);
		range.listen('focus', evt => {
			if ('mousetime' in data && evt.timeStamp - data.mousetime < 100) return;
			range._nativeSelect(range._nativeRange(data.selection))
		})
	}

	function fixInputEvents(element, range, data) {
		// DOM 3 input events, https://www.w3.org/TR/input-events-1/
		// have a data field with the text inserted, but thatisn't enough to fully describe the change;
		// we need to know the old text (or at least its length)
		// and *where* the new text was inserted.
		// So we enhance input events with that information. 
		// the "newText" should always be the same as the 'data' field, if it is defined
		data.oldText = range.all();
		data.liveRanges = new Set();
		range.listen('input', evt => {
			const newText = range.all();
			if (!evt.bililiteRange) {
				evt.bililiteRange = diff(data.oldText, newText);
				if (evt.bililiteRange.unchanged) {
					// no change. Assume that whatever happened, happened at the selection point (and use whatever data the browser gives us).
					evt.bililiteRange.start = range.clone().bounds('selection')[1] - (evt.data || '').length;
				}
			}
			data.oldText = newText;

			// Also update live ranges on this element
			data.liveRanges.forEach(rng => {
				const start = evt.bililiteRange.start;
				const oldend = start + evt.bililiteRange.oldText.length;
				const newend = start + evt.bililiteRange.newText.length;
				// adjust bounds; this tries to emulate the algorithm that Microsoft Word uses for bookmarks
				let [b0, b1] = rng.bounds();
				if (b0 <= start) {
					// no change
				} else if (b0 > oldend) {
					b0 += newend - oldend;
				} else {
					b0 = newend;
				}
				if (b1 < start) {
					// no change
				} else if (b1 >= oldend) {
					b1 += newend - oldend;
				} else {
					b1 = start;
				}
				rng.bounds([b0, b1]);
			})
		});
	}

	function diff(oldText, newText) {
		// Try to find the changed text, assuming it was a continuous change
		if (oldText == newText) {
			return {
				unchanged: true,
				start: 0,
				oldText,
				newText
			}
		}

		const oldlen = oldText.length;
		const newlen = newText.length;
		for (var i = 0; i < newlen && i < oldlen; ++i) {
			if (newText.charAt(i) != oldText.charAt(i)) break;
		}
		const start = i;
		for (i = 0; i < newlen && i < oldlen; ++i) {
			let newpos = newlen - i - 1, oldpos = oldlen - i - 1;
			if (newpos < start || oldpos < start) break;
			if (newText.charAt(newpos) != oldText.charAt(oldpos)) break;
		}
		const oldend = oldlen - i;
		const newend = newlen - i;
		return {
			start,
			oldText: oldText.slice(start, oldend),
			newText: newText.slice(start, newend)
		}
	};
	bililiteRange.diff = diff; // expose

	function correctNewlines(element, range, data) {
		// we need to insert newlines rather than create new elements, so character-based calculations work
		range.listen('paste', evt => {
			if (evt.defaultPrevented) return;
			// windows adds \r's to clipboard!
			range.clone().bounds('selection').
				text(evt.clipboardData.getData("text/plain").replace(/\r/g, ''), { inputType: 'insertFromPaste' }).
				bounds('endbounds').
				select().
				scrollIntoView();
			evt.preventDefault();
		});
		range.listen('keydown', function (evt) {
			if (evt.ctrlKey || evt.altKey || evt.shiftKey || evt.metaKey) return;
			if (evt.defaultPrevented) return;
			if (evt.key == 'Enter') {
				range.clone().bounds('selection').
					text('\n', { inputType: 'insertLineBreak' }).
					bounds('endbounds').
					select().
					scrollIntoView();
				evt.preventDefault();
			}
		});
	}

	// convenience function for defining input events
	function inputEventInit(type, oldText, newText, start, inputType) {
		return {
			type,
			inputType,
			data: newText,
			bubbles: true,
			bililiteRange: {
				unchanged: (oldText == newText),
				start,
				oldText,
				newText
			}
		};
	}

	// base class
	function Range() { }
	Range.prototype = {
		// allow use of range[0] and range[1] for start and end of bounds 
		get 0() {
			return this.bounds()[0];
		},
		set 0(x) {
			this.bounds([x, this[1]]);
			return x;
		},
		get 1() {
			return this.bounds()[1];
		},
		set 1(x) {
			this.bounds([this[0], x]);
			return x;
		},
		all: function (text) {
			if (arguments.length) {
				return this.bounds('all').text(text, { inputType: 'insertReplacementText' });
			} else {
				return this._el[this._textProp];
			}
		},
		bounds: function (s) {
			if (typeof s === 'number') {
				this._bounds = [s, s];
			} else if (bililiteRange.bounds[s]) {
				this.bounds(bililiteRange.bounds[s].apply(this, arguments));
			} else if (s && s.bounds) {
				this._bounds = s.bounds(); // copy bounds from an existing range
			} else if (s) {
				this._bounds = s; // don't do error checking now; things may change at a moment's notice
			} else {
				// constrain bounds now
				var b = [
					Math.max(0, Math.min(this.length, this._bounds[0])),
					Math.max(0, Math.min(this.length, this._bounds[1]))
				];
				b[1] = Math.max(b[0], b[1]);
				return b;
			}
			return this; // allow for chaining
		},
		clone: function () {
			return bililiteRange(this._el).bounds(this.bounds());
		},
		get data() {
			return this._el[datakey];
		},
		dispatch: function (opts = {}) {
			var event = new Event(opts.type, opts);
			event.view = this._win;
			for (let prop in opts) try { event[prop] = opts[prop] } catch (e) { }; // ignore read-only errors for properties that were copied in the previous line
			this._el.dispatchEvent(event); // note that the event handlers will be called synchronously, before the "return this;"
			return this;
		},
		get document() {
			return this._doc;
		},
		dontlisten: function (type, func = console.log) {
			this._el.removeEventListener(type, func);
			return this;
		},
		get element() {
			return this._el
		},
		get length() {
			return this._el[this._textProp].length;
		},
		live(on = true) {
			this.data.liveRanges[on ? 'add' : 'delete'](this);
			return this;
		},
		listen: function (type, func = console.log) {
			this._el.addEventListener(type, func);
			return this;
		},
		scrollIntoView: function (scroller = (top => this._el.scrollTop = top)) {
			var top = this.top();
			// scroll into position if necessary
			if (this._el.scrollTop > top || this._el.scrollTop + this._el.clientHeight < top) {
				scroller.call(this._el, top);
			}
			return this;
		},
		select: function () {
			var b = this.data.selection = this.bounds();
			if (this._el === this._doc.activeElement) {
				// only actually select if this element is active!
				this._nativeSelect(this._nativeRange(b));
			}
			this.dispatch({ type: 'select', bubbles: true });
			return this; // allow for chaining
		},
		selection: function (text) {
			if (arguments.length) {
				return this.bounds('selection').text(text).bounds('endbounds').select();
			} else {
				return this.bounds('selection').text();
			}
		},
		sendkeys: function (text) {
			this.data.sendkeysOriginalText = this.text();
			this.data.sendkeysBounds = undefined;
			function simplechar(rng, c) {
				if (/^{[^}]*}$/.test(c)) c = c.slice(1, -1);	// deal with unknown {key}s
				rng.text(c).bounds('endbounds');
			}
			text.replace(/{[^}]*}|[^{]+|{/g, part => (bililiteRange.sendkeys[part] || simplechar)(this, part, simplechar));
			this.bounds(this.data.sendkeysBounds);
			this.dispatch({ type: 'sendkeys', detail: text });
			return this;
		},
		text: function (text, { inputType = 'insertText' } = {}) {
			if (text !== undefined) {
				let eventparams = [this.text(), text, this[0], inputType];
				this.dispatch(inputEventInit('beforeinput', ...eventparams));
				this._nativeSetText(text, this._nativeRange(this.bounds()));
				this[1] = this[0] + text.length;
				this.dispatch(inputEventInit('input', ...eventparams));
				return this; // allow for chaining
			} else {
				return this._nativeGetText(this._nativeRange(this.bounds()));
			}
		},
		top: function () {
			return this._nativeTop(this._nativeRange(this.bounds()));
		},
		get window() {
			return this._win;
		},
		wrap: function (n) {
			this._nativeWrap(n, this._nativeRange(this.bounds()));
			return this;
		},
	};

	// allow extensions ala jQuery
	bililiteRange.prototype = Range.prototype;
	bililiteRange.extend = function (fns) {
		Object.assign(bililiteRange.prototype, fns);
	};

	bililiteRange.override = (name, fn) => {
		const oldfn = bililiteRange.prototype[name];
		bililiteRange.prototype[name] = function () {
			const oldsuper = this.super;
			this.super = oldfn;
			const ret = fn.apply(this, arguments);
			this.super = oldsuper;
			return ret;
		};
	}

	//bounds functions
	bililiteRange.bounds = {
		all: function () { return [0, this.length] },
		start: function () { return 0 },
		end: function () { return this.length },
		selection: function () {
			if (this._el === this._doc.activeElement) {
				this.bounds('all'); // first select the whole thing for constraining
				return this._nativeSelection();
			} else {
				return this.data.selection;
			}
		},
		startbounds: function () { return this[0] },
		endbounds: function () { return this[1] },
		union: function (name, ...rest) {
			const b = this.clone().bounds(...rest);
			return [Math.min(this[0], b[0]), Math.max(this[1], b[1])];
		},
		intersection: function (name, ...rest) {
			const b = this.clone().bounds(...rest);
			return [Math.max(this[0], b[0]), Math.min(this[1], b[1])];
		}
	};

	// sendkeys functions
	bililiteRange.sendkeys = {
		'{tab}': function (rng, c, simplechar) {
			simplechar(rng, '\t'); // useful for inserting what would be whitespace
		},
		'{newline}': function (rng) {
			rng.text('\n', { inputType: 'insertLineBreak' }).bounds('endbounds');
		},
		'{backspace}': function (rng) {
			var b = rng.bounds();
			if (b[0] == b[1]) rng.bounds([b[0] - 1, b[0]]); // no characters selected; it's just an insertion point. Remove the previous character
			rng.text('', { inputType: 'deleteContentBackward' }); // delete the characters and update the selection
		},
		'{del}': function (rng) {
			var b = rng.bounds();
			if (b[0] == b[1]) rng.bounds([b[0], b[0] + 1]); // no characters selected; it's just an insertion point. Remove the next character
			rng.text('', { inputType: 'deleteContentForward' }).bounds('endbounds'); // delete the characters and update the selection
		},
		'{rightarrow}': function (rng) {
			var b = rng.bounds();
			if (b[0] == b[1]) ++b[1]; // no characters selected; it's just an insertion point. Move to the right
			rng.bounds([b[1], b[1]]);
		},
		'{leftarrow}': function (rng) {
			var b = rng.bounds();
			if (b[0] == b[1]) --b[0]; // no characters selected; it's just an insertion point. Move to the left
			rng.bounds([b[0], b[0]]);
		},
		'{selectall}': function (rng) {
			rng.bounds('all');
		},
		'{selection}': function (rng) {
			// insert the characters without the sendkeys processing
			rng.text(rng.data.sendkeysOriginalText).bounds('endbounds');
		},
		'{mark}': function (rng) {
			rng.data.sendkeysBounds = rng.bounds();
		}
	};
	// Synonyms from the DOM standard (http://www.w3.org/TR/DOM-Level-3-Events-key/)
	bililiteRange.sendkeys['{Enter}'] = bililiteRange.sendkeys['{enter}'] = bililiteRange.sendkeys['{newline}'];
	bililiteRange.sendkeys['{Backspace}'] = bililiteRange.sendkeys['{backspace}'];
	bililiteRange.sendkeys['{Delete}'] = bililiteRange.sendkeys['{del}'];
	bililiteRange.sendkeys['{ArrowRight}'] = bililiteRange.sendkeys['{rightarrow}'];
	bililiteRange.sendkeys['{ArrowLeft}'] = bililiteRange.sendkeys['{leftarrow}'];

	// an input element in a standards document. "Native Range" is just the bounds array
	function InputRange() { }
	InputRange.prototype = new Range();
	InputRange.prototype._textProp = 'value';
	InputRange.prototype._nativeRange = function (bounds) {
		return bounds || [0, this.length];
	};
	InputRange.prototype._nativeSelect = function (rng) {
		this._el.setSelectionRange(rng[0], rng[1]);
	};
	InputRange.prototype._nativeSelection = function () {
		return [this._el.selectionStart, this._el.selectionEnd];
	};
	InputRange.prototype._nativeGetText = function (rng) {
		return this._el.value.substring(rng[0], rng[1]);
	};
	InputRange.prototype._nativeSetText = function (text, rng) {
		var val = this._el.value;
		this._el.value = val.substring(0, rng[0]) + text + val.substring(rng[1]);
	};
	InputRange.prototype._nativeEOL = function () {
		this.text('\n');
	};
	InputRange.prototype._nativeTop = function (rng) {
		if (rng[0] == 0) return 0; // the range starts at the top
		// I can't remember where I found this clever hack to find the location of text in a text area
		var clone = this._el.cloneNode(true);
		clone.style.visibility = 'hidden';
		clone.style.position = 'absolute';
		this._el.parentNode.insertBefore(clone, this._el);
		clone.style.height = '1px';
		clone.value = this._el.value.slice(0, rng[0]);
		var top = clone.scrollHeight;
		// this gives the bottom of the text, so we have to subtract the height of a single line
		clone.value = 'X';
		top -= clone.scrollHeight;
		clone.parentNode.removeChild(clone);
		return top;
	}
	InputRange.prototype._nativeWrap = function () { throw new Error("Cannot wrap in a text element") };

	function W3CRange() { }
	W3CRange.prototype = new Range();
	W3CRange.prototype._textProp = 'textContent';
	W3CRange.prototype._nativeRange = function (bounds) {
		var rng = this._doc.createRange();
		rng.selectNodeContents(this._el);
		if (bounds) {
			w3cmoveBoundary(rng, bounds[0], true, this._el);
			rng.collapse(true);
			w3cmoveBoundary(rng, bounds[1] - bounds[0], false, this._el);
		}
		return rng;
	};
	W3CRange.prototype._nativeSelect = function (rng) {
		this._win.getSelection().removeAllRanges();
		this._win.getSelection().addRange(rng);
	};
	W3CRange.prototype._nativeSelection = function () {
		// returns [start, end] for the selection constrained to be in element
		var rng = this._nativeRange(); // range of the element to constrain to
		if (this._win.getSelection().rangeCount == 0) return [this.length, this.length]; // append to the end
		var sel = this._win.getSelection().getRangeAt(0);
		return [
			w3cstart(sel, rng),
			w3cend(sel, rng)
		];
	};
	W3CRange.prototype._nativeGetText = function (rng) {
		return rng.toString();
	};
	W3CRange.prototype._nativeSetText = function (text, rng) {
		rng.deleteContents();
		rng.insertNode(this._doc.createTextNode(text));
		this._el.normalize(); // merge the text with the surrounding text
	};
	W3CRange.prototype._nativeEOL = function () {
		var rng = this._nativeRange(this.bounds());
		rng.deleteContents();
		var br = this._doc.createElement('br');
		br.setAttribute('_moz_dirty', ''); // for Firefox
		rng.insertNode(br);
		rng.insertNode(this._doc.createTextNode('\n'));
		rng.collapse(false);
	};
	W3CRange.prototype._nativeTop = function (rng) {
		if (this.length == 0) return 0; // no text, no scrolling
		if (rng.toString() == '') {
			var textnode = this._doc.createTextNode('X');
			rng.insertNode(textnode);
		}
		var startrng = this._nativeRange([0, 1]);
		var top = rng.getBoundingClientRect().top - startrng.getBoundingClientRect().top;
		if (textnode) textnode.parentNode.removeChild(textnode);
		return top;
	}
	W3CRange.prototype._nativeWrap = function (n, rng) {
		rng.surroundContents(n);
	};

	// W3C internals
	function nextnode(node, root) {
		//  in-order traversal
		// we've already visited node, so get kids then siblings
		if (node.firstChild) return node.firstChild;
		if (node.nextSibling) return node.nextSibling;
		if (node === root) return null;
		while (node.parentNode) {
			// get uncles
			node = node.parentNode;
			if (node == root) return null;
			if (node.nextSibling) return node.nextSibling;
		}
		return null;
	}
	function w3cmoveBoundary(rng, n, bStart, el) {
		// move the boundary (bStart == true ? start : end) n characters forward, up to the end of element el. Forward only!
		// if the start is moved after the end, then an exception is raised
		if (n <= 0) return;
		var node = rng[bStart ? 'startContainer' : 'endContainer'];
		if (node.nodeType == 3) {
			// we may be starting somewhere into the text
			n += rng[bStart ? 'startOffset' : 'endOffset'];
		}
		while (node) {
			if (node.nodeType == 3) {
				var length = node.nodeValue.length;
				if (n <= length) {
					rng[bStart ? 'setStart' : 'setEnd'](node, n);
					// special case: if we end next to a <br>, include that node.
					if (n == length) {
						// skip past zero-length text nodes
						for (var next = nextnode(node, el); next && next.nodeType == 3 && next.nodeValue.length == 0; next = nextnode(next, el)) {
							rng[bStart ? 'setStartAfter' : 'setEndAfter'](next);
						}
						if (next && next.nodeType == 1 && next.nodeName == "BR") rng[bStart ? 'setStartAfter' : 'setEndAfter'](next);
					}
					return;
				} else {
					rng[bStart ? 'setStartAfter' : 'setEndAfter'](node); // skip past this one
					n -= length; // and eat these characters
				}
			}
			node = nextnode(node, el);
		}
	}
	var START_TO_START = 0; // from the w3c definitions
	var START_TO_END = 1;
	var END_TO_END = 2;
	var END_TO_START = 3;
	// from the Mozilla documentation, for range.compareBoundaryPoints(how, sourceRange)
	// -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. 
	// * Range.END_TO_END compares the end boundary-point of sourceRange to the end boundary-point of range.
	// * Range.END_TO_START compares the end boundary-point of sourceRange to the start boundary-point of range.
	// * Range.START_TO_END compares the start boundary-point of sourceRange to the end boundary-point of range.
	// * Range.START_TO_START compares the start boundary-point of sourceRange to the start boundary-point of range. 
	function w3cstart(rng, constraint) {
		if (rng.compareBoundaryPoints(START_TO_START, constraint) <= 0) return 0; // at or before the beginning
		if (rng.compareBoundaryPoints(END_TO_START, constraint) >= 0) return constraint.toString().length;
		rng = rng.cloneRange(); // don't change the original
		rng.setEnd(constraint.endContainer, constraint.endOffset); // they now end at the same place
		return constraint.toString().length - rng.toString().length;
	}
	function w3cend(rng, constraint) {
		if (rng.compareBoundaryPoints(END_TO_END, constraint) >= 0) return constraint.toString().length; // at or after the end
		if (rng.compareBoundaryPoints(START_TO_END, constraint) <= 0) return 0;
		rng = rng.cloneRange(); // don't change the original
		rng.setStart(constraint.startContainer, constraint.startOffset); // they now start at the same place
		return rng.toString().length;
	}

	function NothingRange() { }
	NothingRange.prototype = new Range();
	NothingRange.prototype._textProp = 'value';
	NothingRange.prototype._nativeRange = function (bounds) {
		return bounds || [0, this.length];
	};
	NothingRange.prototype._nativeSelect = function (rng) { // do nothing
	};
	NothingRange.prototype._nativeSelection = function () {
		return [0, 0];
	};
	NothingRange.prototype._nativeGetText = function (rng) {
		return this._el[this._textProp].substring(rng[0], rng[1]);
	};
	NothingRange.prototype._nativeSetText = function (text, rng) {
		var val = this._el[this._textProp];
		this._el[this._textProp] = val.substring(0, rng[0]) + text + val.substring(rng[1]);
	};
	NothingRange.prototype._nativeEOL = function () {
		this.text('\n');
	};
	NothingRange.prototype._nativeTop = function () {
		return 0;
	};
	NothingRange.prototype._nativeWrap = function () { throw new Error("Wrapping not implemented") };


	// data for elements, similar to jQuery data, but allows for monitoring with custom events
	const monitored = new Set();

	function signalMonitor(prop, value, element) {
		const attr = `data-${prop}`;
		element.dispatchEvent(new CustomEvent(attr, { bubbles: true, detail: value }));
		try {
			element.setAttribute(attr, value); // illegal attribute names will throw. Ignore it			
		} finally { /* ignore */ }
	}

	function createDataObject(el) {
		return el[datakey] = new Proxy(new Data(el), {
			set(obj, prop, value) {
				obj[prop] = value;
				if (monitored.has(prop)) signalMonitor(prop, value, obj.sourceElement);
				return true; // in strict mode, 'set' returns a success flag 
			}
		});
	}

	var Data = function (el) {
		Object.defineProperty(this, 'sourceElement', {
			value: el
		});
	}

	Data.prototype = {};
	// for use with ex options. JSON.stringify(range.data) should return only the options that were
	// both defined with bililiteRange.option() *and* actually had a value set on this particular data object.
	// JSON.stringify (range.data.all) should return all the options that were defined.
	Object.defineProperty(Data.prototype, 'toJSON', {
		value: function () {
			let ret = {};
			for (let key in Data.prototype) if (this.hasOwnProperty(key)) ret[key] = this[key];
			return ret;
		}
	});
	Object.defineProperty(Data.prototype, 'all', {
		get: function () {
			let ret = {};
			for (let key in Data.prototype) ret[key] = this[key];
			return ret;
		}
	});
	Object.defineProperty(Data.prototype, 'trigger', {
		value: function () {
			monitored.forEach(prop => signalMonitor(prop, this[prop], this.sourceElement));
		}
	});

	bililiteRange.createOption = function (name, desc = {}) {
		desc = Object.assign({
			enumerable: true, // use these as the defaults
			writable: true,
			configurable: true
		}, Object.getOwnPropertyDescriptor(Data.prototype, name), desc);
		if ('monitored' in desc) monitored[desc.monitored ? 'add' : 'delete'](name);
		Object.defineProperty(Data.prototype, name, desc);
		return Data.prototype[name]; // return the default value
	}

})();