<?php
/**
 * File Path: includes/import/class-mbs-docx-parser.php
 *
 * MBS_DOCX_Parser
 *
 * Stateless .docx prose extractor. Opens word/document.xml via ZipArchive
 * and SimpleXML, producing raw HTML for MBS_Look_Inside_Sanitizer.
 *
 * @package    My_Book_Showroom
 * @subpackage Import
 * @since      4.20
 */

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

/**
 * Class MBS_DOCX_Parser
 *
 * Parse a .docx file and extract prose content as sanitizable HTML.
 *
 * @since 4.20
 */
class MBS_DOCX_Parser { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/**
	 * WordprocessingML namespace URI.
	 *
	 * @var string
	 */
	const WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';

	/**
	 * XML namespace URI (xml:space attribute).
	 *
	 * @var string
	 */
	const XML_NS = 'http://www.w3.org/XML/1998/namespace';

	/**
	 * Parse a .docx file and extract prose content as HTML.
	 *
	 * Two stopping rules apply -- whichever fires first:
	 *   1. Word limit ($word_limit) is reached.
	 *   2. A second chapter-level heading is encountered (end of Chapter 1).
	 *
	 * Front matter (title page, copyright, dedication, etc.) is skipped by
	 * locating the first chapter-like heading before extraction begins.
	 * Falls back to paragraph 0 when no chapter heading is found.
	 *
	 * Duplicate chapter labels are suppressed: when a DOCX has Heading1
	 * "Chapter 1" immediately followed by a Normal paragraph that starts with
	 * "Chapter 1 ...", the redundant prefix is stripped from the body paragraph.
	 *
	 * @param  string $file_path  Absolute path to .docx file on disk.
	 * @param  int    $word_limit Maximum words to extract before truncating. 0 = no limit.
	 * @return array {
	 *   @type bool   $ok         False on failure.
	 *   @type string $error      Error message when ok is false; empty string on success.
	 *   @type string $html       Raw extracted HTML. Pass through MBS_Look_Inside_Sanitizer.
	 *   @type int    $word_count Word count of extracted content.
	 *   @type bool   $truncated  True if word_limit was reached during extraction.
	 * }
	 */
	public function parse( string $file_path, int $word_limit = MBS_LOOK_INSIDE_WORD_LIMIT ): array {
		$fail = static function ( string $msg ): array {
			return [
				'ok'         => false,
				'error'      => $msg,
				'html'       => '',
				'word_count' => 0,
				'truncated'  => false,
			];
		};

		if ( ! file_exists( $file_path ) ) {
			return $fail( 'File not found.' );
		}

		$zip = new ZipArchive();
		if ( true !== $zip->open( $file_path ) ) {
			return $fail( 'Could not open .docx archive.' );
		}

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

		if ( false === $xml_raw || '' === $xml_raw ) {
			return $fail( 'word/document.xml not found or empty in .docx archive.' );
		}

		libxml_use_internal_errors( true );
		// SA-M1: LIBXML_NONET | LIBXML_NOENT prevent XXE injection.
		$xml = simplexml_load_string( $xml_raw, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT );
		libxml_clear_errors();

		if ( ! $xml instanceof SimpleXMLElement ) {
			return $fail( 'Could not parse word/document.xml.' );
		}

		// Register namespace BEFORE any XPath call.
		$xml->registerXPathNamespace( 'w', self::WORD_NS );

		$paragraphs = $xml->xpath( '//w:body/w:p' );
		if ( false === $paragraphs || ! is_array( $paragraphs ) ) {
			return $fail( 'No paragraphs found in document.' );
		}

		// Find the first chapter-like heading; all paragraphs before it are skipped.
		$start_idx = $this->find_chapter_start( $paragraphs );

		// Chapter-pattern regexes reused here for the Chapter 2+ stop gate.
		$chapter_pat  = '/^\s*(chapter|prologue|epilogue|part\b|book\b)\s*\w*/iu';
		$bare_num_pat = '/^\s*(one|two|three|four|five|six|seven|eight|nine|ten|\d\d?\d?\.?)\s*$/iu';

		$html              = '';
		$word_count        = 0;
		$truncated         = false;
		$last_heading_text = ''; // Used for duplicate-prefix suppression.

		foreach ( $paragraphs as $idx => $para ) {
			if ( $idx < $start_idx ) {
				continue;
			}
			if ( $truncated ) {
				break;
			}

			$tag = $this->resolve_paragraph_tag( $para );

			// Stop at the next chapter-level heading after the first.
			// $start_idx is Chapter 1's heading; any match beyond it is Chapter 2+.
			if ( $idx > $start_idx && ( 'h2' === $tag || 'h3' === $tag ) ) {
				$text = trim( $this->get_para_text( $para ) );
				if ( preg_match( $chapter_pat, $text ) || preg_match( $bare_num_pat, $text ) ) {
					break;
				}
			}

			$para_html = $this->extract_paragraph_html( $para, $word_limit, $word_count, $truncated );

			if ( '' === $para_html ) {
				// Empty heading resets the duplicate-prefix tracker.
				if ( 'p' !== $tag ) {
					$last_heading_text = '';
				}
				continue;
			}

			// Strip duplicate chapter-label prefix from the first body paragraph
			// after a heading (e.g. "Chapter 1 Faith Green..." -> "Faith Green...").
			if ( 'p' === $tag && '' !== $last_heading_text ) {
				$prefix = esc_html( $last_heading_text );
				if ( 0 === stripos( $para_html, $prefix ) ) {
					$para_html = ltrim( substr( $para_html, strlen( $prefix ) ) );
				}
				$last_heading_text = '';
			}

			// Track heading text so the immediately following body paragraph
			// can have its redundant prefix stripped.
			if ( 'p' !== $tag ) {
				$last_heading_text = trim( $this->get_para_text( $para ) );
			} else {
				$last_heading_text = '';
			}

			if ( '' !== $para_html ) {
				$html .= '<' . $tag . '>' . $para_html . '</' . $tag . '>' . "\n";
			}
		}

		return [
			'ok'         => true,
			'error'      => '',
			'html'       => $html,
			'word_count' => $word_count,
			'truncated'  => $truncated,
		];
	}

