<?php
/**
 * Recording diff builder.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChangesWooCommerce\CoreCompat\Recording;

use ScheduledChanges\Discovery\MetadataDenylist;
use ScheduledChanges\Domain\Recording\RecordingSession;
use ScheduledChanges\Field\FieldDefinition;
use ScheduledChanges\Field\FieldRegistry;
use ScheduledChanges\Provider\ObjectContext;
use ScheduledChanges\Support\Json;
use ScheduledChanges\Support\ValueRedactor;

/**
 * Builds structured review diffs from baseline and shadow snapshots.
 */
final class RecordingDiffBuilder {
	private FieldRegistry $fields;
	private MetadataDenylist $metadata_denylist;
	public function __construct( FieldRegistry $fields, ?MetadataDenylist $metadata_denylist = null ) {
		$metadata_denylist     ??= new MetadataDenylist();
		$this->fields            = $fields;
		$this->metadata_denylist = $metadata_denylist;
	}

	/**
	 * Builds a structured recording diff.
	 *
	 * @param RecordingSession    $session Recording session.
	 * @param array<string,mixed> $current_snapshot Latest shadow snapshot.
	 * @return array<string,mixed>
	 */
	public function build( RecordingSession $session, array $current_snapshot ): array {
		$baseline_objects         = $this->objectsByReference( $session->baselineSnapshot() );
		$current_snapshot_objects = $this->objectsByReference( $current_snapshot );
		$current_objects          = $this->currentObjectsBySourceReference( $session, $current_snapshot );
		$changes                  = $this->newVariationChanges( $session, $current_snapshot_objects );
		$warnings                 = array();

		foreach ( $baseline_objects as $source_reference => $baseline_object ) {
			$current_object = $current_objects[ $source_reference ] ?? null;

			if ( null === $current_object ) {
				if ( 'variation' === (string) ( $baseline_object['object_type'] ?? '' ) ) {
					$changes[] = $this->deletedVariationChange( $baseline_object, $source_reference );
				}
				continue;
			}

			$warnings = array_merge(
				$warnings,
				$this->objectWarnings( $baseline_object ),
				$this->objectWarnings( $current_object ),
				$this->unsupportedMetadataWarnings( $baseline_object, $current_object )
			);

			foreach ( $this->providerIds( $baseline_object, $current_object ) as $provider_id ) {
				$before_values = $this->providerValues( $baseline_object, $provider_id );
				$after_values  = $this->providerValues( $current_object, $provider_id );
				$field_ids     = array_values( array_unique( array_merge( array_keys( $before_values ), array_keys( $after_values ) ) ) );
				sort( $field_ids );

				foreach ( $field_ids as $field_id ) {
					if ( $this->hasPendingTaxonomyAssignment( $current_object, $field_id ) ) {
						continue;
					}

					$before = $before_values[ $field_id ] ?? null;
					$after  = $this->normalizePlaceholder(
						$session,
						$baseline_object,
						$field_id,
						$before,
						$after_values[ $field_id ] ?? null
					);
					$before = $this->normalizeComparableValue( $field_id, $before );
					$after  = $this->normalizeComparableValue(
						$field_id,
						$this->normalizeShadowArtifact(
							$baseline_object,
							$field_id,
							$before,
							$after
						)
					);

					if ( $before === $after ) {
						continue;
					}

					$field      = $this->fieldDefinition( $baseline_object, $field_id );
					$selectable = null !== $field && $field->writable() && $field->schedulable();
					$sensitive  = null !== $field && $field->sensitive();

					$change_id = hash(
						'sha256',
						Json::canonicalEncode(
							array(
								$source_reference,
								$provider_id,
								$field_id,
								$before,
								$after,
							)
						)
					);
					$display   = null === $field ? array() : $field->display();
					$changes[] = array(
						'change_id'          => $change_id,
						'group'              => $this->groupForField( $field_id, $provider_id, $field ),
						'group_key'          => $this->displayString( $display, 'group_key' ),
						'provider_label'     => $this->displayString( $display, 'provider_label' ),
						'help_text'          => $this->displayString( $display, 'help_text' ),
						'warning_text'       => $this->displayString( $display, 'warning_text' ),
						'sort_order'         => (int) ( $display['sort_order'] ?? 0 ),
						'source_reference'   => $source_reference,
						'shadow_reference'   => (string) ( $current_object['reference'] ?? '' ),
						'object_id'          => (int) ( $baseline_object['object_id'] ?? 0 ),
						'object_type'        => (string) ( $baseline_object['object_type'] ?? '' ),
						'product_type'       => (string) ( $baseline_object['product_type'] ?? '' ),
						'field_id'           => $field_id,
						'field_label'        => null === $field ? $field_id : $field->label(),
						'before'             => $before,
						'after'              => $after,
						'display_before'     => ValueRedactor::redact( $sensitive, $before ),
						'display_after'      => ValueRedactor::redact( $sensitive, $after ),
						'provider_id'        => $provider_id,
						'sensitive'          => $sensitive,
						'confidence'         => null === $field ? 'unknown' : 'high',
						'selectable'         => $selectable,
						'reversible'         => null !== $field && $field->reversible(),
						'validation_state'   => $selectable ? 'pending' : 'read_only',
						'conflict_policy'    => null === $field ? 'fail' : $field->conflictStrategy(),
						'dependency_summary' => array(),
					);
				}
			}

			array_push(
				$changes,
				...$this->pendingTaxonomyChanges( $baseline_object, $current_object, $source_reference )
			);
		}

		usort(
			$changes,
			static fn ( array $left, array $right ): int => strcmp(
				(string) $left['change_id'],
				(string) $right['change_id']
			)
		);

		return array(
			'captured_at_utc'     => gmdate( 'c' ),
			'changes'             => $changes,
			'warnings'            => $warnings,
			'selected_change_ids' => array_values(
				array_map(
					static fn ( array $change ): string => (string) $change['change_id'],
					array_filter(
						$changes,
						static fn ( array $change ): bool => (bool) $change['selectable']
					)
				)
			),
		);
	}

