<?php
/**
 * Query class.
 *
 * Fetches related posts via a single WP_Query, with transient caching.
 *
 * @package IGIT_RPWT
 */

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

class IGIT_RPWT_Query {

	/**
	 * Transient key prefix.
	 */
	const CACHE_PREFIX = 'igit_rpwt_';

	/**
	 * Return related posts for $post_id.
	 *
	 * Results are cached in a transient unless cache_ttl is 0.
	 *
	 * @param int   $post_id
	 * @param array $args    Override any setting for this call.
	 * @return WP_Post[]
	 */
	public static function get_related_posts( $post_id, array $args = array() ) {
		$settings      = IGIT_RPWT_Settings::get_all();
		$post_count    = isset( $args['post_count'] ) ? absint( $args['post_count'] ) : absint( $settings['post_count'] );
		$relate_by     = isset( $args['relate_by'] )  ? $args['relate_by']  : $settings['relate_by'];
		$cache_ttl     = absint( $settings['cache_ttl'] );
		$post_types    = ! empty( $settings['post_types'] ) && is_array( $settings['post_types'] )
			? array_map( 'sanitize_key', $settings['post_types'] )
			: array( 'post' );
		// Runtime Pro guard: strip CPT post types if Pro is not active.
		// Protects against stale DB values saved during a previous Pro licence.
		if ( ! igit_rpwt_is_pro() ) {
			$builtin_types = array( 'post', 'page', 'attachment', 'revision', 'nav_menu_item' );
			$post_types    = array_values( array_filter( $post_types, function( $pt ) use ( $builtin_types ) {
				return in_array( $pt, $builtin_types, true );
			} ) );
			if ( empty( $post_types ) ) {
				$post_types = array( 'post' );
			}
		}
		$exclude_posts = ! empty( $settings['exclude_posts'] ) ? array_map( 'absint', (array) $settings['exclude_posts'] ) : array();
		$max_age_days  = absint( $settings['max_age_days'] );

		// Build the full not-in list (current post + manually excluded posts).
		$post__not_in = array_unique( array_merge( array( $post_id ), $exclude_posts ) );

		/**
		 * Filter the oversample multiplier used when fetching candidate related posts.
		 *
		 * The query fetches (multiplier × post_count) candidates so the min-term-overlap
		 * filter and rotate_results have a meaningful pool to work from.
		 * The effective value is clamped to a minimum of 1 (no oversampling).
		 *
		 * @param int $multiplier Default 3.
		 * @param int $post_count Display count requested.
		 * @param int $post_id    The current post ID.
		 */
		$oversample_multiplier = max( 1, (int) apply_filters( 'igit_rpwt_query_oversample_multiplier', 3, $post_count, $post_id ) );
		$fetch_limit           = max( $post_count * $oversample_multiplier, 12 );

		// Try transient/object cache first.
		$cache_key = self::CACHE_PREFIX . $post_id;
		if ( $cache_ttl > 0 ) {
			$cached = wp_using_ext_object_cache()
				? wp_cache_get( $cache_key, 'igit_rpwt' )
				: get_transient( $cache_key );
			if ( false !== $cached ) {
				$pool = $cached;
				if ( ! empty( $settings['rotate_results'] ) && igit_rpwt_is_pro() && count( $pool ) > $post_count ) {
					shuffle( $pool );
				}
				return array_slice( $pool, 0, $post_count );
			}
		}

		// ── Build tax_query using relate_taxonomies setting ──────────────────
		// Resolve relate_taxonomies with a runtime Pro guard to strip CPT-only
		// taxonomies when Pro is not active (protects against stale DB values).
		$relate_taxonomies = $settings['relate_taxonomies'] ?? array( 'category', 'post_tag' );
		if ( ! igit_rpwt_is_pro() ) {
			$builtin_types_tax = array( 'post', 'page', 'attachment', 'revision', 'nav_menu_item' );
			$relate_taxonomies = array_values( array_filter( $relate_taxonomies, function( $tax_slug ) use ( $builtin_types_tax ) {
				$tax_obj = get_taxonomy( $tax_slug );
				if ( ! $tax_obj ) {
					return false;
				}
				return ! empty( array_intersect( (array) $tax_obj->object_type, $builtin_types_tax ) );
			} ) );
			if ( empty( $relate_taxonomies ) ) {
				$relate_taxonomies = array( 'category', 'post_tag' );
			}
		}

		$tax_query = array();
		if ( 'fulltext' === $relate_by ) {
			$results = self::get_fulltext_related( $post_id, $post__not_in, $post_types, $fetch_limit );
		} elseif ( 'author' === $relate_by ) {
			$results = self::get_author_related( $post_id, $post__not_in, $post_types, $fetch_limit );
		} elseif ( 'series' === $relate_by ) {
			$results = self::get_series_related( $post_id, $post__not_in, $post_types, $fetch_limit );
		} else {
			$tax_query = self::build_tax_query( $post_id, $relate_by, $settings['exclude_categories'] ?? array(), $relate_taxonomies );
		}

		if ( 'fulltext' !== $relate_by && 'author' !== $relate_by && 'series' !== $relate_by ) {
		$query_args = array(
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'posts_per_page'         => $fetch_limit,
			'post__not_in'           => $post__not_in, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false,
			'ignore_sticky_posts'    => true,
		);

		// Max age filter.
		if ( $max_age_days > 0 ) {
			$query_args['date_query'] = array(
				array( 'after' => '-' . $max_age_days . ' days', 'inclusive' => true ),
			);
		}

		if ( ! empty( $tax_query ) ) {
			$query_args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery
		}

		/**
		 * Filter the WP_Query arguments used to fetch related posts.
		 *
		 * @param array $query_args WP_Query arguments.
		 * @param int   $post_id    Current post ID.
		 * @param array $args       Override arguments passed to get_related_posts().
		 */
		$query_args = apply_filters( 'igit_rpwt_query_args', $query_args, $post_id, $args );

		$query   = new WP_Query( $query_args );
		$results = $query->posts;
		} // end non-fulltext/author/series branch

		// ── Min-term overlap filter ──────────────────────────────────────────
		$min_overlap = absint( $settings['min_term_overlap'] );
		if ( $min_overlap > 1 && ! empty( $results ) ) {
			$source_terms  = self::get_post_term_ids( $post_id, $relate_taxonomies );
			$results = array_values( array_filter( $results, function( $p ) use ( $source_terms, $min_overlap, $relate_taxonomies ) {
				$candidate_terms = self::get_post_term_ids( $p->ID, $relate_taxonomies );
				return count( array_intersect( $source_terms, $candidate_terms ) ) >= $min_overlap;
			} ) );
		}

		// ── Configurable fallback ────────────────────────────────────────────
		if ( empty( $results ) && 'none' !== $settings['fallback_mode'] ) {
			$results = self::run_fallback( $post_id, $post__not_in, $post_types, $post_count, $settings['fallback_mode'] );
		}

		// ── Multisite cross-network search ───────────────────────────────────
		if ( ! empty( $settings['multisite_search'] ) && igit_rpwt_is_pro() && is_multisite() ) {
			$results = self::merge_multisite_results( $results, $post_id, $post_count, $relate_by, $settings );
		}

		/**
		 * Filter the array of related posts before caching and returning.
		 *
		 * Allows adding, removing, or reordering posts. Note: the full result set
		 * (up to post_count × 3) is passed here, not just the sliced display count.
		 *
		 * @param WP_Post[] $results Related post objects.
		 * @param int       $post_id Current post ID.
		 * @param array     $args    Override arguments passed to get_related_posts().
		 */
		$results = apply_filters( 'igit_rpwt_related_posts', $results, $post_id, $args );

		// Cache the full result set (slice when returning).
		if ( $cache_ttl > 0 && ! empty( $results ) ) {
			if ( wp_using_ext_object_cache() ) {
				wp_cache_set( $cache_key, $results, 'igit_rpwt', $cache_ttl );
			} else {
				set_transient( $cache_key, $results, $cache_ttl );
			}
		}

		if ( ! empty( $settings['rotate_results'] ) && igit_rpwt_is_pro() && count( $results ) > $post_count ) {
			shuffle( $results );
		}
		return array_slice( $results, 0, $post_count );
	}

