<?php

namespace Limb_Chatbot\Includes\Services\Knowledge\Content_Extractors;

use Limb_Chatbot\Includes\Data_Objects\File;
use Limb_Chatbot\Includes\Exceptions\Error_Codes;
use Limb_Chatbot\Includes\Exceptions\Exception;
use Limb_Chatbot\Includes\Services\Helper;

/**
 * Document Content Extractor for text-based document files.
 *
 * Extracts and normalizes content from:
 * - .txt  (text/plain)
 * - .doc  (application/msword)
 * - .docx (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
 *
 * Each format produces HTML paragraphs ready for the heading-aware chunker,
 * matching the output contract of PDF_Content_Extractor.
 *
 * @since 1.2.0
 */
class Document_Content_Extractor {

	/**
	 * MIME types this extractor can handle.
	 */
	const SUPPORTED_MIME_TYPES = [
		'text/plain',
		'application/msword',
		'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
	];

	/**
	 * Check whether this extractor supports a given MIME type.
	 *
	 * @param  string  $mime_type  MIME type to check.
	 *
	 * @return bool
	 * @since 1.2.0
	 */
	public static function supports( string $mime_type ): bool {
		return in_array( $mime_type, self::SUPPORTED_MIME_TYPES, true );
	}

	/**
	 * Extract and normalize content from a document file.
	 *
	 * @param  File  $file  The File data object.
	 *
	 * @return string Normalized HTML content ready for chunking.
	 * @throws Exception If the file cannot be read or parsed.
	 * @since 1.2.0
	 */
	public function extract( File $file ): string {
		$mime_type = $file->get_mime_type();

		if ( ! self::supports( $mime_type ) ) {
			throw new Exception(
				Error_Codes::VALIDATION_INVALID_VALUE,
				sprintf(
					__( 'Unsupported document MIME type: %s', 'limb-chatbot' ),
					$mime_type
				)
			);
		}

		$file_path = Helper::get_wp_uploaded_file_dir( $file->get_file_path() );

		if ( ! $file_path || ! file_exists( $file_path ) ) {
			throw new Exception(
				Error_Codes::FILE_NOT_FOUND,
				sprintf(
					__( 'Document file not found at path: %s', 'limb-chatbot' ),
					$file_path ?? 'unknown'
				)
			);
		}

		if ( ! is_readable( $file_path ) ) {
			throw new Exception(
				Error_Codes::FILE_NOT_FOUND,
				sprintf(
					__( 'Document file is not readable: %s', 'limb-chatbot' ),
					$file_path
				)
			);
		}

		switch ( $mime_type ) {
			case 'text/plain':
				$text = $this->extract_from_txt( $file_path );
				break;

			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
				$text = $this->extract_from_docx( $file_path );
				break;

			case 'application/msword':
				$text = $this->extract_from_doc( $file_path );
				break;

			default:
				throw new Exception(
					Error_Codes::VALIDATION_INVALID_VALUE,
					sprintf( __( 'Unsupported document MIME type: %s', 'limb-chatbot' ), $mime_type )
				);
		}

		if ( empty( trim( $text ) ) ) {
			throw new Exception(
				Error_Codes::EMPTY_VALUE,
				__( 'No text content found in document', 'limb-chatbot' )
			);
		}

		return $this->normalize( $text );
	}

	/**
	 * Extract plain text from a .txt file.
	 *
	 * @param  string  $file_path  Absolute path to the file.
	 *
	 * @return string Raw text content.
	 * @throws Exception If the file cannot be read.
	 * @since 1.2.0
	 */
	protected function extract_from_txt( string $file_path ): string {
		$content = file_get_contents( $file_path );

		if ( $content === false ) {
			throw new Exception(
				Error_Codes::TECHNICAL_ERROR,
				__( 'Failed to read text file', 'limb-chatbot' )
			);
		}

		$encoding = mb_detect_encoding( $content, [ 'UTF-8', 'ISO-8859-1', 'Windows-1252', 'ASCII' ], true );
		if ( $encoding && $encoding !== 'UTF-8' ) {
			$content = mb_convert_encoding( $content, 'UTF-8', $encoding );
		}

		return $content;
	}

