/**
 * 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.
 */

import { instanceSymbol } from "../../constants.mjs";
import { ATTRIBUTE_PREFIX, ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import { CustomElement, getSlottedElements } from "../../dom/customelement.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { SliderStyleSheet } from "./stylesheet/slider.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";

import { getWindow } from "../../dom/util.mjs";
import { isObject, isInteger } from "../../types/is.mjs";

export { Slider };

/**
 * @private
 * @type {symbol}
 * @description Reference to the main slider <ul> container element.
 */
const sliderElementSymbol = Symbol("sliderElement");

/**
 * @private
 * @type {symbol}
 * @description Reference to the main control <div> wrapper.
 */
const controlElementSymbol = Symbol("controlElement");

/**
 * @private
 * @type {symbol}
 * @description Reference to the "previous" button element.
 */
const prevElementSymbol = Symbol("prevElement");

/**
 * @private
 * @type {symbol}
 * @description Reference to the "next" button element.
 */
const nextElementSymbol = Symbol("nextElement");

/**
 * @private
 * @type {symbol}
 * @description Reference to the thumbnails container element.
 */
const thumbnailElementSymbol = Symbol("thumbnailElement");

/**
 * @private
 * @type {symbol}
 * @description Stores internal state, configuration, and event handlers.
 */
const configSymbol = Symbol("config");

/**
 * A slider/carousel custom element.
 *
 * @fragments /fragments/components/layout/slider/
 *
 * @example /examples/components/layout/slider-simple
 * @example /examples/components/layout/slider-carousel
 * @example /examples/components/layout/slider-multiple
 *
 * @since 3.74.0
 * @copyright Volker Schukai
 * @summary A responsive slider or carousel with touch support, autoplay, thumbnails and looping behavior.
 * @fires monster-slider-resized - Fired when the slider's dimensions change.
 * @fires monster-slider-moved - Fired when the slider moves to a new slide.
 */
class Slider extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/components/layout/slider@@instance");
	}

	/**
	 *
	 * @return {Components.Layout.Slider
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();

		this[configSymbol] = {
			currentIndex: 0,

			isDragging: false,
			draggingPos: 0,
			startPos: 0,
			autoPlayInterval: null,
			resizeObserver: null, // Store the observer for later cleanup
			resizeFrame: null,
			pendingResizeSize: null,
			lastResizeSize: null,
			visibleSlides: null,
			dragFrame: null,
			pendingDragEvent: null,

			eventHandler: {
				mouseOverPause: null,
				mouseout: null,
				touchstart: null,
				touchend: null,
				thumbnailMoved: null,
			},
		};

		initControlReferences.call(this);

		// Set the CSS custom property for slide width based on visible slides.
		const slidesVisible = getVisibleSlidesFromContainerWidth.call(this);
		setVisibleSlidesWidth.call(this, slidesVisible);

		initEventHandler.call(this);
		initStructure.call(this);

		return this;
	}

	/**
	 * Called when the element is removed from the DOM.
	 * Cleans up intervals, observers, and event listeners to prevent memory leaks.
	 */
	disconnectedCallback() {
		// Check if super.disconnectedCallback exists and call it
		if (super.disconnectedCallback) {
			super.disconnectedCallback();
		}

		this.stopAutoPlay(); // Clear interval

		// Disconnect the ResizeObserver
		if (this[configSymbol]?.resizeObserver) {
			this[configSymbol].resizeObserver.disconnect();
			this[configSymbol].resizeObserver = null;
		}

		if (this[configSymbol]?.resizeFrame) {
			getWindow().cancelAnimationFrame(this[configSymbol].resizeFrame);
			this[configSymbol].resizeFrame = null;
		}

		if (this[configSymbol]?.dragFrame) {
			getWindow().cancelAnimationFrame(this[configSymbol].dragFrame);
			this[configSymbol].dragFrame = null;
		}

		// Remove autoplay-related event listeners
		if (this[configSymbol]?.eventHandler) {
			const { mouseOverPause, mouseout, touchstart, touchend } =
				this[configSymbol].eventHandler;

			if (mouseOverPause) {
				this.removeEventListener("mouseover", mouseOverPause);
				this[configSymbol].eventHandler.mouseOverPause = null;
			}
			if (mouseout) {
				this.removeEventListener("mouseout", mouseout);
				this[configSymbol].eventHandler.mouseout = null;
			}
			if (touchstart) {
				this.removeEventListener("touchstart", touchstart);
				this[configSymbol].eventHandler.touchstart = null;
			}
			if (touchend) {
				this.removeEventListener("touchend", touchend);
				this[configSymbol].eventHandler.touchend = null;
			}
			if (this[configSymbol].eventHandler.thumbnailMoved) {
				this.removeEventListener(
					"monster-slider-moved",
					this[configSymbol].eventHandler.thumbnailMoved,
				);
				this[configSymbol].eventHandler.thumbnailMoved = null;
			}
		}
	}

	/**
	 * 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 Features
	 * @property {boolean} features.carousel Carousel feature (infinite looping)
	 * @property {boolean} features.autoPlay Auto play feature
	 * @property {boolean} features.thumbnails Thumbnails feature
	 * @property {boolean} features.drag Drag feature (touch and mouse)
	 * @property {Object} slides Slides configuration, an object with breakpoints and the number of slides to show
	 * @property {Object} slides.0 Number of slides to show at 0px
	 * @property {Object} slides.600 Number of slides to show at 600px @since 3.109.0
	 * @property {Object} slides.1200 Number of slides to show at 1200px @since 3.109.0
	 * @property {Object} slides.1800 Number of slides to show at 1800px @since 3.109.0
	 * @property {Object} carousel Carousel configuration
	 * @property {number} carousel.transition Duration (ms) of the carousel 'jump' animation when looping.
	 * @property {Object} autoPlay Auto play configuration
	 * @property {number} autoPlay.delay Delay in ms between slide transitions
	 * @property {number} autoPlay.startDelay Delay in ms before autoplay starts
	 * @property {string} autoPlay.direction Direction of the autoplay ("next" or "prev")
	 * @property {boolean} autoPlay.mouseOverPause Pause on mouse over
	 * @property {boolean} autoPlay.touchPause Pause on touch
	 * @property {Object} classes CSS classes
	 * @property {boolean} disabled Disabled state
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},

			classes: {},
			disabled: false,

			features: {
				carousel: true,
				autoPlay: true,
				thumbnails: true,
				drag: true,
			},

			slides: {
				0: 1,
				600: 2,
				1200: 3,
				1800: 4,
			},

			carousel: {
				transition: 250,
			},

			autoPlay: {
				delay: 1500,
				startDelay: 1000,
				direction: "next",
				mouseOverPause: true,
				touchPause: true,
			},
		});
	}

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

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

	/**
	 * Moves the slider to the given index.
	 *
	 * @param {number} index - The slide index to move to.
	 * @return {void}
	 */
	moveTo(index) {
		return moveTo.call(this, index);
	}

	/**
	 * Shows the previous slide.
	 *
	 * @return {void}
	 */
	previous() {
		return prev.call(this);
	}

	/**
	 * Shows the next slide.
	 *
	 * @return {void}
	 */
	next() {
		return next.call(this);
	}

	/**
	 * Stops the auto play.
	 *
	 * @return {void}
	 */
	stopAutoPlay() {
		if (this[configSymbol].autoPlayInterval) {
			clearInterval(this[configSymbol].autoPlayInterval);
			this[configSymbol].autoPlayInterval = null;
		}
	}

	/**
	 * Starts the auto play.
	 *
	 * @return {void}
	 */
	startAutoPlay() {
		initAutoPlay.call(this);
	}
}

