<?php
/**
 * File Path: includes/helpers/class-mbs-look-inside-sanitizer.php
 *
 * Look Inside HTML Sanitizer
 *
 * Static utility class. Cleans and normalises extracted or pasted Look Inside
 * HTML from any source (DOCX parser, EPUB parser, wp_editor save path).
 * Single source of truth for the canonical allowlist and word-limit rule.
 *
 * @package MyBookShowroom
 * @since   1.2.0
 */

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

/**
 * Class MBS_Look_Inside_Sanitizer
 *
 * All methods are static. No constructor. No singleton.
 *
 * @since 1.2.0
 */
class MBS_Look_Inside_Sanitizer { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/**
	 * Clean extracted or pasted Look Inside HTML.
	 * Runs all sanitization passes in sequence.
	 *
	 * @since  1.2.0
	 * @param  string $html            Raw HTML from any source.
	 * @param  bool   $merge_fragments Whether to run merge_paragraph_fragments().
	 *                                 Pass true for EPUB/DOCX; false for wp_editor saves.
	 * @param  int    $word_limit      Truncate output at this many words. 0 = no limit.
	 *                                 Pass MBS_LOOK_INSIDE_WORD_LIMIT for wp_editor saves.
	 *                                 DOCX/EPUB already truncate during extraction; pass 0.
	 * @return string Clean HTML safe for wp_kses_post() storage.
	 */
	public static function clean( string $html, bool $merge_fragments = false, int $word_limit = 0 ): string {
		if ( '' === trim( $html ) ) {
			return '';
		}

		// Step 1: Extract body content -- strip DOCTYPE, <html>, <head>, <body> wrappers.
		$html = self::extract_body( $html );

		// Step 2: Remove internal navigation anchors.
		// MUST run before step 3, which strips id attributes -- after step 3 removes id,
		// <a id="..."> elements become bare <a> tags indistinguishable from other anchors.

		// 2a: <sup> containing <a> -- strip entire <sup> including content (footnote refs).
		$html = preg_replace( '/<sup[^>]*>.*?<\/sup>/is', '', $html );

		// 2b: <a id="..."> with no href attribute -- strip tag and content entirely.
		$html = preg_replace( '/<a\\s(?![^>]*href)[^>]*id=[^>]*>.*?<\/a>/is', '', $html );

		// 2c: <a href="#..."> (hash-only links) -- strip tag, keep text content.
		$html = preg_replace( '/<a\\s[^>]*href="#[^"]*"[^>]*>(.*?)<\/a>/is', '$1', $html );
		$html = preg_replace( "/<a\\s[^>]*href='#[^']*'[^>]*>(.*?)<\/a>/is", '$1', $html );

		// Step 3: Strip all style, class, id, and lang attributes from every element.
		// Two passes: double-quoted values, then single-quoted values.
		$html = preg_replace( '/\\s(?:style|class|id|lang)="[^"]*"/i', '', $html );
		$html = preg_replace( "/\\s(?:style|class|id|lang)='[^']*'/i", '', $html );

		// Step 4: Strip empty tags iteratively until stable (handles nesting).
		$empty_pattern = '/<(strong|em|span|p)>\\s*(?:&nbsp;\\s*)*<\/\\1>/i';
		do {
			$prev = $html;
			$html = preg_replace( $empty_pattern, '', $html );
		} while ( $prev !== $html );

		// Step 5: Normalize indentation -- strip leading &nbsp; sequences at start of <p> text.
		$html = preg_replace( '/(<p[^>]*>)(?:&nbsp;|\\s)+/i', '$1', $html );

		// Step 6: Collapse spacing abuse -- 2+ consecutive <br> tags -> </p><p>.
		// Regex matches all three closing forms: <br>, <br/>, <br />.
		$html = preg_replace( '/(?:<br\\s*\/?>\\s*){2,}/i', '</p><p>', $html );

		// Step 7: Merge paragraph fragments (EPUB/DOCX extraction paths only).
		if ( $merge_fragments ) {
			$html = self::merge_paragraph_fragments( $html );
		}

		// Step 8: Apply wp_kses() with strict allowlist. No attributes on any tag.
		$allowed = array(
			'p'          => array(),
			'br'         => array(),
			'strong'     => array(),
			'em'         => array(),
			'h2'         => array(),
			'h3'         => array(),
			'h4'         => array(),
			'blockquote' => array(),
		);
		$html = wp_kses( $html, $allowed );

		// Step 9: Trim and normalize whitespace.
		$html = trim( preg_replace( '/[ \\t]+/', ' ', $html ) );
		$html = preg_replace( '/\\n{3,}/', "\n\n", $html );

		// Step 10: Word limit truncation.
		// Count on plain text (not raw HTML) to avoid counting tag names as words.
		if ( $word_limit > 0 ) {
			$html = self::apply_word_limit( $html, $word_limit );
		}

		return $html;
	}

