/**
 * 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 {
	assembleMethodSymbol,
	CustomElement,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { ConfigManagerStyleSheet } from "./stylesheet/config-manager.mjs";
import { getWindow } from "../../dom/util.mjs";
import { instanceSymbol } from "../../constants.mjs";
import { diff } from "../../data/diff.mjs";

export { ConfigManager };

/**
 * @private
 * @type {symbol}
 */
const indexDBInstanceSymbol = Symbol("indexDBInstance");

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

/**
 * @private
 * @type {string}
 */
const MODE_READONLY = "readonly";

/**
 * @private
 * @type {string}
 */
const MODE_READ_WRITE = "readwrite";

/**
 * The Config Manager component is used to encapsulate the configuration of the application.
 *
 * @fragments /fragments/components/host/config-manager/
 *
 * @example /examples/components/host/config-manager-simple Config manager
 *
 * @copyright Volker Schukai
 * @summary A configuration manager for persisting and retrieving client-side settings across sessions.
 */
class ConfigManager extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/component-host/config-manager@@instance");
	}

	constructor() {
		super();

		/**
		 * @private
		 * @type {symbol}
		 */
		this[initializedPromiseSymbol] = [];
		this[indexDBInstanceSymbol] = null;
		this[initializedPromiseSymbol].push(openDatabase.call(this));
	}

	/**
	 * @return {Promise}
	 */
	ready() {
		return Promise.all(this[initializedPromiseSymbol]);
	}

	/**
	 * To set the options via the HTML tag, the attribute `data-monster-options` must be used.
	 * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
	 *
	 * The individual configuration values can be found in the table.
	 *
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},
			indexDB: {
				name: "monster",
				version: 2,
				objectStore: {
					name: "config",
					keyPath: "key",
				},
			},
		});
	}

	/**
	 * @param {string} key
	 * @return {Promise<unknown>}
	 */
	getConfig(key) {
		return this.ready().then(() => {
			return getBlob.call(this, key);
		});
	}

	/**
	 * @param {string} key
	 * @return {Promise<boolean>}
	 */
	hasConfig(key) {
		return this.ready()
			.then(() => {
				return getBlob.call(this, key);
			})
			.then(() => {
				return true;
			})
			.catch(() => {
				return false;
			});
	}

	/**
	 * @param {string} key
	 * @param {*} value
	 * @return {Promise<unknown>}
	 */
	setConfig(key, value) {
		return this.ready().then(() => {
			return getBlob
				.call(this, key)
				.then((storedValue) => {
					if (diff(storedValue, value).length === 0) {
						return;
					}
					return setBlob.call(this, key, value);
				})
				.catch((error) => {
					if (error?.message?.match(/is not defined/)) {
						return setBlob.call(this, key, value);
					}
					throw error;
				});
		});
	}

	deleteConfig(key) {
		return this.ready().then(() => {
			return deleteBlob.call(this, key);
		});
	}

	/**
	 *
	 * @return {string}
	 */
	static getTag() {
		return "monster-config-manager";
	}

	/**
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [ConfigManagerStyleSheet];
	}

	/**
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
	}
}

/**
 * @private
 * @returns {Promise<unknown>}
 */
function openDatabase() {
	const window = getWindow();

	const name = this.getOption("indexDB.name");
	const version = this.getOption("indexDB.version");
	const storageName = this.getOption("indexDB.objectStore.name");
	const KeyPath = this.getOption("indexDB.objectStore.keyPath");

	if (!name || !version) {
		throw new Error("The database name and version must be set.");
	}

	const request = window.indexedDB.open(name, version);

	return new Promise((resolve, reject) => {
		let upgradeComplete = true;
		let openComplete = false;

		request.onerror = (event) => {
			console.error("Error opening database", event);
			reject(request.error);
		};

		request.onsuccess = (event) => {
			this[indexDBInstanceSymbol] = event?.target?.result;
			openComplete = true;
			if (upgradeComplete) {
				resolve(request.result);
			}
		};

		request.onupgradeneeded = (event) => {
			const db = event.target.result;
			this[indexDBInstanceSymbol] = db;
			upgradeComplete = false;

			if (!db.objectStoreNames.contains(storageName)) {
				db.createObjectStore(storageName, { keyPath: KeyPath });
			}

			event.target.transaction.oncomplete = () => {
				upgradeComplete = true;
				if (openComplete) {
					resolve(request.result);
				}
			};
		};
	});
}

/**
 * @param {string} mode either "readonly" or "readwrite"
 */
function getObjectStore(mode) {
	const storageName = this.getOption("indexDB.objectStore.name");

	if (!this[indexDBInstanceSymbol]) {
		throw new Error("The database is not open.");
	}

	// @see https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/transaction
	// transaction(storeNames, mode, options)
	const tx = this[indexDBInstanceSymbol].transaction(storageName, mode);
	return tx.objectStore(storageName);
}

/**
 * @return {Promise<unknown>}
 */
function clearObjectStore() {
	const store = getObjectStore.call(this, "readwrite");

	return new Promise((resolve, reject) => {
		const req = store.clear();
		req.onsuccess = function (evt) {
			resolve();
		};
		req.onerror = function (evt) {
			reject(evt.target?.errorCode);
		};
	});
}

function getBlob(key) {
	const store = getObjectStore.call(this, MODE_READONLY);

	const req = store.get(key);

	return new Promise((resolve, reject) => {
		req.onsuccess = function (evt) {
			const value = evt.target.result;
			if (value) {
				resolve(value.blob);
				return;
			}
			reject(new Error("The value of the key '" + key + "' is not defined."));
		};
		req.onerror = function () {
			reject(req.error || new Error("IndexedDB get() failed"));
		};
	});
}

function deleteBlob(key) {
	const store = getObjectStore.call(this, MODE_READ_WRITE);

	const req = store.delete(key);

	return new Promise((resolve, reject) => {
		req.onsuccess = function (evt) {
			resolve();
		};

		req.onerror = function (evt) {
			console.error("deleteBlob:", evt.target.errorCode);
			reject(evt.target.errorCode);
		};
	});
}

/**
 * @private
 * @param key
 * @param blob
 * @returns {Promise<unknown>}
 */
function setBlob(key, blob) {
	const store = getObjectStore.call(this, MODE_READ_WRITE);

	const KeyPath = this.getOption("indexDB.objectStore.keyPath");
	const obj = {};
	obj[KeyPath] = key;
	obj.blob = blob;
	const req = store.put(obj);

	return new Promise((resolve, reject) => {
		req.onsuccess = function (evt) {
			resolve();
		};

		req.onerror = function (evt) {
			console.error("setBlob:", evt.target.errorCode);
			reject(evt.target.errorCode);
		};
	});
}

function getTemplate() {
	// language=HTML
	return `
        <div data-monster-role="control" part="control">
            <slot></slot>
        </div>`;
}

registerCustomElement(ConfigManager);
