/**
 * @license Copyright (c) 2017-2025, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md.
 */

import { BaseComponent } from './base-component';
import { createOutsideClickWatcher } from '../helpers/create-outside-click-watcher';
import { createHoverManager } from '../helpers/create-hover-manager';
import { isMobile } from '../helpers/is-mobile';
import { html } from '../helpers/html-builder';
import { cx } from '../helpers/classnames';
import { randomId } from '../helpers/random-id';
import { createAnchorPositioningWatcher } from '../helpers/create-anchor-positioning-watcher';
import { createMutationObserver } from '../helpers/create-mutation-observer';
import { createCleanupRegistry } from '../helpers/create-cleanup-registry';
import { isElementAttached } from '../helpers/is-element-attached';
import { afterDomReady } from '../helpers/after-dom-ready';

/**
 * Tooltip component.
 * Shows tooltips for elements with aria-describedby attribute that matches tooltip ID.
 */
export class TooltipPopover extends BaseComponent {
	/**
	 * Creates a tooltip component.
	 *
	 * @param element - The tooltip DOM element.
	 * @param config - Configuration options for the tooltip.
	 */
	constructor( element, config = {} ) {
		super( {
			isVisible: false
		} );

		const trigger = config.trigger || document.querySelector(
			[
				`[aria-describedby="${ element.id }"]`,
				`[aria-labelledby="${ element.id }"]`,
				`[data-tooltip-id="${ element.id }"]`
			].join( ', ' )
		);

		this.elements = {
			trigger,
			tooltip: element,
			closeButton: element.querySelector( '.js-tooltip-close-button' ),
			arrow: element.querySelector( '.c-tooltip-popover__arrow' )
		};

		this.config = {
			mode: config.mode || element.getAttribute( 'data-mode' ) || 'hover',
			position: config.position || element.getAttribute( 'data-position' ) || 'bottom',
			disableOnMobile: config.disableOnMobile || element.getAttribute( 'data-disable-on-mobile' ) === 'true',
			hideOnOutsideClick: config.hideOnOutsideClick ?? true,
			destroyOnHide: config.destroyOnHide ?? element.getAttribute( 'data-destroy-on-hide' ) === 'true',
			debounceDelay:
				config.debounceDelay ||
				( element.hasAttribute( 'data-debounce-delay' ) && Number.parseInt( element.getAttribute( 'data-debounce-delay' ), 10 ) ) ||
				null
		};

		// Create cleanup registries
		this.visibleStateCleanup = createCleanupRegistry();
		this.triggerCleanup = createCleanupRegistry();
	}

	/**
	 * Attaches tooltip functionality to all elements with js-tooltip class.
	 */
	static attach() {
		const tooltips = document.querySelectorAll( '.js-tooltip-popover' );

		for ( const tooltip of tooltips ) {
			new TooltipPopover( tooltip ).init();
		}
	}

	/**
	 * Initializes the tooltip component.
	 */
	init() {
		const { tooltip, trigger, closeButton } = this.elements;
		const { mode, disableOnMobile, hideOnOutsideClick, debounceDelay } = this.config;

		if ( disableOnMobile && isMobile() ) {
			return;
		}

		if ( !trigger ) {
			console.warn( 'Tooltip trigger not found. Tooltip will not be initialized.' );
			return;
		}

		// Add close button event listener if close button exists
		if ( closeButton ) {
			this.triggerCleanup.addEventListener( closeButton, 'click', event => {
				event.stopPropagation();
				event.preventDefault();
				this.hide();
			} );
		}

		switch ( mode ) {
			case 'click':
				this.triggerCleanup.addEventListener( trigger, 'click', () => {
					if ( hideOnOutsideClick ) {
						const cleanup = createOutsideClickWatcher( {
							elements: [ trigger, tooltip ],
							callback: () => {
								if ( this.state.isVisible ) {
									this.hide();
								}
							}
						} );

						this.visibleStateCleanup.register( cleanup );
					}

					this.toggle();
				} );

				// Asynchronously check if the trigger or its parents are detached.
				// If so, and the tooltip is visible, hide it.
				// Sometimes, framework might remove the trigger element after the click event.
				// It handles such cases gracefully.
				this.triggerCleanup.addEventListener( window, 'mousedown', () => {
					setTimeout( () => {
						isElementAttached( trigger ).then( isDetached => {
							if ( isDetached && this.state.isVisible ) {
								this.hide();
							}
						} );
					}, 300 );
				} );
				break;

			case 'hover': {
				const manager = createHoverManager( {
					debounceDelay,
					elements: [ trigger, tooltip ],
					onEnter: element => {
						if ( element === trigger ) {
							this.show();
						}
					},
					onLeave: () => {
						this.hide();
					}
				} );

				this.triggerCleanup.register( manager );
			} break;

			case 'focus':
				this.triggerCleanup.addEventListeners( trigger, {
					focus: this.show.bind( this ),
					blur: this.hide.bind( this )
				} );
				break;

			case 'manual':
				/* NOP */
				break;
		}

		this.render();
		return this;
	}

