<?php
namespace WPDRMS\ASP\Suggest;

use WPDRMS\ASP\Index\ORM\IndexTable;
use WPDRMS\Utils\MB;
use WPDRMS\Utils\Suggest;

defined('ABSPATH') or die("You can't access this file directly.");

/**
 * Keyword suggestion source backed by the index table (asp_index).
 *
 * Two modes, driven by the `match_start` argument the framework already passes per path:
 *  - match_start = true  (autocomplete): indexed prefix match on the typed word, ranked by
 *                          weighted occurrence. Fast, uses the term(20) index.
 *  - match_start = false (keyword suggestions): typo correction. Scans a bounded, popularity
 *                          ranked vocabulary pool with Suggest::getSimilarText() - NOT prefix
 *                          anchored, so early-position typos and transpositions (e.g. "blet"
 *                          -> "belt") are corrected. The pool is cached in a transient.
 *
 * The index vocabulary is single-word; multi-word input is matched on the last word and the
 * suggestion is rebuilt as "<leading words> <matched term>" (e.g. "red blet" -> "red belt").
 */
class SuggestIndexTable extends AbstractSuggest {
	/** @var array<string, mixed> */
	protected array $args;

	/**
	 * Number of top terms (by weighted occurrence) kept in the fuzzy candidate pool.
	 */
	private const POOL_SIZE = 3000;

	/**
	 * Cached candidate pool TTL (seconds). Also a vocabulary list, so a stale day is harmless.
	 */
	private const POOL_TTL = 43200; // 12 hours

	/**
	 * Transient key prefix for the cached candidate pool. Shared by candidatePool() and
	 * purgePool() so the key the cache is written under can never drift from the one it is
	 * cleared under.
	 */
	private const POOL_KEY_PREFIX = 'asp_idx_sugg_pool_';

	/**
	 * Option holding the pool cache generation. It is folded into every pool transient key, so
	 * bumping it (purgePool()) makes all cached variants unreachable at once - even behind a
	 * persistent object cache, where a DB-only DELETE would miss entries. Mirrors the
	 * asp_ai_cache_gen mechanism used by AIService.
	 */
	private const POOL_GEN_OPTION = 'asp_idx_sugg_pool_gen';

	/**
	 * @param array<string, mixed> $args
	 */
	public function __construct( array $args = array() ) {
		$defaults   = array(
			'maxCount'        => 10,
			'maxCharsPerWord' => 25,
			'postTypes'       => array(),
			'lang'            => '',
			'match_start'     => false,
			'search_id'       => 0,
		);
		$this->args = wp_parse_args( $args, $defaults );
	}

	/**
	 * @return string[]
	 */
	public function getKeywords( string $q ): array {
		if ( IndexTable::isEmpty() ) {
			return array();
		}

		// The index stores single words; match the last word being typed and keep the rest
		// of the phrase as a prefix to rebuild a valid completion / corrected phrase.
		$parts  = preg_split( '/\s+/', trim( $q ) );
		$parts  = is_array( $parts ) ? $parts : array();
		$needle = (string) array_pop( $parts );
		$lead   = count( $parts ) > 0 ? implode( ' ', $parts ) . ' ' : '';

		if ( $needle === '' ) {
			return array();
		}

		$terms = $this->args['match_start']
			? $this->prefixTerms( $needle )
			: Suggest::getSimilarText( $this->candidatePool(), $needle, (int) $this->args['maxCount'] );

		return $this->buildSuggestions( $terms, $lead, $q );
	}

	/**
	 * Autocomplete mode: indexed prefix match on the term, ordered by weighted occurrence.
	 *
	 * @param string $needle Word being typed.
	 * @return string[]
	 */
	private function prefixTerms( string $needle ): array {
		global $wpdb;
		$table = IndexTable::getTableName();

		/*
		 * The statement is assembled from a trusted table name (getTableName()) and static
		 * clause fragments; every dynamic value is passed as a %s/%d placeholder to prepare().
		 * The sniffs cannot trace placeholders through the clause helper methods, so they are
		 * disabled for this assembled-but-prepared, read-only query.
		 */
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$query = $wpdb->prepare(
			'SELECT term, ' . $this->occurrenceExpr() . " AS occ FROM $table" .
			' WHERE term LIKE %s' . $this->blogClause() . $this->postTypeClause() . $this->langClause() .
			' GROUP BY term ORDER BY occ DESC LIMIT %d',
			array_merge(
				array( $wpdb->esc_like( $needle ) . '%' ),
				$this->clauseParams(),
				array( (int) $this->args['maxCount'] )
			)
		);
		$query = apply_filters( 'asp/suggestions/index/query', $query, $needle );

		$rows = $wpdb->get_col( $query );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = apply_filters( 'asp/suggestions/index/results', $rows, $needle );

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Keyword-suggestion mode: top-N indexed terms by weighted occurrence, cached as a vocabulary
	 * pool to scan with getSimilarText(). Not prefix anchored - corrects typos at any position.
	 *
	 * @return string[]
	 */
	private function candidatePool(): array {
		$key  = $this->poolKey();
		$pool = get_transient( $key );
		if ( is_array( $pool ) ) {
			return $pool;
		}

		global $wpdb;
		$table = IndexTable::getTableName();

		// See prefixTerms() for why the PreparedSQL sniffs are disabled around this assembled-but-prepared query.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$query = $wpdb->prepare(
			'SELECT term, ' . $this->occurrenceExpr() . " AS occ FROM $table" .
			' WHERE 1=1' . $this->blogClause() . $this->postTypeClause() . $this->langClause() .
			' GROUP BY term ORDER BY occ DESC LIMIT %d',
			array_merge( $this->clauseParams(), array( self::POOL_SIZE ) )
		);
		$query = apply_filters( 'asp/suggestions/index/pool_query', $query );

		$pool = $wpdb->get_col( $query );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$pool = is_array( $pool ) ? $pool : array();

		set_transient( $key, $pool, self::POOL_TTL );

		return $pool;
	}

