<?php
declare( strict_types=1 );

namespace FiboFilters\Filters;

use FiboFilters\Filters\Types\Filter;
use FiboFilters\Index\Sources;
use FiboFilters\Index\Values;
use FiboFilters\InvalidFilterException;
use FiboFilters\Struct\QueryContext;
use FiboFilters\Util\FilterUtil;

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

/**
 * Class FiltersManager
 */
class FiltersManager {
	/** @var Filter[] */
	public $instances = [];

	/** @var FilterFactory */
	private $filter_factory;

	/** @var StoreFilters */
	private $store_filters;

	/** @var Sources */
	private $sources;

	/** @var Values */
	private Values $values;

	/** @var FiltersQuery */
	private $filters_query;

	public function __construct(
		FilterFactory $filter_factory,
		StoreFilters $store_filters,
		FiltersQuery $filters_query,
		Sources $sources,
		Values $values
	) {
		$this->filter_factory = $filter_factory;
		$this->store_filters  = $store_filters;
		$this->filters_query  = $filters_query;
		$this->sources        = $sources;
		$this->values         = $values;
	}

	/**
	 * Convert HTTP GET params to instances of filters. Set them for later use as {@see FiltersManager::instances}.
	 *
	 * @param array  $get_vars $_GET vars.
	 * @param string $language Language.
	 *
	 * @return void
	 */
	public function set_filter_instances_by_http_get_params( array $get_vars, string $language = '' ): void {
		if ( empty( $get_vars ) ) {
			return;
		}
		$ctx = new QueryContext( $language );

		$all_filter_url_slugs = $this->store_filters->get_all_filter_url_slugs();

		foreach ( $get_vars as $param => $value ) {
			// Select only filters ids which exists in fact.
			if ( ! in_array( $param, $all_filter_url_slugs, true ) ) {
				continue;
			}

			try {
				$filter_id = $this->store_filters->get_filter_id_by_url_slug( $param );
				if ( $filter_id ) {
					$instance = $this->filter_factory->create_filter_instance( $filter_id, false );
					if ( is_object( $instance ) && is_a( $instance, 'FiboFilters\Filters\Types\Filter' ) ) {
						$instance->set_prop( 'source_id', $this->sources->get_source_id_by_name( $instance->get_source(), $ctx ) );
						$instance->set_user_input( $value );
						$this->instances[] = $instance;
					}
				}
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// There is no need to do anything.
			}
		}
	}

	public function set_all_filter_instances( string $language = '' ): void {
		$ctx     = new QueryContext( $language );
		$sources = $this->sources->get_all_sources( $ctx );

		$filters = $this->store_filters->get_all_filters();

		$source_ids = [];

		foreach ( $filters as $filter ) {
			if ( array_key_exists( $filter['source'], $sources ) ) {
				$source_ids[] = $sources[ $filter['source'] ];
			}
		}

		$this->set_filter_instances_by_source_ids( $source_ids, $language );
	}

	/**
	 * Convert sources to instances of filters. Set them for later use as {@see FiltersManager::instances}.
	 *
	 * @param array  $source_ids IDs collection of the filter sources.
	 * @param string $language Language.
	 */
	public function set_filter_instances_by_source_ids( array $source_ids, string $language = '' ): void {
		$all_filters       = $this->store_filters->get_all_filters();
		$used_instance_ids = [];
		$ctx               = new QueryContext( $language );

		foreach ( $this->instances as $instance ) {
			$used_instance_ids[] = $instance->get_id();
		}

		foreach ( $source_ids as $source_id ) {
			$source_name = $this->sources->get_source_name_by_id( $source_id, $ctx );

			foreach ( $all_filters as $filter ) {
				$new_instance = false;

				if ( $source_name !== $filter['source'] ) {
					continue;
				}

				// Get the filter instance created earlier or create new one.
				if ( in_array( $filter['id'], $used_instance_ids, true ) ) {
					try {
						$instance = $this->get_instance_by_id( $filter['id'] );
					} catch ( \Throwable $e ) {
						$instance = false;
					}
				} else {
					try {
						$instance     = $this->filter_factory->create_filter_instance( $filter['id'] );
						$new_instance = true;
					} catch ( \Throwable $e ) {
						$instance = false;
					}
				}

				if ( is_object( $instance ) && is_a( $instance, 'FiboFilters\Filters\Types\Filter' ) ) {

					$instance->set_prop( 'source_id', $source_id );

					if ( $new_instance ) {
						$this->instances[] = $instance;
					}
				}
			}
		}
	}