	/**
	 * Renders the tooltip component based on current state.
	 *
	 * @param prevState - Previous component state
	 * @param state - Current component state
	 */
	render( prevState, state = this.state ) {
		const { destroyOnHide } = this.config;

		this._updateTooltipClasses();

		if ( !!prevState?.isVisible === !!state.isVisible ) {
			return;
		}

		if ( state.isVisible ) {
			this._showTooltip();
		} else if ( !state.isVisible ) {
			if ( destroyOnHide ) {
				this.destroy();
			} else {
				this._hideTooltip();
			}
		}
	}

	/**
	 * Destroys the tooltip component, cleaning up all resources.
	 * Hides the tooltip, removes event listeners, and removes the tooltip from the DOM.
	 */
	destroy() {
		const { tooltip, trigger } = this.elements;

		// Hide tooltip if it's visible
		this._hideTooltip();

		// Clean up all callbacks and watchers
		this.visibleStateCleanup.destroy();
		this.triggerCleanup.destroy();

		// Clean up trigger element
		if ( trigger ) {
			trigger.classList.remove( 'has-tooltip-visible' );

			// Remove aria attributes we might have added
			if ( trigger.getAttribute( 'aria-describedby' ) === tooltip.id ) {
				trigger.removeAttribute( 'aria-describedby' );
			}

			if ( trigger.getAttribute( 'aria-labelledby' ) === tooltip.id ) {
				trigger.removeAttribute( 'aria-labelledby' );
			}
		}
	}

	/**
	 * Shows the tooltip.
	 */
	show() {
		this.setState( {
			isVisible: true
		} );
	}

	/**
	 * Hides the tooltip.
	 */
	hide() {
		this.setState( {
			isVisible: false
		} );
	}

	/**
	 * Toggles the tooltip visibility.
	 */
	toggle() {
		if ( this.state.isVisible ) {
			this.hide();
		} else {
			this.show();
		}
	}

	/**
	 * Updates tooltip classes and accessibility attributes.
	 *
	 * @param state - Current component state
	 */
	_updateTooltipClasses() {
		const { tooltip } = this.elements;
		const { position } = this.config;

		// Remove any existing position classes
		tooltip.classList.remove(
			'c-tooltip-popover--top',
			'c-tooltip-popover--top-start',
			'c-tooltip-popover--bottom',
			'c-tooltip-popover--bottom-start',
			'c-tooltip-popover--top-end',
			'c-tooltip-popover--bottom-end'
		);

		tooltip.classList.add( `c-tooltip-popover--${ position }` );
	}

	/**
	 * Handles displaying the tooltip.
	 */
	_showTooltip() {
		const { tooltip, trigger } = this.elements;

		tooltip.classList.add( 'is-mounted' );
		document.body.appendChild( tooltip );

		this._positionTooltip();

		// Setup scroll listeners to reposition tooltip on scroll
		this._setupPositioningListeners();

		// Add class to trigger element when tooltip is visible
		trigger.classList.add( 'has-tooltip-visible' );

		// Recalculate UI to enforce transition
		// eslint-disable-next-line no-void
		void tooltip.offsetWidth;
		tooltip.classList.add( 'is-visible' );

		// Setup mutation observer to detect content changes
		this.visibleStateCleanup.register( this._setupMutationObserver() );
	}

