/**
 * 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 { addAttributeToken } from "../../dom/attributes.mjs";
import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs";
import { CustomControl } from "../../dom/customcontrol.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";
import { Formatter } from "../../text/formatter.mjs";
import { isFunction, isObject, isString } from "../../types/is.mjs";
import { validateArray } from "../../types/validate.mjs";
import { Pathfinder } from "../../data/pathfinder.mjs";
import { CommonStyleSheet } from "../stylesheet/common.mjs";
import { ButtonStyleSheet } from "../stylesheet/button.mjs";
import { FormStyleSheet } from "../stylesheet/form.mjs";
import { VariantSelectStyleSheet } from "./stylesheet/variant-select.mjs";
import { Select } from "./select.mjs";

export { VariantSelect };

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

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

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

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

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

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

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

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

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

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

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

/**
 * VariantSelect
 *
 * @fragments /fragments/components/form/variant-select/
 *
 * @example /examples/components/form/variant-select-buttons Button layout
 * @example /examples/components/form/variant-select-row-selects Row selects
 * @example /examples/components/form/variant-select-combined-select Combined select
 * @example /examples/components/form/variant-select-label-template Label templates
 * @example /examples/components/form/variant-select-values-map Values map
 * @example /examples/components/form/variant-select-messages-off Messages off
 * @example /examples/components/form/variant-select-remote Remote data
 *
 * @summary A variant picker for valid product combinations such as color, size and availability-dependent choices.
 * @since 4.64.0
 * @fires monster-variant-select-change
 * @fires monster-variant-select-valid
 * @fires monster-variant-select-invalid
 * @fires monster-variant-select-lazy-load
 * @fires monster-variant-select-lazy-loaded
 * @fires monster-variant-select-lazy-error
 */
class VariantSelect extends CustomControl {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for(
			"@schukai/monster/components/form/variant-select@@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}
	 *
	 * @property {Array<Object>} dimensions Dimension definitions ({key, label, presentation, valueTemplate, labelTemplate})
	 * @property {Array<Object>|Object|null} dimensions.values Optional label map for a dimension
	 * @property {Array<Object>} data Variant items
	 * @property {string|null} url Endpoint to fetch variants
	 * @property {Object} fetch Fetch options (method, headers, body)
	 * @property {Object} mapping Data mapping for fetched results
	 * @property {string} mapping.selector="*" Path to array in fetched data
	 * @property {string} mapping.valueTemplate Template for variant value
	 * @property {string} mapping.labelTemplate Template for combined variant label
	 * @property {Function|null} mapping.filter Filter function
	 * @property {Object} layout Layout options
	 * @property {boolean} layout.autoSelect=true Use select when buttons do not fit
	 * @property {number} layout.buttonMinWidth=84 Minimum width used to estimate button layout
	 * @property {Object} features Feature toggles
	 * @property {boolean} features.messages=true Show status message
	 * @property {Object} messages Message text
	 * @property {string} messages.valid Message for valid selection
	 * @property {string} messages.invalid Message for invalid selection
	 * @property {string} messages.incomplete Message for incomplete selection
	 * @property {Object} actions Callback actions
	 * @property {Function} actions.onchange called on any selection change
	 * @property {Function} actions.onvalid called when a valid variant is selected
	 * @property {Function} actions.oninvalid called when selection becomes invalid
	 * @property {Function} actions.onlazyload called before fetching variants
	 * @property {Function} actions.onlazyloaded called after fetching variants
	 * @property {Function} actions.onlazyerror called on fetch errors
	 * @property {Object} classes CSS classes
	 * @property {string} classes.option Base class for option buttons
	 * @property {string} classes.optionSelected Class for selected options
	 * @property {string} classes.optionDisabled Class for disabled options
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},
			dimensions: [],
			data: [],
			url: null,
			fetch: {
				method: "GET",
				headers: {
					accept: "application/json",
				},
				body: null,
			},
			mapping: {
				selector: "*",
				valueTemplate: "",
				labelTemplate: "",
				filter: null,
			},
			layout: {
				autoSelect: false,
				buttonMinWidth: 84,
				combineSelect: true,
			},
			features: {
				messages: true,
				rowSelects: false,
				combinedSelect: false,
			},
			messages: getTranslations(),
			selection: {},
			value: null,
			classes: {
				option: "monster-button-outline-primary",
				optionSelected: "is-selected",
				optionDisabled: "is-disabled",
			},
			actions: {
				onchange: null,
				onvalid: null,
				oninvalid: null,
				onlazyload: null,
				onlazyloaded: null,
				onlazyerror: null,
			},
		});
	}

	/**
	 * @return {VariantSelect}
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		initControlReferences.call(this);
		initEventHandler.call(this);
		initResizeObserver.call(this);
		refreshData.call(this);
		return this;
	}

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

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

	/**
	 * @return {boolean}
	 */
	static get formAssociated() {
		return true;
	}

	/**
	 * @return {string|null}
	 */
	get value() {
		return this.getOption("value");
	}

	/**
	 * @param {string|null} value
	 */
	set value(value) {
		this.setOption("value", value);
		if (typeof this.setFormValue === "function") {
			this.setFormValue(value ?? "");
		}
	}

	/**
	 * Reload the data from `data` or `url`.
	 */
	refresh() {
		refreshData.call(this);
	}
}

