<?php
/**
 * Shared safety primitives for the small group of legacy core mutators.
 *
 * These operations predate the document/provider write contracts. They now
 * share one narrow implementation for scoped option locks, state hashes and
 * fail-closed recovery errors. The helper deliberately does not provide a
 * generic mutation API: each ability keeps its provider-specific validation,
 * capability checks and readback semantics local.
 *
 * @package WPAgentCompanion
 */

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

define( 'WPAGENT_CORE_MUTATOR_LOCK_TTL', 120 );
define( 'WPAGENT_CORE_MUTATOR_MAX_USER_OBJECTS', 500 );

/** @param array<string,mixed> $state */
function wpagent_core_mutator_hash( array $state ): string {
	$encoded = wp_json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
	return hash( 'sha256', is_string( $encoded ) ? $encoded : serialize( $state ) );
}

/** @return array{exists:bool,value:mixed,raw:string|null,autoload:string|null,hash:string} */
function wpagent_core_mutator_setting_state( string $name ): array {
	$row = wpagent_core_mutator_option_row( $name );
	$exists = is_array( $row );
	$value = $exists ? maybe_unserialize( $row['raw'] ) : null;
	return array(
		'exists' => $exists,
		'value'  => $value,
		'raw'    => $exists ? $row['raw'] : null,
		'autoload' => $exists ? $row['autoload'] : null,
		'hash'   => wpagent_core_mutator_hash(
			array(
				'scope'  => 'setting',
				'name'   => $name,
				'exists' => $exists,
				'value'  => $value,
				// Bind the storage representation too. For these allowlisted scalar
				// settings, an equal PHP value must not hide a changed raw row or a
				// changed autoload policy from a pending conditional write.
				'raw'    => $exists ? $row['raw'] : null,
				'autoload' => $exists ? $row['autoload'] : null,
			)
		),
	);
}

/**
 * The three front-page options are one invariant. Their writes must serialize
 * together, while every unrelated allowlisted option retains its own lock.
 */
function wpagent_core_mutator_setting_lock_identity( string $name ): string {
	if ( in_array( $name, array( 'show_on_front', 'page_on_front', 'page_for_posts' ), true ) ) {
		return 'front-page-settings';
	}
	return $name;
}

/**
 * Returns a bounded snapshot of every object wp_delete_user() can affect.
 *
 * The Core reassign branch updates every authored post and owned link. The
 * delete branch removes a subset of post types and all owned links. Capturing
 * the full bounded scope before the irreversible call makes both branches
 * recheckable. More than 500 objects intentionally refuses automation.
 *
 * @return array{posts:array<int,array{id:int,type:string,status:string,modified_gmt:string}>,links:array<int,int>,bounded:bool,readable:bool}
 */
function wpagent_core_mutator_user_owned_objects( int $user_id ): array {
	global $wpdb;
	$limit = WPAGENT_CORE_MUTATOR_MAX_USER_OBJECTS + 1;
	$posts = $wpdb->get_results(
		$wpdb->prepare(
            "SELECT ID, post_type, post_status, post_modified_gmt FROM {$wpdb->posts} WHERE post_author = %d ORDER BY ID ASC LIMIT %d",
			$user_id,
			$limit
		),
		ARRAY_A
	);
	$posts_error = (string) $wpdb->last_error;
	$links = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT link_id FROM {$wpdb->links} WHERE link_owner = %d ORDER BY link_id ASC LIMIT %d",
			$user_id,
			$limit
		)
	);
	$links_error = (string) $wpdb->last_error;
	$normalized_posts = array();
	foreach ( is_array( $posts ) ? $posts : array() as $post ) {
		if ( ! is_array( $post ) ) {
			continue;
		}
		$normalized_posts[] = array(
			'id'           => (int) ( $post['ID'] ?? 0 ),
			'type'         => (string) ( $post['post_type'] ?? '' ),
			'status'       => (string) ( $post['post_status'] ?? '' ),
			'modified_gmt' => (string) ( $post['post_modified_gmt'] ?? '' ),
		);
	}
	$normalized_links = array_map( 'intval', is_array( $links ) ? $links : array() );
	return array(
		'posts'   => $normalized_posts,
		'links'   => $normalized_links,
		'bounded' => count( $normalized_posts ) <= WPAGENT_CORE_MUTATOR_MAX_USER_OBJECTS && count( $normalized_links ) <= WPAGENT_CORE_MUTATOR_MAX_USER_OBJECTS,
		'readable' => '' === $posts_error && '' === $links_error,
	);
}

