<?php
/**
 * Keyword Extractor.
 *
 * For a given orphan post, produces a ranked list of candidate phrases
 * that could be used as anchor text from other posts. Strategies in
 * priority order:
 *
 *   1. SEO focus keyword (Yoast `_yoast_wpseo_focuskw`, then RankMath
 *      `rank_math_focus_keyword`). High signal — the SEO person chose it.
 *
 *   2. Cleaned title — strip leading numbers, year suffixes, common
 *      stop words. Result is usually a meaningful 3-5 word phrase.
 *
 *   3. Content phrase from paragraph 5+ — extract the most repeated
 *      meaningful 3-4 word substring from deeper content (skipping
 *      intro paragraphs which tend to be fluff).
 *
 * Each candidate carries a strategy label so the UI can show provenance.
 *
 * @package Platform\Helm\Modules\Link_Architect
 */

declare( strict_types = 1 );

namespace Platform\Helm\Modules\Link_Architect;

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

final class KeywordExtractor {

	private const MIN_PHRASE_WORDS = 2;
	private const MAX_PHRASE_WORDS = 5;
	private const CONTENT_START_PARAGRAPH = 5; // Skip intro fluff per spec.

	/** Common English stop words — kept short and surgical, not exhaustive. */
	private const STOP_WORDS = [
		'a', 'an', 'and', 'or', 'but', 'the', 'this', 'that', 'these', 'those',
		'is', 'are', 'was', 'were', 'be', 'been', 'being', 'has', 'have', 'had',
		'do', 'does', 'did', 'will', 'would', 'should', 'could', 'may', 'might',
		'must', 'can', 'shall',
		'of', 'in', 'on', 'at', 'by', 'for', 'with', 'to', 'from', 'as', 'into',
		'through', 'during', 'before', 'after', 'above', 'below', 'up', 'down',
		'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once',
		'here', 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both',
		'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor',
		'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just',
		'i', 'me', 'my', 'we', 'us', 'our', 'you', 'your', 'he', 'him', 'his',
		'she', 'her', 'it', 'its', 'they', 'them', 'their', 'what', 'which',
		'who', 'whom', 'if', 'about', 'because',
	];

	/**
	 * Generate ranked candidate phrases for an orphan post.
	 *
	 * @return array<int,array{phrase:string,strategy:string,confidence:int}>
	 *   confidence: 0–100 (just a UI hint, not used for ranking — order is)
	 */
	public function candidates_for( int $post_id ): array {
		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return [];
		}

		$candidates = [];

		// Strategy 1 — Yoast focus keyword.
		$yoast = (string) get_post_meta( $post_id, '_yoast_wpseo_focuskw', true );
		if ( '' !== trim( $yoast ) && $this->is_valid_phrase( $yoast ) ) {
			$candidates[] = [
				'phrase'     => $this->normalise( $yoast ),
				'strategy'   => 'yoast_focus',
				'confidence' => 95,
			];
		}

		// Strategy 1b — RankMath focus keyword.
		$rm = (string) get_post_meta( $post_id, 'rank_math_focus_keyword', true );
		if ( '' !== trim( $rm ) ) {
			// RankMath stores comma-separated; take the first.
			$rm_first = explode( ',', $rm )[0];
			if ( $this->is_valid_phrase( $rm_first ) && ! $this->contains_phrase( $candidates, $rm_first ) ) {
				$candidates[] = [
					'phrase'     => $this->normalise( $rm_first ),
					'strategy'   => 'rankmath_focus',
					'confidence' => 95,
				];
			}
		}

		// Strategy 2 — Cleaned title.
		$cleaned = $this->cleaned_title( $post->post_title );
		if ( '' !== $cleaned && ! $this->contains_phrase( $candidates, $cleaned ) ) {
			$candidates[] = [
				'phrase'     => $cleaned,
				'strategy'   => 'cleaned_title',
				'confidence' => 75,
			];
		}

		// Strategy 3 — Content phrases from paragraph 5+.
		foreach ( $this->content_phrases( $post->post_content, 3 ) as $phrase ) {
			if ( ! $this->contains_phrase( $candidates, $phrase ) ) {
				$candidates[] = [
					'phrase'     => $phrase,
					'strategy'   => 'content_phrase',
					'confidence' => 60,
				];
			}
		}

