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

import { BaseComponent } from '../components/base-component';
import { createOutsideClickWatcher } from '../helpers/create-outside-click-watcher';
import { onEscapeKeyPress } from '../helpers/escape-handler';
import { bodyScroll } from '../helpers/body-scroll';
import { throttle } from '../helpers/throttle';
import { isHiddenElement } from '../helpers/is-hidden-element';
import { onHeaderResize, createHeaderSpacerManager, Header } from './header';
import { animateElement } from '../helpers/animate-element';
import { isMobile } from '../helpers/is-mobile';
import { prefersReducedMotion } from '../helpers/prefers-reduced-motion';

/**
 * Table of Contents component.
 */
export class TableOfContents extends BaseComponent {
	/**
	 * Creates a table of contents component.
	 *
	 * @param element - The ToC container element.
	 */
	constructor( element ) {
		super( {
			isVisible: element.classList.contains( 'is-visible' ),
			isLinkClicked: false,
			headerHeight: null
		} );

		this.elements = {
			toc: element,
			hideBtn: document.querySelector( '.js-toc-hide-btn' ),
			showBtn: document.querySelector( '.js-toc-show-btn' ),
			stickyButtons: document.querySelectorAll( '.js-toc-btn-sticky' ),
			tocList: element.querySelector( 'nav > ol' )
		};
	}

	/**
	 * Initializes all table of contents components on the page.
	 */
	static attach() {
		const toc = document.querySelector( '.js-toc' );

		if ( toc ) {
			new TableOfContents( toc ).init();
		}
	}

	/**
	 * Initializes the table of contents component with event listeners.
	 */
	init() {
		const { hideBtn, showBtn, tocList, toc } = this.elements;

		hideBtn?.addEventListener( 'click', () => {
			this.animateToC();
			this.setState( { isVisible: false } );
		} );

		showBtn?.addEventListener( 'click', () => {
			this.animateToC();
			this.setState( { isVisible: true } );
		} );

		tocList?.addEventListener( 'click', event => {
			if ( event.target.tagName !== 'A' ) {
				return;
			}

			const headings = this.elements.tocList.querySelectorAll( 'a' );

			headings.forEach( heading => heading.classList.remove( 'is-active' ) );
			event.target.classList.add( 'is-active' );

			this.isLinkClicked = true;
			this.setState( { isVisible: false } );

			if ( isMobile() ) {
				bodyScroll( { shouldBeStopped: false } );
			}

			setTimeout( () => {
				this.isLinkClicked = false;
			}, 1000 );
		} );

		this.headerResizeUnsubscribe = onHeaderResize( height => {
			this.setState( { headerHeight: height } );
		} );

		this.headerSpacerManager = createHeaderSpacerManager( {
			overlay: toc,
			setSpacing: ( overlay, height ) => {
				if ( height ) {
					overlay.style.setProperty( '--header-height', height );
				} else {
					overlay.style.removeProperty( '--header-height' );
				}
			}
		} );

		this.trackActiveHeadingOnScroll();
		this.render();
	}

	/**
	 * Renders the table of contents based on current state.
	 *
	 * @param prevState - Previous component state.
	 * @param state - Current component state.
	 */
	render( prevState, state = this.state ) {
		const { toc, hideBtn, showBtn } = this.elements;
		const { isVisible, headerHeight } = state;

		if ( !!prevState?.isVisible !== !!isVisible ) {
			animateElement( toc, isVisible, {
				duration: 150,
				displayValue: 'block',
				...!this.isLinkClicked && isMobile() && {
					onEntering: () => {
						bodyScroll( { shouldBeStopped: true } );
						this.headerSpacerManager.show();
					},
					onExiting: () => {
						bodyScroll( { shouldBeStopped: false } );
					},
					onExited: () => {
						this.headerSpacerManager.hide();
					}
				}
			} );

			this.outsideClickCleanup?.();
			this.escapeHandlerCleanup?.();

			if ( isVisible ) {
				this.setupOutsideClickWatcher();
				this.setupEscapeHandler();
				hideBtn.focus();
				showBtn?.setAttribute( 'aria-expanded', 'true' );
			} else {
				showBtn?.setAttribute( 'aria-expanded', 'false' );
			}
		}

		if ( headerHeight !== null && ( !isMobile() || !isHiddenElement( toc ) ) ) {
			toc.style.setProperty( '--header-height', `${ headerHeight }px` );
			document.querySelector( '.l-layout__container' )?.style.setProperty( '--header-height', `${ headerHeight }px` );
		}
	}