/**
 * @private
 * @description Initializes the component structure, thumbnails, and autoplay.
 */
function initStructure() {
	if (this.getOption("features.thumbnails")) {
		initThumbnails.call(this);
	}

	// Clones slides if carousel mode is active
	initCarouselClones.call(this);

	if (this.getOption("features.autoPlay")) {
		initAutoPlay.call(this);
	}
}

/**
 * @private
 * @description Generates the thumbnail navigation elements.
 */
function initThumbnails() {
	const thumbnails = this.shadowRoot.querySelector(
		"[data-monster-role='thumbnails']",
	);

	// Clear existing thumbnails before regenerating
	while (thumbnails.firstChild) {
		thumbnails.removeChild(thumbnails.firstChild);
	}

	const { originSlides } = getSlidesAndTotal.call(this);

	originSlides.forEach((x, index) => {
		const thumbnail = document.createElement("div");
		thumbnail.classList.add("thumbnail");
		thumbnail.addEventListener("click", () => {
			this.moveTo(index);
		});

		thumbnails.appendChild(thumbnail);
	});

	// Listen for move events to update the active thumbnail
	if (this[configSymbol].eventHandler.thumbnailMoved === null) {
		this[configSymbol].eventHandler.thumbnailMoved = (e) => {
			const index = e.detail.index;
			const thumbnail = thumbnails.children[index];

			if (!thumbnail) {
				return;
			}

			Array.from(thumbnails.children).forEach((thumb) => {
				thumb.classList.remove("current");
			});

			thumbnail.classList.add("current");
		};
		this.addEventListener(
			"monster-slider-moved",
			this[configSymbol].eventHandler.thumbnailMoved,
		);
	}
}

