<?php
/** Read-only snapshot and comparison abilities for supported content-model providers. */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'WPAGENT_CONTENT_MODEL_COMPARISON_MAX_ITEMS', 80 );
define( 'WPAGENT_CONTENT_MODEL_COMPARISON_VALUE_MAX_BYTES', 65536 );
define( 'WPAGENT_CONTENT_MODEL_COMPARISON_MAX_BYTES', 524288 );

/** @return array<string,mixed> */
function wpagent_content_model_snapshot_schema(): array {
	$field_settings = array(
		'type' => 'object',
		'properties' => array(
			'multiple' => array( 'type' => 'boolean' ),
			'repeatable' => array( 'type' => 'boolean' ),
			'relationship_object' => array( 'type' => 'string' ),
			'relationship_target' => array( 'type' => 'string' ),
		),
		'additionalProperties' => false,
	);
	$field = array(
		'type' => 'object',
		'properties' => array(
			'name' => array( 'type' => 'string' ), 'label' => array( 'type' => 'string' ), 'type' => array( 'type' => 'string' ),
			'required' => array( 'type' => 'boolean' ),
			'options' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'label' => array( 'type' => 'string' ), 'value' => array( 'type' => 'string' ) ), 'required' => array( 'label', 'value' ), 'additionalProperties' => false ) ),
			'settings' => $field_settings,
		),
		'required' => array( 'name', 'label', 'type', 'required', 'options', 'settings' ), 'additionalProperties' => false,
	);
	return array(
		'type' => 'object',
		'properties' => array(
			'schema_version' => array( 'type' => 'integer', 'const' => WPAGENT_CONTENT_MODEL_SNAPSHOT_VERSION ),
			'site_binding' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ),
			'provider' => array( 'type' => 'string', 'enum' => wpagent_content_model_providers() ), 'provider_version' => array( 'type' => 'string' ),
			'capabilities' => array( 'type' => 'object', 'properties' => array( 'snapshot' => array( 'type' => 'boolean' ), 'compare' => array( 'type' => 'boolean' ), 'migration_source' => array( 'type' => 'boolean' ), 'migration_target' => array( 'type' => 'boolean' ) ), 'required' => array( 'snapshot', 'compare', 'migration_source', 'migration_target' ), 'additionalProperties' => false ),
			'identifier' => array( 'type' => 'string' ), 'exists' => array( 'type' => 'boolean' ), 'kind' => array( 'type' => 'string' ), 'label' => array( 'type' => 'string' ),
			'target' => array( 'type' => 'object', 'properties' => array( 'type' => array( 'type' => 'string' ), 'names' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ) ), 'required' => array( 'type', 'names' ), 'additionalProperties' => false ),
			'settings' => array(
				'type' => 'object',
				'properties' => array(
					'enabled' => array( 'type' => 'boolean' ),
					'storage' => array( 'type' => 'string' ),
					'public' => array( 'type' => 'boolean' ),
					'hierarchical' => array( 'type' => 'boolean' ),
					'show_in_rest' => array( 'type' => 'boolean' ),
				),
				'additionalProperties' => false,
			),
			'fields' => array( 'type' => 'array', 'maxItems' => WPAGENT_CONTENT_MODEL_SNAPSHOT_MAX_FIELDS, 'items' => $field ),
			'provider_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ), 'warnings' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), 'snapshot_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ),
		),
		'required' => array( 'schema_version', 'site_binding', 'provider', 'provider_version', 'capabilities', 'identifier', 'exists', 'kind', 'label', 'target', 'settings', 'fields', 'provider_hash', 'warnings', 'snapshot_hash' ),
		'additionalProperties' => false,
	);
}

/**
 * Input schema for readonly snapshot comparisons transported as query params.
 *
 * PHP bracket queries cannot represent empty arrays or objects. The CLI omits
 * those values, so only collection properties whose canonical empty value is
 * unambiguous may be absent in the transport schema. The callback restores
 * them before the existing exact-shape and integrity-hash validation.
 *
 * @return array<string,mixed>
 */
