<?php
/**
 * Ability: wp-agent/render-verify
 *
 * Read-only. Rendert einen Beitrag serverseitig gegen die EFFEKTIVE Ausgabe
 * (nicht die rohe DB-Zeile) und prüft sie gegen builder-/plugin-spezifische
 * Read-Back-Fallen. Das ist der Kern-Moat aus dem Bauplan (docs/strategy/
 * developer-mode-plan-2026-07.md, Punkt 2 Fähigkeit 4 + Punkt 3): "Verifikation
 * muss das gerenderte HTML plus den plugin-eigenen Read-Back prüfen."
 *
 * Serverseitig gerendert, nicht per HTTP-Selbstanfrage auf die Permalink-URL:
 * der PHP-Built-in-Server dieser Testinstanz hängt bei Selbstanfragen/DNS
 * (empirisch belegt). apply_filters( 'the_content', ... ) ist die tatsächliche
 * Frontend-Rendering-Kette und ruft do_blocks() bereits selbst bei Priorität 9
 * auf (siehe wp-includes/default-filters.php), zusammen mit wptexturize,
 * wpautop, do_shortcode (relevant für CF7/WPBakery) und wp_filter_content_tags.
 * Ein zusätzlicher separater do_blocks()-Aufruf vor apply_filters() wäre
 * redundant und würde Block-render_callbacks ohne Nutzen doppelt auslösen;
 * apply_filters( 'the_content', ... ) allein liefert bereits die effektive
 * Ausgabe. Block-Zählung (block_count/freeform_count/block_types) läuft
 * weiterhin über wpagent_block_report() auf dem rohen post_content, wie bei
 * wp-agent/render-check und wp-agent/detect-builder.
 *
 * Drei belegte "Read-back lügt"-Fallen, je ein eigener Check:
 *   b. BUILDER-RENDER-MISMATCH: ein postmeta-JSON-Builder (Elementor, Bricks,
 *      Breakdance, Oxygen) kann gespeichert sein, ohne dass sein Plugin aktiv
 *      ist. Das Frontend rendert dann nur das rohe post_content, nie die
 *      Builder-Ausgabe. Wiederverwendet die Signale aus wp-agent/detect-builder.
 *   c. CF7-FALLE: Contact Form 7 speichert die Formularkonfiguration in
 *      postmeta "_form", post_content ist bei diesem Post-Type inert. Ein
 *      Write dorthin verifiziert sauber und tut nichts.
 *   d. SEO-READ-BACK: Rank Math und SEOPress lesen aus ihren gespeicherten
 *      Provider-Metafeldern. AIOSEO wird über seine native SEO-Ability gelesen.
 *      Yoast wird über seine Meta Surface gelesen. Diese löst in Produktion
 *      das persistierte Indexable auf und baut es in Umgebungen, in denen
 *      Yoast Persistenz absichtlich deaktiviert, im Speicher auf. Nur das
 *      jeweils aktive unterstützte Plugin wird gegen seinen echten Lesepfad
 *      geprüft.
 * Dazu die FREEFORM-Kontrolle (a): core/freeform ist der stille Classic-
 * Editor-Fallback, den render-check bereits kennt.
 *
 * Introspektion vor Hardcoding, wie in detect-builder.php: Builder-Aktivität
 * wird über Klassen-/Konstanten-Introspektion geprüft, nicht nur über
 * is_plugin_active(). Bewusst eigenständig implementiert statt Funktionen aus
 * refresh-builder-cache.php wiederzuverwenden, damit render-verify.php nicht an
 * eine Datei gekoppelt ist, an der parallel andere Sessions arbeiten.
 *
 * @package WPAgentCompanion
 */

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