/**
 * @private
 */
function initControlReferences() {
	this[controlElementSymbol] = this.shadowRoot.querySelector(
		"[data-monster-role=control]",
	);
	this[dimensionsElementSymbol] = this.shadowRoot.querySelector(
		"[data-monster-role=dimensions]",
	);
	this[messageElementSymbol] = this.shadowRoot.querySelector(
		"[data-monster-role=message]",
	);
	this[variantsSymbol] = [];
	this[dimensionValuesSymbol] = new Map();
	this[layoutSymbol] = new Map();
	this[selectControlSymbol] = new Map();
	this[combinedSelectSymbol] = null;
}

/**
 * @private
 */
function initEventHandler() {
	this.shadowRoot.addEventListener("click", (event) => {
		const button = event.target?.closest("button[data-variant-dimension]");
		if (!(button instanceof HTMLButtonElement)) {
			return;
		}
		const key = button.getAttribute("data-variant-dimension");
		const value = button.getAttribute("data-variant-value");
		if (!key) {
			return;
		}
		handleSelectionChange.call(this, key, value);
	});
}

/**
 * @private
 */
function initResizeObserver() {
	if (typeof ResizeObserver !== "function") {
		return;
	}
	if (!(this[controlElementSymbol] instanceof HTMLElement)) {
		return;
	}

	this[resizeObserverSymbol] = new ResizeObserver(() => {
		render.call(this);
	});
	this[resizeObserverSymbol].observe(this[controlElementSymbol]);
}

/**
 * @private
 */
function refreshData() {
	const url = this.getOption("url");
	if (isString(url) && url !== "") {
		void fetchData.call(this);
		return;
	}
	buildFromData.call(this);
}

/**
 * @private
 */
async function fetchData() {
	const url = this.getOption("url");
	if (!isString(url) || url === "") {
		return;
	}

	const action = this.getOption("actions.onlazyload");
	if (isFunction(action)) {
		action.call(this);
	}
	fireCustomEvent(this, "monster-variant-select-lazy-load", {});

	let response = null;
	try {
		response = await fetch(url, buildFetchOptions.call(this));
		if (!response.ok) {
			throw new Error("failed to fetch variants");
		}
	} catch (e) {
		handleFetchError.call(this, e);
		return;
	}

	let json = null;
	try {
		json = await response.json();
	} catch (e) {
		handleFetchError.call(this, e);
		return;
	}

	const selector = this.getOption("mapping.selector", "*");
	let data = json;
	try {
		if (selector !== "*" && selector !== "") {
			data = new Pathfinder(json).getVia(selector);
		}
	} catch (e) {
		handleFetchError.call(this, e);
		return;
	}

	if (!Array.isArray(data)) {
		handleFetchError.call(this, new Error("variant data is not an array"));
		return;
	}

	this.setOption("data", data);
	buildFromData.call(this);

	const doneAction = this.getOption("actions.onlazyloaded");
	if (isFunction(doneAction)) {
		doneAction.call(this, data);
	}
	fireCustomEvent(this, "monster-variant-select-lazy-loaded", { data });
}

/**
 * @private
 * @param {Error} error
 */
