<?php
/**
 * Product editor Record Changes coordinator.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChangesWooCommerce\CoreCompat\Admin;

// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals -- scheduled_changes_ is the product prefix.

use ScheduledChanges\Domain\Launch\CreationSource;
use ScheduledChanges\Domain\Recording\RecordingSession;
use ScheduledChanges\Domain\Recording\RecordingSessionStatus;
use ScheduledChanges\Domain\Shared\NamedTimezone;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Recording\RecordingFinalizationRequiresAcknowledgement;
use ScheduledChangesWooCommerce\CoreCompat\Recording\RecordingPendingTermStore;
use ScheduledChangesWooCommerce\CoreCompat\Recording\RecordingPostRemapper;
use ScheduledChanges\Recording\RecordingService;
use ScheduledChangesWooCommerce\CoreCompat\Recording\ShadowProductAccessGuard;
use ScheduledChanges\Repository\RecordingSessionRepository;
use ScheduledChanges\Repository\LaunchRepository;
use ScheduledChanges\Repository\LaunchVersionRepository;
use ScheduledChanges\Repository\WpdbLaunchRepository;
use ScheduledChanges\Repository\WpdbLaunchVersionRepository;
use ScheduledChanges\Scheduler\SchedulerService;
use ScheduledChanges\Support\PublicErrorMessages;

/**
 * Adds the product-editor Record button and modal scheduling flow.
 */
final class ProductEditorRecordingCoordinator {
	/**
	 * @var RecordingSessionRepository
	 */
	private RecordingSessionRepository $recordings;
	/**
	 * @var LaunchRepository
	 */
	private LaunchRepository $launches;
	/**
	 * @var LaunchVersionRepository
	 */
	private LaunchVersionRepository $versions;
	/**
	 * @var RecordingService
	 */
	private RecordingService $recording_service;
	/**
	 * @var SchedulerService|null
	 */
	private ?SchedulerService $scheduler;
	/**
	 * @var ProductEditorRecordingRequestGuard
	 */
	private ProductEditorRecordingRequestGuard $request_guard;

	/**
	 * Product taxonomy AJAX actions already guarded by this request.
	 *
	 * @var array<string,bool>
	 */
	private array $intercepted_taxonomies = array();

	/**
	 * Stores taxonomy terms that only exist inside a recording session.
	 *
	 * @var RecordingPendingTermStore
	 */
	private RecordingPendingTermStore $pending_terms;

	/**
	 * Creates the product-editor recording controller.
	 *
	 * @param RecordingSessionRepository              $recordings Recording session repository.
	 * @param RecordingService                        $recording_service Recording service.
	 * @param SchedulerService|null                   $scheduler Scheduler service.
	 * @param RecordingPendingTermStore|null          $pending_terms Pending term store.
	 * @param LaunchRepository|null                   $launches Launch repository.
	 * @param LaunchVersionRepository|null            $versions Launch version repository.
	 * @param ProductEditorRecordingRequestGuard|null $request_guard Recording request guard.
	 */
	public function __construct(
		RecordingSessionRepository $recordings,
		RecordingService $recording_service,
		?SchedulerService $scheduler,
		?RecordingPendingTermStore $pending_terms = null,
		?LaunchRepository $launches = null,
		?LaunchVersionRepository $versions = null,
		?ProductEditorRecordingRequestGuard $request_guard = null
	) {
		$this->recordings        = $recordings;
		$this->launches          = $launches ?? new WpdbLaunchRepository();
		$this->versions          = $versions ?? new WpdbLaunchVersionRepository();
		$this->recording_service = $recording_service;
		$this->scheduler         = $scheduler;
		$this->pending_terms     = $pending_terms ?? new RecordingPendingTermStore();
		$this->request_guard     = $request_guard ?? new ProductEditorRecordingRequestGuard();
	}

	/**
	 * Registers WordPress hooks.
	 */
	public function register(): void {
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
		add_action( 'admin_post_scheduled_changes_start_product_editor_recording', array( $this, 'startRecording' ) );
		add_action( 'wp_ajax_scheduled_changes_start_product_editor_recording', array( $this, 'ajaxStartRecording' ) );
		add_action( 'wp_ajax_scheduled_changes_save_product_editor_recording', array( $this, 'ajaxSaveRecordingShadow' ) );
		add_action( 'wp_ajax_scheduled_changes_cancel_product_editor_recording', array( $this, 'ajaxCancelRecording' ) );
		add_action( 'wp_ajax_scheduled_changes_finalize_product_editor_recording', array( $this, 'ajaxFinalizeRecording' ) );
		add_action( 'wp_ajax_scheduled_changes_schedule_product_editor_recording', array( $this, 'ajaxScheduleRecording' ) );
		add_action( 'wp_ajax_scheduled_changes_recording_create_pending_term', array( $this, 'ajaxCreatePendingTerm' ) );
		add_action( 'init', array( $this, 'registerNativeTaxonomyCreationInterceptors' ), 1000 );
		add_action( 'registered_taxonomy', array( $this, 'registerNativeTaxonomyCreationInterceptor' ), 1000, 3 );
	}

	/**
	 * Registers native hierarchical taxonomy creation guards for product taxonomies.
	 */
	public function registerNativeTaxonomyCreationInterceptors(): void {
		$taxonomies = get_object_taxonomies( 'product', 'objects' );

		if ( ! is_array( $taxonomies ) ) {
			return;
		}

		foreach ( $taxonomies as $taxonomy => $object ) {
			$this->registerNativeTaxonomyCreationInterceptor( (string) $taxonomy, array( 'product' ), $object );
		}
	}

	/**
	 * Registers a native AJAX guard for one product taxonomy.
	 *
	 * @param string                           $taxonomy Taxonomy name.
	 * @param string|string[]                  $object_type Registered object type.
	 * @param array<string,mixed>|\WP_Taxonomy $taxonomy_object Taxonomy object or registration args.
	 */
	public function registerNativeTaxonomyCreationInterceptor(
		string $taxonomy,
		$object_type = array(),
		$taxonomy_object = array()
	): void {
		$object_types = is_array( $object_type ) ? $object_type : array( $object_type );
		$registered   = get_taxonomy( $taxonomy );

		if (
			isset( $this->intercepted_taxonomies[ $taxonomy ] )
			|| ! in_array( 'product', array_map( 'strval', $object_types ), true )
			|| ! $registered instanceof \WP_Taxonomy
			|| ! $registered->hierarchical
		) {
			return;
		}

		unset( $taxonomy_object );

		$this->intercepted_taxonomies[ $taxonomy ] = true;
		add_action( 'wp_ajax_add-' . $taxonomy, array( $this, 'maybeInterceptNativeHierarchicalTermCreation' ), 0 );
	}

	/**
	 * Enqueues source/shadow product editor behavior.
	 *
	 * @param string $hook_suffix Current admin screen hook suffix.
	 */
	public function enqueue( string $hook_suffix ): void {
		if ( 'post.php' !== $hook_suffix ) {
			return;
		}

		$screen = get_current_screen();

		if ( null === $screen || 'product' !== $screen->post_type ) {
			return;
		}

		$post_id = $this->requestInt( 'post' );
		$data    = $this->dataForPost( $post_id );

		if ( null === $data ) {
			return;
		}

		$asset_file = SCHEDULED_CHANGES_WC_PLUGIN_DIR . 'assets/build/product-editor-recording.asset.php';
		$asset      = is_readable( $asset_file )
			? include $asset_file
			: array(
				'dependencies' => array(),
				'version'      => SCHEDULED_CHANGES_WC_VERSION,
			);

		wp_enqueue_script(
			'scheduled-changes-product-editor-recording',
			plugin_dir_url( SCHEDULED_CHANGES_WC_PLUGIN_FILE ) . 'assets/build/product-editor-recording.js',
			is_array( $asset['dependencies'] ?? null ) ? $asset['dependencies'] : array(),
			(string) ( $asset['version'] ?? SCHEDULED_CHANGES_WC_VERSION ),
			true
		);
		wp_set_script_translations(
			'scheduled-changes-product-editor-recording',
			'jjs-post-scheduler',
			SCHEDULED_CHANGES_WC_PLUGIN_DIR . 'languages'
		);
		wp_enqueue_style(
			'scheduled-changes-product-editor-recording',
			plugin_dir_url( SCHEDULED_CHANGES_WC_PLUGIN_FILE ) . 'assets/build/product-editor-recording.css',
			array(),
			(string) ( $asset['version'] ?? SCHEDULED_CHANGES_WC_VERSION )
		);
		wp_add_inline_script(
			'scheduled-changes-product-editor-recording',
			'window.scheduledChangesProductEditorRecording = ' . wp_json_encode( $data ) . ';',
			'before'
		);
	}