function wpagent_ability_render_verify(): array {
	return array(
		'label'               => __( 'Render-Verify', 'wp-agent-companion' ),
		'description'         => __( 'Rendert einen Beitrag serverseitig gegen die effektive Ausgabe (apply_filters the_content, nicht die rohe DB-Zeile) und prüft sie gegen builder-/plugin-spezifische Read-Back-Fallen: core/freeform, Builder-Datensatz ohne aktives Plugin, Contact-Form-7-inerten post_content und den tatsächlichen SEO-Render-Pfad.', 'wp-agent-companion' ),
		'category'            => 'wp-agent',
		'input_schema'        => array(
			'type'       => 'object',
			'properties' => array(
				'post_id' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 2147483647 ),
			),
			'required'   => array( 'post_id' ),
			'additionalProperties' => false,
		),
		'output_schema'       => array(
			'type'       => 'object',
			'additionalProperties' => false,
			'properties' => array(
				'post_id'  => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 2147483647 ),
				'rendered' => array(
					'type'                 => 'object',
					'additionalProperties' => false,
					'properties'           => array(
						'html_length'    => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => WPAGENT_BUILDER_READ_MAX_RENDERED_HTML_BYTES ),
						'word_count'     => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => 250000 ),
						'block_count'    => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => WPAGENT_BUILDER_READ_MAX_BLOCKS ),
						'freeform_count' => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => WPAGENT_BUILDER_READ_MAX_BLOCKS ),
						'has_freeform'   => array( 'type' => 'boolean' ),
						'block_types'    => array(
							'type'  => 'array',
							'maxItems' => 128,
							'items' => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 128, 'pattern' => '^[A-Za-z0-9][A-Za-z0-9._:/-]*$' ),
						),
					),
					'required'             => array( 'html_length', 'word_count', 'block_count', 'freeform_count', 'has_freeform', 'block_types' ),
				),
				'checks'   => array(
					'type'  => 'array', 'maxItems' => 4,
					'items' => array(
						'type'       => 'object', 'additionalProperties' => false,
						'properties' => array(
							'check'    => array( 'type' => 'string', 'enum' => array( 'freeform', 'builder_render_mismatch', 'cf7_post_content_inert', 'seo_read_back' ) ),
							'subject'  => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 256 ),
							'agrees'   => array( 'type' => 'boolean' ),
							'severity' => array( 'type' => 'string', 'enum' => array( 'info', 'warn' ) ),
							'note'     => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 1024 ),
						),
						'required' => array( 'check', 'subject', 'agrees', 'severity', 'note' ),
					),
				),
				'verdict'  => array(
					'type'       => 'object', 'additionalProperties' => false,
					'properties' => array(
						'renders_ok' => array( 'type' => 'boolean' ),
						'warnings'   => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => 4 ),
					),
					'required' => array( 'renders_ok', 'warnings' ),
				),
				'notes'    => array(
					'type'  => 'array', 'maxItems' => 16,
					'items' => array( 'type' => 'string', 'maxLength' => 1024 ),
				),
			),
			'required'   => array( 'post_id', 'rendered', 'checks', 'verdict', 'notes' ),
		),
		'execute_callback'    => 'wpagent_exec_render_verify',
		'permission_callback' => 'wpagent_perm_edit_posts',
		'meta'                => array(
			'show_in_rest' => true,
			'annotations'  => array(
				'readonly'    => true,
				'destructive' => false,
				'idempotent'  => true,
			),
		),
	);
}

/**
 * @param mixed $input
 * @return array|WP_Error
 */
