<?php
/**
 * Costudio_Update_Sectioner — Section-based surgical update engine.
 *
 * Splits an existing Gutenberg article into H2-delimited sections, renders
 * them for the editor agent, parses the agent's per-section action protocol,
 * and rebuilds the final article preserving untouched sections byte-for-byte.
 *
 * The whole point of this class is to give the LLM a job it can do well
 * (edit Markdown text) while keeping markup-sensitive work (Gutenberg block
 * serialization, attribute preservation, custom HTML blocks) in deterministic
 * PHP. Sections the user did not ask to change are returned exactly as they
 * came in — no re-serialization, no attribute drift, no validator warnings.
 *
 * @package Contentosapp_Studio
 * @since   3.3.0
 */

defined( 'ABSPATH' ) || exit;

final class Costudio_Update_Sectioner {

	private const PRESERVED_PLACEHOLDER = '[BLOCO PRESERVADO — não editar diretamente]';

	private function __construct() {}

	/**
	 * Cheap probe: does the string look like Gutenberg block markup?
	 *
	 * Block delimiters are HTML comments (<!-- wp:blockname -->), so their
	 * presence is a necessary precondition for parse_blocks() to return
	 * structured blocks instead of a single freeform fallback. Callers use
	 * this to fail fast before feeding rendered HTML into the Sectioner —
	 * see Costudio_WP_Publisher::rebuild_update_content() for the guard.
	 */
	public static function has_block_markup( string $content ): bool {
		return false !== strpos( $content, '<!-- wp:' );
	}

	/**
	 * Split raw Gutenberg content into H2-delimited sections.
	 *
	 * Section 0 is always the intro (everything before the first H2). When
	 * the article starts directly with an H2, section 0 will have an empty
	 * blocks array and is omitted from prompt rendering.
	 *
	 * @return array<int,array{
	 *   index:int,
	 *   heading_text:string,
	 *   heading_level:int|null,
	 *   blocks:array,
	 *   markdown:string,
	 *   has_preserved:bool
	 * }>
	 */
	public static function split( string $gutenberg_content ): array {
		$gutenberg_content = trim( $gutenberg_content );
		if ( '' === $gutenberg_content ) {
			return array();
		}

		$blocks = parse_blocks( $gutenberg_content );
		// parse_blocks() inserts empty blockName entries for whitespace runs.
		$blocks = array_values( array_filter( $blocks, static function ( $b ) {
			return ! empty( $b['blockName'] ) || trim( (string) ( $b['innerHTML'] ?? '' ) ) !== '';
		} ) );

		$sections      = array();
		$current_blocks = array();
		$current_meta   = array(
			'heading_text'  => 'INTRODUÇÃO',
			'heading_level' => null,
		);

		$flush = static function () use ( &$sections, &$current_blocks, &$current_meta ) {
			if ( empty( $current_blocks ) ) {
				return;
			}
			$idx        = count( $sections );
			$markdown   = self::blocks_to_markdown( $current_blocks );
			$preserved  = false !== strpos( $markdown, self::PRESERVED_PLACEHOLDER );
			$sections[] = array(
				'index'         => $idx,
				'heading_text'  => $current_meta['heading_text'],
				'heading_level' => $current_meta['heading_level'],
				'blocks'        => $current_blocks,
				'markdown'      => $markdown,
				'has_preserved' => $preserved,
			);
			$current_blocks = array();
		};

		foreach ( $blocks as $block ) {
			if ( self::is_h2( $block ) ) {
				// H2 boundary: close the previous section and start a new one
				// with this H2 as the lead block.
				$flush();
				$current_meta = array(
					'heading_text'  => self::heading_text( $block ),
					'heading_level' => 2,
				);
				$current_blocks[] = $block;
				continue;
			}
			$current_blocks[] = $block;
		}
		$flush();

		return $sections;
	}

	/**
	 * Render sections as a prompt-friendly numbered Markdown listing.
	 */
	public static function render_for_prompt( array $sections ): string {
		$out = '';
		foreach ( $sections as $section ) {
			$label = sprintf( '[SECTION %d: %s]', $section['index'], $section['heading_text'] );
			$out  .= $label . "\n" . trim( $section['markdown'] ) . "\n\n";
		}
		return rtrim( $out ) . "\n";
	}