	/**
	 * Extract text from a .docx file using ZipArchive.
	 *
	 * DOCX files are ZIP archives containing XML. The main body lives
	 * in word/document.xml. We parse the XML tree to preserve paragraph
	 * boundaries and basic structure.
	 *
	 * @param  string  $file_path  Absolute path to the .docx file.
	 *
	 * @return string Extracted text with paragraph boundaries.
	 * @throws Exception If the archive cannot be opened or parsed.
	 * @since 1.2.0
	 */
	protected function extract_from_docx( string $file_path ): string {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new Exception(
				Error_Codes::TECHNICAL_ERROR,
				__( 'ZipArchive PHP extension is required to process .docx files', 'limb-chatbot' )
			);
		}

		$zip = new \ZipArchive();
		$result = $zip->open( $file_path );

		if ( $result !== true ) {
			throw new Exception(
				Error_Codes::TECHNICAL_ERROR,
				sprintf( __( 'Failed to open .docx file (error code: %d)', 'limb-chatbot' ), $result )
			);
		}

		$xml_content = $zip->getFromName( 'word/document.xml' );
		$zip->close();

		if ( $xml_content === false ) {
			throw new Exception(
				Error_Codes::TECHNICAL_ERROR,
				__( 'Failed to read document content from .docx archive', 'limb-chatbot' )
			);
		}

