<?php
/**
 * Explicit conversion of legacy web FAQ rich blocks to seovault/faq.
 *
 * Detects data-seovault-rich-block="faq-schema" markup published as generic
 * HTML. Does not trust those attributes for schema emission at runtime.
 * Conversion requires user confirmation or an authorized connector migration.
 *
 * @package SEOvaultSEO\Blocks
 */

namespace SEOvaultSEO\Blocks;

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

/**
 * Detect / preview / convert / rollback legacy FAQ rich blocks.
 */
class LegacyFaqRichBlockConverter {

	/**
	 * Legacy web rich-block type.
	 */
	const RICH_BLOCK_TYPE = 'faq-schema';

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

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

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

	/**
	 * Match marker used in content scans.
	 */
	const MARKER_PATTERN = '/data-seovault-rich-block\s*=\s*(["\'])faq-schema\1/i';

	/**
	 * Whether content contains an unconverted legacy FAQ rich block.
	 *
	 * @param string $content Post content.
	 * @return bool
	 */
	public static function content_has_legacy( $content ) {
		return is_string( $content ) && (bool) preg_match( self::MARKER_PATTERN, $content );
	}

	/**
	 * Whether content already has a native SEOvault FAQ block.
	 *
	 * @param string $content Post content.
	 * @return bool
	 */
	public static function content_has_native_faq( $content ) {
		if ( ! is_string( $content ) || '' === $content ) {
			return false;
		}
		if ( false !== strpos( $content, '<!-- wp:seovault/faq' ) ) {
			return true;
		}
		if ( ! function_exists( 'parse_blocks' ) ) {
			return false;
		}
		return self::blocks_contain_native( parse_blocks( $content ) );
	}

	/**
	 * Detect legacy FAQ rich blocks in content (read-only).
	 *
	 * @param string $content Post content.
	 * @return array{
	 *   has_legacy:bool,
	 *   has_native:bool,
	 *   matches:array<int,array<string,mixed>>,
	 *   convertible_count:int,
	 *   blocked_count:int
	 * }
	 */
	public static function detect( $content ) {
		$content = is_string( $content ) ? $content : '';
		$has_legacy = self::content_has_legacy( $content );
		$has_native = self::content_has_native_faq( $content );

		$matches = array();
		if ( $has_legacy ) {
			$matches = self::scan_content( $content );
		}

		$convertible = 0;
		$blocked     = 0;
		foreach ( $matches as $match ) {
			if ( ! empty( $match['convertible'] ) ) {
				++$convertible;
			} else {
				++$blocked;
			}
		}

		return array(
			'has_legacy'         => $has_legacy,
			'has_native'         => $has_native,
			'matches'            => $matches,
			'convertible_count'  => $convertible,
			'blocked_count'      => $blocked,
		);
	}

