<?php
/**
 * AI Response Cleaner utility class.
 *
 * Provides methods to clean and sanitize AI-generated content,
 * removing unwanted characters, markdown formatting, and other artifacts.
 *
 * @package Antimanual
 * @since 2.2.0
 */

namespace Antimanual;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * AI Response Cleaner class.
 */
class AIResponseCleaner {

	/**
	 * Clean a raw AI response that should contain JSON.
	 *
	 * Removes markdown code fences, control characters, and extracts valid JSON.
	 *
	 * @param string $response The raw AI response.
	 * @return string The cleaned response ready for JSON parsing.
	 */
	public static function clean_json_response( string $response ): string {
		$cleaned = $response;

		// Remove UTF-8 BOM if present.
		$cleaned = preg_replace( '/^\xEF\xBB\xBF/', '', $cleaned );

		// Remove markdown code fences (```json, ```JSON, ```, etc.).
		// Pattern matches opening fence with optional language identifier.
		$cleaned = preg_replace( '/^```(?:json|JSON)?\s*\r?\n?/m', '', $cleaned );
		// Pattern matches closing fence.
		$cleaned = preg_replace( '/\r?\n?```\s*$/m', '', $cleaned );

		// Remove any text before the first { and after the last }.
		$first_brace = strpos( $cleaned, '{' );
		$last_brace  = strrpos( $cleaned, '}' );

		if ( false !== $first_brace && false !== $last_brace && $last_brace > $first_brace ) {
			$cleaned = substr( $cleaned, $first_brace, $last_brace - $first_brace + 1 );
		}

		// Remove control characters except for newlines and tabs within JSON strings.
		// This handles invisible characters that might break parsing.
		$cleaned = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $cleaned );

		// Trim whitespace.
		$cleaned = trim( $cleaned );

