<?php
/**
 * Suggestion Engine.
 *
 * Given the content currently in the editor, finds existing published posts
 * this content could link TO. The matching is built to avoid the false
 * positives that plague naive linkers:
 *
 *   - It only looks at REAL, linkable body text. Existing anchors, headings,
 *     image attributes, code/pre blocks, and Gutenberg block comments are all
 *     stripped before matching, so it never suggests linking text that is
 *     already a link, a heading, or part of a code sample.
 *   - It matches the target's full title, meaningful n-grams from the title
 *     (edge-trimmed of stop words), and any SEO focus keyword (Yoast/Rank
 *     Math), longest/most-specific first.
 *   - External-link rules are explicit keyword -> URL pairs you define, matched
 *     whole-word (Unicode-safe) so short acronyms never fire inside a larger
 *     word (e.g. "API" never matches inside "rapidly").
 *   - The current post never suggests itself, and anything already linked is
 *     skipped.
 *
 * @package Platform\Helm\Modules\Link_Suggester
 */

declare( strict_types = 1 );

namespace Platform\Helm\Modules\Link_Suggester;

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

final class SuggestionEngine {

	/**
	 * Generate link suggestions for a piece of editor content.
	 *
	 * @param int    $current_post_id Post being edited (excluded from targets).
	 * @param string $content         Raw editor HTML.
	 * @return array<int,array<string,mixed>> Each: phrase, target_id, target_title, target_url, snippet, is_external.
	 */
	public function suggest( int $current_post_id, string $content ): array {
		$settings = SuggesterSettings::get();
		$max      = (int) $settings['max_suggestions'];

		// If the editor sent little/no content (empty block editor, autosave
		// not yet run), fall back to the saved post body so the scan still has
		// something to work with.
		if ( mb_strlen( trim( wp_strip_all_tags( $content ) ) ) < 30 ) {
			$saved = get_post_field( 'post_content', $current_post_id );
			if ( is_string( $saved ) && '' !== $saved ) {
				$content = $saved;
			}
		}

		$plain = $this->linkable_text( $content );
		if ( '' === trim( $plain ) ) {
			return [];
		}
		$plain_lower = mb_strtolower( $plain );

		// Excluded keywords (case-insensitive exact-phrase match).
		$excluded = [];
		foreach ( (array) $settings['excluded_keywords'] as $kw ) {
			$kw = mb_strtolower( trim( (string) $kw ) );
			if ( '' !== $kw ) {
				$excluded[ $kw ] = true;
			}
		}

		// Already-linked URLs — so we don't double-suggest.
		$already_linked = $this->existing_link_targets( $content );

		// Links Link Architect has already built FROM this post — suppressed
		// permanently (via the shared LinkMemory) so we never re-suggest a
		// pair the Architect created, even if the anchor text differs.
		$built_targets = ( new \Platform\Helm\Core\LinkMemory() )->targets_for( $current_post_id );

		// Pairs the user explicitly dismissed — also suppressed.
		$dismissed_targets = ( new DismissedSuggestions() )->for_source( $current_post_id );
		$suppressed = array_merge( $built_targets, $dismissed_targets );

		$candidates   = $this->candidate_targets( $current_post_id, $settings );
		$suggestions  = [];
		$used_phrases = [];

		foreach ( $candidates as $target ) {
			if ( count( $suggestions ) >= $max ) {
				break;
			}
			$target_id  = (int) $target['ID'];
			$target_url = (string) get_permalink( $target_id );
			if ( '' === $target_url ) {
				continue;
			}
			if ( in_array( $this->normalise_url( $target_url ), $already_linked, true ) ) {
				continue;
			}
			if ( in_array( $target_id, $suppressed, true ) ) {
				continue; // Architect already linked, or user dismissed.
			}

			$phrases = $this->phrases_for_target( $target_id, (string) $target['post_title'], $settings );

			foreach ( $phrases as $phrase ) {
				$phrase_lower = mb_strtolower( $phrase );
				if ( isset( $excluded[ $phrase_lower ] ) ) {
					continue;
				}
				if ( isset( $used_phrases[ $phrase_lower ] ) ) {
					continue;
				}
				if ( false === mb_strpos( $plain_lower, $phrase_lower ) ) {
					continue;
				}
				$suggestions[] = [
					'phrase'       => $phrase,
					'target_id'    => $target_id,
					'target_title' => (string) $target['post_title'],
					'target_url'   => $target_url,
					'snippet'      => $this->context_snippet( $plain, $phrase ),
					'is_external'  => false,
				];
				$used_phrases[ $phrase_lower ] = true;
				break; // One suggestion per target post.
			}
		}

		// External link rules (user-defined keyword -> URL). Explicit and
		// controlled, with zero scanning cost beyond matching your keywords.
		foreach ( (array) $settings['external_links'] as $rule ) {
			$keyword = isset( $rule['keyword'] ) ? (string) $rule['keyword'] : '';
			$url     = isset( $rule['url'] ) ? (string) $rule['url'] : '';
			if ( '' === $keyword || '' === $url ) {
				continue;
			}
			$kw_lower = mb_strtolower( trim( $keyword ) );
			if ( isset( $excluded[ $kw_lower ] ) || isset( $used_phrases[ $kw_lower ] ) ) {
				continue;
			}
			// Whole-word match only, so short acronyms don't fire inside words.
			if ( ! $this->keyword_in_text( $plain_lower, $kw_lower ) ) {
				continue;
			}
			if ( in_array( $this->normalise_url( $url ), $already_linked, true ) ) {
				continue;
			}
			$suggestions[] = [
				'phrase'       => $keyword,
				'target_id'    => 0,
				'target_title' => $this->url_host( $url ),
				'target_url'   => $url,
				'snippet'      => $this->context_snippet( $plain, $keyword ),
				'is_external'  => true,
			];
			$used_phrases[ $kw_lower ] = true;
		}

		return $suggestions;
	}

