/**
 * Inline content editing in preview canvases / iframes.
 * Syncs attributes while typing / formatting so the sidebar stays current.
 * Preview hosts should freeze interpolated HTML while a session is active
 * so attribute updates do not remount the contenteditable mid-edit.
 */
import {
	allowedCommandsFromField,
	sanitizeAllowedHtml,
	sanitizePasteHtml,
	stripToPlainText,
} from './allowedHtml';
import { findCollectionRepeater } from './collectionRepeater';
import {
	cloneState,
	isRedoKeyEvent,
	isUndoKeyEvent,
} from './attributeHistory';
import { handleRichTextBackspace, handleRichTextEnter } from './richEnter';

/**
 * @param {object|null|undefined} field
 * @return {boolean}
 */
export function isContentEditableField( field ) {
	if ( ! field || typeof field !== 'object' ) {
		return false;
	}
	const type = String( field.type || '' );
	return (
		type === 'text' ||
		type === 'textarea' ||
		type === 'wysiwyg' ||
		type === 'richtext' ||
		type === 'textWysiwyg' ||
		type === 'textWysywig'
	);
}

/**
 * Floating format toolbar chrome only (portaled to the parent document).
 * Do NOT include inspector Design/Style UI (.bm-styled-select portals,
 * .bm-color-dropdown, etc.) — those must unfreeze so Style reference and
 * Design edits remount the preview.
 */
export const PREVIEW_EDIT_CHROME_SELECTOR = [
	'.bm-preview-format-bar',
	'.bm-format-toolbar',
	'.bm-format-toolbar__color-popover',
	'.bm-format-toolbar__link-popover',
	'.bm-format-toolbar__fonts-popover',
	'.bm-format-toolbar__select-menu',
].join( ', ' );

/**
 * Bumped when the link / fields popover takes over so contenteditable teardown
 * does not commit a stale label over the draft the user is typing.
 */
let contentEditCommitEpoch = 0;

/**
 * While > Date.now(), refuse collection-field patches from inline edit.
 * Covers the gap where WP setAttributes lands after React rebinds a new index
 * against still-stale preview DOM (titles look “copied” onto neighbors).
 */
let blockCollectionContentWritesUntil = 0;

/** @type {Set<Function>} */
const previewContentEditForceEndListeners = new Set();

/**
 * Skip the next contenteditable teardown commit(s). Call before opening the
 * button "Edit link" popover (or any inspector control that owns the label).
 */
export function suppressPreviewContentEditCommit() {
	contentEditCommitEpoch += 1;
}

/**
 * Block inline collection title/body writes for a short window.
 *
 * @param {number} [ms=450]
 */
export function pauseCollectionContentWrites( ms = 450 ) {
	suppressPreviewContentEditCommit();
	const until = Date.now() + Math.max( 0, Number( ms ) || 0 );
	if ( until > blockCollectionContentWritesUntil ) {
		blockCollectionContentWritesUntil = until;
	}
}

/**
 * Preview hosts register here so collection reorder can release the HTML freeze
 * synchronously (before setAttributes), not after the blur timeout.
 *
 * @param {Function} listener
 * @return {Function} unsubscribe
 */
export function subscribePreviewContentEditForceEnd( listener ) {
	if ( typeof listener !== 'function' ) {
		return () => {};
	}
	previewContentEditForceEndListeners.add( listener );
	return () => {
		previewContentEditForceEndListeners.delete( listener );
	};
}

function notifyPreviewContentEditForceEnd() {
	previewContentEditForceEndListeners.forEach( ( listener ) => {
		try {
			listener();
		} catch ( e ) {
			// ignore
		}
	} );
}

/**
 * Collect preview roots in the parent document and same-origin iframes.
 *
 * @return {Element[]}
 */
export function collectPreviewRoots() {
	const roots = [];
	const seen = new Set();
	const add = ( node ) => {
		if ( ! node || seen.has( node ) ) {
			return;
		}
		seen.add( node );
		roots.push( node );
	};

	try {
		document
			.querySelectorAll(
				'.bm-gutenberg-preview, .bm-preview-root, [data-bm-instance], [data-bm-editor-preview] body'
			)
			.forEach( add );
		document.querySelectorAll( 'iframe' ).forEach( ( frame ) => {
			try {
				const doc = frame.contentDocument;
				if ( ! doc?.body ) {
					return;
				}
				if ( doc.body.getAttribute( 'data-bm-editor-preview' ) === '1' ) {
					add( doc.body );
					return;
				}
				doc
					.querySelectorAll(
						'.bm-gutenberg-preview, .bm-preview-root, [data-bm-instance]'
					)
					.forEach( add );
			} catch ( e ) {
				// cross-origin
			}
		} );
	} catch ( e ) {
		// ignore
	}
	return roots;
}