	/**
	 * Builds product-editor recording data for a post.
	 *
	 * @param int $post_id Product post ID.
	 * @return array<string,mixed>|null
	 */
	public function dataForPost( int $post_id ): ?array {
		if ( $post_id <= 0 || 'product' !== get_post_type( $post_id ) ) {
			return null;
		}

		if ( ShadowProductAccessGuard::isShadow( $post_id ) ) {
			return $this->shadowData( $post_id );
		}

		$product = wc_get_product( $post_id );

		if (
			! $product instanceof \WC_Product
			|| $product instanceof \WC_Product_Variation
			|| ! current_user_can( 'scheduled_changes_create_schedules' )
			|| ! current_user_can( 'edit_post', $post_id )
		) {
			return null;
		}

		$existing = $this->activeProductRecording( $post_id );
		$url      = null === $existing ? $this->startRecordingUrl( $post_id ) : $this->shadowEditorUrl( $existing );

		$nonce = $this->request_guard->sourceStartNonce( $post_id );

		return array(
			'mode'            => 'source',
			'buttonLabel'     => __( 'Record', 'jjs-post-scheduler' ),
			'recordUrl'       => $url,
			'ajaxUrl'         => admin_url( 'admin-ajax.php' ),
			'nonce'           => $nonce,
			'nonces'          => array( 'start' => $nonce ),
			'sourceProductId' => $post_id,
		);
	}

	/**
	 * Starts or resumes a product-editor recording session.
	 */
	public function startRecording(): void {
		$source_product_id = $this->requestInt( 'source_product_id' );

		check_admin_referer( $this->request_guard->sourceNonceAction( 'start', $source_product_id ) );

		try {
			$result = $this->startRecordingForProduct( $source_product_id );

			wp_safe_redirect( (string) $result['shadowEditUrl'] );
			exit;
		} catch ( \Throwable $throwable ) {
			wp_die( esc_html( PublicErrorMessages::message( 'scheduled_changes_recording_start_failed', __( 'Recording could not be started.', 'jjs-post-scheduler' ), $throwable, array( 'surface' => 'admin.recording.start' ) ) ) );
		}
	}

	/**
	 * Starts or resumes a product-editor recording session through AJAX.
	 */
	public function ajaxStartRecording(): void {
		$source_product_id = $this->request_guard->checkStartAjaxNonce();

		try {
			wp_send_json_success(
				$this->startRecordingForProduct( $source_product_id )
			);
		} catch ( \Throwable $throwable ) {
			$this->sendAjaxError( 'scheduled_changes_recording_start_failed', __( 'Recording could not be started.', 'jjs-post-scheduler' ), $throwable );
		}
	}

	/**
	 * Starts or resumes a recording session for a source product.
	 *
	 * @param int $source_product_id Source product ID.
	 * @return array<string,mixed>
	 */
	private function startRecordingForProduct( int $source_product_id ): array {
		if (
			$source_product_id <= 0
			|| ! current_user_can( 'scheduled_changes_create_schedules' )
			|| ! current_user_can( 'edit_post', $source_product_id )
		) {
			throw new \RuntimeException( 'You do not have permission to record this product.' );
		}

		$session      = $this->activeProductRecording( $source_product_id )
			?? $this->recording_service->start( $source_product_id );
		$default_time = $this->defaultScheduleTime();
		$nonces       = $this->request_guard->shadowActionNonces( $session );

		return array(
			'sessionUuid'   => $session->uuid()->value(),
			'shadowId'      => $session->shadowProductId(),
			'shadowEditUrl' => $this->shadowEditorUrl( $session ),
			'nonce'         => $nonces['save'],
			'nonces'        => $nonces,
			'variationMap'  => is_array( $session->variationMap()['source_to_shadow'] ?? null )
				? $session->variationMap()['source_to_shadow']
				: array(),
			'sourceTitle'   => get_the_title( $source_product_id ),
			'sourceEditUrl' => get_edit_post_link( $source_product_id, 'url' ),
			'recordingsUrl' => admin_url( 'admin.php?page=jjs-scheduler-record' ),
			'defaultDate'   => $default_time->format( 'Y-m-d' ),
			'defaultTime'   => $default_time->format( 'H:i' ),
			'timezone'      => $this->siteTimezoneName(),
		);
	}

	/**
	 * Saves the shadow product through AJAX without refreshing the editor.
	 */
	public function ajaxSaveRecordingShadow(): void {
		$uuid = $this->request_guard->checkSessionAjaxNonce( 'save' );

		try {
			wp_send_json_success(
				$this->saveRecordingShadow( $uuid )
			);
		} catch ( \Throwable $throwable ) {
			$this->sendAjaxError( 'scheduled_changes_recording_shadow_save_failed', __( 'Recording copy could not be saved.', 'jjs-post-scheduler' ), $throwable );
		}
	}

	/**
	 * AJAX wrapper for canceling the active product-editor recording.
	 */
	public function ajaxCancelRecording(): void {
		$uuid = $this->request_guard->checkSessionAjaxNonce( 'cancel' );

		try {
			if ( ! current_user_can( 'scheduled_changes_create_schedules' ) ) {
				throw new \RuntimeException( 'Current user cannot cancel this recording session.' );
			}

			$session         = $this->activeSession( $uuid );
			$source_edit_url = get_edit_post_link( $session->sourceProductId(), 'url' );

			if ( ! is_string( $source_edit_url ) || '' === $source_edit_url ) {
				$source_edit_url = admin_url( 'edit.php?post_type=product' );
			}

			$this->recording_service->abandon( $session->uuid(), true );

			wp_send_json_success(
				array(
					'sourceEditUrl' => $source_edit_url,
					'message'       => __( 'Recording canceled. The temporary product copy was removed.', 'jjs-post-scheduler' ),
				)
			);
		} catch ( \Throwable $throwable ) {
			$this->sendAjaxError( 'scheduled_changes_recording_cancel_failed', __( 'Recording could not be canceled.', 'jjs-post-scheduler' ), $throwable );
		}
	}

	/**
	 * AJAX wrapper for finalizing the saved shadow recording.
	 */
	public function ajaxFinalizeRecording(): void {
		$uuid = $this->request_guard->checkSessionAjaxNonce( 'finalize' );

		try {
			wp_send_json_success(
				$this->finalizeForModal(
					$uuid,
					$this->postedBool( 'acknowledge_unsupported_metadata' )
				)
			);
		} catch ( RecordingFinalizationRequiresAcknowledgement $exception ) {
			PublicErrorMessages::message( 'scheduled_changes_recording_unsupported_metadata_acknowledgement_required', __( 'Unsupported metadata changed during recording and needs acknowledgement.', 'jjs-post-scheduler' ), $exception, array( 'surface' => 'ajax.recording.finalize' ) );
			wp_send_json_error(
				array(
					'code'                        => 'unsupported_metadata_acknowledgement_required',
					'message'                     => __( 'Unsupported metadata changed during recording and needs acknowledgement.', 'jjs-post-scheduler' ),
					'requiresUnsupportedMetadataAcknowledgement' => true,
					'unsupportedMetadataWarnings' => $this->formatUnsupportedMetadataWarnings( $exception->warnings() ),
					'selectedChangeCount'         => count( $exception->selectedChangeIds() ),
				),
				409
			);
		} catch ( \Throwable $throwable ) {
			$this->sendAjaxError( 'scheduled_changes_recording_finalize_failed', __( 'Recording could not be finalized.', 'jjs-post-scheduler' ), $throwable );
		}
	}

	/**
	 * AJAX wrapper for scheduling the finalized recording launch.
	 */
	public function ajaxScheduleRecording(): void {
		$uuid = $this->request_guard->checkSessionAjaxNonce( 'schedule' );

		try {
			wp_send_json_success(
				$this->scheduleForModal(
					$uuid,
					$this->postedInt( 'version_id' ),
					$this->postedString( 'go_live_date' ),
					$this->postedString( 'go_live_time' )
				)
			);
		} catch ( \Throwable $throwable ) {
			$this->sendAjaxError( 'scheduled_changes_recording_schedule_failed', __( 'Recorded changes could not be scheduled.', 'jjs-post-scheduler' ), $throwable );
		}
	}

