import { __, _n, sprintf } from '@wordpress/i18n';

import './recording/editor-recording.scss';

type NativeRecordingData =
	| SourceRecordingData
	| WorkspaceRecordingData
	| ClassicTitleActionData;

type SourceRecordingData = {
	mode: 'source';
	buttonLabel: string;
	recordUrl: string;
};

type WorkspaceRecordingData = {
	mode: 'workspace';
	sessionUuid: string;
	postId: number;
	postType: string;
	restUrl: string;
	restNonce: string;
	sourceTitle: string;
	sourceEditUrl: string;
	launchesUrl: string;
	defaultDate: string;
	defaultTime: string;
	timezone: string;
};

type ClassicTitleActionData = {
	mode: 'classicTitleAction';
	buttonLabel: string;
	recordUrl: string;
	postType: string;
	submitReview: boolean;
	afterSaveFieldName: string;
	savingLabel: string;
};

type CaptureResponse = {
	changeCount?: number;
	selectedCount?: number;
	warnings?: Array< { blocking?: boolean; message?: string } >;
};

type FinalizeResponse = {
	launchUuid: string;
	launchId: number;
	versionId: number;
	selectedChangeCount: number;
	actionId?: number;
	scheduledAtUtc?: string;
};

type CancelResponse = {
	uuid: string;
	status: string;
};

type RecordingModalElement = HTMLElement & {
	scheduledChangesReleaseFocusTrap?: () => void;
};

type WordPressEditorApi = {
	data?: {
		dispatch?: ( store: string ) => {
			savePost?: () => Promise< unknown > | unknown;
		};
		select?: ( store: string ) => {
			getEditedPostAttribute?: ( name: string ) => unknown;
			isEditedPostDirty?: () => boolean;
			isSavingPost?: () => boolean;
		};
		subscribe?: ( listener: () => void ) => () => void;
	};
};

declare global {
	interface Window {
		scheduledChangesNativeRecording?: NativeRecordingData;
		wp?: WordPressEditorApi;
	}
}

const data = window.scheduledChangesNativeRecording;

if ( data?.mode === 'source' ) {
	insertSourceButton( data );
}

if ( data?.mode === 'workspace' ) {
	setupWorkspaceRecording( data );
}

if ( data?.mode === 'classicTitleAction' ) {
	insertClassicTitleAction( data );
}

function insertSourceButton( config: SourceRecordingData ) {
	const button = toolbarButton(
		config.buttonLabel || __( 'Record', 'scheduled-changes' )
	);
	button.addEventListener( 'click', async () => {
		if ( isControlBusy( button ) ) {
			return;
		}

		setControlBusy( button, true );
		const overlay = showRecordingStartOverlay();

		try {
			await overlay.countdown;
			overlay.setStarting();
			window.location.assign( config.recordUrl );
		} catch ( error ) {
			overlay.remove();
			setControlBusy( button, false );
			setRecordingStatus( errorMessage( error ), true );
		}
	} );

	placeToolbarButton( button );
}

function setupWorkspaceRecording( config: WorkspaceRecordingData ) {
	document.body.classList.add( 'scheduled-changes-recording-active' );

	const saveButton = toolbarButton(
		__( 'Save recording', 'scheduled-changes' )
	);
	saveButton.classList.add( 'scheduled-changes-recording-save-control' );
	const railSave = addRecordingRail( config, () => saveAndOpenModal() );
	const controls = [ saveButton, railSave ].filter(
		( control ): control is HTMLButtonElement => Boolean( control )
	);
	let busy = false;
	let modalOpen = false;
	let restoreToolbar: ( () => void ) | null = null;

	const syncDisabled = () => {
		const disabled = busy || modalOpen || ! isEditorDirty();

		controls.forEach( ( control ) => {
			control.disabled = disabled;
			control.classList.toggle( 'is-disabled', disabled );
			control.setAttribute(
				'aria-disabled',
				disabled ? 'true' : 'false'
			);
			control.title =
				disabled && ! busy && ! modalOpen
					? __(
							'Make a change before saving the recording.',
							'scheduled-changes'
					  )
					: '';
		} );
	};

	const unsubscribe = window.wp?.data?.subscribe?.( syncDisabled );
	const cleanup = () => {
		document.body.classList.remove( 'scheduled-changes-recording-active' );
		restoreToolbar?.();
		restoreToolbar = null;
		unsubscribe?.();
	};

	const saveAndOpenModal = async () => {
		if ( busy ) {
			return;
		}

		if ( ! isEditorDirty() ) {
			setRecordingStatus(
				__(
					'Make a change before saving the recording.',
					'scheduled-changes'
				)
			);
			syncDisabled();
			return;
		}

		busy = true;
		syncDisabled();

		try {
			setRecordingStatus(
				__(
					'Saving safe copy. The live item is not being changed.',
					'scheduled-changes'
				)
			);
			await saveEditedPost();
			const capture = await captureRecording( config );
			const selected = Number(
				capture.selectedCount ?? capture.changeCount ?? 0
			);

			if ( selected <= 0 ) {
				throw new Error(
					__(
						'Make a change before saving the recording.',
						'scheduled-changes'
					)
				);
			}

			setRecordingStatus(
				__(
					'Safe copy saved. Review the changes and choose when they should go live.',
					'scheduled-changes'
				)
			);
			modalOpen = true;
			showScheduleModal(
				config,
				{
					selectedChangeCount: selected,
				},
				() => {
					modalOpen = false;
					cleanup();
					syncDisabled();
				},
				() => {
					modalOpen = false;
					syncDisabled();
				}
			);
		} catch ( error ) {
			setRecordingStatus( recordingFailureMessage( error ), true );
		} finally {
			busy = false;
			syncDisabled();
		}
	};

	saveButton.addEventListener( 'click', () => {
		void saveAndOpenModal();
	} );

	restoreToolbar = syncWorkspaceToolbar( saveButton );
	setRecordingStatus(
		sprintf(
			/* translators: %s: source item title. */
			__(
				'Safe copy open for %s. The live item is not being changed.',
				'scheduled-changes'
			),
			config.sourceTitle || __( 'this item', 'scheduled-changes' )
		)
	);
	syncDisabled();
}

