/**
 * @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 { createKeyboardFocusCycler, getVerticalKeysMap } from '../helpers/create-keyboard-focus-cycler';
import { createKeyboardBlurWatcher } from '../helpers/create-keyboard-blur-watcher';
import { onEscapeKeyPress } from '../helpers/escape-handler';

/**
 * A dropdown component that manages togglable dropdown menus with automatic positioning.
 * Handles opening, closing, and optimal positioning based on available screen space.
 */
export class Dropdown extends BaseComponent {
	/**
	 * Creates a dropdown component.
	 *
	 * @param wrapper - The dropdown container element.
	 */
	constructor( wrapper ) {
		super( {
			isOpen: false,
			position: 'bottom',
			horizontalOffset: 0,
			selectedValue: null,
			updateValueOnSelect: wrapper.hasAttribute( 'data-update-value-on-select' )
		} );

		const trigger = wrapper.querySelector( '[aria-haspopup="true"][aria-expanded][aria-controls]' );
		const content = document.getElementById( trigger.getAttribute( 'aria-controls' ) );

		this.elements = {
			wrapper,
			trigger,
			content,
			items: [ ...content.querySelectorAll( '.c-dropdown__item' ) ]
		};

		// Get initial selected value from active item or trigger
		this.state.selectedValue = this.findInitialSelectedValue();
	}

	/**
	 * Initializes all dropdown components on the page.
	 */
	static attach() {
		const dropdowns = document.querySelectorAll( '.c-dropdown:has(button[aria-haspopup])' );

		for ( const element of dropdowns ) {
			element.dropdown = new Dropdown( element ).init();
		}
	}

	/**
	 * Initializes the dropdown component with event listeners.
	 */
	init() {
		this.elements.trigger.addEventListener( 'click', () => {
			if ( !this.state.isOpen ) {
				this.determinePosition();
			}

			this.setState( { isOpen: !this.state.isOpen } );
		} );

		for ( const item of this.elements.items ) {
			item.addEventListener( 'click', event => {
				if ( this.state.updateValueOnSelect ) {
					event.preventDefault();
					this.setSelectedItem( item );
				}
			} );
		}

		this.determinePosition();
		return this;
	}

	/**
	 * Finds the initial selected value from active item or trigger text.
	 *
	 * @returns The initial selected value.
	 */
	findInitialSelectedValue() {
		const activeItem = this.elements.items.find( item => item.classList.contains( 'is-active' ) );

		if ( activeItem ) {
			return getItemValue( activeItem );
		}

		// Fall back to trigger text content
		return this.elements.trigger.textContent?.trim() || null;
	}

	/**
	 * Handles selection of a dropdown item.
	 *
	 * @param item - The selected dropdown item.
	 */
	setSelectedItem( item ) {
		const value = getItemValue( item );
		const prevSelectedValue = this.state.selectedValue;

		// Update state
		this.setState( {
			selectedValue: value,
			isOpen: false
		} );

		// Update trigger text if enabled
		if ( this.state.updateValueOnSelect ) {
			this.updateTriggerText( item );
		}

		// Dispatch custom event
		this.dispatchSelectionEvent( value, prevSelectedValue, item );

		// Return focus to trigger
		this.elements.trigger.focus();
	}

	/**
	 * Attaches a listener to the dropdown selection event.
	 */
	onSelectedItem( callback ) {
		onDropdownSelection( this.elements.wrapper, callback );
	}

	/**
	 * Updates the trigger button text with selected item content.
	 *
	 * @param selectedItem - The selected dropdown item.
	 */
	updateTriggerText( selectedItem ) {
		const { trigger } = this.elements;

		const triggerTextElement = trigger.querySelector( '.c-button__label' );
		const itemTextContent = selectedItem.textContent?.trim();

		if ( !itemTextContent || !triggerTextElement ) {
			return;
		}

		triggerTextElement.textContent = itemTextContent;
	}

	/**
	 * Dispatches a custom selection event.
	 *
	 * @param value - The selected value.
	 * @param previousValue - The previously selected value.
	 * @param item - The selected item element.
	 */
	dispatchSelectionEvent( value, previousValue, item ) {
		const selectionEvent = new CustomEvent( 'dropdown:select', {
			bubbles: true,
			detail: {
				value,
				previousValue,
				item,
				dropdown: this
			}
		} );

		this.elements.wrapper.dispatchEvent( selectionEvent );
	}

