/**
 * 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 { instanceSymbol } from "../../constants.mjs";
import { diff } from "../../data/diff.mjs";
import { Pathfinder } from "../../data/pathfinder.mjs";
import {
	assembleMethodSymbol,
	CustomElement,
	attributeObserverSymbol,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import {
	findElementWithSelectorUpwards,
	getDocument,
	getWindow,
} from "../../dom/util.mjs";
import { isArray, isObject, isString } from "../../types/is.mjs";
import { Observer } from "../../types/observer.mjs";
import {
	ATTRIBUTE_DATASOURCE_SELECTOR,
	ATTRIBUTE_DATATABLE_INDEX,
} from "./constants.mjs";
import { Datasource } from "./datasource.mjs";
import { DatasetStyleSheet } from "./stylesheet/dataset.mjs";
import {
	handleDataSourceChanges,
	datasourceLinkedElementSymbol,
} from "./util.mjs";
import { FormStyleSheet } from "../stylesheet/form.mjs";
import { addErrorAttribute } from "../../dom/error.mjs";
import { Pipe } from "../../data/pipe.mjs";
import { clone } from "../../util/clone.mjs";

export { DataSet };

/**
 * A data set component
 *
 * @fragments /fragments/components/datatable/dataset
 *
 * @example /examples/components/datatable/dataset-dom Dom dataset
 * @example /examples/components/datatable/dataset-rest Rest dataset
 *
 * @issue https://localhost.alvine.dev:8440/development/issues/closed/272.html
 *
 * @copyright Volker Schukai
 * @summary A dataset component that can be used to show the data of a data source
 */
