/**
 * @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 { animateElement } from '../helpers/animate-element';
import { bodyScroll } from '../helpers/body-scroll';
import { onEscapeKeyPress } from '../helpers/escape-handler';
import { createHeaderSpacerManager } from '../modules/header';

/**
 * Events used to broadcast information to the children components of the overlay.
 * It might be used to re-initialize navigation and reset other stuff within the overlay.
 */
const EVENTS = {
	OPENED: 'mobile-overlay:opened',
	CLOSED: 'mobile-overlay:closed',
	STATE_CHANGED: 'mobile-overlay:state-changed',
	OPENING: 'mobile-overlay:opening'
};

/**
 * A mobile overlay component that manages fullscreen overlays triggered by buttons.
 * Handles opening, closing, and provides animations for transitions.
 */
export class MobileOverlay extends BaseComponent {
	/**
	 * Creates a mobile overlay component.
	 *
	 * @param overlay - The overlay element.
	 */
	constructor( overlay ) {
		super( {
			isOpen: false,
			isAnimating: false,
			scrollPosition: 0
		} );

		const { id } = overlay;
		const triggers = [ ...document.querySelectorAll( `.js-toggle-mobile-overlay[aria-controls="${ id }"]` ) ];

		this.elements = {
			overlay,
			triggers
		};
	}

	/**
	 * Initializes all mobile overlay components on the page.
	 */
	static attach() {
		const overlays = document.querySelectorAll( '.js-mobile-overlay' );

		for ( const overlay of overlays ) {
			new MobileOverlay( overlay ).init();
		}
	}

	/**
	 * Determines if the overlay should be rendered in headless mode (without header).
	 *
	 * @returns True if the overlay should be rendered without header.
	 */
	get isHeadless() {
		return this.elements.overlay.getAttribute( 'data-headless' ) === 'true';
	}

	/**
	 * Determines if this overlay is the most recently opened one (last in the set).
	 *
	 * @returns True if this is the last opened overlay.
	 */
	get isLastOpened() {
		if ( MobileOverlay.openOverlays.size === 0 ) {
			return false;
		}

		return Array.from( MobileOverlay.openOverlays ).pop() === this;
	}

	/**
	 * Initializes the mobile overlay component with event listeners.
	 */
	init() {
		for ( const trigger of this.elements.triggers ) {
			trigger.addEventListener( 'click', this.onToggle.bind( this ) );
		}
	}

	/**
	 * Dispatches a custom event for the overlay state change.
	 *
	 * @param eventName - The name of the event to dispatch
	 * @private
	 */
	_dispatchStateEvent( eventName ) {
		const { overlay } = this.elements;
		const { isOpen } = this.state;

		// Create the event detail object
		const detail = {
			overlay,
			isOpen,
			id: overlay.id,
			triggers: this.elements.triggers
		};

		// Dispatch the specific event (opened/closed)
		overlay.dispatchEvent( new CustomEvent( eventName, {
			bubbles: true,
			detail
		} ) );

		// Always dispatch the general state-changed event
		overlay.dispatchEvent( new CustomEvent( EVENTS.STATE_CHANGED, {
			bubbles: true,
			detail
		} ) );
	}

	/**
	 * Renders the mobile overlay based on current state.
	 *
	 * @param prevState - Previous component state.
	 * @param state - Current component state.
	 */
	render( prevState = undefined, state = this.state ) {
		const { triggers, overlay } = this.elements;
		const { isOpen } = state;

		overlay.setAttribute( 'aria-hidden', !isOpen );

		for ( const trigger of triggers ) {
			trigger.setAttribute( 'aria-expanded', isOpen );
		}

		// Only animate if state changed
		if ( prevState && prevState.isOpen !== isOpen ) {
			// Set proper global listeners
			this.escapeKeyCleanup?.();
			this.escapeKeyCleanup = null;

			// Set up listeners if overlay is open
			if ( isOpen ) {
				this.escapeKeyCleanup = onEscapeKeyPress( () => this.onToggle() );

				// Move the overlay to the end of document body when opened.
				document.body.appendChild( overlay );

				// Add to the set of open overlays
				MobileOverlay.openOverlays.add( this );
			} else {
				// Remove from the set of open overlays when closed
				MobileOverlay.openOverlays.delete( this );
			}

			// Set aria attributes for all other overlays
			// to ensure only one is open at a time.
			for ( const overlay of MobileOverlay.openOverlays ) {
				const isHidden = overlay !== this;

				overlay.elements.overlay.setAttribute( 'aria-hidden', isHidden );
			}
		}
	}

