<?php
/**
 * Guide content structure: heading anchors, contents lists, and section search.
 *
 * The design problem this solves: a guide is the unit of publishing, but a
 * question is the unit of use. Clients arrive wanting "how do I change the page
 * URL" — too small for its own guide, but it must be findable. So guides are
 * written one-topic-per-guide with one task per heading, and this class makes
 * every heading individually addressable:
 *
 *   - prepare()         renders a guide and gives every h2/h3 a stable id.
 *   - headings()        lists a guide's headings without rendering it.
 *   - search_sections() ranks a search term against headings AND a guide's
 *                       hidden keywords (BW_Guides_Keywords) across guides,
 *                       so results can deep-link to the best-matching section.
 *
 * Everything is derived at render/query time. Nothing is stored and
 * post_content is never rewritten, so content_hash stays stable (invariant 6).
 *
 * Anchor stability: a slug comes from the heading's text, so editing a heading
 * on the hub changes its anchor and any link saved to the old one stops
 * resolving (it lands at the top of the guide — degraded, not broken).
 *
 * Scale: headings() parses raw block markup with a regex rather than rendering,
 * which is cheap enough to run across every guide on each list view at the
 * expected scale (tens of guides). If the library ever grows to hundreds,
 * cache the per-guide heading list in post meta at sync time — deriving meta is
 * fine, rewriting content is not.
 *
 * @package BW_Guides
 */

defined( 'ABSPATH' ) || exit;

class BW_Guides_Content {

	/**
	 * Prefix for generated ids, so a heading anchor can never collide with an
	 * element id belonging to wp-admin itself.
	 */
	const ANCHOR_PREFIX = 'bw-';

	/**
	 * Render a guide and anchor its headings.
	 *
	 * do_blocks() rather than the_content: hub content must never trigger this
	 * site's shortcodes or third-party filters. Anchors are injected after
	 * wp_kses_post so our ids are guaranteed to survive; they are safe by
	 * construction (sanitize_title output).
	 *
	 * @param string $content Raw block markup.
	 * @return array{html:string, headings:array<int,array{level:int,text:string,slug:string}>}
	 */
	public static function prepare( $content ) {
		$html     = wp_kses_post( do_blocks( (string) $content ) );
		$used     = array();
		$headings = array();

		$html = preg_replace_callback(
			'#<(h[23])\b([^>]*)>(.*?)</\1>#is',
			function ( $m ) use ( &$used, &$headings ) {
				$tag   = $m[1];
				$attrs = $m[2];
				$inner = $m[3];
				$text  = trim( wp_strip_all_tags( $inner ) );

				if ( '' === $text ) {
					return $m[0];
				}

				$level = (int) substr( $tag, 1 );

				// Respect an id the author set deliberately.
				if ( preg_match( '#\sid=["\']([^"\']+)["\']#i', $attrs, $existing ) ) {
					$headings[] = array(
						'level' => $level,
						'text'  => $text,
						'slug'  => $existing[1],
					);
					return $m[0];
				}

				$slug       = self::slugify( $text, $used );
				$headings[] = array(
					'level' => $level,
					'text'  => $text,
					'slug'  => $slug,
				);

				return '<' . $tag . $attrs . ' id="' . esc_attr( $slug ) . '">' . $inner . '</' . $tag . '>';
			},
			$html
		);

		return array(
			'html'     => $html,
			'headings' => $headings,
		);
	}

	/**
	 * A guide's headings, read straight from the saved block markup.
	 *
	 * Headings are static blocks, so their markup appears verbatim in
	 * post_content — no need to render. Slugs are produced by the same
	 * algorithm prepare() uses, in the same document order, so the anchors
	 * match what the rendered page actually contains.
	 *
	 * @param string $content Raw block markup.
	 * @return array<int,array{level:int,text:string,slug:string}>
	 */
	public static function headings( $content ) {
		$out  = array();
		$used = array();

		if ( ! preg_match_all( '#<(h[23])\b([^>]*)>(.*?)</\1>#is', (string) $content, $matches, PREG_SET_ORDER ) ) {
			return $out;
		}

		foreach ( $matches as $m ) {
			$text = trim( wp_strip_all_tags( $m[3] ) );
			if ( '' === $text ) {
				continue;
			}

			if ( preg_match( '#\sid=["\']([^"\']+)["\']#i', $m[2], $existing ) ) {
				$slug = $existing[1];
			} else {
				$slug = self::slugify( $text, $used );
			}

			$out[] = array(
				'level' => (int) substr( $m[1], 1 ),
				'text'  => $text,
				'slug'  => $slug,
			);
		}

		return $out;
	}