function handleFetchError(error) {
	addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${error}`);
	const action = this.getOption("actions.onlazyerror");
	if (isFunction(action)) {
		action.call(this, error);
	}
	fireCustomEvent(this, "monster-variant-select-lazy-error", { error });
}

/**
 * @private
 * @return {Object}
 */
function buildFetchOptions() {
	const fetchOptions = Object.assign({}, this.getOption("fetch", {}));
	if (!fetchOptions.method) {
		fetchOptions.method = "GET";
	}
	return fetchOptions;
}

/**
 * @private
 */
function buildFromData() {
	this[initGuardSymbol] = true;

	const dimensions = this.getOption("dimensions", []);
	try {
		validateArray(dimensions);
	} catch (e) {
		addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
		return;
	}

	const data = this.getOption("data", []);
	const mapping = this.getOption("mapping", {});
	const filter = mapping?.filter;

	let items = Array.isArray(data) ? data.slice() : [];
	if (isFunction(filter)) {
		items = items.filter((item) => filter(item));
	}

	const variants = [];
	const valueTemplate = mapping?.valueTemplate;
	for (let i = 0; i < items.length; i += 1) {
		const item = items[i];
		const dims = {};
		let hasMissing = false;

		for (const def of dimensions) {
			const key = def?.key;
			if (!isString(key) || key === "") {
				hasMissing = true;
				break;
			}
			const value = getDimensionValue(item, def);
			if (value === null || value === undefined || value === "") {
				hasMissing = true;
				break;
			}
			dims[key] = String(value);
		}

		if (hasMissing) {
			continue;
		}

		let value = null;
		if (isString(valueTemplate) && valueTemplate !== "") {
			value = new Formatter(item).format(valueTemplate);
		} else if (item?.value !== undefined) {
			value = item.value;
		} else if (item?.id !== undefined) {
			value = item.id;
		} else {
			value = `${i}`;
		}

		variants.push({
			value: String(value),
			data: item,
			dimensions: dims,
		});
	}

	this[variantsSymbol] = variants;
	buildDimensionValues.call(this, dimensions, variants);
	applyInitialSelection.call(this, dimensions, variants);
	render.call(this);
	this[initGuardSymbol] = false;
}

/**
 * @private
 * @param {Array} dimensions
 * @param {Array} variants
 */
function buildDimensionValues(dimensions, variants) {
	this[dimensionValuesSymbol].clear();
	for (const def of dimensions) {
		const key = def?.key;
		if (!isString(key) || key === "") {
			continue;
		}
		const labelMap = new Map();
		appendValuesFromDefinition(def, labelMap);
		for (const variant of variants) {
			const value = variant.dimensions[key];
			if (!labelMap.has(String(value))) {
				const label = getDimensionLabel(variant.data, def, value);
				labelMap.set(value, label);
			}
		}
		this[dimensionValuesSymbol].set(key, labelMap);
	}
}

/**
 * @private
 * @param {Array} dimensions
 * @param {Array} variants
 */
function applyInitialSelection(dimensions, variants) {
	const selection = Object.assign({}, this.getOption("selection", {}));
	const value = this.getOption("value");

	if (isString(value) && value !== "") {
		const match = variants.find((variant) => variant.value === value);
		if (match) {
			for (const def of dimensions) {
				const key = def?.key;
				if (key) {
					selection[key] = match.dimensions[key];
				}
			}
		}
	}

	this.setOption("selection", selection);
	updateSelectedVariant.call(this);
}

/**
 * @private
 */
function render() {
	if (!(this[dimensionsElementSymbol] instanceof HTMLElement)) {
		return;
	}

	const dimensions = this.getOption("dimensions", []);
	const containerWidth = getContainerWidth.call(this);
	const valuesMap = this[dimensionValuesSymbol];
	const selection = this.getOption("selection", {});

	this[dimensionsElementSymbol].innerHTML = "";
	this[selectControlSymbol].clear();
	this[layoutSymbol].clear();
	this[combinedSelectSymbol] = null;

	const useCombinedSelect = shouldUseCombinedSelect.call(
		this,
		dimensions,
		valuesMap,
		containerWidth,
	);

	if (useCombinedSelect) {
		renderCombinedSelect.call(this, dimensions, valuesMap, selection);
		updateUI.call(this);
		updateMessage.call(this);
		return;
	}

	for (const def of dimensions) {
		const key = def?.key;
		if (!isString(key) || key === "") {
			continue;
		}
		const label = def?.label || key;
		const valueMap = valuesMap.get(key) || new Map();

		const row = document.createElement("div");
		row.setAttribute("data-monster-role", "dimension");
		row.setAttribute("data-variant-dimension", key);
		row.setAttribute("part", "dimension");

		const labelNode = document.createElement("div");
		labelNode.setAttribute("data-monster-role", "dimension-label");
		labelNode.textContent = label;
		labelNode.setAttribute("part", "dimension-label");

		const controls = document.createElement("div");
		controls.setAttribute("data-monster-role", "dimension-controls");
		controls.setAttribute("part", "dimension-controls");

		const mode = resolvePresentationMode.call(
			this,
			def,
			valueMap.size,
			containerWidth,
		);
		this[layoutSymbol].set(key, mode);

		if (mode === "select") {
			const select = document.createElement(Select.getTag());
			select.setAttribute("data-monster-options", "{}");
			select.setAttribute("part", "dimension-select");
			select.addEventListener("monster-change", (event) => {
				const value = event?.detail?.value ?? "";
				if (value === "") {
					handleSelectionChange.call(this, key, null);
				} else {
					handleSelectionChange.call(this, key, value);
				}
			});
			controls.appendChild(select);
			this[selectControlSymbol].set(key, select);
			configureSelect.call(this, select, valueMap, selection?.[key] ?? "");
		} else {
			const options = document.createElement("div");
			options.setAttribute("data-monster-role", "options");
			options.setAttribute("part", "dimension-options");

			for (const [value, optionLabel] of valueMap.entries()) {
				const button = document.createElement("button");
				button.type = "button";
				button.className = this.getOption("classes.option");
				button.textContent = optionLabel;
				button.setAttribute("data-variant-dimension", key);
				button.setAttribute("data-variant-value", value);
				button.setAttribute("part", "dimension-option");
				options.appendChild(button);
			}
			controls.appendChild(options);
		}

		row.appendChild(labelNode);
		row.appendChild(controls);
		this[dimensionsElementSymbol].appendChild(row);
	}

	updateUI.call(this);
	updateMessage.call(this);
}

/**
 * @private
 * @param {Object} def
 * @param {number} count
 * @param {number} width
 * @return {string}
 */
function resolvePresentationMode(def, count, width) {
	const presentation = def?.presentation;
	if (presentation === "select" || presentation === "buttons") {
		if (
			presentation === "select" &&
			this.getOption("features.rowSelects") !== true
		) {
			return "buttons";
		}
		return presentation;
	}

	if (this.getOption("features.rowSelects") !== true) {
		return "buttons";
	}

	const auto = this.getOption("layout.autoSelect") !== false;
	if (!auto) {
		return "buttons";
	}

	const minWidth = Number(this.getOption("layout.buttonMinWidth", 84));
	if (!Number.isFinite(minWidth) || minWidth <= 0) {
		return "buttons";
	}
	return count * minWidth > width ? "select" : "buttons";
}

/**
 * @private
 * @return {number}
 */
function getContainerWidth() {
	if (!(this[controlElementSymbol] instanceof HTMLElement)) {
		return 320;
	}
	const width = this[controlElementSymbol].getBoundingClientRect().width;
	if (!Number.isFinite(width) || width <= 0) {
		return 320;
	}
	return width;
}

/**
 * @private
 * @param {Map} values
 * @return {Array}
 */
function buildSelectOptions(values) {
	const options = [];
	for (const [value, label] of values.entries()) {
		options.push({ label, value });
	}
	return options;
}

/**
 * @private
 * @param {string} key
 * @param {string|null} value
 */
function handleSelectionChange(key, value) {
	const selection = Object.assign({}, this.getOption("selection", {}));
	const nextValue = value === null ? null : String(value);
	if (selection[key] === nextValue) {
		selection[key] = null;
	} else {
		selection[key] = nextValue;
	}

	normalizeSelection.call(this, selection, key);
	this.setOption("selection", selection);
	updateSelectedVariant.call(this);
	updateUI.call(this);
	emitSelectionEvents.call(this);
	updateMessage.call(this);
}

/**
 * @private
 * @param {Object} selection
 * @param {string|null} fixedKey
 */
function normalizeSelection(selection, fixedKey) {
	const dimensions = this.getOption("dimensions", []);
	const keys = dimensions
		.map((def) => def?.key)
		.filter((key) => isString(key) && key !== "");

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

	const fixedKeys =
		isString(fixedKey) && selection[fixedKey] != null ? [fixedKey] : [];
	const candidateKeys = keys.filter(
		(key) => !fixedKeys.includes(key) && selection[key] != null,
	);

	const best = findBestSelection.call(
		this,
		selection,
		fixedKeys,
		candidateKeys,
	);

	for (const key of candidateKeys) {
		if (!best.includes(key)) {
			selection[key] = null;
		}
	}
}

/**
 * @private
 * @param {Object} selection
 * @param {Array} fixedKeys
 * @param {Array} candidateKeys
 * @return {Array}
 */
function findBestSelection(selection, fixedKeys, candidateKeys) {
	const total = 1 << candidateKeys.length;
	let best = [];

	for (let mask = 0; mask < total; mask += 1) {
		const subset = [];
		for (let i = 0; i < candidateKeys.length; i += 1) {
			if (mask & (1 << i)) {
				subset.push(candidateKeys[i]);
			}
		}
		const testSelection = buildSelectionForKeys(selection, fixedKeys, subset);
		if (hasVariantMatch.call(this, testSelection)) {
			if (subset.length > best.length) {
				best = subset;
			}
		}
	}

	return best;
}

/**
 * @private
 * @param {Object} selection
 * @param {Array} fixedKeys
 * @param {Array} subsetKeys
 * @return {Object}
 */
function buildSelectionForKeys(selection, fixedKeys, subsetKeys) {
	const result = {};
	for (const key of fixedKeys) {
		result[key] = selection[key];
	}
	for (const key of subsetKeys) {
		result[key] = selection[key];
	}
	return result;
}

/**
 * @private
 * @param {Object} selection
 * @return {boolean}
 */
function hasVariantMatch(selection) {
	const variants = this[variantsSymbol] || [];
	for (const variant of variants) {
		if (matchesSelection(variant, selection)) {
			return true;
		}
	}
	return false;
}

/**
 * @private
 */
function updateSelectedVariant() {
	const selection = this.getOption("selection", {});
	const dimensions = this.getOption("dimensions", []);
	const keys = dimensions
		.map((def) => def?.key)
		.filter((key) => isString(key) && key !== "");

	let complete = true;
	for (const key of keys) {
		if (!isString(selection?.[key]) || selection[key] === "") {
			complete = false;
			break;
		}
	}

	if (!complete) {
		this.value = null;
		return;
	}

	const variants = this[variantsSymbol] || [];
	const match = variants.find((variant) =>
		matchesSelection(variant, selection),
	);
	this.value = match ? match.value : null;
}

/**
 * @private
 */
function updateUI() {
	const selection = this.getOption("selection", {});
	const dimensions = this.getOption("dimensions", []);
	const valuesMap = this[dimensionValuesSymbol];
	const combined = this[combinedSelectSymbol];

	if (combined instanceof Select && combined.shadowRoot) {
		updateCombinedSelect.call(this);
		return;
	}

	for (const def of dimensions) {
		const key = def?.key;
		if (!isString(key) || key === "") {
			continue;
		}
		const available = getAvailableValues.call(this, key, selection);
		const mode = this[layoutSymbol].get(key);

		if (mode === "select") {
			const select = this[selectControlSymbol].get(key);
			if (select instanceof Select && select.shadowRoot) {
				const allValues = valuesMap.get(key) || new Map();
				const options = [];
				for (const [value, label] of allValues.entries()) {
					if (available.has(value)) {
						options.push({ label, value });
					}
				}
				select.setOption("options", options);
				select.value = selection?.[key] ?? "";
			}
			continue;
		}

		const buttons = this.shadowRoot.querySelectorAll(
			`button[data-variant-dimension=\"${key}\"]`,
		);
		for (const button of buttons) {
			const value = button.getAttribute("data-variant-value");
			const isSelected = selection?.[key] === value;
			const isAvailable = available.has(value);

			button.classList.toggle(
				this.getOption("classes.optionSelected"),
				isSelected,
			);
			button.classList.toggle(
				this.getOption("classes.optionDisabled"),
				!isAvailable,
			);
			button.setAttribute("aria-pressed", isSelected ? "true" : "false");
			button.setAttribute("aria-disabled", isAvailable ? "false" : "true");
			button.setAttribute(
				"data-variant-disabled",
				isAvailable ? "false" : "true",
			);
		}
	}
}

