/**
 * ez-drag-n-drop - v1.0.20
 * Simple plugin to allow users to drag and drop to rearrange elements in the DOM.
 * @author Pamblam
 * @website 
 * @license MIT
 */



/**
 * Class to enable dragging and dropping of DOM elements.
 * @see https://pamblam.github.io/ez-drag-n-drop/examples/
 */
class EZDnD_Draggable{
	
	/**
	 * Create new interface for making DOM elements draggable.
	 * @param {HTMLElement} [options.element] - The element that should be draggable.
	 * @param {HTMLElement} [options.anchor] - An Element inside the draggable element that is the handle to drag the draggable element. If not provided, the entire elemenet is the anchor.
	 * @param {HTMLElement[]} [options.containers] - Array of HTMLElements which draggables may be dropped into, as top level children of these elements. If not provided, the element is draggable only within it's immediate ancestor.
	 * @param {HTMLElement|null} [options.placeholder] - If provided, serves as a placeholder to show where a dropped element will land.
	 * @param {String} [options.dragging_class] - If provided this class will be added to any elements as they are being dragged.
	 * @param {String} [options.hovering_class] - If provided this class will be added to any containers as they are being hovered over with a dragged element.
	 * @returns {EZDnD_Draggable}
	 */
	constructor(options){
		
		// handle options/parameters
		this.element = options.element;
		this.anchor = options.anchor || options.element;
		if(!options.containers) options.containers = options.element.parentElement;
		this.containers = Array.isArray(options.containers) ? options.containers : [options.containers];
		this.placeholder = this.normalizePlaceholderInput(options.placeholder);
		this.dragging_class = options.dragging_class || undefined;
		this.hovering_class = options.hovering_class || undefined;
		this.validateClassParameters();
		
		// private helper properties
		this.isDragging = false;
		this.elementClone = false;
		this.mouseOffset = {x:0, y:0};
		this.cssDisplayValue = this.element.style.display || null;
		this.anchorCursor = this.anchor.style.cursor || null;
		this.bodyCursor = document.body.style.cursor || null;
		this.currentAbsPos = {x:0, y:0};
		this.newElementPosition = null;
		
		// Bound events
		this.boundOnAnchorMousedown = this.onAnchorMousedown.bind(this);
		this.boundOnMouseReleasethis = this.onMouseRelease.bind(this);
		this.boundOnMouseMove = this.onMouseMove.bind(this);
		this.boundOnMouseOverAnchor = this.onMouseOverAnchor.bind(this);
		this.boundOnMouseOutAnchor = this.onMouseOutAnchor.bind(this);
		
		// Attach listeners
		this.bind();
	}
	
	/**
	 * Unbinds all events and resets the DOM so that the instance can be garbage collected. Synonym for unbind();
	 * @returns {undefined}
	 */
	destroy(){
		this.unbind();
	}
	
	/**
	 * Unbinds all events and resets the DOM so that the instance can be garbage collected. Synonym for destroy();
	 * @returns {undefined}
	 */
	unbind(){
		this.anchor.removeEventListener('mousedown', this.boundOnAnchorMousedown);
		document.removeEventListener('mouseup', this.boundOnMouseReleasethis);
		document.removeEventListener('mousemove', this.boundOnMouseMove);
		this.anchor.removeEventListener('mouseover', this.boundOnMouseOverAnchor);
		this.anchor.removeEventListener('mouseout', this.boundOnMouseOutAnchor);
		this.onMouseRelease();
	}
	
	/**
	 * Binds events to the elements. This is called on construct, but if you unbind() or destroy(), you can re-bind with this method.
	 * @returns {undefined}
	 */
	bind(){
		this.anchor.addEventListener('mousedown', this.boundOnAnchorMousedown);
		document.addEventListener('mouseup', this.boundOnMouseReleasethis);
		document.addEventListener('mousemove', this.boundOnMouseMove);
		this.anchor.addEventListener('mouseover', this.boundOnMouseOverAnchor);
		this.anchor.addEventListener('mouseout', this.boundOnMouseOutAnchor);
	}
	