	/**
	 * Find and rank guide sections matching a search term.
	 *
	 * Scores each of a guide's headings on two independent signals, summed:
	 *
	 *   - Heading text:     phrase match = 5, else all words present = 3.
	 *   - Section keywords: phrase match = 4, else all words present = 2.
	 *     (BW_Guides_Keywords::keywords_for_heading() — a guide's hidden,
	 *     client-vocabulary synonyms for one specific heading; matched
	 *     case-insensitively against the raw `keywords` string, not word by
	 *     word against a parsed list, so "page url" also matches a keyword
	 *     string like "permalink, rename page, link, url".)
	 *   - Guide-level keywords (heading "*"): a flat +1 to every one of that
	 *     guide's sections when they match, so a query that only identifies
	 *     the guide (not a specific section) still surfaces it, at the bottom
	 *     of the ranking.
	 *
	 * A heading needs no keyword metadata to be found — heading-text matches
	 * alone already score above zero — so this degrades to the pre-0.3.0
	 * heading-only behavior when a guide (or all of them) carries none.
	 * Heading-text matches always outrank keyword-only matches (5/3 vs 4/2),
	 * so a visible heading match always "wins" the top spot over a hidden one.
	 *
	 * @param WP_Post[] $posts  Guides to search.
	 * @param string    $search Search term.
	 * @param int       $limit  Maximum sections to return.
	 * @return array<int,array{post:WP_Post, heading:array, score:int, via:string}>
	 *         via is 'heading' when the heading text itself contributed any
	 *         score, otherwise 'keyword' — a hint for a subtle "matched a
	 *         hidden keyword" UI cue, not a precise attribution when both
	 *         signals fired.
	 */
	public static function search_sections( $posts, $search, $limit = 8 ) {
		$search = trim( (string) $search );
		if ( '' === $search ) {
			return array();
		}

		$search = BW_Guides_Keywords::normalize_for_match( $search );
		$words  = array_values( array_filter( preg_split( '#\s+#', $search ) ) );
		// Drop apostrophes from query tokens: a user typing "cant" should reach a
		// keyword written "can't". Done on the token, not the haystack, so the
		// haystack's own apostrophes still match a query that includes them.
		$words = array_map(
			function ( $w ) {
				return str_replace( "'", '', $w );
			},
			$words
		);

		// Drop filler words so "forgot my password" still matches a keyword
		// list containing "forgot password". If the query is ALL filler,
		// keep the original words rather than matching everything.
		$meaningful = array_values( array_diff( array_map( 'strtolower', $words ), self::STOPWORDS ) );
		if ( $meaningful ) {
			$words = $meaningful;
		}

		$hits  = array();
		$order = 0;

		foreach ( $posts as $post ) {
			$headings = self::headings( $post->post_content );
			if ( ! $headings ) {
				continue;
			}

			$guide_meta  = BW_Guides_Keywords::for_guide( $post );
			$guide_boost = self::phrase_or_words_score( $guide_meta['guide_keywords'], $search, $words, 1, 1 ) > 0;

			foreach ( $headings as $heading ) {
				$heading_score = self::phrase_or_words_score( $heading['text'], $search, $words, 5, 3 );
				$section_kw    = BW_Guides_Keywords::keywords_for_heading( $guide_meta, $heading['text'] );
				$keyword_score = self::phrase_or_words_score( $section_kw, $search, $words, 4, 2 );

				$score = $heading_score + $keyword_score + ( $guide_boost ? 1 : 0 );
				if ( $score <= 0 ) {
					continue;
				}

				$hits[] = array(
					'post'    => $post,
					'heading' => $heading,
					'score'   => $score,
					'via'     => ( $heading_score > 0 ) ? 'heading' : 'keyword',
					'_order'  => $order++,
				);
			}
		}

		// Nothing matched every word. Rather than an empty state, fall back to
		// the closest sections.
		//
		// The all-words rule above is right for short queries — it is what stops
		// "page" returning forty sections. It is wrong for the way people
		// actually type into a box labelled "What do you need help with?": ask
		// "how do I write a news story for the school blog" and the single word
		// "story", which appears in no guide, discards every other signal in the
		// sentence. The longer and more natural the question, the more likely it
		// returns nothing — exactly backwards.
		//
		// So: only when the strict pass found nothing at all, score by the
		// PROPORTION of query words a section matches, and require a real
		// majority so this stays "the closest answers" rather than "anything
		// vaguely related".
		if ( ! $hits ) {
			$hits = self::closest_sections( $posts, $search, $words );
		}

		// usort() is only guaranteed stable from PHP 8; this plugin supports
		// 7.4, so tiebreak on original encounter order explicitly rather than
		// leave equal-score ordering to chance.
		usort(
			$hits,
			function ( $a, $b ) {
				if ( $a['score'] === $b['score'] ) {
					return $a['_order'] <=> $b['_order'];
				}
				return $b['score'] <=> $a['score'];
			}
		);

		$hits = array_slice( $hits, 0, $limit );
		foreach ( $hits as &$hit ) {
			unset( $hit['_order'] );
		}
		unset( $hit );

		return $hits;
	}

