<?php

namespace CelerSearch\Indices;

use CelerSearch\Abstracts\IndexResponse;
use CelerSearch\DataTransfer\IndexCandidate;
use CelerSearch\DataTransfer\IndexConfig;
use CelerSearch\DataTransfer\IndexResponses\ErrorResponse;
use CelerSearch\DataTransfer\IndexResponses\SearchResponse;
use CelerSearch\DataTransfer\IndexResponses\SuccessResponse;
use CelerSearch\DataTransfer\IndexResponses\ValueResponse;
use CelerSearch\DataTransfer\IndexSettings;
use CelerSearch\Exceptions\MissingProviderException;
use CelerSearch\Exceptions\MissingServiceException;
use CelerSearch\Factories\ServiceFactory;
use CelerSearch\Interfaces\IIndexableObject;
use CelerSearch\Services\BaseService;
use CelerSearch\Utilities\Logger;
use InvalidArgumentException;

abstract class BaseIndex {

	/**
	 * Feature Split content
	 */
	const FEATURE_SPLIT_CONTENT = 'split_content';

	/**
	 * The index configuration
	 * @var IndexConfig
	 */
	protected IndexConfig $details;

	/**
	 * The indexing service
	 * @var BaseService|null
	 */
	protected ?BaseService $service = null;

	/**
	 * Index configuration
	 *
	 * @param IndexConfig $details
	 */
	public function __construct( IndexConfig $details ) {
		$this->details = $details;
		$this->init();
	}

	/**
	 * Is indexing?
	 * @var bool
	 */
	protected bool $indexing = false;

	/**
	 * Load the service object and prepare for use.
	 *
	 * @return BaseService
	 *
	 * @throws MissingServiceException
	 * @throws MissingProviderException
	 */
	public function get_service(): BaseService {
		if ( null === $this->service ) {
			$this->service = ServiceFactory::create( $this->details->getServiceId() );
		}

		return $this->service;
	}

	/**
	 * Return the index name
	 * @return string
	 */
	public function get_name(): string {
		return $this->details->getName();
	}

	/**
	 * Returns the index type
	 * @return string
	 */
	public function get_type(): string {
		return $this->details->getType();
	}

	/**
	 * Returns the index id
	 * @return int
	 */
	public function get_id(): int {
		return (int) $this->details->getId();
	}

	/**
	 * Returns the index slug
	 * @return string
	 */
	public function get_slug(): string {
		return $this->details->getSlug();
	}

	/**
	 * Returns the index configuration object
	 *
	 * @return IndexConfig
	 */
	public function get_index_config(): IndexConfig {
		return $this->details;
	}

	/**
	 * Returns stored settings from config
	 *
	 * @return array
	 */
	protected function get_stored_settings(): array {
		$config = $this->details->getConfig();
		if ( ! $config ) {
			return [];
		}
		// Use JSON encode/decode for recursive conversion of nested objects to arrays
		$config_array = json_decode( wp_json_encode( $config ), true );
		if ( ! is_array( $config_array ) ) {
			return [];
		}
		$settings = $config_array['settings'] ?? [];
		return is_array( $settings ) ? $settings : [];
	}

	/**
	 * Performs additional initialization (if needed)
	 * @return void
	 */
	abstract protected function init(): void;

	/**
	 * Returns the index settings
	 * @return IndexSettings;
	 */
	abstract public function get_settings(): IndexSettings;

	/**
	 * Check whether an item is supported.
	 *
	 * @param IndexCandidate $item
	 *
	 * @return bool
	 */
	abstract public function is_supported( IndexCandidate $item ): bool;

	/**
	 * Check whether an item can be indexed.
	 *
	 * @param IndexCandidate $item
	 *
	 * @return bool
	 */
	abstract public function should_index( IndexCandidate $item ): bool;

	/**
	 * Returns specific indexable items
	 *
	 * @param array $ids
	 *
	 * @return array<IndexCandidate>
	 */
	abstract public function get_candidates_by_ids( array $ids ): array;

	/**
	 * Returns items from the index
	 *
	 * @param int $page
	 * @param int $batch_size
	 *
	 * @return array<IndexCandidate>
	 */
	abstract public function get_candidates( int $page, int $batch_size ): array;


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

	/**
	 * Returns the item records
	 *
	 * @param IndexCandidate $item
	 *
	 * @return array<IIndexableObject>
	 */
	abstract public function get_candidate_objects( IndexCandidate $item ): array;

	/**
	 * Returns the size of the batch
	 * @return int
	 */
	public function get_candidate_batch_size(): int {
		$max = (int) apply_filters( 'celersearch_indexable_items_batch_size', 15 );

		return (int) apply_filters( 'celersearch_' . $this->get_slug() . '_indexable_items_batch_size', $max );
	}