	/**
	 * Creates a recording-local taxonomy term token without inserting a real term.
	 */
	public function ajaxCreatePendingTerm(): void {
		$uuid = $this->request_guard->checkSessionAjaxNonce( 'pending_term' );

		try {
			$session   = $this->activeSession( $uuid );
			$shadow_id = (int) $session->shadowProductId();
			$taxonomy  = sanitize_key( $this->postedString( 'taxonomy' ) );
			$name      = $this->postedString( 'term_name' );

			if ( $shadow_id <= 0 || ! ShadowProductAccessGuard::isShadow( $shadow_id ) ) {
				throw new \RuntimeException( 'Recording shadow product cannot accept pending terms.' );
			}

			$taxonomy_object = get_taxonomy( $taxonomy );

			if ( ! $taxonomy_object instanceof \WP_Taxonomy ) {
				throw new \RuntimeException( 'Taxonomy is not available for recording.' );
			}

			$create_cap = (string) ( $taxonomy_object->cap->manage_terms ?? 'manage_terms' );

			if ( '' !== $create_cap && ! current_user_can( $create_cap ) ) {
				throw new \RuntimeException( 'Current user cannot create terms for this taxonomy.' );
			}

			$parent = $this->postedString( 'parent' );
			$term   = $this->pending_terms->create(
				$shadow_id,
				$taxonomy,
				$name,
				ctype_digit( $parent ) ? (int) $parent : 0,
				$parent
			);

			wp_send_json_success(
				array(
					'token'    => $term['token'],
					'taxonomy' => $term['taxonomy'],
					'name'     => $term['name'],
					'slug'     => $term['slug'],
				)
			);
		} catch ( \Throwable $throwable ) {
			$this->sendAjaxError( 'scheduled_changes_recording_pending_term_failed', __( 'Term could not be prepared for recording.', 'jjs-post-scheduler' ), $throwable );
		}
	}

	/**
	 * Converts WordPress' native product-category AJAX creation into a pending recording term.
	 */
	public function maybeInterceptNativeHierarchicalTermCreation(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The native taxonomy nonce is checked after a recording session is detected.
		$action = sanitize_key( wp_unslash( $_POST['action'] ?? '' ) );

		if ( strncmp( $action, 'add-', strlen( 'add-' ) ) !== 0 ) {
			return;
		}

		$taxonomy = sanitize_key( (string) substr( $action, 4 ) );
		$session  = $this->recordingSessionFromTaxonomyAjaxRequest( $taxonomy );

		if ( null === $session ) {
			return;
		}

		$taxonomy_object = get_taxonomy( $taxonomy );

		if ( ! $taxonomy_object instanceof \WP_Taxonomy || ! $taxonomy_object->hierarchical ) {
			return;
		}

		check_ajax_referer( $action, '_ajax_nonce-add-' . $taxonomy );

		if ( ! current_user_can( $taxonomy_object->cap->edit_terms ?? 'edit_terms' ) ) {
			wp_die( '-1' );
		}

		try {
			$result = $this->recordPendingHierarchicalTermCreation(
				$session,
				$taxonomy,
				$this->postedHierarchicalTermNames( $taxonomy ),
				$this->postedTaxonomyAssignment( $taxonomy ),
				$this->postedString( 'new' . $taxonomy . '_parent' )
			);
		} catch ( \Throwable $throwable ) {
			$response = new \WP_Ajax_Response(
				array(
					'what' => $taxonomy,
					'data' => new \WP_Error(
						'scheduled_changes_recording_pending_term_failed',
						PublicErrorMessages::message( 'scheduled_changes_recording_pending_term_failed', __( 'Term could not be prepared for recording.', 'jjs-post-scheduler' ), $throwable, array( 'surface' => 'ajax.recording.native_term' ) )
					),
				)
			);
			$response->send();
		}

		$term = $result['terms'][0] ?? null;

		if ( ! is_array( $term ) ) {
			$response = new \WP_Ajax_Response(
				array(
					'what' => $taxonomy,
					'data' => new \WP_Error( 'scheduled_changes_recording_pending_term_empty', 'Term name is required.' ),
				)
			);
			$response->send();
		}

		$response = new \WP_Ajax_Response(
			array(
				'what'         => $taxonomy,
				'id'           => (string) $term['token'],
				'data'         => $this->pendingHierarchicalTermListItem( $taxonomy, $term ),
				'position'     => -1,
				'supplemental' => array(
					'newcat_parent' => $this->parentDropdownMarkup( $taxonomy_object ),
				),
			)
		);
		$response->send();
	}

	/**
	 * Records pending hierarchical term creation and assignment without inserting a real term.
	 *
	 * @param RecordingSession        $session Active recording session.
	 * @param string                  $taxonomy Taxonomy name.
	 * @param string[]                $names Submitted term names.
	 * @param array<int|string,mixed> $submitted_terms Existing checked term values.
	 * @param string                  $parent_value Parent term ID or pending token.
	 * @return array{terms:array<int,array<string,mixed>>,assignment:array<int,int|string>}
	 */
	public function recordPendingHierarchicalTermCreation(
		RecordingSession $session,
		string $taxonomy,
		array $names,
		array $submitted_terms,
		string $parent_value
	): array {
		$shadow_id = (int) $session->shadowProductId();

		if ( $shadow_id <= 0 || ! ShadowProductAccessGuard::isShadow( $shadow_id ) ) {
			throw new \RuntimeException( 'Recording shadow product cannot accept pending terms.' );
		}

		$parent_term_id = ctype_digit( $parent_value ) ? absint( $parent_value ) : 0;
		$parent_token   = $this->pending_terms->isToken( $parent_value ) ? $parent_value : '';
		$assignment     = $this->pending_terms->normalizeAssignment( $shadow_id, $taxonomy, $submitted_terms );
		$terms          = array();

		foreach ( $names as $name ) {
			$name = trim( sanitize_text_field( $name ) );

			if ( '' === $name ) {
				continue;
			}

			$term         = $this->pending_terms->create( $shadow_id, $taxonomy, $name, $parent_term_id, $parent_token );
			$terms[]      = $term;
			$assignment[] = (string) $term['token'];
		}

		$assignment = $this->pending_terms->normalizeAssignment( $shadow_id, $taxonomy, $assignment );
		$this->pending_terms->setAssignment( $shadow_id, $taxonomy, $assignment );

		return array(
			'terms'      => $terms,
			'assignment' => $assignment,
		);
	}

	/**
	 * Saves the active shadow product from product editor form data.
	 *
	 * @param Uuid $uuid Recording session UUID.
	 * @return array<string,mixed>
	 */
	public function saveRecordingShadow( Uuid $uuid ): array {
		$session   = $this->activeSession( $uuid );
		$shadow_id = $session->shadowProductId();

		if (
			null === $shadow_id
			|| ! ShadowProductAccessGuard::isShadow( $shadow_id )
			|| ! current_user_can( 'edit_post', $shadow_id )
		) {
			throw new \RuntimeException( 'Recording shadow product cannot be saved.' );
		}

		$this->loadWooCommerceMetaBoxes();

		$post = get_post( $shadow_id );

		if ( ! $post instanceof \WP_Post ) {
			throw new \RuntimeException( 'Recording shadow post was not found.' );
		}

		$this->withShadowSaveRequest(
			$session,
			function () use ( $shadow_id, $session, $post ): void {
				$woocommerce_nonce = null;

				// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The recording AJAX nonce is verified before this helper runs.
				if ( isset( $_POST['woocommerce_meta_nonce'] ) && is_string( $_POST['woocommerce_meta_nonce'] ) ) {
					// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The recording AJAX nonce is verified before this helper runs.
					$woocommerce_nonce = sanitize_text_field( wp_unslash( $_POST['woocommerce_meta_nonce'] ) );
				}
				unset( $_POST['woocommerce_meta_nonce'] );

				$post_update = array(
					'ID'             => $shadow_id,
					'post_title'     => $this->postedString( 'post_title' ),
					'post_content'   => $this->postedHtml( 'content', 'post_content' ),
					'post_excerpt'   => $this->postedHtml( 'excerpt', 'post_excerpt' ),
					'post_status'    => ShadowProductAccessGuard::POST_STATUS,
					'post_password'  => $this->postedString( 'post_password' ),
					'comment_status' => $this->postedCommentStatus(),
					'menu_order'     => $this->postedInt( 'menu_order' ),
				);
				$author_id   = $this->postedInt( 'post_author' );
				$publish_at  = $this->postedPublishDate();

				if ( $author_id > 0 && get_userdata( $author_id ) instanceof \WP_User ) {
					$post_update['post_author'] = $author_id;
				}

				if ( $publish_at instanceof \DateTimeImmutable ) {
					$publish_utc                  = $publish_at->setTimezone( new \DateTimeZone( 'UTC' ) );
					$post_update['post_date']     = $publish_at->format( 'Y-m-d H:i:s' );
					$post_update['post_date_gmt'] = $publish_utc->format( 'Y-m-d H:i:s' );
				}

				$updated = wp_update_post( wp_slash( $post_update ), true );

				if ( $updated instanceof \WP_Error ) {
					throw new \RuntimeException( esc_html( $updated->get_error_message() ) );
				}

				if ( null !== $woocommerce_nonce ) {
					$_POST['woocommerce_meta_nonce'] = $woocommerce_nonce;
				}

				$this->saveSubmittedCustomFields(
					$session->sourceProductId(),
					$shadow_id
				);
				$this->saveSubmittedTaxonomies( $shadow_id );
				$this->saveSubmittedFeaturedImage( $shadow_id );
				$this->saveWooCommerceMetaBoxes( $shadow_id, $post );
				$this->restoreShadowStatuses( $session );
			}
		);

		clean_post_cache( $shadow_id );
		wc_delete_product_transients( $shadow_id );

		return array(
			'shadowProductId' => $shadow_id,
			'shadowTitle'     => get_the_title( $shadow_id ),
		);
	}