	/**
	 * @param array<string,mixed> $snapshot Snapshot document.
	 * @return array<string,array<string,mixed>>
	 */
	private function objectsByReference( array $snapshot ): array {
		$objects = array();

		foreach ( is_array( $snapshot['objects'] ?? null ) ? $snapshot['objects'] : array() as $object ) {
			if ( ! is_array( $object ) ) {
				continue;
			}

			$reference = (string) ( $object['reference'] ?? '' );

			if ( '' !== $reference ) {
				$objects[ $reference ] = $object;
			}
		}

		return $objects;
	}

	/**
	 * @param array<string,mixed> $current_snapshot Current shadow snapshot.
	 * @return array<string,array<string,mixed>>
	 */
	private function currentObjectsBySourceReference( RecordingSession $session, array $current_snapshot ): array {
		$objects          = array();
		$shadow_to_source = is_array( $session->variationMap()['shadow_to_source'] ?? null )
			? $session->variationMap()['shadow_to_source']
			: array();

		foreach ( $this->objectsByReference( $current_snapshot ) as $shadow_reference => $object ) {
			$object_type = (string) ( $object['object_type'] ?? '' );
			$object_id   = (int) ( $object['object_id'] ?? 0 );

			if ( 'product' === $object_type ) {
				$objects[ 'product:' . $session->sourceProductId() ] = $object;
				continue;
			}

			if ( 'variation' !== $object_type ) {
				continue;
			}

			$source_id = (int) ( $shadow_to_source[ (string) $object_id ] ?? 0 );

			if ( $source_id > 0 ) {
				$objects[ 'variation:' . $source_id ] = array_merge(
					$object,
					array( 'reference' => $shadow_reference )
				);
			}
		}

		return $objects;
	}