	/**
	 * Hides the tooltip.
	 */
	_hideTooltip() {
		const { tooltip, trigger } = this.elements;

		if ( !tooltip.classList.contains( 'is-visible' ) ) {
			return;
		}

		// Start hiding animation first
		tooltip.classList.remove( 'is-visible' );

		// Remove class from trigger element when tooltip is hidden
		trigger.classList.remove( 'has-tooltip-visible' );

		// After animation completes, reset styles.
		tooltip.addEventListener( 'transitionend', () => {
			if ( !this.state.isVisible && tooltip.parentNode === document.body ) {
				tooltip.classList.remove( 'is-mounted' );
				tooltip.style.left = '';
				tooltip.style.top = '';
			}
		}, { once: true } );

		this.visibleStateCleanup.destroy();
	}

	/**
	 * Positions the tooltip relative to the trigger element.
	 */
	_positionTooltip() {
		const { tooltip, trigger, arrow } = this.elements;
		const { position } = this.config;

		const triggerRect = trigger.getBoundingClientRect();
		const tooltipRect = tooltip.getBoundingClientRect();

		const arrowHeight = arrow.offsetHeight;
		const arrowWidth = arrow.offsetWidth;

		// Calculate the position
		let top, left;

		// Calculate trigger center position
		const triggerCenterX = triggerRect.left + ( triggerRect.width / 2 );
		const triggerEndX = triggerRect.right;

		// Determine horizontal position
		if ( position.endsWith( '-start' ) ) {
			// When using start alignment, calculate how far the arrow should be from the left edge
			// to align with the center of the trigger
			const arrowLeftOffset = triggerCenterX - triggerRect.left - Math.ceil( arrowWidth / 2 );
			left = triggerRect.left;

			// Position the arrow to align with trigger center
			arrow.style.left = `${ arrowLeftOffset }px`;
			arrow.style.right = 'auto';
		} else if ( position.endsWith( '-end' ) ) {
			// When using end alignment, calculate how far the arrow should be from the right edge
			// to align with the center of the trigger
			const arrowRightOffset = triggerEndX - triggerCenterX - Math.ceil( arrowWidth / 2 );
			left = triggerEndX - tooltipRect.width;

			// Position the arrow to align with trigger center
			arrow.style.right = `${ arrowRightOffset }px`;
			arrow.style.left = 'auto';
		} else {
			// Center horizontally (default behavior)
			left = triggerCenterX - ( tooltipRect.width / 2 );

			// Reset arrow position for centered tooltip
			arrow.style.left = '';
			arrow.style.right = '';
		}

		// Position vertically based on chosen position
		if ( position.startsWith( 'top' ) ) {
			top = triggerRect.top - tooltipRect.height - arrowHeight;
		} else {
			// Default to bottom
			top = triggerRect.bottom + arrowHeight;
		}

		// Adjust for scroll position
		top += window.scrollY;
		left += window.scrollX;

		// Prevent tooltip from going off-screen
		if ( left < 0 ) {
			const offset = Math.abs( left );

			left = 0;

			if ( !position.endsWith( '-start' ) && !position.endsWith( '-end' ) ) {
				arrow.style.left = `calc(50% - ${ offset }px)`;
			} else if ( position.endsWith( '-end' ) ) {
				const currentRight = parseFloat( arrow.style.right || 0 );
				arrow.style.right = `${ currentRight + offset }px`;
			}
		} else if ( left + tooltipRect.width > window.innerWidth ) {
			const origLeft = left;
			const overflowAmount = ( left + tooltipRect.width ) - window.innerWidth;

			left = window.innerWidth - tooltipRect.width;

			if ( !position.endsWith( '-start' ) && !position.endsWith( '-end' ) ) {
				const offset = origLeft - left;
				arrow.style.left = `calc(50% + ${ offset }px)`;
			} else if ( position.endsWith( '-start' ) ) {
				const currentLeft = parseFloat( arrow.style.left || 0 );
				arrow.style.left = `${ currentLeft - overflowAmount }px`;
			}
		}

		// Apply position
		tooltip.style.top = `${ top }px`;
		tooltip.style.left = `${ left }px`;
	}

	/**
	 * Sets up mutation observer to detect content changes in the tooltip.
	 * When content changes, repositions the tooltip to accommodate new content size.
	 */
	_setupMutationObserver() {
		const { tooltip } = this.elements;

		return createMutationObserver( {
			element: tooltip,
			attributeFilter: [],
			callback: () => {
				if ( this.state.isVisible ) {
					this._positionTooltip();
				}
			}
		} );
	}

