/**
 * 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 {
	addAttributeToken,
	getLinkedObjects,
	hasObjectLink,
} from "../../dom/attributes.mjs";
import {
	ATTRIBUTE_ERRORMESSAGE,
	customElementUpdaterLinkSymbol,
} from "../../dom/constants.mjs";
import {
	assembleMethodSymbol,
	CustomElement,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import {
	findElementWithSelectorUpwards,
	getDocument,
} from "../../dom/util.mjs";
import { isString, isArray, isObject } from "../../types/is.mjs";
import { Observer } from "../../types/observer.mjs";
import { TokenList } from "../../types/tokenlist.mjs";
import { clone } from "../../util/clone.mjs";
import { State } from "../form/types/state.mjs";
import { ATTRIBUTE_DATASOURCE_SELECTOR } from "./constants.mjs";
import { Datasource } from "./datasource.mjs";
import { Rest as RestDatasource } from "./datasource/rest.mjs";
import { BadgeStyleSheet } from "../stylesheet/badge.mjs";
import { SaveButtonStyleSheet } from "./stylesheet/save-button.mjs";
import "../form/state-button.mjs";

import {
	handleDataSourceChanges,
	datasourceLinkedElementSymbol,
} from "./util.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";

export { SaveButton };

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

/**
 * @private
 * @type {symbol}
 */
const originValuesSymbol = Symbol.for(
	"@schukai/monster/components/datatable/save-button@@originValues",
);

/**
 * @private
 * @type {symbol}
 */
const badgeElementSymbol = Symbol("badgeElement");
const saveInFlightSymbol = Symbol("saveInFlight");
const pendingResetSymbol = Symbol("pendingReset");
const fetchInFlightSymbol = Symbol("fetchInFlight");
const originInitializedSymbol = Symbol("originInitialized");
const internalDisableTimeoutSymbol = Symbol("internalDisableTimeout");
const internalDisableVersionSymbol = Symbol("internalDisableVersion");

/**
 * A save button component
 *
 * @fragments /fragments/components/datatable/save-button
 *
 * @example /examples/components/datatable/save-button-simple Simple example
 * @example /examples/components/datatable/save-button-workflow Draft changes workflow
 *
 * @issue https://localhost.alvine.dev:8440/development/issues/closed/274.html
 *
 * @copyright Volker Schukai
 * @summary A save button for persisting datasource changes with explicit workflow feedback and update handling.
 */
class SaveButton extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for(
			"@schukai/monster/components/datatable/save-button@@instance",
		);
	}

	/**
	 * 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 {string} labels.button The button label
	 * @property {Object} classes The classes
	 * @property {string} classes.bar The bar class
	 * @property {string} classes.badge The badge class
	 * @property {Array} ignoreChanges The ignore changes (regex)
	 * @property {Array} data The data
	 * @property {boolean} disabled The disabled state
	 * @property {boolean} disableWhenNoChanges Disable button when there are no changes
	 * @property {string} logLevel The log level (off, debug)
	 * @return {Object}
	 */
	get defaults() {
		const obj = Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},

			labels: getTranslations(),

			classes: {
				bar: "monster-button-primary",
				badge: "monster-badge-secondary hidden",
			},

			datasource: {
				selector: null,
			},

			changes: "",

			ignoreChanges: [],

			data: {},

			disabled: false,

			disableWhenNoChanges: false,

			disableTimeout: 15000,

			logLevel: "off",
		});

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

	/**
	 *
	 * @return {string}
	 */
	static getTag() {
		return "monster-datasource-save-button";
	}

	/**
	 * 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]();
		const self = this;

		initControlReferences.call(this);
		initEventHandler.call(this);

		const selector = this.getOption("datasource.selector");

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

			if (!(element instanceof Datasource)) {
				throw new TypeError("the element must be a datasource");
			}

			if (element instanceof RestDatasource) {
				element.addEventListener("monster-datasource-fetch", () => {
					if (self[saveInFlightSymbol]) {
						self[pendingResetSymbol] = true;
						return;
					}
					self[fetchInFlightSymbol] = true;
					clearOriginValues.call(self);
				});
				element.addEventListener("monster-datasource-fetched", () => {
					self[fetchInFlightSymbol] = false;
					setOriginValues.call(
						self,
						clone(self[datasourceLinkedElementSymbol].data),
					);
					updateChangesState.call(self);
				});
				element.addEventListener("monster-datasource-error", () => {
					self[fetchInFlightSymbol] = false;
				});
			}

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

			clearOriginValues.call(self);

			element.datasource.attachObserver(
				new Observer(function () {
					if (self[fetchInFlightSymbol] === true) {
						return;
					}
					if (!getOriginValues.call(self)) {
						setOriginValues.call(
							self,
							clone(self[datasourceLinkedElementSymbol].data),
						);
					}

					updateChangesState.call(self);
				}),
			);

			syncOriginValues.call(self);
		}

		this.attachObserver(
			new Observer(() => {
				handleDataSourceChanges.call(this);
			}),
		);
	}

	/**
	 *
	 * @return [CSSStyleSheet]
	 */
	static getCSSStyleSheet() {
		return [SaveButtonStyleSheet, BadgeStyleSheet];
	}
}

