<?php
/**
 * Convert Rank Math / Yoast FAQ & HowTo Gutenberg blocks to SEOvault native blocks.
 *
 * Opt-in only: detect / preview / convert / rollback. Does not hook runtime
 * filters. Conversion requires explicit confirmation or authorized connector migration.
 *
 * @package SEOvaultSEO\Blocks
 */

namespace SEOvaultSEO\Blocks;

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

/**
 * Third-party FAQ/HowTo block converter (Rank Math + Yoast → SEOvault).
 */
class ThirdPartyBlockConverter {

	/**
	 * Source block names handled by this converter.
	 */
	const SUPPORTED_BLOCKS = array(
		'rank-math/faq-block',
		'rank-math/howto-block',
		'yoast/faq-block',
		'yoast/how-to-block',
	);

	/**
	 * Post meta: last conversion backup payload.
	 */
	const META_BACKUP = '_seovault_thirdparty_block_conversion_backup';

	/**
	 * Post meta: conversion stamp (idempotency / audit).
	 */
	const META_CONVERTED = '_seovault_thirdparty_block_converted';

	/**
	 * Post meta: marked unconvertible (exclude from batch loops).
	 */
	const META_UNCONVERTIBLE = '_seovault_thirdparty_block_unconvertible';

	/**
	 * Backup schema version.
	 */
	const BACKUP_VERSION = 1;