	/**
	 * Sets up event listeners for scroll and resize events
	 * to reposition the tooltip when changes in layout occur.
	 */
	_setupPositioningListeners() {
		const positioningCleanup = createAnchorPositioningWatcher( {
			element: this.elements.trigger,
			handler: this._handlePositioningEvent.bind( this )
		} );

		this.visibleStateCleanup.register( positioningCleanup );
	}

	/**
	 * Handles repositioning the tooltip when a positioning event occurs (scroll or resize).
	 */
	_handlePositioningEvent() {
		if ( this.state.isVisible ) {
			this._positionTooltip();
		}
	}
}

/**
 * Creates a tooltip popover for the given trigger element.
 *
 * @param config - Configuration for the tooltip
 * @param config.trigger - The element that will trigger the tooltip
 * @param config.text - The text content of the tooltip
 * @param [config.id] - The ID for the tooltip (auto-generated if not provided)
 * @param [config.mode='hover'] - The trigger mode: 'hover', 'click', or 'focus'
 * @param [config.disableOnMobile=true] - Whether to disable the tooltip on mobile devices
 * @param [config.position='bottom'] - The position of the tooltip: 'top' or 'bottom'
 * @param [config.className] - Additional CSS class(es) for the tooltip
 * @param [config.showAfterMount=false] - Whether to show the tooltip right after mounting
 * @param [config.hideOnOutsideClick=true] - Whether to hide the tooltip when clicking outside
 * @param [config.showCloseButton=false] - Whether to show a close button in the tooltip
 * @param [config.icon] - Optional icon to display on the left side of the tooltip text
 * @returns The created tooltip popover instance
 */
export function createTooltipPopover( config = {} ) {
	const {
		trigger,
		text,
		id = randomId( 'tooltip' ),
		mode = 'hover',
		disableOnMobile = true,
		position = 'bottom',
		variant = '',
		className = '',
		showAfterMount = false,
		showCloseButton,
		icon,
		debounce = false,
		debounceDelay = 300,
		...rest
	} = config;

	// Create the tooltip container
	const tooltip = html.div(
		{
			id,
			class: cx(
				'c-tooltip-popover', 'js-tooltip-popover',
				className,
				{
					[ `c-tooltip-popover--${ variant }` ]: !!variant,
					'c-tooltip-popover--closable': showCloseButton,
					'c-tooltip-popover--with-icon': !!icon
				}
			),
			role: 'tooltip',
			'data-position': position,
			'data-mode': mode,
			'data-disable-on-mobile': disableOnMobile ? 'true' : 'false',
			'data-debounce': debounce ? 'true' : 'false',
			'data-debounce-delay': debounceDelay.toString()
		},

		icon && html[ 'svg-icon' ](
			{
				icon,
				class: 'c-tooltip-popover__icon'
			}
		),

		html.span( { class: 'c-tooltip-popover__text' }, text ),
		html.span( { class: 'c-tooltip-popover__arrow' } ),

		showCloseButton && html.span(
			{
				class: 'c-tooltip-popover__close-button js-tooltip-close-button',
				role: 'button',
				'aria-label': 'Close'
			},
			html[ 'svg-icon' ](
				{
					icon: 'close',
					class: 'c-tooltip-popover__close-icon'
				}
			)
		)
	);

	// Set trigger with aria attribute
	if ( !trigger.hasAttribute( 'aria-labelledby' ) ) {
		trigger.setAttribute( 'aria-labelledby', id );
	} else if ( !trigger.hasAttribute( 'aria-describedby' ) ) {
		trigger.setAttribute( 'aria-describedby', id );
	}

	// Initialize tooltip
	const popover = new TooltipPopover( tooltip, {
		trigger,
		mode,
		position,
		disableOnMobile,
		showCloseButton,
		debounce,
		debounceDelay,
		...rest
	} );

	afterDomReady( () => {
		// Add to document
		document.body.appendChild( tooltip );

		popover.init();

		// Show tooltip right after initialization if requested
		if ( showAfterMount ) {
			popover.show();
		}
	} );

	return popover;
}
