/**
 * 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 { addAttributeToken } from "../../../dom/attributes.mjs";
import { ATTRIBUTE_ERRORMESSAGE } from "../../../dom/constants.mjs";
import { Datasource, dataSourceSymbol } from "../datasource.mjs";
import { DatasourceStyleSheet } from "../stylesheet/datasource.mjs";
import { instanceSymbol } from "../../../constants.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
} from "../../../dom/customelement.mjs";
import { RestAPI } from "../../../data/datasource/server/restapi.mjs";
import { DataFetchError } from "../../../data/datasource/server/restapi/data-fetch-error.mjs";
import { Formatter } from "../../../text/formatter.mjs";
import { clone } from "../../../util/clone.mjs";
import { validateBoolean } from "../../../types/validate.mjs";
import { findElementWithIdUpwards } from "../../../dom/util.mjs";
import { Observer } from "../../../types/observer.mjs";
import { Pathfinder } from "../../../data/pathfinder.mjs";
import { fireCustomEvent } from "../../../dom/events.mjs";
import { isArray, isFunction, isObject, isString } from "../../../types/is.mjs";

export { Rest };

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

/**
 * @private
 * Original at source/components/datatable/datasource/rest.mjs
 * @type {symbol}
 */
const rawDataSymbol = Symbol.for(
	"@schukai/monster/data/datasource/server/restapi/rawdata",
);

/**
 * @private
 * @type {symbol}
 */
const intersectionObserverObserverSymbol = Symbol(
	"intersectionObserverObserver",
);

/**
 * @private
 * @type {symbol}
 */
const filterObserverSymbol = Symbol("filterObserver");
const readRequestIdSymbol = Symbol("readRequestId");
const writeRequestIdSymbol = Symbol("writeRequestId");
const lookupCacheSymbol = Symbol("lookupCache");
const lookupPendingSymbol = Symbol("lookupPending");
const lookupPendingFetchSymbol = Symbol("lookupPendingFetch");

/**
 * A rest api datasource
 *
 * @fragments /fragments/components/datatable/datasource/rest
 *
 * @example /examples/components/datatable/datasource-rest-simple Simple Rest datasource
 * @example /examples/components/datatable/datasource-rest-auto-init Auto init
 * @example /examples/components/datatable/datasource-rest-do-fetch Rest datasource with fetch
 *
 * @issue https://localhost.alvine.dev:8440/development/issues/closed/272.html
 *
 * @copyright Volker Schukai
 * @summary A rest api datasource for the datatable or other components
 */