	/**
	 * Whether content contains a supported third-party block comment.
	 *
	 * @param string $content Post content.
	 * @return bool
	 */
	public static function content_has_third_party( $content ) {
		if ( ! is_string( $content ) || '' === $content ) {
			return false;
		}
		foreach ( self::SUPPORTED_BLOCKS as $name ) {
			if ( false !== strpos( $content, '<!-- wp:' . $name ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Detect third-party FAQ/HowTo blocks (read-only).
	 *
	 * @param string $content Post content.
	 * @return array{
	 *   has_third_party:bool,
	 *   matches:array<int,array<string,mixed>>,
	 *   convertible_count:int,
	 *   blocked_count:int,
	 *   by_source:array<string,int>
	 * }
	 */
	public static function detect( $content ) {
		$content = is_string( $content ) ? $content : '';
		$matches = array();

		if ( self::content_has_third_party( $content ) && function_exists( 'parse_blocks' ) ) {
			self::scan_blocks( parse_blocks( $content ), $matches );
		}

		$convertible = 0;
		$blocked     = 0;
		$by_source   = array();
		foreach ( $matches as $match ) {
			$source = isset( $match['source'] ) ? (string) $match['source'] : '';
			if ( '' !== $source ) {
				if ( ! isset( $by_source[ $source ] ) ) {
					$by_source[ $source ] = 0;
				}
				++$by_source[ $source ];
			}
			if ( ! empty( $match['convertible'] ) ) {
				++$convertible;
			} else {
				++$blocked;
			}
		}

		return array(
			'has_third_party'   => ! empty( $matches ),
			'matches'           => $matches,
			'convertible_count' => $convertible,
			'blocked_count'     => $blocked,
			'by_source'         => $by_source,
		);
	}

	/**
	 * Preview conversion without writing.
	 *
	 * @param int $post_id Post ID.
	 * @return array<string,mixed>
	 */
	public static function preview( $post_id ) {
		$post = get_post( (int) $post_id );
		if ( ! $post ) {
			return array(
				'ok'      => false,
				'code'    => 'invalid_post',
				'message' => 'Post not found.',
			);
		}

		$content   = (string) $post->post_content;
		$detection = self::detect( $content );
		$proposed  = array();

		foreach ( $detection['matches'] as $match ) {
			$entry = array(
				'source'              => isset( $match['source'] ) ? (string) $match['source'] : '',
				'plugin'              => isset( $match['plugin'] ) ? (string) $match['plugin'] : '',
				'type'                => isset( $match['type'] ) ? (string) $match['type'] : '',
				'status'              => isset( $match['status'] ) ? (string) $match['status'] : '',
				'convertible'         => ! empty( $match['convertible'] ),
				'errors'              => isset( $match['errors'] ) ? $match['errors'] : array(),
				'item_count'          => isset( $match['item_count'] ) ? (int) $match['item_count'] : 0,
				'complete_item_count' => isset( $match['complete_item_count'] ) ? (int) $match['complete_item_count'] : 0,
				'attributes'          => isset( $match['attributes'] ) ? $match['attributes'] : null,
			);
			$proposed[] = $entry;
		}

		$status = 'ready';
		if ( ! $detection['has_third_party'] ) {
			$status = 'none';
		} elseif ( $detection['convertible_count'] <= 0 ) {
			$status = 'blocked';
		}

		return array(
			'ok'                 => true,
			'status'             => $status,
			'post_id'            => (int) $post_id,
			'content_hash'       => hash( 'sha256', $content ),
			'has_third_party'    => $detection['has_third_party'],
			'convertible_count'  => $detection['convertible_count'],
			'blocked_count'      => $detection['blocked_count'],
			'by_source'          => $detection['by_source'],
			'matches'            => $proposed,
			'requires_confirm'   => $detection['convertible_count'] > 0,
			'backup_available'   => (bool) get_post_meta( (int) $post_id, self::META_BACKUP, true ),
		);
	}

	/**
	 * Apply conversion after confirmation (or authorized connector migration).
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $args    Options.
	 * @return array<string,mixed>
	 */
	public static function convert( $post_id, array $args = array() ) {
		$post_id = (int) $post_id;
		$post    = get_post( $post_id );
		if ( ! $post ) {
			return self::fail( 'invalid_post', 'Post not found.' );
		}

		$confirmed  = ! empty( $args['confirm'] );
		$authorized = ! empty( $args['authorized_connector_migration'] );
		if ( ! $confirmed && ! $authorized ) {
			return self::fail(
				'confirmation_required',
				'Third-party block conversion requires explicit confirmation or an authorized connector migration.'
			);
		}

		$original = (string) $post->post_content;

		if ( isset( $args['content_hash'] ) && '' !== (string) $args['content_hash'] ) {
			$expected = strtolower( (string) $args['content_hash'] );
			$actual   = hash( 'sha256', $original );
			if ( ! hash_equals( $actual, $expected ) ) {
				return self::fail( 'stale_content', 'Post content changed since preview. Reload and try again.' );
			}
		}

		$detection = self::detect( $original );
		if ( ! $detection['has_third_party'] ) {
			return array(
				'ok'              => true,
				'skipped'         => true,
				'code'            => 'none',
				'converted_count' => 0,
				'content_hash'    => hash( 'sha256', $original ),
				'post_id'         => $post_id,
			);
		}

		if ( $detection['convertible_count'] <= 0 ) {
			return self::fail(
				'not_convertible',
				'Third-party blocks were found but none could be safely converted.',
				array(
					'matches' => $detection['matches'],
				)
			);
		}

		$transformed = self::transform_content( $original );
		if ( empty( $transformed['ok'] ) ) {
			return self::fail(
				isset( $transformed['code'] ) ? (string) $transformed['code'] : 'transform_failed',
				isset( $transformed['message'] ) ? (string) $transformed['message'] : 'Conversion failed.',
				$transformed
			);
		}

		$new_content = (string) $transformed['content'];
		if ( $new_content === $original ) {
			return array(
				'ok'              => true,
				'skipped'         => true,
				'code'            => 'unchanged',
				'converted_count' => 0,
				'content_hash'    => hash( 'sha256', $original ),
				'post_id'         => $post_id,
			);
		}

		$after = self::detect( $new_content );
		if ( $after['convertible_count'] > 0 ) {
			return self::fail( 'transform_incomplete', 'Conversion left convertible third-party blocks behind.' );
		}

		$revision_id = 0;
		if ( function_exists( 'wp_revisions_enabled' ) && wp_revisions_enabled( $post ) && function_exists( 'wp_save_post_revision' ) ) {
			$saved = wp_save_post_revision( $post_id );
			if ( is_numeric( $saved ) ) {
				$revision_id = (int) $saved;
			}
		}

		$backup = array(
			'version'      => self::BACKUP_VERSION,
			'created_at'   => gmdate( 'c' ),
			'post_content' => $original,
			'content_hash' => hash( 'sha256', $original ),
			'revision_id'  => $revision_id,
			'converted'    => (int) $transformed['converted_count'],
			'by_source'    => isset( $transformed['by_source'] ) ? $transformed['by_source'] : array(),
		);
		update_post_meta( $post_id, self::META_BACKUP, $backup );

		$updated = wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $new_content,
			),
			true
		);
		if ( is_wp_error( $updated ) ) {
			return self::fail( 'update_failed', $updated->get_error_message() );
		}

		update_post_meta(
			$post_id,
			self::META_CONVERTED,
			array(
				'at'              => gmdate( 'c' ),
				'converted_count' => (int) $transformed['converted_count'],
				'by_source'       => isset( $transformed['by_source'] ) ? $transformed['by_source'] : array(),
				'revision_id'     => $revision_id,
				'via'             => $authorized ? 'connector' : 'confirm',
			)
		);

		$fresh         = get_post( $post_id );
		$fresh_content = $fresh ? (string) $fresh->post_content : $new_content;

		return array(
			'ok'               => true,
			'skipped'          => false,
			'code'             => 'converted',
			'post_id'          => $post_id,
			'converted_count'  => (int) $transformed['converted_count'],
			'blocked_count'    => (int) $transformed['blocked_count'],
			'by_source'        => isset( $transformed['by_source'] ) ? $transformed['by_source'] : array(),
			'revision_id'      => $revision_id,
			'backup_saved'     => true,
			'content_hash'     => hash( 'sha256', $fresh_content ),
			'has_third_party'  => self::content_has_third_party( $fresh_content ),
		);
	}

	/**
	 * Restore the last conversion backup.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $args    Options (confirm required).
	 * @return array<string,mixed>
	 */
	public static function rollback( $post_id, array $args = array() ) {
		$post_id = (int) $post_id;
		$post    = get_post( $post_id );
		if ( ! $post ) {
			return self::fail( 'invalid_post', 'Post not found.' );
		}

		if ( empty( $args['confirm'] ) && empty( $args['authorized_connector_migration'] ) ) {
			return self::fail( 'confirmation_required', 'Rollback requires explicit confirmation.' );
		}

		$backup = get_post_meta( $post_id, self::META_BACKUP, true );
		if ( ! is_array( $backup ) || empty( $backup['post_content'] ) || ! is_string( $backup['post_content'] ) ) {
			return self::fail( 'backup_missing', 'No third-party block conversion backup is available for this post.' );
		}

		$updated = wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $backup['post_content'],
			),
			true
		);
		if ( is_wp_error( $updated ) ) {
			return self::fail( 'update_failed', $updated->get_error_message() );
		}

		delete_post_meta( $post_id, self::META_CONVERTED );

		$fresh         = get_post( $post_id );
		$fresh_content = $fresh ? (string) $fresh->post_content : $backup['post_content'];

		return array(
			'ok'              => true,
			'code'            => 'restored',
			'post_id'         => $post_id,
			'content_hash'    => hash( 'sha256', $fresh_content ),
			'has_third_party' => self::content_has_third_party( $fresh_content ),
		);
	}