function wpagent_content_model_snapshot_input_schema(): array {
	$schema = wpagent_content_model_snapshot_schema();
	$schema['required'] = array_values( array_diff( $schema['required'], array( 'settings', 'fields', 'warnings' ) ) );
	$schema['properties']['target']['required'] = array( 'type' );
	$schema['properties']['fields']['items']['required'] = array( 'name', 'label', 'type', 'required' );
	return $schema;
}

/** @return mixed */
function wpagent_content_model_normalize_transported_snapshot( $snapshot ) {
	if ( ! is_array( $snapshot ) ) {
		return $snapshot;
	}
	$sanitized = rest_sanitize_value_from_schema( $snapshot, wpagent_content_model_snapshot_input_schema() );
	if ( ! is_wp_error( $sanitized ) ) {
		$snapshot = $sanitized;
	}
	if ( ! array_key_exists( 'settings', $snapshot ) ) {
		$snapshot['settings'] = array();
	}
	if ( ! array_key_exists( 'fields', $snapshot ) ) {
		$snapshot['fields'] = array();
	}
	if ( ! array_key_exists( 'warnings', $snapshot ) ) {
		$snapshot['warnings'] = array();
	}
	if ( is_array( $snapshot['target'] ?? null ) && ! array_key_exists( 'names', $snapshot['target'] ) ) {
		$snapshot['target']['names'] = array();
	}
	if ( is_array( $snapshot['fields'] ) ) {
		foreach ( $snapshot['fields'] as &$field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			if ( ! array_key_exists( 'options', $field ) ) {
				$field['options'] = array();
			}
			if ( ! array_key_exists( 'settings', $field ) ) {
				$field['settings'] = array();
			}
		}
		unset( $field );
	}
	return $snapshot;
}

/** @return array<string,mixed> */
function wpagent_ability_snapshot_content_model(): array {
	return array(
		'label' => __( 'Datenmodell-Snapshot erstellen', 'wp-agent-companion' ),
		'description' => __( 'Liest ein begrenztes ACF-, Pods-, ACPT-Lite- oder Meta-Box-Datenmodell in einer deterministischen, an Website und Provider-Version gebundenen Form. Nicht verfügbare Provider bleiben explizit nicht migrierbar.', 'wp-agent-companion' ),
		'category' => 'wp-agent',
		'input_schema' => array( 'type' => 'object', 'properties' => array( 'provider' => array( 'type' => 'string', 'enum' => wpagent_content_model_providers() ), 'identifier' => array( 'type' => 'string', 'maxLength' => 121 ) ), 'required' => array( 'provider', 'identifier' ), 'additionalProperties' => false ),
		'output_schema' => wpagent_content_model_snapshot_schema(), 'execute_callback' => 'wpagent_exec_snapshot_content_model', 'permission_callback' => 'wpagent_perm_manage_options',
		'meta' => array( 'show_in_rest' => true, 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ) ),
	);
}

/** @return array<string,mixed>|WP_Error */
function wpagent_exec_snapshot_content_model( $input ) {
	$input = is_array( $input ) ? $input : array();
	return wpagent_content_model_snapshot( is_string( $input['provider'] ?? null ) ? $input['provider'] : '', $input['identifier'] ?? null );
}