	/**
	 * Finalizes the active recording and returns modal scheduling data.
	 *
	 * @param Uuid $uuid Recording session UUID.
	 * @return array<string,mixed>
	 */
	public function finalizeForModal( Uuid $uuid, bool $acknowledge_unsupported_metadata = false ): array {
		$session = $this->activeSession( $uuid );
		$title   = get_the_title( $session->sourceProductId() );

		$result = $this->recording_service->finalize(
			$uuid,
			array(),
			sprintf(
				/* translators: %s: source product title. */
				__( 'Recorded changes for %s', 'jjs-post-scheduler' ),
				'' === $title ? (string) $session->sourceProductId() : $title
			),
			new NamedTimezone( $this->siteTimezoneName() ),
			true,
			$acknowledge_unsupported_metadata
		);

		return array(
			'launchUuid'                      => $result->launchUuid()->value(),
			'launchId'                        => $result->launchId(),
			'versionId'                       => $result->versionId(),
			'selectedChangeCount'             => $result->selectedChangeCount(),
			'defaultDate'                     => $this->defaultScheduleTime()->format( 'Y-m-d' ),
			'defaultTime'                     => $this->defaultScheduleTime()->format( 'H:i' ),
			'timezone'                        => $this->siteTimezoneName(),
			'launchesUrl'                     => admin_url( 'admin.php?page=jjs-scheduler' ),
			'sourceEditUrl'                   => get_edit_post_link( $session->sourceProductId(), 'url' ),
			'unsupportedMetadataAcknowledged' => $acknowledge_unsupported_metadata,
		);
	}

	/**
	 * Formats unsupported metadata warnings for AJAX clients without values.
	 *
	 * @param array<int,array<string,mixed>> $warnings Unsupported metadata warnings.
	 * @return array<int,array<string,string>>
	 */
	private function formatUnsupportedMetadataWarnings( array $warnings ): array {
		$formatted = array();

		foreach ( $warnings as $warning ) {
			if ( ! is_array( $warning ) ) {
				continue;
			}

			$formatted[] = array(
				'code'            => sanitize_key( (string) ( $warning['code'] ?? '' ) ),
				'metaKey'         => sanitize_text_field( (string) ( $warning['meta_key'] ?? '' ) ),
				'sourceReference' => sanitize_text_field( (string) ( $warning['source_reference'] ?? '' ) ),
				'message'         => sanitize_text_field( (string) ( $warning['message'] ?? '' ) ),
			);
		}

		return $formatted;
	}

	/**
	 * Schedules a finalized recording launch from modal input.
	 *
	 * @param Uuid   $uuid Recording session UUID.
	 * @param int    $version_id Launch version ID.
	 * @param string $date Date in Y-m-d format.
	 * @param string $time Time in H:i format.
	 * @return array<string,mixed>
	 */
	public function scheduleForModal( Uuid $uuid, int $version_id, string $date, string $time ): array {
		if ( null === $this->scheduler ) {
			throw new \RuntimeException( 'Scheduler service is unavailable.' );
		}

		if ( ! current_user_can( 'scheduled_changes_schedule_changes' ) ) {
			throw new \RuntimeException( 'Current user cannot schedule recorded changes.' );
		}

		$this->assertVersionBelongsToFinalizedSession( $uuid, $version_id );

		$scheduled_at = $this->scheduledTimestamp( $date, $time );
		$action_id    = $this->scheduler->schedule( $version_id, $scheduled_at );

		return array(
			'actionId'       => $action_id,
			'versionId'      => $version_id,
			'scheduledAtUtc' => $scheduled_at->toIso8601(),
			'launchesUrl'    => admin_url( 'admin.php?page=jjs-scheduler' ),
		);
	}

	/**
	 * Verifies product-editor schedule requests are bound to the finalized recording.
	 *
	 * @param Uuid $uuid Recording session UUID.
	 * @param int  $version_id Submitted launch version ID.
	 */
	private function assertVersionBelongsToFinalizedSession( Uuid $uuid, int $version_id ): void {
		$session = $this->recordings->findByUuid( $uuid );

		if ( null === $session || RecordingSessionStatus::FINALIZED !== $session->status() ) {
			throw new \RuntimeException( 'Recording session is not finalized.' );
		}

		if ( $session->ownerId() !== get_current_user_id() || ! current_user_can( 'edit_post', $session->sourceProductId() ) ) {
			throw new \RuntimeException( 'Current user cannot schedule this recording session.' );
		}

		$version = $this->versions->findById( $version_id );

		if ( null === $version ) {
			throw new \RuntimeException( 'Recording launch version was not found.' );
		}

		$launch = $this->launches->findById( $version->launchId() );

		if (
			null === $launch
			|| CreationSource::RECORDING !== $launch->creationSource()
			|| $session->ownerId() !== $launch->ownerId()
			|| $session->ownerId() !== $version->creatorId()
			|| null !== $launch->currentVersionId()
		) {
			throw new \RuntimeException( 'Launch version is not bound to this recording session.' );
		}

		if ( null === $version->plan()->graph()->targetByReference( $session->sourceReference() ) ) {
			throw new \RuntimeException( 'Launch version does not target this recording session source.' );
		}
	}

	/**
	 * Builds shadow product editor localized data.
	 *
	 * @param int $shadow_product_id Shadow product ID.
	 * @return array<string,mixed>|null
	 */
	private function shadowData( int $shadow_product_id ): ?array {
		$uuid_value = (string) get_post_meta( $shadow_product_id, ShadowProductAccessGuard::SESSION_META_KEY, true );

		if ( '' === $uuid_value ) {
			return null;
		}

		$session = $this->recordings->findByUuid( new Uuid( $uuid_value ) );

		if (
			null === $session
			|| RecordingSessionStatus::ACTIVE !== $session->status()
			|| $shadow_product_id !== $session->shadowProductId()
			|| ! current_user_can( 'scheduled_changes_create_schedules' )
			|| ! current_user_can( 'edit_post', $session->sourceProductId() )
		) {
			return null;
		}

		$default_time = $this->defaultScheduleTime();
		$nonces       = $this->request_guard->shadowActionNonces( $session );

		return array(
			'mode'                 => 'shadow',
			'ajaxUrl'              => admin_url( 'admin-ajax.php' ),
			'nonce'                => $nonces['save'],
			'nonces'               => $nonces,
			'sessionUuid'          => $session->uuid()->value(),
			'sourceProductId'      => $session->sourceProductId(),
			'sourceTitle'          => get_the_title( $session->sourceProductId() ),
			'sourceEditUrl'        => get_edit_post_link( $session->sourceProductId(), 'url' ),
			'recordingsUrl'        => admin_url( 'admin.php?page=jjs-scheduler-record' ),
			'shadowProductId'      => $shadow_product_id,
			'variationMap'         => is_array( $session->variationMap()['source_to_shadow'] ?? null )
				? $session->variationMap()['source_to_shadow']
				: array(),
			'defaultDate'          => $default_time->format( 'Y-m-d' ),
			'defaultTime'          => $default_time->format( 'H:i' ),
			'timezone'             => $this->siteTimezoneName(),
			'startedFromDashboard' => '1' === $this->requestString( 'scheduled_changes_recording_started' ),
		);
	}