	/**
	 * Detects shadow variations that have no existing source variation target.
	 *
	 * @param array<string,array<string,mixed>> $current_objects Current snapshot objects by shadow reference.
	 * @return array<int,array<string,mixed>>
	 */
	private function newVariationChanges( RecordingSession $session, array $current_objects ): array {
		$shadow_to_source = is_array( $session->variationMap()['shadow_to_source'] ?? null )
			? $session->variationMap()['shadow_to_source']
			: array();
		$changes          = array();

		foreach ( $current_objects as $shadow_reference => $object ) {
			if ( 'variation' !== (string) ( $object['object_type'] ?? '' ) ) {
				continue;
			}

			$shadow_id = (int) ( $object['object_id'] ?? 0 );

			if ( $shadow_id <= 0 || (int) ( $shadow_to_source[ (string) $shadow_id ] ?? 0 ) > 0 ) {
				continue;
			}

			$after     = $this->variationCreatePayload( $session, $object );
			$changes[] = array(
				'change_id'           => $this->syntheticChangeId( 'variation_create', $shadow_reference, null, $after ),
				'group'               => 'Variations',
				'source_reference'    => 'variation:future-' . $shadow_id,
				'shadow_reference'    => $shadow_reference,
				'object_id'           => 0,
				'object_type'         => 'variation',
				'product_type'        => 'variation',
				'field_id'            => 'variation.lifecycle.create',
				'field_label'         => 'Create variation',
				'before'              => null,
				'after'               => $after,
				'provider_id'         => 'woocommerce-core',
				'confidence'          => 'high',
				'selectable'          => true,
				'reversible'          => false,
				'validation_state'    => 'pending',
				'conflict_policy'     => 'fail',
				'dependency_summary'  => array(),
				'recording_operation' => 'create_variation',
			);
		}

		return $changes;
	}

	/**
	 * Builds a schedulable delete/trash change for a source variation removed from the shadow.
	 *
	 * @param array<string,mixed> $baseline_object Baseline source variation snapshot.
	 * @return array<string,mixed>
	 */
	private function deletedVariationChange( array $baseline_object, string $source_reference ): array {
		$after = array( 'mode' => 'trash' );

		return array(
			'change_id'           => $this->syntheticChangeId( 'variation_trash', $source_reference, null, $after ),
			'group'               => 'Variations',
			'source_reference'    => $source_reference,
			'shadow_reference'    => '',
			'object_id'           => (int) ( $baseline_object['object_id'] ?? 0 ),
			'object_type'         => 'variation',
			'product_type'        => 'variation',
			'field_id'            => 'variation.lifecycle.trash',
			'field_label'         => 'Trash variation',
			'before'              => array( 'status' => 'existing' ),
			'after'               => $after,
			'provider_id'         => 'woocommerce-core',
			'confidence'          => 'high',
			'selectable'          => true,
			'reversible'          => false,
			'validation_state'    => 'pending',
			'conflict_policy'     => 'fail',
			'dependency_summary'  => array(),
			'recording_operation' => 'trash_variation',
		);
	}

	/**
	 * Builds a VariationService-compatible payload from a new shadow variation snapshot.
	 *
	 * @param array<string,mixed> $snapshot_object Shadow variation snapshot.
	 * @return array<string,mixed>
	 */
	private function variationCreatePayload( RecordingSession $session, array $snapshot_object ): array {
		$values     = $this->providerValues( $snapshot_object, 'woocommerce-core' );
		$attributes = array();
		$payload    = array(
			'parent_reference' => 'product:' . $session->sourceProductId(),
			'attributes'       => array(),
			'values'           => array(),
		);

		$field_map = array(
			'core.status'                => 'status',
			'content.description'        => 'description',
			'core.purchase_note'         => 'purchase_note',
			'core.menu_order'            => 'menu_order',
			'pricing.regular_price'      => 'regular_price',
			'pricing.sale_price'         => 'sale_price',
			'pricing.sale_start'         => 'sale_start',
			'pricing.sale_end'           => 'sale_end',
			'inventory.sku'              => 'sku',
			'inventory.global_unique_id' => 'global_unique_id',
			'inventory.manage_stock'     => 'manage_stock',
			'inventory.stock_quantity'   => 'stock_quantity',
			'inventory.stock_status'     => 'stock_status',
			'inventory.backorders'       => 'backorders',
			'shipping.weight'            => 'weight',
			'shipping.dimensions.length' => 'length',
			'shipping.dimensions.width'  => 'width',
			'shipping.dimensions.height' => 'height',
			'shipping.class_id'          => 'shipping_class_id',
			'tax.class'                  => 'tax_class',
			'media.featured_image_id'    => 'image_id',
			'downloads.downloadable'     => 'downloadable',
			'downloads.virtual'          => 'virtual',
			'downloads.files'            => 'downloads',
			'downloads.limit'            => 'download_limit',
			'downloads.expiry'           => 'download_expiry',
		);

		foreach ( $values as $field_id => $value ) {
			$key = preg_replace( '/^variation\.\{stable-key\}\./', '', (string) $field_id );
			$key = is_string( $key ) ? $key : (string) $field_id;

			if ( 'attributes.assignment' === $key && is_array( $value ) ) {
				$attributes = $value;
				continue;
			}

			if ( isset( $field_map[ $key ] ) ) {
				$payload['values'][ $field_map[ $key ] ] = $value;
			}
		}

		$payload['attributes'] = array_map(
			static fn ( $value ): string => sanitize_title( (string) $value ),
			$attributes
		);

		return $payload;
	}