/**
 * Tear down active preview contenteditables and suppress pending sync/commit.
 *
 * @param {object} [options]
 * @param {boolean} [options.unfreeze=true] When true, notify hosts to drop the
 *   frozen preview HTML immediately (required before collection reorder).
 * @param {boolean} [options.pauseCollectionWrites=false] Block collection
 *   inline patches briefly (use on structural reorder / remove / duplicate).
 */
export function endPreviewContentEditForLinkEditor( options = {} ) {
	const unfreeze = options?.unfreeze !== false;
	const pauseWrites = Boolean( options?.pauseCollectionWrites );
	if ( pauseWrites ) {
		pauseCollectionContentWrites( 450 );
	} else {
		suppressPreviewContentEditCommit();
	}
	const stripAll = ( root ) => {
		root
			?.querySelectorAll?.(
				'[data-bm-content-edit="1"], .bm-preview-content-edit'
			)
			?.forEach( ( el ) => {
				try {
					el.blur?.();
					el.removeAttribute?.( 'contenteditable' );
					el.removeAttribute?.( 'data-bm-content-edit' );
					el.classList?.remove?.( 'bm-preview-content-edit' );
				} catch ( e ) {
					// ignore
				}
			} );
	};
	try {
		stripAll( document );
		document.querySelectorAll( 'iframe' ).forEach( ( frame ) => {
			try {
				stripAll( frame.contentDocument );
			} catch ( e ) {
				// ignore
			}
		} );
	} catch ( e ) {
		// ignore
	}
	if ( unfreeze ) {
		notifyPreviewContentEditForceEnd();
	}
}

/**
 * Live-update a button label in preview DOM without remounting interpolate HTML.
 *
 * @param {object} opts
 * @param {number} opts.buttonIndex
 * @param {string} [opts.title]
 * @param {string} [opts.url]
 * @param {boolean} [opts.opensInNewTab]
 * @param {number|null} [opts.cardIndex]
 * @param {number|null} [opts.slideIndex]
 * @param {ParentNode[]} [opts.roots]
 */
export function patchPreviewButtonLink( opts = {} ) {
	const buttonIndex = Number( opts.buttonIndex );
	if ( ! Number.isFinite( buttonIndex ) || buttonIndex < 0 ) {
		return;
	}
	const roots = Array.isArray( opts.roots )
		? opts.roots
		: collectPreviewRoots();
	const title = opts.title != null ? String( opts.title ) : null;
	const url = opts.url != null ? String( opts.url ) : null;
	const opensInNewTab =
		opts.opensInNewTab != null ? Boolean( opts.opensInNewTab ) : null;

	let cardIndex =
		opts.cardIndex != null && Number.isFinite( Number( opts.cardIndex ) )
			? Number( opts.cardIndex )
			: null;
	let slideIndex =
		opts.slideIndex != null && Number.isFinite( Number( opts.slideIndex ) )
			? Number( opts.slideIndex )
			: null;

	const resolveBtnInRoot = ( root ) => {
		if ( ! root?.querySelector ) {
			return null;
		}
		let scope = root;
		if ( slideIndex != null ) {
			scope =
				root.querySelector(
					`[data-bm-slide-index="${ slideIndex }"]`
				) || null;
			if ( ! scope ) {
				return null;
			}
		} else if ( cardIndex != null ) {
			scope =
				root.querySelector(
					`[data-bm-card-index="${ cardIndex }"]`
				) || null;
			if ( ! scope ) {
				return null;
			}
		} else {
			// Without an explicit card/slide, only touch the selected button —
			// never the first [data-bm-btn-index] across the grid.
			const selected =
				root.querySelector(
					`[data-bm-btn-index="${ buttonIndex }"][data-bm-selected="true"]`
				) ||
				root.querySelector(
					`[data-bm-selected="true"][data-bm-el="buttons"] [data-bm-btn-index="${ buttonIndex }"]`
				) ||
				root.querySelector(
					`[data-bm-el="buttons"][data-bm-selected="true"] [data-bm-btn-index="${ buttonIndex }"]`
				);
			if ( ! selected ) {
				return null;
			}
			return selected.matches?.(
				`[data-bm-btn-index="${ buttonIndex }"]`
			)
				? selected
				: selected.querySelector(
						`[data-bm-btn-index="${ buttonIndex }"]`
				  );
		}
		return scope.querySelector(
			`[data-bm-btn-index="${ buttonIndex }"]`
		);
	};

	// Infer card/slide from the selected button when scope was omitted.
	if ( cardIndex == null && slideIndex == null ) {
		for ( const root of roots ) {
			const selected =
				root?.querySelector?.(
					`[data-bm-btn-index="${ buttonIndex }"][data-bm-selected="true"]`
				) ||
				root?.querySelector?.(
					`[data-bm-selected="true"] [data-bm-btn-index="${ buttonIndex }"]`
				);
			const host = selected?.closest?.(
				'[data-bm-card-index], [data-bm-slide-index]'
			);
			if ( ! host ) {
				continue;
			}
			if ( host.hasAttribute( 'data-bm-slide-index' ) ) {
				const n = Number(
					host.getAttribute( 'data-bm-slide-index' )
				);
				if ( Number.isFinite( n ) ) {
					slideIndex = n;
					break;
				}
			}
			if ( host.hasAttribute( 'data-bm-card-index' ) ) {
				const n = Number(
					host.getAttribute( 'data-bm-card-index' )
				);
				if ( Number.isFinite( n ) ) {
					cardIndex = n;
					break;
				}
			}
		}
	}

	roots.forEach( ( root ) => {
		const btn = resolveBtnInRoot( root );
		if ( ! btn ) {
			return;
		}
		if ( url != null ) {
			const href = url.trim() || '#';
			btn.setAttribute( 'href', href );
		}
		if ( opensInNewTab != null ) {
			if ( opensInNewTab ) {
				btn.setAttribute( 'target', '_blank' );
				btn.setAttribute( 'rel', 'noopener noreferrer' );
			} else {
				btn.removeAttribute( 'target' );
				btn.removeAttribute( 'rel' );
			}
		}
		if ( title == null ) {
			return;
		}
		let label =
			btn.querySelector( '.bm-hero__btn-label' ) ||
			btn.querySelector( '.bm-button__label' );
		// Keep an empty label node while drafting so the edit target does not
		// vanish mid-keystroke (icon-only omit happens on the next remount).
		if ( ! label ) {
			const doc = btn.ownerDocument || document;
			label = doc.createElement( 'span' );
			label.className = 'bm-hero__btn-label';
			btn.appendChild( label );
		}
		label.textContent = title;
	} );
}