	/**
	 * Map Rank Math FAQ attributes → SEOvault FAQ attributes (then sanitize).
	 *
	 * @param mixed $raw Rank Math attrs.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	public static function map_rankmath_faq( $raw ) {
		if ( ! is_array( $raw ) ) {
			return self::mapping_failure( 'faq', array( 'attributes_not_array' ) );
		}

		$items = array();
		$questions = isset( $raw['questions'] ) && is_array( $raw['questions'] ) ? $raw['questions'] : array();
		foreach ( $questions as $question ) {
			if ( ! is_array( $question ) ) {
				continue;
			}
			if ( array_key_exists( 'visible', $question ) && empty( $question['visible'] ) ) {
				continue;
			}
			$title   = isset( $question['title'] ) ? (string) $question['title'] : '';
			$content = isset( $question['content'] ) ? (string) $question['content'] : '';
			if ( '' === trim( wp_strip_all_tags( $title ) ) && '' === trim( wp_strip_all_tags( $content ) ) ) {
				continue;
			}
			$items[] = array(
				'question' => $title,
				'answer'   => $content,
			);
		}

		$list_style   = isset( $raw['listStyle'] ) ? (string) $raw['listStyle'] : '';
		$display_mode = ( 'numbered' === $list_style || 'unordered' === $list_style ) ? 'static' : 'accordion';

		$mapped = array(
			'schemaVersion'       => FaqBlockAttributeSanitizer::SCHEMA_VERSION,
			'heading'             => '',
			'intro'               => '',
			'items'               => $items,
			'displayMode'         => $display_mode,
			'stylePreset'         => 'accordion-cards',
			'openFirstItem'       => false,
			'showQuestionNumbers' => false,
			'colorPreset'         => 'blue',
			'density'             => 'comfortable',
			'sharpCorners'        => false,
		);

		return self::finalize_faq_mapping( $mapped );
	}

	/**
	 * Map Yoast FAQ attributes → SEOvault FAQ attributes (then sanitize).
	 *
	 * Prefers jsonQuestion/jsonAnswer (schema fields) over display fields.
	 *
	 * @param mixed $raw Yoast attrs.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	public static function map_yoast_faq( $raw ) {
		if ( ! is_array( $raw ) ) {
			return self::mapping_failure( 'faq', array( 'attributes_not_array' ) );
		}

		$items     = array();
		$questions = isset( $raw['questions'] ) && is_array( $raw['questions'] ) ? $raw['questions'] : array();
		foreach ( $questions as $question ) {
			if ( ! is_array( $question ) ) {
				continue;
			}
			$q = '';
			if ( isset( $question['jsonQuestion'] ) && '' !== trim( (string) $question['jsonQuestion'] ) ) {
				$q = (string) $question['jsonQuestion'];
			} elseif ( isset( $question['question'] ) ) {
				$q = (string) $question['question'];
			}

			$a = '';
			if ( isset( $question['jsonAnswer'] ) && '' !== trim( wp_strip_all_tags( (string) $question['jsonAnswer'] ) ) ) {
				$a = (string) $question['jsonAnswer'];
			} elseif ( isset( $question['answer'] ) ) {
				$a = (string) $question['answer'];
			}

			if ( '' === trim( wp_strip_all_tags( $q ) ) && '' === trim( wp_strip_all_tags( $a ) ) ) {
				continue;
			}

			$items[] = array(
				'question' => $q,
				'answer'   => $a,
			);
		}

		$mapped = array(
			'schemaVersion'       => FaqBlockAttributeSanitizer::SCHEMA_VERSION,
			'heading'             => '',
			'intro'               => '',
			'items'               => $items,
			'displayMode'         => 'accordion',
			'stylePreset'         => 'accordion-cards',
			'openFirstItem'       => false,
			'showQuestionNumbers' => false,
			'colorPreset'         => 'blue',
			'density'             => 'comfortable',
			'sharpCorners'        => false,
		);

		return self::finalize_faq_mapping( $mapped );
	}

	/**
	 * Map Rank Math HowTo attributes → SEOvault HowTo attributes (then sanitize).
	 *
	 * @param mixed $raw Rank Math attrs.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	public static function map_rankmath_howto( $raw ) {
		if ( ! is_array( $raw ) ) {
			return self::mapping_failure( 'howto', array( 'attributes_not_array' ) );
		}

		$steps_out = array();
		$steps     = isset( $raw['steps'] ) && is_array( $raw['steps'] ) ? $raw['steps'] : array();
		foreach ( $steps as $step ) {
			if ( ! is_array( $step ) ) {
				continue;
			}
			if ( array_key_exists( 'visible', $step ) && empty( $step['visible'] ) ) {
				continue;
			}
			$title   = isset( $step['title'] ) ? (string) $step['title'] : '';
			$content = isset( $step['content'] ) ? (string) $step['content'] : '';
			if ( '' === trim( wp_strip_all_tags( $title ) ) && '' === trim( wp_strip_all_tags( $content ) ) ) {
				continue;
			}
			$image_id = 0;
			if ( ! empty( $step['imageID'] ) ) {
				$image_id = (int) $step['imageID'];
			}
			$steps_out[] = array(
				'name'    => $title,
				'text'    => $content,
				'imageId' => $image_id,
			);
		}

		$description = isset( $raw['description'] ) ? (string) $raw['description'] : '';

		$mapped = array(
			'schemaVersion' => HowToBlockAttributeSanitizer::SCHEMA_VERSION,
			// Rank Math has no HowTo title field; reuse description for schema readiness.
			'name'          => wp_strip_all_tags( $description ),
			'description'   => $description,
			'steps'         => $steps_out,
			'supplies'      => array(),
			'tools'         => array(),
			'totalTime'     => self::compose_iso_duration(
				! empty( $raw['hasDuration'] ),
				isset( $raw['days'] ) ? $raw['days'] : '',
				isset( $raw['hours'] ) ? $raw['hours'] : '',
				isset( $raw['minutes'] ) ? $raw['minutes'] : ''
			),
			'estimatedCost' => array(
				'currency' => '',
				'value'    => '',
			),
			'displayMode'   => 'numbered',
			'stylePreset'   => 'clean',
			'colorPreset'   => 'blue',
			'density'       => 'comfortable',
		);

		return self::finalize_howto_mapping( $mapped );
	}

	/**
	 * Map Yoast HowTo attributes → SEOvault HowTo attributes (then sanitize).
	 *
	 * Prefers json* schema fields. Handles legacy step.text arrays.
	 *
	 * @param mixed $raw Yoast attrs.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	public static function map_yoast_howto( $raw ) {
		if ( ! is_array( $raw ) ) {
			return self::mapping_failure( 'howto', array( 'attributes_not_array' ) );
		}

		$steps_out = array();
		$steps     = isset( $raw['steps'] ) && is_array( $raw['steps'] ) ? $raw['steps'] : array();
		foreach ( $steps as $step ) {
			if ( ! is_array( $step ) ) {
				continue;
			}

			$name = '';
			if ( isset( $step['jsonName'] ) && '' !== trim( (string) $step['jsonName'] ) ) {
				$name = (string) $step['jsonName'];
			} elseif ( isset( $step['name'] ) ) {
				$name = (string) $step['name'];
			}

			$text = '';
			if ( isset( $step['jsonText'] ) && '' !== trim( wp_strip_all_tags( self::stringify_yoast_text( $step['jsonText'] ) ) ) ) {
				$text = self::stringify_yoast_text( $step['jsonText'] );
			} elseif ( isset( $step['text'] ) ) {
				$text = self::stringify_yoast_text( $step['text'] );
			}

			if ( '' === trim( wp_strip_all_tags( $name ) ) && '' === trim( wp_strip_all_tags( $text ) ) ) {
				continue;
			}

			$image_id = 0;
			if ( isset( $step['images'] ) && is_array( $step['images'] ) && ! empty( $step['images'][0] ) && is_array( $step['images'][0] ) ) {
				if ( isset( $step['images'][0]['key'] ) ) {
					$image_id = (int) $step['images'][0]['key'];
				}
			}

			$steps_out[] = array(
				'name'    => $name,
				'text'    => $text,
				'imageId' => $image_id,
			);
		}

		$description = '';
		if ( isset( $raw['jsonDescription'] ) && '' !== trim( (string) $raw['jsonDescription'] ) ) {
			$description = (string) $raw['jsonDescription'];
		} elseif ( isset( $raw['description'] ) ) {
			$description = wp_strip_all_tags( (string) $raw['description'] );
		}

		$mapped = array(
			'schemaVersion' => HowToBlockAttributeSanitizer::SCHEMA_VERSION,
			// Yoast has no HowTo title field; reuse description for schema readiness.
			'name'          => wp_strip_all_tags( $description ),
			'description'   => $description,
			'steps'         => $steps_out,
			'supplies'      => array(),
			'tools'         => array(),
			'totalTime'     => self::compose_iso_duration(
				! empty( $raw['hasDuration'] ),
				isset( $raw['days'] ) ? $raw['days'] : '',
				isset( $raw['hours'] ) ? $raw['hours'] : '',
				isset( $raw['minutes'] ) ? $raw['minutes'] : ''
			),
			'estimatedCost' => array(
				'currency' => '',
				'value'    => '',
			),
			'displayMode'   => 'numbered',
			'stylePreset'   => 'clean',
			'colorPreset'   => 'blue',
			'density'       => 'comfortable',
		);

		return self::finalize_howto_mapping( $mapped );
	}

	/**
	 * Build a serialized seovault/faq block from sanitized attributes.
	 *
	 * @param array<string,mixed> $attributes Sanitized attributes.
	 * @return string
	 */
	public static function serialize_faq_block( array $attributes ) {
		$attributes = self::attrs_for_faq_save( $attributes );
		$html       = FaqBlock::build_static_markup( $attributes );
		if ( function_exists( 'serialize_block' ) ) {
			return serialize_block(
				array(
					'blockName'    => FaqBlock::BLOCK_NAME,
					'attrs'        => $attributes,
					'innerBlocks'  => array(),
					'innerHTML'    => $html,
					'innerContent' => array( $html ),
				)
			);
		}

		$json = wp_json_encode( $attributes );
		if ( ! is_string( $json ) ) {
			$json = '{}';
		}
		return '<!-- wp:seovault/faq ' . $json . ' -->' . $html . '<!-- /wp:seovault/faq -->';
	}

