<?php
/**
 * The Search Field Collection class.
 *
 * Represents a collection of search fields.
 *
 * @package GravityKit\GravityView\Search
 * @since 3.0.0
 */

namespace GravityKit\GravityView\Search;

use ArrayIterator;
use GravityView_Widget_Search;
use GV\Collection;
use GV\Collection_Position_Aware;
use GV\Grid;
use GV\Plugin;
use GV\Search\Fields\Search_Field;
use GV\Search\Fields\Search_Field_All;
use GV\Search\Fields\Search_Field_Created_By;
use GV\Search\Fields\Search_Field_Entry_Date;
use GV\Search\Fields\Search_Field_Entry_ID;
use GV\Search\Fields\Search_Field_Gravity_Forms;
use GV\Search\Fields\Search_Field_Is_Approved;
use GV\Search\Fields\Search_Field_Is_Read;
use GV\Search\Fields\Search_Field_Is_Starred;
use GV\Search\Fields\Search_Field_Search_Mode;
use GV\Search\Fields\Search_Field_Submit;
use GV\Template_Context;
use GV\View;
use IteratorAggregate;
use JsonException;

use function gravityview;

/**
 * Represents a collection of search fields.
 *
 * @since 2.42
 * @since 3.0.0 Migrated to PSR-4 namespace.
 *
 * @extends Collection<Search_Field>
 * @implements IteratorAggregate<Search_Field>
 */
final class SearchFieldCollection extends \GV\Collection implements \GV\Collection_Position_Aware, \IteratorAggregate {
	/**
	 * Micro cache to avoid multiple DB and filter calls.
	 *
	 * @since 2.42
	 *
	 * @var array<int, self>
	 */
	private static array $available_fields_cache = [];

	/**
	 * Contains any additional context used for filters.
	 *
	 * @since 2.42
	 *
	 * @var array
	 */
	private array $context;

	/**
	 * Weather a parent collection has searchable fields.
	 *
	 * @since 2.42
	 *
	 * @var bool|null
	 */
	private ?bool $base_has_visible_fields = null;

	/**
	 * The position what this collection was filtered by.
	 *
	 * @since 2.42
	 *
	 * @var string|null
	 */
	private ?string $position = null;

	/**
	 * Holds the configuration per position.
	 *
	 * @since 2.44
	 *
	 * @var array
	 */
	private array $area_configuration = [];

	/**
	 * Creates a collection of fields.
	 *
	 * @since 2.42
	 *
	 * @param Search_Field[] $fields  The fields.
	 * @param array          $context The additional context.
	 */
	private function __construct( array $fields = [], array $context = [] ) {
		$this->storage = $fields;
		$this->context = $context;
	}

	/**
	 * Returns the default search fields.
	 *
	 * @since 2.42
	 *
	 * @param int         $form_id The form ID.
	 * @param string|null $section The section to check.
	 *
	 * @return self
	 */
	public static function available_fields( int $form_id = 0, ?string $section = null ): self {
		$section   = $section ?? 'search';
		$cache_key = $form_id . '_' . $section;

		if ( $form_id > 0 && isset( self::$available_fields_cache[ $cache_key ] ) ) {
			return self::$available_fields_cache[ $cache_key ];
		}

		$fields = [
			new Search_Field_All(),
			new Search_Field_Search_Mode(),
			new Search_Field_Submit(),
			new Search_Field_Entry_Date(),
			new Search_Field_Entry_ID(),
			new Search_Field_Created_By(),
			new Search_Field_Is_Starred(),
			new Search_Field_Is_Read(),
		];

		if ( gravityview()->plugin->supports( Plugin::FEATURE_GFQUERY ) ) {
			$fields[] = new Search_Field_Is_Approved();
		}

		if ( $section && 'search' !== $section ) {
			$fields = array_values(
				array_filter(
					$fields,
					static fn( Search_Field $field ) => $field->is_allowed_for_section( $section )
				)
			);
		}

		$fields = (array) apply_filters( 'gk/gravityview/search/available-fields', $fields, $form_id, $section );

		$collection = new self( array_filter( $fields, static fn( $field ) => $field instanceof Search_Field ) );

		self::$available_fields_cache[ $cache_key ] = $collection;

		return $collection;
	}