class DataSet extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for(
			"@schukai/monster/components/datatable/dataset@@instance",
		);
	}

	/**
	 * This method determines which attributes are to be monitored by `attributeChangedCallback()`.
	 *
	 * @return {string[]}
	 * @since 1.15.0
	 */
	static get observedAttributes() {
		const attributes = super.observedAttributes;
		attributes.push(ATTRIBUTE_DATATABLE_INDEX);
		attributes.push("data-monster-option-mapping-index");
		return attributes;
	}

	/**
	 * 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
	 * @property {object} datasource The datasource
	 * @property {string} datasource.selector The selector of the datasource
	 * @property {object} mapping The mapping
	 * @property {string} mapping.data The data
	 * @property {number} mapping.index The index
	 * @property {object} features The features
	 * @property {boolean} features.refreshOnMutation Refresh on mutation
	 * @property {object} refreshOnMutation The refresh on mutation
	 * @property {string} refreshOnMutation.selector The selector
	 * @property {string} logLevel The log level. Possible values are 'debug', 'info', 'warn', 'error'.
	 */
	get defaults() {
		const obj = Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},

			datasource: {
				selector: null,
			},

			mapping: {
				data: "dataset",
				index: 0,
			},

			logLevel: "error",

			features: {
				/**
				 * @since 3.70.0
				 * @type {boolean}
				 */
				refreshOnMutation: true,
				// Feature flag: default-on no-op write suppression, can be rolled back.
				skipNoopWrite: true,
			},

			/**
			 * @since 3.70.0
			 * @type {boolean}
			 */
			refreshOnMutation: {
				selector:
					"input, select, textarea, monster-select, monster-toggle-switch, monster-choice-cards",
			},

			writeBack: {
				transformer: null,
				callbacks: null,
			},

			data: {},
		});

		updateOptionsFromArguments.call(this, obj);
		return obj;
	}

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

	/**
	 * This method is called when the component is created.
	 * @since 3.70.0
	 * @return {Promise}
	 */
	refresh() {
		// makes sure that handleDataSourceChanges is called
		return new Promise((resolve) => {
			queueMicrotask(() => {
				handleDataSourceChanges.call(this, true);
				resolve();
			});
		});
	}

	/**
	 *
	 * @return {Promise<unknown>}
	 */
	write() {
		return new Promise((resolve, reject) => {
			if (!this[datasourceLinkedElementSymbol]) {
				reject(new Error("No datasource"));
				return;
			}

			const internalUpdateCloneData = this.getInternalUpdateCloneData();
			if (!internalUpdateCloneData) {
				reject(new Error("No update data"));
				return;
			}

			const internalData = internalUpdateCloneData?.["data"];
			if (
				internalData === undefined ||
				internalData === null ||
				internalData === ""
			) {
				reject(new Error("No data"));
				return;
			}

			queueMicrotask(() => {
				const path = this.getOption("mapping.data");
				const index = this.getOption("mapping.index");

				let pathWithIndex;

				if (isString(path) && path !== "") {
					pathWithIndex = path + "." + index;
				} else {
					pathWithIndex = String(index);
				}

				let sanitizedData;
				try {
					sanitizedData = applyWriteBackTransform.call(
						this,
						clone(internalData),
					);
				} catch (e) {
					reject(e);
					return;
				}

				if (this.getOption("features.skipNoopWrite") === true) {
					const currentData = this[datasourceLinkedElementSymbol]?.data;
					let currentValue;
					if (currentData) {
						try {
							currentValue = new Pathfinder(currentData).getVia(pathWithIndex);
						} catch (e) {
							currentValue = undefined;
						}
					}
					if (diff(currentValue, sanitizedData).length === 0) {
						resolve();
						return;
					}
				}

				if (this.getOption("logLevel") === "debug") {
					console.log("monster-dataset: write", {
						path: pathWithIndex,
						data: sanitizedData,
					});
				}

				const data = this[datasourceLinkedElementSymbol]?.data;
				if (!data) {
					reject(new Error("No data"));
					return;
				}

				const unref = JSON.stringify(data);
				const ref = JSON.parse(unref);

				new Pathfinder(ref).setVia(pathWithIndex, sanitizedData);

				this[datasourceLinkedElementSymbol].data = ref;

				resolve();
			});
		});
	}

	/**
	 * This method is responsible for assembling the component.
	 *
	 * It calls the parent's assemble method first, then initializes control references and event handlers.
	 * If the `datasource.selector` option is provided and is a string, it searches for the corresponding
	 * element in the DOM using that selector.
	 *
	 * If the selector matches exactly one element, it checks if the element is an instance of the `Datasource` class.
	 *
	 * If it is, the component's `datasourceLinkedElementSymbol` property is set to the element, and the component
	 * attaches an observer to the datasource's changes.
	 *
	 * The observer is a function that calls the `handleDataSourceChanges` method in the context of the component.
	 * Additionally, the component attaches an observer to itself, which also calls the `handleDataSourceChanges`
	 * method in the component's context.
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();

		setTimeout(() => {
			if (!this[datasourceLinkedElementSymbol]) {
				const selector = this.getOption("datasource.selector");

				if (isString(selector)) {
					const element = findElementWithSelectorUpwards(this, selector);
					if (element === null) {
						addErrorAttribute(
							this,
							"the selector must match exactly one element",
						);
						return;
					}

					if (!(element instanceof Datasource)) {
						addErrorAttribute(this, "the element must be a datasource");
						return;
					}

					this[datasourceLinkedElementSymbol] = element;
					element.datasource.attachObserver(
						new Observer(handleDataSourceChanges.bind(this)),
					);

					handleDataSourceChanges.call(this);
				} else {
					addErrorAttribute(
						this,
						"the datasource selector is missing or invalid",
					);
					return;
				}
			}

			if (
				this.getOption("features.refreshOnMutation") &&
				this.getOption("refreshOnMutation.selector")
			) {
				initMutationObserver.call(this);
			}

			initEventHandler.call(this);
		}, 10);
	}

	/**
	 * @return [CSSStyleSheet]
	 */
	static getCSSStyleSheet() {
		return [FormStyleSheet, DatasetStyleSheet];
	}
}

/**
 * @private
 */