/** @return array{identity:array{id:int,login:string,email:string,registered:string,roles:array<int,string>},owned:array{posts:array<int,array{id:int,type:string,status:string,modified_gmt:string}>,links:array<int,int>,bounded:bool,readable:bool},hash:string} */
function wpagent_core_mutator_user_state( WP_User $user ): array {
	$state = array(
		'identity' => wpagent_core_mutator_user_identity( $user ),
		'owned'    => wpagent_core_mutator_user_owned_objects( (int) $user->ID ),
	);
	$state['hash'] = wpagent_core_mutator_hash( array_merge( array( 'scope' => 'user' ), $state ) );
	return $state;
}

/** @return array<int,int> */
function wpagent_core_mutator_menu_locations( int $menu_id ): array {
	$locations = get_theme_mod( 'nav_menu_locations', array() );
	if ( ! is_array( $locations ) ) {
		return array();
	}
	$bound = array();
	foreach ( $locations as $location => $assigned_menu_id ) {
		if ( is_string( $location ) && $menu_id === (int) $assigned_menu_id ) {
			$bound[ $location ] = $menu_id;
		}
	}
	ksort( $bound, SORT_STRING );
	return $bound;
}

/** @return array{menu:array{id:int,name:string,slug:string,description:string},items:array<int,array{id:int,parent:int,order:int,type:string,object:string,object_id:int,url:string,title:string,attr_title:string,description:string,target:string,classes:array<int,string>,xfn:string,modified_gmt:string}>,locations:array<string,int>,bounded:bool,hash:string}|null */
function wpagent_core_mutator_menu_state( int $menu_id ): ?array {
	$menu = wp_get_nav_menu_object( $menu_id );
	if ( ! $menu instanceof WP_Term ) {
		return null;
	}
	$items = wp_get_nav_menu_items(
		$menu_id,
		array(
			'post_status' => 'any',
			'nopaging'    => false,
			'posts_per_page' => WPAGENT_CORE_MUTATOR_MAX_USER_OBJECTS + 1,
		)
	);
	if ( ! is_array( $items ) ) {
		return null;
	}
	$normalized_items = array();
	foreach ( $items as $item ) {
		if ( ! $item instanceof WP_Post ) {
			return null;
		}
		$classes = get_post_meta( $item->ID, '_menu_item_classes', true );
		$classes = is_array( $classes ) ? array_values( array_filter( $classes, 'is_string' ) ) : array();
		sort( $classes, SORT_STRING );
		$normalized_items[] = array(
			'id'       => (int) $item->ID,
			'parent'   => (int) $item->menu_item_parent,
			'order'    => (int) $item->menu_order,
			'type'     => (string) $item->type,
			'object'   => (string) $item->object,
			'object_id'=> (int) $item->object_id,
			'url'      => (string) $item->url,
			'title'    => (string) $item->title,
			'attr_title' => (string) $item->attr_title,
			'description' => (string) $item->description,
			'target'   => (string) $item->target,
			'classes'  => $classes,
			'xfn'      => (string) $item->xfn,
			'modified_gmt' => (string) $item->post_modified_gmt,
		);
	}
	usort(
		$normalized_items,
		static function ( array $left, array $right ): int {
			return $left['id'] <=> $right['id'];
		}
	);
	$state = array(
		'menu'      => array(
			'id'          => (int) $menu->term_id,
			'name'        => (string) $menu->name,
			'slug'        => (string) $menu->slug,
			'description' => (string) $menu->description,
		),
		'items'     => $normalized_items,
		'locations' => wpagent_core_mutator_menu_locations( $menu_id ),
		'bounded'   => count( $normalized_items ) <= WPAGENT_CORE_MUTATOR_MAX_USER_OBJECTS,
	);
	$state['hash'] = wpagent_core_mutator_hash( array_merge( array( 'scope' => 'menu' ), $state ) );
	return $state;
}

