<?php
/**
 * Reads the hidden keyword/category metadata a guide may carry, so search can
 * rank on more than heading text and the browse grid can group by category.
 *
 * Hub guides carry it inside the raw payload `meta` object, stored verbatim
 * as JSON in `_bw_guides_remote_meta` since 0.1.0 (see
 * BW_Guides_Sync::upsert()) — this class is the first reader of that object;
 * until now it rode along unused (invariant 6: forward-compat payload fields
 * are read at render/query time, never used to rewrite stored content).
 * Local, client-created guides read the same shapes from their own post meta
 * (`_bw_guides_local_category`, `_bw_guides_local_keywords`) so both guide
 * sources behave identically to search and browse. There is no authoring UI
 * for either yet — a guide only has them if the hub sent them, or something
 * (a future tool, a manual wp-cli edit) set them directly.
 *
 * Contract (payload `meta` / local meta), all optional, all tolerated absent
 * or malformed:
 *
 *   category:          string. "" or absent = uncategorized.
 *   section_keywords:  [{"heading": "<exact heading text>", "keywords": "kw, kw"}, ...]
 *                      "heading" is matched case-insensitively and trimmed,
 *                      against this guide's own parsed headings — never by
 *                      slug, so this plugin never has to agree with the hub
 *                      (or itself) on slug generation to find a match.
 *                      The heading text "*" is reserved for guide-level
 *                      keywords (not tied to any one section).
 *
 * @package BW_Guides
 */

defined( 'ABSPATH' ) || exit;

class BW_Guides_Keywords {

	/**
	 * A guide's category + keywords, decoded and normalized.
	 *
	 * @param WP_Post $post A bw_guide post.
	 * @return array{category:string, guide_keywords:string, section_keywords:array<string,string>}
	 *         section_keywords is keyed by normalize_heading() of the heading
	 *         text it applies to.
	 */
	public static function for_guide( $post ) {
		$meta = self::decode_object( get_post_meta( $post->ID, '_bw_guides_remote_meta', true ) );

		$category = isset( $meta['category'] ) ? trim( (string) $meta['category'] ) : '';
		$entries  = ( isset( $meta['section_keywords'] ) && is_array( $meta['section_keywords'] ) ) ? $meta['section_keywords'] : null;

		// Fall back to the local-guide meta keys only where the hub object
		// (or the lack of one) left something unset — a hub guide always
		// wins on its own fields if it ever does carry them.
		if ( '' === $category ) {
			$local_category = get_post_meta( $post->ID, '_bw_guides_local_category', true );
			if ( is_string( $local_category ) && '' !== trim( $local_category ) ) {
				$category = trim( $local_category );
			}
		}
		if ( null === $entries ) {
			$entries = self::decode_entries( get_post_meta( $post->ID, '_bw_guides_local_keywords', true ) );
		}

		$guide_keywords = '';
		$section        = array();

		if ( is_array( $entries ) ) {
			foreach ( $entries as $entry ) {
				if ( ! is_array( $entry ) || ! isset( $entry['heading'], $entry['keywords'] ) ) {
					continue;
				}
				$heading  = trim( (string) $entry['heading'] );
				$keywords = trim( (string) $entry['keywords'] );
				if ( '' === $heading || '' === $keywords ) {
					continue;
				}
				if ( '*' === $heading ) {
					$guide_keywords = $keywords;
					continue;
				}
				$section[ self::normalize_heading( $heading ) ] = $keywords;
			}
		}

		return array(
			'category'         => $category,
			'guide_keywords'   => $guide_keywords,
			'section_keywords' => $section,
		);
	}

	/**
	 * The hidden keywords for one specific heading of a guide, "" if none.
	 * Matching is case-insensitive/trimmed exact text, never by slug.
	 *
	 * @param array  $guide_meta   Return value of for_guide().
	 * @param string $heading_text Exact heading text.
	 * @return string
	 */
	public static function keywords_for_heading( $guide_meta, $heading_text ) {
		$key = self::normalize_heading( $heading_text );
		return isset( $guide_meta['section_keywords'][ $key ] ) ? $guide_meta['section_keywords'][ $key ] : '';
	}