function wpagent_exec_render_verify( $input ) {
	$post_id = wpagent_builder_read_post_id_from_input( $input );
	if ( is_wp_error( $post_id ) ) {
		return $post_id;
	}

	// Existenz-/Berechtigungsprüfung sowie Builder-Erkennung über
	// wp-agent/detect-builder wiederverwenden (nicht dupliziert): liefert bei
	// fehlendem Beitrag 404, bei fehlender edit_post-Capability 403 (siehe
	// detect-builder.php-Kommentar, gleiches Muster wie refresh-builder-cache.php).
	$detection = wpagent_exec_detect_builder( array( 'post_id' => $post_id ) );
	if ( is_wp_error( $detection ) ) {
		return $detection;
	}

	$post = get_post( $post_id );

	// Effektive Ausgabe: apply_filters( 'the_content', ... ) durchläuft die
	// komplette Frontend-Kette (siehe Datei-Kommentar). Wortzahl/HTML-Länge
	// beziehen sich bewusst auf dieses Ergebnis, nicht auf do_blocks() allein
	// wie bei wp-agent/render-check: render-verify prüft die effektive
	// Ausgabe, render-check nur die Block-Interpretation.
	$rendered_html = wpagent_render_verify_render_post_content( $post );
	if ( strlen( $rendered_html ) > WPAGENT_BUILDER_READ_MAX_RENDERED_HTML_BYTES ) {
		return new WP_Error( 'wpagent_builder_read_too_large', __( 'Die gerenderte Builder-Ausgabe überschreitet die sichere Lesegrenze.', 'wp-agent-companion' ), array( 'status' => 413 ) );
	}
	$text          = trim( wp_strip_all_tags( $rendered_html ) );
	$word_count    = ( '' === $text ) ? 0 : count( preg_split( '/\s+/', $text ) );
	$report        = wpagent_block_report( $post->post_content );
	$report_valid  = wpagent_builder_read_validate_block_report( $report );
	if ( is_wp_error( $report_valid ) ) {
		return $report_valid;
	}

	$rendered = array(
		'html_length'    => strlen( $rendered_html ),
		'word_count'     => $word_count,
		'block_count'    => $report['block_count'],
		'freeform_count' => $report['freeform_count'],
		'has_freeform'   => $report['freeform_count'] > 0,
		'block_types'    => $report['block_types'],
	);

	$checks = array();
	$notes  = array();

	$checks[] = wpagent_render_verify_check_freeform( $rendered );

	$builder_check = wpagent_render_verify_check_builder_mismatch( $detection );
	if ( null !== $builder_check ) {
		$checks[] = $builder_check;
	}

	$cf7_check = wpagent_render_verify_check_cf7( $post );
	if ( null !== $cf7_check ) {
		$checks[] = $cf7_check;
	}

	$checks[] = wpagent_render_verify_check_seo( $post_id );

	$warnings                    = 0;
	$has_active_builder_mismatch = false;
	foreach ( $checks as $check ) {
		if ( 'warn' === $check['severity'] ) {
			$warnings++;
		}
		if ( 'builder_render_mismatch' === $check['check'] && ! $check['agrees'] ) {
			$has_active_builder_mismatch = true;
		}
	}

	$renders_ok = ( ! $rendered['has_freeform'] ) && ( ! $has_active_builder_mismatch );

	$result = array(
		'post_id'  => $post_id,
		'rendered' => $rendered,
		'checks'   => $checks,
		'verdict'  => array(
			'renders_ok' => $renders_ok,
			'warnings'   => $warnings,
		),
		'notes'    => $notes,
	);
	$output_valid = wpagent_builder_read_validate_render_verify_output( $result );
	return is_wp_error( $output_valid ) ? $output_valid : $result;
}

/**
 * Rendert post_content mit dem Zielbeitrag als aktuellem Post-Kontext.
 *
 * REST-/MCP-Aufrufe laufen außerhalb des Loops. Shortcodes und Builder-Filter,
 * die get_the_ID() oder global $post verwenden, brauchen trotzdem denselben
 * Kontext wie beim Frontend-Rendern eines einzelnen Beitrags.
 *
 * @param WP_Post $post
 * @return string
 */