	/**
	 * Parse the agent's per-section action response.
	 *
	 * Expected format (tolerant — accepts case/whitespace variations):
	 *
	 *   [SECTION 0]
	 *   KEEP
	 *
	 *   [SECTION 1]
	 *   EDIT
	 *   ## Heading
	 *   New markdown for the section…
	 *
	 *   [SECTION 2]
	 *   DELETE
	 *
	 *   [INSERT AFTER SECTION 2]
	 *   ## New Section
	 *   …
	 *
	 * @return array{
	 *   sections:array<int,array{type:string,content?:string}>,
	 *   insert_after:array<int,array<int,string>>,
	 *   insert_before_first:array<int,string>
	 * }
	 */
	public static function parse_agent_response( string $response ): array {
		$result = array(
			'sections'            => array(),
			'insert_after'        => array(),
			'insert_before_first' => array(),
		);

		// Tokenize on [SECTION N] / [INSERT AFTER SECTION N] / [INSERT BEFORE SECTION 0].
		$pattern = '/\[\s*(SECTION|INSERT\s+AFTER\s+SECTION|INSERT\s+BEFORE\s+SECTION)\s+(-?\d+)\s*\]/i';
		$parts   = preg_split( $pattern, $response, -1, PREG_SPLIT_DELIM_CAPTURE );
		if ( count( $parts ) < 4 ) {
			// No delimiters found.
			return $result;
		}

		// $parts[0] is any preamble before the first delimiter (ignored).
		// Then triples of [type, index, body, type, index, body, ...].
		$i = 1;
		while ( $i < count( $parts ) ) {
			$type  = strtoupper( preg_replace( '/\s+/', ' ', trim( $parts[ $i ] ?? '' ) ) );
			$index = (int) ( $parts[ $i + 1 ] ?? 0 );
			$body  = (string) ( $parts[ $i + 2 ] ?? '' );

			self::apply_token( $result, $type, $index, $body );
			$i += 3;
		}

		return $result;
	}

	/**
	 * Rebuild the article by combining preserved sections with edited/inserted Markdown.
	 *
	 * @param array    $sections          From split().
	 * @param array    $actions           From parse_agent_response().
	 * @param callable $markdown_to_html  Function that converts Markdown to Gutenberg block markup.
	 * @param string   $post_title        Title of the post being updated (used to strip
	 *                                    duplicate-title headings the agent may emit at the
	 *                                    top of an EDIT). Empty string disables the fuzzy
	 *                                    check (the H1 stripping and intro-heading stripping
	 *                                    still run).
	 */
	public static function rebuild( array $sections, array $actions, callable $markdown_to_html, string $post_title = '' ): string {
		$out = '';

		// Insertions before everything.
		foreach ( $actions['insert_before_first'] ?? array() as $md ) {
			$cleaned = self::clean_edit_content( $md, false, $post_title );
			$out    .= self::trim_block_string( $markdown_to_html( $cleaned ) ) . "\n\n";
		}

		foreach ( $sections as $section ) {
			$idx    = $section['index'];
			$action = $actions['sections'][ $idx ] ?? array( 'type' => 'KEEP' );
			$type   = strtoupper( $action['type'] ?? 'KEEP' );

			if ( 'DELETE' === $type ) {
				// Skip the section entirely.
			} elseif ( 'EDIT' === $type && ! empty( $action['content'] ) ) {
				// SECTION 0 is the intro only when it has no H2 of its own —
				// when the article starts directly with an H2, section 0 is a
				// regular H2-led section and the agent should keep the H2.
				$is_intro = ( 0 === $idx ) && ( null === ( $section['heading_level'] ?? null ) );
				$cleaned  = self::clean_edit_content( (string) $action['content'], $is_intro, $post_title );
				$out     .= self::trim_block_string( $markdown_to_html( $cleaned ) ) . "\n\n";
			} else {
				// KEEP (default): serialize original blocks byte-for-byte.
				$out .= trim( serialize_blocks( $section['blocks'] ) ) . "\n\n";
			}

			// Insertions after this section.
			foreach ( $actions['insert_after'][ $idx ] ?? array() as $md ) {
				$cleaned = self::clean_edit_content( $md, false, $post_title );
				$out    .= self::trim_block_string( $markdown_to_html( $cleaned ) ) . "\n\n";
			}
		}

		return trim( $out );
	}