/** @return array{template:string,template_version:string,stylesheet:string,stylesheet_version:string}|WP_Error */
function wpagent_core_mutator_active_theme_identity() {
	$template   = (string) get_template();
	$stylesheet = (string) get_stylesheet();
	if ( '' === $template || '' === $stylesheet
		|| strlen( $template ) > 100 || strlen( $stylesheet ) > 100
		|| 1 !== preg_match( '/^[a-z0-9._-]+$/iD', $template )
		|| 1 !== preg_match( '/^[a-z0-9._-]+$/iD', $stylesheet ) ) {
		return new WP_Error( 'wpagent_menu_location_theme_invalid', __( 'Das aktive Theme besitzt keine sichere Identität für eine Menüpositionsänderung.', 'wp-agent-companion' ), array( 'status' => 502 ) );
	}
	$template_theme   = wp_get_theme( $template );
	$stylesheet_theme = wp_get_theme( $stylesheet );
	if ( ! $template_theme->exists() || ! $stylesheet_theme->exists() ) {
		return new WP_Error( 'wpagent_menu_location_theme_invalid', __( 'Das aktive Theme konnte für eine Menüpositionsänderung nicht sicher bestimmt werden.', 'wp-agent-companion' ), array( 'status' => 502 ) );
	}
	return array(
		'template'           => $template,
		'template_version'   => (string) $template_theme->get( 'Version' ),
		'stylesheet'         => $stylesheet,
		'stylesheet_version' => (string) $stylesheet_theme->get( 'Version' ),
	);
}

/** @param mixed $value */
function wpagent_core_mutator_theme_mods_value_is_safe( $value, int $depth = 0, ?int &$nodes = null ): bool {
	if ( null === $nodes ) {
		$nodes = 0;
	}
	if ( ++$nodes > 50000 || $depth > 16 ) {
		return false;
	}
	if ( is_array( $value ) ) {
		foreach ( $value as $key => $child ) {
			if ( ( ! is_int( $key ) && ! is_string( $key ) ) || ! wpagent_core_mutator_theme_mods_value_is_safe( $child, $depth + 1, $nodes ) ) {
				return false;
			}
		}
		return true;
	}
	return is_null( $value ) || is_bool( $value ) || is_int( $value ) || is_float( $value ) || ( is_string( $value ) && strlen( $value ) <= 1048576 );
}

/**
 * Returns the exact active-theme option row, including bytes not exposed to
 * clients. A menu-location write only edits nav_menu_locations but compares
 * the complete row so unknown theme settings can never be overwritten.
 *
 * @return array{identity:array{template:string,template_version:string,stylesheet:string,stylesheet_version:string},option_name:string,row:array{raw:string,autoload:string}|null,mods:array<string,mixed>,locations:array<string,mixed>,hash:string}|WP_Error
 */
function wpagent_core_mutator_menu_location_state() {
	$identity = wpagent_core_mutator_active_theme_identity();
	if ( is_wp_error( $identity ) ) {
		return $identity;
	}
	$option_name = 'theme_mods_' . $identity['stylesheet'];
	$row         = wpagent_core_mutator_option_row( $option_name );
	if ( is_array( $row ) && strlen( $row['raw'] ) > 1048576 ) {
		return new WP_Error( 'wpagent_menu_location_theme_mods_too_large', __( 'Die Theme-Mod-Einstellungen sind für eine sichere Menüpositionsänderung zu groß.', 'wp-agent-companion' ), array( 'status' => 413 ) );
	}
	$mods = is_array( $row ) ? maybe_unserialize( $row['raw'] ) : array();
	if ( ! is_array( $mods ) || ! wpagent_core_mutator_theme_mods_value_is_safe( $mods ) ) {
		return new WP_Error( 'wpagent_menu_location_theme_mods_invalid', __( 'Die Theme-Mod-Einstellungen sind für eine sichere Menüpositionsänderung ungültig.', 'wp-agent-companion' ), array( 'status' => 502 ) );
	}
	$locations = $mods['nav_menu_locations'] ?? array();
	if ( ! is_array( $locations ) ) {
		return new WP_Error( 'wpagent_menu_location_state_invalid', __( 'Die gespeicherten Menüpositionen sind ungültig und werden nicht überschrieben.', 'wp-agent-companion' ), array( 'status' => 502 ) );
	}
	$state = array(
		'identity'    => $identity,
		'option_name' => $option_name,
		'row'         => $row,
		'mods'        => $mods,
		'locations'   => $locations,
	);
	$state['hash'] = wpagent_core_mutator_hash(
		array(
			'scope'       => 'menu-locations',
			'identity'    => $identity,
			'option_name' => $option_name,
			'row'         => $row,
		)
	);
	return $state;
}