class Rest extends Datasource {
	/**
	 * the constructor of the class
	 */
	constructor() {
		super();
		this[dataSourceSymbol] = new RestAPI();
	}

	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/components/datasource/rest@@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} features Feature definitions
	 * @property {boolean} features.autoInit If true, the component is initialized automatically
	 * @property {boolean} features.filter If true, the filter.id is used to attach a filter control
	 * @property {Object} autoInit Auto init definitions
	 * @property {boolean} autoInit.intersectionObserver If true, the intersection observer is initialized automatically
	 * @property {boolean} autoInit.oneTime If true, the intersection observer is initialized only once
	 * @property {Object} filter Filter definitions
	 * @property {string} filter.id The id of the filter control
	 * @property {Object} response Response definitions
	 * @property {Object} response.path Path definitions (changed in 3.56.0)
	 * @property {string} response.path.message Path to the message (changed in 3.56.0)
	 * @property {Object} read Read configuration
	 * @property {string} read.url The url of the rest api
	 * @property {string} read.method The method of the rest api
	 * @property {Object} read.parameters The parameters of the rest api
	 * @property {Object} read.parameters.filter The filter of the rest api
	 * @property {Object} read.parameters.order The order by of the rest api
	 * @property {Object} read.parameters.page The page of the rest api
	 * @property {string} read.mapping.currentPage The current page
	 * @property {Object} write Write configuration
	 * @property {string} write.url The url of the rest api
	 * @property {string} write.method The method of the rest api
	 * @property {Object} write Write configuration
	 */
	get defaults() {
		const restOptions = new RestAPI().defaults;

		restOptions.read.parameters = {
			filter: null,
			order: null,
			page: "1",
		};

		restOptions.read.mapping.currentPage = "sys.pagination.currentPage";

		return Object.assign({}, super.defaults, restOptions, {
			templates: {
				main: getTemplate(),
			},

			features: {
				autoInit: false,
				filter: false,
			},

			autoInit: {
				intersectionObserver: false,
				oneTime: true,
			},

			filter: {
				id: null,
			},

			/*datatable: {
                id: undefined,  // not used?
            },    */

			response: {
				path: {
					message: "sys.message",
					code: "sys.code",
					validationErrors: null,
					validationMessage: null,
				},
			},

			validation: {
				map: {},
			},

			lookups: {
				enabled: false,
				debug: false,
				sourcePath: "dataset",
				request: {
					idsParam: "ids",
					idsSeparator: ",",
				},
				response: {
					path: "dataset",
					id: "id",
				},
				format: {
					template: "${name}",
					marker: {
						open: ["${"],
						close: ["}"],
					},
				},
				columns: {},
			},
		});
	}

	/**
	 * With this method, you can set the parameters for the rest api. The parameters are
	 * used for building the url.
	 *
	 * @param {string} page
	 * @param {string} query
	 * @param {string} order
	 * @return {Rest}
	 */
	setParameters({ page, query, order }) {
		const parameters = this.getOption("read.parameters");
		if (query !== undefined) {
			parameters.query = `${query}`;
			parameters.page = "1";
		}

		// after a query the page is set to 1, so if the page is not set, it is set to 1
		if (page !== undefined) parameters.page = `${page}`;
		if (order !== undefined) parameters.order = `${order}`;
		this.setOption("read.parameters", parameters);
		return this;
	}

	/**
	 * @private
	 * @return {void}
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		initEventHandler.call(this);
	}

	/**
	 * This method reloads the data from the rest api, this method is deprecated.
	 * You should use the method `read` instead.
	 *
	 * @deprecated 2023-06-25
	 * @return {Promise<never>|*}
	 */
	reload() {
		return this.read();
	}

	/**
	 * Fetches the data from the rest api, this method is deprecated.
	 * You should use the method `read` instead.
	 *
	 * @deprecated 2024-12-24
	 * @return {Promise<never>|*}
	 */
	fetch() {
		return this.read();
	}

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

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

	/**
	 * This method activates the intersection observer manually.
	 * For this purpose, the option `autoInit.intersectionObserver` must be set to `false`.
	 *
	 * @return {Rest}
	 */
	initIntersectionObserver() {
		initIntersectionObserver.call(this);
		return this;
	}

	/**
	 * @private
	 */
	connectedCallback() {
		super.connectedCallback();

		queueMicrotask(() => {
			if (this.getOption("features.filter", false) === true) {
				initFilter.call(this);

				if (!this[filterObserverSymbol]) {
					initAutoInit.call(this);
				}
			} else {
				initAutoInit.call(this);
			}
		});
	}

	/**
	 * @private
	 */
	disconnectedCallback() {
		super.disconnectedCallback();
		removeFilter.call(this);
	}

	/**
	 * This method reads the data from the rest api.
	 * The data is stored in the internal dataset object.
	 *
	 * @return {Promise}
	 * @fires monster-datasource-fetch
	 * @fires monster-datasource-fetched
	 * @fires monster-datasource-error
	 */
	read() {
		const opt = clone(this.getOption("read"));

		const requestId = (this[readRequestIdSymbol] || 0) + 1;
		this[readRequestIdSymbol] = requestId;
		const responseCallback = opt?.responseCallback;
		opt.responseCallback = (obj) => {
			if (this[readRequestIdSymbol] !== requestId) {
				return;
			}
			if (typeof responseCallback === "function") {
				responseCallback(obj);
				return;
			}
			const transformedPayload = this[
				dataSourceSymbol
			].transformServerPayload.call(this[dataSourceSymbol], obj);
			if (typeof transformedPayload === "undefined") {
				return;
			}
			this[dataSourceSymbol].set(transformedPayload);
			applyLookups.call(this, requestId).catch(() => {});
		};
		this[dataSourceSymbol].setOption("read", opt);

		let url = this.getOption("read.url");
		if (!url) {
			return Promise.reject(new Error("No url defined"));
		}

		const param = this.getOption("read.parameters", {});

		if (param.query === null || param.query === undefined) {
			param.query = "";
		}

		if (param.page === null || param.page === undefined) {
			param.page = "1";
		}

		if (param.order === null || param.order === undefined) {
			param.order = "";
		}

		const formatter = new Formatter(param);

		url = formatter.format(url);

		this[dataSourceSymbol].setOption("read.url", url);

		return new Promise((resolve, reject) => {
			fireCustomEvent(this, "monster-datasource-fetch", {
				datasource: this,
			});

			queueMicrotask(() => {
				this[dataSourceSymbol]
					.read()
					.then((response) => {
						if (this[readRequestIdSymbol] === requestId) {
							fireCustomEvent(this, "monster-datasource-fetched", {
								datasource: this,
							});
						}

						resolve(response);
					})
					.catch((error) => {
						handleValidationError.call(this, error);
						if (this[readRequestIdSymbol] === requestId) {
							fireCustomEvent(this, "monster-datasource-error", {
								error: error,
							});

							addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString());
						}
						reject(error);
					});
			});
		});
	}

	/**
	 * Writes the data to the rest api.
	 * @return {Promise}
	 */
	write() {
		const opt = clone(this.getOption("write"));

		const requestId = (this[writeRequestIdSymbol] || 0) + 1;
		this[writeRequestIdSymbol] = requestId;
		const responseCallback = opt?.responseCallback;
		opt.responseCallback = (obj) => {
			if (this[writeRequestIdSymbol] !== requestId) {
				return;
			}
			if (typeof responseCallback === "function") {
				responseCallback(obj);
			}
		};
		this[dataSourceSymbol].setOption("write", opt);

		let url = this.getOption("write.url");
		const formatter = new Formatter(this.getOption("write.parameters"));

		if (!url) {
			return Promise.reject(new Error("No url defined"));
		}

		url = formatter.format(url);

		this[dataSourceSymbol].setOption("write.url", url);

		return new Promise((resolve, reject) => {
			fireCustomEvent(this, "monster-datasource-fetch", {
				datasource: this,
			});

			queueMicrotask(() => {
				this[dataSourceSymbol]
					.write()
					.then((response) => {
						if (this[writeRequestIdSymbol] === requestId) {
							fireCustomEvent(this, "monster-datasource-fetched", {
								datasource: this,
							});
						}

						resolve(response);
					})
					.catch((error) => {
						handleValidationError.call(this, error);
						if (this[writeRequestIdSymbol] === requestId) {
							fireCustomEvent(this, "monster-datasource-error", {
								error: error,
							});

							addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString());
						}
						reject(error);
					});
			});
		});
	}
}