	/**
	 * Returns a collection from a stored configuration.
	 *
	 * @since 2.42
	 *
	 * @param array     $configuration      The configuration.
	 * @param View|null $view               The View object.
	 * @param array     $additional_context Additional params passed along to every field (e.g. Context, Widget args).
	 *
	 * @return self The collection.
	 */
	public static function from_configuration(
		array $configuration,
		?View $view = null,
		array $additional_context = []
	): self {
		$collection = new self( [], $additional_context );

		foreach ( $configuration as $position => $_fields ) {
			if ( ! $_fields ) {
				continue;
			}

			foreach ( $_fields as $uid => $_configuration ) {
				if ( 'area_settings' === $uid ) {
					$collection->set_area_configuration( $position, $_configuration );
					continue;
				}

				$_configuration['UID']      = $uid;
				$_configuration['position'] = $position;

				$field = Search_Field::from_configuration( $_configuration, $view, $additional_context );
				if ( ! $field ) {
					continue;
				}

				$collection->add( $field );
			}
		}

		return $collection;
	}

	/**
	 * Creates a collection based on the legacy configuration.
	 *
	 * @since 2.42
	 *
	 * @param array     $configuration      The legacy configuration.
	 * @param View|null $view               The View.
	 * @param array     $additional_context Additional params passed along to every field (e.g. Context, Widget args).
	 *
	 * @return self The collection.
	 */
	public static function from_legacy_configuration(
		array $configuration,
		?View $view,
		array $additional_context = []
	): self {
		$collection = new self( [], $additional_context );

		try {
			$search_fields = json_decode( $configuration['search_fields'] ?? '[]', true, 512, JSON_THROW_ON_ERROR );
		} catch ( JsonException $e ) {
			return $collection;
		}

		if ( [] === $search_fields ) {
			return $collection;
		}

		$row = Grid::get_row_by_type( '100' );

		if ( [] === $row ) {
			return $collection;
		}

		$form_id = (int) ( $view ? $view->form->ID : ( $configuration['form_id'] ?? 0 ) );

		$shared_data = [
			'sieve_choices' => (bool) ( $configuration['sieve_choices'] ?? false ),
		];

		$areas      = array_keys( $row );
		$area_count = count( $areas );

		// Transform legacy fields into Search Fields.
		foreach ( $search_fields as $i => $field ) {
			// Automatically loop through all available areas.
			$area_key = $areas[ $i % $area_count ];

			$field_id = (string) ( $field['field'] ?? '' );
			$form_id  = (string) ( $field['form_id'] ?? $form_id );

			$field_data = array_merge(
				$shared_data,
				[
					'id'              => $field_id,
					'form_id'         => $form_id,
					'input_type'      => $field['input'] ?? 'input_text',
					'custom_label'    => $field['label'] ?? '',
					'position'        => 'search-general_' . ( $row[ $area_key ][0]['areaid'] ?? '' ),
					'date_range_mode' => $field['date_range_mode'] ?? 'separate',
				]
			);

			$search_field = Search_Field::from_configuration( $field_data, $view, $additional_context );
			if ( ! $search_field ) {
				$field_data['id'] = Search_Field_Gravity_Forms::generate_field_id( $form_id, $field_id );
				$search_field     = Search_Field::from_configuration( $field_data, $view, $additional_context );
			}

			if ( $search_field ) {
				$collection->add( $search_field );
			}
		}

		// Only add the required fields if there are search fields.
		if ( $collection->count() > 0 ) {
			$collection = $collection->ensure_required_search_fields( $configuration );
		}

		if ( 'horizontal' === ( $configuration['search_layout'] ?? null ) ) {
			foreach ( $row as $areas ) {
				foreach ( $areas as $area ) {
					$collection->set_area_configuration(
						'search-general_' . $area['areaid'],
						[ 'layout' => 'row' ]
					);
				}
			}
		}

		return $collection;
	}

	/**
	 * Return the available field instance by field ID, if it exists.
	 *
	 * @since 2.42
	 *
	 * @param int    $form_id  The form ID.
	 * @param string $field_id The field ID.
	 *
	 * @return Search_Field|null The field.
	 */
	public static function get_field_by_field_id( int $form_id, string $field_id ): ?Search_Field {
		$available_fields = self::available_fields( $form_id );

		foreach ( $available_fields as $field ) {
			if ( $field->is_of_type( $field_id ) ) {
				return $field;
			}
		}

		return null;
	}

	/**
	 * Returns the search field collection as a configuration array.
	 *
	 * @since 2.42
	 *
	 * @return array
	 */
	public function to_configuration(): array {
		$configuration = [];
		foreach ( $this->storage as $field ) {
			$data = $field->to_configuration();
			if ( ! isset( $data['position'], $data['UID'] ) ) {
				continue;
			}

			$configuration[ $data['position'] ]               ??= [];
			$configuration[ $data['position'] ][ $data['UID'] ] = $data;
		}

		foreach ( $configuration as $position => $_ ) {
			$settings = $this->get_area_configuration( $position );
			if ( $settings ) {
				// The area settings are stored like a field with a fixed ID.
				// So these values are here to keep the renderer happy.
				$settings['id']    = 'area_settings';
				$settings['label'] = esc_html__( 'Column', 'gk-gravityview' );

				$configuration[ $position ]['area_settings'] = $settings;
			}
		}

		return $configuration;
	}

