<?php
/**
 * Gemeinsame SEO-Meta-Normalisierung für Rank Math, Yoast, AIOSEO und SEOPress.
 *
 * @package WPAgentCompanion
 */

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

define( 'WPAGENT_SEO_LOCK_TTL', 120 );

/**
 * Provider-neutral limits keep otherwise unbounded meta fields safely below
 * common database, REST and render surfaces. Both characters and UTF-8 bytes
 * are bounded, because a character-only limit still permits disproportionate
 * multibyte payloads. The values intentionally allow normal SEO copy without
 * silently truncating it.
 *
 * @return array<string,array{max_length:int,max_bytes:int}>
 */
function wpagent_seo_text_field_limits(): array {
	return array(
		'title'                    => array( 'max_length' => 120, 'max_bytes' => 400 ),
		'description'              => array( 'max_length' => 320, 'max_bytes' => 1200 ),
		'focus_keyword'            => array( 'max_length' => 191, 'max_bytes' => 750 ),
		'canonical_url'            => array( 'max_length' => 2048, 'max_bytes' => 8192 ),
		'open_graph_title'         => array( 'max_length' => 120, 'max_bytes' => 400 ),
		'open_graph_description'   => array( 'max_length' => 320, 'max_bytes' => 1200 ),
		'twitter_title'            => array( 'max_length' => 120, 'max_bytes' => 400 ),
		'twitter_description'      => array( 'max_length' => 320, 'max_bytes' => 1200 ),
	);
}

/** @return array<string,mixed> */
function wpagent_seo_text_input_schema( string $field ): array {
	$limits = wpagent_seo_text_field_limits()[ $field ] ?? null;
	return is_array( $limits )
		? array( 'type' => 'string', 'maxLength' => $limits['max_length'] )
		: array( 'type' => 'string', 'maxLength' => 0 );
}

/** @return array<string,mixed> */
function wpagent_seo_output_text_schema( string $field ): array {
	$schema         = wpagent_seo_text_input_schema( $field );
	$schema['type'] = array( 'string', 'null' );
	return $schema;
}

/** @return array<string,mixed> */
function wpagent_seo_written_value_schema( string $field ): array {
	if ( in_array( $field, array( 'robots_noindex', 'robots_nofollow', 'cornerstone' ), true ) ) {
		return array( 'type' => array( 'boolean', 'null' ) );
	}
	return wpagent_seo_output_text_schema( $field );
}

/** @return array<string,mixed> */
function wpagent_seo_written_schema(): array {
	$properties = array();
	foreach ( array_keys( wpagent_seo_text_field_limits() ) as $field ) {
		$properties[ $field ] = array(
			'type'                 => 'object',
			'properties'           => array(
				'previous'  => wpagent_seo_written_value_schema( $field ),
				'current'   => wpagent_seo_written_value_schema( $field ),
				'persisted' => array( 'type' => 'boolean' ),
			),
			'required'             => array( 'previous', 'current', 'persisted' ),
			'additionalProperties' => false,
		);
	}
	$properties['robots_noindex'] = array(
		'type'                 => 'object',
		'properties'           => array( 'previous' => wpagent_seo_written_value_schema( 'robots_noindex' ), 'current' => wpagent_seo_written_value_schema( 'robots_noindex' ), 'persisted' => array( 'type' => 'boolean' ) ),
		'required'             => array( 'previous', 'current', 'persisted' ),
		'additionalProperties' => false,
	);
	$properties['robots_nofollow'] = array(
		'type'                 => 'object',
		'properties'           => array( 'previous' => wpagent_seo_written_value_schema( 'robots_nofollow' ), 'current' => wpagent_seo_written_value_schema( 'robots_nofollow' ), 'persisted' => array( 'type' => 'boolean' ) ),
		'required'             => array( 'previous', 'current', 'persisted' ),
		'additionalProperties' => false,
	);
	$properties['cornerstone'] = array(
		'type'                 => 'object',
		'properties'           => array( 'previous' => wpagent_seo_written_value_schema( 'cornerstone' ), 'current' => wpagent_seo_written_value_schema( 'cornerstone' ), 'persisted' => array( 'type' => 'boolean' ) ),
		'required'             => array( 'previous', 'current', 'persisted' ),
		'additionalProperties' => false,
	);
	return array( 'type' => 'object', 'properties' => $properties, 'additionalProperties' => false );
}

function wpagent_seo_text_character_length( string $value ): int {
	if ( function_exists( 'mb_strlen' ) ) {
		return (int) mb_strlen( $value, 'UTF-8' );
	}
	$count = preg_match_all( '/./us', $value );
	return false === $count ? 0 : $count;
}