function insertClassicTitleAction( config: ClassicTitleActionData ) {
	if ( document.querySelector( '.scheduled-changes-record-title-action' ) ) {
		return;
	}

	const button = document.createElement( 'a' );
	button.className =
		'page-title-action scheduled-changes-record-title-action';
	button.href = config.recordUrl;
	button.textContent = config.buttonLabel;

	if ( config.submitReview ) {
		button.addEventListener( 'click', ( event ) => {
			event.preventDefault();

			if ( button.getAttribute( 'aria-disabled' ) === 'true' ) {
				return;
			}

			const form = document.getElementById(
				'post'
			) as HTMLFormElement | null;

			if ( ! form ) {
				return;
			}

			button.setAttribute( 'aria-disabled', 'true' );
			button.textContent =
				config.savingLabel ||
				__( 'Saving safe copy', 'scheduled-changes' );

			let action = form.querySelector< HTMLInputElement >(
				`input[name="${ cssEscape( config.afterSaveFieldName ) }"]`
			);

			if ( ! action ) {
				action = document.createElement( 'input' );
				action.type = 'hidden';
				action.name = config.afterSaveFieldName;
				form.appendChild( action );
			}

			action.value = 'review';

			if ( typeof form.requestSubmit === 'function' ) {
				form.requestSubmit();
				return;
			}

			form.submit();
		} );
	}

	const addNew =
		Array.from(
			document.querySelectorAll< HTMLAnchorElement >(
				'.wrap .page-title-action[href*="post-new.php"]'
			)
		).find( ( link ) => matchesPostTypeLink( link, config.postType ) ) ||
		document.querySelector< HTMLAnchorElement >(
			'.wrap .page-title-action[href*="post-new.php"]'
		);
	if ( addNew ) {
		addNew.insertAdjacentElement( 'afterend', button );
		return;
	}

	const heading = document.querySelector( '.wrap h1' );
	heading?.appendChild( button );
}

function matchesPostTypeLink( link: HTMLAnchorElement, postType: string ) {
	try {
		const url = new URL( link.href, window.location.href );
		const type = url.searchParams.get( 'post_type' );
		return type === postType || ( postType === 'post' && ! type );
	} catch {
		return link.href.includes(
			`post_type=${ encodeURIComponent( postType ) }`
		);
	}
}