	/**
	 * Strip headings the agent should not have emitted at the top of an EDIT:
	 *
	 *   1. Any leading H1 (`# …`) — articles never carry H1 in the body
	 *      because WordPress renders the post title as <h1> of the page.
	 *   2. The leading H2 (`## …`) when this section is the intro (section
	 *      0 with no original H2). The intro is just paragraphs.
	 *   3. The leading H2 when it fuzzy-matches the post title — the agent
	 *      sometimes appends "in 2026" or similar and recreates the title
	 *      inside the body, producing the double-title bug.
	 *
	 * Subheadings deeper than H2 are left alone — only the FIRST heading line
	 * is considered, so the body's normal H2/H3 structure is preserved.
	 */
	private static function clean_edit_content( string $content, bool $is_intro, string $post_title ): string {
		$content = trim( $content );
		if ( '' === $content ) {
			return $content;
		}

		$lines = preg_split( "/\r\n|\r|\n/", $content );
		while ( ! empty( $lines ) && '' === trim( (string) $lines[0] ) ) {
			array_shift( $lines );
		}
		if ( empty( $lines ) ) {
			return '';
		}

		$first = trim( (string) $lines[0] );

		// Case 1: leading H1 — always strip.
		if ( preg_match( '/^#\s+\S/u', $first ) && ! preg_match( '/^##/u', $first ) ) {
			array_shift( $lines );
			return self::ltrim_blank_lines( $lines );
		}

		// Case 2 & 3: leading H2 — strip when intro section or when it
		// duplicates the post title.
		if ( preg_match( '/^##\s+(.+)$/u', $first, $m ) ) {
			$heading_text = trim( $m[1] );
			if ( $is_intro || self::looks_like_post_title( $heading_text, $post_title ) ) {
				array_shift( $lines );
				return self::ltrim_blank_lines( $lines );
			}
		}

		return implode( "\n", $lines );
	}

	private static function ltrim_blank_lines( array $lines ): string {
		while ( ! empty( $lines ) && '' === trim( (string) $lines[0] ) ) {
			array_shift( $lines );
		}
		return implode( "\n", $lines );
	}

	/**
	 * Fuzzy comparison between a heading and the post title. Returns true when
	 * the heading is essentially the same string (case/punctuation/whitespace
	 * insensitive), or when one contains the other after normalization (so
	 * "Original Title" matches "Original Title in 2026").
	 */
	private static function looks_like_post_title( string $heading, string $post_title ): bool {
		$heading    = trim( $heading );
		$post_title = trim( $post_title );
		if ( '' === $heading || '' === $post_title ) {
			return false;
		}

		$normalize = static function ( string $s ): string {
			$s = mb_strtolower( $s, 'UTF-8' );
			// Strip Markdown emphasis (* _ ~) and punctuation/symbols, collapse spaces.
			$s = preg_replace( '/[\p{P}\p{S}]+/u', ' ', $s );
			$s = preg_replace( '/\s+/u', ' ', $s );
			return trim( (string) $s );
		};

		$h = $normalize( $heading );
		$t = $normalize( $post_title );
		if ( '' === $h || '' === $t ) {
			return false;
		}
		if ( $h === $t ) {
			return true;
		}
		// One contains the other — covers "title + em 2026" and similar.
		if ( false !== mb_strpos( $h, $t ) || false !== mb_strpos( $t, $h ) ) {
			return true;
		}
		// Last resort: high textual overlap (e.g. minor paraphrase).
		$percent = 0.0;
		similar_text( $h, $t, $percent );
		return $percent >= 80.0;
	}

	// ── Internal helpers ──────────────────────────────────────────────────────

	private static function apply_token( array &$result, string $type, int $index, string $body ): void {
		$body = trim( $body );

		if ( 'SECTION' === $type ) {
			$action = self::extract_action_and_content( $body );
			if ( $action ) {
				$result['sections'][ $index ] = $action;
			}
			return;
		}

		if ( 'INSERT AFTER SECTION' === $type ) {
			$body = self::strip_optional_action_label( $body );
			if ( '' !== $body ) {
				$result['insert_after'][ $index ][] = $body;
			}
			return;
		}

		if ( 'INSERT BEFORE SECTION' === $type ) {
			// Only "before section 0" is meaningful — anything else we treat as insert before 0.
			$body = self::strip_optional_action_label( $body );
			if ( '' !== $body ) {
				$result['insert_before_first'][] = $body;
			}
		}
	}