	/**
	 * Create a complex query
	 *
	 * @param string[] $excluded Filter instance ids that should be omitted.
	 *
	 * @return array
	 */
	public function create_complex_query( array $excluded = [] ): array {
		$complex_query = [];

		foreach ( $this->instances as $instance ) {

			if ( ! $instance->is_selected() ) {
				continue;
			}

			if ( in_array( $instance->get_id(), $excluded, true ) ) {
				continue;
			}

			$elementary_query = $instance->get_elementary_query();
			if ( ! empty( $elementary_query ) ) {
				$complex_query[] = $elementary_query;
			}
		}

		return $complex_query;
	}

	/**
	 * Get instances of initialized filters
	 *
	 * @return Filter[]
	 */
	public function get_instances(): array {
		return $this->instances;
	}

	/**
	 * Get the instance of the filter by id
	 *
	 * @param string $id ID of the filter instance.
	 *
	 * @return Filter
	 * @throws InvalidFilterException The filter instance couldn't be found.
	 */
	public function get_instance_by_id( string $id ): Filter {
		foreach ( $this->instances as $instance ) {
			if ( $instance->get_id() === $id ) {
				return $instance;
			}
		}

		throw new InvalidFilterException( 'There is no such filter instance' );
	}

	/**
	 * Add to the filter all possible values that are assigned to the source ID.
	 * It doesn't matter if there are assigned products.
	 *
	 * @param Filter $filter The instance of the filter.
	 * @param string $language Language.
	 *
	 * @return void
	 */
	public function fill_filter_with_all_values( Filter $filter, string $language = '' ) {
		$ctx    = new QueryContext( $language );
		$values = $this->values->get_values_by_source_id( $filter->get_prop( 'source_id' ), $ctx );

		// Limit filter values.
		$values = $this->limit_filter_values( $values, $filter, 'excluded', 'value', $language );
		$values = $this->limit_filter_values( $values, $filter, 'included', 'value', $language );
		$values = $this->limit_filter_values( $values, $filter, 'excluded', 'label_url', $language );
		$values = $this->limit_filter_values( $values, $filter, 'included', 'label_url', $language );

		$values = $this->limit_total_number_of_filter_values( $values, $filter );

		$filter->set_values( $values );
	}

	/**
	 * Get filter data for all filter instances.
	 *
	 * @param string $context Context where the data is needed. Values: 'all', 'frontend-filtering'.
	 *
	 * @return array
	 */
	public function get_filters_data( string $context = 'all' ): array {
		$data = [];

		if ( $context === 'frontend-filtering' ) {
			foreach ( $this->instances as $instance ) {
				$instance_data = $instance->get_data( $context );
				if ( ! empty( $instance_data ) ) {
					$data[] = $instance_data;
				}
			}
		} else {
			foreach ( $this->instances as $instance ) {
				$instance_data = $instance->get_data( $context );
				if ( ! empty( $instance_data ) ) {
					$data[ $instance->get_id() ] = $instance_data;
				}
			}
		}

		return $data;
	}

	/**
	 * Exclude or include values of filter
	 *
	 * @param array  $values Filter values.
	 * @param Filter $filter Filter instance.
	 * @param string $selection_type Selection type. Options: 'excluded', 'included'.
	 * @param string $selection_field Selection field. Options: 'value', 'label_url'.
	 * @param string $language Language.
	 */
	private function limit_filter_values( array $values, Filter $filter, string $selection_type, string $selection_field, string $language ): array {
		if ( ! in_array( $selection_type, [ 'excluded', 'included' ], true ) ) {
			return $values;
		}
		if ( ! in_array( $selection_field, [ 'value', 'label_url' ], true ) ) {
			return $values;
		}
		// Skip processing some filters.
		if ( in_array( $filter->get_prop( 'source' ), [ 'price' ], true ) ) {
			return $values;
		}
		// Break early if the value array is empty.
		if ( empty( $values ) ) {
			return $values;
		}

		$filter_id       = $filter->get_prop( 'id' );
		$filter_url_slug = $filter->get_prop( 'url_slug' );

		$selected1 = apply_filters( "fibofilters/filter/values/{$selection_type}_by_{$selection_field}/id={$filter_id}", [] );
		$selected2 = apply_filters( "fibofilters/filter/values/{$selection_type}_by_{$selection_field}/url_slug={$filter_url_slug}", [] );
		$selected  = array_merge( $selected1, $selected2 );

		if ( empty( $selected ) ) {
			return $values;
		}

		$selected = array_map( 'strval', $selected );

		/**
		 * If the source is a hierarchical taxonomy, we additionally collect all children of the indicated items.
		 * We rely on the "valuelist" table.
		 */
		if ( strpos( $filter->get_prop( 'source' ), 'tax_' ) === 0 && is_taxonomy_hierarchical( str_replace( 'tax_', '', $filter->get_prop( 'source' ) ) ) ) {
			$children = [];
			foreach ( $selected as $selected_item ) {
				$children_partial = $this->filters_query->get_value_children( $selected_item, $filter->get_prop( 'source_id' ), $language, $selection_field );
				$children         = array_merge( $children, $children_partial );
			}
			if ( ! empty( $children ) ) {
				$selected = array_merge( $selected, wp_list_pluck( $children, $selection_field ) );
			}
		}

		// The actual value filtering operation.
		$values = array_values(
			array_filter(
				$values,
				function ( $value ) use ( $selected, $selection_type, $selection_field ) {
					if ( $selection_type === 'excluded' ) {
						return ! in_array( $value[ $selection_field ], $selected, true );
					} else {
						return in_array( $value[ $selection_field ], $selected, true );
					}
				}
			)
		);

		return $values;
	}