/**
 * @private
 */
function syncOriginValues() {
	if (getOriginValues.call(this)) {
		return;
	}
	const data = this[datasourceLinkedElementSymbol]?.data;
	if (!data) {
		return;
	}
	setOriginValues.call(this, clone(data));
	updateChangesState.call(this);
}

/**
 * @private
 * @return {*}
 */
function getOriginValues() {
	const datasource = this[datasourceLinkedElementSymbol];
	return datasource ? datasource[originValuesSymbol] : null;
}

/**
 * @private
 * @param {*} value
 * @return {void}
 */
function setOriginValues(value) {
	const datasource = this[datasourceLinkedElementSymbol];
	if (datasource) {
		datasource[originValuesSymbol] = value;
	}
	this[originInitializedSymbol] = true;
}

/**
 * @private
 * @return {void}
 */
function clearOriginValues() {
	const datasource = this[datasourceLinkedElementSymbol];
	if (datasource) {
		datasource[originValuesSymbol] = null;
	}
	this[originInitializedSymbol] = false;
}

function getTranslations() {
	const locale = getLocaleOfDocument();
	switch (locale.language) {
		case "de":
			return {
				button: "Speichern",
			};
		case "fr":
			return {
				button: "Enregistrer",
			};
		case "sp":
			return {
				button: "Guardar",
			};
		case "it":
			return {
				button: "Salva",
			};
		case "pl":
			return {
				button: "Zapisz",
			};
		case "no":
			return {
				button: "Lagre",
			};
		case "dk":
			return {
				button: "Gem",
			};
		case "sw":
			return {
				button: "Spara",
			};
		default:
		case "en":
			return {
				button: "Save",
			};
	}
}

/**
 * @private
 * @return {SaveButton}
 * @throws {Error} no shadow-root is defined
 * @throws {TypeError} the element must be a datasource
 * @throws {Error} the selector must match exactly one element
 * @throws {Error} the selector must match exactly one element
 * @throws {TypeError} the element must be a datasource
 * @throws {Error} the selector must match exactly one element
 */
function initControlReferences() {
	if (!this.shadowRoot) {
		throw new Error("no shadow-root is defined");
	}

	this[stateButtonElementSymbol] = this.shadowRoot.querySelector(
		"[data-monster-role=state-button]",
	);

	this[badgeElementSymbol] = this.shadowRoot.querySelector(
		"[data-monster-role=badge]",
	);

	if (this[stateButtonElementSymbol]) {
		queueMicrotask(() => {
			ensureChangedState.call(this);
			this[stateButtonElementSymbol].removeState();
			this[stateButtonElementSymbol].setOption(
				"disabled",
				shouldDisableWhenNoChanges.call(this),
			);
			this[stateButtonElementSymbol].setOption(
				"labels.button",
				this.getOption("labels.button"),
			);
		});
	}

	return this;
}

/**
 * @private
 */
function initEventHandler() {
	queueMicrotask(() => {
		this[stateButtonElementSymbol].setOption("actions.click", () => {
			if (this[saveInFlightSymbol]) {
				return;
			}
			this[saveInFlightSymbol] = true;
			this[stateButtonElementSymbol].setOption("disabled", true);
			scheduleInternalDisableTimeout.call(this);

			flushLinkedForms
				.call(this)
				.then(() => this[datasourceLinkedElementSymbol].write())
				.then(() => {
					clearOriginValues.call(this);
					setOriginValues.call(
						this,
						clone(this[datasourceLinkedElementSymbol].data),
					);
					this[stateButtonElementSymbol].removeState();
					this[stateButtonElementSymbol].setOption(
						"disabled",
						shouldDisableWhenNoChanges.call(this),
					);
					this.setOption("changes", "");
					this.setOption(
						"classes.badge",
						new TokenList(this.getOption("classes.badge"))
							.add("hidden")
							.toString(),
					);
				})
				.catch((error) => {
					addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString());
					this[stateButtonElementSymbol].setOption("disabled", false);
				})
				.finally(() => {
					this[saveInFlightSymbol] = false;
					clearInternalDisableTimeout.call(this);
					if (this[pendingResetSymbol]) {
						this[pendingResetSymbol] = false;
						clearOriginValues.call(this);
					}
				});
		});
	});
}