/**
 * @private
 * @param {Select} select
 * @param {Map} valueMap
 * @param {string} selectedValue
 */
function configureSelect(select, valueMap, selectedValue) {
	const init = () => {
		if (!select.shadowRoot) {
			return false;
		}
		select.setOption("filter.mode", "disabled");
		select.setOption("features.lazyLoad", false);
		select.setOption("options", buildSelectOptions(valueMap));
		select.value = selectedValue;
		return true;
	};

	if (init()) {
		return;
	}

	let attempts = 0;
	const retry = () => {
		attempts += 1;
		if (init() || attempts > 10) {
			return;
		}
		requestAnimationFrame(retry);
	};

	requestAnimationFrame(retry);
}

/**
 * @private
 * @param {Array} dimensions
 * @param {Map} valuesMap
 * @param {number} containerWidth
 * @return {boolean}
 */
function shouldUseCombinedSelect(dimensions, valuesMap, containerWidth) {
	const combine =
		this.getOption("layout.combineSelect") !== false &&
		this.getOption("features.combinedSelect") === true;
	if (!combine) {
		return false;
	}

	for (const def of dimensions) {
		const key = def?.key;
		if (!isString(key) || key === "") {
			continue;
		}
		const valueMap = valuesMap.get(key) || new Map();
		const mode = resolvePresentationMode.call(
			this,
			def,
			valueMap.size,
			containerWidth,
		);
		if (mode !== "select") {
			return false;
		}
	}

	return true;
}