	/**
	 * @param array<string,mixed> $baseline_object Baseline source product snapshot.
	 * @param array<string,mixed> $current_object Current shadow product snapshot.
	 * @return array<int,array<string,mixed>>
	 */
	private function pendingTaxonomyChanges(
		array $baseline_object,
		array $current_object,
		string $source_reference
	): array {
		$pending     = is_array( $current_object['recording_pending_terms'] ?? null )
			? $current_object['recording_pending_terms']
			: array();
		$terms       = is_array( $pending['terms'] ?? null ) ? $pending['terms'] : array();
		$assignments = is_array( $pending['assignments'] ?? null ) ? $pending['assignments'] : array();
		$changes     = array();

		foreach ( $assignments as $taxonomy => $assignment ) {
			if ( ! is_array( $assignment ) ) {
				continue;
			}

			$field_id = 'product.taxonomy.' . (string) $taxonomy . '.term_ids';
			$field    = $this->fieldDefinition( $baseline_object, $field_id );

			if ( null === $field || ! $field->writable() || ! $field->schedulable() ) {
				continue;
			}

			$before = $this->providerValues( $baseline_object, 'product-taxonomies' )[ $field_id ] ?? array();
			$after  = $this->pendingTaxonomyPayload( (string) $taxonomy, $assignment, $terms );

			if ( array() === $after['pending_terms'] ) {
				continue;
			}

			$changes[] = array(
				'change_id'           => $this->syntheticChangeId( 'pending_taxonomy_terms', $source_reference, $before, $after ),
				'group'               => $this->groupForField( $field_id ),
				'source_reference'    => $source_reference,
				'shadow_reference'    => (string) ( $current_object['reference'] ?? '' ),
				'object_id'           => (int) ( $baseline_object['object_id'] ?? 0 ),
				'object_type'         => (string) ( $baseline_object['object_type'] ?? '' ),
				'product_type'        => (string) ( $baseline_object['product_type'] ?? '' ),
				'field_id'            => $field_id,
				'field_label'         => $field->label(),
				'before'              => $before,
				'after'               => $after,
				'provider_id'         => 'product-taxonomies',
				'confidence'          => 'high',
				'selectable'          => true,
				'reversible'          => $field->reversible(),
				'validation_state'    => 'pending',
				'conflict_policy'     => $field->conflictStrategy(),
				'dependency_summary'  => array(),
				'recording_operation' => 'replace_taxonomy_terms_with_pending_terms',
			);
		}

		return $changes;
	}

	/**
	 * @param array<int,int|string>             $assignment Existing term IDs and pending tokens.
	 * @param array<string,array<string,mixed>> $terms Pending term records.
	 * @return array<string,mixed>
	 */
	private function pendingTaxonomyPayload( string $taxonomy, array $assignment, array $terms ): array {
		$term_ids = array();
		$pending  = array();

		foreach ( $assignment as $value ) {
			if ( is_int( $value ) && $value > 0 ) {
				$term_ids[] = $value;
				continue;
			}

			if (
					is_string( $value )
					&& isset( $terms[ $value ] )
					&& (string) ( $terms[ $value ]['taxonomy'] ?? '' ) === $taxonomy
				) {
				$pending[ $value ] = $terms[ $value ];
			}
		}

		return array(
			'taxonomy'      => $taxonomy,
			'term_ids'      => array_values( array_unique( $term_ids ) ),
			'pending_terms' => $pending,
		);
	}