	/**
	 * Renders the dropdown based on current state.
	 *
	 * @param prevState - Previous component state.
	 * @param state - Current component state.
	 */
	render( prevState = undefined, state = this.state ) {
		const { content, trigger } = this.elements;
		const { position, isOpen, horizontalOffset, selectedValue } = state;

		// Update active states when selectedValue changes
		if ( prevState && prevState.selectedValue !== selectedValue ) {
			this.elements.items.forEach( item => {
				const itemValue = getItemValue( item );

				item.classList.toggle( 'is-active', itemValue === selectedValue );
			} );
		}

		// Toggle dropdown position classes.
		content.classList.remove(
			'c-dropdown__content--position-top',
			'c-dropdown__content--position-bottom'
		);

		content.classList.add( `c-dropdown__content--position-${ position }` );

		if ( isOpen && horizontalOffset !== 0 ) {
			content.style.marginLeft = `${ horizontalOffset }px`;
		} else {
			content.style.marginLeft = '';
		}

		// Toggle dropdown visibility.
		content.classList.toggle( 'c-dropdown__content--open', isOpen );

		if ( !content.getAttribute( 'style' ) ) {
			content.removeAttribute( 'style' );
		}

		// Toggle aria attributes for accessibility.
		content.setAttribute( 'aria-hidden', !isOpen );
		trigger.setAttribute( 'aria-expanded', isOpen );

		// Toggle activity classes on button within the trigger.
		if ( trigger.classList.contains( 'c-button' ) ) {
			trigger.classList.toggle( 'is-active', isOpen );
		}

		// Set proper global listeners.
		if ( prevState && prevState.isOpen !== isOpen ) {
			// Clean up previous listeners
			this.outsideClickCleanup?.();
			this.outsideClickCleanup = null;

			this.keyboardFocusCyclerWatcherCleanup?.();
			this.keyboardFocusCyclerWatcherCleanup = null;

			this.keyboardBlurCleanup?.();
			this.keyboardBlurCleanup = null;

			// Set up listeners if dropdown is open
			if ( isOpen ) {
				this.setupOutsideClickWatcher();
				this.setupKeyboardFocusCyclerWatcher();
				this.setupKeyboardBlurWatcher();
			}
		}
	}

	/**
	 * Calculates the optimal position for the dropdown.
	 */
	determinePosition() {
		const { trigger, content, wrapper } = this.elements;
		const preferredDirection = wrapper.dataset.preferredDirection;

		const contentRect = measureDropdownRect( content );
		const availableSpace = getAvailableSpace( trigger );

		const { position, horizontalOffset } = determineDropdownPosition(
			availableSpace,
			contentRect,
			preferredDirection
		);

		this.setState( { position, horizontalOffset } );
	}

	/**
	 * Sets up the keyboard focus cycling for dropdown items.
	 */
	setupKeyboardFocusCyclerWatcher() {
		const keyMap = getVerticalKeysMap();
		const { position } = this.state;

		const focusCycler = createKeyboardFocusCycler( {
			keyMap,
			items: this.elements.items,
			focusElements: true,
			onFocusEnter: item => item.classList.add( 'is-highlighted' ),
			onFocusLeave: item => item.classList.remove( 'is-highlighted' )
		} );

		// Handle case when user presses arrow keys to focus first item.
		const handleFocusFirstKeydown = event => {
			if ( ![ 'ArrowUp', 'Up', 'ArrowDown', 'Down' ].includes( event.key ) ) {
				return;
			}

			window.removeEventListener( 'keydown', handleFocusFirstKeydown );
			event.preventDefault();

			switch ( position ) {
				case 'top':
					if ( event.key === 'ArrowUp' || event.key === 'Up' ) {
						focusCycler.activateLast();
					} else {
						focusCycler.activateFirst();
					}
					break;

				case 'bottom':
					if ( event.key === 'ArrowDown' || event.key === 'Down' ) {
						focusCycler.activateFirst();
					} else {
						focusCycler.activateLast();
					}
					break;
			}
		};

		// Handle case when user presses Escape to close the dropdown.
		const unmountEscapeHandler = onEscapeKeyPress( () => {
			this.setState( { isOpen: false } );
			this.elements.trigger.focus();
		} );

		window.addEventListener( 'keydown', handleFocusFirstKeydown );

		this.keyboardFocusCyclerWatcherCleanup = () => {
			window.removeEventListener( 'keydown', handleFocusFirstKeydown );
			unmountEscapeHandler();
			focusCycler.unmount();
		};
	}

	/**
	 * Sets up the outside click watcher to close the dropdown.
	 */
	setupOutsideClickWatcher() {
		this.outsideClickCleanup = createOutsideClickWatcher( {
			elements: [ this.elements.content, this.elements.trigger ],
			callback: () => this.setState( { isOpen: false } )
		} );
	}

	/**
	 * Sets up a watcher to close dropdown when focus moves outside both trigger and content.
	 */
	setupKeyboardBlurWatcher() {
		this.keyboardBlurCleanup = createKeyboardBlurWatcher( {
			elements: [ this.elements.trigger, this.elements.content ],
			callback: () => this.setState( { isOpen: false } )
		} );
	}
}

/**
 * Measures dimensions of the dropdown content element.
 *
 * @param content - The dropdown content element.
 * @returns The dimensions of the content.
 */