		return $this->parse_docx_xml( $xml_content );
	}

	/**
	 * Parse the word/document.xml content from a DOCX file.
	 *
	 * Walks the XML tree to extract text while preserving paragraph
	 * boundaries, heading styles, list items, and table cells.
	 *
	 * @param  string  $xml_content  Raw XML from word/document.xml.
	 *
	 * @return string Extracted text with structural markers.
	 * @throws Exception If XML parsing fails.
	 * @since 1.2.0
	 */
	protected function parse_docx_xml( string $xml_content ): string {
		libxml_use_internal_errors( true );
		$dom = new \DOMDocument();

		if ( ! $dom->loadXML( $xml_content ) ) {
			libxml_clear_errors();
			throw new Exception(
				Error_Codes::TECHNICAL_ERROR,
				__( 'Failed to parse .docx XML content', 'limb-chatbot' )
			);
		}
		libxml_clear_errors();

		$paragraphs = [];
		$body = $dom->getElementsByTagNameNS(
			'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
			'body'
		);

		if ( $body->length === 0 ) {
			return '';
		}

		$body_node = $body->item( 0 );
		$ns_w      = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';

		foreach ( $body_node->childNodes as $node ) {
			if ( $node->nodeType !== XML_ELEMENT_NODE ) {
				continue;
			}

			$local_name = $node->localName;

			if ( $local_name === 'p' ) {
				$text = $this->extract_paragraph_text( $node, $ns_w );
				if ( trim( $text ) !== '' ) {
					$style = $this->get_paragraph_style( $node, $ns_w );
					$paragraphs[] = [ 'text' => $text, 'style' => $style ];
				}
			} elseif ( $local_name === 'tbl' ) {
				$table_paragraphs = $this->extract_table_text( $node, $ns_w );
				$paragraphs = array_merge( $paragraphs, $table_paragraphs );
			}
		}

		return $this->build_text_from_paragraphs( $paragraphs );
	}

	/**
	 * Extract text from a single w:p element.
	 *
	 * @param  \DOMElement  $p_node  Paragraph node.
	 * @param  string  $ns_w  WordprocessingML namespace.
	 *
	 * @return string Combined text of all runs.
	 * @since 1.2.0
	 */
	protected function extract_paragraph_text( \DOMElement $p_node, string $ns_w ): string {
		$text = '';

		foreach ( $p_node->childNodes as $child ) {
			if ( $child->nodeType !== XML_ELEMENT_NODE ) {
				continue;
			}

			if ( $child->localName === 'r' ) {
				foreach ( $child->childNodes as $run_child ) {
					if ( $run_child->nodeType !== XML_ELEMENT_NODE ) {
						continue;
					}
					if ( $run_child->localName === 't' ) {
						$text .= $run_child->textContent;
					} elseif ( $run_child->localName === 'tab' ) {
						$text .= "\t";
					} elseif ( $run_child->localName === 'br' ) {
						$text .= "\n";
					}
				}
			} elseif ( $child->localName === 'hyperlink' ) {
				foreach ( $child->getElementsByTagNameNS( $ns_w, 'r' ) as $r_node ) {
					foreach ( $r_node->childNodes as $run_child ) {
						if ( $run_child->nodeType === XML_ELEMENT_NODE && $run_child->localName === 't' ) {
							$text .= $run_child->textContent;
						}
					}
				}
			}
		}

		return $text;
	}

	/**
	 * Get the paragraph style name (e.g. "Heading1", "ListParagraph").
	 *
	 * @param  \DOMElement  $p_node  Paragraph node.
	 * @param  string  $ns_w  WordprocessingML namespace.
	 *
	 * @return string Style name or empty string.
	 * @since 1.2.0
	 */
	protected function get_paragraph_style( \DOMElement $p_node, string $ns_w ): string {
		$pPr = $p_node->getElementsByTagNameNS( $ns_w, 'pPr' );
		if ( $pPr->length === 0 ) {
			return '';
		}

		$pStyle = $pPr->item( 0 )->getElementsByTagNameNS( $ns_w, 'pStyle' );
		if ( $pStyle->length === 0 ) {
			return '';
		}

		return $pStyle->item( 0 )->getAttributeNS( $ns_w, 'val' ) ?: '';
	}

	/**
	 * Extract text from a w:tbl (table) element.
	 *
	 * Each cell is treated as a separate paragraph to preserve content.
	 *
	 * @param  \DOMElement  $tbl_node  Table node.
	 * @param  string  $ns_w  WordprocessingML namespace.
	 *
	 * @return array Array of ['text' => ..., 'style' => ''] entries.
	 * @since 1.2.0
	 */
	protected function extract_table_text( \DOMElement $tbl_node, string $ns_w ): array {
		$rows = [];

		foreach ( $tbl_node->getElementsByTagNameNS( $ns_w, 'tr' ) as $tr_node ) {
			$cells = [];
			foreach ( $tr_node->getElementsByTagNameNS( $ns_w, 'tc' ) as $tc_node ) {
				$cell_text = '';
				foreach ( $tc_node->getElementsByTagNameNS( $ns_w, 'p' ) as $p_node ) {
					$p_text = $this->extract_paragraph_text( $p_node, $ns_w );
					if ( trim( $p_text ) !== '' ) {
						$cell_text .= ( $cell_text !== '' ? ' ' : '' ) . $p_text;
					}
				}
				if ( trim( $cell_text ) !== '' ) {
					$cells[] = trim( $cell_text );
				}
			}
			if ( ! empty( $cells ) ) {
				$rows[] = [ 'text' => implode( ' | ', $cells ), 'style' => '' ];
			}
		}

		return $rows;
	}

	/**
	 * Build plain text output from parsed paragraphs.
	 *
	 * Maps DOCX heading styles to a simple prefix so the normalizer
	 * can produce proper HTML heading tags.
	 *
	 * @param  array  $paragraphs  Parsed paragraph entries.
	 *
	 * @return string Combined text with heading markers.
	 * @since 1.2.0
	 */
	protected function build_text_from_paragraphs( array $paragraphs ): string {
		$heading_map = [
			'Heading1' => 'H1: ',
			'Heading2' => 'H2: ',
			'Heading3' => 'H3: ',
			'Heading4' => 'H4: ',
			'Heading5' => 'H5: ',
			'Heading6' => 'H6: ',
			'Title'    => 'H1: ',
			'Subtitle' => 'H2: ',
		];

		$lines = [];
		foreach ( $paragraphs as $p ) {
			$prefix = '';
			if ( ! empty( $p['style'] ) && isset( $heading_map[ $p['style'] ] ) ) {
				$prefix = $heading_map[ $p['style'] ];
			}
			$lines[] = $prefix . $p['text'];
		}

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

	/**
	 * Extract text from a legacy .doc (OLE2 Compound Document) file.
	 *
	 * Uses two complementary strategies:
	 * 1. Reads the "WordDocument" stream to locate the text portion via
	 *    the Clx/PlcPcd tables (works for most .doc files).
	 * 2. Falls back to a binary-safe regex extraction that pulls
	 *    printable ASCII/UTF-8 runs from the raw OLE data.
	 *
	 * @param  string  $file_path  Absolute path to the .doc file.
	 *
	 * @return string Extracted text content.
	 * @throws Exception If the file cannot be read.
	 * @since 1.2.0
	 */
	protected function extract_from_doc( string $file_path ): string {
		$content = file_get_contents( $file_path );

		if ( $content === false ) {
			throw new Exception(
				Error_Codes::TECHNICAL_ERROR,
				__( 'Failed to read .doc file', 'limb-chatbot' )
			);
		}

		// Strategy 1: Try to parse the OLE2 structure for clean Unicode text
		$text = $this->extract_doc_ole2_text( $content );
		if ( ! empty( trim( $text ) ) ) {
			return $text;
		}

		// Strategy 2: Fallback binary extraction
		$text = $this->extract_doc_binary_text( $content );
		if ( ! empty( trim( $text ) ) ) {
			return $text;
		}

		throw new Exception(
			Error_Codes::EMPTY_VALUE,
			__( 'Could not extract readable text from .doc file', 'limb-chatbot' )
		);
	}

	/**
	 * Parse OLE2 Compound Document Format to find the Word text stream.
	 *
	 * Locates the 0Table or 1Table stream, reads the piece table,
	 * and assembles the document text from file data and Unicode runs.
	 *
	 * @param  string  $content  Raw file bytes.
	 *
	 * @return string Extracted text or empty string on failure.
	 * @since 1.2.0
	 */
	protected function extract_doc_ole2_text( string $content ): string {
		// Verify OLE2 magic bytes
		if ( strlen( $content ) < 512 || substr( $content, 0, 8 ) !== "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" ) {
			return '';
		}

		try {
			$sector_size = 1 << unpack( 'v', substr( $content, 30, 2 ) )[1];

			$fat_sectors   = [];
			$num_fat       = unpack( 'V', substr( $content, 44, 4 ) )[1];
			for ( $i = 0; $i < min( $num_fat, 109 ); $i++ ) {
				$fat_sectors[] = unpack( 'V', substr( $content, 76 + $i * 4, 4 ) )[1];
			}

			$fat = '';
			foreach ( $fat_sectors as $fs ) {
				$offset = 512 + $fs * $sector_size;
				if ( $offset + $sector_size <= strlen( $content ) ) {
					$fat .= substr( $content, $offset, $sector_size );
				}
			}

			$dir_start = unpack( 'V', substr( $content, 48, 4 ) )[1];

			$dir_data = $this->read_ole2_stream_by_fat( $content, $fat, $dir_start, $sector_size );

			$streams = [];
			for ( $i = 0; $i < strlen( $dir_data ); $i += 128 ) {
				$entry      = substr( $dir_data, $i, 128 );
				$name_len   = unpack( 'v', substr( $entry, 64, 2 ) )[1];
				$name_raw   = substr( $entry, 0, max( 0, $name_len - 2 ) );
				$name       = '';
				for ( $c = 0; $c < strlen( $name_raw ); $c += 2 ) {
					$char = substr( $name_raw, $c, 1 );
					if ( ord( $char ) >= 32 && ord( $char ) < 127 ) {
						$name .= $char;
					}
				}

				$start_sector = unpack( 'V', substr( $entry, 116, 4 ) )[1];
				$stream_size  = unpack( 'V', substr( $entry, 120, 4 ) )[1];

				if ( ! empty( $name ) && $stream_size > 0 && $stream_size < strlen( $content ) ) {
					$streams[ $name ] = [
						'start' => $start_sector,
						'size'  => $stream_size,
					];
				}
			}

			if ( ! isset( $streams['WordDocument'] ) ) {
				return '';
			}

			$word_stream = $this->read_ole2_stream_by_fat(
				$content, $fat,
				$streams['WordDocument']['start'],
				$sector_size,
				$streams['WordDocument']['size']
			);

			if ( strlen( $word_stream ) < 12 ) {
				return '';
			}

			$flags     = unpack( 'v', substr( $word_stream, 10, 2 ) )[1];
			$is_complex = ( $flags & 0x0004 ) !== 0;

			$table_name = ( $flags & 0x0200 ) ? '1Table' : '0Table';
			if ( ! isset( $streams[ $table_name ] ) ) {
				return '';
			}

			$table_stream = $this->read_ole2_stream_by_fat(
				$content, $fat,
				$streams[ $table_name ]['start'],
				$sector_size,
				$streams[ $table_name ]['size']
			);

			$ccpText = unpack( 'V', substr( $word_stream, 76, 4 ) )[1];
			if ( $ccpText === 0 || $ccpText > 10000000 ) {
				return '';
			}

			$clx_offset = unpack( 'V', substr( $word_stream, 418, 4 ) )[1];
			$clx_size   = unpack( 'V', substr( $word_stream, 422, 4 ) )[1];

			if ( $clx_offset === 0 || $clx_offset + $clx_size > strlen( $table_stream ) ) {
				return '';
			}

			$clx = substr( $table_stream, $clx_offset, $clx_size );

			$pos = 0;
			while ( $pos < strlen( $clx ) && ord( $clx[ $pos ] ) === 0x01 ) {
				$skip = unpack( 'v', substr( $clx, $pos + 1, 2 ) )[1];
				$pos += 3 + $skip;
			}

			if ( $pos >= strlen( $clx ) || ord( $clx[ $pos ] ) !== 0x02 ) {
				return '';
			}

			$pcd_size    = unpack( 'V', substr( $clx, $pos + 1, 4 ) )[1];
			$piece_table = substr( $clx, $pos + 5, $pcd_size );

			$piece_count = ( $pcd_size - 4 ) / ( 4 + 8 );
			if ( $piece_count < 1 ) {
				return '';
			}
			$piece_count = (int) $piece_count;

			$text = '';
			for ( $i = 0; $i < $piece_count; $i++ ) {
				$cp_start = unpack( 'V', substr( $piece_table, $i * 4, 4 ) )[1];
				$cp_end   = unpack( 'V', substr( $piece_table, ( $i + 1 ) * 4, 4 ) )[1];
				$chars    = $cp_end - $cp_start;

				$pcd_offset = ( $piece_count + 1 ) * 4 + $i * 8;
				$fc_value   = unpack( 'V', substr( $piece_table, $pcd_offset + 2, 4 ) )[1];

				$is_ansi = ( $fc_value & 0x40000000 ) !== 0;
				$fc       = $fc_value & 0x3FFFFFFF;

				if ( $is_ansi ) {
					$fc = $fc >> 1;
					$raw = substr( $word_stream, $fc, $chars );
					$text .= mb_convert_encoding( $raw, 'UTF-8', 'Windows-1252' );
				} else {
					$raw = substr( $word_stream, $fc, $chars * 2 );
					$text .= mb_convert_encoding( $raw, 'UTF-8', 'UTF-16LE' );
				}
			}

			$text = preg_replace( '/[\x00-\x06\x08\x0E-\x1F]/', '', $text );
			$text = str_replace( [ "\x07", "\x0B" ], "\n", $text );
			$text = str_replace( "\r", "\n", $text );

			return $text;

		} catch ( \Exception $e ) {
			return '';
		}
	}

	/**
	 * Read a chain of sectors from an OLE2 file following the FAT.
	 *
	 * @param  string  $content  Raw file bytes.
	 * @param  string  $fat  FAT data.
	 * @param  int  $start  Starting sector index.
	 * @param  int  $sector_size  Bytes per sector.
	 * @param  int  $max_size  Maximum bytes to read (0 = unlimited).
	 *
	 * @return string Concatenated stream data.
	 * @since 1.2.0
	 */
	protected function read_ole2_stream_by_fat(
		string $content,
		string $fat,
		int $start,
		int $sector_size,
		int $max_size = 0
	): string {
		$data  = '';
		$sector = $start;
		$limit  = 10000;

		while ( $sector !== 0xFFFFFFFE && $sector !== 0xFFFFFFFF && $limit-- > 0 ) {
			$offset = 512 + $sector * $sector_size;
			if ( $offset + $sector_size > strlen( $content ) ) {
				break;
			}
			$data .= substr( $content, $offset, $sector_size );

			if ( $max_size > 0 && strlen( $data ) >= $max_size ) {
				$data = substr( $data, 0, $max_size );
				break;
			}

			$fat_entry_offset = $sector * 4;
			if ( $fat_entry_offset + 4 > strlen( $fat ) ) {
				break;
			}
			$sector = unpack( 'V', substr( $fat, $fat_entry_offset, 4 ) )[1];
		}

		return $data;
	}

	/**
	 * Fallback binary text extraction for .doc files.
	 *
	 * Scans the raw binary for runs of printable characters,
	 * filtering out short noise fragments.
	 *
	 * @param  string  $content  Raw file bytes.
	 *
	 * @return string Extracted text.
	 * @since 1.2.0
	 */
	protected function extract_doc_binary_text( string $content ): string {
		$text = '';

		// Try UTF-16LE extraction first (Unicode .doc files)
		if ( preg_match_all( '/((?:[\x20-\x7E\xA0-\xFF]\x00){4,})/', $content, $matches ) ) {
			foreach ( $matches[1] as $match ) {
				$decoded = mb_convert_encoding( $match, 'UTF-8', 'UTF-16LE' );
				$decoded = trim( $decoded );
				if ( strlen( $decoded ) >= 4 ) {
					$text .= $decoded . "\n";
				}
			}
		}

		if ( ! empty( trim( $text ) ) ) {
			$text = $this->clean_extracted_binary_text( $text );
			if ( ! empty( trim( $text ) ) ) {
				return $text;
			}
		}

		// Fallback: ASCII extraction
		$text = '';
		if ( preg_match_all( '/([\x20-\x7E\r\n\t]{10,})/', $content, $matches ) ) {
			foreach ( $matches[1] as $match ) {
				$match = trim( $match );
				if ( strlen( $match ) >= 10 ) {
					$text .= $match . "\n";
				}
			}
		}

		return $this->clean_extracted_binary_text( $text );
	}

	/**
	 * Clean up text from binary extraction to remove metadata noise.
	 *
	 * @param  string  $text  Raw extracted text.
	 *
	 * @return string Cleaned text.
	 * @since 1.2.0
	 */
	protected function clean_extracted_binary_text( string $text ): string {
		// Remove common OLE2 metadata / internal markers
		$noise_patterns = [
			'/^(Microsoft\s+(?:Office\s+)?Word|Normal\.dot[mx]?|Times\s+New\s+Roman|Heading\s*\d).*$/mi',
			'/^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}$/mi',
			'/^\s*PK\x03\x04.*$/m',
		];

		foreach ( $noise_patterns as $pattern ) {
			$text = preg_replace( $pattern, '', $text );
		}

		// Collapse excessive blank lines
		$text = preg_replace( '/\n{3,}/', "\n\n", $text );

		return trim( $text );
	}

	/**
	 * Normalize extracted text to HTML paragraphs for chunking.
	 *
	 * Converts plain text (with optional heading markers from DOCX parsing)
	 * to simple HTML, matching the output format of PDF_Content_Extractor.
	 *
	 * @param  string  $text  Raw extracted text.
	 *
	 * @return string HTML content with paragraphs and optional headings.
	 * @since 1.2.0
	 */
	protected function normalize( string $text ): string {
		$text = trim( $text );

		// Normalize line endings
		$text = preg_replace( '/\r\n|\r/', "\n", $text );

		$lines       = explode( "\n", $text );
		$html_blocks = [];

		$heading_patterns = [
			'/^H1:\s*/' => 'h1',
			'/^H2:\s*/' => 'h2',
			'/^H3:\s*/' => 'h3',
			'/^H4:\s*/' => 'h4',
			'/^H5:\s*/' => 'h5',
			'/^H6:\s*/' => 'h6',
		];

		foreach ( $lines as $line ) {
			$line = trim( $line );
			if ( $line === '' ) {
				continue;
			}

			$tag = 'p';
			foreach ( $heading_patterns as $pattern => $heading_tag ) {
				if ( preg_match( $pattern, $line ) ) {
					$line = preg_replace( $pattern, '', $line );
					$tag  = $heading_tag;
					break;
				}
			}

			$escaped       = htmlspecialchars( $line, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
			$html_blocks[] = '<' . $tag . '>' . $escaped . '</' . $tag . '>';
		}

		$html = implode( "\n", $html_blocks );

		// Clean up empty tags and excessive whitespace
		$html = preg_replace( '/<p>\s*<\/p>/', '', $html );
		$html = preg_replace( '/\n{3,}/', "\n\n", $html );

		return trim( $html );
	}
}
