/**
 * Accordion exclusive-open behavior (one item at a time).
 * Native <details> allows multiple open; when data-bm-accordion-multiple="0"
 * (or CSS var --bm-accordion-multiple: 0), opening one closes siblings.
 */

const ACCORDION_ANIMATION_DURATION = 260;
const runningAnimations = new WeakMap();
const suppressedToggles = new WeakSet();

/**
 * @param {string|null|undefined} value
 * @return {boolean}
 */
function isFlagOn( value ) {
	const raw = String( value ?? '' )
		.trim()
		.toLowerCase();
	if ( ! raw ) {
		return true;
	}
	return raw === '1' || raw === 'on' || raw === 'true' || raw === 'yes';
}

/**
 * Whether this accordion root allows several items open together.
 *
 * @param {Element|null|undefined} root
 * @return {boolean}
 */
export function isAccordionMultipleAllowed( root ) {
	if ( ! root || typeof root.getAttribute !== 'function' ) {
		return true;
	}
	if ( root.hasAttribute( 'data-bm-accordion-multiple' ) ) {
		return isFlagOn( root.getAttribute( 'data-bm-accordion-multiple' ) );
	}
	try {
		const fromVar = root.ownerDocument?.defaultView
			?.getComputedStyle?.( root )
			?.getPropertyValue?.( '--bm-accordion-multiple' );
		if ( fromVar != null && String( fromVar ).trim() !== '' ) {
			return isFlagOn( fromVar );
		}
	} catch ( e ) {
		// ignore
	}
	return true;
}

/**
 * Animation selected by the Card Surface control.
 *
 * @param {Element|null|undefined} root
 * @return {'simple'|'slide'|'fade'}
 */
function accordionAnimationMode( root ) {
	const view = root?.ownerDocument?.defaultView;
	if ( ! root || ! view || typeof root.animate !== 'function' ) {
		return 'simple';
	}
	if ( view.matchMedia?.( '(prefers-reduced-motion: reduce)' )?.matches ) {
		return 'simple';
	}
	try {
		const value = view
			.getComputedStyle( root )
			.getPropertyValue( '--bm-accordion-animation' )
			.trim()
			.toLowerCase();
		return value === 'slide' || value === 'fade' ? value : 'simple';
	} catch ( e ) {
		return 'simple';
	}
}

/**
 * Set native open state without treating it as a scripted external toggle.
 *
 * @param {HTMLDetailsElement} details
 * @param {boolean}            open
 */
function setControlledOpen( details, open ) {
	suppressedToggles.add( details );
	details.open = open;
	details.ownerDocument?.defaultView?.setTimeout?.( () => {
		suppressedToggles.delete( details );
	}, 100 );
}

/**
 * Finish an in-progress animation at its intended state.
 *
 * @param {HTMLDetailsElement} details
 */
function finishRunningAnimation( details ) {
	runningAnimations.get( details )?.finish?.();
}

/**
 * Direct children revealed by <details>, excluding its summary.
 *
 * @param {HTMLDetailsElement} details
 * @return {Element[]}
 */
function detailsContentElements( details ) {
	return Array.from( details.children ).filter(
		( child ) => child.tagName !== 'SUMMARY'
	);
}

/**
 * Animate the complete item height.
 *
 * @param {HTMLDetailsElement} details
 * @param {boolean}            opening
 */