	/**
	 * Handles the toggle event for the overlay.
	 */
	async onToggle() {
		if ( this.state.isAnimating ) {
			return;
		}

		const isOpen = !this.state.isOpen;

		this.setState( {
			isOpen,
			isAnimating: true
		} );

		// Dispatch opening event before the animation begins
		if ( isOpen ) {
			this._dispatchStateEvent( EVENTS.OPENING );
		}

		const { overlay } = this.elements;
		const { isHeadless } = this;

		if ( !this.headerSpacerManager ) {
			this.headerSpacerManager = createHeaderSpacerManager( {
				overlay
			} );
		}

		await animateElement( overlay, isOpen, {
			displayValue: 'flex',
			duration: 150,
			onEntering: () => {
				bodyScroll( { shouldBeStopped: true } );

				if ( !isHeadless ) {
					this.headerSpacerManager.show();
				}
			},
			onEntered: () => {
				overlay.focus();

				// Dispatch opened event when animation is complete
				this._dispatchStateEvent( EVENTS.OPENED );
			},
			onExiting: () => {
				bodyScroll( { shouldBeStopped: false } );
			},
			onExited: () => {
				if ( !isHeadless ) {
					this.headerSpacerManager.hide();
				}

				// Dispatch closed event when animation is complete
				this._dispatchStateEvent( EVENTS.CLOSED );
			}
		} );

		// Animation completed
		this.setState( {
			isAnimating: false
		} );
	}
}

/**
 * Static variables are not supported in current configuration
 */
MobileOverlay.openOverlays = new Set();

/**
 * Attaches a listener for mobile overlay state changes.
 *
 * @param options - Configuration options
 * @param options.eventName - The event name to listen for ('opened', 'closed', or 'state-changed')
 * @param options.callback - The callback to execute when the event occurs
 * @param [options.overlay] - Specific overlay element to listen for events from
 * @param [options.target] - Custom event listener target (defaults to document or overlay)
 * @returns Cleanup function to remove the listener
 * @example
 * // Listen for any overlay being opened
 * const cleanup1 = onMobileOverlayStateChange({
 *   eventName: 'opened',
 *   callback: (detail) => {
 *     console.log('Overlay opened:', detail.id);
 *   }
 * });
 *
 * // Listen for a specific overlay being closed
 * const specificOverlay = document.querySelector('#my-overlay');
 * const cleanup2 = onMobileOverlayStateChange({
 *   eventName: 'closed',
 *   callback: (detail) => {
 *     console.log('Specific overlay closed');
 *   },
 *   overlay: specificOverlay
 * });
 *
 * // Listen for overlay state changes within a specific dropdown menu
 * const dropdown = document.querySelector('.dropdown-menu');
 * const cleanup3 = onMobileOverlayStateChange({
 *   eventName: 'state-changed',
 *   callback: (detail) => {
 *     console.log(`Overlay ${detail.id} is now ${detail.isOpen ? 'open' : 'closed'}`);
 *   },
 *   target: dropdown
 * });
 *
 * // Clean up listeners when no longer needed
 * cleanup1();
 * cleanup2();
 * cleanup3();
 */
export function onMobileOverlayStateChange( { eventName, callback, overlay, target } ) {
	// Map shorthand event names to full event types
	const eventMap = {
		'opened': EVENTS.OPENED,
		'closed': EVENTS.CLOSED,
		'state-changed': EVENTS.STATE_CHANGED,
		'opening': EVENTS.OPENING
	};

	// Get the full event name or use the provided one if not in the map
	const fullEventName = eventMap[ eventName ] || eventName;

	// Determine the target element:
	// 1. Use explicitly provided target if set
	// 2. Otherwise use overlay if specified
	// 3. Fall back to document as default
	const listenerTarget = target || overlay || document;

	// Create the event handler function
	const handler = event => {
		// If a specific overlay was requested, check if this event is from that overlay
		if ( overlay && event.detail?.overlay !== overlay ) {
			return;
		}
		callback( event.detail, event );
	};

	// Attach the event listener
	listenerTarget.addEventListener( fullEventName, handler );

	// Return the cleanup function
	return () => {
		listenerTarget.removeEventListener( fullEventName, handler );
	};
}
