'use strict';

/**
 * Bibliothèque de l'application.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
const App = new function()
{
	/**
	 * Ajoute des classes à des éléments.
	 *
	 * @param mixed elem
	 * @param string classname
	 *
	 * @return void
	 */
	this.addClass = (elem, ...classname) => _func(elem, e => e.classList.add(...classname));

	/**
	 * Insère du code ou un élément HTML après chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param mixed html
	 *
	 * @return void
	 */
	this.after = (elem, html) => _insert(elem, html, 'afterend');

	/**
	 * Insère du texte après chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param string text
	 *
	 * @return void
	 */
	this.afterText = (elem, text) => _insertText(elem, text, 'afterend');

	/**
	 * Effectue une requête Ajax.
	 *
	 * @param object data
	 * @param object loadend
	 * @param function error
	 *
	 * @return object
	 */
	this.ajax = function(data, loadend, error)
	{
		const xhr = new XMLHttpRequest();

		// Erreur.
		if (error)
		{
			xhr.addEventListener('error', error);
		}

		// Chargement terminé.
		xhr.addEventListener('loadend', () =>
		{
			const r = xhr.response;

			if (r && typeof r == 'object')
			{
				['error', 'info', 'no_result', 'success'].includes(r.status)
					? loadend?.[r.status]?.(r)
					: loadend?.dflt?.(r);
			}

			loadend?.always?.(r);
		});

		// Requête.
		xhr.open('POST', GALLERY.path + '/ajax.php');
		xhr.setRequestHeader('Content-type', 'application/json');
		xhr.responseType = 'json';

		// On envoie les données.
		data.anticsrf = GALLERY.anticsrf;
		xhr.send(JSON.stringify(data));

		return xhr;
	};

	/**
	 * Crée une animation sur un élément.
	 *
	 * @param mixed elem
	 * @param object css
	 * @param mixed options
	 * @param function callback
	 *
	 * @return void
	 */
	this.animate = function(elem, css, options, callback = () => {})
	{
		_getElem(elem).animate(css, options).finished.then(callback).catch(() => {});
	};

	/**
	 * Insère du code ou un élément HTML à la fin de chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param mixed html
	 *
	 * @return void
	 */
	this.append = (elem, html) => _insert(elem, html, 'beforeend');

	/**
	 * Insère du texte à la fin de chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param string text
	 *
	 * @return void
	 */
	this.appendText = (elem, text) => _insertText(elem, text, 'beforeend');

	/**
	 * Retourne la valeur d'un attribut d'un élément,
	 * ou bien crée ou change la valeur d'un attribut de plusieurs éléments.
	 *
	 * @param mixed elem
	 * @param string attr
	 * @param string val
	 *
	 * @return mixed
	 */
	this.attr = function(elem, attr, val)
	{
		return val !== undefined
			? _func(elem, e => e.setAttribute(attr, val))
			: typeof attr == 'object'
				? _object(elem, attr, (e, n, v) => e.setAttribute(n, v))
				: _getElem(elem)?.getAttribute(attr);
	};

	/**
	 * Insère du code ou un élément HTML avant chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param mixed html
	 *
	 * @return void
	 */
	this.before = (elem, html) => _insert(elem, html, 'beforebegin');

	/**
	 * Insère du texte avant chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param string text
	 *
	 * @return void
	 */
	this.beforeText = (elem, text) => _insertText(elem, text, 'beforebegin');

	/**
	 * Ajoute un gestionnaire d'événement "click" sur des éléments,
	 * tout en désactivant le comportement par défaut.
	 *
	 * @param mixed elem
	 * @param function callback
	 *
	 * @return void
	 */
	this.click = function(elem, callback)
	{
		_func(elem, e =>
		{
			e.addEventListener('click', function(evt)
			{
				evt.preventDefault();
				callback.bind(this)(evt);
			});
		});
	}

	/**
	 * Permet de convertir du code HTML en éléments HTML.
	 *
	 * @param string html
	 *
	 * @return object
	 */
	this.createElement = function(html)
	{
		const template = document.createElement('template');
		template.innerHTML = _secure(html);
		return template.content.firstElementChild;
	};

	/**
	 * Applique une fonction sur un ensemble d'éléments.
	 *
	 * @param mixed elem
	 * @param function callback
	 *
	 * @return void
	 */
	this.each = (elem, callback) => _func(elem, callback);

	/**
	 * Supprime tout le code HTML dans des éléments.
	 *
	 * @param mixed elem
	 *
	 * @return void
	 */
	this.empty = elem => _func(elem, e => e.replaceChildren());

	/**
	 * Détermine si au moins un des éléments possède une classe.
	 *
	 * @param mixed elem
	 * @param string classname
	 *
	 * @return boolean
	 */
	this.hasClass = function(elem, classname)
	{
		let has_class = false;
		_func(elem, e =>
		{
			if (e.classList.contains(classname))
			{
				has_class = true;
			}
		});
		return has_class;
	};

	/**
	 * Cache des éléments avec ou sans effet de fondu.
	 *
	 * @param mixed elem
	 * @param mixed options
	 * @param function callback
	 *
	 * @return void
	 */
	this.hide = function(elem, options = 0, callback)
	{
		const opt = typeof options == 'object' ? options : {duration: options};
		if (opt?.duration)
		{
			const list_elem = _getElemList(elem);
			const last_elem = list_elem[list_elem.length - 1];
			_func(elem, e =>
			{
				App.animate(e, {opacity: 0}, opt, () =>
				{
					e.style.display = 'none';
					if (e === last_elem)
					{
						callback?.();
					}
				});
			});
		}
		else
		{
			_func(elem, e => e.style.display = 'none');
			callback?.();
		}
	};

	/**
	 * Remplace tous les éléments enfants de plusieurs éléments par du code ou un élément HTML.
	 *
	 * @param mixed elem
	 * @param mixed html
	 *
	 * @return void
	 */
	this.html = function(elem, html)
	{
		_func(elem, e =>
		{
			e.replaceChildren();
			if (html !== null)
			{
				typeof html == 'object'
					? e.insertAdjacentElement('afterbegin', html)
					: e.insertAdjacentHTML('afterbegin', _secure(html));
			}
		});
	};

	/**
	 * Retourne un lien relatif d'une page.
	 *
	 * @param string page
	 *
	 * @return string
	 */
	this.link = function(page)
	{
		return GALLERY.path === '' || GALLERY.path.substring(0, 1) === '/'
			? GALLERY.path + '/' + (GALLERY.url_rewrite ? '' : '?q=') + page
			: '#';
	};

	/**
	 * Supprime un gestionnaire d'événement sur des éléments.
	 *
	 * @param mixed elem
	 * @param string name
	 * @param function callback
	 *
	 * @return void
	 */
	this.off = function(elem, name, callback)
	{
		typeof name == 'object'
			? _object(elem, name, (e, l, c) => e.removeEventListener(l, c))
			: _func(elem, e => e.removeEventListener(name, callback));
	};

	/**
	 * Retourne la position absolue et les dimensions d'un élément.
	 *
	 * @param mixed elem
	 *
	 * @return object
	 */
	this.offset = function(elem)
	{
		const rect = _getElem(elem).getBoundingClientRect();
		return {
			bottom: rect.bottom + window.scrollY,
			height: rect.height,
			left: rect.left + window.scrollX,
			right: rect.right + window.scrollX,
			top: rect.top + window.scrollY,
			width: rect.width
		};
	};

	/**
	 * Ajoute un gestionnaire d'événement sur des éléments.
	 *
	 * @param mixed elem
	 * @param string name
	 * @param function callback
	 *
	 * @return void
	 */
	this.on = function(elem, name, callback)
	{
		typeof name == 'object'
			? _object(elem, name, (e, l, c) => e.addEventListener(l, c))
			: _func(elem, e => e.addEventListener(name, callback));
	};

	/**
	 * Recharge la page courante en supprimant la partie "ancre".
	 *
	 * @return void
	 */
	this.pageReload = function()
	{
		document.location = document.location.href.replace(document.location.hash, '');
	};

	/**
	 * Insère du code ou un élément HTML au début de chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param mixed html
	 *
	 * @return void
	 */
	this.prepend = (elem, html) => _insert(elem, html, 'afterbegin');

	/**
	 * Insère du texte au début de chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param string text
	 *
	 * @return void
	 */
	this.prependText = (elem, text) => _insertText(elem, text, 'afterbegin');

	/**
	 * Raccourci pour *.querySelector().
	 *
	 * @param mixed first
	 * @param string second
	 *
	 * @return object
	 */
	this.q = function(first, second)
	{
		return second ? first.querySelector(second) : document.querySelector(first);
	};

	/**
	 * Raccourci pour *.querySelectorAll().
	 *
	 * @param mixed first
	 * @param string second
	 *
	 * @return object
	 */
	this.qAll = function(first, second)
	{
		return second ? first.querySelectorAll(second) : document.querySelectorAll(first);
	};

	/**
	 * Déclenche une fonction de callback au chargement du document.
	 *
	 * @param function callback
	 *
	 * @return void
	 */
	this.ready = callback => document.addEventListener('DOMContentLoaded', callback);

	/**
	 * Permet de construire une regexp insensible aux accents les plus courants.
	 *
	 * @param string str
	 *
	 * @return string
	 */
	this.regexpAccents = function(str)
	{
		const chars =
		[
			'aàáâãäåāăąǎȁ', 'cçćĉċč', 'dðďđ', 'eèéêëēėęěȅ', 'gĝğġ', 'hĥħ',
			'iìíîïīİįıǐȉ', 'jĵ', 'lĺľł', 'nñńňŋ', 'oòóôõöøōőǒȍ', 'rŕřȑȓ',
			'sśŝşšș', 'tťŧț', 'uùúûüūůűųǔȕ', 'wŵẁẃẅ', 'yýÿŷȳỳ', 'zźżž'
		];
		chars.forEach(c => str = str.replace(new RegExp(`[${c}]`, 'gi'), `[${c}]`));
		return str;
	}

	/**
	 * Échappe les caractères spéciaux dans une chaîne destinée à être utilisée dans une regexp.
	 *
	 * @param string str
	 *
	 * @return string
	 */
	this.regexpEscape = function(str)
	{
		return str.replace(/[\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
	}

	/**
	 * Supprime des éléments HTML.
	 *
	 * @param mixed elem
	 *
	 * @return void
	 */
	this.remove = elem => _func(elem, e => e.remove());

	/**
	 * Supprime des attributs de plusieurs éléments.
	 *
	 * @param mixed elem
	 * @param string attrs
	 *
	 * @return void
	 */
	this.removeAttr = function(elem, ...attrs)
	{
		for (const attr of attrs)
		{
			_func(elem, e => e.removeAttribute(attr));
		}
	};

	/**
	 * Supprime des classes de plusieurs éléments.
	 *
	 * @param mixed elem
	 * @param string classname
	 *
	 * @return void
	 */
	this.removeClass = (elem, ...classname) => _func(elem, e => e.classList.remove(...classname));

	/**
	 * Remplace une classe par une autre pour plusieurs éléments.
	 *
	 * @param mixed elem
	 * @param string old_classname
	 * @param string new_classname
	 *
	 * @return void
	 */
	this.replaceClass = function(elem, old_classname, new_classname)
	{
		_func(elem, e => e.classList.replace(old_classname, new_classname));
	};

	/**
	 * Affiche des éléments avec ou sans effet de fondu.
	 *
	 * @param mixed elem
	 * @param mixed options
	 * @param string display
	 * @param function callback
	 *
	 * @return void
	 */
	this.show = function(elem, options = 0, display = 'block', callback)
	{
		const show = val =>
		{
			_func(elem, e => e.style.display = val);
			callback?.();
		};
		switch (typeof options)
		{
			case 'string' :
				show(options);
				return;
			case 'number' :
				options = {duration: options};
				break;
		}
		if (options?.duration)
		{
			const list_elem = _getElemList(elem);
			const last_elem = list_elem[list_elem.length - 1];
			_func(elem, e =>
			{
				App.style(e, {display: display, opacity: 0});
				App.animate(e, {opacity: 1}, options, () =>
				{
					e.style.opacity = 1;
					if (e === last_elem)
					{
						callback?.();
					}
				});
			});
		}
		else
		{
			show(display);
		}
	};

	/**
	 * Applique des propriétés CSS à des éléments.
	 *
	 * @param mixed elem
	 * @param object css
	 *
	 * @return void
	 */
	this.style = (elem, css) => _func(elem, e => Object.assign(e.style, css));

	/**
	 * Retourne le texte d'un élément,
	 * ou bien ajoute ou remplace du texte dans des éléments.
	 *
	 * @param mixed elem
	 * @param string text
	 *
	 * @return mixed
	 */
	this.text = function(elem, text)
	{
		return text === undefined
			? _getElem(elem)?.textContent
			: _func(elem, e => e.textContent = text);
	};

	/**
	 * Ajoute ou supprime une classe à des éléments.
	 * Si "force" vaut true, la classe sera uniquement ajoutée.
	 * Si "force" vaut false, la classe sera uniquement supprimée.
	 *
	 * @param mixed elem
	 * @param string classname
	 * @param boolean force
	 *
	 * @return void
	 */
	this.toggleClass = function(elem, classname, force)
	{
		_func(elem, e => e.classList.toggle(classname, force));
	};

	/**
	 * Déclenche un événement sur des éléments.
	 *
	 * @param mixed elem
	 * @param string name
	 *
	 * @return void
	 */
	this.trigger = (elem, name) => _func(elem, e => e.dispatchEvent(new Event(name)));

	/**
	 * Retourne la valeur d'un élément,
	 * ou bien modifie la valeur de plusieurs éléments.
	 *
	 * @param mixed elem
	 * @param string val
	 *
	 * @return mixed
	 */
	this.val = function(elem, val)
	{
		return val === undefined
			? _getElem(elem)?.value
			: _func(elem, e => e.value = val);
	}

	/**
	 * Entoure plusieurs éléments "elem" par un élément "wrapper".
	 *
	 * @param mixed elem
	 * @param object wrapper
	 *
	 * @return void
	 */
	this.wrap = function(elem, wrapper)
	{
		_func(elem, e =>
		{
			e.parentNode.insertBefore(wrapper, e);
			wrapper.appendChild(e);
		});
	}



	/**
	 * Applique une fonction sur une liste d'éléments HTML
	 * depuis un sélecteur, un élément ou une liste d'éléments.
	 *
	 * @param mixed elem
	 * @param function callback
	 *
	 * @return void
	 */
	function _func(elem, callback)
	{
		if (elem)
		{
			_getElemList(elem).forEach(e => callback(e));
		}
	}

	/**
	 * Retourne un élément HTML.
	 *
	 * @param mixed elem
	 *
	 * @return object
	 */
	function _getElem(elem)
	{
		const name = elem.constructor.name;
		return name == 'Array'
			? App.q(elem[0], elem[1])
			: name == 'String'
				? App.q(elem)
				: elem;
	}

	/**
	 * Retourne une liste d'éléments HTML.
	 *
	 * @param mixed elem
	 *
	 * @return object
	 */
	function _getElemList(elem)
	{
		const name = elem.constructor.name;
		return name == 'NodeList'
			? elem
			: name == 'Array'
				? App.qAll(elem[0], elem[1])
				: name == 'String'
					? App.qAll(elem)
					: [elem];
	}

	/**
	 * Insère du code ou un élément HTML "html"
	 * à la position "position" par rapport à chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param mixed html
	 * @param string position
	 *
	 * @return void
	 */
	function _insert(elem, html, position)
	{
		if (html !== null)
		{
			_func(elem, e =>
			{
				typeof html == 'object'
					? e.insertAdjacentElement(position, html)
					: e.insertAdjacentHTML(position, _secure(html));
			});
		}
	}

	/**
	 * Insère du texte à la position "position" par rapport à chaque élément "elem".
	 *
	 * @param mixed elem
	 * @param string text
	 * @param string position
	 *
	 * @return void
	 */
	function _insertText(elem, text, position)
	{
		_func(elem, e => e.insertAdjacentText(position, text));
	}

	/**
	 * Exécute une fonction de callback sur des éléments avec des paramètres passés dans un objet.
	 *
	 * @param mixed elem
	 * @param object object
	 * @param function callback
	 *
	 * @return void
	 */
	function _object(elem, object, callback)
	{
		_func(elem, e =>
		{
			for (const prop in object)
			{
				callback(e, prop, object[prop]);
			}
		});
	}

	/**
	 * Retourne le code HTML passé en argument uniquement s'il ne pose pas de problème
	 * de sécurité (présence de code JavaScript en particulier), sinon une chaîne vide.
	 *
	 * @param string html
	 *
	 * @return string
	 */
	function _secure(html)
	{
		const template = document.createElement('template');
		template.innerHTML = `<div>${html}</div>`;
		const list = template.content.firstElementChild.querySelectorAll('*');
		const unsafe_tags = ['embed', 'frame', 'iframe', 'link', 'object', 'script', 'style'];

		for (const elem of list)
		{
			const tag_name = elem.tagName.toLowerCase();

			if (unsafe_tags.includes(tag_name))
			{
				console.error(`Unsafe HTML: <${tag_name}> tag`);
				return '';
			}

			for (const attr of elem.attributes)
			{
				const attr_name = attr.name.toLowerCase();
				const attr_value = attr.value.toLowerCase().replace(/[\n\r\s\t]+/g, '');

				if (attr_name.substring(0, 2) == 'on' || attr_name == 'style')
				{
					console.error(`Unsafe HTML: ${attr_name} attribute`);
					return '';
				}

				if (attr_value.match('javascript:') && attr.value != 'javascript:;')
				{
					console.error('Unsafe HTML: javascript source');
					return '';
				}
			};
		}

		return html;
	}
};