/**
 * @private
 * @param {Array} dimensions
 * @param {Map} valuesMap
 * @param {Object} selection
 */
function renderCombinedSelect(dimensions, valuesMap, selection) {
	const select = document.createElement(Select.getTag());
	select.setAttribute("data-monster-options", "{}");
	select.setAttribute("part", "combined-select");
	select.setAttribute("data-monster-role", "combined-select");
	select.style.width = "100%";

	select.addEventListener("monster-change", (event) => {
		const value = event?.detail?.value ?? "";
		if (value === "") {
			this.setOption("selection", {});
			this.value = null;
			updateMessage.call(this);
			emitSelectionEvents.call(this);
			return;
		}
		applyVariantSelection.call(this, value);
	});

	this[dimensionsElementSymbol].appendChild(select);
	this[combinedSelectSymbol] = select;

	configureCombinedSelect.call(this, select, selection);
}

/**
 * @private
 * @param {Select} select
 * @param {Object} selection
 */
function configureCombinedSelect(select, selection) {
	const init = () => {
		if (!select.shadowRoot) {
			return false;
		}
		select.setOption("filter.mode", "options");
		select.setOption("filter.position", "popper");
		select.setOption("features.lazyLoad", false);
		select.setOption("options", buildCombinedOptions.call(this));
		select.value = this.getOption("value") ?? "";
		return true;
	};

	if (init()) {
		return;
	}

	let attempts = 0;
	const retry = () => {
		attempts += 1;
		if (init() || attempts > 10) {
			return;
		}
		requestAnimationFrame(retry);
	};
	requestAnimationFrame(retry);
}