/** @return true|WP_Error */
function wpagent_seo_validate_text_value( string $field, $value, int $status = 400 ) {
	$limits = wpagent_seo_text_field_limits()[ $field ] ?? null;
	if ( ! is_array( $limits ) || ! is_string( $value ) || ! wp_is_valid_utf8( $value ) || str_contains( $value, "\0" ) ) {
		return new WP_Error( 'wpagent_seo_text_invalid', __( 'Ein SEO-Textfeld enthält keinen gültigen UTF-8-Text.', 'wp-agent-companion' ), array( 'status' => $status, 'field' => $field ) );
	}
	if ( strlen( $value ) > $limits['max_bytes'] || wpagent_seo_text_character_length( $value ) > $limits['max_length'] ) {
		return new WP_Error( 'wpagent_seo_text_too_long', __( 'Ein SEO-Textfeld überschreitet die erlaubte Feldgrenze.', 'wp-agent-companion' ), array( 'status' => 400 === $status ? 413 : $status, 'field' => $field, 'max_length' => $limits['max_length'], 'max_bytes' => $limits['max_bytes'] ) );
	}
	return true;
}

/** @return true|WP_Error */
function wpagent_seo_validate_output_snapshot( array $snapshot ) {
	$fields = array(
		'title'                  => 'seo_title',
		'description'            => 'seo_description',
		'focus_keyword'          => 'focus_keyword',
		'canonical_url'          => 'canonical_url',
		'open_graph_title'       => 'open_graph_title',
		'open_graph_description' => 'open_graph_description',
		'twitter_title'          => 'twitter_title',
		'twitter_description'    => 'twitter_description',
	);
	foreach ( $fields as $field => $snapshot_key ) {
		$valid = wpagent_seo_validate_text_value( $field, $snapshot[ $snapshot_key ] ?? null, 502 );
		if ( is_wp_error( $valid ) ) {
			return new WP_Error( 'wpagent_seo_provider_state_invalid', __( 'Das SEO-Plugin lieferte einen zu großen oder ungültigen Textwert. Der Zustand wird nicht ausgegeben.', 'wp-agent-companion' ), array( 'status' => 502, 'field' => $field ) );
		}
	}
	return true;
}

/**
 * @return 'rankmath'|'yoast'|'aioseo'|'seopress'|'none'
 */
function wpagent_detect_seo_plugin(): string {
	if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath\\Helper' ) || function_exists( 'rank_math' ) ) {
		return 'rankmath';
	}
	if ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Options' ) ) {
		return 'yoast';
	}
	if ( defined( 'AIOSEO_VERSION' ) || function_exists( 'aioseo' ) ) {
		return 'aioseo';
	}
	if ( defined( 'SEOPRESS_VERSION' ) || function_exists( 'seopress_get_service' ) ) {
		return 'seopress';
	}
	return 'none';
}

/**
 * Wandelt die öffentlich benannten Eingabefelder in den internen Vertrag um.
 * Nicht übergebene Werte bleiben null, `false` bleibt dagegen ein echter Wert.
 *
 * @param array<string,mixed> $input
 * @return array<string,string|bool|null>|WP_Error
 */