	// -------------------------------------------------------------------------
	// Front-matter detection
	// -------------------------------------------------------------------------

	/**
	 * Find the index of the first chapter-like heading paragraph.
	 *
	 * Iterates through all paragraphs looking for the first Heading1 or
	 * Heading2 whose plain text matches a chapter / prologue / epilogue / part
	 * pattern, or a bare standalone chapter number ("1", "01", "One", etc.).
	 *
	 * Returns 0 (start from the very first paragraph) when no chapter heading
	 * is found, so books without styled headings are still fully extracted.
	 *
	 * @param  SimpleXMLElement[] $paragraphs Array of <w:p> elements from XPath.
	 * @return int                            Array index of the first chapter paragraph.
	 */
	private function find_chapter_start( array $paragraphs ): int {
		$chapter_pat  = '/^\s*(chapter|prologue|epilogue|part\b|book\b)\s*\w*/iu';
		$bare_num_pat = '/^\s*(one|two|three|four|five|six|seven|eight|nine|ten|\d\d?\d?\.?)\s*$/iu';

		foreach ( $paragraphs as $idx => $para ) {
			$style = $this->get_para_style( $para );
			if ( 'Heading1' !== $style && 'Heading2' !== $style ) {
				continue;
			}
			$text = trim( $this->get_para_text( $para ) );
			if ( '' === $text ) {
				continue;
			}
			if ( preg_match( $chapter_pat, $text ) || preg_match( $bare_num_pat, $text ) ) {
				return $idx;
			}
		}

		return 0; // Fallback: extract from the very first paragraph.
	}

	// -------------------------------------------------------------------------
	// Paragraph helpers
	// -------------------------------------------------------------------------

	/**
	 * Return the pStyle value for a paragraph, or '' if unstyled.
	 *
	 * Single source of truth for style reading -- used by find_chapter_start()
	 * and resolve_paragraph_tag().
	 *
	 * @param  SimpleXMLElement $para A <w:p> element.
	 * @return string pStyle value, e.g. 'Heading1', 'Normal', or ''.
	 */
	private function get_para_style( SimpleXMLElement $para ): string {
		foreach ( $para->children( self::WORD_NS ) as $name => $node ) {
			if ( 'pPr' !== $name ) {
				continue;
			}
			foreach ( $node->children( self::WORD_NS ) as $ppr_name => $ppr_node ) {
				if ( 'pStyle' !== $ppr_name ) {
					continue;
				}
				return (string) $ppr_node->attributes( self::WORD_NS )['val'];
			}
			break;
		}
		return '';
	}