/**
 * @private
 * @description Initializes the autoplay functionality and its event handlers.
 */
function initAutoPlay() {
	const self = this;

	if (this.getOption("features.autoPlay") === false) {
		return;
	}

	const autoPlay = this.getOption("autoPlay");
	if (!isObject(autoPlay)) {
		return;
	}
	const delay = autoPlay.delay;
	const startDelay = autoPlay.startDelay;
	const direction = autoPlay.direction;

	function start() {
		// Clear any existing interval before starting a new one
		if (self[configSymbol].autoPlayInterval) {
			clearInterval(self[configSymbol].autoPlayInterval);
		}

		self[configSymbol].autoPlayInterval = setInterval(() => {
			const { totalOriginSlides } = getSlidesAndTotal.call(self);

			if (direction === "next") {
				// Check if carousel looping is disabled and we're at the end
				if (
					!self.getOption("features.carousel") &&
					self[configSymbol].currentIndex >= totalOriginSlides - 1
				) {
					self[configSymbol].currentIndex = -1;
				}
				self.next();
			} else {
				// Check if carousel looping is disabled and we're at the beginning
				if (
					!self.getOption("features.carousel") &&
					self[configSymbol].currentIndex <= 0
				) {
					self[configSymbol].currentIndex = totalOriginSlides;
				}
				self.previous();
			}
		}, delay);
	}

	setTimeout(() => {
		start();
	}, startDelay);

	// Add listeners for pause-on-hover
	if (autoPlay.mouseOverPause) {
		if (this[configSymbol].eventHandler.mouseOverPause === null) {
			this[configSymbol].eventHandler.mouseOverPause = () => {
				clearInterval(this[configSymbol].autoPlayInterval);
			};

			this.addEventListener(
				"mouseover",
				this[configSymbol].eventHandler.mouseOverPause,
			);
		}

		if (this[configSymbol].eventHandler.mouseout === null) {
			this[configSymbol].eventHandler.mouseout = () => {
				if (this[configSymbol].isDragging) {
					return;
				}
				start();
			};

			this.addEventListener(
				"mouseout",
				this[configSymbol].eventHandler.mouseout,
			);
		}
	}

	// Add listeners for pause-on-touch
	if (autoPlay.touchPause) {
		if (this[configSymbol].eventHandler.touchstart === null) {
			this[configSymbol].eventHandler.touchstart = () => {
				clearInterval(this[configSymbol].autoPlayInterval);
			};

			this.addEventListener(
				"touchstart",
				this[configSymbol].eventHandler.touchstart,
			);
		}

		if (this[configSymbol].eventHandler.touchend === null) {
			this[configSymbol].eventHandler.touchend = () => {
				if (this[configSymbol].isDragging) {
					return;
				}
				start();
			};

			this.addEventListener(
				"touchend",
				this[configSymbol].eventHandler.touchend,
			);
		}
	}
}