function animateSlide( details, opening ) {
	finishRunningAnimation( details );
	const summary = details.querySelector( ':scope > summary' );
	if ( ! summary ) {
		details.open = opening;
		return;
	}

	const startHeight = details.offsetHeight;
	if ( opening ) {
		setControlledOpen( details, true );
	}
	const styles = details.ownerDocument.defaultView.getComputedStyle( details );
	const borders =
		( parseFloat( styles.borderTopWidth ) || 0 ) +
		( parseFloat( styles.borderBottomWidth ) || 0 );
	const endHeight = opening
		? details.scrollHeight + borders
		: summary.offsetHeight + borders;

	details.style.height = `${ startHeight }px`;
	details.style.overflow = 'hidden';
	const animation = details.animate(
		[
			{ height: `${ startHeight }px` },
			{ height: `${ endHeight }px` },
		],
		{
			duration: ACCORDION_ANIMATION_DURATION,
			easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
			fill: 'forwards',
		}
	);

	let done = false;
	const finish = () => {
		if ( done ) {
			return;
		}
		done = true;
		animation.cancel();
		details.style.removeProperty( 'height' );
		details.style.removeProperty( 'overflow' );
		if ( ! opening ) {
			setControlledOpen( details, false );
		}
		runningAnimations.delete( details );
	};
	animation.onfinish = finish;
	animation.oncancel = () => {
		if ( ! done ) {
			finish();
		}
	};
	runningAnimations.set( details, {
		finish: () => {
			try {
				animation.finish();
			} catch ( e ) {
				finish();
			}
		},
	} );
}

/**
 * Fade the revealed content while retaining native details semantics.
 *
 * @param {HTMLDetailsElement} details
 * @param {boolean}            opening
 */
function animateFade( details, opening ) {
	finishRunningAnimation( details );
	if ( opening ) {
		setControlledOpen( details, true );
	}
	const content = detailsContentElements( details );
	if ( ! content.length ) {
		setControlledOpen( details, opening );
		return;
	}

	const animations = content.map( ( element ) =>
		element.animate(
			[
				{ opacity: opening ? 0 : 1 },
				{ opacity: opening ? 1 : 0 },
			],
			{
				duration: ACCORDION_ANIMATION_DURATION,
				easing: 'ease-out',
				fill: 'both',
			}
		)
	);
	let done = false;
	let finishedCount = 0;
	const finish = () => {
		if ( done ) {
			return;
		}
		done = true;
		animations.forEach( ( animation ) => animation.cancel() );
		if ( ! opening ) {
			setControlledOpen( details, false );
		}
		runningAnimations.delete( details );
	};
	animations.forEach( ( animation ) => {
		animation.onfinish = () => {
			finishedCount += 1;
			if ( finishedCount === animations.length ) {
				finish();
			}
		};
	} );
	runningAnimations.set( details, {
		finish: () => {
			animations.forEach( ( animation ) => {
				try {
					animation.finish();
				} catch ( e ) {
					// The shared cleanup below still applies the final state.
				}
			} );
			finish();
		},
	} );
}

/**
 * Open or close with the selected effect.
 *
 * @param {HTMLDetailsElement} details
 * @param {boolean}            opening
 * @param {'slide'|'fade'}     mode
 */
function animateDetails( details, opening, mode ) {
	if ( mode === 'fade' ) {
		animateFade( details, opening );
		return;
	}
	animateSlide( details, opening );
}

/**
 * Collapse every sibling of `current`.
 *
 * @param {Element} root    Accordion root.
 * @param {Element} current Item to keep.
 * @param {'simple'|'slide'|'fade'} mode Animation mode.
 * @return {boolean} Whether anything closed.
 */
function closeSiblings( root, current, mode = 'simple' ) {
	let closed = false;
	root.querySelectorAll( 'details.bm-accordion__item' ).forEach( ( item ) => {
		if ( item !== current && item.open ) {
			if ( mode === 'simple' ) {
				item.open = false;
			} else {
				animateDetails( item, false, mode );
			}
			closed = true;
		}
	} );
	return closed;
}

/**
 * Nearest scrollable ancestor, falling back to the document scroller.
 *
 * @param {Element} el
 * @return {Element|null}
 */
function scrollContainer( el ) {
	const doc = el.ownerDocument;
	const view = doc?.defaultView;
	if ( ! view ) {
		return null;
	}
	let node = el.parentElement;
	while ( node ) {
		const overflowY = view.getComputedStyle( node ).overflowY;
		if (
			( overflowY === 'auto' ||
				overflowY === 'scroll' ||
				overflowY === 'overlay' ) &&
			node.scrollHeight > node.clientHeight
		) {
			return node;
		}
		node = node.parentElement;
	}
	return doc.scrollingElement || doc.documentElement || null;
}