	/**
	 * Extract plain text from a paragraph (for heading detection only).
	 *
	 * Concatenates all <w:t> run text without bold/italic wrapping.
	 * Not for HTML output -- use extract_paragraph_html() for that.
	 *
	 * @param  SimpleXMLElement $para A <w:p> element.
	 * @return string Plain text content of the paragraph.
	 */
	private function get_para_text( SimpleXMLElement $para ): string {
		$text = '';
		foreach ( $para->children( self::WORD_NS ) as $local_name => $child ) {
			if ( 'r' !== $local_name ) {
				continue;
			}
			foreach ( $child->children( self::WORD_NS ) as $name => $node ) {
				if ( 't' === $name ) {
					$text .= (string) $node;
				}
			}
		}
		return $text;
	}

	/**
	 * Resolve the HTML tag name for a paragraph based on its pStyle value.
	 *
	 * Delegates to get_para_style() so style-reading logic exists in one place.
	 *
	 * @param  SimpleXMLElement $para A <w:p> element.
	 * @return string HTML tag name: 'h2', 'h3', 'h4', or 'p'.
	 */
	private function resolve_paragraph_tag( SimpleXMLElement $para ): string {
		switch ( $this->get_para_style( $para ) ) {
			case 'Heading1':
				return 'h2';
			case 'Heading2':
				return 'h3';
			case 'Heading3':
				return 'h4';
			default:
				return 'p';
		}
	}

	/**
	 * Extract inline HTML for a single paragraph's runs and hyperlinks.
	 *
	 * @param  SimpleXMLElement $para       A <w:p> element.
	 * @param  int              $word_limit Maximum word count (0 = no limit).
	 * @param  int             &$word_count Running word count (passed by reference).
	 * @param  bool            &$truncated  Truncation flag (passed by reference).
	 * @return string Inline HTML for the paragraph content.
	 */
	private function extract_paragraph_html(
		SimpleXMLElement $para,
		int $word_limit,
		int &$word_count,
		bool &$truncated
	): string {
		$out = '';

		foreach ( $para->children( self::WORD_NS ) as $local_name => $child ) {
			if ( $truncated ) {
				break;
			}

			if ( 'r' === $local_name ) {
				$out .= $this->extract_run_html( $child, $word_limit, $word_count, $truncated );
			} elseif ( 'hyperlink' === $local_name ) {
				foreach ( $child->children( self::WORD_NS ) as $hyp_name => $hyp_child ) {
					if ( $truncated ) {
						break;
					}
					if ( 'r' === $hyp_name ) {
						$out .= $this->extract_run_html( $hyp_child, $word_limit, $word_count, $truncated );
					}
				}
			}
		}

		return $out;
	}

	/**
	 * Extract inline HTML for a single <w:r> run element.
	 *
	 * @param  SimpleXMLElement $run        A <w:r> element.
	 * @param  int              $word_limit Maximum word count (0 = no limit).
	 * @param  int             &$word_count Running word count (passed by reference).
	 * @param  bool            &$truncated  Truncation flag (passed by reference).
	 * @return string Inline HTML (possibly wrapped in <strong> and/or <em>).
	 */
	private function extract_run_html(
		SimpleXMLElement $run,
		int $word_limit,
		int &$word_count,
		bool &$truncated
	): string {
		$text   = '';
		$bold   = false;
		$italic = false;

		foreach ( $run->children( self::WORD_NS ) as $name => $node ) {
			if ( 'rPr' === $name ) {
				foreach ( $node->children( self::WORD_NS ) as $rpr_name => $rpr_child ) {
					if ( 'b' === $rpr_name ) {
						$bold = true;
					} elseif ( 'i' === $rpr_name ) {
						$italic = true;
					}
				}
			} elseif ( 't' === $name ) {
				$raw_text = (string) $node;

				if ( '' !== $raw_text ) {
					$word_count += str_word_count( $raw_text );

					if ( $word_limit > 0 && $word_count >= $word_limit ) {
						$truncated = true;
					}
				}

				$xml_attrs = $node->attributes( self::XML_NS );
				$space_val = isset( $xml_attrs['space'] ) ? (string) $xml_attrs['space'] : '';

				if ( 'preserve' === $space_val ) {
					$text .= $raw_text;
				} else {
					if ( '' !== $raw_text && ' ' !== substr( $raw_text, -1 ) ) {
						$text .= $raw_text . ' ';
					} else {
						$text .= $raw_text;
					}
				}
			}
		}

		if ( '' === $text ) {
			return '';
		}

		$escaped = esc_html( $text );

		if ( $bold && $italic ) {
			return '<strong><em>' . $escaped . '</em></strong>';
		} elseif ( $bold ) {
			return '<strong>' . $escaped . '</strong>';
		} elseif ( $italic ) {
			return '<em>' . $escaped . '</em>';
		}

		return $escaped;
	}
}