function wpagent_render_verify_render_post_content( WP_Post $post ): string {
	$context_globals = array(
		'post',
		'id',
		'authordata',
		'currentday',
		'currentmonth',
		'page',
		'pages',
		'multipage',
		'more',
		'numpages',
	);
	$previous_globals = array();

	foreach ( $context_globals as $name ) {
		$previous_globals[ $name ] = array(
			'exists' => array_key_exists( $name, $GLOBALS ),
			'value'  => $GLOBALS[ $name ] ?? null,
		);
	}

	$GLOBALS['post'] = $post;

	try {
		setup_postdata( $post );
		return (string) apply_filters( 'the_content', $post->post_content );
	} finally {
		foreach ( $previous_globals as $name => $state ) {
			if ( $state['exists'] ) {
				$GLOBALS[ $name ] = $state['value'];
			} else {
				unset( $GLOBALS[ $name ] );
			}
		}
	}
}

/**
 * FREEFORM: core/freeform ist der stille Classic-Editor-Fallback, den
 * parse_blocks() erzeugt, wenn Inhalt nicht als gültiges Block-Markup
 * geparst werden kann (dieselbe Signal-Definition wie wp-agent/render-check).
 *
 * @param array{block_count:int,freeform_count:int,has_freeform:bool,block_types:array<int,string>} $rendered
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}
 */
function wpagent_render_verify_check_freeform( array $rendered ): array {
	$has_freeform = $rendered['has_freeform'];

	return array(
		'check'    => 'freeform',
		'subject'  => 'post_content',
		'agrees'   => ! $has_freeform,
		'severity' => $has_freeform ? 'warn' : 'info',
		'note'     => $has_freeform
			? __( 'Block-Markup ist still auf den Classic-Editor zurückgefallen (core/freeform gefunden).', 'wp-agent-companion' )
			: __( 'Kein core/freeform-Block gefunden: kein stiller Rückfall auf den Classic-Editor.', 'wp-agent-companion' ),
	);
}

/**
 * BUILDER-RENDER-MISMATCH: die zentrale Read-back-lügt-Falle. Ein
 * Builder-Datensatz kann in postmeta liegen (z. B. _elementor_data), aber
 * wenn das zugehörige Plugin nicht aktiv ist, rendert das Frontend nur das
 * rohe post_content, nie die Builder-Ausgabe. Nutzt die Signale aus
 * wp-agent/detect-builder, prüft aber zusätzlich, ob der jeweilige Builder
 * tatsächlich geladen ist.
 *
 * @param array<string,mixed> $detection Ergebnis von wpagent_exec_detect_builder().
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}|null Null, wenn kein postmeta-JSON-Builder erkannt wurde.
 */
function wpagent_render_verify_check_builder_mismatch( array $detection ): ?array {
	$postmeta_builder_keys = array(
		'_bricks_page_content_2' => 'bricks',
		'ct_builder_json'        => 'oxygen',
		'ct_builder_shortcodes'  => 'oxygen',
		'_breakdance_data'       => 'breakdance',
		'_elementor_data'        => 'elementor',
	);

	$found_key     = '';
	$found_builder = '';
	foreach ( (array) ( $detection['signals'] ?? array() ) as $signal ) {
		if ( ! is_array( $signal ) || 'postmeta' !== ( $signal['type'] ?? '' ) ) {
			continue;
		}
		$key = (string) ( $signal['key'] ?? '' );
		if ( isset( $postmeta_builder_keys[ $key ] ) ) {
			$found_key     = $key;
			$found_builder = $postmeta_builder_keys[ $key ];
			break;
		}
	}

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

	$active = wpagent_render_verify_builder_plugin_active( $found_builder );

	return array(
		'check'    => 'builder_render_mismatch',
		'subject'  => $found_key,
		'agrees'   => $active,
		'severity' => $active ? 'info' : 'warn',
		'note'     => $active
			? sprintf(
				/* translators: 1: postmeta-Schlüssel, 2: Builder-Name */
				__( 'Builder-Datensatz "%1$s" (%2$s) in postmeta vorhanden, Plugin ist aktiv: das Frontend rendert die Builder-Ausgabe.', 'wp-agent-companion' ),
				$found_key,
				$found_builder
			)
			: sprintf(
				/* translators: 1: postmeta-Schlüssel, 2: Builder-Name */
				__( 'Builder-Datensatz "%1$s" (%2$s) in postmeta vorhanden, aber Plugin nicht aktiv: das Frontend zeigt nur rohes post_content, nicht die Builder-Ausgabe.', 'wp-agent-companion' ),
				$found_key,
				$found_builder
			),
	);
}