/**
 * @private
 */
function updateCombinedSelect() {
	const select = this[combinedSelectSymbol];
	if (!(select instanceof Select) || !select.shadowRoot) {
		return;
	}
	select.setOption("options", buildCombinedOptions.call(this));
	select.value = this.getOption("value") ?? "";
}

/**
 * @private
 * @return {Array}
 */
function buildCombinedOptions() {
	const variants = this[variantsSymbol] || [];
	const dimensions = this.getOption("dimensions", []);
	const valuesMap = this[dimensionValuesSymbol];
	const labelTemplate = this.getOption("mapping.labelTemplate", "");
	const options = [];

	for (const variant of variants) {
		let label = "";
		if (isString(labelTemplate) && labelTemplate !== "") {
			label = new Formatter(variant.data).format(labelTemplate);
		} else {
			label = buildVariantLabel(variant, dimensions, valuesMap);
		}
		options.push({
			label,
			value: variant.value,
		});
	}

	return options;
}

/**
 * @private
 * @param {Object} variant
 * @param {Array} dimensions
 * @param {Map} valuesMap
 * @return {string}
 */
function buildVariantLabel(variant, dimensions, valuesMap) {
	const parts = [];
	for (const def of dimensions) {
		const key = def?.key;
		if (!isString(key) || key === "") {
			continue;
		}
		const labelName = def?.label || key;
		const value = variant.dimensions?.[key];
		const labelMap = valuesMap.get(key) || new Map();
		const labelValue = labelMap.get(String(value)) ?? value;
		parts.push(`${labelName}: ${labelValue}`);
	}
	return parts.join(", ");
}

/**
 * @private
 * @param {string} value
 */