	/**
	 * From a section body like "EDIT\n## H2\nMarkdown..." extract the action
	 * verb and the remaining content. Tolerates "EDIT:" and stray whitespace.
	 *
	 * @return array{type:string,content?:string}|null
	 */
	private static function extract_action_and_content( string $body ): ?array {
		if ( '' === $body ) {
			return null;
		}
		$lines = preg_split( "/\r\n|\r|\n/", $body );
		// First non-empty line is the action verb.
		$verb_line = '';
		$rest_idx  = 0;
		foreach ( $lines as $i => $line ) {
			if ( '' === trim( $line ) ) {
				continue;
			}
			$verb_line = trim( $line );
			$rest_idx  = $i + 1;
			break;
		}
		if ( '' === $verb_line ) {
			return null;
		}

		// Accept "EDIT", "EDIT:", "Edit:", "**EDIT**", etc.
		$verb_clean = strtoupper( preg_replace( '/[^A-Za-z]/', '', $verb_line ) );

		if ( 'KEEP' === $verb_clean ) {
			return array( 'type' => 'KEEP' );
		}
		if ( 'DELETE' === $verb_clean ) {
			return array( 'type' => 'DELETE' );
		}
		if ( 'EDIT' === $verb_clean ) {
			$content = trim( implode( "\n", array_slice( $lines, $rest_idx ) ) );
			if ( '' === $content ) {
				// EDIT with no content → treat as KEEP (defensive).
				return array( 'type' => 'KEEP' );
			}
			// Defensive: if content looks like an editorial instruction (a
			// parenthetical note or implausibly short), treat as KEEP rather
			// than publishing the instruction text as article content.
			if ( strlen( $content ) < 80 || preg_match( '/^\s*\(.*\)\s*$/s', $content ) ) {
				return array( 'type' => 'KEEP' );
			}
			return array( 'type' => 'EDIT', 'content' => $content );
		}

		// Unknown verb → treat as KEEP.
		return array( 'type' => 'KEEP' );
	}

	/**
	 * For INSERT blocks the agent may emit an optional "EDIT" line before the
	 * content; strip it so we get just the Markdown body.
	 */
	private static function strip_optional_action_label( string $body ): string {
		$lines = preg_split( "/\r\n|\r|\n/", $body );
		if ( empty( $lines ) ) {
			return '';
		}
		$first = strtoupper( trim( $lines[0] ) );
		$first = preg_replace( '/[^A-Z]/', '', (string) $first );
		if ( in_array( $first, array( 'EDIT', 'INSERT', 'NEW' ), true ) ) {
			array_shift( $lines );
		}
		return trim( implode( "\n", $lines ) );
	}

	private static function is_h2( array $block ): bool {
		if ( 'core/heading' !== ( $block['blockName'] ?? '' ) ) {
			return false;
		}
		$level = (int) ( $block['attrs']['level'] ?? 2 );
		// Gutenberg default for core/heading is level 2 (no attr emitted).
		return 2 === $level;
	}