/**
 * Prüft, ob das zum erkannten postmeta-JSON-Builder gehörende Plugin (bei
 * Bricks: das Theme) tatsächlich geladen ist. Introspektion vor Hardcoding
 * über Klassen-/Konstanten-Signale, dieselbe Herangehensweise wie
 * detect-builder.php und refresh-builder-cache.php, hier aber eigenständig
 * implementiert (siehe Datei-Kommentar).
 *
 * @param string $builder
 * @return bool
 */
function wpagent_render_verify_builder_plugin_active( string $builder ): bool {
	switch ( $builder ) {
		case 'elementor':
			return class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance );

		case 'bricks':
			return class_exists( '\Bricks\Builder' ) || defined( 'BRICKS_VERSION' ) || 'bricks' === get_template();

		case 'breakdance':
			return defined( 'BREAKDANCE_VERSION' )
				|| defined( 'BREAKDANCE_PLUGIN_VERSION' )
				|| class_exists( '\Breakdance\Plugin' )
				|| function_exists( 'Breakdance\plugin' );

		case 'oxygen':
			return defined( 'CT_VERSION' )
				|| defined( 'OXYGEN_VSB_VERSION' )
				|| class_exists( 'Oxygen_VSB' )
				|| function_exists( 'ct_template_output' )
				|| function_exists( 'ct_shortcodes' );

		default:
			return true;
	}
}

/**
 * CF7-FALLE: Contact Form 7 speichert die Formularkonfiguration in postmeta
 * "_form", post_content ist bei diesem Post-Type inert. Ein Write dorthin
 * verifiziert sauber und tut nichts.
 *
 * @param WP_Post $post
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}|null Null bei anderen Post-Types.
 */
function wpagent_render_verify_check_cf7( WP_Post $post ): ?array {
	if ( 'wpcf7_contact_form' !== $post->post_type ) {
		return null;
	}

	$content_is_empty = '' === trim( wp_strip_all_tags( $post->post_content ) );

	return array(
		'check'    => 'cf7_post_content_inert',
		'subject'  => 'post_content',
		'agrees'   => $content_is_empty,
		'severity' => $content_is_empty ? 'info' : 'warn',
		'note'     => $content_is_empty
			? __( 'post_content ist leer: Contact Form 7 nutzt ohnehin nur postmeta "_form".', 'wp-agent-companion' )
			: __( 'post_content bei CF7 rendert nicht als Formular; die Konfiguration liegt in postmeta "_form".', 'wp-agent-companion' ),
	);
}

/**
 * SEO-READ-BACK: liest Titel/Description über den Pfad, den das aktive
 * SEO-Plugin tatsächlich zum Rendern nutzt, statt einen postmeta-Schlüssel
 * anzunehmen. Rank Math und SEOPress lesen ihre eigenen Post-Metafelder,
 * AIOSEO stellt eine native SEO-Ability bereit. Yoast rendert seit v14 aus der
 * Indexables-Tabelle wp_yoast_indexable, nicht aus postmeta, das ist die
 * Yoast-Read-back-Falle.
 *
 * @param int $post_id
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}
 */