	/**
	 * Ensure placeholder is a HTMLElement or null.
	 * @ignore
	 */
	normalizePlaceholderInput(placeholder){
		if(placeholder instanceof HTMLElement) return placeholder;
		if(typeof placeholder === 'string'){
			var d = document.createElement('div');
			d.innerHTML = placeholder;
			var children = [...d.children];
			if(children.length) return children[0];
			return null;
		}
		return null;
	}
	
	/**
	 * Change the cursor on mouseover of the anchor.
	 * @ignore
	 */
	onMouseOverAnchor(){
		this.anchor.style.cursor = 'grab';
	}
	
	/**
	 * Reset the cursor on mouseout of the anchor.
	 * @ignore
	 */
	onMouseOutAnchor(){
		this.anchor.style.cursor = this.anchorCursor;
	}
	
	/**
	 * Validate the constructor parameters.
	 * @ignore
	 */
	validateClassParameters(){
		// Ensure anchor, element are HTMLElements
		['element', 'anchor'].forEach(node=>{
			if(!(this[node] instanceof HTMLElement)){
				throw new Error(node+" must be instance of HTMLElement");
			}
		});
		// Ensure all containers are HTMLElements
		this.containers.forEach(container=>{
			if(!(container instanceof HTMLElement)){
				throw new Error("containers must be instances of HTMLElement");
			}
		});
		// Ensure Element is in a continaer
		if(!this.isInContainer(this.element)){
			throw new Error("element must be contained within container.");
		}
		// Ensure anchor is within element
		if(!this.element.contains(this.anchor) && this.element !== this.anchor){
			throw new Error("anchor must be contained within element.");
		}
	}
	
	/**
	 * Is the element in a container.
	 * @ignore
	 */
	isInContainer(element){
		var isElementInContainer = false;
		this.containers.forEach(container=>{
			if(container.contains(element)){
				isElementInContainer = true;
			}
		});
		return isElementInContainer;
	}
	
	/**
	 * Handle mouseup event.
	 * @ignore
	 */
	onMouseRelease(){
		var wasDragging = !!this.isDragging;
		var wasMoved = !!this.newElementPosition;
		
		this.isDragging = false;
		if(this.elementClone) this.elementClone.remove();
		this.mouseOffset = {x:0, y:0};
		this.currentAbsPos = {x:0, y:0};
		
		if(this.newElementPosition){
			this.newElementPosition.ele.insertAdjacentElement(this.newElementPosition.pos, this.element);
			if(this.placeholder) this.placeholder.remove();
			if(this.hovering_class){
				this.newElementPosition.container.classList.remove(this.hovering_class);
			}
		}
		
		document.body.style.cursor = this.bodyCursor;
		this.anchor.style.cursor = this.anchorCursor;
		this.newElementPosition = null;
		this.element.style.display = this.cssDisplayValue;
		
		if(wasDragging){ 
			if(wasMoved){
				var event = new CustomEvent('dnd-completed', {detail: this, bubbles: true});
				this.element.dispatchEvent(event);
			}else{
				var event = new CustomEvent('dnd-canceled', {detail: this, bubbles: true});
				this.element.dispatchEvent(event);
			}
		}
	}
	
	/**
	 * Handle mouse down event.
	 * @ignore
	 */
	onAnchorMousedown(e){
		e.preventDefault();
		this.isDragging = true;
		this.calculateMouseOffset(e);
		this.elementClone = this.cloneElement();
		this.cssDisplayValue = this.element.style.display || null;
		this.element.style.display = 'none';
		document.body.style.cursor = 'grabbing';
		var event = new CustomEvent('dnd-started', {detail: this, bubbles: true});
		this.element.dispatchEvent(event);
	}
	