	// -------------------------------------------------------------------------

	/**
	 * Strip DOCTYPE, <html>, <head>, <body> wrapper tags; return body content only.
	 *
	 * @param  string $html Raw HTML.
	 * @return string       Body content.
	 */
	private static function extract_body( string $html ): string {
		$html = preg_replace( '/<!DOCTYPE[^>]*>/i', '', $html );
		if ( preg_match( '/<body[^>]*>(.*?)<\/body>/is', $html, $m ) ) {
			return $m[1];
		}
		$html = preg_replace( '/<html[^>]*>|<\/html>/i', '', $html );
		$html = preg_replace( '/<head[^>]*>.*?<\/head>/is', '', $html );
		return $html;
	}

	/**
	 * Merge paragraph fragments using a single forward pass over paragraph segments.
	 *
	 * Heuristics (in priority order):
	 * 1. Next <p> starts with a lowercase letter -- merge (~99% confidence).
	 * 2. Current <p> ends with a hyphenated word fragment -- merge + remove hyphen (~99%).
	 * 3. Current <p> ends without terminal punctuation -- merge (~85%).
	 *
	 * Only plain <p> elements are candidates. Headings/blockquotes pass through as-is.
	 *
	 * @param  string $html HTML containing <p> and other block elements.
	 * @return string       HTML with paragraph fragments merged.
	 */
	private static function merge_paragraph_fragments( string $html ): string {
		preg_match_all( '/<p>.*?<\/p>|<h[234]>.*?<\/h[234]>|<blockquote>.*?<\/blockquote>/is', $html, $m );
		if ( empty( $m[0] ) ) {
			return $html;
		}

		$segments  = $m[0];
		$count     = count( $segments );
		$output    = array();
		$skip_next = false;

		for ( $i = 0; $i < $count; $i++ ) {
			if ( $skip_next ) {
				$skip_next = false;
				continue;
			}

			$current = $segments[ $i ];

			// Only plain <p> elements can be merged candidates.
			if ( 0 !== strpos( $current, '<p>' ) || ! isset( $segments[ $i + 1 ] ) ) {
				$output[] = $current;
				continue;
			}

			$next = $segments[ $i + 1 ];
			if ( 0 !== strpos( $next, '<p>' ) ) {
				$output[] = $current;
				continue;
			}

			$cur_text  = wp_strip_all_tags( $current );
			$next_text = wp_strip_all_tags( $next );
			$merge     = false;
			$rm_hyphen = false;

			// Heuristic 1: next paragraph starts with a lowercase letter.
			if ( preg_match( '/^[a-z]/u', ltrim( $next_text ) ) ) {
				$merge = true;
			}

			// Heuristic 2: current paragraph ends with a hyphenated word fragment.
			// Runs independently of H1 so hyphen removal always fires when needed.
			if ( preg_match( '/\\w-$/u', rtrim( $cur_text ) ) ) {
				$merge     = true;
				$rm_hyphen = true;
			}

			// Heuristic 3: current paragraph ends without terminal punctuation.
			if ( ! $merge && ! preg_match( '/[.!?][\'"]?$/u', rtrim( $cur_text ) ) ) {
				$merge = true;
			}

			if ( $merge ) {
				$cur_inner  = preg_replace( '/^<p>|<\/p>$/', '', $current );
				$next_inner = preg_replace( '/^<p>|<\/p>$/', '', $next );
				if ( $rm_hyphen ) {
					$cur_inner = rtrim( $cur_inner, '-' );
				}
				$output[]  = '<p>' . rtrim( $cur_inner ) . ' ' . ltrim( $next_inner ) . '</p>';
				$skip_next = true;
			} else {
				$output[] = $current;
			}
		}

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

	/**
	 * Truncate HTML at the last complete </p> boundary before the word limit.
	 *
	 * Word count is measured on plain text (wp_strip_all_tags), not raw HTML,
	 * so tag names are not counted as words (avoids ~5-7% inflation).
	 *
	 * Fallback: if no </p> boundary is found within the limit (e.g. a single
	 * enormous paragraph), truncates at the last whole-word boundary and
	 * wraps in <p>...</p>. Never returns empty when input is non-empty.
	 *
	 * @param  string $html       Clean HTML.
	 * @param  int    $word_limit Maximum word count.
	 * @return string             Truncated HTML with note appended, or original if within limit.
	 */
	public static function apply_word_limit( string $html, int $word_limit ): string {
		if ( str_word_count( wp_strip_all_tags( $html ) ) <= $word_limit ) {
			return $html;
		}

		// translators: Appended to Look Inside excerpts that exceed the word limit.
		$note = '<p><em>' . esc_html( __( 'Excerpt truncated at 6,000 words.', 'my-book-showroom' ) ) . '</em></p>';

		// Collect all </p> end positions.
		$end_tag = '</p>';
		$end_len = strlen( $end_tag );
		$p_ends  = array();
		$offset  = 0;
		while ( false !== ( $pos = strpos( $html, $end_tag, $offset ) ) ) {
			$p_ends[] = $pos + $end_len;
			$offset   = $pos + 1;
		}

		// Walk backwards to find the last </p> boundary within the word limit.
		foreach ( array_reverse( $p_ends ) as $end_pos ) {
			$candidate = substr( $html, 0, $end_pos );
			if ( str_word_count( wp_strip_all_tags( $candidate ) ) <= $word_limit ) {
				return $candidate . "\n" . $note;
			}
		}

		// Fallback: no </p> boundary within limit -- truncate at whole-word boundary.
		$plain   = wp_strip_all_tags( $html );
		$words   = preg_split( '/\\s+/', trim( $plain ), -1, PREG_SPLIT_NO_EMPTY );
		$excerpt = implode( ' ', array_slice( $words, 0, $word_limit ) );
		return '<p>' . esc_html( $excerpt ) . '&#8230;</p>' . "\n" . $note;
	}

	/**
	 * Truncate HTML at the second chapter-level heading.
	 *
	 * When multiple chapters are packed into a single document -- common in
	 * single-file EPUB spines or abnormally structured DOCXs -- this ensures
	 * Look Inside never crosses into Chapter 2.
	 *
	 * Detection: scans h1-h3 tags whose inner text matches a chapter /
	 * prologue / epilogue / part pattern, or a bare standalone number
	 * ("1", "01", "One", etc.). Everything from the second such heading
	 * onward is discarded.
	 *
	 * Safe no-op when fewer than two chapter headings are present (the
	 * normal case for EPUBs that have one chapter per spine document).
	 *
	 * @since  1.4.0
	 * @param  string $html Raw HTML (pre-sanitization).
	 * @return string HTML truncated at Chapter 2 boundary, or original if
	 *                only one chapter heading is found.
	 */
	public static function truncate_at_chapter_boundary( string $html ): string {
		if ( '' === trim( $html ) ) {
			return $html;
		}

		$heading_pat = '/<h[1-3][^>]*>(.*?)<\/h[1-3]>/is';
		if ( ! preg_match_all( $heading_pat, $html, $matches, PREG_OFFSET_CAPTURE ) ) {
			return $html;
		}

		$chapter_pat  = '/\b(chapter|prologue|epilogue|part|book)\b/iu';
		$bare_num_pat = '/^\s*(one|two|three|four|five|six|seven|eight|nine|ten|\d\d?\d?\.?)\s*$/iu';

		$chapter_offsets = array();
		foreach ( $matches[0] as $i => $match ) {
			$inner = trim( wp_strip_all_tags( $matches[1][ $i ][0] ) );
			if ( '' === $inner ) {
				continue;
			}
			if ( preg_match( $chapter_pat, $inner ) || preg_match( $bare_num_pat, $inner ) ) {
				$chapter_offsets[] = $match[1];
			}
		}

		if ( count( $chapter_offsets ) < 2 ) {
			return $html;
		}

		return substr( $html, 0, $chapter_offsets[1] );
	}
}