/**
 * @private
 * @return {Promise<void>}
 */
function flushLinkedForms() {
	const datasource = this[datasourceLinkedElementSymbol];
	if (!datasource) {
		return Promise.resolve();
	}

	const roots = new Set();
	let current = this;
	while (current) {
		const root = current.getRootNode?.();
		if (root && typeof root.querySelectorAll === "function") {
			roots.add(root);
		}

		if (root instanceof ShadowRoot) {
			current = root.host;
		} else {
			break;
		}
	}

	const doc = getDocument();
	if (doc) {
		roots.add(doc);
	}

	const forms = new Set();
	for (const scope of roots) {
		const nodes = scope.querySelectorAll?.("monster-form") || [];
		for (const node of nodes) {
			forms.add(node);
		}
	}

	const writes = [];

	for (const form of forms) {
		if (!(form instanceof HTMLElement)) {
			continue;
		}
		if (form[datasourceLinkedElementSymbol] !== datasource) {
			continue;
		}

		if (hasObjectLink(form, customElementUpdaterLinkSymbol)) {
			const updaters = getLinkedObjects(form, customElementUpdaterLinkSymbol);
			for (const list of updaters) {
				for (const updater of list) {
					updater.retrieve();
				}
			}
		}

		if (typeof form.write === "function") {
			writes.push(form.write());
		}
	}

	if (writes.length === 0) {
		return Promise.resolve();
	}

	return Promise.all(writes).then(() => {});
}

/**
 * @private
 */
function updateChangesState() {
	const currentValues = this[datasourceLinkedElementSymbol]?.datasource?.get();
	const ignoreChanges = this.getOption("ignoreChanges");
	const originValues = getOriginValues.call(this);
	const disableWhenNoChanges = shouldDisableWhenNoChanges.call(this);

	if (
		this[originInitializedSymbol] !== true ||
		this[fetchInFlightSymbol] === true ||
		originValues === null ||
		originValues === undefined ||
		(isObject(originValues) &&
			Object.keys(originValues).length === 0 &&
			isObject(currentValues) &&
			Object.keys(currentValues).length === 0)
	) {
		this.setOption("changes", "");
		this.setOption(
			"classes.badge",
			new TokenList(this.getOption("classes.badge")).add("hidden").toString(),
		);
		this[stateButtonElementSymbol].setOption("disabled", disableWhenNoChanges);
		clearInternalDisableTimeout.call(this);
		return;
	}
	let result = diff(originValues, currentValues);

	if (
		this.getOption("logLevel") === "debug" ||
		location.search.includes("logLevel=debug")
	) {
		console.groupCollapsed("SaveButton");
		console.log("originValues", JSON.parse(JSON.stringify(originValues)));
		console.log("currentValues", JSON.parse(JSON.stringify(currentValues)));
		console.log("result of diff", result);
		console.log("ignoreChanges", ignoreChanges);

		if (isArray(result) && result.length > 0) {
			const formattedDiff = result.map((change) => ({
				Operator: change?.operator,
				Path: change?.path?.join("."),
				"First Value": change?.first?.value,
				"First Type": change?.first?.type,
				"Second Value": change?.second?.value,
				"Second Type": change?.second?.type,
			}));

			console.table(formattedDiff);
		} else {
			console.log("There are no changes to save");
		}
		console.groupEnd();
	}

	if (isArray(ignoreChanges) && ignoreChanges.length > 0) {
		const itemsToRemove = [];
		for (const item of result) {
			for (const ignorePattern of ignoreChanges) {
				const p = new RegExp(ignorePattern);

				let matchPath = item.path;
				if (isArray(item.path)) {
					matchPath = item.path.join(".");
				}

				if (p.test(matchPath)) {
					itemsToRemove.push(item);
					break;
				}
			}
		}

		for (const itemToRemove of itemsToRemove) {
			const index = result.indexOf(itemToRemove);
			if (index > -1) {
				result.splice(index, 1);
			}
		}
	}

	const changeCount = countChanges(result);
	if (changeCount > 0) {
		ensureChangedState.call(this);
		this[stateButtonElementSymbol].setState("changed");
		this[stateButtonElementSymbol].setOption("disabled", false);
		clearInternalDisableTimeout.call(this);
		this.setOption("changes", changeCount);
		this.setOption(
			"classes.badge",
			new TokenList(this.getOption("classes.badge"))
				.remove("hidden")
				.toString(),
		);
	} else {
		this[stateButtonElementSymbol].removeState();
		this[stateButtonElementSymbol].setOption("disabled", disableWhenNoChanges);
		clearInternalDisableTimeout.call(this);
		this.setOption("changes", "");
		this.setOption(
			"classes.badge",
			new TokenList(this.getOption("classes.badge")).add("hidden").toString(),
		);
	}
}