	/**
	 * @param array<string,mixed> $current_object Current shadow product snapshot.
	 */
	private function hasPendingTaxonomyAssignment( array $current_object, string $field_id ): bool {
		$taxonomy = $this->taxonomyFromFieldId( $field_id );

		if ( '' === $taxonomy ) {
			return false;
		}

		$pending     = is_array( $current_object['recording_pending_terms'] ?? null )
			? $current_object['recording_pending_terms']
			: array();
		$assignments = is_array( $pending['assignments'] ?? null ) ? $pending['assignments'] : array();

		return isset( $assignments[ $taxonomy ] );
	}

	private function taxonomyFromFieldId( string $field_id ): string {
		if ( 1 !== preg_match( '/^product\\.taxonomy\\.([a-z0-9_-]+)\\.term_ids$/', $field_id, $matches ) ) {
			return '';
		}

		return (string) $matches[1];
	}

	/**
	 * @param mixed $before
	 * @param mixed $after
	 */
	private function syntheticChangeId( string $type, string $reference, $before, $after ): string {
		return hash(
			'sha256',
			Json::canonicalEncode(
				array(
					$type,
					$reference,
					$before,
					$after,
				)
			)
		);
	}

	/**
	 * @param array<string,mixed> $left Left object.
	 * @param array<string,mixed> $right Right object.
	 * @return string[]
	 */
	private function providerIds( array $left, array $right ): array {
		$left_ids  = array_keys( is_array( $left['providers'] ?? null ) ? $left['providers'] : array() );
		$right_ids = array_keys( is_array( $right['providers'] ?? null ) ? $right['providers'] : array() );
		$ids       = array_values( array_unique( array_merge( $left_ids, $right_ids ) ) );
		sort( $ids );

		return $ids;
	}

	/**
	 * @param array<string,mixed> $snapshot_object Object snapshot.
	 * @return array<string,mixed>
	 */
	private function providerValues( array $snapshot_object, string $provider_id ): array {
		$providers = is_array( $snapshot_object['providers'] ?? null ) ? $snapshot_object['providers'] : array();
		$provider  = is_array( $providers[ $provider_id ] ?? null ) ? $providers[ $provider_id ] : array();
		$values    = is_array( $provider['values'] ?? null ) ? $provider['values'] : array();

		return $values;
	}

	/**
	 * @param array<string,mixed> $snapshot_object Object snapshot.
	 * @return array<int,array<string,mixed>>
	 */
	private function objectWarnings( array $snapshot_object ): array {
		$warnings = is_array( $snapshot_object['warnings'] ?? null ) ? $snapshot_object['warnings'] : array();

		return array_values( array_filter( $warnings, 'is_array' ) );
	}

	/**
	 * Converts unchanged unique placeholders back to their logical source value.
	 *
	 * @param array<string,mixed> $baseline_object Baseline source object snapshot.
	 * @param mixed               $before
	 * @param mixed               $after
	 * @return mixed
	 */
	private function normalizePlaceholder(
		RecordingSession $session,
		array $baseline_object,
		string $field_id,
		$before,
		$after
	) {
		$placeholder = $this->placeholderFor( $session, $baseline_object, $field_id );

		if ( null !== $placeholder && $after === $placeholder ) {
			return $before;
		}

		if (
			'product.core.slug' === $field_id
			&& $after === $this->shadowSlug( $session, $baseline_object )
		) {
			return $before;
		}

		if (
			'variation.{stable-key}.relationships.parent_id' === $field_id
			&& (int) $after === $session->shadowProductId()
		) {
			return $before;
		}

		return $after;
	}

	/**
	 * Removes changes introduced by the isolated shadow workspace itself.
	 *
	 * @param array<string,mixed> $baseline_object Baseline source object snapshot.
	 * @param mixed               $before
	 * @param mixed               $after
	 * @return mixed
	 */
	private function normalizeShadowArtifact(
		array $baseline_object,
		string $field_id,
		$before,
		$after
	) {
		$object_type  = (string) ( $baseline_object['object_type'] ?? '' );
		$product_type = (string) ( $baseline_object['product_type'] ?? '' );

		if (
			'product.inventory.stock_status' === $field_id
			&& 'product' === $object_type
			&& 'variable' === $product_type
			&& 'outofstock' === $after
		) {
			return $before;
		}

		return $after;
	}

