/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
 * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
 *
 * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
 * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
 * For more information about purchasing a commercial license, please contact Volker Schukai.
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import { internalStateSymbol, internalSymbol } from "../constants.mjs";
import { extend } from "../data/extend.mjs";
import { BaseWithOptions } from "../types/basewithoptions.mjs";
import { getGlobalObject } from "../types/global.mjs";
import { ID } from "../types/id.mjs";
import { isString } from "../types/is.mjs";
import { Observer } from "../types/observer.mjs";
import { ProxyObserver } from "../types/proxyobserver.mjs";
import {
	ATTRIBUTE_CLASS,
	ATTRIBUTE_ID,
	ATTRIBUTE_TITLE,
} from "./constants.mjs";
import { instanceSymbol } from "../constants.mjs";

export { Resource, KEY_DOCUMENT, KEY_QUERY, referenceSymbol };

/**
 * @private
 * @type {string}
 */
const KEY_DOCUMENT = "document";

/**
 * @private
 * @type {string}
 */
const KEY_QUERY = "query";

/**
 * @private
 * @type {string}
 */
const KEY_TIMEOUT = "timeout";

/**
 * @private
 * @type {symbol}
 */
const referenceSymbol = Symbol("reference");

/**
 * This class is the base class for all resources to be loaded.
 *
 * @fragments /fragments/libraries/dom/resource/
 *
 * @example /examples/libraries/dom/resource/simple/ Create and connect a DOM resource
 * @license AGPLv3
 * @since 1.25.0
 * @copyright Volker Schukai
 * @summary A base resource abstraction for creating, connecting and observing DOM-loaded assets.
 */
class Resource extends BaseWithOptions {
	/**
	 *
	 * @param {Object|undefined} options
	 */
	constructor(options) {
		super(options);

		let uri = this.getOption(this.constructor.getURLAttribute());

		if (uri === undefined) {
			throw new Error("missing source");
		} else if (uri instanceof URL) {
			uri = uri.toString();
		} else if (!isString(uri)) {
			throw new Error("unsupported url type");
		}

		this[internalSymbol][this.constructor.getURLAttribute()] = uri;
		this[internalStateSymbol] = new ProxyObserver({
			loaded: false,
			error: undefined,
		});

		this[referenceSymbol] = undefined;
	}

	/**
	 * @return {boolean}
	 */
	isConnected() {
		if (this[referenceSymbol] instanceof HTMLElement) {
			return this[referenceSymbol].isConnected;
		}

		return false;
	}

	/**
	 * This method is overridden by the special classes and creates the DOM object.
	 * This method is also called implicitly, if not yet done explicitly, by calling `connect()`.
	 *
	 * @throws {Error} this method must be implemented by derived classes
	 * @return {Monster.DOM.Resource}
	 */
	create() {
		throw new Error("this method must be implemented by derived classes");
	}

	/**
	 * This method appends the HTMLElement to the specified document.
	 * If the element has not yet been created, `create()` is called implicitly.
	 *
	 * throws {Error} target not found
	 * @return {Monster.DOM.Resource}
	 */
	connect() {
		if (!(this[referenceSymbol] instanceof HTMLElement)) {
			this.create();
		}

		appendToDocument.call(this);
		return this;
	}

	/**
	 * @property {Document} document the document object into which the node is to be appended
	 * @property {string} src/href url to the corresponding resource
	 * @property {string} query defines the location where the resource is to be hooked into the dom.
	 * @property {string} id element attribute id
	 * @property {string} title element attribute title
	 * @property {string} class element attribute class
	 * @property {int} timeout timeout
	 */
	get defaults() {
		return extend({}, super.defaults, {
			[this.constructor.getURLAttribute()]: undefined,
			[KEY_DOCUMENT]: getGlobalObject("document"),
			[KEY_QUERY]: "head",
			[KEY_TIMEOUT]: 10000,
			[ATTRIBUTE_ID]: new ID("resource").toString(),
			[ATTRIBUTE_CLASS]: undefined,
			[ATTRIBUTE_TITLE]: undefined,
		});
	}

	/**
	 * With `available()` you can check if a resource is available.
	 * This is the case when the tag is included and the resource is loaded.
	 *
	 * @return {Promise}
	 */
	available() {
		const self = this;
		if (!(self[referenceSymbol] instanceof HTMLElement)) {
			return Promise.reject("no element");
		}

		if (!self.isConnected()) {
			return Promise.reject("element not connected");
		}

		if (self[internalStateSymbol].getSubject()["loaded"] === true) {
			if (self[internalStateSymbol].getSubject()["error"] !== undefined) {
				return Promise.reject(self[internalStateSymbol].getSubject()["error"]);
			}

			return Promise.resolve();
		}

		return new Promise(function (resolve, reject) {
			const timeout = setTimeout(() => {
				reject("timeout");
			}, self.getOption("timeout"));

			const observer = new Observer(() => {
				clearTimeout(timeout);
				self[internalStateSymbol].detachObserver(observer);
				resolve();
			});

			self[internalStateSymbol].attachObserver(observer);
		});
	}

	/**
	 * @return {string}
	 */
	static getURLAttribute() {
		throw new Error("this method must be implemented by derived classes");
	}

	/**
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/dom/resource");
	}
}

/**
 * @private
 * @return {Promise}
 * throws {Error} target not found
 */
function appendToDocument() {
	const targetNode = document.querySelector(this.getOption(KEY_QUERY, "head"));
	if (!(targetNode instanceof HTMLElement)) {
		throw new Error("target not found");
	}

	addEvents.call(this);
	targetNode.appendChild(this[referenceSymbol]);

	return this;
}

/**
 * @private
 * @return {addEvents}
 */
function addEvents() {
	const onError = () => {
		this[referenceSymbol].removeEventListener("error", onError);
		this[referenceSymbol].removeEventListener("load", onLoad);

		this[internalStateSymbol].setSubject({
			loaded: true,
			error: `${
				this[referenceSymbol][this.constructor.getURLAttribute()]
			} is not available`,
		});

		return;
	};

	const onLoad = () => {
		this[referenceSymbol].removeEventListener("error", onError);
		this[referenceSymbol].removeEventListener("load", onLoad);
		this[internalStateSymbol].getSubject()["loaded"] = true;
	};

	this[referenceSymbol].addEventListener("load", onLoad, false);
	this[referenceSymbol].addEventListener("error", onError, false);

	return this;
}