/**
 * @private
 */
function removeFilter() {
	const filterID = this.getOption("filter.id", undefined);
	if (!filterID) return;

	const filterControl = findElementWithIdUpwards(this, filterID);

	if (filterControl && this[filterObserverSymbol]) {
		filterControl?.detachObserver(this[filterObserverSymbol]);
	}
}

/**
 * @private
 */
function initFilter() {
	const filterID = this.getOption("filter.id", undefined);

	if (!filterID)
		throw new Error("filter feature is enabled but no filter id is defined");

	const filterControl = findElementWithIdUpwards(this, filterID);
	if (!filterControl) {
		addAttributeToken(
			this,
			ATTRIBUTE_ERRORMESSAGE,
			"filter feature is enabled but no filter control with id " +
				filterID +
				" is found",
		);
		return;
	}

	this[filterObserverSymbol] = new Observer(() => {
		const query = filterControl.getOption("query");
		if (query === undefined || query === null) {
			return;
		}

		this.setParameters({ query: query });
		this.read()
			.then((response) => {
				if (!(response instanceof Response)) {
					return Promise.reject(new Error("response is not a Response object"));
				}

				if (response?.ok === true) {
					this.dispatchEvent(new CustomEvent("reload", { bubbles: true }));
					filterControl?.showSuccess();
				}

				if (response.bodyUsed === true) {
					return handleIntersectionObserver.call(
						this,
						response[rawDataSymbol],
						response,
						filterControl,
					);
				}

				response
					.text()
					.then((jsonAsText) => {
						let json;
						try {
							json = JSON.parse(jsonAsText);
						} catch (e) {
							const message = e instanceof Error ? e.message : `${e}`;
							filterControl?.showFailureMessage(message);
							return Promise.reject(e);
						}

						return handleIntersectionObserver.call(
							this,
							json,
							response,
							filterControl,
						);
					})
					.catch((e) => {
						filterControl?.showFailureMessage(e.message);
					});
			})
			.catch((e) => {
				this.dispatchEvent(
					new CustomEvent("error", { bubbles: true, detail: e }),
				);

				if (!(e instanceof Error)) {
					e = new Error(e);
				}

				filterControl?.showFailureMessage(e.message);
				return Promise.reject(e);
			});
	});

	filterControl.attachObserver(this[filterObserverSymbol]);
}