function wpagent_seo_fields_from_input( array $input ) {
	$text_fields = array(
		'title'                    => 'seo_title',
		'description'              => 'seo_description',
		'focus_keyword'            => 'focus_keyword',
		'open_graph_title'         => 'open_graph_title',
		'open_graph_description'   => 'open_graph_description',
		'twitter_title'            => 'twitter_title',
		'twitter_description'      => 'twitter_description',
	);
	$fields      = array();
	foreach ( $text_fields as $field => $input_key ) {
		if ( ! array_key_exists( $input_key, $input ) ) {
			$fields[ $field ] = null;
			continue;
		}
		$valid = wpagent_seo_validate_text_value( $field, $input[ $input_key ] );
		if ( is_wp_error( $valid ) ) {
			return $valid;
		}
		$normalized = sanitize_text_field( $input[ $input_key ] );
		$valid      = wpagent_seo_validate_text_value( $field, $normalized );
		if ( is_wp_error( $valid ) ) {
			return $valid;
		}
		$fields[ $field ] = $normalized;
	}

	$fields['canonical_url'] = null;
	if ( array_key_exists( 'canonical_url', $input ) ) {
		$valid = wpagent_seo_validate_text_value( 'canonical_url', $input['canonical_url'] );
		if ( is_wp_error( $valid ) ) {
			return $valid;
		}
		$raw = trim( $input['canonical_url'] );
		if ( '' === $raw ) {
			$fields['canonical_url'] = '';
		} else {
			$parts = wp_parse_url( $raw );
			if (
				! is_array( $parts )
				|| ! isset( $parts['scheme'], $parts['host'] )
				|| ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true )
				|| '' === (string) $parts['host']
			) {
				return new WP_Error( 'wpagent_invalid_canonical_url', __( 'Die Canonical-URL muss leer oder eine absolute HTTP- beziehungsweise HTTPS-URL sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
			}
			$canonical = esc_url_raw( $raw, array( 'http', 'https' ) );
			if ( '' === $canonical ) {
				return new WP_Error( 'wpagent_invalid_canonical_url', __( 'Die Canonical-URL ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400 ) );
			}
			$valid = wpagent_seo_validate_text_value( 'canonical_url', $canonical );
			if ( is_wp_error( $valid ) ) {
				return $valid;
			}
			$fields['canonical_url'] = $canonical;
		}
	}

	foreach ( array( 'robots_noindex', 'robots_nofollow' ) as $field ) {
		$fields[ $field ] = array_key_exists( $field, $input ) ? (bool) $input[ $field ] : null;
	}
	$fields['cornerstone'] = array_key_exists( 'cornerstone', $input ) ? (bool) $input['cornerstone'] : null;

	return $fields;
}

/**
 * @param array<string,mixed> $fields
 */
function wpagent_seo_fields_have_value( array $fields ): bool {
	foreach ( $fields as $value ) {
		if ( null !== $value ) {
			return true;
		}
	}
	return false;
}

/** @return string|WP_Error */
function wpagent_seo_acquire_lock( int $post_id ) {
	$option = 'wp_agent_seo_lock_' . $post_id;
	$token  = wpagent_companion_acquire_option_lock( $option, WPAGENT_SEO_LOCK_TTL );
	if ( is_string( $token ) ) {
		return $token;
	}
	return new WP_Error( 'wpagent_seo_locked', __( 'Die SEO-Daten dieses Beitrags werden gerade geändert. Bitte erneut versuchen.', 'wp-agent-companion' ), array( 'status' => 409 ) );
}

function wpagent_seo_release_lock( int $post_id, string $token ): void {
	wpagent_companion_release_option_lock( 'wp_agent_seo_lock_' . $post_id, $token );
}

function wpagent_seo_lock_is_owned( int $post_id, string $token ): bool {
	return wpagent_companion_option_lock_is_owned( 'wp_agent_seo_lock_' . $post_id, $token );
}

function wpagent_seo_refresh_lock( int $post_id, string $token ): bool {
	return wpagent_companion_refresh_option_lock( 'wp_agent_seo_lock_' . $post_id, $token, WPAGENT_SEO_LOCK_TTL );
}

/**
 * A lock must still be ours immediately before a provider mutation and after
 * its readback. A lost option lock means that the provider state is uncertain:
 * never compensate it blindly.
 */
function wpagent_seo_lock_checkpoint( int $post_id, string $token ): bool {
	return wpagent_seo_lock_is_owned( $post_id, $token ) && wpagent_seo_refresh_lock( $post_id, $token );
}

/**
 * Sichert Provider-Metafelder einschließlich ihrer Existenz und eventueller
 * Mehrfachwerte. Das ist genauer als ein normalisierter SEO-Snapshot und
 * erlaubt einen verlustfreien Rollback nach einem Teilfehler.
 *
 * @param string[] $meta_keys
 * @return array<string,array<int,mixed>>
 */
function wpagent_capture_seo_postmeta( int $post_id, array $meta_keys ): array {
	$state = array();
	foreach ( array_values( array_unique( $meta_keys ) ) as $meta_key ) {
		$state[ $meta_key ] = get_post_meta( $post_id, $meta_key, false );
	}
	return $state;
}

/**
 * @param array<string,array<int,mixed>> $state
 */
function wpagent_restore_seo_postmeta( int $post_id, array $state, array $candidate ): bool {
	// A provider or editor may have written after WPAgently's candidate. Do not
	// erase that newer state merely because the original write needs recovery.
	if ( wpagent_capture_seo_postmeta( $post_id, array_keys( $state ) ) !== $candidate ) {
		return false;
	}
	foreach ( $state as $meta_key => $values ) {
		delete_post_meta( $post_id, $meta_key );
		foreach ( $values as $value ) {
			add_post_meta( $post_id, $meta_key, $value );
		}
	}

	foreach ( $state as $meta_key => $values ) {
		if ( get_post_meta( $post_id, $meta_key, false ) !== $values ) {
			return false;
		}
	}
	return true;
}

/** @param mixed $value @return mixed */
function wpagent_seo_meta_hash_canonicalize( $value ) {
	if ( ! is_array( $value ) ) {
		return $value;
	}
	if ( ! array_is_list( $value ) ) {
		ksort( $value, SORT_STRING );
	}
	foreach ( $value as $key => $child ) {
		$value[ $key ] = wpagent_seo_meta_hash_canonicalize( $child );
	}
	return $value;
}

/**
 * The public snapshot is normalized for callers, whereas the reviewed hash
 * additionally binds every raw row in the bounded provider surface. This
 * detects duplicate rows and other native drift that a get_post_meta(...,
 * true) view would otherwise conceal without exposing those rows to clients.
 *
 * @param array<string,mixed> $snapshot
 * @param array<string,mixed> $provider_state
 */
function wpagent_seo_meta_state_hash( array $snapshot, array $provider_state = array() ): string {
	unset( $snapshot['state_hash'] );
	return hash(
		'sha256',
		(string) wp_json_encode(
			wpagent_seo_meta_hash_canonicalize( array( 'surface' => $snapshot, 'provider_state' => $provider_state ) ),
			JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
		)
	);
}

/** @param array<string,mixed> $snapshot @param array<string,mixed> $provider_state @return array<string,mixed>|WP_Error */
function wpagent_seo_meta_with_state_hash( array $snapshot, array $provider_state = array() ) {
	$valid = wpagent_seo_validate_output_snapshot( $snapshot );
	if ( is_wp_error( $valid ) ) {
		return $valid;
	}
	$snapshot['state_hash'] = wpagent_seo_meta_state_hash( $snapshot, $provider_state );
	return $snapshot;
}

/** @return WP_Error */
function wpagent_seo_recovery_required( int $post_id, string $previous_hash, string $reason ): WP_Error {
	return new WP_Error(
		'wpagent_seo_recovery_required',
		__( 'Der SEO-Providerzustand konnte nach einer unsicheren Änderung nicht automatisch wiederhergestellt werden. Prüfe den Beitrag im WordPress-Admin.', 'wp-agent-companion' ),
		array( 'status' => 500, 'post_id' => $post_id, 'previous_hash' => $previous_hash, 'recovery_required' => true, 'reason' => $reason )
	);
}

/**
 * @return string
 */
function wpagent_aioseo_posts_table(): string {
	global $wpdb;
	return $wpdb->prefix . 'aioseo_posts';
}

/**
 * @return string
 */
function wpagent_aioseo_quote_identifier( string $identifier ): string {
	return '`' . str_replace( '`', '``', $identifier ) . '`';
}

/**
 * AIOSEO's posts table deliberately has only a non-unique post_id index. Safe
 * initialization and rollback therefore require an InnoDB transaction. A
 * serializable missing-row lookup reserves the indexed gap until COMMIT.
 *
 * @return true|WP_Error
 */
function wpagent_aioseo_require_transactional_storage() {
	global $wpdb;
	$server = method_exists( $wpdb, 'db_server_info' ) ? strtolower( (string) $wpdb->db_server_info() ) : '';
	if ( str_contains( $server, 'sqlite' ) ) {
		return new WP_Error( 'wpagent_aioseo_storage_unsupported', __( 'AIOSEO kann auf dieser Datenbank nicht nebenläufigkeitssicher geändert werden.', 'wp-agent-companion' ), array( 'status' => 409 ) );
	}

	$tables  = array( wpagent_aioseo_posts_table(), $wpdb->postmeta );
	$engines = $wpdb->get_results(
		$wpdb->prepare(
			'SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (%s, %s)',
			$tables[0],
			$tables[1]
		),
		OBJECT_K
	);
	if ( ! is_array( $engines ) || 2 !== count( $engines ) ) {
		return new WP_Error( 'wpagent_aioseo_storage_unsupported', __( 'Die AIOSEO-Datenbanktabelle unterstützt keinen sicheren transaktionalen Rollback.', 'wp-agent-companion' ), array( 'status' => 409 ) );
	}
	foreach ( $tables as $table ) {
		if ( ! isset( $engines[ $table ]->ENGINE ) || 'innodb' !== strtolower( (string) $engines[ $table ]->ENGINE ) ) {
			return new WP_Error( 'wpagent_aioseo_storage_unsupported', __( 'Die AIOSEO- und WordPress-Metatabellen unterstützen keinen gemeinsamen sicheren Rollback.', 'wp-agent-companion' ), array( 'status' => 409 ) );
		}
	}
	return true;
}

/**
 * Verhindert, dass AIOSEOs eigene Transaktionsgrenze eine bereits laufende
 * Transaktion des aufrufenden Plugins implizit festschreibt oder zurückrollt.
 *
 * @return true|WP_Error
 */
function wpagent_aioseo_require_idle_transaction( string $isolation_level = 'REPEATABLE READ' ) {
	global $wpdb;
	// MySQL has no MariaDB-compatible @@in_transaction variable. SET TRANSACTION
	// is portable, has no implicit commit and fails while a transaction is active.
	$isolation_level = 'SERIALIZABLE' === $isolation_level ? 'SERIALIZABLE' : 'REPEATABLE READ';
	$suppress_errors = $wpdb->suppress_errors( true );
	$can_start       = false !== $wpdb->query( "SET TRANSACTION ISOLATION LEVEL {$isolation_level}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	$wpdb->suppress_errors( $suppress_errors );
	if ( ! $can_start ) {
		return new WP_Error( 'wpagent_aioseo_transaction_busy', __( 'AIOSEO kann innerhalb einer bereits laufenden Datenbanktransaktion nicht sicher geändert werden.', 'wp-agent-companion' ), array( 'status' => 409 ) );
	}
	return true;
}

/**
 * @return array<int,array<string,mixed>>|WP_Error
 */
function wpagent_aioseo_storage_rows( int $post_id, bool $for_update = false ) {
	global $wpdb;
	$table = wpagent_aioseo_quote_identifier( wpagent_aioseo_posts_table() );
	$sql   = $wpdb->prepare( "SELECT * FROM {$table} WHERE post_id = %d ORDER BY id", $post_id ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	if ( $for_update ) {
		$sql .= ' FOR UPDATE';
	}
	$rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
	if ( ! is_array( $rows ) ) {
		return new WP_Error( 'wpagent_aioseo_storage_read_failed', __( 'Der AIOSEO-Datenbankzustand konnte nicht sicher gelesen werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	return $rows;
}

/**
 * Verwirft request-lokale Provider- und WordPress-Metacaches nach COMMIT oder
 * ROLLBACK, damit die anschließende Verifikation tatsächlich die DB liest.
 */
function wpagent_aioseo_reset_read_caches( int $post_id ): void {
	wp_cache_delete( $post_id, 'post_meta' );
	clean_post_cache( $post_id );
	if ( function_exists( 'aioseo' ) && isset( aioseo()->core->db ) ) {
		try {
			if ( isset( aioseo()->meta->metaData ) && method_exists( aioseo()->meta->metaData, 'bustPostCache' ) ) {
				aioseo()->meta->metaData->bustPostCache( $post_id );
			}
			aioseo()->core->db
				->start( 'aioseo_posts' )
				->where( 'post_id', $post_id )
				->resetCache()
				->run();
		} catch ( Throwable ) {
			// Der nachfolgende native Read-back meldet einen echten Lesefehler.
		}
	}
}

/**
 * Startet die AIOSEO-Mutation und sperrt sowohl den Provider-Row als auch alle
 * Postmeta-Zeilen des Beitrags. Fremde AIOSEO-, UI-, Cron- und Import-Writes
 * können dadurch nicht zwischen Snapshot, Write und Rollback gelangen.
 *
 * @return array<string,mixed>|WP_Error
 */
function wpagent_aioseo_begin_mutation( int $post_id ) {
	global $wpdb;
	$supported = wpagent_aioseo_require_transactional_storage();
	if ( is_wp_error( $supported ) ) {
		return $supported;
	}

	$idle = wpagent_aioseo_require_idle_transaction();
	if ( is_wp_error( $idle ) ) {
		return $idle;
	}
	if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
		return new WP_Error( 'wpagent_aioseo_transaction_failed', __( 'Die sichere AIOSEO-Datenbanktransaktion konnte nicht gestartet werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}

	$rows = wpagent_aioseo_storage_rows( $post_id, true );
	if ( is_wp_error( $rows ) || 1 !== count( $rows ) ) {
		$wpdb->query( 'ROLLBACK' );
		if ( is_wp_error( $rows ) ) {
			return $rows;
		}
		return new WP_Error( 'wpagent_aioseo_ambiguous_record', __( 'AIOSEO besitzt für diesen Beitrag keinen eindeutigen SEO-Datensatz. Die Änderung wurde abgebrochen.', 'wp-agent-companion' ), array( 'status' => 409, 'record_count' => count( $rows ) ) );
	}

	$postmeta = wpagent_aioseo_quote_identifier( $wpdb->postmeta );
	$locked   = $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$postmeta} WHERE post_id = %d FOR UPDATE", $post_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	if ( ! is_array( $locked ) ) {
		$wpdb->query( 'ROLLBACK' );
		return new WP_Error( 'wpagent_aioseo_transaction_failed', __( 'Die gespiegelten AIOSEO-Metadaten konnten nicht sicher gesperrt werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}

	wpagent_aioseo_reset_read_caches( $post_id );
	$before = wpagent_read_aioseo_meta( $post_id );
	if ( is_wp_error( $before ) ) {
		$wpdb->query( 'ROLLBACK' );
		return $before;
	}

	return array(
		'row'     => $rows[0],
		'mirrors' => wpagent_capture_seo_postmeta( $post_id, wpagent_aioseo_mirrored_meta_keys() ),
		'before'  => $before,
	);
}

/**
 * @param array<string,mixed> $context
 */
function wpagent_aioseo_rollback_mutation( int $post_id, array $context ): bool {
	global $wpdb;
	if ( false === $wpdb->query( 'ROLLBACK' ) ) {
		return false;
	}

	wpagent_aioseo_reset_read_caches( $post_id );
	$rows = wpagent_aioseo_storage_rows( $post_id );
	$expected_rows = null === $context['row'] ? array() : array( $context['row'] );
	if ( is_wp_error( $rows ) || $expected_rows !== $rows ) {
		return false;
	}
	if ( wpagent_capture_seo_postmeta( $post_id, wpagent_aioseo_mirrored_meta_keys() ) !== $context['mirrors'] ) {
		return false;
	}
	if ( null === $context['row'] ) {
		return true;
	}
	$fresh = wpagent_read_aioseo_meta( $post_id );
	return ! is_wp_error( $fresh ) && $fresh === $context['before'];
}

function wpagent_aioseo_commit_mutation( int $post_id ): bool {
	global $wpdb;
	if ( false === $wpdb->query( 'COMMIT' ) ) {
		$wpdb->query( 'ROLLBACK' );
		wpagent_aioseo_reset_read_caches( $post_id );
		return false;
	}
	wpagent_aioseo_reset_read_caches( $post_id );
	return true;
}

/**
 * Verifiziert die von AIOSEO selbst gepflegten Übersetzungs-Spiegel für alle
 * Felder des aktuellen Patches. Ein Provider-Row und sein Spiegel dürfen nie
 * mit unterschiedlichen sichtbaren Werten festgeschrieben werden.
 *
 * @param array<string,string|bool|null> $fields
 * @return array<string,bool>|WP_Error
 */
function wpagent_aioseo_verify_touched_mirrors( int $post_id, array $fields ) {
	$rows = wpagent_aioseo_storage_rows( $post_id );
	if ( is_wp_error( $rows ) || 1 !== count( $rows ) ) {
		return is_wp_error( $rows )
			? $rows
			: new WP_Error( 'wpagent_aioseo_ambiguous_record', __( 'AIOSEO besitzt für diesen Beitrag keinen eindeutigen SEO-Datensatz.', 'wp-agent-companion' ), array( 'status' => 409, 'record_count' => count( $rows ) ) );
	}
	$map = array(
		'title'                  => array( 'title', '_aioseo_title' ),
		'description'            => array( 'description', '_aioseo_description' ),
		'open_graph_title'       => array( 'og_title', '_aioseo_og_title' ),
		'open_graph_description' => array( 'og_description', '_aioseo_og_description' ),
		'twitter_title'          => array( 'twitter_title', '_aioseo_twitter_title' ),
		'twitter_description'    => array( 'twitter_description', '_aioseo_twitter_description' ),
	);
	$result = array();
	foreach ( $map as $field => $keys ) {
		if ( null === ( $fields[ $field ] ?? null ) ) {
			continue;
		}
		$values           = get_post_meta( $post_id, $keys[1], false );
		$result[ $field ] = 1 === count( $values ) && (string) $values[0] === (string) ( $rows[0][ $keys[0] ] ?? '' );
	}
	return $result;
}

/**
 * Ergänzt einen Provider-Fehler um den verifizierten Rollback-Status, ohne
 * dessen ursprünglichen HTTP-Status oder Diagnosedaten zu verlieren.
 *
 * @param array<string,array<int,mixed>> $state
 */
function wpagent_seo_error_with_postmeta_rollback( WP_Error $error, int $post_id, array $state ): WP_Error {
	$data              = $error->get_error_data();
	$data              = is_array( $data ) ? $data : array();
	$error->add_data(
		array_merge(
			$data,
			array(
				'rolled_back'       => false,
				'rollback_verified' => false,
				'recovery_required' => true,
			)
		)
	);
	return $error;
}

/**
 * @return array<string,mixed>|WP_Error
 */
function wpagent_read_aioseo_meta( int $post_id ) {
	$rows = wpagent_aioseo_storage_rows( $post_id );
	if ( is_wp_error( $rows ) ) {
		return $rows;
	}
	if ( count( $rows ) > 1 ) {
		return new WP_Error( 'wpagent_aioseo_ambiguous_record', __( 'AIOSEO besitzt für diesen Beitrag mehrere SEO-Datensätze. Ein eindeutiger Read-back ist nicht möglich.', 'wp-agent-companion' ), array( 'status' => 409, 'record_count' => count( $rows ) ) );
	}
	$read = wpagent_companion_get_registered_ability( 'aioseo-posts/seo-data-get' );
	if ( ! $read instanceof WP_Ability ) {
		return new WP_Error( 'wpagent_aioseo_api_missing', __( 'AIOSEO stellt seine native SEO-Ability nicht bereit.', 'wp-agent-companion' ), array( 'status' => 409 ) );
	}
	$fresh = $read->execute( array( 'postId' => $post_id ) );
	if ( is_wp_error( $fresh ) ) {
		$data = $fresh->get_error_data();
		if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
			$data = is_array( $data ) ? $data : array();
			$status = 'ability_invalid_permissions' === $fresh->get_error_code() ? 403 : 500;
			$fresh->add_data( $data + array( 'status' => $status ) );
		}
		return $fresh;
	}
	if ( ! is_array( $fresh ) ) {
		return new WP_Error( 'wpagent_aioseo_readback_failed', __( 'AIOSEO hat beim erneuten Lesen keine gültigen SEO-Daten geliefert.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	$robots = is_array( $fresh['robots'] ?? null ) ? $fresh['robots'] : null;
	$social = is_array( $fresh['social'] ?? null ) ? $fresh['social'] : null;
	if (
		null === $robots ||
		null === $social ||
		array_diff( array( 'use_default', 'noindex', 'nofollow' ), array_keys( $robots ) ) ||
		array_diff( array( 'og_title', 'og_description', 'twitter_title', 'twitter_description' ), array_keys( $social ) )
	) {
		return new WP_Error( 'wpagent_aioseo_readback_failed', __( 'AIOSEO hat beim erneuten Lesen keine gültigen SEO-Daten geliefert.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	return $fresh;
}

/**
 * Liefert für alle vier Provider denselben gespeicherten Post-SEO-Vertrag.
 * Die Robots-Bools geben den gespeicherten Override wieder. Globale Defaults
 * werden bewusst nicht als postbezogener Wert ausgegeben.
 *
 * @param 'rankmath'|'yoast'|'aioseo'|'seopress' $plugin
 * @return array<string,mixed>|WP_Error
 */
function wpagent_read_seo_meta( int $post_id, string $plugin ) {
	if ( 'aioseo' === $plugin ) {
		$fresh = wpagent_read_aioseo_meta( $post_id );
		if ( is_wp_error( $fresh ) ) {
			return $fresh;
		}
		$rows = wpagent_aioseo_storage_rows( $post_id );
		if ( is_wp_error( $rows ) ) {
			return $rows;
		}
		$robots = is_array( $fresh['robots'] ?? null ) ? $fresh['robots'] : array();
		$social = is_array( $fresh['social'] ?? null ) ? $fresh['social'] : array();
		$snapshot = array(
			'post_id'                  => $post_id,
			'plugin'                   => $plugin,
			'seo_title'                => (string) ( $fresh['title'] ?? '' ),
			'seo_description'          => (string) ( $fresh['description'] ?? '' ),
			'focus_keyword'            => (string) ( $fresh['focus_keyphrase'] ?? '' ),
			'canonical_url'            => (string) ( $fresh['canonical_url'] ?? '' ),
			'robots_noindex'           => ! empty( $robots['noindex'] ),
			'robots_nofollow'          => ! empty( $robots['nofollow'] ),
			'open_graph_title'         => (string) ( $social['og_title'] ?? '' ),
			'open_graph_description'   => (string) ( $social['og_description'] ?? '' ),
			'twitter_title'            => (string) ( $social['twitter_title'] ?? '' ),
			'twitter_description'      => (string) ( $social['twitter_description'] ?? '' ),
			'cornerstone_supported'    => false,
			'cornerstone'              => null,
		);
		return wpagent_seo_meta_with_state_hash(
			$snapshot,
			array(
				'aioseo_rows' => $rows,
				'aioseo_mirrors' => wpagent_capture_seo_postmeta( $post_id, wpagent_aioseo_mirrored_meta_keys() ),
			)
		);
	}

	$maps = array(
		'rankmath' => array(
			'seo_title'               => 'rank_math_title',
			'seo_description'         => 'rank_math_description',
			'focus_keyword'           => 'rank_math_focus_keyword',
			'canonical_url'           => 'rank_math_canonical_url',
			'open_graph_title'        => 'rank_math_facebook_title',
			'open_graph_description'  => 'rank_math_facebook_description',
			'twitter_title'           => 'rank_math_twitter_title',
			'twitter_description'     => 'rank_math_twitter_description',
		),
		'yoast'    => array(
			'seo_title'               => '_yoast_wpseo_title',
			'seo_description'         => '_yoast_wpseo_metadesc',
			'focus_keyword'           => '_yoast_wpseo_focuskw',
			'canonical_url'           => '_yoast_wpseo_canonical',
			'open_graph_title'        => '_yoast_wpseo_opengraph-title',
			'open_graph_description'  => '_yoast_wpseo_opengraph-description',
			'twitter_title'           => '_yoast_wpseo_twitter-title',
			'twitter_description'     => '_yoast_wpseo_twitter-description',
		),
		'seopress' => array(
			'seo_title'               => '_seopress_titles_title',
			'seo_description'         => '_seopress_titles_desc',
			'focus_keyword'           => '_seopress_analysis_target_kw',
			'canonical_url'           => '_seopress_robots_canonical',
			'open_graph_title'        => '_seopress_social_fb_title',
			'open_graph_description'  => '_seopress_social_fb_desc',
			'twitter_title'           => '_seopress_social_twitter_title',
			'twitter_description'     => '_seopress_social_twitter_desc',
		),
	);
	if ( ! isset( $maps[ $plugin ] ) ) {
		return new WP_Error( 'wpagent_seo_plugin_missing', __( 'Kein unterstütztes SEO-Plugin aktiv.', 'wp-agent-companion' ), array( 'status' => 409 ) );
	}

	$snapshot = array( 'post_id' => $post_id, 'plugin' => $plugin );
	foreach ( $maps[ $plugin ] as $field => $meta_key ) {
		$snapshot[ $field ] = (string) get_post_meta( $post_id, $meta_key, true );
	}
	if ( 'rankmath' === $plugin ) {
		$robots = get_post_meta( $post_id, 'rank_math_robots', true );
		$robots = is_array( $robots ) ? $robots : array();
		$snapshot['robots_noindex']  = in_array( 'noindex', $robots, true );
		$snapshot['robots_nofollow'] = in_array( 'nofollow', $robots, true );
		$snapshot['cornerstone_supported'] = true;
		$snapshot['cornerstone'] = 'on' === (string) get_post_meta( $post_id, 'rank_math_pillar_content', true );
	} elseif ( 'yoast' === $plugin ) {
		$snapshot['robots_noindex']  = '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true );
		$snapshot['robots_nofollow'] = '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow', true );
		$snapshot['cornerstone_supported'] = true;
		$snapshot['cornerstone'] = '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_is_cornerstone', true );
	} else {
		$snapshot['robots_noindex']  = 'yes' === (string) get_post_meta( $post_id, '_seopress_robots_index', true );
		$snapshot['robots_nofollow'] = 'yes' === (string) get_post_meta( $post_id, '_seopress_robots_follow', true );
		$snapshot['cornerstone_supported'] = false;
		$snapshot['cornerstone'] = null;
	}
	$provider_keys = array_values( $maps[ $plugin ] );
	if ( 'rankmath' === $plugin ) {
		$provider_keys[] = 'rank_math_robots';
		$provider_keys[] = 'rank_math_pillar_content';
	} elseif ( 'yoast' === $plugin ) {
		$provider_keys[] = '_yoast_wpseo_meta-robots-noindex';
		$provider_keys[] = '_yoast_wpseo_meta-robots-nofollow';
		$provider_keys[] = '_yoast_wpseo_is_cornerstone';
	} else {
		$provider_keys[] = '_seopress_robots_index';
		$provider_keys[] = '_seopress_robots_follow';
	}
	return wpagent_seo_meta_with_state_hash( $snapshot, wpagent_capture_seo_postmeta( $post_id, $provider_keys ) );
}