function wpagent_render_verify_check_seo( int $post_id ): array {
	$plugin = wpagent_detect_seo_plugin();

	if ( 'rankmath' === $plugin ) {
		return wpagent_render_verify_check_seo_rankmath( $post_id );
	}

	if ( 'yoast' === $plugin ) {
		return wpagent_render_verify_check_seo_yoast( $post_id );
	}

	if ( 'aioseo' === $plugin ) {
		return wpagent_render_verify_check_seo_aioseo( $post_id );
	}

	if ( 'seopress' === $plugin ) {
		return wpagent_render_verify_check_seo_seopress( $post_id );
	}

	return array(
		'check'    => 'seo_read_back',
		'subject'  => 'kein aktives unterstütztes SEO-Plugin',
		'agrees'   => true,
		'severity' => 'info',
		'note'     => __( 'Kein aktives unterstütztes SEO-Plugin (Rank Math, Yoast, AIOSEO oder SEOPress) erkannt. Der SEO-Read-Back entfällt.', 'wp-agent-companion' ),
	);
}

/**
 * @param int $post_id
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}
 */
function wpagent_render_verify_check_seo_rankmath( int $post_id ): array {
	$title       = (string) get_post_meta( $post_id, 'rank_math_title', true );
	$description = (string) get_post_meta( $post_id, 'rank_math_description', true );

	return array(
		'check'    => 'seo_read_back',
		'subject'  => 'rank_math_title / rank_math_description (postmeta)',
		// Rank Math liest beim Rendern denselben postmeta-Schlüssel: Speichern
		// und Rendern teilen sich eine einzige Quelle, ein Auseinanderlaufen ist
		// strukturell ausgeschlossen (anders als bei Yoast, siehe unten).
		'agrees'   => true,
		'severity' => 'info',
		'note'     => sprintf(
			/* translators: 1: Titel, 2: Description */
			__( 'Aktives SEO-Plugin: Rank Math. Rendert Titel/Description direkt aus postmeta (rank_math_title="%1$s", rank_math_description="%2$s"), kein separater Indexables-Layer wie bei Yoast.', 'wp-agent-companion' ),
			$title,
			$description
		),
	);
}

/**
 * Prüft AIOSEO über dessen native, berechtigungsgeprüfte SEO-Ability.
 *
 * @param int $post_id
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}
 */
function wpagent_render_verify_check_seo_aioseo( int $post_id ): array {
	$snapshot = wpagent_read_seo_meta( $post_id, 'aioseo' );
	if ( is_wp_error( $snapshot ) ) {
		return array(
			'check'    => 'seo_read_back',
			'subject'  => 'aioseo-posts/seo-data-get',
			'agrees'   => false,
			'severity' => 'warn',
			'note'     => sprintf(
				/* translators: %s: provider error message. */
				__( 'Aktives SEO-Plugin: AIOSEO. Die native SEO-Ability konnte die gespeicherten Post-SEO-Daten nicht lesen: %s', 'wp-agent-companion' ),
				$snapshot->get_error_message()
			),
		);
	}

	return array(
		'check'    => 'seo_read_back',
		'subject'  => 'aioseo-posts/seo-data-get',
		'agrees'   => true,
		'severity' => 'info',
		'note'     => __( 'Aktives SEO-Plugin: AIOSEO. Titel, Description, Canonical, Robots- und Social-Meta wurden über AIOSEOs native SEO-Ability gelesen.', 'wp-agent-companion' ),
	);
}

/**
 * Prüft SEOPress über die provider-eigenen Post-Metafelder, die auch dessen
 * Frontend-Renderer verwendet.
 *
 * @param int $post_id
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}
 */