	/**
	 * Get the new position of the dragging element.
	 * @ignore
	 */
	getNewElementPosition(e){
		var container = null;
		var mousePos = {x: e.pageX, y: e.pageY};
		
		this.containers.forEach(c=>{
			var {top, left, right, bottom} = c.getBoundingClientRect();
			top += scrollY;
			bottom += scrollY;
			left += scrollX;
			right += scrollX;
			
			const isVerticallyIncontainer = mousePos.x >= left && mousePos.x <= right;
			const isHorizontallyInContainer = mousePos.y >= top && mousePos.y <= bottom;
			if(isVerticallyIncontainer && isHorizontallyInContainer){
				container = c;
			}
		});
		
		if(!container) return null;
		var children = [...container.children];
		
		// if there are no children
		if(!children.length){
			return {pos: 'afterbegin', ele: container, container};
		}
		
		// Get the closest element, it's distance, and the position
		var position, element, distance;
		
		children.forEach(child=>{
			var posit = null;
			var pos = this.calculateElementCenterPos(child);
			var d = Math.hypot(pos.x-mousePos.x, pos.y-mousePos.y);
			if(mousePos.y < pos.y){
				// it's above
				posit = 'beforebegin';
			}else if(mousePos.y > pos.y){
				// it's below
				posit = 'afterend';
			}else if(mousePos.x > pos.x){
				// it's to the right
				posit = 'afterend';
			}else{
				// it's to the left
				posit = 'beforebegin';
			}
			if(!distance || d < distance){
				distance = d;
				element = child;
				position = posit;
			}
		});
		
		return {pos: position, ele: element, container};
	}
	
	/**
	 * Calculate the center of a DOM element.
	 * @ignore
	 */
	calculateElementCenterPos(element){
		var {top, left, width, height} = element.getBoundingClientRect();
		top += scrollY;
		left += scrollX;
		return {
			x: left + (width / 2),
			y: top + (height / 2)
		};
	}
	
	/**
	 * Handle mouse move event.
	 * @ignore
	 */
	onMouseMove(e){
		if(this.isDragging === false) return;
		var event = new CustomEvent('dnd-dragging', {detail: this, bubbles: true});
		this.element.dispatchEvent(event);
		this.currentAbsPos = {
			x: e.pageX-this.mouseOffset.x, 
			y: e.pageY-this.mouseOffset.y
		};
		this.elementClone.style.top = this.currentAbsPos.y+"px";
		this.elementClone.style.left = this.currentAbsPos.x+"px";
		
		var prev_container = this.newElementPosition ? this.newElementPosition.container : undefined;
		
		this.newElementPosition = this.getNewElementPosition(e);
		
		if(prev_container){
			if(this.newElementPosition){
				if(this.newElementPosition.container !== prev_container){
					prev_container.classList.remove(this.hovering_class);
				}
			}else{
				prev_container.classList.remove(this.hovering_class);
			}
		}
		
		if(this.newElementPosition && this.hovering_class){
			this.newElementPosition.container.classList.add(this.hovering_class);
		}
		if(this.placeholder){
			if(this.newElementPosition){
				this.newElementPosition.ele.insertAdjacentElement(this.newElementPosition.pos, this.placeholder);
			}else{
				this.placeholder.remove();
			}
		}
	}
	
	/**
	 * Calculate mouse offset.
	 * @ignore
	 */
	calculateMouseOffset(e){
		var rect = this.element.getBoundingClientRect();
		var x = e.pageX - (rect.left + scrollX); //x position within the element.
		var y = e.pageY - (rect.top + scrollY);
		this.mouseOffset = {x, y};
	}
	
	/**
	 * Clone the main element.
	 * @ignore
	 */
	cloneElement(){
		
		var clone = this.deepCloneNode(this.element);
		
		console.log(clone);
		document.body.append(clone);
		
		var pos = this.element.getBoundingClientRect();
		this.currentAbsPos = {
			x:pos.left + scrollX, 
			y:pos.top + scrollY
		};
		
		clone.style.position = 'absolute';
		clone.style.top = this.currentAbsPos.y+"px";
		clone.style.left = this.currentAbsPos.x+"px";
		
		if(this.dragging_class){
			clone.classList.add(this.dragging_class);
		}
		
		return clone;
	}
	