	/**
	 * Returns the number of total pages
	 * @return int
	 */
	public function get_candidate_batch_pages(): int {
		return (int) ceil( $this->get_candidate_count() / $this->get_candidate_batch_size() );
	}

	/**
	 * Returns a map of sort field keys to actual index field names.
	 *
	 * Override in subclasses to map generic keys ('date', 'title')
	 * to the actual field names used in that index.
	 *
	 * @return array
	 */
	public function get_sort_field_map(): array {
		return [];
	}

	/**
	 * Resolve a sort key (e.g. 'date', 'title-desc') to sort rules
	 * using the index-specific field map.
	 *
	 * @param string $sort_key
	 *
	 * @return array Sort rules array (e.g. ['created_at:desc'])
	 */
	public function resolve_sort( string $sort_key ): array {
		$field_map = $this->get_sort_field_map();

		$sort_map = [
			'date'       => [ ( $field_map['date'] ?? 'created_at' ) . ':desc' ],
			'date-asc'   => [ ( $field_map['date'] ?? 'created_at' ) . ':asc' ],
			'title'      => [ ( $field_map['title'] ?? 'title' ) . ':asc' ],
			'title-desc' => [ ( $field_map['title'] ?? 'title' ) . ':desc' ],
		];

		$sort_map = apply_filters( 'celersearch_sort_options', $sort_map, $sort_key, $this );

		return $sort_map[ $sort_key ] ?? [];
	}

	/**
	 * Perform a search on the index
	 *
	 * @param string $query
	 * @param array $params
	 *
	 * @return IndexResponse|SearchResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function search( string $query, array $params = [] ): IndexResponse|SearchResponse|ErrorResponse {
		return $this->get_service()->search( $this, $query, $params );
	}

	/**
	 * Format a search hit for autocomplete display
	 *
	 * Override in subclasses to customize hit formatting for different content types.
	 * Returns null if the hit cannot be formatted (e.g., item no longer exists).
	 *
	 * @param array $hit Raw hit from search index
	 *
	 * @return array|null Formatted hit or null to skip
	 */
	abstract public function format_autocomplete_hit( array $hit ): ?array;

	/**
	 * Sanitize _formatted fields from search engine response
	 *
	 * Strips all HTML except highlight tags to prevent raw HTML
	 * (e.g. WooCommerce price markup) from appearing in autocomplete.
	 *
	 * @param array $formatted
	 *
	 * @return array
	 */
	protected function sanitize_formatted_fields( array $formatted ): array {
		$text_fields = [ 'title', 'post_title', 'content', 'excerpt', 'short_description' ];

		foreach ( $text_fields as $field ) {
			if ( isset( $formatted[ $field ] ) ) {
				$formatted[ $field ] = strip_tags( $formatted[ $field ], '<em>' );
			}
		}

		return $formatted;
	}

	/**
	 * Get status filter for autocomplete search
	 *
	 * Override in subclasses to provide index-specific status filtering.
	 * Returns null if no status filter should be applied.
	 *
	 * @param string $context 'frontend' or 'admin'
	 *
	 * @return array|null Filter array or null to skip filtering
	 */
	abstract public function get_autocomplete_status_filter( string $context ): ?array;

	/**
	 * Syncs item to the index
	 *
	 * @param IIndexableObject $item
	 *
	 * @return IndexResponse|SuccessResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function sync_item( IIndexableObject $item ): IndexResponse|SuccessResponse|ErrorResponse {
		return $this->get_service()->sync_item( $this, $item );
	}

	/**
	 * Deletes item from the index
	 *
	 * @param IIndexableObject $item
	 *
	 * @return IndexResponse|SuccessResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function delete_item( IIndexableObject $item ): IndexResponse|SuccessResponse|ErrorResponse {
		return $this->get_service()->delete_item( $this, $item );
	}

	/**
	 * Counts the index items
	 * @return IndexResponse|ValueResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function count_items(): IndexResponse|ValueResponse|ErrorResponse {
		return $this->get_service()->count_items( $this );
	}

	/**
	 * Check whether the index exists
	 * @return IndexResponse|SuccessResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function check(): IndexResponse|SuccessResponse|ErrorResponse {
		return $this->get_service()->check_index( $this );
	}

	/**
	 * Create the index if it doesn't exist
	 *
	 * @return IndexResponse|SuccessResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function touch(): IndexResponse|SuccessResponse|ErrorResponse {
		return $this->get_service()->touch_index( $this );
	}

	/**
	 * Clear the index
	 * @return IndexResponse|SuccessResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function clear(): IndexResponse|SuccessResponse|ErrorResponse {
		return $this->get_service()->clear_index( $this );
	}

	/**
	 * Delete the index
	 * @return IndexResponse|SuccessResponse|ErrorResponse
	 * @throws MissingProviderException
	 * @throws MissingServiceException
	 */
	public function delete(): IndexResponse|SuccessResponse|ErrorResponse {
		return $this->get_service()->delete_index( $this );
	}