/**
 * @param {EventTarget|Node|null|undefined} target
 * @return {boolean}
 */
export function isPreviewEditChromeTarget( target ) {
	const el =
		target?.nodeType === 1
			? target
			: target?.parentElement || null;
	return Boolean( el?.closest?.( PREVIEW_EDIT_CHROME_SELECTOR ) );
}

/**
 * Schema opt-in: allow typing in the preview.
 * Templates must set `"inlineEdit": true` on the field.
 *
 * @param {object|null|undefined} field
 * @return {boolean}
 */
export function isInlineEditEnabled( field ) {
	if ( ! field || typeof field !== 'object' ) {
		return false;
	}
	const raw =
		field.inlineEdit ?? field.inline_edit ?? field.previewInlineEdit;
	return raw === true || raw === 1 || raw === '1' || raw === 'true';
}

/**
 * Find a content field that has preview inlineEdit enabled.
 *
 * @param {Array<object>} schema
 * @param {string}        key
 * @return {object|null}
 */
export function findContentField( schema, key ) {
	if ( ! key || ! Array.isArray( schema ) ) {
		return null;
	}
	const match = ( field ) =>
		isContentEditableField( field ) && isInlineEditEnabled( field );

	const top = schema.find(
		( f ) => f?.key === key && f?.type !== 'elementsOrder'
	);
	if ( top && match( top ) ) {
		return top;
	}
	const slides = schema.find(
		( f ) => f?.key === 'slides' && Array.isArray( f.fields )
	);
	const inSlides = ( slides?.fields || [] ).find( ( f ) => f?.key === key );
	if ( inSlides && match( inSlides ) ) {
		return inSlides;
	}
	const collection = findCollectionRepeater( schema );
	const inColl = ( collection?.fields || [] ).find( ( f ) => f?.key === key );
	if ( inColl && match( inColl ) ) {
		return inColl;
	}
	return null;
}

/**
 * Buttons repeater field with inlineEdit enabled (label editing in preview).
 *
 * @param {Array<object>} schema
 * @return {object|null}
 */
export function findInlineEditableButtonsField( schema ) {
	if ( ! Array.isArray( schema ) ) {
		return null;
	}
	const match = ( field ) =>
		field?.key === 'buttons' && isInlineEditEnabled( field );

	const top = schema.find( match );
	if ( top ) {
		return top;
	}
	const slides = schema.find(
		( f ) => f?.key === 'slides' && Array.isArray( f.fields )
	);
	const inSlides = ( slides?.fields || [] ).find( match );
	if ( inSlides ) {
		return inSlides;
	}
	const collection = findCollectionRepeater( schema );
	const inColl = ( collection?.fields || [] ).find( match );
	if ( inColl ) {
		return inColl;
	}
	return null;
}

/**
 * Whether a schema key can be edited inline in preview.
 *
 * @param {Array<object>} schema
 * @param {string|null}   key
 * @param {number|null}   buttonIndex
 * @return {boolean}
 */
export function canInlineEditKey( schema, key, buttonIndex = null ) {
	if (
		( buttonIndex != null && Number.isFinite( Number( buttonIndex ) ) ) ||
		key === 'buttons'
	) {
		return Boolean( findInlineEditableButtonsField( schema ) );
	}
	return Boolean( findContentField( schema, key ) );
}

/**
 * Build a setAttributes patch for an inline content commit.
 *
 * @param {object} attributes
 * @param {object} commit
 * @return {object}
 */