	/**
	 * Build a serialized seovault/howto block from sanitized attributes.
	 *
	 * @param array<string,mixed> $attributes Sanitized attributes.
	 * @return string
	 */
	public static function serialize_howto_block( array $attributes ) {
		$attributes = self::attrs_for_howto_save( $attributes );
		$html       = HowToBlock::build_static_markup( $attributes );
		if ( function_exists( 'serialize_block' ) ) {
			return serialize_block(
				array(
					'blockName'    => HowToBlock::BLOCK_NAME,
					'attrs'        => $attributes,
					'innerBlocks'  => array(),
					'innerHTML'    => $html,
					'innerContent' => array( $html ),
				)
			);
		}

		$json = wp_json_encode( $attributes );
		if ( ! is_string( $json ) ) {
			$json = '{}';
		}
		return '<!-- wp:seovault/howto ' . $json . ' -->' . $html . '<!-- /wp:seovault/howto -->';
	}

	/**
	 * Improvement descriptors when third-party FAQ/HowTo blocks are still present.
	 *
	 * @param string $content Post content.
	 * @return array<int,array<string,mixed>>
	 */
	public static function improvement_descriptors( $content ) {
		if ( ! self::content_has_third_party( $content ) ) {
			return array();
		}
		$detection = self::detect( $content );
		if ( $detection['convertible_count'] <= 0 && $detection['blocked_count'] <= 0 ) {
			return array();
		}
		return array(
			array(
				'code'        => 'thirdparty.block.conversion_available',
				'severity'    => 'recommendation',
				'message'     => 'Yoast or Rank Math FAQ/How-to blocks were found. Convert them to SEOvault blocks to restore editing and schema.',
				'action'      => 'convert_thirdparty_blocks',
				'convertible' => (int) $detection['convertible_count'],
				'blocked'     => (int) $detection['blocked_count'],
				'by_source'   => $detection['by_source'],
			),
		);
	}

