<?php
declare( strict_types=1 );

namespace FiboFilters\SeoRules;

use FiboFilters\Index\Documents;
use FiboFilters\Index\Sources;
use FiboFilters\Index\Values;
use FiboFilters\WPDBException;

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

/**
 * Estimates whether a filter combination has at least N products using the doclist table.
 * One SQL query per SEO rule and intersections calculated in PHP on an in-memory index.
 */
class SeoRuleProductEstimator {
	/**
	 * @var Sources
	 */
	private Sources $sources;
	/**
	 * @var Values
	 */
	private Values $values;
	/**
	 * @var Documents
	 */
	private Documents $documents;
	/**
	 * @var int
	 */
	private int $min_products;

	public function __construct( Sources $sources, Values $values, Documents $documents ) {
		$this->sources   = $sources;
		$this->values    = $values;
		$this->documents = $documents;

		$this->min_products = (int) apply_filters(
			'fibofilters/seo/min_products_per_url',
			5
		);
	}

	/**
	 * Convenience wrapper:
	 *  - resolves category_value_id from rule,
	 *  - builds value index for all value_ids used in that rule.
	 *
	 * @param SeoRule $rule Seo rule.
	 * @param int[]   $all_value_ids All value_ids used in this rule.
	 *
	 * @return array
	 * @throws WPDBException On DB errors.
	 */
	public function build_value_index_for_rule( SeoRule $rule, array $all_value_ids ): array {
		$taxonomy_value_id = $this->get_taxonomy_value_id_for_rule( $rule );

		return $this->documents->build_value_post_tree( $taxonomy_value_id, $all_value_ids );
	}

	/**
	 * Maps taxonomy type + term_id to value_id in index.
	 *
	 * @param string $type    'product_cat' or 'product_tag'.
	 * @param int    $term_id Term ID.
	 *
	 * @return int value_id or 0.
	 * @throws WPDBException On DB errors.
	 */
	public function get_taxonomy_value_id_for_term( string $type, int $term_id ): int {
		if ( ! in_array( $type, [ 'product_cat', 'product_tag' ], true ) ) {
			return 0;
		}

		if ( $term_id <= 0 ) {
			return 0;
		}

		$taxonomy    = ( 'product_cat' === $type ) ? 'product_cat' : 'product_tag';
		$source_name = ( 'product_cat' === $type ) ? 'tax_product_cat' : 'tax_product_tag';

		$source_id = $this->sources->get_source_id_by_name( $source_name );

		if ( $source_id <= 0 ) {
			return 0;
		}

		$term = get_term( $term_id, $taxonomy );
		if ( ! $term || is_wp_error( $term ) ) {
			return 0;
		}

		$label_url = $term->slug;

		$rows = $this->values->get_value_by_label_url( $label_url, $source_id );

		if ( empty( $rows ) || empty( $rows[0]['value_id'] ) ) {
			return 0;
		}

		return (int) $rows[0]['value_id'];
	}

	/**
	 * Build value index for rules of type "product_cat:any"
	 *
	 * @param int[] $all_value_ids      value_id from filters.
	 * @param int[] $taxonomy_value_ids value_id of categories/tags.
	 *
	 * @return array
	 * @throws WPDBException On DB errors.
	 */
	public function build_value_index_for_any_taxonomy(
		array $all_value_ids,
		array $taxonomy_value_ids
	): array {
		$combined_value_ids = array_merge( $all_value_ids, $taxonomy_value_ids );

		return $this->documents->build_value_post_tree( 0, $combined_value_ids );
	}