	/**
	 * Returns The iterator.
	 *
	 * @since 2.42
	 *
	 * @return ArrayIterator<Search_Field>
	 */
	public function getIterator(): ArrayIterator {
		$fields = [];

		foreach ( $this->storage as $field ) {
			$configuration                    = $field->to_configuration();
			$fields[ $configuration['type'] ] = $field;
		}

		return new ArrayIterator( $fields );
	}

	/**
	 * @inheritDoc
	 * @since 2.42
	 */
	public function by_position( $position ) {
		$clone                          = clone $this;
		$clone->base_has_visible_fields = $this->has_visible_fields();
		$clone->position                = $position;

		$search         = implode( '.*', array_map( 'preg_quote', explode( '*', $position ) ) );
		$clone->storage = array_values(
			array_filter(
				$clone->storage,
				static fn( Search_Field $field ): bool => preg_match( "#^{$search}$#", $field->position ),
			)
		);

		return $clone;
	}

	/**
	 * Returns whether this collection contains a date field.
	 *
	 * @since 2.42
	 *
	 * @return bool Whether the collection contains a date field.
	 */
	public function has_date_field(): bool {
		$date_field_types = [ 'date', 'date_range', 'entry_date' ];

		foreach ( $this->storage as $field ) {
			$input_type = $field->to_template_data()['input'] ?? '';
			if ( in_array( $input_type, $date_field_types, true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns whether this collection contains a date-range search field that uses the combined picker.
	 *
	 * Entry Date fields default to the range input, so they are also considered range fields
	 * unless explicitly rendered as a single date. Range fields configured to render as two
	 * separate single-date pickers do not require the combined date-range picker bundle.
	 *
	 * @since 3.0.0
	 *
	 * @return bool Whether the collection contains a combined date-range search field.
	 */
	public function has_date_range_field(): bool {
		foreach ( $this->storage as $field ) {
			$template_data = $field->to_template_data();
			$input         = $template_data['input'] ?? '';
			$mode          = $template_data['date_range_mode'] ?? 'separate';

			if ( 'separate' === $mode ) {
				continue;
			}

			if ( 'date_range' === $input ) {
				return true;
			}

			// Entry Date reports `entry_date` for `input` and carries the configured mode in `input_type`.
			if ( 'entry_date' === $input && 'date_range' === ( $template_data['input_type'] ?? 'date_range' ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns whether this collection contains a field that needs the single-date picker.
	 *
	 * Includes fields whose input type is `date`, Entry Date configured as a single date, and
	 * any date-range field that is rendered as two separate single-date pickers.
	 *
	 * @since 3.0.0
	 *
	 * @return bool Whether the collection contains a field that needs the single-date picker.
	 */
	public function has_date_picker_field(): bool {
		foreach ( $this->storage as $field ) {
			$template_data = $field->to_template_data();
			$input         = $template_data['input'] ?? '';
			$mode          = $template_data['date_range_mode'] ?? 'separate';

			if ( 'date' === $input ) {
				return true;
			}

			if ( 'date_range' === $input && 'separate' === $mode ) {
				return true;
			}

			$entry_date_input = $template_data['input_type'] ?? 'date_range';
			if ( 'entry_date' !== $input ) {
				continue;
			}

			if ( 'date' === $entry_date_input ) {
				return true;
			}

			if ( 'date_range' === $entry_date_input && 'separate' === $mode ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns whether one of the visible fields has a request value.
	 *
	 * @since 2.42
	 *
	 * @return bool Whether one of the visible fields has a request value.
	 */
	public function has_request_values(): bool {
		foreach ( $this->storage as $field ) {
			if ( ! $field->is_visible() ) {
				continue;
			}

			if ( $field->has_request_value() ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns whether the current collection has any fields of the provided type.
	 *
	 * @since 2.42
	 *
	 * @param string $type The type to check.
	 *
	 * @return bool Whether the field type is in the collection.
	 */
	public function has_fields_of_type( string $type ): bool {
		foreach ( $this->storage as $field ) {
			if ( $field->is_of_type( $type ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Ensures required fields are added to the collection.
	 *
	 * @since 2.42
	 *
	 * @param array $configuration The configuration.
	 *
	 * @return self A new collection with the required fields.
	 */
	public function ensure_required_search_fields( array $configuration = [] ): self {
		$collection = clone $this;

		// Add Submit and Search Mode fields in a separate row.
		$submit_row           = Grid::get_row_by_type( '50/50' );
		$search_mode_position = 'search-general_' . ( $submit_row['1-2 right'][0]['areaid'] ?? '' );

		foreach ( $collection->storage as $field ) {
			if ( $field->is_of_type( 'submit' ) ) {
				// If only the search mode is missing, add it with an existing Submit field.
				$search_mode_position = $field->position;
			}
		}

		/** @var Search_Field[] $required_fields */
		$required_fields = [
			Search_Field_Submit::from_configuration(
				[
					'position'     => 'search-general_' . ( $submit_row['1-2 left'][0]['areaid'] ?? '' ),
					'search_clear' => (bool) ( $configuration['search_clear'] ?? false ),
				]
			),
			Search_Field_Search_Mode::from_configuration(
				[
					'position'   => $search_mode_position,
					'mode'       => $configuration['search_mode'] ?? 'any',
					'input_type' => 'hidden',
				]
			),
		];

		foreach ( $required_fields as $field ) {
			if ( ! $collection->has_fields_of_type( $field->get_type() ) ) {
				$collection->add( $field );
			}
		}

		return $collection;
	}

	/**
	 * Checks if the collection has any visible searchable fields.
	 *
	 * This excludes Submit and Search Mode fields as they are not actual search fields.
	 *
	 * @since 2.42
	 *
	 * @return bool Whether there are visible searchable fields.
	 */
	public function has_visible_fields( bool $ignore_base = false ): bool {
		if ( ! $ignore_base && true === $this->base_has_visible_fields ) {
			return true;
		}

		foreach ( $this->storage as $field ) {
			if ( $field->is_visible() && $field->is_searchable_field() ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns the fields as filtered template data.
	 *
	 * @since 2.42
	 *
	 * @return array The template data.
	 */
	public function to_template_data(): array {
		if ( ! $this->has_visible_fields() ) {
			return [];
		}

		$search_fields = [];
		foreach ( $this->storage as $field ) {
			if ( ! $field->is_visible() ) {
				continue;
			}

			$search_fields[] = $field->to_template_data();
		}

		/**
		 * Modify what fields are shown. The order of the fields in the $search_filters array controls the order as displayed in the search bar widget.
		 *
		 * @param array                        $search_fields Array of search filters with `key`, `label`, `value`, `type`, `choices` keys
		 * @param GravityView_Widget_Search    $widget        Current widget object
		 * @param array  |null                 $widget_args   Args passed to this method. {@since 1.8}
		 * @param null|string|Template_Context $context       {@since 2.0}
		 * @param string|null                  $position      The search field position {@since 2.42}
		 *
		 * @type array The filtered search filters.
		 */
		$search_fields = apply_filters(
			'gravityview_widget_search_filters',
			$search_fields,
			$this->context['widget'] ?? null,
			$this->context['widget_args'] ?? null,
			$this->context['context'] ?? null,
			$this->position,
		);

		gravityview()->log->debug( 'Calculated Search Fields: ', [ 'data' => $search_fields ] );

		return $search_fields;
	}

	/**
	 * Returns a collection of fields of a specific type.
	 *
	 * @since 2.42
	 *
	 * @param string $type The search type or class name.
	 *
	 * @return self A new collection.
	 */
	public function by_type( string $type ): self {
		$clone = clone $this;

		$clone->storage = array_filter(
			$clone->storage,
			static function ( Search_Field $field ) use ( $type ): bool {
				if ( is_subclass_of( $type, Search_Field::class ) ) {
					return $field instanceof $type;
				}

				return $field->is_of_type( $type );
			}
		);

		return $clone;
	}

	/**
	 * Adds multiple fields to the collection.
	 *
	 * @since 2.42
	 *
	 * @param Search_Field $value     The field to add.
	 * @param Search_Field ...$fields Additional fields to add.
	 */
	public function add( $value /*, ...$fields */ ): void {
		$fields = func_get_args();
		foreach ( $fields as $field ) {
			if ( ! $field instanceof Search_Field ) {
				continue;
			}

			parent::add( $field );
		}
	}

	/**
	 * Sets the position configuration.
	 *
	 * @since 2.44
	 *
	 * @param string $position      The position.
	 * @param array  $configuration The configuration.
	 */
	private function set_area_configuration( string $position, array $configuration ): void {
		$this->area_configuration[ $position ] = $configuration;
	}

	/**
	 * Returns the position configuration.
	 *
	 * @since 2.44
	 *
	 * @param string $position The position.
	 *
	 * @return array The configuration.
	 */
	public function get_area_configuration( string $position ): array {
		return $this->area_configuration[ $position ] ?? [];
	}

	/**
	 * Clears the internal caches.
	 *
	 * @since    3.0.0
	 *
	 * @internal Used for testing purposes only.
	 */
	public static function clear_cache(): void {
		self::$available_fields_cache = [];
	}
}