	/**
	 * Closest-match pass, used only when the all-words search found nothing.
	 *
	 * Scores each section by how MANY of the query's meaningful words it
	 * matches, across the same three signals the strict pass uses (heading text,
	 * that section's keywords, the guide's keywords). A section must match a
	 * clear majority to appear, so a long question narrows the field instead of
	 * opening it: "how do I write a news story for the school blog" keeps the
	 * blog-writing sections and drops everything that merely mentions "school".
	 *
	 * Scores are deliberately small (2 or 3) and never collide with the strict
	 * pass, which cannot run at the same time — but keeping them low means that
	 * if the two are ever merged, an exact match still outranks an approximate
	 * one.
	 *
	 * @param WP_Post[] $posts  Guides to search.
	 * @param string    $search Full search phrase.
	 * @param string[]  $words  Meaningful query words.
	 * @return array<int,array> Hit rows in the same shape as search_sections().
	 */
	private static function closest_sections( $posts, $search, $words ) {
		// Drop tokens too short to carry meaning BEFORE counting. word_in_whole()
		// already refuses to match them, so leaving them in the total only raised
		// the bar: "where did the editor go" reduces to did/editor/go, of which two
		// can never match, so a section had to hit an impossible 2 of 3.
		$words = array_values( array_filter( $words, function ( $w ) {
			return strlen( $w ) >= 3;
		} ) );
		$total = count( $words );
		// One word that matched nothing strictly cannot match anything loosely
		// either — the two passes are identical for a single-word query.
		if ( $total < 2 ) {
			return array();
		}
		// Majority, and never fewer than two words, so a 3-word query needs 2 and
		// a 7-word question needs 4. Below that it stops being "closest" and
		// starts being noise.
		$needed = max( 2, (int) ceil( $total * 0.5 ) );

		$hits  = array();
		$order = 0;

		foreach ( $posts as $post ) {
			$headings = self::headings( $post->post_content );
			if ( ! $headings ) {
				continue;
			}
			$guide_meta = BW_Guides_Keywords::for_guide( $post );

			foreach ( $headings as $heading ) {
				$section_kw = BW_Guides_Keywords::keywords_for_heading( $guide_meta, $heading['text'] );

				$in_heading = BW_Guides_Keywords::normalize_for_match( $heading['text'] );
				$in_section = BW_Guides_Keywords::normalize_for_match( $section_kw );
				$in_guide   = BW_Guides_Keywords::normalize_for_match( $guide_meta['guide_keywords'] );

				$matched     = 0;
				$via_heading = false;
				foreach ( $words as $word ) {
					if ( self::word_in_whole( $in_heading, $word ) ) {
						$matched++;
						$via_heading = true;
					} elseif ( self::word_in_whole( $in_section, $word ) || self::word_in_whole( $in_guide, $word ) ) {
						$matched++;
					}
				}

				if ( $matched < $needed ) {
					continue;
				}

				$hits[] = array(
					'post'    => $post,
					'heading' => $heading,
					// Every word but one still reads as a near miss; keep it above
					// the rest so it sorts to the top of the "closest" list.
					'score'   => ( $matched >= $total - 1 ) ? 3 : 2,
					'via'     => $via_heading ? 'heading' : 'keyword',
					'_order'  => $order++,
				);
			}
		}

		return $hits;
	}

	/**
	 * Score one piece of text against a search phrase: a full-phrase match
	 * scores higher than merely containing every word of it separately.
	 * Empty haystacks (no keywords set) always score 0 — absence of metadata
	 * must never look like a match.
	 *
	 * @param string   $haystack     Text to test (heading text, or a raw
	 *                               "kw, kw, kw" keywords string).
	 * @param string   $search       Full search phrase.
	 * @param string[] $words        Search phrase split into words.
	 * @param int      $phrase_score Score if $search appears verbatim.
	 * @param int      $words_score  Score if every word appears (any order).
	 * @return int
	 */
	private static function phrase_or_words_score( $haystack, $search, $words, $phrase_score, $words_score ) {
		// Normalise entities, curly apostrophes and whitespace on BOTH sides, so
		// a user typing "cant get in" still matches a keyword written "can't get
		// in", and content rendering "&amp;amp;" matches an author's "&". The
		// caller normalises $search/$words once; the haystack varies per call.
		$haystack = BW_Guides_Keywords::normalize_for_match( $haystack );
		if ( '' === $haystack ) {
			return 0;
		}
		if ( false !== stripos( $haystack, $search ) ) {
			return $phrase_score;
		}
		if ( ! $words ) {
			return 0;
		}
		foreach ( $words as $word ) {
			if ( ! self::word_in( $haystack, $word ) ) {
				return 0;
			}
		}
		return $words_score;
	}