function wpagent_render_verify_check_seo_seopress( int $post_id ): array {
	$snapshot = wpagent_read_seo_meta( $post_id, 'seopress' );
	if ( is_wp_error( $snapshot ) ) {
		return array(
			'check'    => 'seo_read_back',
			'subject'  => 'SEOPress-Postmeta',
			'agrees'   => false,
			'severity' => 'warn',
			'note'     => sprintf(
				/* translators: %s: provider error message. */
				__( 'Aktives SEO-Plugin: SEOPress. Die gespeicherten Post-SEO-Daten konnten nicht gelesen werden: %s', 'wp-agent-companion' ),
				$snapshot->get_error_message()
			),
		);
	}

	return array(
		'check'    => 'seo_read_back',
		'subject'  => '_seopress_titles_* / _seopress_robots_* / _seopress_social_*',
		'agrees'   => true,
		'severity' => 'info',
		'note'     => __( 'Aktives SEO-Plugin: SEOPress. Titel, Description, Canonical, Robots- und Social-Meta wurden aus den provider-eigenen Post-Metafeldern gelesen, die SEOPress auch für seine Frontend-Ausgabe verwendet.', 'wp-agent-companion' ),
	);
}

/**
 * Liest die wirksamen Yoast-Werte über Yoasts Meta Surface.
 *
 * Die Surface ist der von Yoast selbst verwendete Read-Pfad. In Produktion
 * basiert sie auf dem persistierten Indexable. In lokalen und anderen
 * Nicht-Produktionsumgebungen kann Yoast die Indexable-Persistenz absichtlich
 * überspringen und baut dieselbe Presentation im Speicher auf. Eine direkte
 * Tabellenabfrage würde dort einen falschen Fehler melden.
 *
 * @param int $post_id
 * @return array<string,string|bool>|null
 */
function wpagent_render_verify_read_yoast_effective_meta( int $post_id ): ?array {
	if ( ! function_exists( 'YoastSEO' ) ) {
		return null;
	}

	try {
		$meta = YoastSEO()->meta->for_post( $post_id );
		if ( ! is_object( $meta ) || ! isset( $meta->title ) || ! isset( $meta->meta_description ) ) {
			return null;
		}
		$robots = is_array( $meta->robots ?? null ) ? $meta->robots : array();

		return array(
			'title'                   => (string) $meta->title,
			'description'             => (string) $meta->meta_description,
			'canonical_url'           => (string) ( $meta->canonical ?? '' ),
			'robots_noindex'          => 'noindex' === (string) ( $robots['index'] ?? 'index' ),
			'robots_nofollow'         => 'nofollow' === (string) ( $robots['follow'] ?? 'follow' ),
			'open_graph_title'        => (string) ( $meta->open_graph_title ?? '' ),
			'open_graph_description'  => (string) ( $meta->open_graph_description ?? '' ),
			'twitter_title'           => (string) ( $meta->twitter_title ?? '' ),
			'twitter_description'     => (string) ( $meta->twitter_description ?? '' ),
		);
	} catch ( Throwable ) {
		return null;
	}
}

/**
 * Vergleicht Yoasts wirksame Presentation mit den gerade gespeicherten
 * Postmeta-Werten. Yoast-Platzhalter werden vor dem Vergleich über denselben
 * Ersetzungsweg aufgelöst, den das Plugin für die Ausgabe verwendet.
 *
 * @param int $post_id
 * @param array<string,string>|null $expected_fields Optional nur die Felder,
 *        die der aktuelle Schreibvorgang tatsächlich geändert hat.
 * @return array{check:string,subject:string,agrees:bool,severity:string,note:string}
 */