function applyVariantSelection(value) {
	const variants = this[variantsSymbol] || [];
	const match = variants.find((entry) => entry.value === value);
	if (!match) {
		this.setOption("selection", {});
		this.value = null;
		updateMessage.call(this);
		emitSelectionEvents.call(this);
		return;
	}

	this.setOption("selection", Object.assign({}, match.dimensions));
	this.value = match.value;
	updateMessage.call(this);
	emitSelectionEvents.call(this);
}

/**
 * @private
 */
function emitSelectionEvents() {
	if (this[initGuardSymbol]) {
		return;
	}

	const selection = this.getOption("selection", {});
	const value = this.getOption("value");
	const variants = this[variantsSymbol] || [];
	const variant = variants.find((entry) => entry.value === value) || null;
	const detail = { selection, value, variant };

	const changeAction = this.getOption("actions.onchange");
	if (isFunction(changeAction)) {
		changeAction.call(this, detail);
	}
	fireCustomEvent(this, "monster-variant-select-change", detail);

	const isValid = isString(value) && value !== "";
	if (this[lastValiditySymbol] === isValid) {
		return;
	}
	this[lastValiditySymbol] = isValid;

	if (isValid) {
		const action = this.getOption("actions.onvalid");
		if (isFunction(action)) {
			action.call(this, detail);
		}
		fireCustomEvent(this, "monster-variant-select-valid", detail);
		return;
	}

	const action = this.getOption("actions.oninvalid");
	if (isFunction(action)) {
		action.call(this, detail);
	}
	fireCustomEvent(this, "monster-variant-select-invalid", detail);
}

/**
 * @private
 */
function updateMessage() {
	if (this.getOption("features.messages") === false) {
		if (this[messageElementSymbol] instanceof HTMLElement) {
			this[messageElementSymbol].textContent = "";
		}
		return;
	}

	if (!(this[messageElementSymbol] instanceof HTMLElement)) {
		return;
	}

	const selection = this.getOption("selection", {});
	const value = this.getOption("value");
	const dimensions = this.getOption("dimensions", []);
	const keys = dimensions
		.map((def) => def?.key)
		.filter((key) => isString(key) && key !== "");

	let complete = true;
	for (const key of keys) {
		if (!isString(selection?.[key]) || selection[key] === "") {
			complete = false;
			break;
		}
	}

	let message = "";
	if (!complete) {
		message = this.getOption("messages.incomplete");
	} else if (!isString(value) || value === "") {
		message = this.getOption("messages.invalid");
	} else {
		message = this.getOption("messages.valid");
	}

	this[messageElementSymbol].textContent = message || "";
}

/**
 * @private
 * @param {string} key
 * @param {Object} selection
 * @return {Set}
 */
function getAvailableValues(key, selection) {
	const variants = this[variantsSymbol] || [];
	const available = new Set();

	for (const variant of variants) {
		if (matchesSelection(variant, selection, key)) {
			available.add(variant.dimensions[key]);
		}
	}

	return available;
}

/**
 * @private
 * @param {Object} variant
 * @param {Object} selection
 * @param {string|null} ignoreKey
 * @return {boolean}
 */
function matchesSelection(variant, selection, ignoreKey = null) {
	for (const [key, value] of Object.entries(selection || {})) {
		if (ignoreKey && key === ignoreKey) {
			continue;
		}
		if (value === null || value === undefined || value === "") {
			continue;
		}
		if (variant.dimensions[key] !== value) {
			return false;
		}
	}
	return true;
}

/**
 * @private
 * @param {Object} item
 * @param {Object} def
 * @return {string|null}
 */
function getDimensionValue(item, def) {
	const template = def?.valueTemplate;
	if (isString(template) && template !== "") {
		return new Formatter(item).format(template);
	}

	const key = def?.key;
	if (!isString(key) || key === "") {
		return null;
	}

	try {
		return new Pathfinder(item).getVia(key);
	} catch (_e) {
		return item?.[key];
	}
}

/**
 * @private
 * @param {Object} item
 * @param {Object} def
 * @param {string} fallback
 * @return {string}
 */
function getDimensionLabel(item, def, fallback) {
	const template = def?.labelTemplate;
	if (isString(template) && template !== "") {
		return new Formatter(item).format(template);
	}
	return fallback;
}

/**
 * @private
 * @param {Object} def
 * @param {Map} labelMap
 */