function initEventHandler() {
	this[attributeObserverSymbol][ATTRIBUTE_DATATABLE_INDEX] = () => {
		// @deprecated use data-monster-option-mapping-index
		const index = this.getAttribute(ATTRIBUTE_DATATABLE_INDEX);
		if (index) {
			this.setOption("mapping.index", parseInt(index, 10));
			handleDataSourceChanges.call(this);
		}
	};

	this[attributeObserverSymbol]["data-monster-option-mapping-index"] = () => {
		const index = this.getAttribute("data-monster-option-mapping-index");
		if (index !== null && index !== undefined && index !== "") {
			this.setOption("mapping.index", parseInt(index, 10));
			handleDataSourceChanges.call(this);
		}
	};

	if (this[datasourceLinkedElementSymbol] instanceof Datasource) {
		this[datasourceLinkedElementSymbol]?.datasource?.attachObserver(
			new Observer(() => {
				let index = 0;
				if (
					typeof this[datasourceLinkedElementSymbol]?.currentPage === "function"
				) {
					const page = this[datasourceLinkedElementSymbol].currentPage();
					if (page !== null && page !== undefined && page !== "") {
						index = parseInt(page, 10) - 1;
					}
				}

				this.setOption("mapping.index", index);
				handleDataSourceChanges.call(this);
			}),
		);

		this[datasourceLinkedElementSymbol]?.attachObserver(
			new Observer(() => {
				let index = 0;
				if (
					typeof this[datasourceLinkedElementSymbol]?.currentPage === "function"
				) {
					const page = this[datasourceLinkedElementSymbol].currentPage();
					if (page !== null && page !== undefined && page !== "") {
						index = parseInt(page, 10) - 1;
					}
				}

				this.setOption("mapping.index", index);
				handleDataSourceChanges.call(this);
			}),
		);

		handleDataSourceChanges.call(this);
	}
}

/**
 *
 * @param {Object} options
 */
function updateOptionsFromArguments(options) {
	const index = this.getAttribute(ATTRIBUTE_DATATABLE_INDEX); // @deprecated use data-monster-option-mapping-index

	if (index !== null && index !== undefined) {
		options.mapping.index = parseInt(index, 10);
	}

	const selector = this.getAttribute(ATTRIBUTE_DATASOURCE_SELECTOR);

	if (selector) {
		options.datasource.selector = selector;
	}
}

/**
 * @private
 */
function initMutationObserver() {
	const config = { attributes: false, childList: true, subtree: true };

	const callback = (mutationList, observer) => {
		if (mutationList.length === 0) {
			return;
		}

		let doneFlag = false;
		for (const mutation of mutationList) {
			if (mutation.type === "childList") {
				for (const node of mutation.addedNodes) {
					if (
						node instanceof HTMLElement &&
						node.matches(this.getOption("refreshOnMutation.selector"))
					) {
						doneFlag = true;
						break;
					}
				}

				if (doneFlag) {
					break;
				}
			}
		}

		if (doneFlag) {
			this.refresh();
		}
	};

	const observer = new MutationObserver(callback);
	observer.observe(this, config);
}

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

/**
 * @private
 * @param {Object|Array} data
 * @return {Object|Array}
 */
function applyWriteBackTransform(data) {
	const transformer = this.getOption("writeBack.transformer");
	if (transformer === undefined || transformer === null || transformer === "") {
		return clone(data);
	}

	const pipe = new Pipe(transformer);
	const callbacks = this.getOption("writeBack.callbacks");

	if (isArray(callbacks)) {
		for (const callback of callbacks) {
			if (typeof callback === "function") {
				pipe.setCallback(callback);
			}
		}
	}

	if (isObject(callbacks)) {
		for (const key in callbacks) {
			if (
				Object.prototype.hasOwnProperty.call(callbacks, key) &&
				typeof callbacks[key] === "function"
			) {
				pipe.setCallback(key, callbacks[key]);
			}
		}
	}

	const result = pipe.run(data);
	if (typeof result === "undefined") {
		return clone(data);
	}

	return clone(result);
}

registerCustomElement(DataSet);