export function buildInlineContentPatch( attributes, commit ) {
	const {
		key,
		value,
		slideIndex = null,
		cardIndex = null,
		buttonIndex = null,
		collectionKey = null,
	} = commit || {};

	const patchButtons = ( list, index, title ) => {
		const buttons = Array.isArray( list ) ? list : [];
		if ( index == null || index < 0 || index >= buttons.length ) {
			return buttons;
		}
		return buttons.map( ( btn, i ) => {
			if ( i !== index ) {
				return btn;
			}
			const link =
				btn?.link && typeof btn.link === 'object' ? btn.link : {};
			return {
				...btn,
				link: { ...link, title },
			};
		} );
	};

	if ( buttonIndex != null && Number.isFinite( Number( buttonIndex ) ) ) {
		const bi = Number( buttonIndex );
		const title = stripToPlainText( value );

		if ( slideIndex != null && Number.isFinite( Number( slideIndex ) ) ) {
			const slides = Array.isArray( attributes?.slides )
				? attributes.slides
				: [];
			const si = Number( slideIndex );
			return {
				slides: slides.map( ( slide, i ) =>
					i === si
						? {
								...slide,
								buttons: patchButtons(
									slide?.buttons,
									bi,
									title
								),
						  }
						: slide
				),
			};
		}

		if (
			cardIndex != null &&
			collectionKey &&
			Number.isFinite( Number( cardIndex ) )
		) {
			const list = Array.isArray( attributes?.[ collectionKey ] )
				? attributes[ collectionKey ]
				: [];
			const ci = Number( cardIndex );
			return {
				[ collectionKey ]: list.map( ( item, i ) =>
					i === ci
						? {
								...item,
								buttons: patchButtons(
									item?.buttons,
									bi,
									title
								),
						  }
						: item
				),
			};
		}

		return {
			buttons: patchButtons( attributes?.buttons, bi, title ),
		};
	}

	if ( ! key ) {
		return {};
	}

	if ( slideIndex != null && Number.isFinite( Number( slideIndex ) ) ) {
		const slides = Array.isArray( attributes?.slides )
			? attributes.slides
			: [];
		const si = Number( slideIndex );
		return {
			slides: slides.map( ( slide, i ) =>
				i === si ? { ...slide, [ key ]: value } : slide
			),
		};
	}

	if (
		cardIndex != null &&
		collectionKey &&
		Number.isFinite( Number( cardIndex ) )
	) {
		const list = Array.isArray( attributes?.[ collectionKey ] )
			? attributes[ collectionKey ]
			: [];
		const ci = Number( cardIndex );
		return {
			[ collectionKey ]: list.map( ( item, i ) =>
				i === ci ? { ...item, [ key ]: value } : item
			),
		};
	}

	return { [ key ]: value };
}

/**
 * Read a content attr value for slide / card / top-level context.
 *
 * @param {object} attributes
 * @param {object} context
 * @return {string}
 */
export function readInlineContentValue( attributes, context = {} ) {
	const {
		key,
		slideIndex = null,
		cardIndex = null,
		collectionKey = null,
	} = context;
	if ( ! key ) {
		return '';
	}
	if ( slideIndex != null && Number.isFinite( Number( slideIndex ) ) ) {
		const slides = Array.isArray( attributes?.slides )
			? attributes.slides
			: [];
		const slide = slides[ Number( slideIndex ) ];
		return slide?.[ key ] == null ? '' : String( slide[ key ] );
	}
	if (
		cardIndex != null &&
		collectionKey &&
		Number.isFinite( Number( cardIndex ) )
	) {
		const list = Array.isArray( attributes?.[ collectionKey ] )
			? attributes[ collectionKey ]
			: [];
		const item = list[ Number( cardIndex ) ];
		return item?.[ key ] == null ? '' : String( item[ key ] );
	}
	return attributes?.[ key ] == null ? '' : String( attributes[ key ] );
}

/**
 * @param {ParentNode} root
 * @param {object}     opts
 * @return {HTMLElement|null}
 */