	private function limit_total_number_of_filter_values( array $values, Filter $filter ): array {
		$limit = apply_filters( 'fibofilters/filter/max_number_of_filter_values', 1000 );

		if ( count( $values ) <= $limit ) {
			return $values;
		}

		// Do not limit the values of product categories and attributes.
		if ( $filter->get_prop( 'source' ) === 'tax_product_cat' || strpos( $filter->get_prop( 'source' ), 'tax_pa_' ) !== false ) {
			return $values;
		}

		if ( strpos( $filter->get_prop( 'source' ), 'tax_' ) === 0 && is_taxonomy_hierarchical( substr( $filter->get_prop( 'source' ), 4 ) ) ) {
			/**
			 * If the source is a hierarchical taxonomy, we additionally collect all parents of values.
			 * This is necessary to display tree structure correctly.
			 */
			$values_limited             = array_slice( $values, 0, $limit );
			$values_limited_parents     = wp_list_pluck( $values_limited, 'parent_value' );
			$all_parents                = FilterUtil::get_parents_recursive( $values, $values_limited_parents );
			$values_limited_values      = wp_list_pluck( $values_limited, 'value' );
			$values_limited_parents_all = wp_list_pluck( $all_parents, 'value' );
			$values                     = array_filter(
				$values,
				function ( $value ) use ( $values_limited_values, $values_limited_parents_all ) {
					return in_array( $value['value'], $values_limited_values, true ) || in_array( $value['value'], $values_limited_parents_all, true );
				}
			);
			$values                     = array_values( $values );
		} else {
			$values = array_slice( $values, 0, $limit );
		}

		return $values;
	}

	/**
	 * Get GET vars without invalid values
	 *
	 * @param array  $get_vars Current GET vars.
	 * @param array  $context Context.
	 * @param string $language Language.
	 */
	public function get_patched_get_vars( array $get_vars = [], array $context = [], string $language = '' ): array {
		$patched_get_vars = [];

		foreach ( $this->instances as $instance ) {
			$values = $instance->convert_user_input_to_values();
			if ( $instance->get_type() === 'range' ) {
				$url_slug = $instance->get_prop( 'url_slug' );
				if ( $values[0] === - 1 && $values[1] === - 1 ) {
					$patched_get_vars[ $url_slug ] = [];
				} else {
					$patched_get_vars[ $url_slug ] = ( $values[0] === - 1 ? '' : $values[0] ) . '-' . ( $values[1] === - 1 ? '' : $values[1] );
				}
				// Remove range value if it is the same as the GET value.
				if ( isset( $get_vars[ $url_slug ] ) && $get_vars[ $url_slug ] === $patched_get_vars[ $url_slug ] ) {
					unset( $patched_get_vars[ $url_slug ] );
				}
			} elseif ( $instance->get_type() === 'search' ) {
				// Do nothing for search filters.
			} else {
				$ctx           = new QueryContext( $language );
				$source_values = $this->values->get_values_by_source_id( $instance->get_prop( 'source_id' ), $ctx );
				$label_urls    = wp_list_pluck( $source_values, 'label_url' );
				// Leave only values that exists.
				$existing_values = array_values( array_intersect( $values, $label_urls ) );

				if ( $values !== $existing_values ) {
					$patched_get_vars[ $instance->get_prop( 'url_slug' ) ] = join( ',', $existing_values );
				}
			}

			// For product category filter, remove selected value if user is on the same category page.
			if ( $instance->get_source() === 'tax_product_cat' &&
				in_array(
					$instance->get_type(),
					[
						'checkboxes',
						'select',
					],
					true
				) &&
				! empty( $context['conditions']['is_product_taxonomy'] ) &&
				! empty( $context['details']['term_slug'] ) &&
				$context['details']['term_slug'] === $instance->get_user_input()
			) {
				$patched_get_vars[ $instance->get_prop( 'url_slug' ) ] = '';
			}
		}

		return $patched_get_vars;
	}
}