/** @return array<string,mixed>|WP_Error */
function wpagent_content_model_validate_snapshot( $snapshot ) {
	if ( ! is_array( $snapshot ) || array_is_list( $snapshot ) ) {
		return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Ein Datenmodell-Snapshot muss ein Objekt sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$expected = array( 'schema_version', 'site_binding', 'provider', 'provider_version', 'capabilities', 'identifier', 'exists', 'kind', 'label', 'target', 'settings', 'fields', 'provider_hash', 'warnings', 'snapshot_hash' );
	if ( array_diff( array_keys( $snapshot ), $expected ) || array_diff( $expected, array_keys( $snapshot ) ) || WPAGENT_CONTENT_MODEL_SNAPSHOT_VERSION !== (int) ( $snapshot['schema_version'] ?? 0 ) || ! in_array( $snapshot['provider'] ?? '', wpagent_content_model_providers(), true ) || ! is_string( $snapshot['site_binding'] ?? null ) || ! preg_match( '/^[a-f0-9]{64}$/D', $snapshot['site_binding'] ) || ! is_string( $snapshot['provider_version'] ?? null ) || strlen( $snapshot['provider_version'] ) > 100 || ! is_string( $snapshot['identifier'] ?? null ) || strlen( $snapshot['identifier'] ) > 121 || ! is_bool( $snapshot['exists'] ?? null ) || ! is_string( $snapshot['kind'] ?? null ) || strlen( $snapshot['kind'] ) > 50 || ! is_string( $snapshot['label'] ?? null ) || strlen( $snapshot['label'] ) > 200 || ! is_array( $snapshot['target'] ?? null ) || ! is_array( $snapshot['settings'] ?? null ) || ! is_array( $snapshot['fields'] ?? null ) || count( $snapshot['fields'] ) > WPAGENT_CONTENT_MODEL_SNAPSHOT_MAX_FIELDS || ! is_array( $snapshot['warnings'] ?? null ) || count( $snapshot['warnings'] ) > 20 || ! is_string( $snapshot['provider_hash'] ?? null ) || ! preg_match( '/^[a-f0-9]{64}$/D', $snapshot['provider_hash'] ) || ! is_string( $snapshot['snapshot_hash'] ?? null ) || ! preg_match( '/^[a-f0-9]{64}$/D', $snapshot['snapshot_hash'] ) ) {
		return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot ist unvollständig oder liegt außerhalb der sicheren Grenze.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$identifier = wpagent_content_model_identifier( $snapshot['provider'], $snapshot['identifier'] );
	if ( is_wp_error( $identifier ) || ! hash_equals( $identifier, $snapshot['identifier'] ) ) {
		return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot enthält einen ungültigen Anbieterbezeichner.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$capabilities = $snapshot['capabilities'];
	if ( array_diff( array_keys( $capabilities ), array( 'snapshot', 'compare', 'migration_source', 'migration_target' ) ) || array_diff( array( 'snapshot', 'compare', 'migration_source', 'migration_target' ), array_keys( $capabilities ) ) ) {
		return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot enthält keine vollständigen Anbieterfähigkeiten.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	foreach ( $capabilities as $capability ) if ( ! is_bool( $capability ) ) return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot enthält ungültige Anbieterfähigkeiten.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	$target = $snapshot['target'];
	if ( array_diff( array_keys( $target ), array( 'type', 'names' ) ) || array_diff( array( 'type', 'names' ), array_keys( $target ) ) || ! is_string( $target['type'] ?? null ) || ! is_array( $target['names'] ?? null ) || count( $target['names'] ) > 20 ) return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot enthält eine ungültige Zielbindung.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	$target_names = array();
	foreach ( $target['names'] as $name ) {
		if ( ! is_string( $name ) || '' === $name || strlen( $name ) > 64 || isset( $target_names[ $name ] ) ) return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot enthält einen ungültigen Zielnamen.', 'wp-agent-companion' ), array( 'status' => 400 ) );
		$target_names[ $name ] = true;
	}
	$names = array();
	foreach ( $snapshot['fields'] as $field ) {
		if ( ! is_array( $field ) || array_diff( array_keys( $field ), array( 'name', 'label', 'type', 'required', 'options', 'settings' ) ) || array_diff( array( 'name', 'label', 'type', 'required', 'options', 'settings' ), array_keys( $field ) ) || ! is_string( $field['name'] ?? null ) || '' === $field['name'] || strlen( $field['name'] ) > 64 || isset( $names[ $field['name'] ] ) || ! is_string( $field['label'] ?? null ) || strlen( $field['label'] ) > 200 || ! is_string( $field['type'] ?? null ) || strlen( $field['type'] ) > 64 || ! is_bool( $field['required'] ?? null ) || ! is_array( $field['options'] ?? null ) || count( $field['options'] ) > 20 || ! is_array( $field['settings'] ?? null ) ) {
			return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot enthält ein ungültiges Feld.', 'wp-agent-companion' ), array( 'status' => 400 ) );
		}
		$names[ $field['name'] ] = true;
		foreach ( $field['options'] as $option ) if ( ! is_array( $option ) || array_diff( array_keys( $option ), array( 'label', 'value' ) ) || array_diff( array( 'label', 'value' ), array_keys( $option ) ) || ! is_string( $option['label'] ?? null ) || ! is_string( $option['value'] ?? null ) || strlen( $option['label'] ) > 100 || strlen( $option['value'] ) > 100 ) return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot enthält eine ungültige Auswahloption.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$nodes = 0;
	if ( ! wpagent_content_model_within_limits( $snapshot, 0, $nodes ) ) return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot ist zu tief oder zu groß.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	$encoded = wp_json_encode( $snapshot, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
	if ( false === $encoded || strlen( $encoded ) > WPAGENT_CONTENT_MODEL_SNAPSHOT_MAX_BYTES ) return new WP_Error( 'wpagent_content_model_snapshot_invalid', __( 'Der Datenmodell-Snapshot überschreitet die sichere Größengrenze.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	$hash = $snapshot['snapshot_hash']; unset( $snapshot['snapshot_hash'] );
	if ( ! hash_equals( wpagent_content_model_hash( $snapshot ), $hash ) ) return new WP_Error( 'wpagent_content_model_snapshot_tampered', __( 'Der Datenmodell-Snapshot stimmt nicht mehr mit seinem Integritätshash überein.', 'wp-agent-companion' ), array( 'status' => 409 ) );
	$snapshot['snapshot_hash'] = $hash;
	return $snapshot;
}

/** @return array<string,mixed> */
function wpagent_content_model_comparison_schema(): array {
	$value  = wpagent_builder_json_document_schema( WPAGENT_CONTENT_MODEL_COMPARISON_VALUE_MAX_BYTES );
	$scope  = array( 'type' => 'string', 'enum' => array( 'snapshot', 'provider', 'model', 'field' ) );
	$name   = array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 128 );
	$change = array( 'type' => 'object', 'properties' => array( 'scope' => $scope, 'name' => $name, 'before' => $value, 'after' => $value ), 'required' => array( 'scope', 'name', 'before', 'after' ), 'additionalProperties' => false );
	$loss   = array( 'type' => 'object', 'properties' => array( 'scope' => $scope, 'name' => $name, 'reason' => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 64, 'pattern' => '^[a-z][a-z0-9_]{0,63}$' ), 'before' => $value, 'after' => $value ), 'required' => array( 'scope', 'name', 'reason', 'before', 'after' ), 'additionalProperties' => false );
	return array( 'type' => 'object', 'properties' => array( 'left_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ), 'right_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ), 'additions' => array( 'type' => 'array', 'maxItems' => WPAGENT_CONTENT_MODEL_COMPARISON_MAX_ITEMS, 'items' => $change ), 'removals' => array( 'type' => 'array', 'maxItems' => WPAGENT_CONTENT_MODEL_COMPARISON_MAX_ITEMS, 'items' => $change ), 'compatible_changes' => array( 'type' => 'array', 'maxItems' => WPAGENT_CONTENT_MODEL_COMPARISON_MAX_ITEMS, 'items' => $change ), 'losses' => array( 'type' => 'array', 'maxItems' => WPAGENT_CONTENT_MODEL_COMPARISON_MAX_ITEMS, 'items' => $loss ), 'lossless' => array( 'type' => 'boolean' ) ), 'required' => array( 'left_hash', 'right_hash', 'additions', 'removals', 'compatible_changes', 'losses', 'lossless' ), 'additionalProperties' => false );
}

function wpagent_content_model_comparison_invalid_output() {
	return new WP_Error( 'wpagent_content_model_comparison_output_invalid', __( 'Der Datenmodellvergleich überschreitet den sicheren, integritätsgebundenen Ausgabeumfang.', 'wp-agent-companion' ), array( 'status' => 500 ) );
}

/** @param mixed $value */
function wpagent_content_model_comparison_text_valid( $value, int $minimum, int $maximum, ?string $pattern = null ): bool {
	if ( ! is_string( $value ) || strlen( $value ) < $minimum || strlen( $value ) > $maximum || wp_check_invalid_utf8( $value, false ) !== $value || preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value ) ) {
		return false;
	}
	return null === $pattern || 1 === preg_match( $pattern, $value );
}

/** @param mixed $value */
function wpagent_content_model_comparison_value_valid( $value ) {
	$nodes = 0;
	return wpagent_content_model_within_limits( $value, 0, $nodes ) ? true : wpagent_content_model_comparison_invalid_output();
}

/** @return array{json:string,sha256:string,bytes:int}|WP_Error */
function wpagent_content_model_comparison_value_envelope( $value ) {
	$document = wpagent_builder_json_document_from_value( $value, WPAGENT_CONTENT_MODEL_COMPARISON_VALUE_MAX_BYTES, 'wpagent_content_model_comparison_value_valid', 'wpagent_content_model_comparison' );
	return is_wp_error( $document ) ? wpagent_content_model_comparison_invalid_output() : $document;
}

/** @return bool */
function wpagent_content_model_comparison_entry_meta_valid( array $entry, bool $loss ): bool {
	$expected = $loss ? array( 'scope', 'name', 'reason', 'before', 'after' ) : array( 'scope', 'name', 'before', 'after' );
	return $expected === array_keys( $entry )
		&& in_array( $entry['scope'], array( 'snapshot', 'provider', 'model', 'field' ), true )
		&& wpagent_content_model_comparison_text_valid( $entry['name'], 1, 128 )
		&& ( ! $loss || wpagent_content_model_comparison_text_valid( $entry['reason'], 1, 64, '/^[a-z][a-z0-9_]{0,63}$/' ) );
}

/** @return array<int,array<string,mixed>>|WP_Error */
function wpagent_content_model_comparison_bound_entries( array $entries, bool $loss ) {
	if ( count( $entries ) > WPAGENT_CONTENT_MODEL_COMPARISON_MAX_ITEMS ) {
		return wpagent_content_model_comparison_invalid_output();
	}
	$bound = array();
	foreach ( $entries as $entry ) {
		if ( ! is_array( $entry ) || ! wpagent_content_model_comparison_entry_meta_valid( $entry, $loss ) ) {
			return wpagent_content_model_comparison_invalid_output();
		}
		$before = wpagent_content_model_comparison_value_envelope( $entry['before'] );
		$after  = wpagent_content_model_comparison_value_envelope( $entry['after'] );
		if ( is_wp_error( $before ) || is_wp_error( $after ) ) {
			return wpagent_content_model_comparison_invalid_output();
		}
		$entry['before'] = $before;
		$entry['after']  = $after;
		$bound[]         = $entry;
	}
	return $bound;
}

/** @return true|WP_Error */
function wpagent_content_model_validate_comparison( array $comparison ) {
	$expected = array( 'left_hash', 'right_hash', 'additions', 'removals', 'compatible_changes', 'losses', 'lossless' );
	if ( $expected !== array_keys( $comparison ) || ! is_string( $comparison['left_hash'] ) || ! is_string( $comparison['right_hash'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $comparison['left_hash'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $comparison['right_hash'] ) || ! is_bool( $comparison['lossless'] ) ) {
		return wpagent_content_model_comparison_invalid_output();
	}
	foreach ( array( 'additions' => false, 'removals' => false, 'compatible_changes' => false, 'losses' => true ) as $property => $loss ) {
		if ( ! is_array( $comparison[ $property ] ) || count( $comparison[ $property ] ) > WPAGENT_CONTENT_MODEL_COMPARISON_MAX_ITEMS ) {
			return wpagent_content_model_comparison_invalid_output();
		}
		foreach ( $comparison[ $property ] as $entry ) {
			if ( ! is_array( $entry ) || ! wpagent_content_model_comparison_entry_meta_valid( $entry, $loss ) || is_wp_error( wpagent_builder_json_document_to_value( $entry['before'], WPAGENT_CONTENT_MODEL_COMPARISON_VALUE_MAX_BYTES, 'wpagent_content_model_comparison_value_valid', 'wpagent_content_model_comparison' ) ) || is_wp_error( wpagent_builder_json_document_to_value( $entry['after'], WPAGENT_CONTENT_MODEL_COMPARISON_VALUE_MAX_BYTES, 'wpagent_content_model_comparison_value_valid', 'wpagent_content_model_comparison' ) ) ) {
				return wpagent_content_model_comparison_invalid_output();
			}
		}
	}
	$encoded = wp_json_encode( $comparison, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
	return false === $encoded || strlen( $encoded ) > WPAGENT_CONTENT_MODEL_COMPARISON_MAX_BYTES ? wpagent_content_model_comparison_invalid_output() : true;
}

/** @return array<string,mixed> */
function wpagent_ability_compare_content_model_snapshots(): array {
	return array(
		'label' => __( 'Datenmodell-Snapshots vergleichen', 'wp-agent-companion' ),
		'description' => __( 'Vergleicht zwei integritätsgeprüfte Datenmodell-Snapshots mit exakten Feldzugängen, Entfernungen, kompatiblen Änderungen und Verlusthinweisen. Es schreibt nichts.', 'wp-agent-companion' ),
		'category' => 'wp-agent',
		'input_schema' => array( 'type' => 'object', 'properties' => array( 'left' => wpagent_content_model_snapshot_input_schema(), 'right' => wpagent_content_model_snapshot_input_schema() ), 'required' => array( 'left', 'right' ), 'additionalProperties' => false ),
		'output_schema' => wpagent_content_model_comparison_schema(), 'execute_callback' => 'wpagent_exec_compare_content_model_snapshots', 'permission_callback' => 'wpagent_perm_manage_options',
		'meta' => array( 'show_in_rest' => true, 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ) ),
	);
}

/** @return array<string,mixed>|WP_Error */
function wpagent_content_model_compare( array $left, array $right ) {
	$additions = array(); $removals = array(); $changes = array(); $losses = array();
	if ( ! hash_equals( $left['site_binding'], $right['site_binding'] ) ) $losses[] = array( 'scope' => 'snapshot', 'name' => 'site_binding', 'reason' => 'different_site', 'before' => $left['site_binding'], 'after' => $right['site_binding'] );
	if ( $left['provider'] !== $right['provider'] ) $changes[] = array( 'scope' => 'provider', 'name' => 'provider', 'before' => $left['provider'], 'after' => $right['provider'] );
	if ( $left['provider_version'] !== $right['provider_version'] ) $changes[] = array( 'scope' => 'provider', 'name' => 'version', 'before' => $left['provider_version'], 'after' => $right['provider_version'] );
	foreach ( array( 'kind', 'target', 'settings' ) as $key ) if ( wpagent_content_model_canonicalize( $left[ $key ] ) !== wpagent_content_model_canonicalize( $right[ $key ] ) ) $losses[] = array( 'scope' => 'model', 'name' => $key, 'reason' => 'incompatible_model_change', 'before' => $left[ $key ], 'after' => $right[ $key ] );
	if ( $left['label'] !== $right['label'] ) $changes[] = array( 'scope' => 'model', 'name' => 'label', 'before' => $left['label'], 'after' => $right['label'] );
	foreach ( array( 'left' => $left, 'right' => $right ) as $side => $snapshot ) if ( ! $snapshot['capabilities']['snapshot'] ) $losses[] = array( 'scope' => 'provider', 'name' => $side, 'reason' => 'provider_unavailable', 'before' => $snapshot['warnings'], 'after' => array() );
	foreach ( array( 'left' => $left, 'right' => $right ) as $side => $snapshot ) foreach ( $snapshot['warnings'] as $warning ) $losses[] = array( 'scope' => 'provider', 'name' => $side, 'reason' => (string) $warning, 'before' => array(), 'after' => array() );
	$left_fields = array_column( $left['fields'], null, 'name' ); $right_fields = array_column( $right['fields'], null, 'name' );
	foreach ( $left_fields as $name => $field ) {
		if ( ! isset( $right_fields[ $name ] ) ) { $removals[] = array( 'scope' => 'field', 'name' => $name, 'before' => $field, 'after' => null ); continue; }
		$other = $right_fields[ $name ];
		if ( $field['type'] !== $other['type'] || wpagent_content_model_canonicalize( $field['settings'] ) !== wpagent_content_model_canonicalize( $other['settings'] ) ) { $losses[] = array( 'scope' => 'field', 'name' => $name, 'reason' => 'incompatible_field_change', 'before' => $field, 'after' => $other ); continue; }
		if ( wpagent_content_model_canonicalize( $field ) !== wpagent_content_model_canonicalize( $other ) ) $changes[] = array( 'scope' => 'field', 'name' => $name, 'before' => $field, 'after' => $other );
	}
	foreach ( $right_fields as $name => $field ) if ( ! isset( $left_fields[ $name ] ) ) $additions[] = array( 'scope' => 'field', 'name' => $name, 'before' => null, 'after' => $field );
	$sort = static function ( array &$items ): void { usort( $items, static fn( array $a, array $b ): int => strcmp( $a['scope'] . "\0" . $a['name'] . "\0" . ( $a['reason'] ?? '' ), $b['scope'] . "\0" . $b['name'] . "\0" . ( $b['reason'] ?? '' ) ) ); };
	$sort( $additions ); $sort( $removals ); $sort( $changes ); $sort( $losses );
	$additions = wpagent_content_model_comparison_bound_entries( $additions, false );
	$removals  = wpagent_content_model_comparison_bound_entries( $removals, false );
	$changes   = wpagent_content_model_comparison_bound_entries( $changes, false );
	$losses    = wpagent_content_model_comparison_bound_entries( $losses, true );
	if ( is_wp_error( $additions ) || is_wp_error( $removals ) || is_wp_error( $changes ) || is_wp_error( $losses ) ) {
		return wpagent_content_model_comparison_invalid_output();
	}
	$result = array( 'left_hash' => $left['snapshot_hash'], 'right_hash' => $right['snapshot_hash'], 'additions' => $additions, 'removals' => $removals, 'compatible_changes' => $changes, 'losses' => $losses, 'lossless' => array() === $additions && array() === $removals && array() === $losses );
	$output_valid = wpagent_content_model_validate_comparison( $result );
	return is_wp_error( $output_valid ) ? $output_valid : $result;
}

/** @return array<string,mixed>|WP_Error */
function wpagent_exec_compare_content_model_snapshots( $input ) {
	$input = is_array( $input ) ? $input : array();
	$left = wpagent_content_model_validate_snapshot( wpagent_content_model_normalize_transported_snapshot( $input['left'] ?? null ) );
	$right = wpagent_content_model_validate_snapshot( wpagent_content_model_normalize_transported_snapshot( $input['right'] ?? null ) );
	return is_wp_error( $left ) ? $left : ( is_wp_error( $right ) ? $right : wpagent_content_model_compare( $left, $right ) );
}