	/**
	 * 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(
				'status'              => $match['status'],
				'convertible'         => ! empty( $match['convertible'] ),
				'errors'              => isset( $match['errors'] ) ? $match['errors'] : array(),
				'had_inline_jsonld'   => ! empty( $match['had_inline_jsonld'] ),
				'item_count'          => 0,
				'complete_item_count' => 0,
				'attributes'          => null,
			);
			if ( ! empty( $match['attributes'] ) && is_array( $match['attributes'] ) ) {
				$attrs                        = $match['attributes'];
				$entry['attributes']          = self::public_attributes( $attrs );
				$entry['item_count']          = isset( $attrs['items'] ) && is_array( $attrs['items'] ) ? count( $attrs['items'] ) : 0;
				$entry['complete_item_count'] = count( FaqBlockAttributeSanitizer::get_complete_items( $attrs ) );
			}
			$proposed[] = $entry;
		}

		$status = 'ready';
		if ( ! $detection['has_legacy'] ) {
			$status = $detection['has_native'] ? 'already_converted' : '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_legacy'         => $detection['has_legacy'],
			'has_native'         => $detection['has_native'],
			'convertible_count'  => $detection['convertible_count'],
			'blocked_count'      => $detection['blocked_count'],
			'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',
				'Legacy FAQ 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_legacy'] ) {
			return array(
				'ok'                => true,
				'skipped'           => true,
				'code'              => $detection['has_native'] ? 'already_converted' : 'none',
				'converted_count'   => 0,
				'content_hash'      => hash( 'sha256', $original ),
				'post_id'           => $post_id,
			);
		}

		if ( $detection['convertible_count'] <= 0 ) {
			return self::fail(
				'not_convertible',
				'Legacy FAQ markup was found but could not 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,
			);
		}

		// Idempotent second pass should leave no markers.
		if ( self::content_has_legacy( $new_content ) ) {
			// Leave non-convertible leftovers; require they are not the ones we marked convertible.
			$after = self::detect( $new_content );
			if ( $after['convertible_count'] > 0 ) {
				return self::fail( 'transform_incomplete', 'Conversion left convertible legacy FAQ markup 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'],
		);
		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'],
				'stripped_scripts' => (int) $transformed['stripped_scripts'],
				'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'],
			'stripped_scripts'   => (int) $transformed['stripped_scripts'],
			'revision_id'        => $revision_id,
			'backup_saved'       => true,
			'content_hash'       => hash( 'sha256', $fresh_content ),
			'has_legacy'         => self::content_has_legacy( $fresh_content ),
			'has_native'         => self::content_has_native_faq( $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 legacy FAQ 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_legacy'   => self::content_has_legacy( $fresh_content ),
			'has_native'   => self::content_has_native_faq( $fresh_content ),
		);
	}

	/**
	 * Frontend filter: disable inline FAQ JSON-LD inside legacy rich wrappers.
	 *
	 * Does not convert markup and does not emit schema from attributes.
	 *
	 * @param string $content Content.
	 * @return string
	 */
	public static function filter_disable_legacy_inline_jsonld( $content ) {
		if ( ! is_string( $content ) || '' === $content || ! self::content_has_legacy( $content ) ) {
			return $content;
		}
		return self::strip_legacy_inline_faq_jsonld( $content );
	}

	/**
	 * Improvement descriptors when legacy FAQ HTML is still present.
	 *
	 * @param string $content Post content.
	 * @return array<int,array<string,mixed>>
	 */
	public static function improvement_descriptors( $content ) {
		if ( ! self::content_has_legacy( $content ) ) {
			return array();
		}
		$detection = self::detect( $content );
		return array(
			array(
				'code'        => 'faq.legacy_rich_block.conversion_available',
				'severity'    => 'recommendation',
				'message'     => 'A legacy web FAQ rich block was found. Convert it to the SEOvault FAQ block to avoid duplicate schema.',
				'action'      => 'convert_legacy_faq_rich_block',
				'convertible' => (int) $detection['convertible_count'],
				'blocked'     => (int) $detection['blocked_count'],
			),
		);
	}