	/**
	 * @param array<string,mixed> $baseline_object Baseline source object snapshot.
	 */
	private function shadowSlug( RecordingSession $session, array $baseline_object ): string {
		return 'scheduled-changes-recording-' . substr( $session->uuid()->value(), 0, 12 ) . '-' . (int) ( $baseline_object['object_id'] ?? 0 );
	}

	/**
	 * @param mixed $value
	 * @return mixed
	 */
	private function normalizeComparableValue( string $field_id, $value ) {
		if ( 'product.attributes.definitions' !== $field_id || ! is_array( $value ) ) {
			return $value;
		}

		return array_values(
			array_map(
				static function ( $attribute ) {
					if ( ! is_array( $attribute ) ) {
						return $attribute;
					}

					if ( isset( $attribute['name'] ) && is_scalar( $attribute['name'] ) ) {
						$attribute['name'] = strtolower( (string) $attribute['name'] );
					}

					return $attribute;
				},
				$value
			)
		);
	}

	/**
	 * @param array<string,mixed> $baseline_object Baseline source object snapshot.
	 * @return mixed
	 */
	private function placeholderFor( RecordingSession $session, array $baseline_object, string $field_id ) {
		$provider_map = $session->providerMap();
		$key          = preg_replace( '/^(product|variation\.\{stable-key\})\./', '', $field_id );
		$field_key    = is_string( $key ) ? $key : $field_id;
		$object_type  = (string) ( $baseline_object['object_type'] ?? '' );

		if ( 'product' === $object_type ) {
			return $provider_map['unique_placeholders']['product'][ $field_key ]['placeholder'] ?? null;
		}

		$reference = (string) ( $baseline_object['reference'] ?? '' );

		return $provider_map['unique_placeholders']['variations'][ $reference ][ $field_key ]['placeholder'] ?? null;
	}

	/**
	 * @param array<string,mixed> $baseline_object Baseline source object snapshot.
	 */
	private function fieldDefinition( array $baseline_object, string $field_id ): ?FieldDefinition {
		$object_id = (int) ( $baseline_object['object_id'] ?? 0 );

		if ( $object_id <= 0 ) {
			return null;
		}

		$context = new ObjectContext(
			(string) ( $baseline_object['object_type'] ?? '' ),
			$object_id,
			(string) ( $baseline_object['product_type'] ?? '' )
		);

		return $this->fields->discover( $context )->fields()->get( $field_id );
	}

	private function groupForField( string $field_id, string $provider_id = '', ?FieldDefinition $field = null ): string {
		if ( null !== $field ) {
			$display = $field->display();
			$group   = $this->displayString( $display, 'group_label' );

			if ( '' !== $group ) {
				return $group;
			}
		}

		switch ( true ) {
			case strpos( $field_id, '.pricing.' ) !== false:
				return 'Pricing';
			case strpos( $field_id, '.inventory.' ) !== false:
				return 'Inventory';
			case strpos( $field_id, '.shipping.' ) !== false:
				return 'Shipping and tax';
			case strpos( $field_id, '.tax.' ) !== false:
				return 'Shipping and tax';
			case strpos( $field_id, '.media.' ) !== false:
				return 'Media';
			case strpos( $field_id, '.attributes.' ) !== false:
				return 'Attributes';
			case strncmp( $field_id, 'variation.', strlen( 'variation.' ) ) === 0:
				return 'Variations';
			case strpos( $field_id, '.content.' ) !== false:
				return 'Content';
			default:
				return 'woocommerce-core' !== $provider_id && '' !== $provider_id
					? 'Provider fields'
					: 'Product details';
		}
	}

	/**
	 * @param array<string,mixed> $display Display metadata.
	 */
	private function displayString( array $display, string $key ): string {
		return isset( $display[ $key ] ) && is_scalar( $display[ $key ] )
			? (string) $display[ $key ]
			: '';
	}

