<?php

namespace CelerSearch\Indices;

defined( 'ABSPATH' ) || exit;

use CelerSearch\Builders\IndexSettingsBuilder;
use CelerSearch\Builders\SearchableAttributesBuilder;
use CelerSearch\DataTransfer\IndexCandidate;
use CelerSearch\DataTransfer\IndexSettings;
use CelerSearch\DataTransfer\IndexablePost;
use CelerSearch\Interfaces\IIndexableObject;
use CelerSearch\Utilities\PostUtilities;
use RuntimeException;
use WP_Post;

class PostsIndex extends BasePostIndex {

	/**
	 * The allowed post types
	 * @var array
	 */
	protected array $post_types;

	/**
	 * Performs additional initialization (if needed)
	 * @return void
	 */
	protected function init(): void {
		$config           = $this->details->getConfig();
		$this->post_types = isset( $config->post_types ) ? $config->post_types : array();

		// If post_types is an object (due to array to object conversion), convert back to array
		if ( is_object( $this->post_types ) ) {
			$this->post_types = (array) $this->post_types;
		}
	}

	/**
	 * @inheritDoc
	 */
	public function get_sort_field_map(): array {
		return [ 'date' => 'created_at', 'title' => 'title' ];
	}

	/**
	 * Check whether an item is supported.
	 *
	 * @param IndexCandidate $item
	 *
	 * @return bool
	 */
	public function is_supported( IndexCandidate $item ): bool {
		return $item && is_a( $item->item, \WP_Post::class ) && in_array( $item->item->post_type, $this->post_types, true );
	}

	/**
	 * Check whether an item can be indexed.
	 *
	 * @param IndexCandidate $item
	 *
	 * @return bool
	 */
	public function should_index( IndexCandidate $item ): bool {
		if ( ! $this->is_supported( $item ) ) {
			return false;
		}

		// Index posts with meaningful statuses (exclude trash, auto-draft)
		$indexable_statuses = apply_filters( 'celersearch_indexable_post_statuses', [
			'publish',
			'draft',
			'pending',
			'private',
			'future',
		], $item->item );

		return in_array( $item->item->post_status, $indexable_statuses, true );
	}

	/**
	 * Returns items from the index
	 *
	 * @param $page
	 * @param $batch_size
	 *
	 * @return array<IndexCandidate>
	 */
	public function get_candidates( $page, $batch_size ): array {
		$args = [
			'post_type'              => $this->post_types,
			'posts_per_page'         => $batch_size,
			'post_status'            => 'any',
			'order'                  => 'ASC',
			'orderby'                => 'ID',
			'paged'                  => $page,
			// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Required to prevent recursive search filters during indexing
			'suppress_filters'       => true,
			'cache_results'          => false,
			'lazy_load_term_meta'    => false,
			'update_post_term_cache' => false,
		];

		return array_map( function ( \WP_Post $item ) {
			return new IndexCandidate( $item );
		}, get_posts( $args ) );
	}

	/**
	 * Returns specific items by IDs
	 *
	 * @param $ids
	 *
	 * @return array<IndexCandidate>
	 */
	public function get_candidates_by_ids( $ids ): array {
		$args             = [
			'post_type'              => $this->post_types,
			'posts_per_page'         => count( $ids ),
			'post_status'            => 'any',
			'order'                  => 'ASC',
			'orderby'                => 'ID',
			'paged'                  => 1,
			// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Required to prevent recursive search filters during indexing
			'suppress_filters'       => true,
			'cache_results'          => false,
			'lazy_load_term_meta'    => false,
			'update_post_term_cache' => false,
		];
		$args['post__in'] = (array) $ids;

		return array_map( function ( \WP_Post $item ) {
			return new IndexCandidate( $item );
		}, get_posts( $args ) );
	}

	/**
	 * Returns items from the index
	 *
	 * @return int
	 */
	public function get_candidate_count(): int {

		$query = new \WP_Query(
			array(
				'post_type'              => $this->post_types,
				'post_status'            => 'any',
				// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Required to prevent recursive search filters during indexing
				'suppress_filters'       => true,
				'cache_results'          => false,
				'lazy_load_term_meta'    => false,
				'update_post_term_cache' => false,
			)
		);

		return (int) $query->found_posts;
	}