	/**
	 * Push the service specific index settings to remote
	 * @return IndexResponse|SuccessResponse|ErrorResponse
	 * @throws MissingServiceException|MissingProviderException
	 */
	public function push_settings(): ErrorResponse|SuccessResponse|IndexResponse {
		return $this->get_service()->push_settings( $this );
	}

	/**
	 * Sync specific objects to the index
	 *
	 * @param array<IIndexableObject> $objects The records.
	 *
	 * @return void
	 */
	protected function sync_objects( array $objects ): void {

		try {
			foreach ( $objects as $record ) {
				$this->sync_item( $record );
			}
		} catch ( \Throwable $throwable ) {
			Logger::channel( Logger::CHANNEL_ERRORS )->error( 'Sync objects failed', [
				'index_slug' => $this->get_slug(),
				'exception'  => $throwable->getMessage(),
			] );
		}
	}


	/**
	 * Rebuild the index for specific page
	 *
	 * @param int $page
	 *
	 * @return void
	 * @throws MissingServiceException|MissingProviderException
	 */
	public function rebuild_index( int $page ): void {

		$page = (int) $page;

		if ( $page < 1 ) {
			throw new InvalidArgumentException( 'Page should be superior to 0.' );
		}
		if ( 1 === $page ) {
			// Delete the index first to ensure fresh start with correct primary key
			$delete_response = $this->delete();
			if ( ! $delete_response->is_error_response() && $delete_response->has_task() ) {
				$this->get_service()->wait_for_completion( $delete_response );
			}

			// Create the index
			$touch_response = $this->touch();
			if ( ! $touch_response->is_error_response() && $touch_response->has_task() ) {
				$this->get_service()->wait_for_completion( $touch_response );
			}

			// Push settings
			$settings_response = $this->push_settings();
			if ( ! $settings_response->is_error_response() && $settings_response->has_task() ) {
				$this->get_service()->wait_for_completion( $settings_response );
			}
		}
		$batch_size = (int) $this->get_candidate_batch_size();

		if ( $batch_size < 1 ) {
			throw new InvalidArgumentException( 'Re-index batch size can not be lower than 1.' );
		}

		$items_count   = $this->get_candidate_count();
		$max_num_pages = (int) max( ceil( $items_count / $batch_size ), 1 );
		$items         = $this->get_candidates( $page, $batch_size );
		$this->indexing = true;
		$this->handle_rebuild_index( $items );
		$this->indexing = false;

		if ( $page === $max_num_pages ) {
			do_action( 'celersearch_rebuilt_items', $this->get_slug() );
		}
	}

	/**
	 * Rebuild the index for specific object ids
	 *
	 * @param array<int> $object_ids
	 *
	 * @return void
	 */
	public function rebuild_index_specific( array $object_ids ): void {
		$items          = $this->get_candidates_by_ids( $object_ids );
		$this->indexing = true;
		$this->handle_rebuild_index( $items );
		$this->indexing = false;
		do_action( 'celersearch_rebuilt_items', $this->get_slug() );
	}


	/**
	 * Rebuild the index for specific items
	 * @param array<IndexCandidate> $items
	 *
	 * @return void
	 */
	public function handle_rebuild_index(array $items) : void {

		foreach ( $items as $key => $item ) {
			if ( ! $this->should_index( $item ) ) {
				Logger::channel( Logger::CHANNEL_OBSERVERS )->debug( 'Rebuild skipped item', [
					'index_slug' => $this->get_slug(),
					'item_key'   => $key,
				] );
				continue;
			}
			try {
				do_action( 'celersearch_before_get_candidate_objects', $item );
				$objects = $this->get_candidate_objects( $item );
				do_action( 'celersearch_after_get_candidate_objects', $item );
				$this->sync_objects( $objects );
				Logger::channel( Logger::CHANNEL_OBSERVERS )->debug( 'Rebuild synced item', [
					'index_slug' => $this->get_slug(),
					'item_key'   => $key,
				] );
			} catch ( \Throwable $e ) {
				Logger::channel( Logger::CHANNEL_ERRORS )->error( 'Rebuild failed for item', [
					'index_slug' => $this->get_slug(),
					'item_key'   => $key,
					'exception'  => $e->getMessage(),
				] );
			}
		}
	}

}