		return $cleaned;
	}

	/**
	 * Clean content field from unwanted characters.
	 *
	 * Removes zero-width characters, normalizes line breaks, and trims whitespace.
	 *
	 * @param string $content The content to clean.
	 * @return string The cleaned content.
	 */
	public static function clean_content( string $content ): string {
		$cleaned = $content;

		// Remove zero-width characters that might appear in AI responses.
		$cleaned = preg_replace( '/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $cleaned );

		// Remove smart quotes and replace with regular quotes.
		$cleaned = str_replace(
			array( "\u{201C}", "\u{201D}", "\u{2018}", "\u{2019}" ),
			array( '"', '"', "'", "'" ),
			$cleaned
		);

		// Normalize line breaks to Unix style.
		$cleaned = str_replace( "\r\n", "\n", $cleaned );
		$cleaned = str_replace( "\r", "\n", $cleaned );

		// Remove excessive newlines (more than 2 consecutive).
		$cleaned = preg_replace( "/\n{3,}/", "\n\n", $cleaned );

		// Trim whitespace.
		$cleaned = trim( $cleaned );

		return $cleaned;
	}

	/**
	 * Clean slug field to ensure URL-safe format.
	 *
	 * @param string $slug       The slug to clean.
	 * @param int    $max_length Optional. Maximum character length. 0 = unlimited.
	 * @return string The cleaned slug.
	 */
	public static function clean_slug( string $slug, int $max_length = 0 ): string {
		if ( empty( $slug ) ) {
			return '';
		}

		// Convert to lowercase.
		$cleaned = strtolower( $slug );

		// Remove any characters that aren't alphanumeric or hyphens.
		$cleaned = preg_replace( '/[^a-z0-9\-]/', '-', $cleaned );

		// Remove multiple consecutive hyphens.
		$cleaned = preg_replace( '/-+/', '-', $cleaned );

		// Trim hyphens from start and end.
		$cleaned = trim( $cleaned, '-' );

		// Enforce max length at a word boundary when specified.
		if ( $max_length > 0 && strlen( $cleaned ) > $max_length ) {
			$cleaned = substr( $cleaned, 0, $max_length );
			// Trim any trailing hyphens that result from the cut.
			$cleaned = rtrim( $cleaned, '-' );
		}

		return $cleaned;
	}

	/**
	 * Clean plain text response (for non-JSON AI responses like topic lists).
	 *
	 * @param string $response The AI response.
	 * @return string The cleaned response.
	 */
	public static function clean_plain_text( string $response ): string {
		$cleaned = $response;

		// Remove UTF-8 BOM if present.
		$cleaned = preg_replace( '/^\xEF\xBB\xBF/', '', $cleaned );

		// Remove markdown code fences.
		$cleaned = preg_replace( '/^```\w*\s*\r?\n?/m', '', $cleaned );
		$cleaned = preg_replace( '/\r?\n?```\s*$/m', '', $cleaned );

		// Remove zero-width characters.
		$cleaned = preg_replace( '/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $cleaned );

		// Remove control characters except newlines and tabs.
		$cleaned = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $cleaned );

		// Normalize line breaks.
		$cleaned = str_replace( "\r\n", "\n", $cleaned );
		$cleaned = str_replace( "\r", "\n", $cleaned );

		// Trim whitespace.
		$cleaned = trim( $cleaned );

		return $cleaned;
	}

	/**
	 * Clean Gutenberg block content.
	 *
	 * Ensures proper block formatting and removes invalid characters.
	 *
	 * @param string $content The Gutenberg block content.
	 * @return string The cleaned block content.
	 */
	public static function clean_gutenberg_content( string $content ): string {
		$cleaned = self::clean_content( $content );

		// Remove any markdown-style formatting that might have slipped through.
		// Remove markdown headers (## or ###) that aren't inside blocks.
		$cleaned = preg_replace( '/^#{1,6}\s+/m', '', $cleaned );

		// Remove markdown bullet points that aren't inside blocks.
		$cleaned = preg_replace( '/^[\*\-]\s+(?!\[)/m', '', $cleaned );

		// Ensure proper Gutenberg block comment format.
		// Fix malformed block comments that might have extra spaces or characters.
		$cleaned = preg_replace( '/<!--\s*wp:/', '<!-- wp:', $cleaned );
		$cleaned = preg_replace( '/\/wp:\s*-->/', '/wp: -->', $cleaned );

		// Convert legacy `wp:list` blocks (with raw <li> items) into the modern
		// format that nests each item in its own `wp:list-item` block. Required
		// since WordPress 6.0; legacy output triggers "Block contains unexpected
		// or invalid content" with an "Attempt recovery" prompt in the editor.
		$cleaned = self::normalize_list_blocks( $cleaned );

		// Ensure heading tags inside `wp:heading` blocks carry the
		// `wp-block-heading` class WordPress 6.3+ expects on save validation.
		$cleaned = self::normalize_heading_blocks( $cleaned );

		// Ensure the `<button>` inside `core/accordion-heading` is rendered as
		// `<button type="button" …>` to match the core block's save() output.
		$cleaned = self::normalize_core_accordion_blocks( $cleaned );

		// Ensure `wp:aab/accordion-item` blocks carry the `heading` attribute
		// that the Advanced Accordion Block plugin uses to reconstruct save().
		$cleaned = self::normalize_aab_accordion_blocks( $cleaned );

		return $cleaned;
	}

	/**
	 * Normalize legacy Gutenberg list blocks into the modern `wp:list-item` format.
	 *
	 * WordPress 6.0+ stores each `<li>` as a nested `wp:list-item` block inside
	 * the parent `wp:list`. AI-generated content frequently emits the legacy
	 * shape `<!-- wp:list --><ul><li>…</li></ul><!-- /wp:list -->`, which the
	 * block validator rejects — surfacing as "Attempt recovery" boxes in the
	 * editor. This method rewrites those blocks in place while leaving anything
	 * already in modern form untouched.
	 *
	 * @param string $content The post content.
	 * @return string Content with list blocks normalized.
	 */
	public static function normalize_list_blocks( string $content ): string {
		if ( false === stripos( $content, 'wp:list' ) ) {
			return $content;
		}

		return preg_replace_callback(
			'/(<!--\s*wp:list(?:\s+\{[^}]*\})?\s*-->)([\s\S]*?)(<!--\s*\/wp:list\s*-->)/i',
			static function ( $match ) {
				$open  = $match[1];
				$inner = $match[2];
				$close = $match[3];

				// Already in modern format — leave the block alone.
				if ( false !== stripos( $inner, 'wp:list-item' ) ) {
					return $match[0];
				}

				// No <li> elements to wrap (empty list, or already broken) — skip.
				if ( false === stripos( $inner, '<li' ) ) {
					return $match[0];
				}

				$wrapped = self::wrap_top_level_li_in_listitem( $inner );

				return $open . $wrapped . $close;
			},
			$content
		);
	}

	/**
	 * Wrap each top-level `<li>` in `<!-- wp:list-item -->` block delimiters.
	 *
	 * Uses depth tracking so nested `<li>` inside an inner `<ul>`/`<ol>` is
	 * left untouched (it belongs to the inner list, not this one).
	 *
	 * @param string $html Inner HTML of a `wp:list` block.
	 * @return string
	 */
	private static function wrap_top_level_li_in_listitem( string $html ): string {
		$output = '';
		$offset = 0;
		$len    = strlen( $html );

		while ( $offset < $len ) {
			// Find the next <li …> opening tag from the current offset.
			if ( ! preg_match( '/<li\b[^>]*>/i', $html, $m, PREG_OFFSET_CAPTURE, $offset ) ) {
				$output .= substr( $html, $offset );
				return $output;
			}

			$li_open_pos = $m[0][1];
			$li_open_tag = $m[0][0];

			// Emit everything before the <li> verbatim (whitespace, the <ul>, etc.).
			$output .= substr( $html, $offset, $li_open_pos - $offset );

			// Walk forward to find the matching </li>, tracking nested <li> depth.
			$content_start = $li_open_pos + strlen( $li_open_tag );
			$depth         = 1;
			$scan          = $content_start;
			$found_close   = false;

			while ( $scan < $len ) {
				if ( ! preg_match( '/<li\b[^>]*>|<\/li\s*>/i', $html, $sm, PREG_OFFSET_CAPTURE, $scan ) ) {
					break;
				}
				$tag     = $sm[0][0];
				$tag_pos = $sm[0][1];

				if ( '<' === $tag[0] && '/' === $tag[1] ) {
					$depth--;
					if ( 0 === $depth ) {
						$li_inner    = substr( $html, $content_start, $tag_pos - $content_start );
						$output     .= "<!-- wp:list-item -->\n" . $li_open_tag . $li_inner . '</li>' . "\n<!-- /wp:list-item -->";
						$offset      = $tag_pos + strlen( $tag );
						$found_close = true;
						break;
					}
				} else {
					$depth++;
				}
				$scan = $tag_pos + strlen( $tag );
			}

			if ( ! $found_close ) {
				// Malformed input — emit the remainder as-is and stop processing.
				$output .= substr( $html, $li_open_pos );
				return $output;
			}
		}

		return $output;
	}

	/**
	 * Ensure heading tags inside `wp:heading` blocks carry `wp-block-heading`.
	 *
	 * Since WordPress 6.3 the core/heading block's save() output includes the
	 * `wp-block-heading` class. Saved HTML missing that class fails block
	 * validation and the editor shows "Block contains unexpected or invalid
	 * content" with an "Attempt recovery" button.
	 *
	 * @param string $content The post content.
	 * @return string
	 */
	public static function normalize_heading_blocks( string $content ): string {
		if ( false === stripos( $content, 'wp:heading' ) ) {
			return $content;
		}

		return preg_replace_callback(
			'/(<!--\s*wp:heading(?:\s+\{[^}]*\})?\s*-->\s*)<(h[1-6])\b([^>]*)>/i',
			static function ( $match ) {
				$open_comment = $match[1];
				$tag          = $match[2];
				$attrs        = $match[3];

				if ( preg_match( '/\bclass\s*=\s*("|\')([^"\']*)\1/i', $attrs, $class_match ) ) {
					if ( false !== strpos( $class_match[2], 'wp-block-heading' ) ) {
						return $match[0];
					}
					$new_classes = trim( $class_match[2] . ' wp-block-heading' );
					$attrs       = str_replace( $class_match[0], 'class="' . $new_classes . '"', $attrs );
					return $open_comment . '<' . $tag . $attrs . '>';
				}

				return $open_comment . '<' . $tag . ' class="wp-block-heading"' . $attrs . '>';
			},
			$content
		);
	}

	/**
	 * Ensure `core/accordion-heading` buttons render with `type="button"`.
	 *
	 * The core block's save() emits `<button type="button" class="wp-block-…">`.
	 * Without the `type` attribute, block validation fails on parse.
	 *
	 * @param string $content The post content.
	 * @return string
	 */
	public static function normalize_core_accordion_blocks( string $content ): string {
		if ( false === stripos( $content, 'wp-block-accordion-heading__toggle' ) ) {
			return $content;
		}

		return preg_replace_callback(
			'/<button\b([^>]*?)class\s*=\s*("|\')([^"\']*\bwp-block-accordion-heading__toggle\b[^"\']*)\2([^>]*)>/i',
			static function ( $match ) {
				$pre_attrs  = $match[1];
				$quote      = $match[2];
				$classes    = $match[3];
				$post_attrs = $match[4];

				if ( false !== stripos( $pre_attrs . $post_attrs, 'type=' ) ) {
					return $match[0];
				}

				return '<button type="button"' . $pre_attrs . 'class=' . $quote . $classes . $quote . $post_attrs . '>';
			},
			$content
		);
	}

	/**
	 * Ensure `wp:aab/accordion-item` blocks carry the `heading` attribute.
	 *
	 * The Advanced Accordion Block plugin reconstructs save() HTML from the
	 * block's attributes. With no `heading` attribute set, save() produces an
	 * empty title while the saved HTML contains the question — the mismatch
	 * triggers "Attempt recovery" in the editor. We parse the question text
	 * from the rendered `<h5 class="aagb__accordion_title">` and merge it into
	 * the block's JSON attribute payload so attributes and HTML agree.
	 *
	 * @param string $content The post content.
	 * @return string
	 */
	public static function normalize_aab_accordion_blocks( string $content ): string {
		if ( false === stripos( $content, 'wp:aab/accordion-item' ) ) {
			return $content;
		}

		return preg_replace_callback(
			'/<!--\s*wp:aab\/accordion-item(\s+\{[^}]*\})?\s*-->([\s\S]*?)<!--\s*\/wp:aab\/accordion-item\s*-->/i',
			static function ( $match ) {
				$attrs_json = isset( $match[1] ) ? trim( (string) $match[1] ) : '';
				$inner_html = $match[2];

				if ( ! preg_match( '/<h[1-6][^>]*class\s*=\s*("|\')[^"\']*aagb__accordion_title[^"\']*\1[^>]*>([\s\S]*?)<\/h[1-6]>/i', $inner_html, $heading_match ) ) {
					return $match[0];
				}

				$heading_text = trim( wp_strip_all_tags( $heading_match[2] ) );
				if ( '' === $heading_text ) {
					return $match[0];
				}

				$attrs = array();
				if ( '' !== $attrs_json ) {
					$decoded = json_decode( $attrs_json, true );
					if ( is_array( $decoded ) ) {
						$attrs = $decoded;
					}
				}

				if ( isset( $attrs['heading'] ) && (string) $attrs['heading'] === $heading_text ) {
					return $match[0];
				}

				$attrs['heading'] = $heading_text;
				$encoded          = wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );

				return '<!-- wp:aab/accordion-item ' . $encoded . ' -->' . $inner_html . '<!-- /wp:aab/accordion-item -->';
			},
			$content
		);
	}
}