/**
 * Keep `anchor` visually still while `mutate` changes layout above it.
 *
 * @param {Element}  anchor
 * @param {Function} mutate
 */
function preserveAnchorPosition( anchor, mutate ) {
	const before = anchor.getBoundingClientRect().top;
	mutate();
	const delta = anchor.getBoundingClientRect().top - before;
	if ( Math.abs( delta ) < 0.5 ) {
		return;
	}
	const scroller = scrollContainer( anchor );
	if ( ! scroller ) {
		return;
	}
	// CSS scroll-behavior:smooth would animate the correction into a visible slide.
	const previous = scroller.style.scrollBehavior;
	scroller.style.scrollBehavior = 'auto';
	scroller.scrollTop += delta;
	scroller.style.scrollBehavior = previous;
}

/**
 * Collapse siblings before the browser expands the clicked item, so the page
 * reflows once instead of growing and then shrinking across two frames.
 *
 * @param {Event} event
 */
function onSummaryClick( event ) {
	const target = event.target;
	if ( ! target || typeof target.closest !== 'function' ) {
		return;
	}
	const summary = target.closest( 'summary.bm-accordion__title' );
	if ( ! summary ) {
		return;
	}
	const details = summary.closest( 'details.bm-accordion__item' );
	if ( ! details ) {
		return;
	}
	const root = details.closest( '.bm-accordion' );
	if ( ! root ) {
		return;
	}
	finishRunningAnimation( details );
	const opening = ! details.open;
	const mode = accordionAnimationMode( root );

	if ( mode !== 'simple' ) {
		event.preventDefault();
		if ( opening && ! isAccordionMultipleAllowed( root ) ) {
			closeSiblings( root, details, mode );
		}
		animateDetails( details, opening, mode );
		return;
	}

	// Only expanding shifts siblings; collapsing needs no pre-pass.
	if ( opening && ! isAccordionMultipleAllowed( root ) ) {
		preserveAnchorPosition( summary, () =>
			closeSiblings( root, details, 'simple' )
		);
	}
}

/**
 * Close siblings for opens that bypass a summary click (scripted `open = true`).
 *
 * @param {Event} event
 */
function onDetailsToggle( event ) {
	const details = event.target;
	if (
		! details ||
		details.tagName !== 'DETAILS' ||
		! details.classList?.contains( 'bm-accordion__item' )
	) {
		return;
	}
	if ( suppressedToggles.has( details ) ) {
		suppressedToggles.delete( details );
		return;
	}
	if ( ! details.open ) {
		return;
	}
	const root = details.closest?.( '.bm-accordion' );
	if ( ! root || isAccordionMultipleAllowed( root ) ) {
		return;
	}
	closeSiblings( root, details, 'simple' );
}

/**
 * Bind exclusive-open once per document (or iframe document).
 *
 * @param {Document} [doc]
 */
export function initBmAccordion( doc = document ) {
	if ( ! doc || doc.__bmAccordionBound ) {
		return;
	}
	doc.__bmAccordionBound = true;
	doc.addEventListener( 'click', onSummaryClick, true );
	doc.addEventListener( 'toggle', onDetailsToggle, true );
}

/**
 * After switching to exclusive mode, leave at most one item open.
 *
 * @param {ParentNode|null|undefined} scope
 */
export function enforceAccordionExclusiveOpen( scope ) {
	if ( ! scope || typeof scope.querySelectorAll !== 'function' ) {
		return;
	}
	const roots =
		scope.nodeType === 1 && scope.matches?.( '.bm-accordion' )
			? [ scope ]
			: Array.from( scope.querySelectorAll?.( '.bm-accordion' ) || [] );
	roots.forEach( ( root ) => {
		if ( isAccordionMultipleAllowed( root ) ) {
			return;
		}
		const openItems = Array.from(
			root.querySelectorAll( 'details.bm-accordion__item[open]' )
		);
		if ( openItems.length <= 1 ) {
			return;
		}
		openItems.slice( 1 ).forEach( ( item ) => {
			item.open = false;
		} );
	} );
}