	/**
	 * Find related posts by full-text content similarity.
	 * Tries MySQL FULLTEXT MATCH…AGAINST first; falls back to WP_Query 's' param.
	 *
	 * @param int      $post_id      Current post ID.
	 * @param int[]    $post__not_in Post IDs to exclude.
	 * @param string[] $post_types   Post types to search.
	 * @param int      $limit        Maximum posts to fetch.
	 * @return WP_Post[]
	 */
	private static function get_fulltext_related( $post_id, array $post__not_in, array $post_types, $limit ) {
		global $wpdb;

		$content = get_post_field( 'post_content', $post_id );
		$title   = get_the_title( $post_id );
		$text    = wp_strip_all_tags( $title . ' ' . $content );

		// Extract top keywords: strip common stopwords, sort by length, deduplicate.
		$stopwords = array( 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'was', 'that', 'this', 'with', 'have', 'from', 'they', 'will', 'been', 'their', 'what', 'when', 'your', 'more', 'also', 'into', 'than', 'then', 'its', 'had', 'has', 'which', 'about', 'there', 'would', 'other', 'some', 'just', 'like', 'over', 'such', 'after', 'most', 'through', 'where', 'being', 'each', 'these', 'those', 'how', 'any', 'very', 'even' );
		$words     = array_filter(
			array_unique( preg_split( '/\s+/', strtolower( $text ) ) ),
			function ( $w ) use ( $stopwords ) {
				return strlen( $w ) > 3 && ! in_array( $w, $stopwords, true );
			}
		);
		usort( $words, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } );
		$keywords = implode( ' ', array_slice( $words, 0, 10 ) );