function wpagent_render_verify_check_seo_yoast( int $post_id, ?array $expected_fields = null ): array {
	$effective = wpagent_render_verify_read_yoast_effective_meta( $post_id );
	if ( null === $effective ) {
		return array(
			'check'    => 'seo_read_back',
			'subject'  => 'YoastSEO()->meta->for_post() (nicht verfügbar)',
			'agrees'   => false,
			'severity' => 'warn',
			'note'     => __( 'Aktives SEO-Plugin: Yoast, aber die wirksame Yoast-Presentation konnte nicht über die Meta Surface gelesen werden. Ein verlässlicher Read-Back ist deshalb nicht möglich.', 'wp-agent-companion' ),
		);
	}

	$raw_fields = $expected_fields ?? array(
		'title'                   => (string) get_post_meta( $post_id, '_yoast_wpseo_title', true ),
		'description'             => (string) get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ),
		'canonical_url'           => (string) get_post_meta( $post_id, '_yoast_wpseo_canonical', true ),
		'robots_noindex'          => '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true ),
		'robots_nofollow'         => '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow', true ),
		'open_graph_title'        => (string) get_post_meta( $post_id, '_yoast_wpseo_opengraph-title', true ),
		'open_graph_description'  => (string) get_post_meta( $post_id, '_yoast_wpseo_opengraph-description', true ),
		'twitter_title'           => (string) get_post_meta( $post_id, '_yoast_wpseo_twitter-title', true ),
		'twitter_description'     => (string) get_post_meta( $post_id, '_yoast_wpseo_twitter-description', true ),
	);
	$post       = get_post( $post_id );
	$agrees     = true;
	$checked    = array();

	foreach ( array( 'title', 'description', 'canonical_url', 'robots_noindex', 'robots_nofollow', 'open_graph_title', 'open_graph_description', 'twitter_title', 'twitter_description' ) as $field ) {
		if ( ! array_key_exists( $field, $raw_fields ) ) {
			continue;
		}
		if ( is_bool( $raw_fields[ $field ] ) ) {
			$matches   = (bool) ( $effective[ $field ] ?? false ) === $raw_fields[ $field ];
			$agrees    = $agrees && $matches;
			$checked[] = $field;
			continue;
		}
		$raw = (string) $raw_fields[ $field ];
		// Ohne benutzerdefinierten Wert darf Yoast seinen Standard rendern. Die
		// Leerschreibung selbst wurde bereits unabhängig aus postmeta gelesen.
		if ( '' === $raw ) {
			continue;
		}
		$replace_vars = in_array( $field, array( 'title', 'description', 'open_graph_title', 'open_graph_description', 'twitter_title', 'twitter_description' ), true );
		$expected = $replace_vars && function_exists( 'wpseo_replace_vars' ) && $post
			? (string) wpseo_replace_vars( $raw, $post )
			: $raw;
		$normalize = static fn ( string $value ): string => wp_specialchars_decode( $value, ENT_QUOTES );
		// Yoasts Canonical_Presenter unterdrückt das Canonical absichtlich bei
		// noindex. Der gespeicherte Wert wurde bereits separat aus postmeta
		// verifiziert; die leere effektive Ausgabe ist in diesem Fall korrekt.
		$canonical_suppressed = 'canonical_url' === $field
			&& ! empty( $effective['robots_noindex'] )
			&& '' === (string) ( $effective[ $field ] ?? '' );
		$matches   = $canonical_suppressed || $normalize( (string) ( $effective[ $field ] ?? '' ) ) === $normalize( $expected );
		$agrees    = $agrees && $matches;
		$checked[] = $field;
	}

	return array(
		'check'    => 'seo_read_back',
		'subject'  => 'YoastSEO()->meta->for_post().title / meta_description',
		'agrees'   => $agrees,
		'severity' => $agrees ? 'info' : 'warn',
		'note'     => sprintf(
			/* translators: %s: comma-separated SEO fields checked. */
			__( 'Aktives SEO-Plugin: Yoast. Die wirksamen Werte aus YoastSEO()->meta->for_post() wurden für die Felder %s mit den gespeicherten und bei Textfeldern über Yoast aufgelösten SEO-Werten verglichen. Die Meta Surface berücksichtigt in Produktion das persistierte Indexable und in Nicht-Produktionsumgebungen Yoasts bewusst nur im Speicher aufgebaute Presentation.', 'wp-agent-companion' ),
			empty( $checked ) ? __( 'keine benutzerdefinierten Ausgabefelder', 'wp-agent-companion' ) : implode( ', ', $checked )
		),
	);
}