		return $candidates;
	}

	/* =========================================================================
	 * STRATEGY: CLEANED TITLE
	 * ========================================================================= */

	private function cleaned_title( string $title ): string {
		$t = wp_strip_all_tags( $title );
		// Strip leading numerals like "5 Best..." or "10 Ways to...".
		$t = (string) preg_replace( '/^\s*\d+\s+/u', '', $t );
		// Strip trailing year suffix like "in 2024" or "2024".
		$t = (string) preg_replace( '/\s+(?:in\s+)?20\d{2}\s*$/u', '', $t );
		// Strip trailing punctuation.
		$t = trim( $t, " \t\n\r\0\x0B.,:;!?-—–" );

		$words = preg_split( '/\s+/u', strtolower( $t ) ) ?: [];
		$meaningful = [];
		foreach ( $words as $w ) {
			$w = trim( $w, " \t\n\r\0\x0B.,:;!?-—–\"'()[]" );
			if ( '' === $w ) {
				continue;
			}
			if ( in_array( $w, self::STOP_WORDS, true ) ) {
				continue;
			}
			$meaningful[] = $w;
			if ( count( $meaningful ) >= self::MAX_PHRASE_WORDS ) {
				break;
			}
		}
		if ( count( $meaningful ) < self::MIN_PHRASE_WORDS ) {
			return '';
		}
		return implode( ' ', $meaningful );
	}

	/* =========================================================================
	 * STRATEGY: CONTENT PHRASES (paragraph 5+)
	 * ========================================================================= */

	/**
	 * Extract the top N most frequent meaningful 3-word phrases from
	 * paragraphs at index 5 and beyond. Falls back to all paragraphs
	 * if the post is shorter than 5 paragraphs.
	 *
	 * @return string[]
	 */
	private function content_phrases( string $content, int $limit ): array {
		$plain = wp_strip_all_tags( $content );
		// Split into paragraphs by 2+ newlines (markdown-style) — also handles single-newline blocks.
		$paragraphs = (array) preg_split( '/(?:\r?\n){2,}|<\/p>|<br\s*\/?>(?:\s*<br\s*\/?>)+/i', $plain );
		$paragraphs = array_values( array_filter( array_map( 'trim', $paragraphs ), 'strlen' ) );

		$start = self::CONTENT_START_PARAGRAPH - 1; // 0-indexed.
		if ( count( $paragraphs ) <= $start ) {
			// Post too short — use everything we have.
			$start = 0;
		}
		$slice = array_slice( $paragraphs, $start );
		$body  = strtolower( implode( ' ', $slice ) );

		// Tokenise to lowercase word array.
		$tokens = preg_split( '/[^a-z0-9\']+/u', $body ) ?: [];
		$tokens = array_values( array_filter( $tokens, 'strlen' ) );

		// Generate 3-gram phrases that aren't all stop words.
		$ngrams = [];
		$n      = count( $tokens );
		for ( $i = 0; $i <= $n - 3; $i++ ) {
			$a = $tokens[ $i ];
			$b = $tokens[ $i + 1 ];
			$c = $tokens[ $i + 2 ];
			// At least 2 of 3 words must be meaningful (non-stop, non-numeric, length >= 3).
			$meaningful_count = 0;
			foreach ( [ $a, $b, $c ] as $w ) {
				if ( strlen( $w ) < 3 ) {
					continue;
				}
				if ( in_array( $w, self::STOP_WORDS, true ) ) {
					continue;
				}
				if ( ctype_digit( $w ) ) {
					continue;
				}
				$meaningful_count++;
			}
			if ( $meaningful_count < 2 ) {
				continue;
			}
			$phrase = "{$a} {$b} {$c}";
			$ngrams[ $phrase ] = ( $ngrams[ $phrase ] ?? 0 ) + 1;
		}

		// Sort by frequency desc, then by alphabetical for stable order.
		arsort( $ngrams );

		$out = [];
		foreach ( array_keys( $ngrams ) as $phrase ) {
			if ( count( $out ) >= $limit ) {
				break;
			}
			$out[] = (string) $phrase;
		}
		return $out;
	}

	/* =========================================================================
	 * HELPERS
	 * ========================================================================= */

	private function normalise( string $phrase ): string {
		return trim( (string) preg_replace( '/\s+/u', ' ', strtolower( $phrase ) ) );
	}

	private function is_valid_phrase( string $phrase ): bool {
		$normalised = $this->normalise( $phrase );
		$words      = explode( ' ', $normalised );
		$words      = array_values( array_filter( $words, 'strlen' ) );

		if ( count( $words ) < self::MIN_PHRASE_WORDS ) {
			return false;
		}
		if ( count( $words ) > self::MAX_PHRASE_WORDS ) {
			return false;
		}
		// At least one word must be non-stop.
		foreach ( $words as $w ) {
			if ( ! in_array( $w, self::STOP_WORDS, true ) && strlen( $w ) >= 3 ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param array<int,array{phrase:string}> $candidates
	 */
	private function contains_phrase( array $candidates, string $phrase ): bool {
		$norm = $this->normalise( $phrase );
		foreach ( $candidates as $c ) {
			if ( $this->normalise( (string) $c['phrase'] ) === $norm ) {
				return true;
			}
		}
		return false;
	}
}