function measureDropdownRect( content ) {
	// Temporarily show the content to measure it.
	content.style.visibility = 'hidden';
	content.style.display = 'block';

	const contentRect = content.getBoundingClientRect();

	// Restore original styles.
	content.style.display = '';
	content.style.visibility = '';

	return contentRect;
}

/**
 * Calculates available space around the trigger element.
 *
 * @param trigger - The dropdown trigger element.
 * @returns Object containing space measurements.
 */
function getAvailableSpace( trigger ) {
	const triggerRect = trigger.getBoundingClientRect();
	const viewportHeight = window.innerHeight;
	const viewportWidth = window.innerWidth;

	return {
		above: triggerRect.top,
		below: viewportHeight - triggerRect.bottom,
		left: triggerRect.left,
		right: viewportWidth - triggerRect.right,
		triggerRect,
		viewportWidth
	};
}

/**
 * Determines the optimal position for the dropdown based on available space.
 *
 * @param space - Available space measurements.
 * @param contentRect - The dimensions of the content.
 * @param preferredDirection - Preferred direction from data attribute.
 * @returns Position information with vertical position and horizontal offset.
 */
function determineDropdownPosition( space, contentRect, preferredDirection ) {
	const position = determineVerticalPosition( space, contentRect, preferredDirection );
	const horizontalOffset = calculateHorizontalOffset( space, contentRect );

	return { position, horizontalOffset };
}

/**
 * Determines the vertical position (top/bottom) for the dropdown.
 *
 * @param space - Available space measurements.
 * @param contentRect - The dimensions of the content.
 * @param preferredDirection - Preferred direction from data attribute.
 * @returns The vertical position ('top' or 'bottom').
 */
function determineVerticalPosition( space, contentRect, preferredDirection ) {
	const minRequiredVerticalSpace = contentRect.height + 10;

	if ( preferredDirection === 'up' && space.above >= minRequiredVerticalSpace ) {
		return 'top';
	}

	if ( preferredDirection === 'down' && space.below >= minRequiredVerticalSpace ) {
		return 'bottom';
	}

	return ( space.below < minRequiredVerticalSpace ) ? 'top' : 'bottom';
}

/**
 * Calculates the horizontal offset to prevent screen overflow.
 *
 * @param space - Available space measurements.
 * @param contentRect - The dimensions of the content.
 * @returns The horizontal offset in pixels.
 */
function calculateHorizontalOffset( space, contentRect ) {
	const { viewportWidth } = space;

	const screenMargin = 5;

	const contentLeft = contentRect.left;
	const contentRight = contentRect.right;

	// Check if dropdown overflows right side of screen.
	if ( contentRight > viewportWidth ) {
		const rightOverflow = contentRight - viewportWidth;

		return -rightOverflow - screenMargin;
	}

	// Check if dropdown overflows left side of screen.
	if ( contentLeft < 0 ) {
		return Math.abs( contentLeft ) + screenMargin;
	}

	return 0;
}

/**
 * Gets the value from a dropdown item.
 *
 * @param item - The dropdown item element.
 * @returns The item value.
 */
function getItemValue( item ) {
	return item.dataset.itemId ||
		   item.dataset.value ||
		   item.textContent?.trim() ||
		   null;
}

/**
 * Attaches a listener to the dropdown selection event.
 *
 * @param element - The element within a dropdown component that should listen for changes
 * @param callback - Callback function to execute when selection changes
 * @returns A cleanup function to remove the event listener
 */
export function onDropdownSelection( element, callback ) {
	const handler = event => {
		callback( event.detail );
	};

	// Find the closest dropdown container
	const dropdownContainer = element.closest( '.c-dropdown' );

	if ( !dropdownContainer ) {
		throw new Error( 'Element is not inside a dropdown component' );
	}

	dropdownContainer.addEventListener( 'dropdown:select', handler );

	return () => {
		dropdownContainer.removeEventListener( 'dropdown:select', handler );
	};
}

/**
 * Programmatically selects a dropdown item by value.
 *
 * @param element - An element within the dropdown component or the dropdown container itself
 * @param value - The value to select (matches data-item-id, data-value, or text content)
 * @returns Boolean indicating whether the selection was successful
 */
export function selectDropdownItem( element, value ) {
	// Find the closest dropdown container
	const dropdownContainer = element.closest( '.c-dropdown' ) ||
		( element.classList.contains( 'c-dropdown' ) ? element : null );

	if ( !dropdownContainer ) {
		throw new Error( 'Element is not inside a dropdown component' );
	}

	// Find the item with matching value
	const items = dropdownContainer.querySelectorAll( '.c-dropdown__item' );
	const targetItem = Array.from( items ).find( item => {
		const itemValue = getItemValue( item );
		return itemValue === value;
	} );

	if ( !targetItem ) {
		console.warn( `Dropdown item with value "${ value }" not found` );
		return false;
	}

	// Trigger a click on the item to select it
	targetItem.click();
	return true;
}