	/**
	 * Returns the item records
	 *
	 * @param IndexCandidate $item
	 *
	 * @return IIndexableObject[]
	 */
	public function get_candidate_objects( IndexCandidate $item ): array {

		/* @var WP_Post $post */
		$post = $item->item;

		$attributes = $this->format_attributes( $post );

		$removed = remove_filter( 'the_content', 'wptexturize', 10 );

		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying WordPress core filter
		$content = apply_filters( 'the_content', apply_filters(
				'celersearch_searchable_post_content',
				$post->post_content,
				$post
			)
		);

		if ( true === $removed ) {
			add_filter( 'the_content', 'wptexturize', 10 );
		}

		$content       = PostUtilities::prepare_content( $content );
		$settings      = $this->get_settings();
		$split_enabled = defined( 'CELERSEARCH_SPLIT_POSTS' ) ? (bool) CELERSEARCH_SPLIT_POSTS : $settings->is_split_enabled();
		$split_size    = defined( 'CELERSEARCH_CONTENT_MAX_SIZE' ) ? (int) CELERSEARCH_CONTENT_MAX_SIZE : $settings->get_split_max_size();
		$parts         = PostUtilities::explode_content( $content, $split_size );

		if ( ! $split_enabled ) {
			$parts = array( array_shift( $parts ) );
		}

		$records = array();
		foreach ( $parts as $i => $part ) {
			$record                 = $attributes;
			$object_id              = PostUtilities::get_post_object_id( $post->ID, $i );
			$record['object_id']    = $object_id;
			$record['content']      = $part;
			$record['record_index'] = $i;
			$records[]              = new IndexablePost( $object_id, $record );
		}

		$records = (array) apply_filters( 'celersearch_searchable_post_records', $records, $post );

		return (array) apply_filters( 'celersearch_searchable_post_' . $post->post_type . '_records', $records, $post );
	}

	/**
	 * Returns the post attributes for indexing
	 *
	 * Inspired by WP Search with Algolia plugin
	 *
	 * @param WP_Post $post
	 *
	 * @return array<string, mixed>
	 */
	private function format_attributes( \WP_Post $post ): array {
		$attributes           = [];
		$attributes['id']      = $post->ID;
		$attributes['post_id'] = $post->ID;
		$attributes['type']   = $post->post_type;
		$attributes['status'] = $post->post_status;

		$post_type = get_post_type_object( $post->post_type );
		if ( null === $post_type ) {
			throw new RuntimeException( 'Unable to fetch the post type information.' );
		}
		$attributes['type_label']           = $post_type->labels->name;
		$attributes['title']                = $post->post_title;
		$attributes['excerpt']              = PostUtilities::prepare_content( apply_filters( 'the_excerpt', $post->post_excerpt ) ); // phpcs:ignore -- Legitimate use of Core hook.
		$attributes['created_at']           = get_post_time( 'U', false, $post );
		$attributes['created_at_formatted'] = get_the_date( '', $post );
		$attributes['updated_at']           = get_post_modified_time( 'U', false, $post );
		$attributes['comment_count']        = (int) $post->comment_count;
		$attributes['menu_order']           = (int) $post->menu_order;

		$author = get_userdata( $post->post_author );
		if ( $author ) {
			$attributes['author'] = array(
				'id'           => (int) $post->post_author,
				'display_name' => $author->display_name,
				'url'          => $author->user_url,
				'login'        => $author->user_login,
			);
		}

		$attributes['images'] = PostUtilities::get_post_images( $post->ID );

		$attributes['permalink'] = get_permalink( $post );
		$attributes['mime_type'] = $post->post_mime_type;

		// Push all taxonomies by default, including custom ones.
		$taxonomy_objects = get_object_taxonomies( $post->post_type, 'objects' );

		$attributes['taxonomies']              = array();
		$attributes['taxonomies_hierarchical'] = array();
		foreach ( $taxonomy_objects as $taxonomy ) {
			$terms = wp_get_object_terms( $post->ID, $taxonomy->name );
			$terms = is_array( $terms ) ? $terms : array();

			if ( $taxonomy->hierarchical ) {
				$hierarchical_taxonomy_values = PostUtilities::get_taxonomy_tree( $terms, $taxonomy->name );
				if ( ! empty( $hierarchical_taxonomy_values ) ) {
					$attributes['taxonomies_hierarchical'][ $taxonomy->name ] = $hierarchical_taxonomy_values;
				}
			}

			$taxonomy_values = array_map( 'html_entity_decode', wp_list_pluck( $terms, 'name' ) );
			if ( ! empty( $taxonomy_values ) ) {
				$attributes['taxonomies'][ $taxonomy->name ] = $taxonomy_values;
			}
		}

		$attributes['is_sticky'] = is_sticky( $post->ID ) ? 1 : 0;

		$attributes = (array) apply_filters( 'celersearch_searchable_post_shared_attributes', $attributes, $post );

		return (array) apply_filters( 'celersearch_searchable_post_' . $post->post_type . '_shared_attributes', $attributes, $post );

	}