/**
 * Replaces an active theme's complete theme-mod option only if the exact
 * previously-read database row is still present. The create branch uses the
 * options table's unique key rather than an unsafe check-then-insert.
 *
 * @param array{identity:array{template:string,template_version:string,stylesheet:string,stylesheet_version:string},option_name:string,row:array{raw:string,autoload:string}|null,mods:array<string,mixed>,locations:array<string,mixed>,hash:string} $before
 * @param array<string,mixed> $candidate_mods
 * @return array{raw:string,autoload:string}|false
 */
function wpagent_core_mutator_replace_theme_mods_if_current( array $before, array $candidate_mods ) {
	global $wpdb;
	$option_name = (string) ( $before['option_name'] ?? '' );
	if ( '' === $option_name || ! wpagent_core_mutator_theme_mods_value_is_safe( $candidate_mods ) ) {
		return false;
	}
	$candidate_raw = maybe_serialize( $candidate_mods );
	if ( strlen( $candidate_raw ) > 1048576 ) {
		return false;
	}
	$row = $before['row'] ?? null;
	if ( null === $row ) {
		if ( ! wpagent_companion_add_option_create_only( $option_name, $candidate_mods, '', null ) ) {
			return false;
		}
		$current = wpagent_core_mutator_option_row( $option_name );
		return is_array( $current ) && hash_equals( $candidate_raw, $current['raw'] ) ? $current : false;
	}
	if ( ! is_array( $row ) || ! isset( $row['raw'], $row['autoload'] ) ) {
		return false;
	}
	$updated = $wpdb->query(
		$wpdb->prepare(
			"UPDATE {$wpdb->options} SET option_value = %s, autoload = %s WHERE option_name = %s AND HEX(option_value) = HEX(%s) AND HEX(autoload) = HEX(%s)",
			$candidate_raw,
			$row['autoload'],
			$option_name,
			$row['raw'],
			$row['autoload']
		)
	);
	if ( 1 !== $updated ) {
		return false;
	}
	wpagent_companion_forget_option_cache( $option_name, in_array( $row['autoload'], wp_autoload_values_to_autoload(), true ) );
	$current = wpagent_core_mutator_option_row( $option_name );
	return is_array( $current ) && hash_equals( $candidate_raw, $current['raw'] ) && hash_equals( $row['autoload'], $current['autoload'] ) ? $current : false;
}

function wpagent_core_mutator_lock_option( string $kind, string $identity ): string {
	return 'wp_agent_core_mutator_lock_' . hash( 'sha256', $kind . "\0" . $identity );
}