/**
 * @private
 * @param {Array} changes
 * @return {number}
 */
function countChanges(changes) {
	if (!isArray(changes) || changes.length === 0) {
		return 0;
	}

	let total = 0;
	for (const change of changes) {
		if (change?.operator === "add") {
			const nonEmpty = countNonEmptyLeafValues(change?.second?.value);
			total += Math.max(1, nonEmpty);
			continue;
		}

		if (change?.operator === "remove") {
			const nonEmpty = countNonEmptyLeafValues(change?.first?.value);
			total += Math.max(1, nonEmpty);
			continue;
		}

		total += 1;
	}

	return total;
}

/**
 * @private
 * @param {*} value
 * @return {number}
 */
function countNonEmptyLeafValues(value) {
	if (value === null || value === undefined) {
		return 0;
	}

	if (value instanceof Date) {
		return 1;
	}

	if (typeof value === "string") {
		return value.trim() === "" ? 0 : 1;
	}

	if (typeof value === "number") {
		return Number.isFinite(value) ? 1 : 0;
	}

	if (typeof value === "boolean") {
		return 1;
	}

	if (isArray(value)) {
		if (value.length === 0) {
			return 0;
		}
		return value.reduce((sum, item) => sum + countNonEmptyLeafValues(item), 0);
	}

	if (isObject(value)) {
		const keys = Object.keys(value);
		if (keys.length === 0) {
			return 0;
		}
		return keys.reduce(
			(sum, key) => sum + countNonEmptyLeafValues(value[key]),
			0,
		);
	}

	return 1;
}

/**
 * @private
 * @return {void}
 */
function ensureChangedState() {
	const stateButton = this[stateButtonElementSymbol];
	if (!stateButton || typeof stateButton.getOption !== "function") {
		return;
	}

	if (stateButton.getOption("states.changed")) {
		return;
	}

	const states = Object.assign({}, stateButton.getOption("states") || {}, {
		changed: getChangedState(),
	});
	stateButton.setOption("states", states);
}

/**
 * @private
 */
function scheduleInternalDisableTimeout() {
	const timeout = this.getOption("disableTimeout");
	if (!Number.isInteger(timeout) || timeout <= 0) {
		return;
	}

	clearInternalDisableTimeout.call(this);

	const version = (this[internalDisableVersionSymbol] || 0) + 1;
	this[internalDisableVersionSymbol] = version;

	this[internalDisableTimeoutSymbol] = setTimeout(() => {
		if (this[internalDisableVersionSymbol] !== version) {
			return;
		}
		if (this[saveInFlightSymbol] !== true) {
			return;
		}
		if (
			this.getOption("disabled", false) === true ||
			this.hasAttribute("disabled")
		) {
			return;
		}

		const changes = Number(this.getOption("changes") || 0);
		if (!Number.isFinite(changes) || changes <= 0) {
			return;
		}

		this[stateButtonElementSymbol]?.setOption("disabled", false);
	}, timeout);
}

/**
 * @private
 */
function clearInternalDisableTimeout() {
	if (this[internalDisableTimeoutSymbol]) {
		clearTimeout(this[internalDisableTimeoutSymbol]);
		this[internalDisableTimeoutSymbol] = null;
	}
}

/**
 * @private
 * @return {boolean}
 */
function shouldDisableWhenNoChanges() {
	return this.getOption("disableWhenNoChanges") === true;
}

/**
 * @private
 * @return {State}
 */
function getChangedState() {
	return new State(
		"changed",
		'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">' +
			'<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708z"/>' +
			'<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383m.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>' +
			"</svg>",
	);
}

/**
 * @param {Object} options
 * @deprecated 2024-12-31
 */
function updateOptionsFromArguments(options) {
	const selector = this.getAttribute(ATTRIBUTE_DATASOURCE_SELECTOR);
	if (selector) {
		options.datasource.selector = selector;
	}
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
		<div data-monster-role="control" part="control"
			 data-monster-attributes="disabled path:disabled | if:true">
			<monster-state-button data-monster-role="state-button"></monster-state-button>
			<div data-monster-attributes="disabled path:disabled | if:true, class path:classes.badge"
				 data-monster-role="badge"
				 data-monster-replace="path:changes"></div>
		</div>
	`;
}

registerCustomElement(SaveButton);