function resolveEditTarget( root, opts ) {
	const {
		selectedElementKey,
		focusSlideIndex,
		focusCardIndex,
		focusButtonIndex,
		fieldSchema,
	} = opts;

	const btnIdx =
		focusButtonIndex != null && Number.isFinite( Number( focusButtonIndex ) )
			? Number( focusButtonIndex )
			: selectedElementKey === 'buttons'
			? 0
			: null;

	if ( btnIdx != null ) {
		if ( ! canInlineEditKey( fieldSchema, 'buttons', btnIdx ) ) {
			return null;
		}
		let scope = root;
		if ( focusSlideIndex != null ) {
			scope =
				root.querySelector(
					`[data-bm-slide-index="${ Number( focusSlideIndex ) }"]`
				) || root;
		} else if ( focusCardIndex != null ) {
			scope =
				root.querySelector(
					`[data-bm-card-index="${ Number( focusCardIndex ) }"]`
				) || root;
		}
		const btn = scope.querySelector( `[data-bm-btn-index="${ btnIdx }"]` );
		if ( ! btn ) {
			return null;
		}
		return (
			btn.querySelector( '.bm-hero__btn-label' ) ||
			btn.querySelector( '.bm-button__label' ) ||
			null
		);
	}

	if ( ! selectedElementKey ) {
		return null;
	}
	if ( ! findContentField( fieldSchema, selectedElementKey ) ) {
		return null;
	}

	// Tabs / flat collections put data-bm-card-index on the element itself
	// (no wrapper). Prefer the exact key+index match before scoping to a parent.
	if ( focusCardIndex != null && Number.isFinite( Number( focusCardIndex ) ) ) {
		const indexed = root.querySelectorAll(
			`[data-bm-el="${ CSS.escape( selectedElementKey ) }"][data-bm-card-index="${ Number(
				focusCardIndex
			) }"]`
		);
		if ( indexed.length ) {
			for ( const node of indexed ) {
				if ( node.getAttribute( 'data-bm-selected' ) === 'true' ) {
					return preferEditTextTarget( node );
				}
			}
			return preferEditTextTarget( indexed[ 0 ] );
		}
	}

	let scope = root;
	if ( focusSlideIndex != null ) {
		scope =
			root.querySelector(
				`[data-bm-slide-index="${ Number( focusSlideIndex ) }"]`
			) || root;
	} else if ( focusCardIndex != null ) {
		scope =
			root.querySelector(
				`[data-bm-card-index="${ Number( focusCardIndex ) }"]`
			) || root;
	}

	const nodes = scope.querySelectorAll(
		`[data-bm-el="${ CSS.escape( selectedElementKey ) }"]`
	);
	if ( ! nodes.length ) {
		return null;
	}
	for ( const node of nodes ) {
		if ( node.getAttribute( 'data-bm-selected' ) === 'true' ) {
			return preferEditTextTarget( node );
		}
	}
	return preferEditTextTarget( nodes[ 0 ] );
}

/**
 * Prefer an inner plain-text edit target so sibling icons are not wiped.
 *
 * @param {HTMLElement|null} node
 * @return {HTMLElement|null}
 */
function preferEditTextTarget( node ) {
	if ( ! node?.querySelector ) {
		return node;
	}
	const text = node.querySelector(
		'.bm-edit-text, .bm-tabs__label-text, .bm-accordion__title-text'
	);
	return text || node;
}

/**
 * Bind contenteditable for the active selection. Returns cleanup.
 *
 * @param {ParentNode|null} root
 * @param {object}          opts
 * @return {Function}
 */