	/**
	 * Detects metadata changed on the shadow that no provider owns safely.
	 *
	 * @param array<string,mixed> $baseline_object Baseline source object snapshot.
	 * @param array<string,mixed> $current_object Current shadow object snapshot.
	 * @return array<int,array<string,mixed>>
	 */
	private function unsupportedMetadataWarnings( array $baseline_object, array $current_object ): array {
		$source_id = (int) ( $baseline_object['object_id'] ?? 0 );
		$shadow_id = (int) ( $current_object['object_id'] ?? 0 );

		if ( $source_id <= 0 || $shadow_id <= 0 ) {
			return array();
		}

		$owned_keys = $this->ownedMetadataKeys( $baseline_object, $current_object );
		$source     = $this->rawMetaValues( $source_id );
		$shadow     = $this->rawMetaValues( $shadow_id );
		$keys       = array_values( array_unique( array_merge( array_keys( $source ), array_keys( $shadow ) ) ) );
		$warnings   = array();

		sort( $keys );

		foreach ( $keys as $key ) {
			if (
				isset( $owned_keys[ $key ] )
				|| $this->metadata_denylist->isDenied( $key )
				|| $this->isNativeWooCommerceMeta( $key )
			) {
				continue;
			}

			if ( ( $source[ $key ] ?? array() ) === ( $shadow[ $key ] ?? array() ) ) {
				continue;
			}

			$warnings[] = array(
				'code'             => 'unsupported_metadata_changed',
				'source_reference' => (string) ( $baseline_object['reference'] ?? '' ),
				'shadow_reference' => (string) ( $current_object['reference'] ?? '' ),
				'meta_key'         => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Warning payload key, not a query argument.
				'message'          => 'Metadata changed during recording but no writable provider owns it; it was not scheduled.',
			);
		}

		return $warnings;
	}

	/**
	 * @param array<string,mixed> $baseline_object Baseline source object snapshot.
	 * @param array<string,mixed> $current_object Current shadow object snapshot.
	 * @return array<string,bool>
	 */
	private function ownedMetadataKeys( array $baseline_object, array $current_object ): array {
		$keys = array();

		foreach ( array( $baseline_object, $current_object ) as $object ) {
			foreach ( is_array( $object['providers'] ?? null ) ? $object['providers'] : array() as $provider ) {
				$values = is_array( $provider['values'] ?? null ) ? $provider['values'] : array();

				foreach ( array_keys( $values ) as $field_id ) {
					$field = $this->fieldDefinition( $object, (string) $field_id );
					$key   = null === $field ? '' : (string) ( $field->validationRules()['meta_key'] ?? '' );

					if ( '' !== $key ) {
						$keys[ $key ] = true;
					}
				}
			}
		}

		return $keys;
	}

	/**
	 * @return array<string,array<int,mixed>>
	 */
	private function rawMetaValues( int $post_id ): array {
		$raw = get_post_meta( $post_id );
		$out = array();

		foreach ( is_array( $raw ) ? $raw : array() as $key => $values ) {
			if ( ! is_array( $values ) ) {
				continue;
			}

			$out[ (string) $key ] = array_map(
				static fn ( $value ) => maybe_unserialize( $value ),
				$values
			);
			sort( $out[ (string) $key ] );
		}

		ksort( $out );

		return $out;
	}

	private function isNativeWooCommerceMeta( string $key ): bool {
		if ( strncmp( $key, '_scheduled_changes_', strlen( '_scheduled_changes_' ) ) === 0 ) {
			return true;
		}

		return in_array(
			$key,
			array(
				'_backorders',
				'_button_text',
				'_cogs_total_value',
				'_cogs_value',
				'_crosssell_ids',
				'_default_attributes',
				'_download_expiry',
				'_download_limit',
				'_downloadable',
				'_downloadable_files',
				'_featured',
				'_global_unique_id',
				'_height',
				'_length',
				'_low_stock_amount',
				'_manage_stock',
				'_price',
				'_product_attributes',
				'_product_image_gallery',
				'_product_url',
				'_purchase_note',
				'_regular_price',
				'_sale_price',
				'_sale_price_dates_from',
				'_sale_price_dates_to',
				'_sku',
				'_sold_individually',
				'_stock',
				'_stock_status',
				'_tax_class',
				'_tax_status',
				'_thumbnail_id',
				'_upsell_ids',
				'_virtual',
				'_weight',
				'_width',
				'total_sales',
			),
			true
		);
	}
}