		if ( empty( $keywords ) ) {
			return array();
		}

		// Try FULLTEXT MATCH…AGAINST.
		$not_in_sql    = implode( ',', array_map( 'absint', $post__not_in ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- absint() guarantees integer-only values.
		$post_type_sql = "'" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "'"; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- esc_sql() escapes each value.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$ids = $wpdb->get_col(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery
				"SELECT ID FROM {$wpdb->posts}
				WHERE post_status = 'publish'
				AND post_type IN ({$post_type_sql})
				AND ID NOT IN ({$not_in_sql})
				AND MATCH(post_content, post_title) AGAINST (%s IN BOOLEAN MODE)
				ORDER BY MATCH(post_content, post_title) AGAINST (%s IN BOOLEAN MODE) DESC
				LIMIT %d",
				$keywords,
				$keywords,
				$limit
			)
		);
		// phpcs:enable

		if ( ! empty( $ids ) ) {
			$q = new WP_Query( array(
				'post__in'               => array_map( 'absint', $ids ),
				'post_type'              => $post_types,
				'post_status'            => 'publish',
				'posts_per_page'         => $limit,
				'orderby'                => 'post__in',
				'no_found_rows'          => true,
				'update_post_term_cache' => false,
				'update_post_meta_cache' => false,
				'ignore_sticky_posts'    => true,
			) );
			return $q->posts;
		}

		// FULLTEXT returned no results; log a debug notice so site owners can investigate.
		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( sprintf(
				'IGIT RPWT: FULLTEXT query returned no results for post %d. Falling back to keyword search. '
				. 'For best results add: ALTER TABLE %s ADD FULLTEXT(post_content, post_title);',
				$post_id,
				$wpdb->posts
			) );
		}