	/**
	 * Filler words ignored by the all-words match (never by the phrase match).
	 * Small and deliberately conservative — this is not NLP, just enough that
	 * natural phrasing ("how do I…", "…my password") doesn't kill a match.
	 */
	const STOPWORDS = array(
		'a', 'an', 'the', 'my', 'i', 'do', 'does', 'how', 'to', 'in', 'on',
		'of', 'for', 'is', 'it', 'me', 'can', 'what', 'where', 'when', 'why',
		'are', 'you', 'your', 'we', 'our', 'this', 'that', 'did', 'go',
	);

	/**
	 * Word-in-haystack with a trailing-s tolerance: "blocks" also matches a
	 * haystack that only says "block" (the reverse already works, because a
	 * singular query is a substring of the plural).
	 *
	 * @param string $haystack Text to search in.
	 * @param string $word     Single query word.
	 * @return bool
	 */
	/**
	 * Stricter word match, used only by the closest-match pass.
	 *
	 * word_in() is substring-based, which is right for the all-words rule: every
	 * word has to match, so an accidental substring hit almost never completes a
	 * set on its own. Counting matches PROPORTIONALLY changes that — "at" is
	 * inside "that", "create" and "status", so a handful of short filler tokens
	 * would push unrelated sections over the threshold and make the fallback
	 * worse than the empty state it replaces.
	 *
	 * So: short tokens must appear as whole words; longer ones keep the useful
	 * substring behaviour ("redirects" reaching "redirect"). Tokens under three
	 * characters carry no signal at this length and are ignored entirely.
	 *
	 * @param string $haystack Text to search in.
	 * @param string $word     Single query word.
	 * @return bool
	 */
	private static function word_in_whole( $haystack, $word ) {
		$len = strlen( $word );
		if ( $len < 3 ) {
			return false;
		}
		if ( $len <= 4 ) {
			return 1 === preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/i', $haystack );
		}
		return self::word_in( $haystack, $word );
	}

	private static function word_in( $haystack, $word ) {
		if ( false !== stripos( $haystack, $word ) ) {
			return true;
		}
		// Query tokens arrive with apostrophes stripped, so also compare against
		// a stripped haystack — "cant" has to reach a keyword written "can't".
		$bare = str_replace( "'", '', $haystack );
		if ( $bare !== $haystack && false !== stripos( $bare, $word ) ) {
			return true;
		}
		if ( strlen( $word ) >= 4 && 's' === strtolower( substr( $word, -1 ) ) ) {
			$singular = substr( $word, 0, -1 );
			return false !== stripos( $haystack, $singular ) || false !== stripos( $bare, $singular );
		}
		return false;
	}

	/**
	 * Build the "In this guide" contents list from a guide's top-level headings.
	 * Returns an empty string when there are too few to be worth showing.
	 *
	 * @param array $headings From prepare() or headings().
	 * @param int   $min      Minimum h2 count before a contents list is useful.
	 * @return string
	 */
	public static function contents_list( $headings, $min = 3 ) {
		$tops = array();
		foreach ( $headings as $heading ) {
			if ( 2 === $heading['level'] ) {
				$tops[] = $heading;
			}
		}

		if ( count( $tops ) < $min ) {
			return '';
		}

		$items = '';
		foreach ( $tops as $heading ) {
			$items .= '<li><a href="#' . esc_attr( $heading['slug'] ) . '">' . esc_html( $heading['text'] ) . '</a></li>';
		}

		return '<nav class="bw-guides-toc" aria-label="' . esc_attr__( 'Contents of this guide', 'bw-guides' ) . '">'
			. '<h2 class="bw-guides-toc-title">' . esc_html__( 'In this guide', 'bw-guides' ) . '</h2>'
			. '<ul>' . $items . '</ul></nav>';
	}

	/**
	 * Slug for a heading, unique within one guide.
	 *
	 * @param string $text Heading text.
	 * @param array  $used Slugs already taken, by reference.
	 * @return string
	 */
	private static function slugify( $text, &$used ) {
		$slug = sanitize_title( $text );
		if ( '' === $slug ) {
			$slug = 'section';
		}
		$slug = self::ANCHOR_PREFIX . $slug;

		$base = $slug;
		$n    = 2;
		while ( isset( $used[ $slug ] ) ) {
			$slug = $base . '-' . $n;
			++$n;
		}
		$used[ $slug ] = true;

		return $slug;
	}
}