function cssEscape( value: string ) {
	if ( typeof window.CSS?.escape === 'function' ) {
		return window.CSS.escape( value );
	}

	return value.replace( /["\\]/g, '\\$&' );
}

function toolbarButton( label: string ): HTMLButtonElement {
	const button = document.createElement( 'button' );
	button.type = 'button';
	button.className =
		'components-button is-primary scheduled-changes-record-toolbar-button';
	button.textContent = label;
	return button;
}

function placeToolbarButton( button: HTMLButtonElement ) {
	if ( tryPlaceToolbarButton( button ) ) {
		return;
	}

	const observer = new MutationObserver( () => {
		if ( tryPlaceToolbarButton( button ) ) {
			observer.disconnect();
		}
	} );
	observer.observe( document.body, { childList: true, subtree: true } );
}

function tryPlaceToolbarButton( button: HTMLButtonElement ) {
	if (
		button.isConnected &&
		button.closest( '.edit-post-header, .editor-header' )
	) {
		return true;
	}

	const publish = findPublishButton();

	if ( ! publish ) {
		return false;
	}

	publish.insertAdjacentElement( 'afterend', button );
	return true;
}

function syncWorkspaceToolbar( saveButton: HTMLButtonElement ): () => void {
	const sync = () => {
		tryPlaceToolbarButton( saveButton );
		hideNativePublishControls();
	};

	sync();

	const observer = new MutationObserver( sync );
	observer.observe( document.body, { childList: true, subtree: true } );

	return () => {
		observer.disconnect();
		restoreNativePublishControls();
	};
}

function findPublishButton(): HTMLButtonElement | null {
	const classMatch = document.querySelector< HTMLButtonElement >(
		'.editor-post-publish-button__button, .editor-post-publish-panel__toggle'
	);

	if ( classMatch ) {
		return classMatch;
	}

	const buttons = Array.from(
		document.querySelectorAll< HTMLButtonElement >(
			'.edit-post-header button.components-button, .editor-header button.components-button'
		)
	);

	return (
		buttons.find( ( button ) =>
			/^(Publish|Update|Schedule)$/i.test(
				( button.textContent || '' ).trim()
			)
		) || null
	);
}

function nativePublishControls(): HTMLButtonElement[] {
	const controls = [
		...document.querySelectorAll< HTMLButtonElement >(
			'.editor-post-publish-button__button, .editor-post-publish-panel__toggle'
		),
		...Array.from(
			document.querySelectorAll< HTMLButtonElement >(
				'.edit-post-header button.components-button, .editor-header button.components-button'
			)
		).filter( ( button ) =>
			/^(Publish|Update|Schedule)$/i.test(
				( button.textContent || '' ).trim()
			)
		),
	].filter(
		( button ) =>
			! button.classList.contains(
				'scheduled-changes-record-toolbar-button'
			)
	);

	return Array.from( new Set( controls ) );
}

function hideNativePublishControls() {
	nativePublishControls().forEach( ( button ) => {
		if ( ! button.dataset.scheduledChangesOriginalTabIndex ) {
			button.dataset.scheduledChangesOriginalTabIndex =
				button.getAttribute( 'tabindex' ) ?? '';
		}

		button.classList.add( 'scheduled-changes-native-publish-hidden' );
		button.setAttribute( 'aria-hidden', 'true' );
		button.tabIndex = -1;
	} );
}

function restoreNativePublishControls() {
	document
		.querySelectorAll< HTMLButtonElement >(
			'.scheduled-changes-native-publish-hidden'
		)
		.forEach( ( button ) => {
			button.classList.remove(
				'scheduled-changes-native-publish-hidden'
			);
			button.removeAttribute( 'aria-hidden' );

			const originalTabIndex =
				button.dataset.scheduledChangesOriginalTabIndex;

			if ( undefined === originalTabIndex || '' === originalTabIndex ) {
				button.removeAttribute( 'tabindex' );
			} else {
				button.setAttribute( 'tabindex', originalTabIndex );
			}

			delete button.dataset.scheduledChangesOriginalTabIndex;
		} );
}

function addRecordingRail(
	config: WorkspaceRecordingData,
	onSaveReview: () => Promise< void >
): HTMLButtonElement {
	document
		.querySelector( '.scheduled-changes-recording-editor-rail' )
		?.remove();

	const rail = document.createElement( 'section' );
	rail.className = 'scheduled-changes-recording-editor-rail';
	rail.setAttribute( 'role', 'region' );
	rail.setAttribute(
		'aria-label',
		__( 'Recording controls', 'scheduled-changes' )
	);

	const railInner = document.createElement( 'div' );
	railInner.className = 'scheduled-changes-recording-editor-rail__inner';

	const status = document.createElement( 'div' );
	status.className =
		'scheduled-changes-recording-editor-status scheduled-changes-recording-editor-rail__status';
	status.setAttribute( 'role', 'status' );
	status.setAttribute( 'aria-live', 'polite' );
	status.setAttribute( 'aria-atomic', 'true' );
	const statusMessage = document.createElement( 'p' );
	statusMessage.textContent = sprintf(
		/* translators: %s: source item title. */
		__(
			'Safe copy open for %s. The live item is not being changed.',
			'scheduled-changes'
		),
		config.sourceTitle || __( 'this item', 'scheduled-changes' )
	);
	status.appendChild( statusMessage );

	const actions = document.createElement( 'div' );
	actions.className = 'scheduled-changes-recording-editor-rail__actions';

	const save = document.createElement( 'button' );
	save.type = 'button';
	save.className = 'btn btn-primary scheduled-changes-recording-save-control';
	save.textContent = __( 'Save recording', 'scheduled-changes' );
	save.addEventListener( 'click', () => {
		void onSaveReview();
	} );

	const cancel = document.createElement( 'button' );
	cancel.type = 'button';
	cancel.className = 'btn btn-danger';
	cancel.textContent = __( 'Cancel recording', 'scheduled-changes' );
	cancel.addEventListener( 'click', async () => {
		if ( cancel.disabled ) {
			return;
		}

		if ( ! ( await confirmCancelRecording() ) ) {
			return;
		}

		cancel.disabled = true;
		setRecordingStatus( __( 'Canceling recording.', 'scheduled-changes' ) );

		try {
			await cancelRecording( config );
			window.location.assign( config.sourceEditUrl );
		} catch ( error ) {
			cancel.disabled = false;
			setRecordingStatus( cancelFailureMessage( error ), true );
		}
	} );

	actions.append( save, cancel );
	railInner.append( status, actions );
	rail.appendChild( railInner );
	document.body.appendChild( rail );

	return save;
}

function showRecordingStartOverlay( seconds = 5 ): {
	countdown: Promise< void >;
	setStarting: () => void;
	remove: () => void;
} {
	document
		.querySelector( '.scheduled-changes-recording-start-overlay' )
		?.remove();

	const overlay = document.createElement( 'div' );
	overlay.className = 'scheduled-changes-recording-start-overlay';
	overlay.setAttribute( 'role', 'dialog' );
	overlay.setAttribute( 'aria-modal', 'true' );
	overlay.setAttribute(
		'aria-labelledby',
		'scheduled-changes-recording-start-overlay-title'
	);

	const panel = document.createElement( 'div' );
	panel.className = 'modal scheduled-changes-recording-start-overlay__panel';
	panel.tabIndex = -1;

	const body = document.createElement( 'div' );
	body.className = 'modal-body';

	const ring = document.createElement( 'div' );
	ring.className = 'ring';
	ring.setAttribute( 'aria-hidden', 'true' );
	ring.textContent = 'REC';

	const title = document.createElement( 'h3' );
	title.id = 'scheduled-changes-recording-start-overlay-title';
	title.textContent = __( 'Preparing your recording', 'scheduled-changes' );

	const message = document.createElement( 'p' );
	message.textContent = __(
		'You are about to edit a temporary copy. Saving stops recording - your original stays untouched.',
		'scheduled-changes'
	);

	const countdown = document.createElement( 'div' );
	countdown.className =
		'scheduled-changes-recording-start-overlay__countdown countdown';
	countdown.setAttribute( 'aria-live', 'polite' );
	countdown.style.marginTop = 'var(--s-4)';

	const countdownNote = document.createElement( 'p' );
	countdownNote.className = 'token-note';

	body.append( ring, title, message, countdown, countdownNote );
	panel.append( body );
	overlay.appendChild( panel );
	document.body.appendChild( overlay );
	document.body.classList.add(
		'scheduled-changes-recording-start-overlay-open'
	);
	const releaseFocusTrap = trapModalFocus( overlay, panel );

	let remaining = Math.max( 0, Math.floor( seconds ) );
	let intervalId: number | undefined;
	let removed = false;
	let resolveCountdown = () => undefined;
	const countdownPromise = new Promise< void >( ( resolve ) => {
		resolveCountdown = resolve;
	} );
	const renderCountdown = () => {
		countdown.textContent = String( remaining );
		countdown.setAttribute(
			'aria-label',
			sprintf(
				/* translators: %d: number of seconds before recording starts. */
				_n( '%d second', '%d seconds', remaining, 'scheduled-changes' ),
				remaining
			)
		);
		countdownNote.textContent = sprintf(
			/* translators: %d: number of seconds before recording starts. */
			_n(
				'Opening the hidden copy in %d second.',
				'Opening the hidden copy in %d seconds.',
				remaining,
				'scheduled-changes'
			),
			remaining
		);
	};

	renderCountdown();

	if ( remaining === 0 ) {
		resolveCountdown();
	} else {
		intervalId = window.setInterval( () => {
			remaining -= 1;

			if ( remaining <= 0 ) {
				remaining = 0;
				renderCountdown();
				if ( intervalId !== undefined ) {
					window.clearInterval( intervalId );
					intervalId = undefined;
				}
				resolveCountdown();
				return;
			}

			renderCountdown();
		}, 1000 );
	}

	return {
		countdown: countdownPromise,
		setStarting: () => {
			countdown.textContent = '';
			countdown.removeAttribute( 'aria-label' );
			countdownNote.textContent = __(
				'Opening the hidden copy…',
				'scheduled-changes'
			);
		},
		remove: () => {
			if ( removed ) {
				return;
			}

			removed = true;

			if ( intervalId !== undefined ) {
				window.clearInterval( intervalId );
			}

			releaseFocusTrap();
			overlay.remove();
			document.body.classList.remove(
				'scheduled-changes-recording-start-overlay-open'
			);
		},
	};
}

function showScheduleModal(
	config: WorkspaceRecordingData,
	capture: { selectedChangeCount: number },
	onScheduled: () => void,
	onClose: () => void
) {
	document.querySelector( '.scheduled-changes-recording-modal' )?.remove();

	const modal = document.createElement( 'div' ) as RecordingModalElement;
	modal.className = 'scheduled-changes-recording-modal';
	modal.setAttribute( 'role', 'dialog' );
	modal.setAttribute( 'aria-modal', 'true' );
	modal.setAttribute(
		'aria-labelledby',
		'scheduled-changes-recording-modal-title'
	);
	modal.setAttribute(
		'aria-describedby',
		'scheduled-changes-recording-modal-summary'
	);

	const panel = document.createElement( 'div' );
	panel.className = 'modal scheduled-changes-recording-modal__panel';

	const body = document.createElement( 'div' );
	body.className = 'modal-body';
	body.style.textAlign = 'left';

	const title = document.createElement( 'h3' );
	title.id = 'scheduled-changes-recording-modal-title';
	title.style.textAlign = 'center';
	title.textContent = __(
		'When should this change go live?',
		'scheduled-changes'
	);

	const summary = document.createElement( 'p' );
	summary.id = 'scheduled-changes-recording-modal-summary';
	summary.className = 'scheduled-changes-recording-modal__summary';
	summary.style.textAlign = 'center';
	summary.textContent = sprintf(
		/* translators: %1$d: selected change count. */
		_n(
			'%1$d recorded change ready. Your original has not changed yet.',
			'%1$d recorded changes ready. Your original has not changed yet.',
			capture.selectedChangeCount,
			'scheduled-changes'
		),
		capture.selectedChangeCount
	);

	const form = document.createElement( 'form' );
	form.id = 'scheduled-changes-recording-modal-form';
	form.className = 'scheduled-changes-recording-modal__form modal-fields';

	const dateLabel = labeledInput(
		__( 'Go-live date', 'scheduled-changes' ),
		'date',
		'scheduled-changes-recording-go-live-date',
		config.defaultDate
	);
	const timeLabel = labeledInput(
		__( 'Go-live time', 'scheduled-changes' ),
		'time',
		'scheduled-changes-recording-go-live-time',
		config.defaultTime
	);
	const timezonePillWrap = document.createElement( 'div' );
	timezonePillWrap.className = 'scheduled-changes-recording-modal__timezone';
	timezonePillWrap.style.textAlign = 'center';

	const timezonePill = document.createElement( 'span' );
	timezonePill.className = 'tz-pill';
	timezonePill.textContent = sprintf(
		/* translators: %s: timezone name. */
		__( '%s - your site timezone', 'scheduled-changes' ),
		config.timezone
	);
	timezonePillWrap.append( timezonePill );

	const status = document.createElement( 'p' );
	status.className = 'scheduled-changes-recording-modal__status';
	status.setAttribute( 'role', 'status' );
	status.setAttribute( 'aria-live', 'polite' );
	status.setAttribute( 'aria-atomic', 'true' );

	const actions = document.createElement( 'div' );
	actions.className = 'scheduled-changes-recording-modal__actions modal-foot';

	const submit = document.createElement( 'button' );
	submit.type = 'submit';
	submit.setAttribute( 'form', form.id );
	submit.className = 'button button-primary btn btn-primary';
	submit.textContent = __( 'Schedule change', 'scheduled-changes' );

	const cancel = document.createElement( 'button' );
	cancel.type = 'button';
	cancel.className = 'button btn btn-secondary';
	cancel.textContent = __( 'Cancel recording', 'scheduled-changes' );

	actions.append( submit, cancel );
	form.append( dateLabel, timeLabel, timezonePillWrap, status );
	body.append( title, summary, form );
	panel.append( body, actions );
	modal.appendChild( panel );
	document.body.appendChild( modal );

	const dateInput = form.querySelector< HTMLInputElement >(
		'#scheduled-changes-recording-go-live-date'
	);
	const timeInput = form.querySelector< HTMLInputElement >(
		'#scheduled-changes-recording-go-live-time'
	);

	modal.scheduledChangesReleaseFocusTrap = trapModalFocus(
		modal,
		dateInput || panel
	);

	const close = () => {
		modal.scheduledChangesReleaseFocusTrap?.();
		modal.remove();
		onClose();
	};

	cancel.addEventListener( 'click', async () => {
		if ( submit.disabled || cancel.disabled ) {
			return;
		}

		if ( ! ( await confirmCancelRecording() ) ) {
			return;
		}

		submit.disabled = true;
		cancel.disabled = true;
		status.textContent = __( 'Canceling recording.', 'scheduled-changes' );

		try {
			await cancelRecording( config );
			window.location.assign( config.sourceEditUrl );
		} catch ( error ) {
			submit.disabled = false;
			cancel.disabled = false;
			status.textContent = cancelFailureMessage( error );
			status.classList.add( 'is-error' );
		}
	} );

	form.addEventListener( 'submit', async ( event ) => {
		event.preventDefault();

		if ( submit.disabled || cancel.disabled ) {
			return;
		}

		if ( ! dateInput?.value || ! timeInput?.value ) {
			status.textContent = __(
				'Choose a date and time.',
				'scheduled-changes'
			);
			status.classList.add( 'is-error' );
			return;
		}

		if (
			isPastSchedule( dateInput.value, timeInput.value, config.timezone )
		) {
			status.textContent = __(
				'Choose a future date and time.',
				'scheduled-changes'
			);
			status.classList.add( 'is-error' );
			return;
		}

		submit.disabled = true;
		cancel.disabled = true;
		status.classList.remove( 'is-error' );
		status.textContent = __(
			'Scheduling this change.',
			'scheduled-changes'
		);

		try {
			const finalized = await finalizeRecording(
				config,
				dateInput.value,
				timeInput.value
			);
			showScheduledResult(
				panel,
				config.launchesUrl,
				config.sourceEditUrl,
				dateInput.value,
				timeInput.value,
				config.timezone,
				finalized
			);
			setRecordingStatus( __( 'Changes saved.', 'scheduled-changes' ) );
			onScheduled();
		} catch ( error ) {
			submit.disabled = false;
			cancel.disabled = false;
			status.textContent = scheduleFailureMessage( error );
			status.classList.add( 'is-error' );
		}
	} );

	modal.addEventListener( 'click', ( event ) => {
		if ( event.target === modal ) {
			close();
		}
	} );
}

function labeledInput(
	labelText: string,
	type: string,
	id: string,
	value: string
) {
	const fieldGroup = document.createElement( 'div' );
	fieldGroup.className = 'scheduled-changes-recording-modal__field field-grp';

	const label = document.createElement( 'label' );
	label.setAttribute( 'for', id );
	label.textContent = labelText;

	const input = document.createElement( 'input' );
	input.id = id;
	input.type = type;
	input.required = true;
	input.value = value;
	input.className = 'input';

	fieldGroup.append( label, input );

	return fieldGroup;
}

function showScheduledResult(
	panel: HTMLElement,
	launchesUrl: string,
	sourceEditUrl: string,
	date: string,
	time: string,
	timezone: string,
	finalized: FinalizeResponse
) {
	panel.innerHTML = '';
	panel.dataset.scheduledChangesLaunchUuid = finalized.launchUuid;

	const body = document.createElement( 'div' );
	body.className = 'modal-body';

	const ring = document.createElement( 'div' );
	ring.className = 'ring';
	ring.setAttribute( 'aria-hidden', 'true' );
	ring.textContent = 'OK';

	const title = document.createElement( 'h3' );
	title.textContent = __( 'Changes saved', 'scheduled-changes' );

	const message = document.createElement( 'p' );
	message.textContent = sprintf(
		/* translators: 1: go-live date, 2: go-live time, 3: timezone name. */
		__(
			'Your changes will go live on %1$s at %2$s. Times use %3$s.',
			'scheduled-changes'
		),
		date,
		time,
		timezone
	);

	const actions = document.createElement( 'p' );
	actions.className = 'scheduled-changes-recording-modal__actions modal-foot';

	const launches = document.createElement( 'a' );
	launches.className = 'button button-primary btn btn-primary';
	launches.href = launchesUrl;
	launches.textContent = __( 'View scheduled changes', 'scheduled-changes' );

	const source = document.createElement( 'a' );
	source.className = 'button btn btn-secondary';
	source.href = sourceEditUrl;
	source.textContent = __( 'Return to source item', 'scheduled-changes' );

	actions.append( launches, source );
	body.append( ring, title, message );
	panel.append( body, actions );
	launches.focus();
}

function confirmCancelRecording(): Promise< boolean > {
	return new Promise( ( resolve ) => {
		document
			.querySelector( '.scheduled-changes-recording-confirm' )
			?.remove();

		const modal = document.createElement( 'div' );
		modal.className = 'scheduled-changes-recording-confirm';
		modal.setAttribute( 'role', 'dialog' );
		modal.setAttribute( 'aria-modal', 'true' );
		modal.setAttribute(
			'aria-labelledby',
			'scheduled-changes-recording-confirm-title'
		);

		const panel = document.createElement( 'div' );
		panel.className = 'modal scheduled-changes-recording-confirm__panel';

		const body = document.createElement( 'div' );
		body.className = 'modal-body';

		const ring = document.createElement( 'div' );
		ring.className = 'ring';
		ring.setAttribute( 'aria-hidden', 'true' );
		ring.style.background = 'var(--status-failed-soft)';
		ring.textContent = '!';

		const title = document.createElement( 'h3' );
		title.id = 'scheduled-changes-recording-confirm-title';
		title.textContent = __( 'Cancel this safe copy?', 'scheduled-changes' );

		const message = document.createElement( 'p' );
		message.textContent = __(
			'This removes the safe copy. The live item will not be changed.',
			'scheduled-changes'
		);

		const actions = document.createElement( 'div' );
		actions.className =
			'scheduled-changes-recording-confirm__actions modal-foot';

		const keep = document.createElement( 'button' );
		keep.type = 'button';
		keep.className = 'button btn btn-secondary';
		keep.textContent = __( 'Keep editing', 'scheduled-changes' );

		const confirm = document.createElement( 'button' );
		confirm.type = 'button';
		confirm.className = 'button button-primary btn btn-primary';
		confirm.style.background = 'var(--status-failed)';
		confirm.textContent = __(
			'Yes, cancel recording',
			'scheduled-changes'
		);

		let resolved = false;
		const finish = ( confirmed: boolean ) => {
			if ( resolved ) {
				return;
			}

			resolved = true;
			document.removeEventListener( 'keydown', onKeyDown );
			releaseFocusTrap();
			modal.remove();
			resolve( confirmed );
		};
		const onKeyDown = ( event: KeyboardEvent ) => {
			if ( event.key === 'Escape' ) {
				finish( false );
			}
		};

		keep.addEventListener( 'click', () => finish( false ) );
		confirm.addEventListener( 'click', () => finish( true ) );
		modal.addEventListener( 'click', ( event ) => {
			if ( event.target === modal ) {
				finish( false );
			}
		} );
		document.addEventListener( 'keydown', onKeyDown );

		actions.append( keep, confirm );
		body.append( ring, title, message );
		panel.append( body, actions );
		modal.appendChild( panel );
		document.body.appendChild( modal );
		const releaseFocusTrap = trapModalFocus( modal, confirm );
	} );
}

async function saveEditedPost() {
	const savePost = window.wp?.data?.dispatch?.( 'core/editor' ).savePost;

	if ( ! savePost ) {
		throw new Error(
			__(
				'The WordPress editor save action is unavailable.',
				'scheduled-changes'
			)
		);
	}

	await savePost();
	await waitForEditorSave();
}

async function waitForEditorSave() {
	for ( let index = 0; index < 80; index += 1 ) {
		if ( ! isEditorSaving() ) {
			return;
		}

		await delay( 250 );
	}

	throw new Error(
		__( 'The editor did not finish saving in time.', 'scheduled-changes' )
	);
}

function isEditorDirty(): boolean {
	return Boolean(
		window.wp?.data?.select?.( 'core/editor' ).isEditedPostDirty?.()
	);
}

function isEditorSaving(): boolean {
	return Boolean(
		window.wp?.data?.select?.( 'core/editor' ).isSavingPost?.()
	);
}

function delay( ms: number ): Promise< void > {
	return new Promise( ( resolve ) => window.setTimeout( resolve, ms ) );
}

async function captureRecording(
	config: WorkspaceRecordingData
): Promise< CaptureResponse > {
	return apiPost< CaptureResponse >(
		`${ config.restUrl }/capture`,
		config,
		{}
	);
}

async function finalizeRecording(
	config: WorkspaceRecordingData,
	date: string,
	time: string
): Promise< FinalizeResponse > {
	return apiPost< FinalizeResponse >(
		`${ config.restUrl }/finalize`,
		config,
		{
			go_live_date: date,
			go_live_time: time,
			retain_workspace: false,
			acknowledge_unsupported: true,
		}
	);
}

async function cancelRecording(
	config: WorkspaceRecordingData
): Promise< CancelResponse > {
	return apiPost< CancelResponse >(
		`${ config.restUrl }/cancel`,
		config,
		{}
	);
}

async function apiPost< T >(
	url: string,
	config: WorkspaceRecordingData,
	body: Record< string, unknown >
): Promise< T > {
	const response = await fetch( url, {
		method: 'POST',
		body: JSON.stringify( body ),
		credentials: 'same-origin',
		headers: {
			'Content-Type': 'application/json',
			'X-WP-Nonce': config.restNonce,
		},
	} );
	const payload = ( await response.json() ) as
		| T
		| { code?: string; message?: string; data?: { status?: number } };

	if ( ! response.ok ) {
		throw new Error(
			'message' in payload && payload.message
				? payload.message
				: __(
						"JJ's Post and Product Scheduler request failed.",
						'scheduled-changes'
				  )
		);
	}

	return payload as T;
}

function isPastSchedule(
	date: string,
	time: string,
	timezone: string,
	now = new Date()
): boolean {
	if (
		! /^\d{4}-\d{2}-\d{2}$/.test( date ) ||
		! /^\d{2}:\d{2}$/.test( time )
	) {
		return true;
	}

	const formatter = new Intl.DateTimeFormat( 'en-CA', {
		timeZone: timezone || 'UTC',
		year: 'numeric',
		month: '2-digit',
		day: '2-digit',
		hour: '2-digit',
		minute: '2-digit',
		hourCycle: 'h23',
	} );
	const parts = Object.fromEntries(
		formatter
			.formatToParts( now )
			.filter( ( part ) => part.type !== 'literal' )
			.map( ( part ) => [ part.type, part.value ] )
	);
	const current = `${ parts.year }-${ parts.month }-${ parts.day }T${ parts.hour }:${ parts.minute }`;

	return `${ date }T${ time }` <= current;
}

function trapModalFocus(
	modal: HTMLElement,
	initialFocus: HTMLElement
): () => void {
	const ownerDocument = modal.ownerDocument;
	const previousFocus =
		ownerDocument.activeElement instanceof HTMLElement
			? ownerDocument.activeElement
			: null;
	const focusInitial = () => initialFocus.focus();
	const onKeyDown = ( event: KeyboardEvent ) => {
		if ( event.key !== 'Tab' ) {
			return;
		}

		if (
			ownerDocument.activeElement instanceof HTMLElement &&
			! modal.contains( ownerDocument.activeElement )
		) {
			return;
		}

		const focusable = focusableElements( modal );

		if ( focusable.length === 0 ) {
			event.preventDefault();
			focusInitial();
			return;
		}

		const currentIndex = focusable.indexOf(
			ownerDocument.activeElement as HTMLElement
		);
		let nextIndex = currentIndex + 1;

		if ( event.shiftKey ) {
			nextIndex =
				currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1;
		} else if (
			currentIndex === -1 ||
			currentIndex === focusable.length - 1
		) {
			nextIndex = 0;
		}

		event.preventDefault();
		focusable[ nextIndex ].focus();
	};

	ownerDocument.addEventListener( 'keydown', onKeyDown, true );
	focusInitial();

	return () => {
		ownerDocument.removeEventListener( 'keydown', onKeyDown, true );

		if ( previousFocus?.isConnected ) {
			previousFocus.focus();
		}
	};
}

function focusableElements( container: HTMLElement ): HTMLElement[] {
	return Array.from(
		container.querySelectorAll< HTMLElement >(
			'a[href], button, input, select, textarea, [tabindex]'
		)
	).filter( ( element ) => {
		if (
			element.hasAttribute( 'hidden' ) ||
			element.getAttribute( 'aria-hidden' ) === 'true' ||
			element.getAttribute( 'tabindex' ) === '-1'
		) {
			return false;
		}

		if (
			element instanceof HTMLButtonElement ||
			element instanceof HTMLInputElement ||
			element instanceof HTMLSelectElement ||
			element instanceof HTMLTextAreaElement
		) {
			return ! element.disabled;
		}

		return true;
	} );
}

function setRecordingStatus( message: string, isError = false ) {
	const status = ensureRecordingStatusElement();

	status.classList.remove(
		'notice-warning',
		'notice-error',
		'notice-info',
		'is-warning',
		'is-error',
		'is-info'
	);
	status.classList.toggle( 'is-error', isError );
	status.classList.toggle( 'is-info', ! isError );
	status.innerHTML = '';

	const paragraph = document.createElement( 'p' );
	paragraph.textContent = message;
	status.appendChild( paragraph );
}

function ensureRecordingStatusElement(): HTMLElement {
	const railStatus = document.querySelector(
		'.scheduled-changes-recording-editor-rail .scheduled-changes-recording-editor-status'
	) as HTMLElement | null;

	if ( railStatus ) {
		railStatus.classList.remove( 'notice' );
		railStatus.classList.add(
			'scheduled-changes-recording-editor-rail__status'
		);
		railStatus.setAttribute( 'role', 'status' );
		railStatus.setAttribute( 'aria-live', 'polite' );
		railStatus.setAttribute( 'aria-atomic', 'true' );
		return railStatus;
	}

	const railInner = document.querySelector(
		'.scheduled-changes-recording-editor-rail__inner'
	);
	let status = document.querySelector(
		'.scheduled-changes-recording-editor-status'
	) as HTMLElement | null;

	if ( status ) {
		if ( railInner ) {
			status.classList.remove( 'notice' );
			status.classList.add(
				'scheduled-changes-recording-editor-rail__status'
			);
			railInner.prepend( status );
		}

		status.setAttribute( 'role', 'status' );
		status.setAttribute( 'aria-live', 'polite' );
		status.setAttribute( 'aria-atomic', 'true' );
		return status;
	}

	status = document.createElement( 'div' );
	status.className = railInner
		? 'scheduled-changes-recording-editor-status scheduled-changes-recording-editor-rail__status'
		: 'notice scheduled-changes-recording-editor-status';
	status.setAttribute( 'role', 'status' );
	status.setAttribute( 'aria-live', 'polite' );
	status.setAttribute( 'aria-atomic', 'true' );

	if ( railInner ) {
		railInner.prepend( status );
	} else {
		document.body.appendChild( status );
	}

	return status;
}

function errorMessage( error: unknown ): string {
	if ( error instanceof Error ) {
		return error.message;
	}

	return __(
		"JJ's Post and Product Scheduler could not complete the recording action.",
		'scheduled-changes'
	);
}

function recordingFailureMessage( error: unknown ): string {
	return sprintf(
		/* translators: %s: specific save or review error returned by the server. */
		__(
			'Could not save the safe copy. The live item was not changed. Refresh this editor and try again if the action expired, or use Cancel recording to remove the safe copy. Error: %s',
			'scheduled-changes'
		),
		errorMessage( error )
	);
}

function scheduleFailureMessage( error: unknown ): string {
	return sprintf(
		/* translators: %s: specific scheduling error returned by the server. */
		__(
			'Safe copy saved, but the scheduled change was not created. The live item was not changed. Check the date and time, then try scheduling again. Error: %s',
			'scheduled-changes'
		),
		errorMessage( error )
	);
}

function cancelFailureMessage( error: unknown ): string {
	return sprintf(
		/* translators: %s: specific cancel error returned by the server. */
		__(
			'Recording could not be canceled. The safe copy is still active, and the live item was not changed. Try canceling again, or return to the source item without saving this copy. Error: %s',
			'scheduled-changes'
		),
		errorMessage( error )
	);
}

function isControlBusy( button: HTMLButtonElement ): boolean {
	return button.getAttribute( 'aria-disabled' ) === 'true' || button.disabled;
}

function setControlBusy( button: HTMLButtonElement, busy: boolean ) {
	button.disabled = busy;
	button.setAttribute( 'aria-disabled', busy ? 'true' : 'false' );
	button.classList.toggle( 'is-disabled', busy );
}
