/**
 * FormatToolbar while a preview contenteditable with formatting is focused.
 * Portals to document.body and anchors above the caret / selection
 * (incl. iframe offset). General typography / spacing stay in the side panel.
 */
import {
	createPortal,
	useCallback,
	useEffect,
	useLayoutEffect,
	useMemo,
	useRef,
	useState,
} from '@wordpress/element';
import {
	toolbarCommandsFromField,
} from '../../shared/allowedHtml';
import FormatToolbar from './FormatToolbar';

const GAP = 8;
const EDGE = 8;
const SEL_ATTR = 'data-bm-format-sel';

/**
 * Map an iframe-local rect into the top window viewport.
 *
 * @param {HTMLElement|null} target
 * @param {{ top: number, left: number, bottom: number, right: number, width: number, height: number }} rect
 * @return {{ top: number, left: number, bottom: number, right: number, width: number, height: number }}
 */
function toTopViewportRect( target, rect ) {
	const win = target?.ownerDocument?.defaultView;
	if ( ! win || win === window ) {
		return rect;
	}
	const frame = win.frameElement;
	if ( ! frame || typeof frame.getBoundingClientRect !== 'function' ) {
		return rect;
	}
	const frameRect = frame.getBoundingClientRect();
	return {
		top: frameRect.top + rect.top,
		left: frameRect.left + rect.left,
		bottom: frameRect.top + rect.bottom,
		right: frameRect.left + rect.right,
		width: rect.width,
		height: rect.height,
	};
}

/**
 * Union of non-empty client rects for a Range.
 *
 * @param {Range} range
 * @return {{ top: number, left: number, bottom: number, right: number, width: number, height: number }|null}
 */
function unionRangeRects( range ) {
	let top = Infinity;
	let left = Infinity;
	let bottom = -Infinity;
	let right = -Infinity;
	const list = range.getClientRects?.() || [];
	for ( let i = 0; i < list.length; i++ ) {
		const r = list[ i ];
		if ( ! r || ( r.width === 0 && r.height === 0 ) ) {
			continue;
		}
		top = Math.min( top, r.top );
		left = Math.min( left, r.left );
		bottom = Math.max( bottom, r.bottom );
		right = Math.max( right, r.right );
	}
	if ( top === Infinity ) {
		const b = range.getBoundingClientRect?.();
		if ( ! b || ( b.width === 0 && b.height === 0 ) ) {
			return null;
		}
		return {
			top: b.top,
			left: b.left,
			bottom: b.bottom,
			right: b.right,
			width: b.width,
			height: b.height,
		};
	}
	return {
		top,
		left,
		bottom,
		right,
		width: right - left,
		height: bottom - top,
	};
}

/**
 * Client rect for a collapsed caret (browsers often return an empty box).
 *
 * @param {Range} range
 * @param {Document} doc
 * @return {{ top: number, left: number, bottom: number, right: number, width: number, height: number }|null}
 */
function caretRectFromRange( range, doc ) {
	const list = range.getClientRects?.() || [];
	for ( let i = 0; i < list.length; i++ ) {
		const r = list[ i ];
		if ( ! r ) {
			continue;
		}
		// Collapsed carets may report 0×0 but still have a valid position.
		if ( Number.isFinite( r.top ) && Number.isFinite( r.left ) ) {
			return {
				top: r.top,
				left: r.left,
				bottom: r.bottom || r.top,
				right: r.right || r.left,
				width: r.width || 0,
				height: r.height || 0,
			};
		}
	}
	const b = range.getBoundingClientRect?.();
	if (
		b &&
		Number.isFinite( b.top ) &&
		Number.isFinite( b.left ) &&
		( b.width > 0 || b.height > 0 || b.top !== 0 || b.left !== 0 )
	) {
		return {
			top: b.top,
			left: b.left,
			bottom: b.bottom || b.top,
			right: b.right || b.left,
			width: b.width || 0,
			height: b.height || 0,
		};
	}

	// Insert a temporary marker to measure caret position.
	try {
		const marker = doc.createElement( 'span' );
		marker.appendChild( doc.createTextNode( '\u200b' ) );
		const probe = range.cloneRange();
		probe.insertNode( marker );
		const m = marker.getBoundingClientRect();
		const parent = marker.parentNode;
		parent?.removeChild( marker );
		parent?.normalize?.();
		if ( m && Number.isFinite( m.top ) && Number.isFinite( m.left ) ) {
			return {
				top: m.top,
				left: m.left,
				bottom: m.bottom || m.top,
				right: m.right || m.left,
				width: m.width || 0,
				height: m.height || 0,
			};
		}
	} catch ( e ) {
		// Fall through.
	}
	return null;
}