export function bindPreviewContentEdit( root, opts = {} ) {
	if ( ! root || typeof opts.setAttributes !== 'function' ) {
		return () => {};
	}
	// Reorder drag keeps a frozen DOM; never attach a write session to it.
	if (
		root.classList?.contains?.( 'is-bm-collection-dragging' ) ||
		root.closest?.( '.is-bm-collection-dragging' )
	) {
		return () => {};
	}
	const collectionKeyHint =
		opts.collectionKey ||
		findCollectionRepeater( opts.fieldSchema || [] )?.key ||
		null;
	if (
		collectionKeyHint &&
		Date.now() < blockCollectionContentWritesUntil
	) {
		return () => {};
	}

	const fieldSchema = opts.fieldSchema || [];
	const getAttributes =
		typeof opts.getAttributes === 'function'
			? opts.getAttributes
			: () => opts.attributes || {};
	const collection =
		opts.collectionKey ||
		findCollectionRepeater( fieldSchema )?.key ||
		null;

	root.querySelectorAll( '[data-bm-content-edit]' ).forEach( ( el ) => {
		el.removeAttribute( 'contenteditable' );
		el.removeAttribute( 'data-bm-content-edit' );
		el.classList.remove( 'bm-preview-content-edit' );
	} );

	const target = resolveEditTarget( root, {
		...opts,
		fieldSchema,
	} );
	if ( ! target ) {
		return () => {};
	}

	const isButton =
		opts.focusButtonIndex != null ||
		opts.selectedElementKey === 'buttons' ||
		Boolean( target.closest?.( '[data-bm-btn-index]' ) );

	const field = isButton
		? { type: 'text', allowedCommand: [] }
		: findContentField( fieldSchema, opts.selectedElementKey );
	const commands = allowedCommandsFromField( field );
	// null = full rich set; [] = plain text only.
	const isPlainSurface =
		isButton ||
		( Array.isArray( commands ) && commands.length === 0 );

	const ownerDocument = target.ownerDocument || document;

	// Checklist / list roots: keep at least one <li> so Enter splits items.
	if (
		! isPlainSurface &&
		target.nodeType === 1 &&
		/^(UL|OL)$/i.test( target.tagName ) &&
		! target.querySelector( 'li' )
	) {
		const li = ownerDocument.createElement( 'li' );
		li.appendChild( ownerDocument.createTextNode( '\u200B' ) );
		target.appendChild( li );
	}

	let originHtml = target.innerHTML;
	const originAttrs = cloneState( getAttributes() || {} );
	let committed = false;
	const commitEpochAtStart = contentEditCommitEpoch;
	let cancelled = false;
	let suppressBlur = false;
	let blurLocks = 0;
	let blurTimer = 0;
	let syncTimer = 0;
	let sessionHistoryTimer = 0;
	/** @type {string[]} */
	let sessionPast = [];
	/** @type {string[]} */
	let sessionFuture = [];
	let sessionCursor = originHtml;

	const topDocument =
		ownerDocument.defaultView?.top?.document || ownerDocument;

	/**
	 * Focus / pointer landed on the floating format toolbar (parent frame) —
	 * must not end the iframe edit session.
	 *
	 * @param {Element|null|undefined} el
	 * @return {boolean}
	 */
	const isFormatChrome = ( el ) => isPreviewEditChromeTarget( el );

	const hasOpenFormatChrome = () => {
		try {
			return Boolean(
				topDocument.querySelector(
					'.bm-preview-format-bar.is-visible, .bm-format-toolbar__color-popover, .bm-format-toolbar__link-popover, .bm-format-toolbar__fonts-popover, .bm-format-toolbar__select-menu'
				)
			);
		} catch ( e ) {
			return false;
		}
	};

	const readCleaned = () => {
		const raw = ( target.innerHTML || '' ).replace( /\u200B/g, '' );
		return isPlainSurface
			? stripToPlainText( raw )
			: sanitizeAllowedHtml( raw, commands );
	};

	const applyPatch = ( cleaned, { persistent = true } = {} ) => {
		if ( contentEditCommitEpoch !== commitEpochAtStart ) {
			return;
		}
		if (
			collection &&
			Date.now() < blockCollectionContentWritesUntil
		) {
			return;
		}
		const btnHit = target.closest?.( '[data-bm-btn-index]' );
		let buttonIndex = opts.focusButtonIndex;
		if ( buttonIndex == null && btnHit ) {
			const parsed = Number( btnHit.getAttribute( 'data-bm-btn-index' ) );
			buttonIndex = Number.isFinite( parsed ) ? parsed : null;
		}
		const patch = buildInlineContentPatch( getAttributes(), {
			key: isButton ? null : opts.selectedElementKey,
			value: cleaned,
			slideIndex: opts.focusSlideIndex,
			cardIndex: opts.focusCardIndex,
			buttonIndex,
			collectionKey: collection,
		} );
		if ( patch && Object.keys( patch ).length ) {
			if ( ! persistent && typeof opts.markEphemeral === 'function' ) {
				opts.markEphemeral();
			}
			opts.setAttributes( patch );
		}
	};

	const writeSurface = ( html ) => {
		if ( isPlainSurface ) {
			target.textContent = html;
		} else {
			target.innerHTML = html;
		}
	};

	const pushSessionHistory = () => {
		const cleaned = readCleaned();
		if ( cleaned === sessionCursor ) {
			return;
		}
		sessionPast.push( sessionCursor );
		if ( sessionPast.length > 80 ) {
			sessionPast.shift();
		}
		sessionCursor = cleaned;
		sessionFuture = [];
	};

	const scheduleSessionHistory = () => {
		if ( sessionHistoryTimer ) {
			window.clearTimeout( sessionHistoryTimer );
		}
		sessionHistoryTimer = window.setTimeout( () => {
			sessionHistoryTimer = 0;
			pushSessionHistory();
		}, 280 );
	};

	const undoSession = () => {
		if ( sessionHistoryTimer ) {
			window.clearTimeout( sessionHistoryTimer );
			sessionHistoryTimer = 0;
			pushSessionHistory();
		}
		if ( ! sessionPast.length ) {
			if ( sessionCursor === originHtml ) {
				return typeof opts.onDocumentUndo === 'function'
					? Boolean( opts.onDocumentUndo() )
					: false;
			}
			sessionFuture.push( sessionCursor );
			sessionCursor = originHtml;
			writeSurface( originHtml );
			applyPatch( readCleaned(), { persistent: false } );
			return true;
		}
		sessionFuture.push( sessionCursor );
		sessionCursor = sessionPast.pop();
		writeSurface( sessionCursor );
		applyPatch( readCleaned(), { persistent: false } );
		return true;
	};

	const redoSession = () => {
		if ( sessionHistoryTimer ) {
			window.clearTimeout( sessionHistoryTimer );
			sessionHistoryTimer = 0;
			pushSessionHistory();
		}
		if ( ! sessionFuture.length ) {
			return typeof opts.onDocumentRedo === 'function'
				? Boolean( opts.onDocumentRedo() )
				: false;
		}
		sessionPast.push( sessionCursor );
		sessionCursor = sessionFuture.pop();
		writeSurface( sessionCursor );
		applyPatch( readCleaned(), { persistent: false } );
		return true;
	};

	/** Push live HTML to attributes without ending the session. */
	const sync = () => {
		if ( cancelled || contentEditCommitEpoch !== commitEpochAtStart ) {
			return;
		}
		applyPatch( readCleaned(), { persistent: false } );
	};

	const scheduleSync = () => {
		if ( syncTimer ) {
			window.clearTimeout( syncTimer );
		}
		syncTimer = window.setTimeout( () => {
			syncTimer = 0;
			sync();
		}, 80 );
	};

	const commit = () => {
		if (
			cancelled ||
			committed ||
			contentEditCommitEpoch !== commitEpochAtStart
		) {
			return;
		}
		committed = true;
		if ( syncTimer ) {
			window.clearTimeout( syncTimer );
			syncTimer = 0;
		}
		if ( sessionHistoryTimer ) {
			window.clearTimeout( sessionHistoryTimer );
			sessionHistoryTimer = 0;
		}
		const cleaned = readCleaned();
		if ( ! isPlainSurface && target.innerHTML !== cleaned ) {
			target.innerHTML = cleaned;
		} else if (
			isPlainSurface &&
			( target.textContent || '' ) !== cleaned
		) {
			target.textContent = cleaned;
		}
		applyPatch( cleaned, { persistent: true } );
		if ( typeof opts.onSessionCommit === 'function' ) {
			try {
				opts.onSessionCommit( originAttrs, cloneState( getAttributes() || {} ) );
			} catch ( e ) {
				// ignore
			}
		}
	};

	const session = {
		target,
		field,
		commands,
		isButton,
		sync,
		scheduleSync,
		undo: undoSession,
		redo: redoSession,
		suppressBlur: () => {
			suppressBlur = true;
			opts.onEditSurfaceActive?.( true );
		},
		lockBlur: () => {
			blurLocks += 1;
			opts.onEditSurfaceActive?.( true );
		},
		unlockBlur: () => {
			blurLocks = Math.max( 0, blurLocks - 1 );
			// Never unfreeze here — closing a color/link popover must not remount
			// the preview. Session ends only via onBlur / outside pointerdown.
			if ( blurLocks === 0 ) {
				scheduleSync();
			}
		},
	};
	opts.onSessionStart?.( session );

	target.setAttribute( 'contenteditable', 'true' );
	target.setAttribute( 'data-bm-content-edit', '1' );
	target.classList.add( 'bm-preview-content-edit' );
	target.setAttribute( 'spellcheck', 'true' );

	const cancel = () => {
		cancelled = true;
		target.innerHTML = originHtml;
	};

	const onFocus = () => {
		opts.onEditSurfaceActive?.( true );
	};

	const onBlur = () => {
		if ( blurTimer ) {
			window.clearTimeout( blurTimer );
		}
		// Longer delay so nested color Dropdown / ColorPicker can take focus
		// without ending the edit session.
		blurTimer = window.setTimeout( () => {
			blurTimer = 0;
			if ( blurLocks > 0 || suppressBlur ) {
				suppressBlur = false;
				// Defer attr sync so toolbar selection restore can finish first.
				scheduleSync();
				return;
			}
			const active =
				topDocument.activeElement || ownerDocument.activeElement;
			if (
				active === target ||
				isFormatChrome( active ) ||
				hasOpenFormatChrome()
			) {
				scheduleSync();
				return;
			}
			opts.onEditSurfaceActive?.( false );
			if ( ! cancelled ) {
				commit();
			}
		}, 200 );
	};

	const onInput = () => {
		// Allow further syncs after a blur commit while the same session stays open.
		committed = false;
		opts.onEditSurfaceActive?.( true );
		scheduleSync();
		scheduleSessionHistory();
	};

	const onKeyDown = ( event ) => {
		if ( isUndoKeyEvent( event ) ) {
			event.preventDefault();
			event.stopPropagation();
			undoSession();
			return;
		}
		if ( isRedoKeyEvent( event ) ) {
			event.preventDefault();
			event.stopPropagation();
			redoSession();
			return;
		}
		if ( event.key === 'Escape' ) {
			event.preventDefault();
			event.stopPropagation();
			cancel();
			target.blur();
			return;
		}
		if ( ! isPlainSurface && handleRichTextBackspace( event, ownerDocument ) ) {
			scheduleSync();
			scheduleSessionHistory();
			return;
		}
		if ( event.key !== 'Enter' ) {
			return;
		}
		// Plain / button fields: Enter commits.
		if ( isPlainSurface ) {
			event.preventDefault();
			target.blur();
			return;
		}
		// Lists: Enter → new <li>; Shift+Enter / non-list → <br> (never <p>/<div>).
		if ( handleRichTextEnter( event, ownerDocument ) ) {
			scheduleSync();
			scheduleSessionHistory();
		}
	};

	const onPaste = ( event ) => {
		event.preventDefault();
		event.stopPropagation();
		const html = event.clipboardData?.getData( 'text/html' ) || '';
		const plain = event.clipboardData?.getData( 'text/plain' ) || '';
		const cleaned =
			isPlainSurface
				? stripToPlainText( plain || html )
				: sanitizePasteHtml( html || plain, commands );
		if ( cleaned ) {
			ownerDocument.execCommand(
				isPlainSurface ? 'insertText' : 'insertHTML',
				false,
				cleaned
			);
		}
		scheduleSync();
		scheduleSessionHistory();
	};

	const onDrop = ( event ) => {
		const types = Array.from( event.dataTransfer?.types || [] );
		if ( types.includes( 'Files' ) || event.dataTransfer?.files?.length ) {
			event.preventDefault();
			event.stopPropagation();
		}
	};

	const onDragOver = ( event ) => {
		const types = Array.from( event.dataTransfer?.types || [] );
		if ( types.includes( 'Files' ) ) {
			event.preventDefault();
			event.dataTransfer.dropEffect = 'none';
		}
	};

	const onClick = ( event ) => {
		event.stopPropagation();
	};

	target.addEventListener( 'focus', onFocus );
	target.addEventListener( 'blur', onBlur );
	target.addEventListener( 'input', onInput );
	// Capture so list Enter wins over other handlers that force <br>.
	target.addEventListener( 'keydown', onKeyDown, true );
	target.addEventListener( 'paste', onPaste );
	target.addEventListener( 'drop', onDrop );
	target.addEventListener( 'dragover', onDragOver );
	target.addEventListener( 'click', onClick );

	const autoFocus = opts.autoFocus !== false;
	window.requestAnimationFrame( () => {
		try {
			originHtml = target.innerHTML;
			sessionCursor = originHtml;
			sessionPast = [];
			sessionFuture = [];
			committed = false;
			cancelled = false;
			if ( autoFocus && ownerDocument.activeElement !== target ) {
				target.focus( { preventScroll: true } );
			}
		} catch ( e ) {
			// ignore
		}
	} );

	return () => {
		if ( blurTimer ) {
			window.clearTimeout( blurTimer );
		}
		if ( syncTimer ) {
			window.clearTimeout( syncTimer );
		}
		if ( sessionHistoryTimer ) {
			window.clearTimeout( sessionHistoryTimer );
		}
		target.removeEventListener( 'focus', onFocus );
		target.removeEventListener( 'blur', onBlur );
		target.removeEventListener( 'input', onInput );
		target.removeEventListener( 'keydown', onKeyDown, true );
		target.removeEventListener( 'paste', onPaste );
		target.removeEventListener( 'drop', onDrop );
		target.removeEventListener( 'dragover', onDragOver );
		target.removeEventListener( 'click', onClick );
		// Flush on teardown unless the link / fields popover suppressed it —
		// otherwise a stale contenteditable label overwrites the draft.
		const commitSuppressed =
			contentEditCommitEpoch !== commitEpochAtStart;
		if ( ! committed && ! cancelled && ! commitSuppressed ) {
			if ( isButton ) {
				const attrs = getAttributes() || {};
				const bi =
					opts.focusButtonIndex != null
						? Number( opts.focusButtonIndex )
						: Number(
								target
									.closest?.( '[data-bm-btn-index]' )
									?.getAttribute( 'data-bm-btn-index' )
						  );
				let list = attrs.buttons;
				if (
					opts.focusSlideIndex != null &&
					Array.isArray( attrs.slides )
				) {
					list =
						attrs.slides[ Number( opts.focusSlideIndex ) ]
							?.buttons;
				} else if (
					opts.focusCardIndex != null &&
					collection &&
					Array.isArray( attrs[ collection ] )
				) {
					list =
						attrs[ collection ][ Number( opts.focusCardIndex ) ]
							?.buttons;
				}
				const attrTitle = stripToPlainText(
					String(
						Array.isArray( list ) && Number.isFinite( bi )
							? list[ bi ]?.link?.title ?? ''
							: ''
					)
				);
				const cleaned = readCleaned();
				const originPlain = stripToPlainText( originHtml );
				// Attributes already moved away from this surface (Edit link).
				if (
					attrTitle !== originPlain &&
					attrTitle !== cleaned
				) {
					// leave attributes alone
				} else {
					commit();
				}
			} else {
				commit();
			}
		}
		target.removeAttribute( 'contenteditable' );
		target.removeAttribute( 'data-bm-content-edit' );
		target.classList.remove( 'bm-preview-content-edit' );
		opts.onEditSurfaceActive?.( false );
		opts.onSessionEnd?.( session );
	};
}

/** Injected into preview / Gutenberg selection CSS. */
export const PREVIEW_CONTENT_EDIT_CSS = `
[data-bm-content-edit="1"],
.bm-preview-content-edit{
	cursor:text!important;
	outline:2px solid rgba(53,37,205,.55)!important;
	outline-offset:2px;
	min-width:0.5ch;
}
[data-bm-content-edit="1"]:focus,
.bm-preview-content-edit:focus{
	outline-color:rgba(53,37,205,.9)!important;
}
`;