		// Fallback: WP_Query search.
		$search_term = implode( ' ', array_slice( array_values( $words ), 0, 5 ) );
		$q = new WP_Query( array(
			's'                      => $search_term,
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'posts_per_page'         => $limit,
			'post__not_in'           => $post__not_in, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false,
			'ignore_sticky_posts'    => true,
		) );
		return $q->posts;
	}

	/**
	 * Find related posts by the same author.
	 *
	 * @param int      $post_id      Current post ID.
	 * @param int[]    $post__not_in Post IDs to exclude.
	 * @param string[] $post_types   Post types to search.
	 * @param int      $limit        Maximum posts to fetch.
	 * @return WP_Post[]
	 */
	private static function get_author_related( $post_id, array $post__not_in, array $post_types, $limit ) {
		$author_id = (int) get_post_field( 'post_author', $post_id );
		if ( ! $author_id ) {
			return array();
		}
		$q = new WP_Query( array(
			'author'                 => $author_id,
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'posts_per_page'         => $limit,
			'post__not_in'           => $post__not_in, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false,
			'ignore_sticky_posts'    => true,
		) );
		return $q->posts;
	}

	/**
	 * Find related posts in the same series (igit_series taxonomy).
	 *
	 * @param int      $post_id      Current post ID.
	 * @param int[]    $post__not_in Post IDs to exclude.
	 * @param string[] $post_types   Post types to search.
	 * @param int      $limit        Maximum posts to fetch.
	 * @return WP_Post[]
	 */
	private static function get_series_related( $post_id, array $post__not_in, array $post_types, $limit ) {
		if ( ! taxonomy_exists( 'igit_series' ) ) {
			return array();
		}
		$term_ids = wp_get_post_terms( $post_id, 'igit_series', array( 'fields' => 'ids' ) );
		if ( is_wp_error( $term_ids ) || empty( $term_ids ) ) {
			return array();
		}
		$q = new WP_Query( array(
			'tax_query'              => array( // phpcs:ignore WordPress.DB.SlowDBQuery
				array(
					'taxonomy' => 'igit_series',
					'field'    => 'term_id',
					'terms'    => $term_ids,
					'operator' => 'IN',
				),
			),
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'posts_per_page'         => $limit,
			'post__not_in'           => $post__not_in, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false,
			'ignore_sticky_posts'    => true,
		) );
		return $q->posts;
	}

	/**
	 * Append cross-site results from a multisite network.
	 *
	 * Iterates over all sites in the network, runs a simplified taxonomy query
	 * on each, and merges unique results into $current_results.
	 *
	 * @param WP_Post[] $current_results Existing results from the current site.
	 * @param int       $post_id         Current post ID.
	 * @param int       $post_count      Desired total result count.
	 * @param string    $relate_by       Relate-by strategy.
	 * @param array     $settings        Full settings array.
	 * @return WP_Post[]
	 */
	private static function merge_multisite_results( array $current_results, $post_id, $post_count, $relate_by, array $settings ) {
		$current_blog_id = get_current_blog_id();
		$need            = max( 0, ( $post_count * 3 ) - count( $current_results ) );
		if ( $need <= 0 ) {
			return $current_results;
		}

		$sites = get_sites( array(
			'number'  => 50,
			'site__not_in' => array( $current_blog_id ),
		) );

		$seen_urls = array();
		foreach ( $current_results as $p ) {
			$seen_urls[] = get_permalink( $p->ID );
		}

		$cross_posts = array();

		foreach ( $sites as $site ) {
			switch_to_blog( $site->blog_id );

			$tax_query = self::build_tax_query(
				0, // no source post on remote site; just use same terms
				in_array( $relate_by, array( 'category', 'tag', 'both' ), true ) ? $relate_by : 'both',
				array(),
				array( 'category', 'post_tag' )
			);

			// Best-effort: match by the same words from the source post title.
			$search = implode( ' ', array_slice( preg_split( '/\s+/', wp_strip_all_tags( get_the_title( $post_id ) ) ), 0, 5 ) );

			$q_args = array(
				's'                      => $search,
				'post_type'              => $settings['post_types'],
				'post_status'            => 'publish',
				'posts_per_page'         => max( 6, $need ),
				'no_found_rows'          => true,
				'update_post_term_cache' => false,
				'update_post_meta_cache' => false,
				'ignore_sticky_posts'    => true,
			);

			$remote_q = new WP_Query( $q_args );

			foreach ( $remote_q->posts as $remote_post ) {
				$url = get_permalink( $remote_post->ID );
				if ( ! in_array( $url, $seen_urls, true ) ) {
					// Attach blog_id so the template can build the correct permalink.
					$remote_post->igit_blog_id = $site->blog_id;
					$remote_post->igit_url     = $url;
					$cross_posts[]             = $remote_post;
					$seen_urls[]               = $url;
				}
			}

			restore_current_blog();

			if ( count( $cross_posts ) >= $need ) {
				break;
			}
		}

		return array_merge( $current_results, $cross_posts );
	}

	/**
	 * Build a tax_query array based on the current post's terms.
	 *
	 * Supports arbitrary taxonomy slugs via $relate_taxonomies. The legacy
	 * $relate_by ('category', 'tag', 'both') is preserved as a quick UI toggle
	 * that filters which of the relate_taxonomies to actually use.
	 *
	 * @param int    $post_id            Current post ID.
	 * @param string $relate_by          'category', 'tag', or 'both'.
	 * @param array  $exclude_categories Category IDs to force-exclude from results.
	 * @param array  $relate_taxonomies  Taxonomy slugs to match on.
	 * @return array
	 */
	private static function build_tax_query( $post_id, $relate_by, array $exclude_categories, array $relate_taxonomies = array( 'category', 'post_tag' ) ) {
		$clauses = array();

		foreach ( $relate_taxonomies as $taxonomy ) {
			$taxonomy = sanitize_key( $taxonomy );
			if ( ! taxonomy_exists( $taxonomy ) ) {
				continue;
			}

			// Legacy relate_by gate: honour 'category' / 'tag' selections.
			if ( 'category' === $taxonomy && 'tag' === $relate_by ) {
				continue;
			}
			if ( 'post_tag' === $taxonomy && 'category' === $relate_by ) {
				continue;
			}

			$term_ids = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) );
			if ( is_wp_error( $term_ids ) || empty( $term_ids ) ) {
				continue;
			}

			// Strip excluded categories from category clause.
			if ( 'category' === $taxonomy && ! empty( $exclude_categories ) ) {
				$term_ids = array_values( array_diff(
					array_map( 'absint', $term_ids ),
					array_map( 'absint', $exclude_categories )
				) );
			}

			if ( ! empty( $term_ids ) ) {
				$clauses[] = array(
					'taxonomy' => $taxonomy,
					'field'    => 'term_id',
					'terms'    => $term_ids,
					'operator' => 'IN',
				);
			}
		}

		// Always suppress explicitly excluded categories from results.
		if ( ! empty( $exclude_categories ) && taxonomy_exists( 'category' ) ) {
			$clauses[] = array(
				'taxonomy' => 'category',
				'field'    => 'term_id',
				'terms'    => array_map( 'absint', $exclude_categories ),
				'operator' => 'NOT IN',
			);
		}

		if ( empty( $clauses ) ) {
			return array();
		}

		$clauses['relation'] = 'OR';
		return $clauses;
	}

	/**
	 * Get all term IDs for a post across multiple taxonomies.
	 *
	 * @param int   $post_id    Post ID.
	 * @param array $taxonomies Taxonomy slugs.
	 * @return int[]
	 */
	private static function get_post_term_ids( $post_id, array $taxonomies ) {
		$ids = array();
		foreach ( $taxonomies as $taxonomy ) {
			$terms = wp_get_post_terms( $post_id, sanitize_key( $taxonomy ), array( 'fields' => 'ids' ) );
			if ( ! is_wp_error( $terms ) ) {
				$ids = array_merge( $ids, $terms );
			}
		}
		return $ids;
	}

	/**
	 * Run a fallback query when the primary taxonomy query returns no results.
	 *
	 * @param int      $post_id      Current post ID.
	 * @param int[]    $post__not_in Post IDs to exclude.
	 * @param string[] $post_types   Post types to include.
	 * @param int      $post_count   Number of posts needed.
	 * @param string   $mode         'recent', 'popular', or 'random'.
	 * @return WP_Post[]
	 */
	private static function run_fallback( $post_id, array $post__not_in, array $post_types, $post_count, $mode ) {
		$orderby_map = array(
			'recent'  => array( 'orderby' => 'date',          'order' => 'DESC' ),
			'popular' => array( 'orderby' => 'comment_count', 'order' => 'DESC' ),
			'random'  => array( 'orderby' => 'rand',          'order' => 'DESC' ),
		);
		$order = isset( $orderby_map[ $mode ] ) ? $orderby_map[ $mode ] : $orderby_map['random'];

		$q = new WP_Query( array_merge(
			array(
				'post_type'              => $post_types,
				'post_status'            => 'publish',
				'posts_per_page'         => $post_count * 3,
				'post__not_in'           => $post__not_in, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
				'no_found_rows'          => true,
				'update_post_term_cache' => false,
				'update_post_meta_cache' => false,
				'ignore_sticky_posts'    => true,
			),
			$order
		) );
		return $q->posts;
	}

	/**
	 * Delete the cached related posts for a given post.
	 * Hooked to save_post.
	 *
	 * @param int $post_id
	 */
	public static function flush_post_cache( $post_id ) {
		$key = self::CACHE_PREFIX . $post_id;
		delete_transient( $key );
		wp_cache_delete( $key, 'igit_rpwt' );
	}

	/**
	 * Delete all igit_rpwt transients.
	 * Called on plugin deactivation or settings save.
	 */
	public static function flush_all_cache() {
		global $wpdb;

		// 1. Clear the object-cache group (WP 6.1+ supports wp_cache_flush_group()).
		if ( wp_using_ext_object_cache() ) {
			if ( function_exists( 'wp_cache_flush_group' ) ) {
				wp_cache_flush_group( 'igit_rpwt' );
			}
			// Older object caches: individual keys cannot be bulk-deleted, so
			// the transient purge below is still the primary mechanism.
		}

		// 2. Remove all transients from the options table.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
				'%_transient_' . self::CACHE_PREFIX . '%',
				'%_transient_timeout_' . self::CACHE_PREFIX . '%'
			)
		);
	}
}