/**
 * @private
 * @param json
 * @param response
 * @param filterControl
 * @returns {Promise<never>|Promise<Awaited<unknown>>}
 */
function handleIntersectionObserver(json, response, filterControl) {
	const path = new Pathfinder(json);

	const codePath = this.getOption("response.path.code");

	if (path.exists(codePath)) {
		const code = `${path.getVia(codePath)}`;
		if (code && code === "200") {
			filterControl?.showSuccess();
			return Promise.resolve(response);
		}

		const messagePath = this.getOption("response.path.message");
		if (path.exists(messagePath)) {
			const message = path.getVia(messagePath);
			filterControl?.showFailureMessage(message);
			return Promise.reject(new Error(message));
		}

		return Promise.reject(new Error("Response code is not 200"));
	}
}

/**
 * @private
 */
function initAutoInit() {
	const autoInit = this.getOption("features.autoInit");
	validateBoolean(autoInit);

	if (autoInit !== true) return;

	if (this.getOption("autoInit.intersectionObserver") === true) {
		initIntersectionObserver.call(this);
		return;
	}

	queueMicrotask(() => {
		this.read().catch(() => {});
	});
}

/**
 * @private
 */
function initEventHandler() {
	this[intersectionObserverHandlerSymbol] = (entries) => {
		entries.forEach((entry) => {
			if (entry.isIntersecting) {
				if (entry.intersectionRatio > 0) {
					this.read();
				}

				// only load once
				if (
					this.getOption("autoInit.oneTime") === true &&
					this[intersectionObserverObserverSymbol] !== undefined
				) {
					this[intersectionObserverObserverSymbol].unobserve(this);
				}
			}
		});
	};
}

/**
 * @private
 */
function initIntersectionObserver() {
	this.classList.add("intersection-observer");

	const options = {
		root: null,
		rootMargin: "0px",
		threshold: 0.1,
	};

	this[intersectionObserverObserverSymbol] = new IntersectionObserver(
		this[intersectionObserverHandlerSymbol],
		options,
	);

	this[intersectionObserverObserverSymbol].observe(this);
}

/**
 * @private
 * @param {Error} error
 * @return {Promise<void>}
 */