/** @return string|WP_Error */
function wpagent_core_mutator_acquire_lock( string $kind, string $identity ) {
	if ( 1 !== preg_match( '/^[a-z0-9_-]{1,64}$/D', $kind ) || '' === $identity || strlen( $identity ) > 255 ) {
		return new WP_Error( 'wpagent_core_mutator_lock_invalid', __( 'Ungültige Sperren-Ressource.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	$token = wpagent_companion_acquire_option_lock( wpagent_core_mutator_lock_option( $kind, $identity ), WPAGENT_CORE_MUTATOR_LOCK_TTL );
	if ( is_string( $token ) ) {
		return $token;
	}
	return new WP_Error( 'wpagent_core_mutator_locked', __( 'Diese Ressource wird gerade durch einen anderen Vorgang geändert.', 'wp-agent-companion' ), array( 'status' => 409 ) );
}

function wpagent_core_mutator_refresh_lock( string $kind, string $identity, string $token ): bool {
	return wpagent_companion_refresh_option_lock( wpagent_core_mutator_lock_option( $kind, $identity ), $token, WPAGENT_CORE_MUTATOR_LOCK_TTL );
}

function wpagent_core_mutator_lock_is_owned( string $kind, string $identity, string $token ): bool {
	return wpagent_companion_option_lock_is_owned( wpagent_core_mutator_lock_option( $kind, $identity ), $token );
}

function wpagent_core_mutator_release_lock( string $kind, string $identity, string $token ): void {
	wpagent_companion_release_option_lock( wpagent_core_mutator_lock_option( $kind, $identity ), $token );
}

/** @return string|WP_Error */
function wpagent_core_mutator_expected_hash( array $input ) {
	$hash = $input['expected_hash'] ?? null;
	if ( ! is_string( $hash ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $hash ) ) {
		return new WP_Error( 'wpagent_core_mutator_expected_hash', __( 'expected_hash muss ein SHA-256-Hash aus einem aktuellen Lesevorgang sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	return $hash;
}

function wpagent_core_mutator_is_strict_integer( $value ): bool {
	return is_int( $value ) || ( is_string( $value ) && 1 === preg_match( '/^(?:0|[1-9][0-9]*)$/D', $value ) );
}

/** @return string|WP_Error */
function wpagent_core_mutator_bounded_text( $value, int $maximum_length, bool $allow_empty = false ) {
	if ( ! is_string( $value ) || strlen( $value ) > $maximum_length || ( ! $allow_empty && '' === trim( $value ) ) || 1 === preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value ) ) {
		return new WP_Error( 'wpagent_core_mutator_text', __( 'Der Textwert ist leer, enthält Steuerzeichen oder überschreitet die zulässige Länge.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	return $value;
}

/**
 * Validates a provider-owned output string before it crosses the public
 * Ability boundary. Outputs are deliberately rejected, never truncated: a
 * shortened title, comment or URL would falsely claim a complete read.
 *
 * @return string|WP_Error
 */
function wpagent_core_schema_output_text( $value, int $maximum_length, string $field, bool $allow_empty = true ) {
	if ( ! is_string( $value ) || strlen( $value ) > $maximum_length ) {
		return new WP_Error(
			'wpagent_core_schema_output_overflow',
			/* translators: %s: output field name. */
			sprintf( __( 'Die Provider-Ausgabe für %s überschreitet die veröffentlichte Sicherheitsgrenze. Die Antwort wurde nicht gekürzt.', 'wp-agent-companion' ), $field ),
			array( 'status' => 500, 'recovery_required' => false )
		);
	}
	if ( ! $allow_empty && '' === $value ) {
		return new WP_Error( 'wpagent_core_schema_output_invalid', __( 'Eine erforderliche Provider-Ausgabe ist leer.', 'wp-agent-companion' ), array( 'status' => 500, 'recovery_required' => false ) );
	}
	return $value;
}

/**
 * Validates a public list envelope without silently discarding provider rows.
 *
 * @return array|WP_Error
 */
function wpagent_core_schema_output_list( $value, int $maximum_items, string $field ) {
	if ( ! is_array( $value ) || count( $value ) > $maximum_items ) {
		return new WP_Error(
			'wpagent_core_schema_output_overflow',
			/* translators: %s: output field name. */
			sprintf( __( 'Die Provider-Liste für %s überschreitet die veröffentlichte Sicherheitsgrenze. Die Antwort wurde nicht gekürzt.', 'wp-agent-companion' ), $field ),
			array( 'status' => 500, 'recovery_required' => false )
		);
	}
	return array_values( $value );
}

/** @return int|WP_Error */
function wpagent_core_schema_input_integer( $value, int $minimum, int $maximum, string $field ) {
	if ( ! wpagent_core_mutator_is_strict_integer( $value ) || (int) $value < $minimum || (int) $value > $maximum ) {
		return new WP_Error(
			'wpagent_core_schema_input_invalid',
			/* translators: %s: input field name. */
			sprintf( __( '%s liegt außerhalb der zulässigen Ganzzahlgrenze.', 'wp-agent-companion' ), $field ),
			array( 'status' => 400 )
		);
	}
	return (int) $value;
}

function wpagent_core_mutator_setting_values_equal( string $name, $actual, $expected ): bool {
	if ( in_array( $name, array( 'posts_per_page', 'page_on_front', 'page_for_posts', 'start_of_week', 'default_category' ), true ) ) {
		return wpagent_core_mutator_is_strict_integer( $actual ) && (int) $actual === (int) $expected;
	}
	return is_scalar( $actual ) && is_scalar( $expected ) && (string) $actual === (string) $expected;
}

/** @return array{raw:string,autoload:string}|null */
function wpagent_core_mutator_option_row( string $name ): ?array {
	return wpagent_companion_read_option_row_direct( $name );
}

/**
 * Restores a setting only if the database still contains exactly the candidate
 * written by this request. It never overwrites a third-party update.
 *
 * @param array{raw:string,autoload:string} $before
 * @param array{raw:string,autoload:string} $candidate
 */
function wpagent_core_mutator_restore_option_if_current( string $name, array $before, array $candidate ): bool {
	global $wpdb;
	if ( '' === $name || ! isset( $before['raw'], $before['autoload'], $candidate['raw'], $candidate['autoload'] ) ) {
		return false;
	}
	$updated = $wpdb->query(
		$wpdb->prepare(
			"UPDATE {$wpdb->options} SET option_value = %s, autoload = %s WHERE option_name = %s AND HEX(option_value) = HEX(%s) AND autoload = %s",
			$before['raw'],
			$before['autoload'],
			$name,
			$candidate['raw'],
			$candidate['autoload']
		)
	);
	if ( 1 !== $updated ) {
		return false;
	}
	wpagent_companion_forget_option_cache( $name, in_array( $before['autoload'], wp_autoload_values_to_autoload(), true ) );
	return true;
}

/**
 * Replaces only the exact option row observed by an allowlisted settings
 * write. The raw value and autoload column are both predicates, so a
 * concurrent option update cannot be overwritten after the PHP-level hash
 * read. This intentionally does not call option hooks: no hook contract can
 * make the subsequent database write conditional on the observed raw row.
 *
 * @param array{raw:string,autoload:string} $before
 */
function wpagent_core_mutator_replace_option_if_current( string $name, array $before, string $candidate_raw ): bool {
	global $wpdb;
	if ( '' === $name || ! isset( $before['raw'], $before['autoload'] ) ) {
		return false;
	}
	$updated = $wpdb->query(
		$wpdb->prepare(
			"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND HEX(option_value) = HEX(%s) AND autoload = %s",
			$candidate_raw,
			$name,
			$before['raw'],
			$before['autoload']
		)
	);
	if ( 1 !== $updated ) {
		return false;
	}
	wpagent_companion_forget_option_cache( $name, in_array( $before['autoload'], wp_autoload_values_to_autoload(), true ) );
	return true;
}

/** @return WP_Error */
function wpagent_core_mutator_recovery_required( string $code, string $message, string $current_hash = '' ) {
	$data = array( 'status' => 500, 'recovery_required' => true, 'rollback_verified' => false );
	if ( '' !== $current_hash ) {
		$data['current_hash'] = $current_hash;
	}
	return new WP_Error( $code, $message, $data );
}

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

/**
 * Snapshot an exact, newly created post candidate before compensating it.
 *
 * The complete persisted post record, metadata and taxonomy assignments are
 * deliberately hash-bound. A cleanup may only delete the same candidate; a
 * new wp-admin edit or provider-side enrichment leaves the post for recovery.
 *
 * @return array{post_id:int,hash:string}|null
 */
function wpagent_core_mutator_created_post_candidate( int $post_id, string $post_type, int $author_id ): ?array {
	$post = get_post( $post_id, ARRAY_A );
	if ( ! is_array( $post ) || $post_id !== (int) ( $post['ID'] ?? 0 ) || $post_type !== (string) ( $post['post_type'] ?? '' ) || $author_id !== (int) ( $post['post_author'] ?? 0 ) ) {
		return null;
	}
	$terms = array();
	foreach ( get_object_taxonomies( $post_type, 'names' ) as $taxonomy ) {
		$assigned = wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'all' ) );
		if ( is_wp_error( $assigned ) || ! is_array( $assigned ) ) {
			return null;
		}
		$terms[ $taxonomy ] = array_map(
			static fn( WP_Term $term ): array => array( 'term_id' => (int) $term->term_id, 'slug' => (string) $term->slug, 'taxonomy' => (string) $term->taxonomy ),
			array_filter( $assigned, static fn( $term ): bool => $term instanceof WP_Term )
		);
	}
	$state = wpagent_core_mutator_canonicalize_candidate_value(
		array(
			'scope'      => 'created-post',
			'post'       => $post,
			'meta'       => get_post_meta( $post_id ),
			'taxonomies' => $terms,
		)
	);
	return array( 'post_id' => $post_id, 'hash' => wpagent_core_mutator_hash( $state ) );
}

/**
 * Compensate only a freshly re-read created-post candidate. This intentionally
 * declines any uncertain cleanup rather than deleting a later foreign edit.
 *
 * @param array{post_id:int,hash:string}|null $candidate
 */
function wpagent_core_mutator_delete_created_post_if_current( ?array $candidate, string $post_type, int $author_id ): bool {
	if ( ! is_array( $candidate ) || ! isset( $candidate['post_id'], $candidate['hash'] ) || ! is_int( $candidate['post_id'] ) || ! is_string( $candidate['hash'] ) || ! preg_match( '/^[a-f0-9]{64}$/', $candidate['hash'] ) ) {
		return false;
	}
	$fresh = wpagent_core_mutator_created_post_candidate( $candidate['post_id'], $post_type, $author_id );
	if ( ! is_array( $fresh ) || ! hash_equals( $candidate['hash'], $fresh['hash'] ) ) {
		return false;
	}
	$deleted = wp_delete_post( $candidate['post_id'], true );
	return $deleted instanceof WP_Post && ! get_post( $candidate['post_id'] );
}

/** @return WP_Error */
function wpagent_core_mutator_created_post_recovery_required( int $post_id, string $post_type, int $author_id, string $reason, $original_error = null ): WP_Error {
	$current = wpagent_core_mutator_created_post_candidate( $post_id, $post_type, $author_id );
	$data = array(
		'status'            => 500,
		'post_id'           => $post_id,
		'reason'            => $reason,
		'rollback_verified' => false,
		'recovery_required' => true,
	);
	if ( is_array( $current ) ) {
		$data['current_hash'] = $current['hash'];
	}
	if ( $original_error instanceof WP_Error ) {
		$data['original_error'] = $original_error->get_error_code();
		$original_data = $original_error->get_error_data();
		if ( is_array( $original_data ) && is_string( $original_data['verification_path'] ?? null ) && preg_match( '/^[A-Za-z0-9_.-]{1,100}$/D', $original_data['verification_path'] ) ) {
			$data['original_error_path'] = $original_data['verification_path'];
		}
	}
	return new WP_Error( 'wpagent_created_post_recovery_required', __( 'Die neu erstellte Ressource konnte nicht sicher kompensiert werden. Wiederhole den Vorgang nicht automatisch und prüfe den aktuellen Zustand manuell.', 'wp-agent-companion' ), $data );
}

/**
 * Snapshot a newly created navigation menu before compensating it.
 *
 * This is intentionally separate from delete-menu: a generic pre-existing
 * menu cannot be removed safely because Core has no conditional delete. A
 * create flow may compensate only its immediately re-read, bounded candidate.
 *
 * @return array{menu_id:int,hash:string}|null
 */
function wpagent_core_mutator_created_menu_candidate( int $menu_id ): ?array {
	$state = wpagent_core_mutator_menu_state( $menu_id );
	if ( ! is_array( $state ) || ! $state['bounded'] ) {
		return null;
	}
	return array( 'menu_id' => $menu_id, 'hash' => $state['hash'] );
}

/**
 * Compensate only an unchanged freshly-created menu. The current-state hash
 * covers its term fields, every item and every assigned location, so a later
 * wp-admin edit refuses the cleanup instead of deleting foreign work.
 *
 * @param array{menu_id:int,hash:string}|null $candidate
 */
function wpagent_core_mutator_delete_created_menu_if_current( ?array $candidate ): bool {
	if ( ! is_array( $candidate ) || ! isset( $candidate['menu_id'], $candidate['hash'] ) || ! is_int( $candidate['menu_id'] ) || ! is_string( $candidate['hash'] ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $candidate['hash'] ) ) {
		return false;
	}
	$fresh = wpagent_core_mutator_created_menu_candidate( $candidate['menu_id'] );
	if ( ! is_array( $fresh ) || ! hash_equals( $candidate['hash'], $fresh['hash'] ) || ! function_exists( 'wp_delete_nav_menu' ) ) {
		return false;
	}
	$deleted = wp_delete_nav_menu( $candidate['menu_id'] );
	return true === $deleted && ! wp_get_nav_menu_object( $candidate['menu_id'] );
}

/**
 * Return a complete internal snapshot of a fresh user candidate. Password and
 * activation-key bytes are hash-bound locally and are never exposed by an
 * Ability response. User meta and owned content are included because deleting
 * a user removes both.
 *
 * @return array{user_id:int,hash:string}|null
 */
function wpagent_core_mutator_created_user_candidate( int $user_id ): ?array {
	$user = get_userdata( $user_id );
	if ( ! $user instanceof WP_User ) {
		return null;
	}
	$meta = get_user_meta( $user_id );
	if ( ! is_array( $meta ) || count( $meta ) > WPAGENT_CORE_MUTATOR_MAX_USER_OBJECTS || ! wpagent_core_mutator_theme_mods_value_is_safe( $meta ) ) {
		return null;
	}
	ksort( $meta, SORT_STRING );
	$owned = wpagent_core_mutator_user_owned_objects( $user_id );
	if ( ! $owned['bounded'] || ! $owned['readable'] ) {
		return null;
	}
	$record = array(
		'ID'                  => (int) $user->ID,
		'user_login'          => (string) $user->user_login,
		'user_pass'           => (string) $user->user_pass,
		'user_nicename'       => (string) $user->user_nicename,
		'user_email'          => (string) $user->user_email,
		'user_url'            => (string) $user->user_url,
		'user_registered'     => (string) $user->user_registered,
		'user_activation_key' => (string) $user->user_activation_key,
		'user_status'         => (int) $user->user_status,
		'display_name'        => (string) $user->display_name,
		'spam'                => isset( $user->spam ) ? (int) $user->spam : 0,
		'deleted'             => isset( $user->deleted ) ? (int) $user->deleted : 0,
	);
	return array(
		'user_id' => $user_id,
		'hash'    => wpagent_core_mutator_hash(
			wpagent_core_mutator_canonicalize_candidate_value(
				array(
					'scope'  => 'created-user',
					'record' => $record,
					'meta'   => $meta,
					'owned'  => $owned,
				)
			)
		),
	);
}

/**
 * Compensate only a fresh, exact user candidate. General delete-user remains
 * manual-only because a caller cannot prove this short create-flow ownership.
 *
 * @param array{user_id:int,hash:string}|null $candidate
 */
function wpagent_core_mutator_delete_created_user_if_current( ?array $candidate ): bool {
	if ( ! is_array( $candidate ) || ! isset( $candidate['user_id'], $candidate['hash'] ) || ! is_int( $candidate['user_id'] ) || ! is_string( $candidate['hash'] ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $candidate['hash'] ) ) {
		return false;
	}
	$fresh = wpagent_core_mutator_created_user_candidate( $candidate['user_id'] );
	if ( ! is_array( $fresh ) || ! hash_equals( $candidate['hash'], $fresh['hash'] ) || ! function_exists( 'wp_delete_user' ) ) {
		return false;
	}
	return true === wp_delete_user( $candidate['user_id'] ) && ! get_userdata( $candidate['user_id'] );
}