/**
 * @private
 * @description Calculates the number of slides that should be visible based on breakpoints.
 * @return {number}
 */
function getVisibleSlidesFromContainerWidth() {
	const containerWidth = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="slider"]`,
	).offsetWidth;
	const slides = this.getOption("slides");
	let visibleSlides = 1;

	if (!isObject(slides)) {
		return visibleSlides;
	}

	// Find the largest breakpoint that is smaller than the current container width
	for (const key in slides) {
		if (containerWidth >= key) {
			visibleSlides = slides[key];
		}
	}

	const { originSlides } = getSlidesAndTotal.call(this);
	// Ensure we don't try to show more slides than are available
	if (visibleSlides > originSlides.length) {
		visibleSlides = originSlides.length; // Fixed: was originSlides.length - 1
	}

	return visibleSlides;
}

/**
 * @private
 * @param {number} slidesVisible
 * @return {boolean}
 */
function setVisibleSlidesWidth(slidesVisible) {
	if (this[configSymbol].visibleSlides === slidesVisible) {
		return false;
	}
	this[configSymbol].visibleSlides = slidesVisible;
	this[sliderElementSymbol].style.setProperty(
		"--monster-slides-width",
		`${100 / slidesVisible}%`,
	);
	return true;
}

/**
 * @private
 * @description Clones slides to create the "infinite" loop effect for the carousel.
 */
function initCarouselClones() {
	const { slides, totalSlides } = getSlidesAndTotal.call(this);
	const slidesVisible = getVisibleSlidesFromContainerWidth.call(this);

	// Only clone if there are more slides than are visible
	if (totalSlides > slidesVisible) {
		// Clone slides from the beginning and append them to the end
		let current = slides[0];
		let last = slides[totalSlides - 1];
		for (let i = 0; i < slidesVisible; i++) {
			const clone = current.cloneNode(true);
			clone.setAttribute("data-monster-clone-from", i);
			last.insertAdjacentElement("afterend", clone);
			current = current.nextElementSibling;
			last = clone;
		}

		// Clone slides from the end and prepend them to the beginning
		current = slides[totalSlides - 1];
		let first = slides[0];
		for (let i = 0; i < slidesVisible; i++) {
			const clone = current.cloneNode(true);
			// Fixed: Index was totalSlides - i, should be totalSlides - 1 - i
			clone.setAttribute("data-monster-clone-from", totalSlides - 1 - i);
			first.insertAdjacentElement("beforebegin", clone);
			current = current.previousElementSibling;
			first = clone;
		}

		moveTo.call(this, 0);
	}
}

/**
 * @private
 * @description Gets all slides, original slides, and their counts.
 * @return {{slides: HTMLElement[], totalSlides: number, originSlides: HTMLElement[], totalOriginSlides: number}}
 */
function getSlidesAndTotal() {
	// Get only original slides (excluding clones)
	const originSlides = Array.from(
		getSlottedElements.call(
			this,
			":scope:not([data-monster-clone-from])",
			null,
		),
	);
	const totalOriginSlides = originSlides.length;

	// Get all slides (including clones)
	const slides = Array.from(getSlottedElements.call(this, ":scope", null));
	const totalSlides = slides.length;

	return { originSlides, totalOriginSlides, slides, totalSlides };
}

/**
 * @private
 * @description Moves to the next slide.
 * @return {number}
 */
function next() {
	const nextIndex = this[configSymbol].currentIndex + 1;

	// Use requestAnimationFrame to ensure the move happens in the next frame,
	// allowing CSS transitions to apply correctly.
	getWindow().requestAnimationFrame(() => {
		moveTo.call(this, nextIndex);
	});

	return 0;
}

/**
 * @private
 * @description Moves to the previous slide.
 * @return {number}
 */
function prev() {
	const prevIndex = this[configSymbol].currentIndex - 1;

	// Use requestAnimationFrame for smooth transitions
	getWindow().requestAnimationFrame(() => {
		moveTo.call(this, prevIndex);
	});

	return 0;
}

/**
 * @private
 * @description Sets the CSS transform and 'current' class for the slide container.
 * @param {HTMLElement[]} slides - Array of all slide elements.
 * @param {number} index - The target slide index.
 */
function setMoveProperties(slides, index) {
	// Remove 'current' class from all slides
	slides.forEach((slide) => {
		slide.classList.remove("current");
	});

	let offset = -(index * 100);
	const slidesVisible = getVisibleSlidesFromContainerWidth.call(this);

	offset = offset / slidesVisible;

	if (offset !== 0) {
		offset += "%";
	}

	this[sliderElementSymbol].style.transform =
		`translateX(calc(${offset} + ${this[configSymbol].draggingPos}px))`;

	if (slides[index]) {
		slides[index].classList.add("current");
	}
	this[configSymbol].lastOffset = offset;
}

/**
 * @private
 * @description The core logic for moving the slider to a specific index.
 * @param {number} index - The target index (relative to original slides).
 * @param {boolean} [animation=true] - Whether to use CSS transitions for this move.
 * @fires monster-slider-moved
 */
function moveTo(index, animation) {
	const { slides, totalSlides, originSlides, totalOriginSlides } =
		getSlidesAndTotal.call(this);

	// Remove/add 'animate' class to enable/disable CSS transitions
	if (animation === false) {
		this[sliderElementSymbol].classList.remove("animate");
	} else {
		this[sliderElementSymbol].classList.add("animate");
	}

	// Handle carousel looping logic
	if (this.getOption("features.carousel") === true) {
		if (index < 0) {
			index = -1; // Will trigger the "jump" to the end
		}

		if (index > totalOriginSlides) {
			index = totalOriginSlides; // Will trigger the "jump" to the start
		}
	} else {
		// Handle non-carousel boundary logic
		if (index < 0) {
			index = 0;
		}

		if (index >= totalOriginSlides) {
			index = totalOriginSlides - 1;
		}
	}

	if (!isInteger(index)) {
		return;
	}

	const visibleSlides = getVisibleSlidesFromContainerWidth.call(this);

	// Hide controls if all original slides are visible
	if (totalOriginSlides <= visibleSlides) {
		this[prevElementSymbol].classList.add("hidden");
		this[nextElementSymbol].classList.add("hidden");
		this[thumbnailElementSymbol].classList.add("hidden");
		return;
	}
	this[prevElementSymbol].classList.remove("hidden");
	this[nextElementSymbol].classList.remove("hidden");
	this[thumbnailElementSymbol].classList.remove("hidden");

	// Calculate the actual index in the 'slides' array (which includes clones)
	let slidesIndex = index + visibleSlides;
	this[configSymbol].currentIndex = index;

	if (slidesIndex < 0) {
		// We are at the "pre-cloned" slides, set index to the end
		slidesIndex = totalSlides - 1 - visibleSlides;
		this[configSymbol].currentIndex = totalOriginSlides - 1;
	} else if (index > totalOriginSlides) {
		// We are at the "post-cloned" slides, set index to the start
		slidesIndex = 0;
		this[configSymbol].currentIndex = 0;
	}

	setMoveProperties.call(this, slides, slidesIndex);

	// Handle the "jump" back to the start/end for seamless looping
	if (index === totalOriginSlides) {
		setTimeout(() => {
			getWindow().requestAnimationFrame(() => {
				moveTo.call(this, 0, false); // Jump to first slide without animation
			});
		}, this.getOption("carousel.transition"));
	} else if (index === -1) {
		setTimeout(() => {
			getWindow().requestAnimationFrame(() => {
				moveTo.call(this, totalOriginSlides - 1, false); // Jump to last slide without animation
			});
		}, this.getOption("carousel.transition"));
	}

	fireCustomEvent(this, "monster-slider-moved", {
		index: this[configSymbol].currentIndex, // Fire with the "real" index
	});
}

/**
 * @private
 * @description Initializes all event handlers for navigation, dragging, and resizing.
 * @return {initEventHandler}
 * @fires monster-slider-resized
 */
function initEventHandler() {
	const self = this;

	const nextElements = this[nextElementSymbol];
	if (nextElements) {
		nextElements.addEventListener("click", () => {
			self.next();
		});
	}

	const prevElements = this[prevElementSymbol];
	if (prevElements) {
		prevElements.addEventListener("click", () => {
			self.previous();
		});
	}

	// Initialize drag-to-move event listeners
	if (this.getOption("features.drag")) {
		this[sliderElementSymbol].addEventListener("mousedown", (e) =>
			startDragging.call(this, e, "mouse"),
		);

		this[sliderElementSymbol].addEventListener("touchstart", (e) =>
			startDragging.call(this, e, "touch"),
		);
	}

	this[configSymbol].lastResizeSize = {
		width: this[sliderElementSymbol]?.offsetWidth || 0,
		height: this[sliderElementSymbol]?.offsetHeight || 0,
	};

	// Observe slider size changes to update layout
	const resizeObserver = new ResizeObserver((entries) => {
		for (let entry of entries) {
			const { width, height } = entry.contentRect;
			scheduleResizeUpdate.call(this, width, height);
		}
	});

	resizeObserver.observe(this[sliderElementSymbol]);
	this[configSymbol].resizeObserver = resizeObserver; // Store for cleanup

	return this;
}

function scheduleResizeUpdate(width, height) {
	const lastSize = this[configSymbol].lastResizeSize;
	if (
		this[configSymbol].resizeFrame === null &&
		lastSize &&
		width === lastSize.width &&
		height === lastSize.height
	) {
		return;
	}
	this[configSymbol].pendingResizeSize = { width, height };
	if (this[configSymbol].resizeFrame !== null) {
		return;
	}
	this[configSymbol].resizeFrame = getWindow().requestAnimationFrame(() => {
		this[configSymbol].resizeFrame = null;
		flushResizeUpdate.call(this);
	});
}

function flushResizeUpdate() {
	const size = this[configSymbol].pendingResizeSize;
	this[configSymbol].pendingResizeSize = null;
	if (!size) {
		return;
	}

	const lastSize = this[configSymbol].lastResizeSize;
	if (
		lastSize &&
		size.width === lastSize.width &&
		size.height === lastSize.height
	) {
		return;
	}
	this[configSymbol].lastResizeSize = size;

	const previousVisibleSlides = this[configSymbol].visibleSlides;
	const slidesVisible = getVisibleSlidesFromContainerWidth.call(this);
	const visibleSlidesChanged = setVisibleSlidesWidth.call(this, slidesVisible);

	if (
		visibleSlidesChanged &&
		previousVisibleSlides !== null &&
		this.getOption("features.thumbnails")
	) {
		initThumbnails.call(this);
	}

	this.stopAutoPlay();
	moveTo.call(this, 0, false);
	this.startAutoPlay();

	fireCustomEvent(this, "monster-slider-resized", size);
}

/**
 * @private
 * @description Handles the "mousedown" or "touchstart" event to begin dragging.
 * @param {Event} e - The mousedown or touchstart event.
 * @param {string} type - The event type ("mouse" or "touch").
 */
function startDragging(e, type) {
	const { slides } = getSlidesAndTotal.call(this);

	// Get the width of a single slide for calculating drag distance
	const widthOfSlider = slides[this[configSymbol].currentIndex]?.offsetWidth;

	// Set dragging state and initial position
	this[configSymbol].isDragging = true;
	this[configSymbol].startPos = getPositionX(e, type);
	this[sliderElementSymbol].classList.add("grabbing");
	// Disable transitions during drag for smooth movement
	this[sliderElementSymbol].style.transitionProperty = "none";

	const callbackMousemove = (x) => {
		scheduleDragging.call(this, x, type);
	};

	const callbackMouseUp = () => {
		const endEvent = type === "mouse" ? "mouseup" : "touchend";
		const moveEvent = type === "mouse" ? "mousemove" : "touchmove";

		// Clean up global event listeners
		document.body.removeEventListener(endEvent, callbackMouseUp);
		document.body.removeEventListener(moveEvent, callbackMousemove);

		if (this[configSymbol].dragFrame !== null) {
			getWindow().cancelAnimationFrame(this[configSymbol].dragFrame);
			this[configSymbol].dragFrame = null;
		}
		flushDragging.call(this, type);

		this[configSymbol].isDragging = false;
		this[configSymbol].startPos = 0;
		this[sliderElementSymbol].classList.remove("grabbing");
		this[sliderElementSymbol].style.transitionProperty = ""; // Re-enable transitions

		const lastPos = this[configSymbol].draggingPos;
		this[configSymbol].draggingPos = 0;

		// Calculate how many slides were "swiped" and move to the new index
		let newIndex = this[configSymbol].currentIndex;
		const shift = lastPos / widthOfSlider;
		const shiftIndex = Math.round(shift);

		newIndex += shiftIndex * -1;
		this.moveTo(newIndex);
	};

	document.body.addEventListener("mouseup", callbackMouseUp);
	document.body.addEventListener("mousemove", callbackMousemove);
	document.body.addEventListener("touchend", callbackMouseUp);
	document.body.addEventListener("touchmove", callbackMousemove);
}

/**
 * @private
 * @description Get the X coordinate from a mouse or touch event.
 * @param {Event} e - The mouse or touch event.
 * @param {string} type - The event type ("mouse" or "touch").
 * @return {number} The clientX position.
 */
function getPositionX(e, type) {
	return type === "mouse" ? e.pageX : e.touches[0].clientX;
}

/**
 * @private
 * @description Called on mousemove/touchmove to update the slider's transform.
 * @param {Event} e - The mousemove or touchmove event.
 * @param {string} type - The event type ("mouse" or "touch").
 */
function dragging(e, type) {
	if (!this[configSymbol].isDragging) return;
	this[configSymbol].draggingPos =
		getPositionX(e, type) - this[configSymbol].startPos;

	// Update position based on drag delta
	this[sliderElementSymbol].style.transform =
		`translateX(calc(${this[configSymbol].lastOffset} + ${this[configSymbol].draggingPos}px))`;
}

function scheduleDragging(e, type) {
	this[configSymbol].pendingDragEvent = e;
	if (this[configSymbol].dragFrame !== null) {
		return;
	}
	this[configSymbol].dragFrame = getWindow().requestAnimationFrame(() => {
		this[configSymbol].dragFrame = null;
		flushDragging.call(this, type);
	});
}

function flushDragging(type) {
	const event = this[configSymbol].pendingDragEvent;
	this[configSymbol].pendingDragEvent = null;
	if (!event) {
		return;
	}
	dragging.call(this, event, type);
}

/**
 * @private
 * @description Caches references to key elements in the shadow DOM.
 * @return {void}
 */
function initControlReferences() {
	this[controlElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="control"]`,
	);

	this[sliderElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="slider"]`,
	);

	this[prevElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="prev"]`,
	);

	this[nextElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="next"]`,
	);

	this[thumbnailElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="thumbnails"]`,
	);
}

/**
 * @private
 * @description Returns the HTML template string for the component's shadow DOM.
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <div data-monster-role="control" part="control">
            <div class="prev hidden" data-monster-role="prev" part="prev">
                <slot name="prev"></slot>
            </div>
            <div data-monster-role="slider" part="slides">
                <slot></slot>
            </div>
            <div class="hidden" data-monster-role="thumbnails" part="thumbnails"></div>
            <div class="next hidden" data-monster-role="next" part="next">
                <slot name="next"></slot>
            </div>
        </div>`;
}

registerCustomElement(Slider);