	/**
	 * Create post index settings
	 *
	 * @return IndexSettings
	 */
	public function get_settings(): IndexSettings {

		$settings = ( new IndexSettingsBuilder )
			->primary_key( 'object_id' )
			->distinct_attribute( 'post_id' )
			->searchable_attributes( [
				'title:unordered',
				'taxonomies:unordered',
				'content:unordered'
			] )
			->filterable_attributes( [
				'taxonomies',
				'taxonomies_hierarchical',
				'author.display_name',
				'type_label',
				'status',
				'post_id',
			] )
			->sortable_attributes( [
				'is_sticky:desc',
				'created_at:desc',
				'record_index:asc',
				'title:asc',
			] )
			->snippet_attributes( [
				'title:30',
				// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying WordPress core filter
				'content:' . intval( apply_filters( 'excerpt_length', 55 ) ),
			] )
			->from_stored( $this->get_stored_settings() );


		$settings = apply_filters( 'celersearch_posts_index_settings', $settings, $this );

		return $settings->build();

	}

	/**
	 * Whitelist of searchable attributes for the admin UI.
	 *
	 * Superset of the hardcoded defaults in get_settings(), covering the
	 * keys produced by format_attributes() that are reasonable to search.
	 * Each real taxonomy registered on the configured post types is
	 * exposed as a separate `taxonomies.<name>` entry so admins can
	 * include/exclude them individually. `taxonomies` stays as a
	 * catch-all fallback that matches every nested taxonomy field.
	 *
	 * @return array<array{name:string, label:string}>
	 */
	public function get_available_searchable_attributes(): array {
		$builder = new SearchableAttributesBuilder();

		$builder->group( __( 'Content', 'celersearch' ) )
			->entry( 'title', __( 'Post title', 'celersearch' ) )
			->entry( 'content', __( 'Content', 'celersearch' ) )
			->entry( 'excerpt', __( 'Excerpt', 'celersearch' ) );

		$builder->group( __( 'Author', 'celersearch' ) )
			->entry( 'author.display_name', __( 'Author name', 'celersearch' ) )
			->entry( 'author.login', __( 'Author login', 'celersearch' ) );

		$builder->group( __( 'Meta', 'celersearch' ) )
			->entry( 'type_label', __( 'Post type label', 'celersearch' ) )
			->entry( 'permalink', __( 'Permalink', 'celersearch' ) );

		$builder->group( __( 'Taxonomies', 'celersearch' ) )
			->entries( $this->collect_searchable_taxonomies() );

		$builder->group( __( 'Catch-all', 'celersearch' ) )
			->entry( 'taxonomies', __( 'All taxonomies', 'celersearch' ) );

		return (array) apply_filters(
			'celersearch_posts_available_searchable_attributes',
			$builder->build(),
			$this
		);
	}