function handleValidationError(error) {
	const path = this.getOption("response.path.validationErrors");
	if (!path) {
		return Promise.resolve();
	}

	if (!(error instanceof DataFetchError)) {
		return Promise.resolve();
	}

	const messagePath = this.getOption("response.path.validationMessage");

	return error
		.getBody()
		.then((body) => {
			let json = body;
			if (typeof body === "string") {
				try {
					json = JSON.parse(body);
				} catch {
					return;
				}
			}

			if (!isObject(json)) {
				return;
			}

			const pf = new Pathfinder(json);
			if (!pf.exists(path)) {
				return;
			}

			const rawErrors = pf.getVia(path);
			if (!isObject(rawErrors)) {
				return;
			}

			const normalizedErrors = {};
			for (const [key, value] of Object.entries(rawErrors)) {
				if (isObject(value)) {
					normalizedErrors[key] = {
						code: value.code || null,
						message: value.message || `${value}`,
					};
				} else {
					normalizedErrors[key] = { code: null, message: `${value}` };
				}
			}

			let message = null;
			if (messagePath && pf.exists(messagePath)) {
				message = pf.getVia(messagePath);
			}

			this.dispatchEvent(
				new CustomEvent("monster-datasource-validation", {
					bubbles: true,
					detail: {
						errors: normalizedErrors,
						raw: rawErrors,
						message,
						payload: json,
					},
				}),
			);
		})
		.catch(() => {});
}

/**
 * @private
 * @return {boolean}
 */
function lookupDebugEnabled() {
	return this.getOption("lookups.debug", false) === true;
}

/**
 * @private
 * @param {number} requestId
 * @return {Promise<void>}
 */
async function applyLookups(requestId) {
	if (!this.getOption("lookups.enabled", false)) {
		return;
	}

	const columns = this.getOption("lookups.columns", {});
	if (!isObject(columns)) {
		return;
	}

	const columnEntries = Object.entries(columns).filter(([, cfg]) =>
		isObject(cfg),
	);
	if (columnEntries.length === 0) {
		return;
	}

	const resolverColumns = [];
	const remoteColumns = [];
	for (const [name, cfg] of columnEntries) {
		if (isFunction(cfg.resolve)) {
			resolverColumns.push([name, cfg]);
		} else if (isString(cfg.url) && cfg.url !== "") {
			remoteColumns.push([name, cfg]);
		}
	}

	if (resolverColumns.length > 0) {
		updateLookupRows.call(this, requestId, (rows) => {
			for (const [, cfg] of resolverColumns) {
				const key = cfg.key;
				const target = cfg.target;
				if (!isString(key) || !isString(target)) {
					continue;
				}

				for (const row of rows) {
					const entry = cfg.resolve(row[key], row);
					if (!entry) {
						continue;
					}
					row[target] = formatLookupValue.call(this, cfg, entry, row);
					if (isString(cfg.loadingKey)) {
						row[cfg.loadingKey] = false;
					}
				}
			}
		});
	}

	if (remoteColumns.length === 0) {
		return;
	}

	await Promise.all(
		remoteColumns.map(([name, cfg]) =>
			resolveRemoteLookup.call(this, requestId, name, cfg),
		),
	);
}

/**
 * @private
 * @param {number} requestId
 * @param {string} name
 * @param {object} cfg
 * @return {Promise<void>}
 */
async function resolveRemoteLookup(requestId, name, cfg) {
	const key = cfg.key;
	const target = cfg.target;
	if (!isString(key) || !isString(target)) {
		return;
	}

	const cache = getLookupCache.call(this, name);
	const pending = getLookupPending.call(this, name);
	const pendingFetches = getLookupPendingFetches.call(this, name);

	const ids = collectLookupIds.call(
		this,
		key,
		this.getOption("lookups.sourcePath", "dataset"),
	);
	const missingIds = ids.filter((id) => !cache.has(id) && !pending.has(id));

	if (missingIds.length === 0) {
		if (pending.size > 0) {
			updateLookupRows.call(this, requestId, (rows) => {
				if (!isString(cfg.loadingKey)) return;
				for (const row of rows) {
					if (pending.has(String(row[key]))) {
						row[cfg.loadingKey] = true;
					}
				}
			});

			if (pendingFetches.size > 0) {
				const fetches = Array.from(pendingFetches);
				await Promise.all(fetches.map((promise) => promise.catch(() => null)));
			}
		}
		applyLookupCache.call(this, requestId, cfg, cache);
		return;
	}

	updateLookupRows.call(this, requestId, (rows) => {
		if (!isString(cfg.loadingKey)) return;
		for (const row of rows) {
			if (
				missingIds.includes(String(row[key])) ||
				pending.has(String(row[key]))
			) {
				row[cfg.loadingKey] = true;
			}
		}
	});

	missingIds.forEach((id) => pending.add(id));

	const fetchPromise = fetchLookupEntries.call(this, cfg, missingIds);
	pendingFetches.add(fetchPromise);
	let response;
	try {
		response = await fetchPromise;
	} finally {
		pendingFetches.delete(fetchPromise);
	}
	response.forEach((entry, id) => cache.set(id, entry));
	missingIds.forEach((id) => pending.delete(id));

	applyLookupCache.call(this, requestId, cfg, cache);
}