	private function url_host( string $url ): string {
		$host = wp_parse_url( $url, PHP_URL_HOST );
		return is_string( $host ) && '' !== $host ? $host : $url;
	}

	/**
	 * Whole-word (Unicode-safe) match. The keyword must not be flanked by
	 * letters or digits.
	 */
	private function keyword_in_text( string $haystack_lower, string $needle_lower ): bool {
		if ( '' === $needle_lower ) {
			return false;
		}
		$pattern = '/(?<![\p{L}\p{N}])' . preg_quote( $needle_lower, '/' ) . '(?![\p{L}\p{N}])/u';
		return 1 === preg_match( $pattern, $haystack_lower );
	}

	/**
	 * Strip everything that isn't real, linkable body text: existing anchors,
	 * headings, images, code, and Gutenberg block markup.
	 */
	private function linkable_text( string $html ): string {
		$html = (string) preg_replace( '/<a\b[^>]*>.*?<\/a>/is', ' ', $html );
		$html = (string) preg_replace( '/<h[1-6]\b[^>]*>.*?<\/h[1-6]>/is', ' ', $html );
		$html = (string) preg_replace( '/<img\b[^>]*>/i', ' ', $html );
		$html = (string) preg_replace( '/<(code|pre|script|style)\b[^>]*>.*?<\/\1>/is', ' ', $html );
		$html = (string) preg_replace( '/<!--\s*\/?wp:.*?-->/s', ' ', $html );
		$text = wp_strip_all_tags( $html );
		$text = (string) preg_replace( '/\s+/u', ' ', $text );
		return $text;
	}

	/**
	 * @return string[] Normalised URLs already linked in the content.
	 */
	private function existing_link_targets( string $html ): array {
		$out = [];
		if ( preg_match_all( '/<a\b[^>]*href=["\']([^"\']+)["\']/i', $html, $m ) ) {
			foreach ( $m[1] as $url ) {
				$out[] = $this->normalise_url( $url );
			}
		}
		return $out;
	}

	private function normalise_url( string $url ): string {
		$url = (string) strtok( $url, '#' );
		return untrailingslashit( strtolower( $url ) );
	}