	/**
	 * Recursively clone the given HTMLElement, it's computed styles, 
	 * attributes, and children. Does not copy IDs.
	 * @param {HTMLElement} elem
	 * @returns {Element|Boolean}
	 */
	deepCloneNode(elem) {
		if (!elem instanceof HTMLElement) return false;
		var clone = document.createElement(elem.tagName);

		// Copy styles
		var styles = getComputedStyle(elem);
		for (let i = styles.length; i--; ) {
			let prop = styles[i].split('-').map((w, i) => {
				w = w.toLowerCase();
				if (i > 0) w = w.substr(0, 1).toUpperCase() + w.substr(1);
				return w;
			}).join('');
			let val = styles[prop];
			clone.style[prop] = val;
		}

		// Copy attributes (except ID)
		var attributes = elem.attributes;
		for (let i = attributes.length; i--; ) {
			let prop = attributes[i].name;
			let val = attributes[i].value;
			if (prop.toLowerCase() === 'id') continue;
			clone.setAttribute(prop, val);
		}

		// Copy child nodes
		var children = elem.childNodes;
		for (let i = children.length; i--; ) {
			let node = children[i];
			let clonedChild;
			if (node.nodeType === 1) {
				// It's an element
				clonedChild = this.deepCloneNode(node);
			} else if (node.nodeType === 3) {
				// It's text
				clonedChild = document.createTextNode(node.textContent);
			}
			if (clonedChild) clone.prepend(clonedChild);
		}

		// Copy Values
		if (elem.value) clone.value = elem.value;

		return clone;
	}
	
}

/**
 * Convenience class for handling groups of draggables
 * @see https://pamblam.github.io/ez-drag-n-drop/examples/
 */
class EZDnD_Group{
	
	/**
	 * Greate a group of draggable elements
	 * @param {String} [options.element_selectors] - CSS Selector representing all draggable elements.
	 * @param {String} [options.anchor_selectors] - CSS Selector representing handles in each of the draggable elements. If none provided the entire element becomes a handle.
	 * @param {String} [options.container_selectors] - CSS Selector representing all areas in which elements may be dropped. If not provided, each element is movable only within their immediate ancestor.
	 * @param {HTMLElement} [options.placeholder] - If provided, serves as a placeholder to show where a dropped element will land.
	 * @param {String} [options.dragging_class] - If provided this class will be added to any elements as they are being dragged.
	 * @param {String} [options.hovering_class] - If provided this class will be added to any containers as they are being hovered over with a dragged element.
	 * @returns {EZDnD_Group}
	 */
	constructor(options){	
		this.draggables = [];
		const containers = options.container_selectors ? [...document.querySelectorAll(options.container_selectors)] : undefined;
		var dragging_class = options.dragging_class || undefined;
		var hovering_class = options.hovering_class || undefined;
		[...document.querySelectorAll(options.element_selectors)].forEach(element=>{
			const anchor = options.anchor_selectors ? element.querySelector(options.anchor_selectors) : undefined;
			const placeholder = options.placeholder || undefined;
			this.draggables.push(new EZDnD_Draggable({
				element, anchor, containers, placeholder, dragging_class, hovering_class
			}));
		});
	}
	
	/**
	 * Unbinds all events and resets the DOM so that the instance can be garbage collected. Synonym for unbind();
	 * @returns {undefined}
	 */
	destroy(){
		this.unbind();
	}
	
	/**
	 * Unbinds all events and resets the DOM so that the instance can be garbage collected. Synonym for destroy();
	 * @returns {undefined}
	 */
	unbind(){
		this.draggables.forEach(draggable=>draggable.unbind());
	}
	
	/**
	 * Re-binds all events after an instance has been unbound.
	 * @returns {undefined}
	 */
	bind(){
		this.draggables.forEach(draggable=>draggable.bind());
	}
	
}