	/**
	 * Group guides for the browse grid.
	 *
	 * Hub guides are grouped by category, alphabetically; hub guides with no
	 * category fall into "More guides". Every local (client-created) guide is
	 * grouped last under "Your guides" regardless of any category meta it
	 * happens to carry — a client's own guides stay a single, easy-to-spot
	 * block rather than being scattered among the standard library.
	 *
	 * @param WP_Post[] $posts
	 * @return array<int, array{label:string, posts:WP_Post[]}>
	 */
	public static function group_guides( $posts ) {
		$by_category   = array();
		$uncategorized = array();
		$local         = array();

		foreach ( $posts as $post ) {
			$is_hub = 'hub' === get_post_meta( $post->ID, BW_Guides_CPT::SOURCE_META, true );
			if ( ! $is_hub ) {
				$local[] = $post;
				continue;
			}

			$category = self::for_guide( $post )['category'];
			if ( '' === $category ) {
				$uncategorized[] = $post;
				continue;
			}

			if ( ! isset( $by_category[ $category ] ) ) {
				$by_category[ $category ] = array();
			}
			$by_category[ $category ][] = $post;
		}

		ksort( $by_category, SORT_STRING | SORT_FLAG_CASE );

		// "Getting started" always leads — a new user's first visit shouldn't
		// open on whichever category sorts first alphabetically.
		foreach ( array_keys( $by_category ) as $label ) {
			if ( 0 === strcasecmp( $label, 'Getting started' ) ) {
				$by_category = array( $label => $by_category[ $label ] ) + $by_category;
				break;
			}
		}

		$groups = array();
		foreach ( $by_category as $label => $group_posts ) {
			$groups[] = array(
				'label' => $label,
				'posts' => $group_posts,
			);
		}
		if ( $uncategorized ) {
			$groups[] = array(
				'label' => __( 'More guides', 'bw-guides' ),
				'posts' => $uncategorized,
			);
		}
		if ( $local ) {
			$groups[] = array(
				'label' => __( 'Your guides', 'bw-guides' ),
				'posts' => $local,
			);
		}

		return $groups;
	}

	/**
	 * @param string $text
	 * @return string
	 */
	/**
	 * Normalise a heading before comparing it.
	 *
	 * Both sides of this comparison come from different places and are written
	 * by different hands: the keyword row is typed by an author, the heading
	 * text is extracted from rendered block markup. So they drift in ways that
	 * are invisible to a human reading them side by side:
	 *
	 *   - HTML entities. Content renders "width &amp;amp; height" while the author
	 *     typed "width & height". wp_strip_all_tags() strips tags but does NOT
	 *     decode entities, so these never matched and the section became
	 *     unsearchable — silently, because nothing errors.
	 *   - Curly vs straight apostrophes. An editor (or a phone) produces "don’t"
	 *     where the keyword row says "don't".
	 *   - Non-breaking spaces and runs of whitespace from block markup.
	 *
	 * @param string $text Raw heading text.
	 * @return string Comparison key.
	 */
	private static function normalize_heading( $text ) {
		return self::normalize_for_match( $text );
	}

	/**
	 * Shared normaliser for any author-text vs content-text comparison.
	 * Deliberately conservative: it only removes differences that carry no
	 * meaning to a reader.
	 *
	 * @param string $text Text to normalise.
	 * @return string
	 */
	public static function normalize_for_match( $text ) {
		$text = (string) $text;
		// Decode entities first, so "&amp;amp;" collapses to "&" before anything else.
		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		// Curly quotes/apostrophes -> straight equivalents.
		$text = str_replace(
			array( "\xE2\x80\x98", "\xE2\x80\x99", "\xE2\x80\x9C", "\xE2\x80\x9D", "\xE2\x80\xB2", "\xC2\xB4" ),
			array( "'", "'", '"', '"', "'", "'" ),
			$text
		);
		// Non-breaking space -> ordinary space, then collapse runs.
		$text = str_replace( "\xC2\xA0", ' ', $text );
		$text = preg_replace( '/\s+/u', ' ', $text );
		return strtolower( trim( (string) $text ) );
	}

	/**
	 * `_bw_guides_remote_meta` is stored as a JSON string (wp_json_encode at
	 * sync time in BW_Guides_Sync::upsert()). Decode defensively — a
	 * hand-edited or malformed value must never fatal a search or a page
	 * load, it just behaves as if the guide carries no metadata.
	 *
	 * @param mixed $raw
	 * @return array
	 */
	private static function decode_object( $raw ) {
		if ( is_array( $raw ) ) {
			return $raw;
		}
		if ( ! is_string( $raw ) || '' === $raw ) {
			return array();
		}
		$decoded = json_decode( $raw, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	/**
	 * `_bw_guides_local_keywords` has no writer yet, so tolerate either a
	 * JSON string (mirroring the hub's storage shape) or a plain PHP array
	 * (WordPress round-trips arrays through postmeta natively via
	 * maybe_serialize()) — whichever a future tool ends up using.
	 *
	 * @param mixed $raw
	 * @return array|null Null means "nothing usable was stored".
	 */
	private static function decode_entries( $raw ) {
		if ( is_array( $raw ) ) {
			return $raw;
		}
		if ( is_string( $raw ) && '' !== $raw ) {
			$decoded = json_decode( $raw, true );
			if ( is_array( $decoded ) ) {
				return $decoded;
			}
		}
		return null;
	}
}