/**
 * @private
 * @param {number} requestId
 * @param {object} cfg
 * @param {Map} cache
 */
function applyLookupCache(requestId, cfg, cache) {
	const key = cfg.key;
	const target = cfg.target;

	updateLookupRows.call(this, requestId, (rows) => {
		for (const row of rows) {
			const entry = cache.get(String(row[key]));
			if (entry) {
				row[target] = formatLookupValue.call(this, cfg, entry, row);
			}
			if (isString(cfg.loadingKey)) {
				row[cfg.loadingKey] = false;
			}
		}
	});
}

/**
 * @private
 * @param {string} key
 * @return {string[]}
 */
function collectLookupIds(key, sourcePath) {
	const rows = resolveLookupRows.call(this, undefined, sourcePath);
	if (!isArray(rows)) {
		return [];
	}

	const ids = new Set();
	for (const row of rows) {
		const value = row?.[key];
		if (value === undefined || value === null || value === "") {
			continue;
		}
		if (isArray(value)) {
			value.forEach((entry) => ids.add(String(entry)));
		} else {
			ids.add(String(value));
		}
	}

	return Array.from(ids);
}

/**
 * @private
 * @param {object} cfg
 * @param {string[]} ids
 * @return {Promise<Map<string, object>>}
 */
async function fetchLookupEntries(cfg, ids) {
	const requestDefaults = this.getOption("lookups.request", {});
	const request = {
		...requestDefaults,
		...(cfg.request || {}),
	};

	const init = isObject(request.init) ? request.init : {};
	const url = buildLookupUrl(cfg.url, ids, request);
	const debug = lookupDebugEnabled.call(this);
	if (debug) {
		console.debug("[monster-datasource-rest] lookup fetch", {
			url,
			ids,
		});
	}

	const response = await fetch(url, init);
	if (!response.ok) {
		if (debug) {
			console.debug("[monster-datasource-rest] lookup failed", {
				url,
				status: response.status,
			});
		}
		return new Map();
	}

	let payload;
	try {
		payload = await response.json();
	} catch (_error) {
		if (debug) {
			console.debug("[monster-datasource-rest] lookup invalid json", { url });
		}
		return new Map();
	}

	const responseDefaults = this.getOption("lookups.response", {});
	const responseConfig = {
		...responseDefaults,
		...(cfg.response || {}),
	};

	let entries = payload;
	if (isString(responseConfig.path) && responseConfig.path !== "") {
		entries = new Pathfinder(payload).getVia(responseConfig.path);
	}

	if (!isArray(entries)) {
		if (debug) {
			console.debug("[monster-datasource-rest] lookup no entries", {
				url,
				path: responseConfig.path,
			});
		}
		return new Map();
	}

	const idKey = responseConfig.id || "id";
	const result = new Map();
	for (const entry of entries) {
		if (!entry || entry[idKey] === undefined || entry[idKey] === null) {
			continue;
		}
		result.set(String(entry[idKey]), entry);
	}
	if (debug) {
		console.debug("[monster-datasource-rest] lookup resolved", {
			url,
			entries: result.size,
		});
	}

	return result;
}

/**
 * @private
 * @param {string} url
 * @param {string[]} ids
 * @param {object} request
 * @return {string}
 */
function buildLookupUrl(url, ids, request) {
	const idsParam = request.idsParam || "ids";
	const idsValue = buildLookupIdsValue(ids, request);

	if (url.includes("${")) {
		const formatter = new Formatter({ ids: idsValue });
		return formatter.format(url);
	}

	const separator = url.includes("?") ? "&" : "?";
	return `${url}${separator}${encodeURIComponent(idsParam)}=${encodeURIComponent(
		idsValue,
	)}`;
}