	/**
	 * Whitelist of attributes the admin UI can mark as filterable.
	 * Curated to avoid long-text / HTML / URL fields where filtering rarely makes sense.
	 *
	 * @return array<array{name:string, label:string, group?:string}>
	 */
	public function get_available_filterable_attributes(): array {
		$builder = new SearchableAttributesBuilder();

		$builder->group( __( 'Post meta', 'celersearch' ) )
			->entry( 'post_id', __( 'Post ID', 'celersearch' ) )
			->entry( 'type', __( 'Post type slug', 'celersearch' ) )
			->entry( 'type_label', __( 'Post type label', 'celersearch' ) )
			->entry( 'status', __( 'Post status', 'celersearch' ) )
			->entry( 'is_sticky', __( 'Sticky', 'celersearch' ) )
			->entry( 'created_at', __( 'Created at (timestamp)', 'celersearch' ) )
			->entry( 'updated_at', __( 'Updated at (timestamp)', 'celersearch' ) );

		$builder->group( __( 'Author', 'celersearch' ) )
			->entry( 'author.id', __( 'Author ID', 'celersearch' ) )
			->entry( 'author.display_name', __( 'Author name', 'celersearch' ) )
			->entry( 'author.login', __( 'Author login', 'celersearch' ) );

		$builder->group( __( 'Taxonomies', 'celersearch' ) )
			->entries( $this->collect_searchable_taxonomies() );

		foreach ( $this->collect_hierarchical_taxonomies() as $entry ) {
			$entry['label'] = sprintf( __( '%s (hierarchical)', 'celersearch' ), $entry['label'] );
			$builder->entry( $entry['name'], $entry['label'], __( 'Taxonomies', 'celersearch' ) );
		}

		$builder->group( __( 'Catch-all', 'celersearch' ) )
			->entry( 'taxonomies', __( 'All taxonomies', 'celersearch' ) )
			->entry( 'taxonomies_hierarchical', __( 'All hierarchical taxonomies', 'celersearch' ) );

		return (array) apply_filters(
			'celersearch_posts_available_filterable_attributes',
			$builder->build(),
			$this
		);
	}

	/**
	 * Whitelist of attributes the admin UI can mark as sortable.
	 * Only scalar (non-array, non-object) leaf fields — Meilisearch rejects sorts on arrays/objects.
	 *
	 * @return array<array{name:string, label:string, group?:string}>
	 */
	public function get_available_sortable_attributes(): array {
		$builder = new SearchableAttributesBuilder();

		$builder->group( __( 'Post meta', 'celersearch' ) )
			->entry( 'post_id', __( 'Post ID', 'celersearch' ) )
			->entry( 'title', __( 'Post title', 'celersearch' ) )
			->entry( 'is_sticky', __( 'Sticky', 'celersearch' ) )
			->entry( 'menu_order', __( 'Menu order', 'celersearch' ) )
			->entry( 'created_at', __( 'Created at', 'celersearch' ) )
			->entry( 'updated_at', __( 'Updated at', 'celersearch' ) )
			->entry( 'comment_count', __( 'Comment count', 'celersearch' ) )
			->entry( 'record_index', __( 'Record index (content split order)', 'celersearch' ) );

		$builder->group( __( 'Author', 'celersearch' ) )
			->entry( 'author.display_name', __( 'Author name', 'celersearch' ) );

		return (array) apply_filters(
			'celersearch_posts_available_sortable_attributes',
			$builder->build(),
			$this
		);
	}

	/**
	 * Like collect_searchable_taxonomies() but scoped to hierarchical ones —
	 * these get emitted separately under `taxonomies_hierarchical.<name>`.
	 *
	 * @return array<array{name:string, label:string}>
	 */
	protected function collect_hierarchical_taxonomies(): array {
		$entries = [];
		$seen    = [];
		foreach ( $this->post_types as $post_type ) {
			foreach ( get_object_taxonomies( $post_type, 'objects' ) as $tax ) {
				if ( ! $tax->hierarchical || isset( $seen[ $tax->name ] ) ) {
					continue;
				}
				$seen[ $tax->name ] = true;
				$entries[]          = [
					'name'  => 'taxonomies_hierarchical.' . $tax->name,
					'label' => $tax->labels->singular_name ?: ( $tax->label ?: $tax->name ),
				];
			}
		}
		return $entries;
	}

	/**
	 * Enumerate taxonomies registered on the configured post types as
	 * `taxonomies.<name>` whitelist entries, deduped across post types.
	 *
	 * @return array<array{name:string, label:string}>
	 */
	protected function collect_searchable_taxonomies(): array {
		$entries = [];
		$seen    = [];
		foreach ( $this->post_types as $post_type ) {
			$taxonomies = get_object_taxonomies( $post_type, 'objects' );
			foreach ( $taxonomies as $tax ) {
				if ( isset( $seen[ $tax->name ] ) ) {
					continue;
				}
				$seen[ $tax->name ] = true;
				$entries[]          = [
					'name'  => 'taxonomies.' . $tax->name,
					'label' => $tax->labels->singular_name ?: ( $tax->label ?: $tax->name ),
				];
			}
		}
		return $entries;
	}
}