	/**
	 * Candidate target posts: published, selected post types, excluding the
	 * current post. Newest first, bounded by a scan ceiling so we never scan
	 * the entire library on a single request.
	 *
	 * @param array<string,mixed> $settings
	 * @return array<int,array<string,mixed>>
	 */
	private function candidate_targets( int $current_post_id, array $settings ): array {
		global $wpdb;

		$types = $settings['target_post_types'];
		if ( empty( $types ) ) {
			return [];
		}
		$type_in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
		$ceiling = 400;

		$args   = $types;
		$args[] = $current_post_id;
		$args[] = $ceiling;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT ID, post_title
			 FROM {$wpdb->posts}
			 WHERE post_status = 'publish'
			   AND post_type IN ({$type_in})
			   AND ID <> %d
			   AND post_title <> ''
			 ORDER BY post_date DESC
			 LIMIT %d",
			$args
		), ARRAY_A );

		return is_array( $rows ) ? $rows : [];
	}

	/**
	 * Phrases to test for a target, longest/most-specific first: full title,
	 * meaningful n-grams (edge-trimmed of stop words), and SEO focus keyword.
	 *
	 * Matching n-grams (not just full titles) is what makes this actually
	 * surface links — a target titled "A Beginner's Guide to Internal Linking"
	 * matches the phrase "Internal Linking" inside an article even though the
	 * full title never appears verbatim.
	 *
	 * @param array<string,mixed> $settings
	 * @return string[]
	 */
	private function phrases_for_target( int $target_id, string $title, array $settings ): array {
		$min_words = (int) $settings['min_title_words'];
		$phrases   = [];

		$title = trim( (string) preg_replace( '/\s+/u', ' ', $title ) );
		$words = '' === $title ? [] : explode( ' ', $title );
		$wc    = count( $words );

		if ( $wc >= $min_words ) {
			$phrases[] = $title;
		}

		if ( $wc > $min_words && $wc <= 10 ) {
			for ( $len = $wc - 1; $len >= $min_words; $len-- ) {
				for ( $i = 0; $i + $len <= $wc; $i++ ) {
					$gram  = array_slice( $words, $i, $len );
					$first = $this->clean_word( $gram[0] );
					$last  = $this->clean_word( $gram[ count( $gram ) - 1 ] );
					if ( '' === $first || '' === $last ) {
						continue;
					}
					if ( $this->is_stop_word( $first ) || $this->is_stop_word( $last ) ) {
						continue;
					}
					$phrases[] = implode( ' ', $gram );
				}
			}
		}

		// SEO focus keywords — the Rank Math / Yoast adapter. Reads each
		// plugin's focus-keyword meta if present; absent plugins are simply
		// skipped, so this is an integration, never a dependency.
		foreach ( [ '_yoast_wpseo_focuskw', 'rank_math_focus_keyword' ] as $key ) {
			$kw = (string) get_post_meta( $target_id, $key, true );
			$kw = trim( explode( ',', $kw )[0] ?? '' );
			if ( '' !== $kw && str_word_count( $kw ) >= max( 1, $min_words - 1 ) ) {
				$phrases[] = $kw;
			}
		}

		// De-dupe (case-insensitive), preserve order.
		$seen = [];
		$out  = [];
		foreach ( $phrases as $p ) {
			$lk = mb_strtolower( trim( $p ) );
			if ( '' !== $lk && ! isset( $seen[ $lk ] ) ) {
				$seen[ $lk ] = true;
				$out[]       = trim( $p );
			}
		}
		return $out;
	}

	private function clean_word( string $w ): string {
		return trim( (string) preg_replace( '/[^\p{L}\p{N}]+/u', '', $w ) );
	}

	private function is_stop_word( string $w ): bool {
		static $stops = null;
		if ( null === $stops ) {
			$stops = array_flip( [
				'a', 'an', 'and', 'the', 'to', 'of', 'in', 'on', 'for', 'with',
				'how', 'what', 'why', 'when', 'your', 'you', 'is', 'are', 'be',
				'this', 'that', 'from', 'at', 'by', 'or', 'as', 'it', 'its',
				'guide', 'tips', 'best', 'top', 'ways', 'using', 'use',
			] );
		}
		return isset( $stops[ mb_strtolower( $w ) ] );
	}

	/**
	 * A short context snippet around the first occurrence of the phrase, so the
	 * writer can see where it would link.
	 */
	private function context_snippet( string $plain, string $phrase ): string {
		$pos = mb_stripos( $plain, $phrase );
		if ( false === $pos ) {
			return '';
		}
		$start   = max( 0, $pos - 40 );
		$len     = mb_strlen( $phrase ) + 80;
		$snippet = trim( mb_substr( $plain, $start, $len ) );
		if ( $start > 0 ) {
			$snippet = '…' . $snippet;
		}
		return $snippet . '…';
	}
}