/**
 * @private
 * @param {string[]} ids
 * @param {object} request
 * @return {string}
 */
function buildLookupIdsValue(ids, request) {
	if (isFunction(request.queryBuilder)) {
		return request.queryBuilder(ids);
	}

	const idsTemplate = request.idsTemplate;
	const idsSeparator = request.idsSeparator || ",";
	const wrapOpen = request.wrapOpen || "";
	const wrapClose = request.wrapClose || "";

	let values = ids;
	if (isString(idsTemplate) && idsTemplate !== "") {
		values = ids.map((id) => {
			const formatter = new Formatter({ id });
			return formatter.format(idsTemplate);
		});
	}

	return `${wrapOpen}${values.join(idsSeparator)}${wrapClose}`;
}

/**
 * @private
 * @param {object} cfg
 * @param {object} entry
 * @param {object} row
 * @return {string}
 */
function formatLookupValue(cfg, entry, row) {
	if (isFunction(cfg.format)) {
		return cfg.format(entry, row);
	}

	const formatDefaults = this.getOption("lookups.format", {});
	const format = isObject(cfg.format) ? cfg.format : {};
	const template = format.template || formatDefaults.template || "${name}";
	if (!isString(template)) {
		return "";
	}

	const formatter = new Formatter({ ...entry, row });
	const marker = format.marker || formatDefaults.marker;
	if (marker?.open) {
		formatter.setMarker(marker.open, marker.close);
	}

	return formatter.format(template);
}

/**
 * @private
 * @param {number} requestId
 * @param {Function} update
 */
function updateLookupRows(requestId, update) {
	if (this[readRequestIdSymbol] !== requestId) {
		return;
	}

	const data = this[dataSourceSymbol].get();
	const sourcePath = this.getOption("lookups.sourcePath", "dataset");
	const next = clone(data);
	const rows = resolveLookupRows.call(this, next, sourcePath);

	if (!isArray(rows)) {
		return;
	}

	update(rows);
	this[dataSourceSymbol].set(next);
}

/**
 * @private
 * @param {object} [data]
 * @param {string} [sourcePath]
 * @return {Array|undefined}
 */
function resolveLookupRows(data, sourcePath) {
	const source = data || this[dataSourceSymbol].get();
	if (isString(sourcePath) && sourcePath !== "") {
		return new Pathfinder(source).getVia(sourcePath);
	}
	if (isArray(source)) {
		return source;
	}
	if (isObject(source) && isArray(source.dataset)) {
		return source.dataset;
	}
	return undefined;
}

/**
 * @private
 * @param {string} name
 * @return {Map<string, object>}
 */
function getLookupCache(name) {
	if (!this[lookupCacheSymbol]) {
		this[lookupCacheSymbol] = new Map();
	}
	if (!this[lookupCacheSymbol].has(name)) {
		this[lookupCacheSymbol].set(name, new Map());
	}
	return this[lookupCacheSymbol].get(name);
}

/**
 * @private
 * @param {string} name
 * @return {Set<string>}
 */
function getLookupPending(name) {
	if (!this[lookupPendingSymbol]) {
		this[lookupPendingSymbol] = new Map();
	}
	if (!this[lookupPendingSymbol].has(name)) {
		this[lookupPendingSymbol].set(name, new Set());
	}
	return this[lookupPendingSymbol].get(name);
}

/**
 * @private
 * @param {string} name
 * @return {Set<Promise<Map<string, object>>>}
 */
function getLookupPendingFetches(name) {
	if (!this[lookupPendingFetchSymbol]) {
		this[lookupPendingFetchSymbol] = new Map();
	}
	if (!this[lookupPendingFetchSymbol].has(name)) {
		this[lookupPendingFetchSymbol].set(name, new Set());
	}
	return this[lookupPendingFetchSymbol].get(name);
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `<slot></slot>`;
}

registerCustomElement(Rest);