	/**
	 * Returns a verified active recording session.
	 *
	 * @param Uuid $uuid Recording session UUID.
	 */
	private function activeSession( Uuid $uuid ): RecordingSession {
		$session = $this->recordings->findByUuid( $uuid );

		if ( null === $session ) {
			throw new \RuntimeException( 'Recording session was not found.' );
		}

		if ( RecordingSessionStatus::ACTIVE !== $session->status() ) {
			throw new \RuntimeException( 'Recording session is no longer active.' );
		}

		if ( ! current_user_can( 'edit_post', $session->sourceProductId() ) ) {
			throw new \RuntimeException( 'Current user cannot use this recording session.' );
		}

		return $session;
	}

	/**
	 * Loads WooCommerce meta-box classes when admin-ajax has not loaded them.
	 */
	private function loadWooCommerceMetaBoxes(): void {
		if ( ! defined( 'WC_ABSPATH' ) ) {
			throw new \RuntimeException( 'WooCommerce admin classes are unavailable.' );
		}

		if ( ! class_exists( '\WC_Meta_Box_Product_Data' ) ) {
			require_once WC_ABSPATH . 'includes/admin/meta-boxes/class-wc-meta-box-product-data.php';
		}

		if ( ! class_exists( '\WC_Meta_Box_Product_Images' ) ) {
			require_once WC_ABSPATH . 'includes/admin/meta-boxes/class-wc-meta-box-product-images.php';
		}
	}

	/**
	 * Saves WooCommerce product meta boxes after classes are loaded.
	 *
	 * @param int      $shadow_id Shadow product ID.
	 * @param \WP_Post $post Shadow post.
	 */
	private function saveWooCommerceMetaBoxes( int $shadow_id, \WP_Post $post ): void {
		$product_data_save  = array( 'WC_Meta_Box_Product_Data', 'save' );
		$variation_save     = array( 'WC_Meta_Box_Product_Data', 'save_variations' );
		$product_image_save = array( 'WC_Meta_Box_Product_Images', 'save' );

		if ( ! is_callable( $product_data_save ) || ! is_callable( $product_image_save ) ) {
			throw new \RuntimeException( 'WooCommerce product save handlers are unavailable.' );
		}

		call_user_func( $product_data_save, $shadow_id, $post );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX nonce is verified before preserving the submitted editor payload.
		if ( isset( $_POST['variable_post_id'] ) && is_callable( $variation_save ) ) {
			call_user_func( $variation_save, $shadow_id, $post );
		}

		call_user_func( $product_image_save, $shadow_id, $post );
	}

	/**
	 * Persists the native WordPress Custom Fields metabox payload on the shadow.
	 *
	 * @param int $source_id Source product ID.
	 * @param int $shadow_id Shadow product ID.
	 */
	private function saveSubmittedCustomFields( int $source_id, int $shadow_id ): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- The outer recording AJAX nonce is verified before this helper runs.
		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The submitted arrays are unslashed here and each supported scalar field is sanitized below.
		$submitted_meta = isset( $_POST['meta'] ) && is_array( $_POST['meta'] )
			? wp_unslash( $_POST['meta'] )
			: array();
		$deleted_meta   = isset( $_POST['deletemeta'] ) && is_array( $_POST['deletemeta'] )
			? wp_unslash( $_POST['deletemeta'] )
			: array();
		// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		foreach ( $submitted_meta as $submitted_meta_id => $values ) {
			if ( ! is_array( $values ) ) {
				continue;
			}

			$submitted_id   = absint( $submitted_meta_id );
			$submitted      = get_metadata_by_mid( 'post', $submitted_id );
			$shadow_meta_id = $this->shadowMetaIdForSubmittedMeta(
				$source_id,
				$shadow_id,
				$submitted_id
			);
			$key            = $this->customFieldKeyFromValue( $values['key'] ?? null );
			$value          = $this->sanitizeCustomFieldValue( $values['value'] ?? '' );

			if (
				'' === $key
				|| null === $value
				|| is_protected_meta( $key, 'post' )
				|| ! current_user_can( 'edit_post_meta', $shadow_id, $key )
			) {
				continue;
			}