	/**
	 * Toggles the scroll position of the body when the ToC is visible.
	 * If the ToC is visible, it prevents scrolling and saves the current scroll position.
	 * If the ToC is hidden, it restores the scroll position.
	 */
	bodyScrollToggle() {
		const { isVisible } = this.state;

		bodyScroll( { shouldBeStopped: isVisible } );
	}

	/**
	 * Sets up the outside click watcher to hide the ToC.
	 */
	setupOutsideClickWatcher() {
		this.outsideClickCleanup = createOutsideClickWatcher( {
			elements: [ this.elements.toc, this.elements.showBtn ],
			callback: () => this.setState( { isVisible: false } )
		} );
	}

	/**
	 * Sets the escape key handler to hide the ToC.
	 */
	setupEscapeHandler() {
		this.escapeHandlerCleanup = onEscapeKeyPress( () => this.setState( { isVisible: false } ) );
	}

	/**
	 * Tracks the active heading on scroll.
	 */
	trackActiveHeadingOnScroll() {
		const { tocList, stickyButtons } = this.elements;
		const headings = tocList?.querySelectorAll( 'a' ) ?? [];

		const getHeadingsData = () => Array.from( headings ).map( heading => {
			const currentHeadingId = heading.getAttribute( 'href' ).substring( 1 );
			const currentHeading = document.getElementById( currentHeadingId )?.closest( '.b-heading' );
			const rect = currentHeading.getBoundingClientRect();

			return {
				rect,
				element: currentHeading,
				tocItem: heading
			};
		} ).filter( Boolean );

		function updateToc() {
			if ( window.scrollY > 110 ) {
				stickyButtons.forEach( button => button.classList.add( 'c-toc__show-button--sticky' ) );
			} else {
				stickyButtons.forEach( button => button.classList.remove( 'c-toc__show-button--sticky' ) );
			}

			if ( this.isLinkClicked ) {
				return;
			}

			const headerHeight = Header.the.getVisibleHeight() + 5 || 0;
			const headingsData = getHeadingsData();

			// Find headings that are visible in the viewport (accounting for header)
			const visibleHeadings = headingsData.filter( hd =>
				hd.rect.top >= headerHeight &&
				hd.rect.top < window.innerHeight
			);

			let activeHeading;

			if ( visibleHeadings.length > 0 ) {
				// Use the first visible heading
				activeHeading = visibleHeadings[ 0 ];
			} else {
				// If no headings are visible, find the one just above the viewport
				const headingsAbove = headingsData
					.filter( hd => hd.rect.bottom <= headerHeight )
					.sort( ( a, b ) => b.rect.bottom - a.rect.bottom );

				if ( headingsAbove.length > 0 ) {
					activeHeading = headingsAbove[ 0 ];
				} else {
					// If no headings above, use the first heading below the viewport
					const headingsBelow = headingsData
						.filter( hd => hd.rect.top > window.innerHeight )
						.sort( ( a, b ) => a.rect.top - b.rect.top );

					if ( headingsBelow.length > 0 ) {
						activeHeading = headingsBelow[ 0 ];
					}
				}
			}

			if ( activeHeading && !activeHeading.tocItem.classList.contains( 'is-active' ) ) {
				headings.forEach( heading => heading.classList.remove( 'is-active' ) );

				activeHeading.tocItem.classList.add( 'is-active' );
				activeHeading.tocItem.scrollIntoView( {
					behavior: prefersReducedMotion() ? 'instant' : 'smooth',
					block: 'nearest',
					inline: 'nearest'
				} );
			}

			// When scroll is near the bottom of the page, ensure the last heading is active.
			if ( window.scrollY + window.innerHeight >= document.body.scrollHeight - 10 ) {
				const lastHeading = headings[ headings.length - 1 ];

				if ( lastHeading && !lastHeading.classList.contains( 'is-active' ) ) {
					headings.forEach( heading => heading.classList.remove( 'is-active' ) );
					lastHeading.classList.add( 'is-active' );
				}
			}
		}

		window.addEventListener( 'scroll', throttle( updateToc.bind( this ), 50 ) );
		window.addEventListener( 'resize', throttle( updateToc.bind( this ), 100 ) );
	}

	animateToC() {
		const { toc } = this.elements;
		const animationClassName = 'c-toc--transition';

		toc.classList.add( animationClassName );

		toc.addEventListener( 'transitionend', () => {
			toc.classList.remove( animationClassName );
		}, {
			once: true
		} );
	}
}