/**
 * Live selection or caret rect inside target, in top viewport coords.
 *
 * @param {HTMLElement|null} target
 * @return {{ top: number, left: number, bottom: number, right: number, width: number, height: number }|null}
 */
function getLiveSelectionRect( target ) {
	if ( ! target ) {
		return null;
	}
	const doc = target.ownerDocument || document;
	const win = doc.defaultView || window;
	const sel = win.getSelection?.();
	if ( ! sel || ! sel.rangeCount ) {
		return null;
	}
	const range = sel.getRangeAt( 0 );
	try {
		if ( ! target.contains( range.commonAncestorContainer ) ) {
			return null;
		}
	} catch ( e ) {
		return null;
	}
	if ( ! sel.isCollapsed ) {
		const local = unionRangeRects( range );
		return local ? toTopViewportRect( target, local ) : null;
	}
	const caret = caretRectFromRange( range, doc );
	return caret ? toTopViewportRect( target, caret ) : null;
}

/**
 * Fallback: top edge of the focused editable (toolbar sits above the field).
 *
 * @param {HTMLElement|null} target
 * @return {{ top: number, left: number, bottom: number, right: number, width: number, height: number }|null}
 */
function getFocusedFieldRect( target ) {
	if ( ! target || typeof target.getBoundingClientRect !== 'function' ) {
		return null;
	}
	const b = target.getBoundingClientRect();
	if ( ! b || ( ! b.width && ! b.height ) ) {
		return null;
	}
	return toTopViewportRect( target, {
		top: b.top,
		left: b.left,
		bottom: b.top,
		right: b.right,
		width: b.width,
		height: 0,
	} );
}

/**
 * Persist-mark rect (color picker) in top viewport coords.
 *
 * @param {HTMLElement|null} target
 * @return {{ top: number, left: number, bottom: number, right: number, width: number, height: number }|null}
 */
function getPersistMarkRect( target ) {
	if ( ! target?.querySelector ) {
		return null;
	}
	const mark = target.querySelector( `[${ SEL_ATTR }]` );
	if ( ! mark || typeof mark.getBoundingClientRect !== 'function' ) {
		return null;
	}
	const b = mark.getBoundingClientRect();
	if ( ! b.width && ! b.height ) {
		return null;
	}
	return toTopViewportRect( target, {
		top: b.top,
		left: b.left,
		bottom: b.bottom,
		right: b.right,
		width: b.width,
		height: b.height,
	} );
}

/**
 * @param {object} props
 * @param {{ target: HTMLElement, field: object|null, commands: string[]|null, isButton?: boolean, suppressBlur: Function }|null} props.session
 * @param {string} [props.className]
 */