	private static function heading_text( array $block ): string {
		$html = (string) ( $block['innerHTML'] ?? '' );
		$text = wp_strip_all_tags( $html );
		$text = trim( html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ) );
		return '' !== $text ? $text : 'SEM TÍTULO';
	}

	/**
	 * Convert an array of Gutenberg blocks into agent-friendly Markdown.
	 * Used both for prompt rendering and for has_preserved detection.
	 */
	private static function blocks_to_markdown( array $blocks ): string {
		$out = '';
		foreach ( $blocks as $block ) {
			$out .= self::block_to_markdown( $block ) . "\n\n";
		}
		return trim( $out );
	}

	private static function block_to_markdown( array $block ): string {
		$name  = (string) ( $block['blockName'] ?? '' );
		$html  = (string) ( $block['innerHTML'] ?? '' );
		$inner = trim( $html );

		switch ( $name ) {
			case 'core/heading':
				$level = max( 2, min( 6, (int) ( $block['attrs']['level'] ?? 2 ) ) );
				$text  = self::inline_html_to_markdown( wp_strip_all_tags( $inner ) );
				return str_repeat( '#', $level ) . ' ' . trim( $text );

			case 'core/paragraph':
				$text = preg_replace( '#^\s*<p[^>]*>|</p>\s*$#i', '', $inner );
				return trim( self::inline_html_to_markdown( $text ) );

			case 'core/list':
				return self::list_to_markdown( $block );

			case 'core/list-item':
				$text = preg_replace( '#^\s*<li[^>]*>|</li>\s*$#i', '', $inner );
				return '- ' . trim( self::inline_html_to_markdown( $text ) );

			case 'core/image':
				return self::image_to_markdown( $inner );

			case 'core/quote':
				$text = preg_replace( '#^\s*<blockquote[^>]*>|</blockquote>\s*$#i', '', $inner );
				$text = self::inline_html_to_markdown( trim( $text ) );
				$text = preg_replace( '/^(.+)$/m', '> $1', trim( $text ) );
				return $text;

			case 'core/separator':
				return '---';

			case '':
				// Whitespace-only block.
				return '';

			default:
				// core/html, core/embed, core/audio, core/video, core/gallery,
				// core/group, core/columns, and anything custom — keep them as
				// a placeholder so the agent doesn't try to rewrite them.
				return self::PRESERVED_PLACEHOLDER;
		}
	}

	private static function list_to_markdown( array $block ): string {
		$ordered = ! empty( $block['attrs']['ordered'] );
		$items   = $block['innerBlocks'] ?? array();
		if ( empty( $items ) ) {
			// Some lists store items directly in innerHTML.
			$html = (string) ( $block['innerHTML'] ?? '' );
			preg_match_all( '#<li[^>]*>(.*?)</li>#is', $html, $m );
			$lines = array();
			foreach ( $m[1] as $li_html ) {
				$lines[] = self::inline_html_to_markdown( trim( $li_html ) );
			}
		} else {
			$lines = array();
			foreach ( $items as $item ) {
				$html = (string) ( $item['innerHTML'] ?? '' );
				$html = preg_replace( '#^\s*<li[^>]*>|</li>\s*$#i', '', $html );
				$lines[] = self::inline_html_to_markdown( trim( $html ) );
			}
		}

		$out = '';
		foreach ( $lines as $i => $line ) {
			$marker = $ordered ? ( ( $i + 1 ) . '.' ) : '-';
			$out   .= $marker . ' ' . $line . "\n";
		}
		return rtrim( $out );
	}

	private static function image_to_markdown( string $inner_html ): string {
		preg_match( '/<img[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $src_m );
		preg_match( '/<img[^>]*\balt=["\']([^"\']*)["\'][^>]*>/i', $inner_html, $alt_m );
		$src = $src_m[1] ?? '';
		$alt = $alt_m[1] ?? '';
		if ( '' === $src ) {
			return '';
		}
		return '![' . $alt . '](' . $src . ')';
	}

	/**
	 * Convert basic inline HTML to Markdown so the agent receives clean text.
	 * Intentionally conservative — anything we don't recognize is stripped.
	 */
	private static function inline_html_to_markdown( string $html ): string {
		// Links first (must come before bold/italic since href may contain markup chars).
		$html = preg_replace_callback(
			'#<a\b[^>]*\bhref=["\']([^"\']+)["\'][^>]*>(.*?)</a>#is',
			static function ( $m ) {
				$text = trim( wp_strip_all_tags( $m[2] ) );
				$url  = trim( $m[1] );
				if ( '' === $text || '' === $url ) {
					return $text;
				}
				return '[' . $text . '](' . $url . ')';
			},
			$html
		);

		$html = preg_replace( '#<(strong|b)\b[^>]*>(.*?)</\1>#is', '**$2**', $html );
		$html = preg_replace( '#<(em|i)\b[^>]*>(.*?)</\1>#is',     '*$2*',   $html );
		$html = preg_replace( '#<br\s*/?>#i', "\n", $html );
		$html = wp_strip_all_tags( $html );
		$html = html_entity_decode( $html, ENT_QUOTES, 'UTF-8' );
		return trim( $html );
	}

	/**
	 * markdown_to_html callbacks return content that may have leading/trailing
	 * whitespace — normalize so the final document has tidy spacing.
	 */
	private static function trim_block_string( string $block_html ): string {
		return trim( $block_html );
	}
}