	/**
	 * Map a web FaqSchemaBlockConfig bag to native FAQ attributes (then sanitize).
	 *
	 * @param mixed $raw Raw decoded config.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[],status:string,convertible:bool}
	 */
	public static function map_and_sanitize_config( $raw ) {
		$errors   = array();
		$stripped = array();

		if ( null === $raw ) {
			return array(
				'attributes'  => FaqBlockAttributeSanitizer::defaults(),
				'errors'      => array( 'config_missing' ),
				'stripped'    => array(),
				'status'      => 'malformed',
				'convertible' => false,
			);
		}

		if ( ! is_array( $raw ) ) {
			return array(
				'attributes'  => FaqBlockAttributeSanitizer::defaults(),
				'errors'      => array( 'config_not_object' ),
				'stripped'    => array(),
				'status'      => 'malformed',
				'convertible' => false,
			);
		}

		$encoded = wp_json_encode( $raw );
		$limits  = FaqBlockLimits::all();
		if ( is_string( $encoded ) && strlen( $encoded ) > $limits['max_payload_bytes'] ) {
			return array(
				'attributes'  => FaqBlockAttributeSanitizer::defaults(),
				'errors'      => array( 'payload_too_large' ),
				'stripped'    => array(),
				'status'      => 'oversized',
				'convertible' => false,
			);
		}

		if ( self::config_looks_malicious( $raw ) ) {
			return array(
				'attributes'  => FaqBlockAttributeSanitizer::defaults(),
				'errors'      => array( 'malicious_config' ),
				'stripped'    => array(),
				'status'      => 'malicious',
				'convertible' => false,
			);
		}

		$mapped = array(
			'schemaVersion'       => FaqBlockAttributeSanitizer::SCHEMA_VERSION,
			'heading'             => isset( $raw['title'] ) ? $raw['title'] : ( isset( $raw['heading'] ) ? $raw['heading'] : '' ),
			'intro'               => isset( $raw['intro'] ) ? $raw['intro'] : '',
			'items'               => isset( $raw['items'] ) ? $raw['items'] : array(),
			'displayMode'         => isset( $raw['displayMode'] ) ? $raw['displayMode'] : 'accordion',
			'stylePreset'         => isset( $raw['stylePreset'] ) ? $raw['stylePreset'] : 'accordion-cards',
			'openFirstItem'       => ! empty( $raw['openFirstItem'] ),
			'showQuestionNumbers' => ! empty( $raw['showQuestionNumbers'] ),
			'colorPreset'         => isset( $raw['colorPreset'] ) ? $raw['colorPreset'] : 'blue',
			'density'             => isset( $raw['density'] ) ? $raw['density'] : 'comfortable',
			'sharpCorners'        => ! empty( $raw['sharpCorners'] ),
		);

		// schemaMode and other web-only keys are intentionally dropped.
		foreach ( array_keys( $raw ) as $key ) {
			$key = (string) $key;
			if ( ! array_key_exists( $key, $mapped ) && 'title' !== $key && 'schemaMode' !== $key ) {
				$stripped[] = $key;
			}
		}
		if ( array_key_exists( 'schemaMode', $raw ) ) {
			$stripped[] = 'schemaMode';
		}

		$sanitized = FaqBlockAttributeSanitizer::sanitize( $mapped );
		$errors    = array_merge( $errors, $sanitized['errors'] );
		$stripped  = array_values( array_unique( array_merge( $stripped, $sanitized['stripped'] ) ) );

		$blocking = array_intersect(
			$sanitized['errors'],
			array( 'payload_too_large', 'attributes_not_array' )
		);
		$convertible = empty( $blocking );

		$status = 'ok';
		if ( ! $convertible ) {
			$status = in_array( 'payload_too_large', $sanitized['errors'], true ) ? 'oversized' : 'malformed';
		}

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

	/**
	 * Parse a raw data-seovault-config attribute value.
	 *
	 * @param string $raw Attribute value.
	 * @return array{decoded:mixed,errors:string[],status:string}
	 */
	public static function parse_config_attribute( $raw ) {
		$errors = array();
		if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
			return array(
				'decoded' => null,
				'errors'  => array( 'config_missing' ),
				'status'  => 'malformed',
			);
		}

		$limits = FaqBlockLimits::all();
		if ( strlen( $raw ) > ( $limits['max_payload_bytes'] * 2 ) ) {
			return array(
				'decoded' => null,
				'errors'  => array( 'payload_too_large' ),
				'status'  => 'oversized',
			);
		}

		$candidates = array( $raw );
		$decoded_entities = html_entity_decode( $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		if ( $decoded_entities !== $raw ) {
			$candidates[] = $decoded_entities;
		}
		$url_decoded = rawurldecode( $decoded_entities );
		if ( $url_decoded !== $decoded_entities ) {
			$candidates[] = $url_decoded;
		}

		foreach ( $candidates as $candidate ) {
			$json = json_decode( $candidate, true );
			if ( JSON_ERROR_NONE === json_last_error() ) {
				return array(
					'decoded' => $json,
					'errors'  => array(),
					'status'  => 'ok',
				);
			}
			$errors[] = 'json_decode_failed';
		}

		return array(
			'decoded' => null,
			'errors'  => array_values( array_unique( $errors ) ),
			'status'  => 'malformed',
		);
	}

	/**
	 * Build a serialized seovault/faq block from sanitized attributes.
	 *
	 * @param array<string,mixed> $attributes Sanitized attributes.
	 * @return string
	 */
	public static function serialize_native_block( array $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 -->';
	}

	/**
	 * Strip FAQPage JSON-LD script tags associated with legacy FAQ wrappers.
	 *
	 * @param string $html HTML fragment or full content.
	 * @return string
	 */
	public static function strip_legacy_inline_faq_jsonld( $html ) {
		if ( ! is_string( $html ) || '' === $html ) {
			return is_string( $html ) ? $html : '';
		}

		$dom = self::load_dom( $html );
		if ( ! $dom ) {
			// Fallback: remove ld+json scripts that mention FAQPage near the marker.
			return (string) preg_replace_callback(
				'/<script\b[^>]*type\s*=\s*([\'"])application\/ld\+json\1[^>]*>.*?<\/script>/is',
				static function ( $m ) {
					$body = isset( $m[0] ) ? $m[0] : '';
					if ( preg_match( '/FAQPage/i', $body ) ) {
						return '';
					}
					return $body;
				},
				$html
			);
		}

		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( '//*[@data-seovault-rich-block="faq-schema"]' );
		if ( $nodes ) {
			foreach ( $nodes as $node ) {
				if ( ! $node instanceof \DOMElement ) {
					continue;
				}
				self::remove_faq_jsonld_near( $node, $xpath );
			}
		}

		return self::export_dom_html( $dom, $html );
	}

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

	/**
	 * Scan content for legacy matches.
	 *
	 * @param string $content Content.
	 * @return array<int,array<string,mixed>>
	 */
	private static function scan_content( $content ) {
		$matches = array();

		if ( function_exists( 'parse_blocks' ) && false !== strpos( $content, '<!-- wp:' ) ) {
			$blocks = parse_blocks( $content );
			self::scan_blocks( $blocks, $matches );
			if ( ! empty( $matches ) ) {
				return $matches;
			}
		}

		foreach ( self::extract_matches_from_html( $content ) as $match ) {
			$matches[] = $match;
		}

		return $matches;
	}

	/**
	 * Walk parsed blocks for HTML/freeform hosts.
	 *
	 * @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 ( FaqBlock::BLOCK_NAME === $name ) {
				// Native block — ignore; already converted path.
				continue;
			}

			$inner = '';
			if ( isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ) {
				$inner = $block['innerHTML'];
			} elseif ( isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
				$inner = implode( '', array_filter( $block['innerContent'], 'is_string' ) );
			}

			if ( '' !== $inner && self::content_has_legacy( $inner ) ) {
				foreach ( self::extract_matches_from_html( $inner ) as $match ) {
					$match['host_block'] = $name ? $name : 'core/freeform';
					$matches[]           = $match;
				}
			}

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

	/**
	 * Extract match records from an HTML fragment.
	 *
	 * @param string $html HTML.
	 * @return array<int,array<string,mixed>>
	 */
	private static function extract_matches_from_html( $html ) {
		$out = array();
		$dom = self::load_dom( $html );
		if ( ! $dom ) {
			// Regex fallback for severely malformed markup: still refuse to trust for conversion.
			if ( preg_match( self::MARKER_PATTERN, $html ) ) {
				$out[] = array(
					'status'            => 'malformed',
					'convertible'       => false,
					'errors'            => array( 'dom_parse_failed' ),
					'had_inline_jsonld' => (bool) preg_match( '/application\/ld\+json/i', $html ),
					'attributes'        => null,
					'raw_config'        => null,
				);
			}
			return $out;
		}

		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( '//*[@data-seovault-rich-block="faq-schema"]' );
		if ( ! $nodes ) {
			return $out;
		}

		foreach ( $nodes as $node ) {
			if ( ! $node instanceof \DOMElement ) {
				continue;
			}

			$raw_config = $node->getAttribute( 'data-seovault-config' );
			$parsed     = self::parse_config_attribute( $raw_config );
			$mapped     = self::map_and_sanitize_config( $parsed['decoded'] );

			$status = $mapped['status'];
			if ( 'ok' !== $parsed['status'] && 'ok' === $status ) {
				$status = $parsed['status'];
			}

			$had_script = self::node_has_faq_jsonld( $node, $xpath );
			if ( ! $had_script && is_array( $parsed['decoded'] ) && isset( $parsed['decoded']['schemaMode'] )
				&& 'inline-jsonld' === $parsed['decoded']['schemaMode'] ) {
				$had_script = true;
			}

			$convertible = ( 'ok' === $status ) && ! empty( $mapped['convertible'] );
			if ( 'ok' !== $parsed['status'] ) {
				$convertible = false;
			}

			$out[] = array(
				'status'            => $convertible ? 'ok' : $status,
				'convertible'       => $convertible,
				'errors'            => array_values(
					array_unique(
						array_merge(
							$parsed['errors'],
							$mapped['errors']
						)
					)
				),
				'had_inline_jsonld' => $had_script,
				'attributes'        => $mapped['attributes'],
				'stripped'          => $mapped['stripped'],
				'raw_config'        => is_string( $raw_config ) ? substr( $raw_config, 0, 200 ) : null,
			);
		}

		return $out;
	}

	/**
	 * Transform content, replacing convertible legacy FAQ hosts.
	 *
	 * @param string $content Original content.
	 * @return array<string,mixed>
	 */
	private static function transform_content( $content ) {
		$converted = 0;
		$stripped  = 0;

		if ( function_exists( 'parse_blocks' ) && false !== strpos( $content, '<!-- wp:' ) ) {
			$blocks = parse_blocks( $content );
			$result = self::transform_blocks( $blocks, $converted, $stripped );
			if ( empty( $result['ok'] ) ) {
				return $result;
			}
			$serialized = serialize_blocks( $result['blocks'] );
			return array(
				'ok'               => true,
				'content'          => $serialized,
				'converted_count'  => $converted,
				'stripped_scripts' => $stripped,
			);
		}

		$fragment = self::replace_legacy_in_html( $content, $converted, $stripped );
		if ( empty( $fragment['ok'] ) ) {
			return $fragment;
		}

		return array(
			'ok'               => true,
			'content'          => $fragment['html'],
			'converted_count'  => $converted,
			'stripped_scripts' => $stripped,
		);
	}

	/**
	 * Transform a block tree.
	 *
	 * @param array $blocks    Blocks.
	 * @param int   $converted Converted counter (by ref).
	 * @param int   $stripped  Stripped scripts counter (by ref).
	 * @return array<string,mixed>
	 */
	private static function transform_blocks( array $blocks, &$converted, &$stripped ) {
		$out = array();
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}

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

			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				$inner = self::transform_blocks( $block['innerBlocks'], $converted, $stripped );
				if ( empty( $inner['ok'] ) ) {
					return $inner;
				}
				$block['innerBlocks'] = $inner['blocks'];
			}