	/**
	 * Per-blog, per-generation, per-variant transient key for the candidate pool.
	 */
	private function poolKey(): string {
		$gen = (int) get_option( self::POOL_GEN_OPTION, 1 );
		return self::POOL_KEY_PREFIX . get_current_blog_id() . '_' . $gen . '_' .
			md5( $this->args['lang'] . '|' . implode( ',', (array) $this->args['postTypes'] ) );
	}

	/**
	 * Invalidates every cached candidate pool variant. Hooked to full index-rebuild completion
	 * (see SuggestHooks) so keyword suggestions never serve a vocabulary that predates a reindex
	 * for up to POOL_TTL.
	 *
	 * Bumping the generation option busts all variants instantly, including behind a persistent
	 * object cache; the follow-up DB DELETE then clears the now-orphaned transient rows on sites
	 * that have no object cache, so the options table does not accumulate dead entries.
	 */
	public static function purgePool(): void {
		update_option( self::POOL_GEN_OPTION, (int) get_option( self::POOL_GEN_OPTION, 1 ) + 1 );

		global $wpdb;
		$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->prepare(
				"DELETE FROM `{$wpdb->options}` WHERE `option_name` LIKE %s OR `option_name` LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$wpdb->esc_like( '_transient_' . self::POOL_KEY_PREFIX ) . '%',
				$wpdb->esc_like( '_transient_timeout_' . self::POOL_KEY_PREFIX ) . '%'
			)
		);
	}

	/**
	 * Normalises raw terms into final suggestions: rebuilds the phrase, trims to length,
	 * drops the original query, dedupes and slices to maxCount.
	 *
	 * @param string[] $terms Raw matched terms.
	 * @param string   $lead  Leading words of the phrase (empty for single-word input).
	 * @param string   $q     Original query.
	 * @return string[]
	 */
	private function buildSuggestions( array $terms, string $lead, string $q ): array {
		$res = array();
		$ql  = MB::strtolower( $q );

		foreach ( $terms as $term ) {
			$suggestion = MB::strtolower( $lead . $term );
			$suggestion = wd_substr_at_word( $suggestion, $this->args['maxCharsPerWord'], '' );
			if ( $suggestion === '' || $suggestion === $ql || in_array( $suggestion, $res, true ) ) {
				continue;
			}
			$res[] = $suggestion;
			if ( count( $res ) >= $this->args['maxCount'] ) {
				break;
			}
		}

		return $res;
	}

	private function occurrenceExpr(): string {
		return '(SUM(title) * 3 + SUM(content) + SUM(excerpt) + SUM(comment) + SUM(tag) + SUM(link) + SUM(author) + SUM(customfield))';
	}

	private function blogClause(): string {
		return ' AND blogid = %d';
	}

	private function postTypeClause(): string {
		$types = array_filter( (array) $this->args['postTypes'] );
		if ( count( $types ) === 0 ) {
			return '';
		}
		return ' AND post_type IN (' . implode( ', ', array_fill( 0, count( $types ), '%s' ) ) . ')';
	}

	private function langClause(): string {
		$lang = (string) $this->args['lang'];
		if ( $lang === '' || $lang === '0' ) {
			return '';
		}
		return " AND (lang = %s OR lang = '')";
	}

	/**
	 * Ordered placeholder values matching blogClause() + postTypeClause() + langClause().
	 *
	 * @return array<int, int|string>
	 */
	private function clauseParams(): array {
		$params = array( get_current_blog_id() );

		$types = array_filter( (array) $this->args['postTypes'] );
		foreach ( $types as $type ) {
			$params[] = $type;
		}

		$lang = (string) $this->args['lang'];
		if ( $lang !== '' && $lang !== '0' ) {
			$params[] = $lang;
		}

		return $params;
	}
}