			if ( $shadow_meta_id > 0 ) {
				update_metadata_by_mid( 'post', $shadow_meta_id, $value, $key );
			} elseif (
				is_object( $submitted )
				&& $source_id === (int) $submitted->post_id
				&& current_user_can( 'add_post_meta', $shadow_id, $key )
			) {
				add_post_meta( $shadow_id, $key, $value );
			}
		}

		foreach ( array_keys( $deleted_meta ) as $submitted_meta_id ) {
			$shadow_meta_id = $this->shadowMetaIdForSubmittedMeta(
				$source_id,
				$shadow_id,
				absint( $submitted_meta_id )
			);
			$meta           = $shadow_meta_id > 0
				? get_metadata_by_mid( 'post', $shadow_meta_id )
				: false;
			$key            = is_object( $meta ) ? (string) $meta->meta_key : '';

			if (
				$shadow_meta_id <= 0
				|| '' === $key
				|| is_protected_meta( $key, 'post' )
				|| ! current_user_can( 'delete_post_meta', $shadow_id, $key )
			) {
				continue;
			}

			delete_metadata_by_mid( 'post', $shadow_meta_id );
		}

		$key = $this->customFieldKeyFromValue( $this->postedRawString( 'metakeyinput' ) );

		if ( '' === $key || '#NONE#' === $this->postedRawString( 'metakeyinput' ) ) {
			$key = $this->customFieldKeyFromValue( $this->postedRawString( 'metakeyselect' ) );
		}

		if (
			'' !== $key
			&& '#NONE#' !== $key
			&& ! is_protected_meta( $key, 'post' )
			&& current_user_can( 'add_post_meta', $shadow_id, $key )
		) {
			$value = null;

			// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The outer recording AJAX nonce is verified before this helper runs.
			if ( isset( $_POST['metavalue'] ) && is_string( $_POST['metavalue'] ) ) {
				// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The called helper sanitizes this custom-field value.
				// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The outer recording AJAX nonce is verified before this helper runs.
				$value = $this->sanitizeCustomFieldValue( wp_unslash( $_POST['metavalue'] ) );
				// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			}

			if ( null !== $value ) {
				add_post_meta( $shadow_id, $key, $value );
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	/**
	 * Sanitizes a scalar value from WordPress' native Custom Fields metabox.
	 *
	 * @param mixed $value Submitted custom-field value.
	 */
	private function sanitizeCustomFieldValue( $value ): ?string {
		if ( ! is_string( $value ) ) {
			return null;
		}

		return sanitize_textarea_field( $value );
	}

	/**
	 * Validates a native Custom Fields key without rewriting supported keys.
	 *
	 * @param mixed $value Submitted custom-field key.
	 */
	private function customFieldKeyFromValue( $value ): string {
		if ( ! is_string( $value ) ) {
			return '';
		}

		if (
			'' === $value
			|| strlen( $value ) > 255
			|| preg_match( '/[\x00-\x1F\x7F<>]/', $value )
		) {
			return '';
		}

		return $value;
	}

	/**
	 * Maps a native custom-field row from the source editor to its shadow row.
	 *
	 * @param int $source_id Source product ID.
	 * @param int $shadow_id Shadow product ID.
	 * @param int $submitted_meta_id Submitted source or shadow meta ID.
	 */
	private function shadowMetaIdForSubmittedMeta(
		int $source_id,
		int $shadow_id,
		int $submitted_meta_id
	): int {
		if ( $submitted_meta_id <= 0 ) {
			return 0;
		}

		$submitted = get_metadata_by_mid( 'post', $submitted_meta_id );

		if ( ! is_object( $submitted ) ) {
			return 0;
		}

		if ( $shadow_id === (int) $submitted->post_id ) {
			return $submitted_meta_id;
		}

		if ( $source_id !== (int) $submitted->post_id ) {
			return 0;
		}

		global $wpdb;

		$key = (string) $submitted->meta_key;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Meta IDs are required to preserve duplicate-key row ordering.
		$source_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC",
				$source_id,
				$key
			)
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Meta IDs are required to preserve duplicate-key row ordering.
		$shadow_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC",
				$shadow_id,
				$key
			)
		);
		$position   = array_search( (string) $submitted_meta_id, array_map( 'strval', $source_ids ), true );

		return false !== $position && isset( $shadow_ids[ $position ] )
			? (int) $shadow_ids[ $position ]
			: 0;
	}

	/**
	 * Persists submitted product taxonomies onto the shadow product only.
	 *
	 * @param int $shadow_id Shadow product ID.
	 */
	private function saveSubmittedTaxonomies( int $shadow_id ): void {
		$taxonomies = get_object_taxonomies( 'product', 'objects' );

		if ( ! is_array( $taxonomies ) ) {
			return;
		}

		foreach ( $taxonomies as $taxonomy => $object ) {
			if (
				! $object instanceof \WP_Taxonomy
				|| ! current_user_can( $object->cap->assign_terms ?? 'assign_terms' )
			) {
				continue;
			}

			// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX nonce is verified before preserving the product editor payload.
			if ( isset( $_POST['tax_input'][ $taxonomy ] ) ) {
				$value = wp_unslash( $_POST['tax_input'][ $taxonomy ] );
			} elseif ( isset( $_POST[ $taxonomy ] ) ) {
				$value = wp_unslash( $_POST[ $taxonomy ] );
			} else {
				continue;
			}
			// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

			$submitted = is_array( $value )
				? array_values( $value )
				: array_filter(
					array_map( 'trim', explode( ',', (string) $value ) ),
					static fn ( string $term ): bool => '' !== $term
				);
			$terms     = $this->pending_terms->normalizeAssignment( $shadow_id, $taxonomy, $submitted );
			$real_ids  = array_values(
				array_filter(
					array_map(
						static fn ( $term ): int => is_int( $term ) ? $term : 0,
						$terms
					),
					static fn ( int $term_id ): bool => $term_id > 0
				)
			);

			wp_set_object_terms( $shadow_id, $real_ids, $taxonomy, false );
			$this->pending_terms->setAssignment( $shadow_id, $taxonomy, $terms );
		}
	}

	/**
	 * Persists the submitted featured image onto the shadow product only.
	 *
	 * @param int $shadow_id Shadow product ID.
	 */
	private function saveSubmittedFeaturedImage( int $shadow_id ): void {
		if ( ! $this->postedKeyExists( '_thumbnail_id' ) ) {
			return;
		}

		$thumbnail_id = $this->postedInt( '_thumbnail_id' );

		if ( $thumbnail_id > 0 ) {
			set_post_thumbnail( $shadow_id, $thumbnail_id );

			return;
		}

		delete_post_thumbnail( $shadow_id );
	}

	/**
	 * Stores desired values that cannot be applied directly to the hidden shadow.
	 *
	 * @param int              $shadow_id Shadow product ID.
	 * @param RecordingSession $session Recording session.
	 */
	private function storeIntendedShadowValues( int $shadow_id, RecordingSession $session ): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- AJAX nonce is verified before preserving the product editor payload.
		if ( array_key_exists( 'post_name', $_POST ) ) {
			update_post_meta(
				$shadow_id,
				ShadowProductAccessGuard::INTENDED_SLUG_META,
				sanitize_title( wp_unslash( $_POST['post_name'] ) )
			);
		}

		if ( array_key_exists( 'post_status', $_POST ) ) {
			$status  = sanitize_key( wp_unslash( $_POST['post_status'] ) );
			$allowed = array_keys( get_post_stati( array( 'internal' => false ) ) );

			if ( in_array( $status, $allowed, true ) && ShadowProductAccessGuard::POST_STATUS !== $status ) {
				update_post_meta( $shadow_id, ShadowProductAccessGuard::INTENDED_STATUS_META, $status );
			}
		}

		if ( array_key_exists( '_visibility', $_POST ) ) {
			$visibility = sanitize_key( wp_unslash( $_POST['_visibility'] ) );

			if ( array_key_exists( $visibility, wc_get_product_visibility_options() ) ) {
				update_post_meta(
					$shadow_id,
					ShadowProductAccessGuard::INTENDED_VISIBILITY_META,
					$visibility
				);
			}
		}

		$source_to_shadow = is_array( $session->variationMap()['source_to_shadow'] ?? null )
			? $session->variationMap()['source_to_shadow']
			: array();
		$shadow_ids       = array_map( 'intval', array_values( $source_to_shadow ) );
		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Individual variation IDs are normalized with absint below.
		$variation_ids = isset( $_POST['variable_post_id'] ) && is_array( $_POST['variable_post_id'] )
			? wp_unslash( $_POST['variable_post_id'] )
			: array();
		// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( array() !== $variation_ids ) {
			foreach ( $variation_ids as $index => $posted_variation_id ) {
				$posted_id           = absint( $posted_variation_id );
				$shadow_variation_id = (int) ( $source_to_shadow[ (string) $posted_id ] ?? 0 );

				if ( $shadow_variation_id <= 0 && in_array( $posted_id, $shadow_ids, true ) ) {
					$shadow_variation_id = $posted_id;
				}

				if ( $shadow_variation_id <= 0 ) {
					continue;
				}

				update_post_meta(
					$shadow_variation_id,
					ShadowProductAccessGuard::INTENDED_STATUS_META,
					isset( $_POST['variable_enabled'][ $index ] ) ? 'publish' : 'private'
				);
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	/**
	 * Normalizes product editor POST data so WooCommerce saves the shadow only.
	 *
	 * @param int              $shadow_id Shadow product ID.
	 * @param RecordingSession $session Recording session.
	 */
	private function normalizeShadowPostData( int $shadow_id, RecordingSession $session ): void {
		$_POST['post_ID']              = (string) $shadow_id;
		$_POST['ID']                   = (string) $shadow_id;
		$_POST['post_status']          = ShadowProductAccessGuard::POST_STATUS;
		$_POST['original_post_status'] = ShadowProductAccessGuard::POST_STATUS;
		$_POST['hidden_post_status']   = ShadowProductAccessGuard::POST_STATUS;

		unset( $_POST['publish'], $_POST['save'], $_POST['saveasdraft'], $_POST['saveasprivate'], $_POST['pending'] );

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- AJAX nonce is verified before this helper is used.
		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WooCommerce editor payload is normalized for WooCommerce's own save handlers.
		$_POST = ( new RecordingPostRemapper() )->remap( $_POST, $session );
		// phpcs:enable WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	/**
	 * Runs a shadow save while the product-editor payload is remapped to the shadow.
	 *
	 * @param RecordingSession $session Recording session.
	 * @param callable         $callback Save callback.
	 */
	private function withShadowSaveRequest( RecordingSession $session, callable $callback ): void {
		$shadow_id = (int) $session->shadowProductId();

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- AJAX nonce is verified before preserving the submitted editor payload.
		$original_post = $_POST;
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		try {
			$this->request_guard->assertShadowSaveContext( $session, $shadow_id );
			$this->withRecordingContext(
				$session,
				'shadow_save',
				function () use ( $shadow_id, $session, $callback ): void {
					$this->storeIntendedShadowValues( $shadow_id, $session );
					$this->normalizeShadowPostData( $shadow_id, $session );
					$callback();
				}
			);
		} finally {
			$_POST = $original_post;
		}
	}

	/**
	 * Runs a controller operation inside the public recording context hooks.
	 *
	 * @param RecordingSession $session Recording session.
	 * @param string           $phase Recording phase.
	 * @param callable         $callback Operation callback.
	 */
	private function withRecordingContext( RecordingSession $session, string $phase, callable $callback ): void {
		$context = array(
			'session_uuid'      => $session->uuid()->value(),
			'source_product_id' => $session->sourceProductId(),
			'shadow_product_id' => $session->shadowProductId(),
			'owner_id'          => $session->ownerId(),
			'phase'             => $phase,
		);

		do_action( 'scheduled_changes_recording_context_started', $context );

		try {
			$callback();
		} finally {
			do_action( 'scheduled_changes_recording_context_ended', $context );
		}
	}

	/**
	 * Re-applies the non-public shadow status after WooCommerce meta-box saves.
	 *
	 * @param RecordingSession $session Recording session.
	 */
	private function restoreShadowStatuses( RecordingSession $session ): void {
		$shadow_id = $session->shadowProductId();

		if ( null === $shadow_id ) {
			return;
		}

		wp_update_post(
			array(
				'ID'          => $shadow_id,
				'post_status' => ShadowProductAccessGuard::POST_STATUS,
				'post_name'   => 'scheduled-changes-recording-' . substr( $session->uuid()->value(), 0, 12 ) . '-' . $session->sourceProductId(),
			)
		);
		$shadow_product = wc_get_product( $shadow_id );

		if (
			$shadow_product instanceof \WC_Product
			&& is_callable( array( $shadow_product, 'set_catalog_visibility' ) )
		) {
			call_user_func( array( $shadow_product, 'set_catalog_visibility' ), 'hidden' );
			$shadow_product->save();
		}

		ShadowProductAccessGuard::markShadow(
			$shadow_id,
			$session->uuid()->value(),
			$session->sourceProductId(),
			$session->sourceProductId(),
			'product'
		);

		$shadow_to_source = is_array( $session->variationMap()['shadow_to_source'] ?? null )
			? $session->variationMap()['shadow_to_source']
			: array();
		$variation_ids    = get_posts(
			array(
				'post_parent'    => $shadow_id,
				'post_type'      => 'product_variation',
				'post_status'    => 'any',
				'fields'         => 'ids',
				'posts_per_page' => -1,
				'orderby'        => 'ID',
				'order'          => 'ASC',
			)
		);

		foreach ( $variation_ids as $variation_id ) {
			$shadow_variation_id = (int) $variation_id;

			if ( $shadow_variation_id <= 0 ) {
				continue;
			}

			wp_update_post(
				array(
					'ID'          => $shadow_variation_id,
					'post_status' => ShadowProductAccessGuard::POST_STATUS,
				)
			);
			ShadowProductAccessGuard::markShadow(
				$shadow_variation_id,
				$session->uuid()->value(),
				$session->sourceProductId(),
				(int) ( $shadow_to_source[ (string) $shadow_variation_id ] ?? 0 ),
				'variation'
			);
		}
	}

	/**
	 * Returns an admin-post URL that starts recording for a source product.
	 *
	 * @param int $source_product_id Source product ID.
	 */
	private function startRecordingUrl( int $source_product_id ): string {
		return wp_nonce_url(
			admin_url(
				add_query_arg(
					array(
						'action'            => 'scheduled_changes_start_product_editor_recording',
						'source_product_id' => $source_product_id,
					),
					'admin-post.php'
				)
			),
			$this->request_guard->sourceNonceAction( 'start', $source_product_id )
		);
	}

	/**
	 * Returns the shadow product editor URL for a recording session.
	 *
	 * @param RecordingSession $session Recording session.
	 */
	private function shadowEditorUrl( RecordingSession $session ): string {
		$shadow_id = $session->shadowProductId();

		if ( null === $shadow_id ) {
			throw new \RuntimeException( 'Recording session has no shadow product.' );
		}

		$url = get_edit_post_link( $shadow_id, 'url' );

		if ( ! is_string( $url ) || '' === $url ) {
			throw new \RuntimeException( 'Unable to open the recording shadow editor.' );
		}

		return add_query_arg(
			array(
				'scheduled_changes_recording'         => $session->uuid()->value(),
				'scheduled_changes_recording_started' => '1',
			),
			$url
		);
	}

	/**
	 * Parses modal date/time input into a UTC timestamp.
	 *
	 * @param string $date Date in Y-m-d format.
	 * @param string $time Time in H:i format.
	 */
	private function scheduledTimestamp( string $date, string $time ): UtcTimestamp {
		$local  = \DateTimeImmutable::createFromFormat(
			'!Y-m-d H:i',
			$date . ' ' . $time,
			new \DateTimeZone( $this->siteTimezoneName() )
		);
		$errors = \DateTimeImmutable::getLastErrors();

		if (
			false === $local
			|| ( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) )
		) {
			throw new \InvalidArgumentException( 'Choose a valid go-live date and time.' );
		}

		$scheduled_at = new UtcTimestamp( $local->setTimezone( new \DateTimeZone( 'UTC' ) ) );

		if ( $scheduled_at->native() <= UtcTimestamp::now()->native() ) {
			throw new \InvalidArgumentException( 'Choose a go-live time in the future.' );
		}

		return $scheduled_at;
	}

	/**
	 * Resolves the active recording session for a native taxonomy AJAX request.
	 *
	 * @param string $taxonomy Taxonomy name.
	 */
	private function recordingSessionFromTaxonomyAjaxRequest( string $taxonomy ): ?RecordingSession {
		$taxonomy_object = get_taxonomy( $taxonomy );

		if (
			! $taxonomy_object instanceof \WP_Taxonomy
			|| ! in_array( 'product', array_map( 'strval', (array) $taxonomy_object->object_type ), true )
			|| ! current_user_can( 'scheduled_changes_create_schedules' )
		) {
			return null;
		}

		foreach ( array( 'session_uuid', 'scheduled_changes_recording_session_uuid' ) as $key ) {
			$uuid_value = $this->rawPostedString( $key );

			if ( '' === $uuid_value ) {
				continue;
			}

			try {
				$session = $this->recordings->findByUuid( new Uuid( $uuid_value ) );
			} catch ( \Throwable $exception ) {
				$session = null;
			}

			if ( $this->isUsableRecordingSession( $session ) ) {
				return $session;
			}
		}

		foreach ( $this->taxonomyAjaxPostCandidates() as $post_id ) {
			$session = $this->recordingSessionForEditorPostId( $post_id );

			if ( $this->isUsableRecordingSession( $session ) ) {
				return $session;
			}
		}

		return null;
	}

	/**
	 * Resolves an active recording session from either source or shadow product IDs.
	 *
	 * @param int $post_id Source or shadow product post ID.
	 */
	private function recordingSessionForEditorPostId( int $post_id ): ?RecordingSession {
		if ( $post_id <= 0 ) {
			return null;
		}

		if ( ShadowProductAccessGuard::isShadow( $post_id ) ) {
			$uuid_value = (string) get_post_meta( $post_id, ShadowProductAccessGuard::SESSION_META_KEY, true );

			if ( '' === $uuid_value ) {
				return null;
			}

			try {
				return $this->recordings->findByUuid( new Uuid( $uuid_value ) );
			} catch ( \Throwable $exception ) {
				return null;
			}
		}

		if ( 'product' !== get_post_type( $post_id ) ) {
			return null;
		}

		return $this->activeProductRecording( $post_id );
	}

	private function activeProductRecording( int $product_id ): ?RecordingSession {
		return $this->recordings->findActiveBySourceIdentity(
			'woocommerce-core',
			'product',
			'product:' . $product_id
		);
	}

	/**
	 * Checks a recording session can be used by the current request.
	 *
	 * @param RecordingSession|null $session Recording session.
	 */
	private function isUsableRecordingSession( ?RecordingSession $session ): bool {
		return $session instanceof RecordingSession
			&& RecordingSessionStatus::ACTIVE === $session->status()
			&& null !== $session->shadowProductId()
			&& current_user_can( 'edit_post', $session->sourceProductId() );
	}

	/**
	 * Returns source/shadow product IDs available to a native taxonomy AJAX request.
	 *
	 * @return int[]
	 */
	private function taxonomyAjaxPostCandidates(): array {
		$candidates = array();

		foreach ( array( 'scheduled_changes_recording_shadow_id', 'shadow_product_id', 'post_ID', 'post_id', 'post' ) as $key ) {
			$value = $this->rawPostedInt( $key );

			if ( $value > 0 ) {
				$candidates[] = $value;
			}
		}

		$raw_referer     = wp_get_raw_referer();
		$referer_post_id = $this->request_guard->postIdFromReferer( is_string( $raw_referer ) ? $raw_referer : '' );

		if ( $referer_post_id > 0 ) {
			$candidates[] = $referer_post_id;
		}

		return array_values( array_unique( $candidates ) );
	}

	/**
	 * Reads submitted hierarchical term names from a native taxonomy add request.
	 *
	 * @param string $taxonomy Taxonomy name.
	 * @return string[]
	 */
	private function postedHierarchicalTermNames( string $taxonomy ): array {
		$value = $this->rawPostedString( 'new' . $taxonomy );

		return array_values(
			array_filter(
				array_map( 'trim', explode( ',', $value ) ),
				static fn ( string $name ): bool => '' !== $name
			)
		);
	}

	/**
	 * Reads submitted taxonomy assignment values from a native taxonomy add request.
	 *
	 * @param string $taxonomy Taxonomy name.
	 * @return array<int|string,mixed>
	 */
	private function postedTaxonomyAssignment( string $taxonomy ): array {
		// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The native taxonomy nonce is checked before this data is used.
		if ( isset( $_POST['tax_input'][ $taxonomy ] ) ) {
			$value = wp_unslash( $_POST['tax_input'][ $taxonomy ] );
		} elseif ( isset( $_POST[ $taxonomy ] ) ) {
			$value = wp_unslash( $_POST[ $taxonomy ] );
		} else {
			$value = array();
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( is_array( $value ) ) {
			return array_values( $value );
		}

		return array_filter(
			array_map( 'trim', explode( ',', (string) $value ) ),
			static fn ( string $term ): bool => '' !== $term
		);
	}

	/**
	 * Builds a wpList-compatible checkbox row for a pending recording term.
	 *
	 * @param string              $taxonomy Taxonomy name.
	 * @param array<string,mixed> $term Pending term.
	 */
	private function pendingHierarchicalTermListItem( string $taxonomy, array $term ): string {
		$token = (string) ( $term['token'] ?? '' );
		$name  = (string) ( $term['name'] ?? '' );

		return sprintf(
			'<li id="%1$s-%2$s" class="scheduled-changes-pending-term"><label class="selectit"><input value="%2$s" type="checkbox" checked="checked" name="tax_input[%1$s][]" id="in-%1$s-%2$s" data-scheduled-changes-pending-term="%2$s" /> %3$s</label></li>',
			esc_attr( $taxonomy ),
			esc_attr( $token ),
			esc_html( $name )
		);
	}

	/**
	 * Builds the refreshed native parent dropdown markup.
	 *
	 * @param \WP_Taxonomy $taxonomy Taxonomy object.
	 */
	private function parentDropdownMarkup( \WP_Taxonomy $taxonomy ): string {
		$parent_dropdown_args = array(
			'taxonomy'         => $taxonomy->name,
			'hide_empty'       => 0,
			'name'             => 'new' . $taxonomy->name . '_parent',
			'orderby'          => 'name',
			'hierarchical'     => 1,
			'show_option_none' => '&mdash; ' . $taxonomy->labels->parent_item . ' &mdash;',
		);

		/** This filter is documented in wp-admin/includes/meta-boxes.php */
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- This is the core WordPress taxonomy metabox filter.
		$parent_dropdown_args = apply_filters( 'post_edit_category_parent_dropdown_args', $parent_dropdown_args );

		ob_start();
		wp_dropdown_categories( $parent_dropdown_args );

		return (string) ob_get_clean();
	}

	/**
	 * Returns the default local schedule time.
	 */
	private function defaultScheduleTime(): \DateTimeImmutable {
		return new \DateTimeImmutable( '+1 hour', new \DateTimeZone( $this->siteTimezoneName() ) );
	}

	/**
	 * Returns the site timezone name with a UTC fallback.
	 */
	private function siteTimezoneName(): string {
		$timezone = wp_timezone_string();

		return NamedTimezone::isValid( $timezone ) ? $timezone : 'UTC';
	}

	/**
	 * Reads an integer from POST/GET data.
	 *
	 * @param string $key Request key.
	 */
	private function requestInt( string $key ): int {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing data or nonce-verified admin-post data.
		return absint( wp_unslash( $_REQUEST[ $key ] ?? 0 ) );
	}

	/**
	 * Reads a sanitized string from request data.
	 *
	 * @param string $key Request key.
	 */
	private function requestString( string $key ): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing data.
		return sanitize_text_field( wp_unslash( $_GET[ $key ] ?? '' ) );
	}

	/**
	 * Reads an integer from POST data before deciding whether to intercept native AJAX.
	 *
	 * @param string $key POST key.
	 */
	private function rawPostedInt( string $key ): int {
		// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only routing data used before optional native nonce verification; value is unslashed and cast below.
		$value = $_POST[ $key ] ?? 0;
		// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		return absint( wp_unslash( $value ) );
	}

	/**
	 * Reads a sanitized string from POST data before deciding whether to intercept native AJAX.
	 *
	 * @param string $key POST key.
	 */
	private function rawPostedString( string $key ): string {
		// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only routing data used before optional native nonce verification; value is unslashed and sanitized below.
		$value = $_POST[ $key ] ?? '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		return sanitize_text_field( wp_unslash( $value ) );
	}

	/**
	 * Reads an integer from POST data after AJAX nonce verification.
	 *
	 * @param string $key POST key.
	 */
	private function postedInt( string $key ): int {
		// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX nonce is verified before this helper is used; value is unslashed and sanitized below.
		$value = $_POST[ $key ] ?? 0;
		// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		return absint( wp_unslash( $value ) );
	}

	/**
	 * Reads a boolean from POST data after AJAX nonce verification.
	 *
	 * @param string $key POST key.
	 */
	private function postedBool( string $key ): bool {
		return in_array( strtolower( $this->postedString( $key ) ), array( '1', 'true', 'yes', 'on' ), true );
	}

	/**
	 * Reads a sanitized string from POST data after AJAX nonce verification.
	 *
	 * @param string $key POST key.
	 */
	private function postedString( string $key ): string {
		// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX nonce is verified before this helper is used; value is unslashed and sanitized below.
		$value = $_POST[ $key ] ?? '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		return sanitize_text_field( wp_unslash( $value ) );
	}

	/**
	 * Reads an unslashed raw string from POST data for callers that validate
	 * without rewriting the submitted value.
	 *
	 * @param string $key POST key.
	 */
	private function postedRawString( string $key ): string {
		// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX nonce is verified before this helper is used; callers validate without rewriting.
		$value = $_POST[ $key ] ?? '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( ! is_string( $value ) ) {
			return '';
		}

		return wp_unslash( $value );
	}

	/**
	 * Checks whether a POST key exists after AJAX nonce verification.
	 *
	 * @param string $key POST key.
	 */
	private function postedKeyExists( string $key ): bool {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- AJAX nonce is verified before this helper is used.
		$exists = array_key_exists( $key, $_POST );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		return $exists;
	}

	/**
	 * Reads sanitized rich text from POST data after AJAX nonce verification.
	 *
	 * @param string $key Primary POST key.
	 * @param string $fallback Fallback POST key.
	 */
	private function postedHtml( string $key, string $fallback ): string {
		// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX nonce is verified before this helper is used; rich text is sanitized below.
		$value = wp_unslash( $_POST[ $key ] ?? $_POST[ $fallback ] ?? '' );
		// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		return is_string( $value ) ? wp_kses_post( $value ) : '';
	}

	/**
	 * Reads the comment status from POST data.
	 */
	private function postedCommentStatus(): string {
		return 'open' === $this->postedString( 'comment_status' ) ? 'open' : 'closed';
	}

	/**
	 * Reads the classic editor publish-date fields in the site timezone.
	 */
	private function postedPublishDate(): ?\DateTimeImmutable {
		$parts = array();

		foreach ( array( 'aa', 'mm', 'jj', 'hh', 'mn' ) as $key ) {
			if ( ! $this->postedKeyExists( $key ) ) {
				return null;
			}

			$parts[ $key ] = $this->postedInt( $key );
		}

		if (
			$parts['aa'] < 1000
			|| ! checkdate( $parts['mm'], $parts['jj'], $parts['aa'] )
			|| $parts['hh'] > 23
			|| $parts['mn'] > 59
		) {
			return null;
		}

		$seconds = min( 59, $this->postedInt( 'ss' ) );
		$value   = sprintf(
			'%04d-%02d-%02d %02d:%02d:%02d',
			$parts['aa'],
			$parts['mm'],
			$parts['jj'],
			$parts['hh'],
			$parts['mn'],
			$seconds
		);
		$date    = \DateTimeImmutable::createFromFormat(
			'!Y-m-d H:i:s',
			$value,
			new \DateTimeZone( $this->siteTimezoneName() )
		);

		return false === $date ? null : $date;
	}

	/**
	 * Sends a stable AJAX error while recording internal detail for diagnostics.
	 */
	private function sendAjaxError( string $code, string $message, \Throwable $throwable, int $status = 400 ): void {
		wp_send_json_error(
			PublicErrorMessages::ajaxPayload( $code, $message, $throwable, array( 'surface' => 'ajax.recording' ) ),
			$status
		);
	}
}