			$inner_html = isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ? $block['innerHTML'] : '';
			if ( '' === $inner_html && isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
				$inner_html = implode( '', array_filter( $block['innerContent'], 'is_string' ) );
			}

			if ( '' !== $inner_html && self::content_has_legacy( $inner_html ) ) {
				$replaced = self::replace_legacy_in_html( $inner_html, $converted, $stripped );
				if ( empty( $replaced['ok'] ) ) {
					return $replaced;
				}

				$new_html = (string) $replaced['html'];
				$expanded = self::expand_html_host_to_blocks( $new_html, $name, $block );
				foreach ( $expanded as $nb ) {
					$out[] = $nb;
				}
				continue;
			}

			$out[] = $block;
		}

		return array(
			'ok'     => true,
			'blocks' => $out,
		);
	}

	/**
	 * Replace convertible legacy FAQ nodes in HTML with native block markup.
	 *
	 * @param string $html      HTML.
	 * @param int    $converted Counter (by ref).
	 * @param int    $stripped  Counter (by ref).
	 * @return array<string,mixed>
	 */
	private static function replace_legacy_in_html( $html, &$converted, &$stripped ) {
		$dom = self::load_dom( $html );
		if ( ! $dom ) {
			return array(
				'ok'      => false,
				'code'    => 'dom_parse_failed',
				'message' => 'Could not parse legacy FAQ HTML for conversion.',
			);
		}

		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( '//*[@data-seovault-rich-block="faq-schema"]' );
		if ( ! $nodes || 0 === $nodes->length ) {
			return array(
				'ok'   => true,
				'html' => $html,
			);
		}

		// Snapshot to array because we mutate the tree.
		$list = array();
		foreach ( $nodes as $node ) {
			$list[] = $node;
		}

		$placeholders = array();

		foreach ( $list as $index => $node ) {
			if ( ! $node instanceof \DOMElement || ! $node->parentNode ) {
				continue;
			}

			if ( self::remove_faq_jsonld_near( $node, $xpath ) ) {
				++$stripped;
			}

			$raw_config = $node->getAttribute( 'data-seovault-config' );
			$parsed     = self::parse_config_attribute( $raw_config );
			$mapped     = self::map_and_sanitize_config( $parsed['decoded'] );

			if ( 'ok' !== $parsed['status'] || empty( $mapped['convertible'] ) || 'ok' !== $mapped['status'] ) {
				// Leave non-convertible nodes in place (scripts already stripped when present).
				continue;
			}

			$token = '%%SEOVAULT_FAQ_CONVERTED_' . $index . '_' . wp_generate_password( 8, false ) . '%%';
			$serialized = self::serialize_native_block( $mapped['attributes'] );
			$placeholders[ $token ] = $serialized;

			$text = $dom->createTextNode( $token );
			$node->parentNode->replaceChild( $text, $node );
			++$converted;
		}

		$exported = self::export_dom_html( $dom, $html );
		foreach ( $placeholders as $token => $serialized ) {
			$exported = str_replace( $token, "\n" . $serialized . "\n", $exported );
		}

		return array(
			'ok'   => true,
			'html' => $exported,
		);
	}

	/**
	 * @param \DOMElement $node  Legacy node.
	 * @param \DOMXPath   $xpath XPath.
	 * @return bool Whether a FAQ JSON-LD script was removed.
	 */
	private static function remove_faq_jsonld_near( \DOMElement $node, \DOMXPath $xpath ) {
		$removed = false;

		$scripts = $xpath->query( './/script', $node );
		if ( $scripts ) {
			foreach ( iterator_to_array( $scripts ) as $script ) {
				if ( ! $script instanceof \DOMElement ) {
					continue;
				}
				$type = strtolower( (string) $script->getAttribute( 'type' ) );
				if ( false !== strpos( $type, 'ld+json' ) && self::script_is_faqpage( $script ) ) {
					if ( $script->parentNode ) {
						$script->parentNode->removeChild( $script );
					}
					$removed = true;
				}
			}
		}

		// Adjacent following siblings (web renderer sometimes concatenates section + script).
		$sibling = $node->nextSibling;
		while ( $sibling ) {
			$next = $sibling->nextSibling;
			if ( $sibling instanceof \DOMElement
				&& 'script' === strtolower( $sibling->tagName )
				&& false !== stripos( (string) $sibling->getAttribute( 'type' ), 'ld+json' )
				&& self::script_is_faqpage( $sibling )
			) {
				if ( $sibling->parentNode ) {
					$sibling->parentNode->removeChild( $sibling );
				}
				$removed = true;
			} elseif ( XML_TEXT_NODE === $sibling->nodeType && '' === trim( (string) $sibling->nodeValue ) ) {
				// skip whitespace
			} else {
				break;
			}
			$sibling = $next;
		}

		return $removed;
	}

	/**
	 * @param \DOMElement $node  Node.
	 * @param \DOMXPath   $xpath XPath.
	 * @return bool
	 */
	private static function node_has_faq_jsonld( \DOMElement $node, \DOMXPath $xpath ) {
		$scripts = $xpath->query( './/script', $node );
		if ( $scripts ) {
			foreach ( $scripts as $script ) {
				if ( ! $script instanceof \DOMElement ) {
					continue;
				}
				$type = strtolower( (string) $script->getAttribute( 'type' ) );
				if ( false !== strpos( $type, 'ld+json' ) && self::script_is_faqpage( $script ) ) {
					return true;
				}
			}
		}
		$sibling = $node->nextSibling;
		while ( $sibling ) {
			if ( $sibling instanceof \DOMElement
				&& 'script' === strtolower( $sibling->tagName )
				&& false !== stripos( (string) $sibling->getAttribute( 'type' ), 'ld+json' )
				&& self::script_is_faqpage( $sibling )
			) {
				return true;
			}
			if ( XML_TEXT_NODE === $sibling->nodeType && '' === trim( (string) $sibling->nodeValue ) ) {
				$sibling = $sibling->nextSibling;
				continue;
			}
			break;
		}
		return false;
	}

	/**
	 * @param \DOMElement $script Script element.
	 * @return bool
	 */
	private static function script_is_faqpage( \DOMElement $script ) {
		$text = (string) $script->textContent;
		return (bool) preg_match( '/FAQPage/i', $text );
	}

	/**
	 * Heuristic malicious config detection before sanitizer.
	 *
	 * @param array $raw Raw config.
	 * @return bool
	 */
	private static function config_looks_malicious( array $raw ) {
		$encoded = wp_json_encode( $raw );
		if ( ! is_string( $encoded ) ) {
			return true;
		}
		if ( preg_match( '/<__proto__|constructor\s*\(|\b__defineGetter__\b/i', $encoded ) ) {
			return true;
		}
		if ( preg_match( '/<script\b|javascript\s*:|onerror\s*=|onload\s*=/i', $encoded ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Expand a converted HTML host into Gutenberg blocks (split mixed content).
	 *
	 * @param string              $html          Converted HTML (may contain native FAQ block comments).
	 * @param string              $original_name Original host block name.
	 * @param array<string,mixed> $original      Original block (attrs fallback).
	 * @return array<int,array<string,mixed>>
	 */
	private static function expand_html_host_to_blocks( $html, $original_name, array $original ) {
		$trim = trim( (string) $html );
		if ( '' === $trim ) {
			return array();
		}

		if ( false === strpos( $trim, '<!-- wp:seovault/faq' ) ) {
			$block               = $original;
			$block['innerHTML']  = $html;
			$block['innerContent'] = array( $html );
			return array( $block );
		}

		$parts  = preg_split( '/(<!--\s*wp:seovault\/faq\b.*?<!--\s*\/wp:seovault\/faq\s*-->)/s', $trim, -1, PREG_SPLIT_DELIM_CAPTURE );
		$blocks = array();
		if ( ! is_array( $parts ) ) {
			$block                 = $original;
			$block['innerHTML']    = $html;
			$block['innerContent'] = array( $html );
			return array( $block );
		}

		foreach ( $parts as $part ) {
			$part = (string) $part;
			if ( '' === trim( $part ) ) {
				continue;
			}
			if ( 0 === strpos( ltrim( $part ), '<!-- wp:seovault/faq' ) || 0 === strpos( ltrim( $part ), '<!--wp:seovault/faq' ) ) {
				$parsed = parse_blocks( $part );
				foreach ( $parsed as $nb ) {
					if ( is_array( $nb ) && ! empty( $nb['blockName'] ) ) {
						$blocks[] = $nb;
					}
				}
				continue;
			}

			$host_name = $original_name ? $original_name : 'core/html';
			if ( 'core/freeform' === $host_name || null === $original_name || '' === $original_name ) {
				$host_name = 'core/html';
			}
			$blocks[] = array(
				'blockName'    => $host_name,
				'attrs'        => isset( $original['attrs'] ) && is_array( $original['attrs'] ) ? $original['attrs'] : array(),
				'innerBlocks'  => array(),
				'innerHTML'    => $part,
				'innerContent' => array( $part ),
			);
		}

		return $blocks;
	}

	/**
	 * @param array $blocks Blocks.
	 * @return bool
	 */
	private static function blocks_contain_native( array $blocks ) {
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}
			if ( isset( $block['blockName'] ) && FaqBlock::BLOCK_NAME === $block['blockName'] ) {
				return true;
			}
			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && self::blocks_contain_native( $block['innerBlocks'] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Load HTML into DOMDocument.
	 *
	 * @param string $html HTML.
	 * @return \DOMDocument|null
	 */
	private static function load_dom( $html ) {
		if ( ! class_exists( 'DOMDocument' ) ) {
			return null;
		}
		$previous = libxml_use_internal_errors( true );
		$dom      = new \DOMDocument();
		$wrapped  = '<!DOCTYPE html><html><body><div id="seovault-legacy-faq-root">' . $html . '</div></body></html>';
		$loaded   = $dom->loadHTML( '<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_NONET );
		libxml_clear_errors();
		libxml_use_internal_errors( $previous );
		return $loaded ? $dom : null;
	}

	/**
	 * Export inner HTML of the conversion root.
	 *
	 * @param \DOMDocument $dom      DOM.
	 * @param string       $original Original HTML (fallback).
	 * @return string
	 */
	private static function export_dom_html( \DOMDocument $dom, $original ) {
		$root = $dom->getElementById( 'seovault-legacy-faq-root' );
		if ( ! $root ) {
			return $original;
		}
		$html = '';
		foreach ( $root->childNodes as $child ) {
			$html .= $dom->saveHTML( $child );
		}
		return $html;
	}

	/**
	 * Attributes safe for preview responses (no huge answer HTML).
	 *
	 * @param array $attrs Attributes.
	 * @return array
	 */
	private static function public_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',
			'stylePreset'         => isset( $attrs['stylePreset'] ) ? (string) $attrs['stylePreset'] : 'accordion-cards',
			'openFirstItem'       => ! empty( $attrs['openFirstItem'] ),
			'showQuestionNumbers' => ! empty( $attrs['showQuestionNumbers'] ),
			'colorPreset'         => isset( $attrs['colorPreset'] ) ? (string) $attrs['colorPreset'] : 'blue',
			'density'             => isset( $attrs['density'] ) ? (string) $attrs['density'] : 'comfortable',
			'sharpCorners'        => ! empty( $attrs['sharpCorners'] ),
			'items'               => $items,
		);
	}

	/**
	 * @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
		);
	}
}