	/**
	 * Checks if a given filter combination has at least N products
	 * using an in-memory index built by build_value_index():
	 *
	 * [ value_id => [ parent_id => post_ids[] ] ].
	 *
	 * The N threshold is read from the 'fibofilters/seo/min_products_per_url' filter.
	 *
	 * @param array $filters     ['color' => ['value_id' => 207, ...], ...].
	 * @param array $value_index [value_id => product_ids[]].
	 */
	public function has_enough_products_for_filters(
		array $filters,
		array $value_index
	): bool {

		$value_ids = [];

		foreach ( $filters as $filter_data ) {
			if ( empty( $filter_data['value_id'] ) ) {
				continue;
			}
			$value_id = (int) $filter_data['value_id'];
			if ( $value_id > 0 ) {
				$value_ids[] = $value_id;
			}
		}

		$value_ids = array_values( array_unique( $value_ids ) );

		if ( empty( $value_ids ) ) {
			return false;
		}

		// Early-fail: if any value_id has no products at all.
		foreach ( $value_ids as $value_id ) {
			if ( empty( $value_index[ $value_id ] ) ) {
				return false;
			}
		}

		// Single-value combination: number of products = number of parent_ids.
		if ( 1 === count( $value_ids ) ) {
			$single_value_id   = $value_ids[0];
			$parents_for_value = $value_index[ $single_value_id ] ?? [];

			return count( $parents_for_value ) >= $this->min_products;
		}

		// Multi-value combination.
		// Choose as base the value_id with the fewest parents (smallest list).
		$base_value_id       = null;
		$smallest_parent_cnt = PHP_INT_MAX;

		foreach ( $value_ids as $value_id ) {
			$parents_for_value = $value_index[ $value_id ] ?? [];
			$cnt               = count( $parents_for_value );

			if ( $cnt < $smallest_parent_cnt ) {
				$smallest_parent_cnt = $cnt;
				$base_value_id       = $value_id;
			}
		}

		if ( null === $base_value_id ) {
			return false;
		}

		$remaining_value_ids = array_values(
			array_diff( $value_ids, [ $base_value_id ] )
		);

		$base_by_parent    = $value_index[ $base_value_id ];
		$qualified_parents = 0;

		foreach ( $base_by_parent as $parent_id => $base_post_ids ) {
			if ( empty( $base_post_ids ) ) {
				continue;
			}

			$post_lists = [ $base_post_ids ];

			// For this parent, collect post_id lists for all other value_ids.
			foreach ( $remaining_value_ids as $value_id ) {
				$posts_for_parent_and_value = $value_index[ $value_id ][ $parent_id ] ?? [];

				if ( empty( $posts_for_parent_and_value ) ) {
					// This parent doesn't have this value_id at all -> cannot match full combination.
					$post_lists = [];
					break;
				}

				$post_lists[] = $posts_for_parent_and_value;
			}

			if ( empty( $post_lists ) ) {
				continue;
			}

			// Check if this parent has at least one variation (post_id)
			// that appears in all post_lists.
			if ( $this->parent_has_any_common_post( $post_lists ) ) {
				$qualified_parents++;

				// Early exit as soon as we meet the threshold.
				if ( $qualified_parents >= $this->min_products ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Maps rule base_view (e.g. 'product_cat:225' or 'product_tag:37')
	 * to a taxonomy value_id from the index, or returns 0 when not applicable / not found.
	 *
	 * @param SeoRule $rule Seo rule.
	 *
	 * @return int Context value_id or 0.
	 * @throws WPDBException On DB errors.
	 */
	private function get_taxonomy_value_id_for_rule( SeoRule $rule ): int {
		$base_view = $rule->get_base_view();

		list( $type, $arg ) = SeoRuleUrlResolver::parse_base_view( $base_view );

		// For "any" we intentionally return 0 - with this rule we will count the category per-URL, not per-rule.
		if ( $arg === 'any' ) {
			return 0;
		}

		$term_id = (int) $arg;

		return $this->get_taxonomy_value_id_for_term( $type, $term_id );
	}

	/**
	 * Checks whether the intersection of all product ID lists
	 * contains at least $min_products elements.
	 *
	 * Optimised by:
	 *  - using the shortest list as a base,
	 *  - converting other lists to hash-sets for O(1) membership checks,
	 *  - stopping as soon as we reach $min_products.
	 *
	 * @param array $post_lists Lists of product IDs to check.
	 */
	private function parent_has_any_common_post( array $post_lists ): bool {

		if ( empty( $post_lists ) ) {
			return false;
		}

		// Pick the shortest list as the base.
		$base_index    = 0;
		$shortest_size = count( $post_lists[0] );

		foreach ( $post_lists as $index => $list ) {
			$current_size = count( $list );
			if ( $current_size < $shortest_size ) {
				$shortest_size = $current_size;
				$base_index    = $index;
			}
		}

		$base_list = $post_lists[ $base_index ];
		unset( $post_lists[ $base_index ] );

		if ( empty( $base_list ) ) {
			return false;
		}

		// Convert remaining lists into sets: [post_id => true].
		$sets = [];
		foreach ( $post_lists as $list ) {
			$sets[] = array_fill_keys( $list, true );
		}

		// Check if any post_id from base_list exists in all sets.
		foreach ( $base_list as $post_id ) {
			$present_in_all = true;

			foreach ( $sets as $set ) {
				if ( ! isset( $set[ $post_id ] ) ) {
					$present_in_all = false;
					break;
				}
			}

			if ( $present_in_all ) {
				return true;
			}
		}

		return false;
	}
}