	/**
	 * Find post IDs whose content contains supported third-party blocks.
	 *
	 * Uses SQL LIKE on post_content (more reliable than get_posts search).
	 *
	 * @param int $limit  Max IDs.
	 * @param int $offset Offset.
	 * @return array{ids:int[],total:int}
	 */
	public static function find_posts( $limit = 50, $offset = 0 ) {
		global $wpdb;

		$limit  = max( 1, min( 200, (int) $limit ) );
		$offset = max( 0, (int) $offset );

		$like_parts = array();
		foreach ( self::SUPPORTED_BLOCKS as $name ) {
			$like_parts[] = $wpdb->prepare(
				'post_content LIKE %s',
				'%' . $wpdb->esc_like( '<!-- wp:' . $name ) . '%'
			);
		}
		$like_sql = implode( ' OR ', $like_parts );

		$meta_key = self::META_UNCONVERTIBLE;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- LIKE fragments prepared above; core posts/postmeta have no WP_Query equivalent for this scan.
		$total = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(p.ID) FROM {$wpdb->posts} p
				WHERE p.post_status NOT IN ('trash','auto-draft','inherit')
				AND ({$like_sql})
				AND p.ID NOT IN (
					SELECT pm.post_id FROM {$wpdb->postmeta} pm
					WHERE pm.meta_key = %s AND pm.meta_value = '1'
				)",
				$meta_key
			)
		);

		$ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT p.ID FROM {$wpdb->posts} p
				WHERE p.post_status NOT IN ('trash','auto-draft','inherit')
				AND ({$like_sql})
				AND p.ID NOT IN (
					SELECT pm.post_id FROM {$wpdb->postmeta} pm
					WHERE pm.meta_key = %s AND pm.meta_value = '1'
				)
				ORDER BY p.ID ASC
				LIMIT %d OFFSET %d",
				$meta_key,
				$limit,
				$offset
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$ids = array_map( 'intval', is_array( $ids ) ? $ids : array() );

		return array(
			'ids'   => $ids,
			'total' => $total,
		);
	}

	/**
	 * Site-wide scan summary (counts only; does not convert).
	 *
	 * @param int $sample_limit Max posts to deep-detect for by_source totals.
	 * @return array<string,mixed>
	 */
	public static function scan_site( $sample_limit = 200 ) {
		$found       = self::find_posts( (int) $sample_limit, 0 );
		$by_source   = array();
		$by_plugin   = array(
			'rankmath' => array(
				'posts'  => 0,
				'blocks' => 0,
			),
			'yoast'    => array(
				'posts'  => 0,
				'blocks' => 0,
			),
		);
		$convertible = 0;
		$blocked     = 0;
		$sampled     = 0;

		foreach ( $found['ids'] as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post ) {
				continue;
			}
			++$sampled;
			$detection = self::detect( (string) $post->post_content );
			$convertible += (int) $detection['convertible_count'];
			$blocked     += (int) $detection['blocked_count'];

			$has_rankmath = false;
			$has_yoast    = false;
			foreach ( $detection['by_source'] as $source => $count ) {
				if ( ! isset( $by_source[ $source ] ) ) {
					$by_source[ $source ] = 0;
				}
				$by_source[ $source ] += (int) $count;

				$plugin = self::plugin_for_source( (string) $source );
				if ( isset( $by_plugin[ $plugin ] ) ) {
					$by_plugin[ $plugin ]['blocks'] += (int) $count;
					if ( 'rankmath' === $plugin ) {
						$has_rankmath = true;
					} elseif ( 'yoast' === $plugin ) {
						$has_yoast = true;
					}
				}
			}
			if ( $has_rankmath ) {
				++$by_plugin['rankmath']['posts'];
			}
			if ( $has_yoast ) {
				++$by_plugin['yoast']['posts'];
			}
		}

		return array(
			'ok'                => true,
			'post_total'        => (int) $found['total'],
			'sampled_posts'     => $sampled,
			'convertible_count' => $convertible,
			'blocked_count'     => $blocked,
			'by_source'         => $by_source,
			'by_plugin'         => $by_plugin,
			'has_more'          => (int) $found['total'] > $sampled,
		);
	}

	/**
	 * Convert a batch of posts that still contain third-party blocks.
	 *
	 * Always takes the next remaining matches from offset 0 so converted posts
	 * (which no longer match LIKE) do not cause skipped IDs.
	 *
	 * @param int                 $limit  Batch size.
	 * @param int                 $offset Ignored (kept for API compatibility).
	 * @param array<string,mixed> $args   Passed to convert() (must include confirm or authorized flag).
	 * @return array<string,mixed>
	 */
	public static function convert_batch( $limit = 10, $offset = 0, array $args = array() ) {
		unset( $offset );

		$confirmed  = ! empty( $args['confirm'] );
		$authorized = ! empty( $args['authorized_connector_migration'] );
		if ( ! $confirmed && ! $authorized ) {
			return self::fail(
				'confirmation_required',
				'Batch conversion requires explicit confirmation or an authorized connector migration.'
			);
		}

		$before  = self::find_posts( 1, 0 );
		$found   = self::find_posts( $limit, 0 );
		$results = array();
		$converted_posts  = 0;
		$converted_blocks = 0;
		$failed  = 0;
		$skipped = 0;

		foreach ( $found['ids'] as $post_id ) {
			$result = self::convert(
				$post_id,
				array(
					'confirm'                        => $confirmed,
					'authorized_connector_migration' => $authorized,
				)
			);
			$entry = array(
				'post_id' => (int) $post_id,
				'ok'      => ! empty( $result['ok'] ),
				'code'    => isset( $result['code'] ) ? (string) $result['code'] : '',
			);
			if ( ! empty( $result['ok'] ) && empty( $result['skipped'] ) ) {
				++$converted_posts;
				$converted_blocks += isset( $result['converted_count'] ) ? (int) $result['converted_count'] : 0;
				delete_post_meta( $post_id, self::META_UNCONVERTIBLE );
			} elseif ( ! empty( $result['ok'] ) && ! empty( $result['skipped'] ) ) {
				++$skipped;
			} else {
				++$failed;
				$entry['message'] = isset( $result['message'] ) ? (string) $result['message'] : '';
				$code = isset( $result['code'] ) ? (string) $result['code'] : '';
				if ( in_array( $code, array( 'not_convertible', 'transform_incomplete', 'transform_failed' ), true ) ) {
					update_post_meta( $post_id, self::META_UNCONVERTIBLE, '1' );
					$entry['marked_unconvertible'] = true;
				}
			}
			$results[] = $entry;
		}

		$after = self::find_posts( 1, 0 );

		return array(
			'ok'                => true,
			'converted_posts'   => $converted_posts,
			'converted_blocks'  => $converted_blocks,
			'skipped'           => $skipped,
			'failed'            => $failed,
			'processed'         => count( $found['ids'] ),
			'total_before'      => (int) $before['total'],
			'remaining'         => (int) $after['total'],
			'has_more'          => (int) $after['total'] > 0,
			'results'           => $results,
		);
	}

	/**
	 * Compose Rank Math / Yoast duration fields into ISO-8601 for SEOvault totalTime.
	 *
	 * SEOvault HowTo sanitizer only accepts Schema.org duration forms (e.g. PT30M, P1DT2H).
	 *
	 * @param bool  $has_duration Whether duration is enabled.
	 * @param mixed $days         Days.
	 * @param mixed $hours        Hours.
	 * @param mixed $minutes      Minutes.
	 * @return string
	 */
	public static function compose_iso_duration( $has_duration, $days = '', $hours = '', $minutes = '' ) {
		if ( ! $has_duration ) {
			return '';
		}

		$d = max( 0, (int) $days );
		$h = max( 0, (int) $hours );
		$m = max( 0, (int) $minutes );

		if ( $d <= 0 && $h <= 0 && $m <= 0 ) {
			return '';
		}

		$out = 'P';
		if ( $d > 0 ) {
			$out .= $d . 'D';
		}
		if ( $h > 0 || $m > 0 ) {
			$out .= 'T';
			if ( $h > 0 ) {
				$out .= $h . 'H';
			}
			if ( $m > 0 ) {
				$out .= $m . 'M';
			}
		}

		return 'P' === $out ? '' : $out;
	}

	// ---------------------------------------------------------------------
	// Internals
	// ---------------------------------------------------------------------

	/**
	 * Transform post content: replace supported third-party blocks.
	 *
	 * @param string $content Original content.
	 * @return array<string,mixed>
	 */
	private static function transform_content( $content ) {
		if ( ! function_exists( 'parse_blocks' ) || ! function_exists( 'serialize_blocks' ) ) {
			return array(
				'ok'      => false,
				'code'    => 'blocks_api_unavailable',
				'message' => 'WordPress block parsing API is unavailable.',
			);
		}

		$stats = array(
			'converted_count' => 0,
			'blocked_count'   => 0,
			'by_source'       => array(),
		);

		$blocks     = parse_blocks( $content );
		$converted  = self::transform_blocks( $blocks, $stats );
		$serialized = serialize_blocks( $converted );

		return array(
			'ok'              => true,
			'content'         => $serialized,
			'converted_count' => (int) $stats['converted_count'],
			'blocked_count'   => (int) $stats['blocked_count'],
			'by_source'       => $stats['by_source'],
		);
	}

	/**
	 * Recursively transform blocks.
	 *
	 * @param array                $blocks Blocks.
	 * @param array<string,mixed>  $stats  Stats bag (by ref).
	 * @return array
	 */
	private static function transform_blocks( array $blocks, array &$stats ) {
		$out = array();

		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}

			$name = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';

			if ( in_array( $name, self::SUPPORTED_BLOCKS, true ) ) {
				$replacement = self::build_replacement_block( $name, isset( $block['attrs'] ) ? $block['attrs'] : array() );
				if ( ! empty( $replacement['convertible'] ) && isset( $replacement['block'] ) && is_array( $replacement['block'] ) ) {
					$out[] = $replacement['block'];
					++$stats['converted_count'];
					if ( ! isset( $stats['by_source'][ $name ] ) ) {
						$stats['by_source'][ $name ] = 0;
					}
					++$stats['by_source'][ $name ];
				} else {
					// Keep original so content is not lost when mapping fails.
					$out[] = $block;
					++$stats['blocked_count'];
				}
				continue;
			}

			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				$block['innerBlocks'] = self::transform_blocks( $block['innerBlocks'], $stats );
			}

			$out[] = $block;
		}

		return $out;
	}

	/**
	 * Build a SEOvault replacement block for a source block.
	 *
	 * @param string $source_name Source block name.
	 * @param mixed  $attrs       Source attributes.
	 * @return array{convertible:bool,block:?array,mapped:array}
	 */
	private static function build_replacement_block( $source_name, $attrs ) {
		$mapped = self::map_source_attrs( $source_name, $attrs );
		if ( empty( $mapped['convertible'] ) ) {
			return array(
				'convertible' => false,
				'block'       => null,
				'mapped'      => $mapped,
			);
		}

		$type = self::block_type_for_source( $source_name );
		if ( 'faq' === $type ) {
			$save_attrs = self::attrs_for_faq_save( $mapped['attributes'] );
			$html       = FaqBlock::build_static_markup( $save_attrs );
			return array(
				'convertible' => true,
				'mapped'      => $mapped,
				'block'       => array(
					'blockName'    => FaqBlock::BLOCK_NAME,
					'attrs'        => $save_attrs,
					'innerBlocks'  => array(),
					'innerHTML'    => $html,
					'innerContent' => array( $html ),
				),
			);
		}

		$save_attrs = self::attrs_for_howto_save( $mapped['attributes'] );
		$html       = HowToBlock::build_static_markup( $save_attrs );
		return array(
			'convertible' => true,
			'mapped'      => $mapped,
			'block'       => array(
				'blockName'    => HowToBlock::BLOCK_NAME,
				'attrs'        => $save_attrs,
				'innerBlocks'  => array(),
				'innerHTML'    => $html,
				'innerContent' => array( $html ),
			),
		);
	}

	/**
	 * Strip server-only fields so saved attrs match the editor contract.
	 *
	 * @param array<string,mixed> $attributes Sanitized FAQ attrs.
	 * @return array<string,mixed>
	 */
	private static function attrs_for_faq_save( array $attributes ) {
		$items = array();
		if ( isset( $attributes['items'] ) && is_array( $attributes['items'] ) ) {
			foreach ( $attributes['items'] as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}
				$items[] = array(
					'id'       => isset( $item['id'] ) ? (string) $item['id'] : '',
					'question' => isset( $item['question'] ) ? (string) $item['question'] : '',
					'answer'   => isset( $item['answer'] ) ? (string) $item['answer'] : '',
				);
			}
		}

		return array(
			'schemaVersion'       => isset( $attributes['schemaVersion'] ) ? (int) $attributes['schemaVersion'] : 1,
			'heading'             => isset( $attributes['heading'] ) ? (string) $attributes['heading'] : '',
			'intro'               => isset( $attributes['intro'] ) ? (string) $attributes['intro'] : '',
			'items'               => $items,
			'displayMode'         => isset( $attributes['displayMode'] ) ? (string) $attributes['displayMode'] : 'accordion',
			'stylePreset'         => isset( $attributes['stylePreset'] ) ? (string) $attributes['stylePreset'] : 'accordion-cards',
			'openFirstItem'       => ! empty( $attributes['openFirstItem'] ),
			'showQuestionNumbers' => ! empty( $attributes['showQuestionNumbers'] ),
			'colorPreset'         => isset( $attributes['colorPreset'] ) ? (string) $attributes['colorPreset'] : 'blue',
			'density'             => isset( $attributes['density'] ) ? (string) $attributes['density'] : 'comfortable',
			'sharpCorners'        => ! empty( $attributes['sharpCorners'] ),
		);
	}

	/**
	 * Strip server-only fields so saved attrs match the editor contract.
	 *
	 * @param array<string,mixed> $attributes Sanitized HowTo attrs.
	 * @return array<string,mixed>
	 */
	private static function attrs_for_howto_save( array $attributes ) {
		$steps = array();
		if ( isset( $attributes['steps'] ) && is_array( $attributes['steps'] ) ) {
			foreach ( $attributes['steps'] as $step ) {
				if ( ! is_array( $step ) ) {
					continue;
				}
				$steps[] = array(
					'id'      => isset( $step['id'] ) ? (string) $step['id'] : '',
					'name'    => isset( $step['name'] ) ? (string) $step['name'] : '',
					'text'    => isset( $step['text'] ) ? (string) $step['text'] : '',
					'imageId' => isset( $step['imageId'] ) ? (int) $step['imageId'] : 0,
				);
			}
		}

		$cost = isset( $attributes['estimatedCost'] ) && is_array( $attributes['estimatedCost'] )
			? $attributes['estimatedCost']
			: array(
				'currency' => '',
				'value'    => '',
			);

		return array(
			'schemaVersion' => isset( $attributes['schemaVersion'] ) ? (int) $attributes['schemaVersion'] : 1,
			'name'          => isset( $attributes['name'] ) ? (string) $attributes['name'] : '',
			'description'   => isset( $attributes['description'] ) ? (string) $attributes['description'] : '',
			'steps'         => $steps,
			'supplies'      => isset( $attributes['supplies'] ) && is_array( $attributes['supplies'] ) ? $attributes['supplies'] : array(),
			'tools'         => isset( $attributes['tools'] ) && is_array( $attributes['tools'] ) ? $attributes['tools'] : array(),
			'totalTime'     => isset( $attributes['totalTime'] ) ? (string) $attributes['totalTime'] : '',
			'estimatedCost' => array(
				'currency' => isset( $cost['currency'] ) ? (string) $cost['currency'] : '',
				'value'    => isset( $cost['value'] ) ? (string) $cost['value'] : '',
			),
			'displayMode'   => isset( $attributes['displayMode'] ) ? (string) $attributes['displayMode'] : 'numbered',
			'stylePreset'   => isset( $attributes['stylePreset'] ) ? (string) $attributes['stylePreset'] : 'clean',
			'colorPreset'   => isset( $attributes['colorPreset'] ) ? (string) $attributes['colorPreset'] : 'blue',
			'density'       => isset( $attributes['density'] ) ? (string) $attributes['density'] : 'comfortable',
		);
	}

	/**
	 * Map attributes for a known source block.
	 *
	 * @param string $source_name Source block name.
	 * @param mixed  $attrs       Attributes.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	private static function map_source_attrs( $source_name, $attrs ) {
		switch ( $source_name ) {
			case 'rank-math/faq-block':
				return self::map_rankmath_faq( $attrs );
			case 'yoast/faq-block':
				return self::map_yoast_faq( $attrs );
			case 'rank-math/howto-block':
				return self::map_rankmath_howto( $attrs );
			case 'yoast/how-to-block':
				return self::map_yoast_howto( $attrs );
			default:
				return self::mapping_failure( 'faq', array( 'unsupported_source' ) );
		}
	}

	/**
	 * Walk parsed blocks and collect match descriptors.
	 *
	 * @param array $blocks  Blocks.
	 * @param array $matches Matches (by ref).
	 * @return void
	 */
	private static function scan_blocks( array $blocks, array &$matches ) {
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}

			$name = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';
			if ( in_array( $name, self::SUPPORTED_BLOCKS, true ) ) {
				$mapped = self::map_source_attrs( $name, isset( $block['attrs'] ) ? $block['attrs'] : array() );
				$type   = self::block_type_for_source( $name );
				$item_count = 0;
				$complete   = 0;

				if ( 'faq' === $type ) {
					$item_count = isset( $mapped['attributes']['items'] ) && is_array( $mapped['attributes']['items'] )
						? count( $mapped['attributes']['items'] )
						: 0;
					$complete = count( FaqBlockAttributeSanitizer::get_complete_items( $mapped['attributes'] ) );
				} else {
					$item_count = isset( $mapped['attributes']['steps'] ) && is_array( $mapped['attributes']['steps'] )
						? count( $mapped['attributes']['steps'] )
						: 0;
					$complete = $item_count;
				}

				$matches[] = array(
					'source'              => $name,
					'plugin'              => self::plugin_for_source( $name ),
					'type'                => $type,
					'status'              => isset( $mapped['status'] ) ? (string) $mapped['status'] : '',
					'convertible'         => ! empty( $mapped['convertible'] ),
					'errors'              => isset( $mapped['errors'] ) ? $mapped['errors'] : array(),
					'item_count'          => $item_count,
					'complete_item_count' => $complete,
					'attributes'          => 'faq' === $type
						? self::public_faq_attributes( $mapped['attributes'] )
						: self::public_howto_attributes( $mapped['attributes'] ),
				);
			}

			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				self::scan_blocks( $block['innerBlocks'], $matches );
			}
		}
	}

	/**
	 * @param string $source_name Source block.
	 * @return string faq|howto
	 */
	private static function block_type_for_source( $source_name ) {
		if ( false !== strpos( $source_name, 'howto' ) || false !== strpos( $source_name, 'how-to' ) ) {
			return 'howto';
		}
		return 'faq';
	}

	/**
	 * @param string $source_name Source block.
	 * @return string rankmath|yoast
	 */
	private static function plugin_for_source( $source_name ) {
		if ( 0 === strpos( $source_name, 'yoast' ) ) {
			return 'yoast';
		}
		return 'rankmath';
	}

	/**
	 * Finalize FAQ mapping through the sanitizer.
	 *
	 * @param array<string,mixed> $mapped Mapped attrs.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	private static function finalize_faq_mapping( array $mapped ) {
		$sanitized = FaqBlockAttributeSanitizer::sanitize( $mapped );
		$blocking  = array_intersect(
			$sanitized['errors'],
			array( 'payload_too_large', 'attributes_not_array' )
		);
		$items       = isset( $sanitized['attributes']['items'] ) && is_array( $sanitized['attributes']['items'] )
			? $sanitized['attributes']['items']
			: array();
		$convertible = empty( $blocking ) && count( $items ) > 0;

		$status = 'ok';
		if ( ! $convertible ) {
			if ( in_array( 'payload_too_large', $sanitized['errors'], true ) ) {
				$status = 'oversized';
			} elseif ( empty( $items ) ) {
				$status = 'empty';
			} else {
				$status = 'malformed';
			}
		}

		return array(
			'attributes'  => $sanitized['attributes'],
			'errors'      => $sanitized['errors'],
			'stripped'    => $sanitized['stripped'],
			'status'      => $status,
			'convertible' => $convertible,
		);
	}

	/**
	 * Finalize HowTo mapping through the sanitizer.
	 *
	 * @param array<string,mixed> $mapped Mapped attrs.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	private static function finalize_howto_mapping( array $mapped ) {
		$sanitized = HowToBlockAttributeSanitizer::sanitize( $mapped );
		$blocking  = array_intersect(
			$sanitized['errors'],
			array( 'payload_too_large', 'attributes_not_array' )
		);
		$steps       = isset( $sanitized['attributes']['steps'] ) && is_array( $sanitized['attributes']['steps'] )
			? $sanitized['attributes']['steps']
			: array();
		$convertible = empty( $blocking ) && count( $steps ) > 0;

		$status = 'ok';
		if ( ! $convertible ) {
			if ( in_array( 'payload_too_large', $sanitized['errors'], true ) ) {
				$status = 'oversized';
			} elseif ( empty( $steps ) ) {
				$status = 'empty';
			} else {
				$status = 'malformed';
			}
		}

		return array(
			'attributes'  => $sanitized['attributes'],
			'errors'      => $sanitized['errors'],
			'stripped'    => $sanitized['stripped'],
			'status'      => $status,
			'convertible' => $convertible,
		);
	}

	/**
	 * @param string   $type   faq|howto.
	 * @param string[] $errors Errors.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	private static function mapping_failure( $type, array $errors ) {
		$defaults = ( 'howto' === $type )
			? HowToBlockAttributeSanitizer::defaults()
			: FaqBlockAttributeSanitizer::defaults();

		return array(
			'attributes'  => $defaults,
			'errors'      => $errors,
			'stripped'    => array(),
			'status'      => 'malformed',
			'convertible' => false,
		);
	}

	/**
	 * Normalize Yoast text fields that may be strings or content-part arrays.
	 *
	 * @param mixed $value Raw.
	 * @return string
	 */
	private static function stringify_yoast_text( $value ) {
		if ( is_string( $value ) ) {
			return $value;
		}
		if ( ! is_array( $value ) ) {
			return '';
		}

		$parts = array();
		foreach ( $value as $part ) {
			if ( is_string( $part ) ) {
				$parts[] = $part;
				continue;
			}
			if ( ! is_array( $part ) ) {
				continue;
			}
			if ( isset( $part['value'] ) && is_string( $part['value'] ) ) {
				$parts[] = $part['value'];
			} elseif ( isset( $part['text'] ) && is_string( $part['text'] ) ) {
				$parts[] = $part['text'];
			} elseif ( isset( $part['props']['children'] ) ) {
				$parts[] = self::stringify_yoast_text( $part['props']['children'] );
			}
		}

		return implode( '', $parts );
	}

	/**
	 * Preview-safe FAQ attributes.
	 *
	 * @param array $attrs Attributes.
	 * @return array
	 */
	private static function public_faq_attributes( array $attrs ) {
		$items = array();
		if ( isset( $attrs['items'] ) && is_array( $attrs['items'] ) ) {
			foreach ( $attrs['items'] as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}
				$items[] = array(
					'id'       => isset( $item['id'] ) ? (string) $item['id'] : '',
					'question' => isset( $item['question'] ) ? (string) $item['question'] : '',
					'answer'   => isset( $item['answer_plain'] )
						? (string) $item['answer_plain']
						: FaqBlockAttributeSanitizer::normalize_plain_answer( isset( $item['answer'] ) ? $item['answer'] : '' ),
				);
			}
		}

		return array(
			'schemaVersion' => isset( $attrs['schemaVersion'] ) ? (int) $attrs['schemaVersion'] : 1,
			'heading'       => isset( $attrs['heading'] ) ? (string) $attrs['heading'] : '',
			'intro'         => isset( $attrs['intro'] ) ? (string) $attrs['intro'] : '',
			'displayMode'   => isset( $attrs['displayMode'] ) ? (string) $attrs['displayMode'] : 'accordion',
			'items'         => $items,
		);
	}

	/**
	 * Preview-safe HowTo attributes.
	 *
	 * @param array $attrs Attributes.
	 * @return array
	 */
	private static function public_howto_attributes( array $attrs ) {
		$steps = array();
		if ( isset( $attrs['steps'] ) && is_array( $attrs['steps'] ) ) {
			foreach ( $attrs['steps'] as $step ) {
				if ( ! is_array( $step ) ) {
					continue;
				}
				$steps[] = array(
					'id'   => isset( $step['id'] ) ? (string) $step['id'] : '',
					'name' => isset( $step['name'] ) ? (string) $step['name'] : '',
					'text' => isset( $step['text_plain'] )
						? (string) $step['text_plain']
						: HowToBlockAttributeSanitizer::normalize_plain_text( isset( $step['text'] ) ? $step['text'] : '' ),
				);
			}
		}

		return array(
			'schemaVersion' => isset( $attrs['schemaVersion'] ) ? (int) $attrs['schemaVersion'] : 1,
			'name'          => isset( $attrs['name'] ) ? (string) $attrs['name'] : '',
			'description'   => isset( $attrs['description'] ) ? (string) $attrs['description'] : '',
			'totalTime'     => isset( $attrs['totalTime'] ) ? (string) $attrs['totalTime'] : '',
			'steps'         => $steps,
		);
	}

	/**
	 * @param string              $code    Code.
	 * @param string              $message Message.
	 * @param array<string,mixed> $extra   Extra.
	 * @return array<string,mixed>
	 */
	private static function fail( $code, $message, array $extra = array() ) {
		return array_merge(
			array(
				'ok'      => false,
				'code'    => (string) $code,
				'message' => (string) $message,
			),
			$extra
		);
	}
}