function appendValuesFromDefinition(def, labelMap) {
	const values = def?.values;
	if (!values) {
		return;
	}
	if (Array.isArray(values)) {
		for (const entry of values) {
			const value = entry?.value;
			if (!isString(value) || value === "") {
				continue;
			}
			const label = entry?.label ?? value;
			labelMap.set(String(value), String(label));
		}
		return;
	}
	if (isObject(values)) {
		for (const [value, label] of Object.entries(values)) {
			if (!isString(value) || value === "") {
				continue;
			}
			labelMap.set(String(value), String(label ?? value));
		}
	}
}

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

registerCustomElement(VariantSelect);

/**
 * @private
 * @returns {object}
 */
function getTranslations() {
	const locale = getLocaleOfDocument();
	switch (locale.language) {
		case "de":
			return {
				valid: "Auswahl ist gueltig.",
				invalid: "Diese Variantenkombination existiert nicht.",
				incomplete: "Bitte alle Varianten auswaehlen.",
			};
		case "es":
			return {
				valid: "La seleccion es valida.",
				invalid: "Esta combinacion no existe.",
				incomplete: "Seleccione todas las opciones.",
			};
		case "zh":
			return {
				valid: "选择有效。",
				invalid: "该组合不存在。",
				incomplete: "请完成所有选项。",
			};
		case "hi":
			return {
				valid: "चयन मान्य है।",
				invalid: "यह संयोजन उपलब्ध नहीं है।",
				incomplete: "कृपया सभी विकल्प चुनें।",
			};
		case "bn":
			return {
				valid: "নির্বাচন বৈধ।",
				invalid: "এই সংযোজনটি নেই।",
				incomplete: "অনুগ্রহ করে সব বিকল্প নির্বাচন করুন।",
			};
		case "pt":
			return {
				valid: "Selecao valida.",
				invalid: "Esta combinacao nao existe.",
				incomplete: "Selecione todas as opcoes.",
			};
		case "ru":
			return {
				valid: "Выбор корректен.",
				invalid: "Такой комбинации нет.",
				incomplete: "Выберите все опции.",
			};
		case "ja":
			return {
				valid: "選択は有効です。",
				invalid: "この組み合わせは存在しません。",
				incomplete: "すべての項目を選択してください。",
			};
		case "pa":
			return {
				valid: "ਚੋਣ ਠੀਕ ਹੈ।",
				invalid: "ਇਹ ਸੰਯੋਜਨ ਉਪਲਬਧ ਨਹੀਂ ਹੈ।",
				incomplete: "ਕਿਰਪਾ ਕਰਕੇ ਸਾਰੇ ਵਿਕਲਪ ਚੁਣੋ।",
			};
		case "mr":
			return {
				valid: "निवड वैध आहे.",
				invalid: "हा संयोजन उपलब्ध नाही.",
				incomplete: "कृपया सर्व पर्याय निवडा.",
			};
		case "fr":
			return {
				valid: "Selection valide.",
				invalid: "Cette combinaison n'existe pas.",
				incomplete: "Veuillez choisir toutes les options.",
			};
		case "it":
			return {
				valid: "Selezione valida.",
				invalid: "Questa combinazione non esiste.",
				incomplete: "Seleziona tutte le opzioni.",
			};
		case "nl":
			return {
				valid: "Selectie is geldig.",
				invalid: "Deze combinatie bestaat niet.",
				incomplete: "Selecteer alle opties.",
			};
		case "sv":
			return {
				valid: "Valet ar giltigt.",
				invalid: "Denna kombination finns inte.",
				incomplete: "Valj alla alternativ.",
			};
		case "pl":
			return {
				valid: "Wybor jest poprawny.",
				invalid: "Ta kombinacja nie istnieje.",
				incomplete: "Wybierz wszystkie opcje.",
			};
		case "da":
			return {
				valid: "Valget er gyldigt.",
				invalid: "Denne kombination findes ikke.",
				incomplete: "Vaelg alle muligheder.",
			};
		case "fi":
			return {
				valid: "Valinta on kelvollinen.",
				invalid: "Tallaista yhdistelmaa ei ole.",
				incomplete: "Valitse kaikki vaihtoehdot.",
			};
		case "no":
			return {
				valid: "Valget er gyldig.",
				invalid: "Denne kombinasjonen finnes ikke.",
				incomplete: "Velg alle alternativer.",
			};
		case "cs":
			return {
				valid: "Vyber je platny.",
				invalid: "Tato kombinace neexistuje.",
				incomplete: "Vyberte vsechny moznosti.",
			};
		default:
			return {
				valid: "Selection is valid.",
				invalid: "This combination does not exist.",
				incomplete: "Please select all options.",
			};
	}
}