export default function PreviewFormatToolbar( {
	session = null,
	className = '',
} ) {
	const barRef = useRef( null );
	const updateRef = useRef( () => {} );
	const lastRectRef = useRef( null );
	/** Keep bar while interacting with toolbar / color popover after selection collapses. */
	const keepVisibleRef = useRef( false );
	const [ coords, setCoords ] = useState( null );
	const [ visible, setVisible ] = useState( false );

	const commands = useMemo( () => {
		if ( ! session || session.isButton ) {
			return [];
		}
		return toolbarCommandsFromField( session.field, session.commands );
	}, [ session ] );

	const sessionKey = useMemo( () => {
		if ( ! session?.target ) {
			return 'none';
		}
		const el = session.target;
		const fieldKey = session.field?.key || '';
		const bmEl = el.getAttribute?.( 'data-bm-el' ) || '';
		const btn = el.getAttribute?.( 'data-bm-btn-index' ) || '';
		return `${ fieldKey }:${ bmEl }:${ btn }:${ el.tagName || '' }`;
	}, [ session ] );

	const getTarget = useCallback(
		() => session?.target || null,
		[ session ]
	);

	const onPointerDown = useCallback( () => {
		keepVisibleRef.current = true;
		session?.suppressBlur?.();
	}, [ session ] );

	const onBlurLock = useCallback(
		( locked ) => {
			keepVisibleRef.current = Boolean( locked );
			if ( locked ) {
				session?.lockBlur?.();
			} else {
				session?.unlockBlur?.();
			}
			window.requestAnimationFrame( () => updateRef.current() );
		},
		[ session ]
	);

	const updatePosition = useCallback( () => {
		const target = session?.target || null;
		const bar = barRef.current;
		if ( ! target || ! bar ) {
			setVisible( false );
			setCoords( null );
			return;
		}

		const live = getLiveSelectionRect( target );
		const mark = ! live ? getPersistMarkRect( target ) : null;
		const field = ! live && ! mark ? getFocusedFieldRect( target ) : null;
		let rect = live || mark || field;

		if ( rect ) {
			lastRectRef.current = rect;
		} else if ( keepVisibleRef.current && lastRectRef.current ) {
			rect = lastRectRef.current;
		}

		if ( ! rect ) {
			setVisible( false );
			setCoords( null );
			return;
		}

		const barRect = bar.getBoundingClientRect();
		const barW = Math.max( barRect.width, 1 );
		const barH = Math.max( barRect.height, 1 );
		const vw = window.innerWidth;
		const vh = window.innerHeight;

		let top = rect.top - barH - GAP;
		let place = 'above';
		if ( top < EDGE ) {
			top = rect.bottom + GAP;
			place = 'below';
			if ( top + barH > vh - EDGE ) {
				top = Math.max( EDGE, Math.min( rect.top, vh - barH - EDGE ) );
				place = 'overlay';
			}
		}

		// Center on the selection; clamp to the viewport.
		let left = rect.left + rect.width / 2 - barW / 2;
		if ( left + barW > vw - EDGE ) {
			left = vw - EDGE - barW;
		}
		left = Math.max( EDGE, left );

		setVisible( true );
		setCoords( { top, left, place } );
	}, [ session ] );

	updateRef.current = updatePosition;

	const onChange = useCallback( () => {
		const el = session?.target;
		if ( ! el || session?.isButton ) {
			return;
		}
		if ( typeof session?.scheduleSync === 'function' ) {
			session.scheduleSync();
		} else {
			session?.sync?.();
		}
		window.requestAnimationFrame( () => updateRef.current() );
	}, [ session ] );

	useLayoutEffect( () => {
		updatePosition();
	}, [ updatePosition, commands ] );

	useEffect( () => {
		if ( ! session?.target ) {
			setVisible( false );
			lastRectRef.current = null;
			keepVisibleRef.current = false;
			return undefined;
		}
		const target = session.target;
		const doc = target.ownerDocument || document;
		const win = doc.defaultView || window;
		const frame = win !== window ? win.frameElement : null;

		const onScrollOrResize = () => updatePosition();
		const onSelectionChange = () => {
			const live = getLiveSelectionRect( target );
			if ( live ) {
				if ( ! keepVisibleRef.current ) {
					lastRectRef.current = live;
				}
			} else if (
				! keepVisibleRef.current &&
				! target.querySelector?.( `[${ SEL_ATTR }]` ) &&
				doc.activeElement !== target &&
				! target.contains?.( doc.activeElement )
			) {
				// Only hide when focus left the editable (not on caret collapse).
				lastRectRef.current = null;
				setVisible( false );
				setCoords( null );
				return;
			}
			updatePosition();
		};

		const onFocusIn = () => {
			window.requestAnimationFrame( () => updateRef.current() );
		};

		// Portaled font menus live under document.body — keep the edit session.
		const onPortalPointerDown = ( event ) => {
			const el = event?.target;
			if (
				el?.closest?.(
					'.bm-format-toolbar__select-menu, .bm-format-toolbar__color-popover, .bm-format-toolbar__link-popover, .bm-format-toolbar__fonts-popover'
				)
			) {
				onPointerDown();
			}
		};

		window.addEventListener( 'resize', onScrollOrResize );
		window.addEventListener( 'scroll', onScrollOrResize, true );
		doc.addEventListener( 'scroll', onScrollOrResize, true );
		doc.addEventListener( 'selectionchange', onSelectionChange );
		doc.addEventListener( 'focusin', onFocusIn, true );
		document.addEventListener( 'pointerdown', onPortalPointerDown, true );
		target.addEventListener( 'input', onScrollOrResize );
		target.addEventListener( 'keyup', onSelectionChange );
		target.addEventListener( 'mouseup', onSelectionChange );
		target.addEventListener( 'focus', onFocusIn );
		if ( frame ) {
			frame.ownerDocument?.defaultView?.addEventListener(
				'scroll',
				onScrollOrResize,
				true
			);
		}

		let ro = null;
		if ( typeof ResizeObserver !== 'undefined' ) {
			ro = new ResizeObserver( onScrollOrResize );
			ro.observe( target );
			if ( barRef.current ) {
				ro.observe( barRef.current );
			}
		}

		const raf = window.requestAnimationFrame( updatePosition );

		return () => {
			window.cancelAnimationFrame( raf );
			window.removeEventListener( 'resize', onScrollOrResize );
			window.removeEventListener( 'scroll', onScrollOrResize, true );
			doc.removeEventListener( 'scroll', onScrollOrResize, true );
			doc.removeEventListener( 'selectionchange', onSelectionChange );
			doc.removeEventListener( 'focusin', onFocusIn, true );
			document.removeEventListener(
				'pointerdown',
				onPortalPointerDown,
				true
			);
			target.removeEventListener( 'input', onScrollOrResize );
			target.removeEventListener( 'keyup', onSelectionChange );
			target.removeEventListener( 'mouseup', onSelectionChange );
			target.removeEventListener( 'focus', onFocusIn );
			if ( frame ) {
				frame.ownerDocument?.defaultView?.removeEventListener(
					'scroll',
					onScrollOrResize,
					true
				);
			}
			ro?.disconnect();
		};
	}, [ session, updatePosition, onPointerDown ] );

	if ( ! session || session.isButton ) {
		return null;
	}
	if ( Array.isArray( commands ) && commands.length === 0 ) {
		return null;
	}

	const style =
		visible && coords
			? {
					position: 'fixed',
					top: `${ coords.top }px`,
					left: `${ coords.left }px`,
					zIndex: 100160,
			  }
			: {
					position: 'fixed',
					top: '-9999px',
					left: '-9999px',
					visibility: 'hidden',
					pointerEvents: 'none',
					zIndex: 100160,
			  };

	return createPortal(
		<div
			ref={ barRef }
			className={
				'bm-preview-format-bar bm-preview-format-bar--anchored' +
				( coords?.place ? ` is-${ coords.place }` : '' ) +
				( visible ? ' is-visible' : ' is-hidden' ) +
				( className ? ` ${ className }` : '' )
			}
			style={ style }
			aria-hidden={ visible ? undefined : 'true' }
			onMouseDown={ ( event ) => {
				onPointerDown();
				// Keep iframe selection from collapsing when clicking the bar.
				if (
					event?.target?.closest?.(
						'button, .components-button, .components-dropdown, .bm-styled-select__toggle, .bm-searchable-select, select'
					)
				) {
					event.preventDefault();
				}
			} }
			onPointerDown={ ( event ) => {
				onPointerDown();
				if (
					event?.target?.closest?.(
						'button, .components-button, .components-dropdown, .bm-styled-select__toggle, .bm-searchable-select, select'
					)
				) {
					event.preventDefault();
				}
			} }
		>
			<FormatToolbar
				key={ sessionKey }
				commands={ commands }
				getTarget={ getTarget }
				onPointerDown={ onPointerDown }
				onBlurLock={ onBlurLock }
				onChange={ onChange }
			/>
		</div>,
		document.body
	);
}
