<?php

namespace CelerSearch\Services;

use Exception;
use CelerSearch\Abstracts\IndexResponse;
use CelerSearch\Builders\IndexResponseBuilder;
use CelerSearch\DataTransfer\IndexResponses\ErrorResponse;
use CelerSearch\DataTransfer\IndexResponses\SearchResponse;
use CelerSearch\DataTransfer\IndexResponses\SuccessResponse;
use CelerSearch\Indices\BaseIndex;
use CelerSearch\Interfaces\IIndexableObject;
use CelerSearch\Utilities\ArrayUtilities;
use CelerSearch\Utilities\Logger;
use CelerSearch\Vendor\Meilisearch\Client;
use CelerSearch\Vendor\Meilisearch\Exceptions\ApiException;
use CelerSearch\Vendor\RebelCode\Psr7\Uri;
use CelerSearch\Vendor\RebelCode\WordPress\Http\HandlerStack;
use CelerSearch\Vendor\RebelCode\WordPress\Http\Middleware;
use CelerSearch\Vendor\RebelCode\WordPress\Http\Middleware\PrepareBody;
use CelerSearch\Vendor\RebelCode\WordPress\Http\WpClient;
use CelerSearch\Vendor\RebelCode\WordPress\Http\WpHandler;

class MeiliSearch extends BaseService {

	/**
	 * The meili search client
	 * @var Client
	 */
	protected Client $client;

	/**
	 * A good place to create API connection, etc.
	 * @return void
	 * @throws Exception;
	 */
	public function init(): void {

		$config = $this->details->getConfig();

		$url    = isset( $config->url ) ? $config->url : '';
		$apiKey = isset( $config->api_key ) ? $config->api_key : '';

		if ( empty( $url ) ) {
			throw new \Exception( 'MeiliSearch API URL is missing.' );
		}

		if ( empty( $apiKey ) ) {
			throw new \Exception( 'MeiliSearch API Key is missing.' );
		}

		$wpHandler = new WpHandler( [
			'timeout'     => 30,
			'redirection' => 3,
		] );

		$handlerStack = new HandlerStack( $wpHandler, [
			Middleware::factory( PrepareBody::class )
		] );

		$client       = new WpClient( $handlerStack, new Uri( $url ) );
		$this->client = new Client( $url, $apiKey, $client );
	}

	/**
	 * Lightweight health check using MeiliSearch's /health endpoint
	 *
	 * @return bool
	 */
	public function health_check(): bool {
		try {
			return $this->client->isHealthy();
		} catch ( \Exception $e ) {
			return false;
		}
	}

	/**
	 * Format the search parameters
	 * @param array $params
	 *
	 * @return array
	 */
	protected function format_search_params(array $params): array {
		// Handle highlighting
		if ( isset( $params['highlighting'] ) && is_array( $params['highlighting'] ) ) {
			$highlighting = $params['highlighting'];
			if ( ! empty( $highlighting['enabled'] ) ) {
				$params['attributesToHighlight'] = $highlighting['fields'] ?? [];
				$params['highlightPreTag'] = $highlighting['pre_tag'] ?? '<em>';
				$params['highlightPostTag'] = $highlighting['post_tag'] ?? '</em>';
			}
			unset( $params['highlighting'] );
		}

		// Handle facets
		if ( isset( $params['facets'] ) && is_array( $params['facets'] ) ) {
			// Pass facets directly — MeiliSearch SDK accepts an array of attribute names
			// Normalized format matches MeiliSearch format, no conversion needed
		}

		// Handle sort
		if ( isset( $params['sort'] ) && is_array( $params['sort'] ) ) {
			// Pass sort directly — already in MeiliSearch format (e.g., ['price:asc'])
		}

		// Handle filters
		if ( isset( $params['filters'] ) && is_array( $params['filters'] ) ) {
			$filter_string = $this->build_filter_string( $params['filters'] );
			if ( ! empty( $filter_string ) ) {
				$params['filter'] = $filter_string;
			}
			unset( $params['filters'] );
		}

		return $params;
	}

	/**
	 * Build MeiliSearch filter string from normalized filter format
	 *
	 * @param array $filters
	 *
	 * @return string
	 */
	private function build_filter_string( array $filters ): string {
		// Check if this is a group with relation
		if ( isset( $filters['relation'] ) && isset( $filters['conditions'] ) ) {
			$relation = strtoupper( $filters['relation'] );
			$parts = [];
			foreach ( $filters['conditions'] as $condition ) {
				$part = $this->build_filter_string( $condition );
				if ( ! empty( $part ) ) {
					$parts[] = $part;
				}
			}
			if ( empty( $parts ) ) {
				return '';
			}
			return '(' . implode( " {$relation} ", $parts ) . ')';
		}

		// Check if this is a single condition
		if ( isset( $filters['field'] ) && isset( $filters['operator'] ) ) {
			return $this->build_single_filter( $filters );
		}

		// Handle array of filters (combine with AND by default)
		if ( is_array( $filters ) && ! isset( $filters['field'] ) ) {
			$parts = [];
			foreach ( $filters as $filter ) {
				if ( is_array( $filter ) ) {
					$part = $this->build_filter_string( $filter );
					if ( ! empty( $part ) ) {
						$parts[] = $part;
					}
				}
			}
			if ( empty( $parts ) ) {
				return '';
			}
			// If multiple parts, wrap in parentheses and combine with AND
			if ( count( $parts ) > 1 ) {
				return '(' . implode( ' AND ', $parts ) . ')';
			}
			return $parts[0];
		}

		return '';
	}

	/**
	 * Build a single filter condition
	 *
	 * @param array $filter
	 *
	 * @return string
	 */
	private function build_single_filter( array $filter ): string {
		$field = $filter['field'];
		$operator = $filter['operator'];
		$value = $filter['value'];

		switch ( $operator ) {
			case 'IN':
				if ( ! is_array( $value ) ) {
					$value = [ $value ];
				}
				$parts = [];
				foreach ( $value as $v ) {
					$escaped = is_numeric( $v ) ? $v : "'" . str_replace( "'", "\\'", $v ) . "'";
					$parts[] = "{$field} = {$escaped}";
				}
				return '(' . implode( ' OR ', $parts ) . ')';

			case 'NOT IN':
				if ( ! is_array( $value ) ) {
					$value = [ $value ];
				}
				$parts = [];
				foreach ( $value as $v ) {
					$escaped = is_numeric( $v ) ? $v : "'" . str_replace( "'", "\\'", $v ) . "'";
					$parts[] = "NOT {$field} = {$escaped}";
				}
				return '(' . implode( ' AND ', $parts ) . ')';

			default:
				// Escape string values for simple operators
				$escaped_value = is_numeric( $value ) ? $value : "'" . str_replace( "'", "\\'", (string) $value ) . "'";

				switch ( $operator ) {
					case '=':
						return "{$field} = {$escaped_value}";

					case '!=':
						return "NOT {$field} = {$escaped_value}";

					case '>':
					case '>=':
					case '<':
					case '<=':
						return "{$field} {$operator} {$escaped_value}";

					default:
						return "{$field} = {$escaped_value}";
				}
		}
	}

	/**
	 * Perform a search on the index
	 *
	 * @param BaseIndex $index
	 * @param string $query
	 * @param array $params
	 *
	 * @return SearchResponse|ErrorResponse
	 */
	public function search( BaseIndex $index, string $query, array $params = [] ): SearchResponse|ErrorResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		$params = $this->format_search_params( $params );
		$result = $this->client->index( $index->get_slug() )->search( $query, $params );

		$builder->response_body( $result->getRaw() );
		$builder->response_code( 200 );

		$offset       = $result->getOffset();
		$per_page     = $result->getLimit();
		$total_hits   = $result->getEstimatedTotalHits();
		$current_page = $per_page > 0 && $offset < $total_hits ? ( (int) ( $offset / $per_page ) ) + 1 : - 1;
		$total_pages  = $total_hits > 0 && $per_page > 0 ? ceil( $total_hits / $per_page ) : 1;
		$builder->hits( $result->getHits() );
		$builder->hits_per_page( $result->getLimit() );
		$builder->current_page( $current_page );
		$builder->total_hits( $result->getEstimatedTotalHits() );
		$builder->total_pages( $total_pages );

		// Extract facet distribution if available
		$facet_distribution = $result->getFacetDistribution();
		if ( ! empty( $facet_distribution ) ) {
			$builder->facet_distribution( $facet_distribution );
		}

		// Extract facet stats if available (min/max for numeric facets like price)
		$facet_stats = $result->getFacetStats();
		if ( ! empty( $facet_stats ) ) {
			$builder->facet_stats( $facet_stats );
		}

		return $builder->build();
	}

	/**
	 * Syncs item to the index
	 *
	 * @param BaseIndex $index
	 * @param IIndexableObject $item
	 *
	 * @return IndexResponse
	 */
	public function sync_item( BaseIndex $index, IIndexableObject $item ): IndexResponse {

		$builder    = new IndexResponseBuilder( __FUNCTION__ );
		$start_time = microtime( true );

		try {
			$data = $item->get_indexable_data();

			Logger::channel( Logger::CHANNEL_OBSERVERS )->debug( 'MeiliSearch sync started', [
				'index_slug' => $index->get_slug(),
				'object_id'  => $item->get_id(),
				'post_id'    => $data['post_id'] ?? null,
				'post_title' => $data['post_title'] ?? null,
			] );

			$result      = $this->client->index( $index->get_slug() )->updateDocuments( [ $data ] );
			$duration_ms = round( ( microtime( true ) - $start_time ) * 1000, 2 );

			Logger::channel( Logger::CHANNEL_OBSERVERS )->info( 'MeiliSearch sync completed', [
				'index_slug'  => $index->get_slug(),
				'object_id'   => $item->get_id(),
				'duration_ms' => $duration_ms,
			] );

			$builder->response_code( 200 );
			$builder->response_body( [] );
			$builder->task_object( $result );
			if ( method_exists( $result, 'getTaskUid' ) ) {
				$builder->task_id( (string) $result->getTaskUid() );
			}
		} catch ( \Exception $e ) {
			Logger::channel( Logger::CHANNEL_ERRORS )->error( 'MeiliSearch sync failed', [
				'index_slug' => $index->get_slug(),
				'object_id'  => $item->get_id(),
				'exception'  => $e->getMessage(),
				'error_code' => $e->getCode(),
			] );

			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Deletes item from the index
	 *
	 * @param BaseIndex $index
	 * @param IIndexableObject $item
	 *
	 * @return IndexResponse
	 */
	public function delete_item( BaseIndex $index, IIndexableObject $item ): IndexResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		try {
			$result = $this->client->index( $index->get_slug() )->deleteDocument( $item->get_id() );
			$builder->response_code( 200 );
			$builder->response_body( [] );
			$builder->task_object( $result );
			if ( method_exists( $result, 'getTaskUid' ) ) {
				$builder->task_id( (string) $result->getTaskUid() );
			}
		} catch ( \Exception $e ) {
			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Counts index indexed data
	 *
	 * @param BaseIndex $index
	 *
	 * @return IndexResponse
	 */
	public function count_items( BaseIndex $index ): IndexResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		try {
			$result = $this->client->index( $index->get_slug() )->stats();
			$builder->value( isset( $result['numberOfDocuments'] ) ? (int) $result['numberOfDocuments'] : 0 );
			$builder->response_code( 200 );
			$builder->response_body( $result );
		} catch ( \Exception $e ) {
			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Check whether the index exists
	 *
	 * @param BaseIndex $index
	 *
	 * @return IndexResponse
	 */
	public function check_index( BaseIndex $index ): IndexResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		try {
			$this->client->index( $index->get_slug() )->fetchInfo();
			$builder->response_code( 200 );
			$builder->response_body( [] );
		} catch ( \Exception $e ) {
			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Create the index if it doesn't exist
	 *
	 * @param BaseIndex $index
	 *
	 * @return IndexResponse
	 */
	public function touch_index( BaseIndex $index ): IndexResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		try {
			$settings    = $index->get_settings();
			$primary_key = $settings->get_primary_key();
			$options     = ! empty( $primary_key ) ? [ 'primaryKey' => $primary_key ] : [];
			$result      = $this->client->createIndex( $index->get_slug(), $options );

			Logger::channel( Logger::CHANNEL_OBSERVERS )->info( 'MeiliSearch index created', [
				'index_slug'  => $index->get_slug(),
				'primary_key' => $primary_key,
			] );

			$builder->response_code( 200 );
			$builder->response_body( [] );
			$builder->task_object( $result );
			if ( method_exists( $result, 'getTaskUid' ) ) {
				$builder->task_id( (string) $result->getTaskUid() );
			}
		} catch ( \Exception $e ) {
			Logger::channel( Logger::CHANNEL_ERRORS )->error( 'MeiliSearch touch_index failed', [
				'index_slug' => $index->get_slug(),
				'exception'  => $e->getMessage(),
				'error_code' => $e->getCode(),
			] );

			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Clear the index
	 *
	 * @param BaseIndex $index
	 *
	 * @return SuccessResponse|ErrorResponse
	 */
	public function clear_index( BaseIndex $index ): SuccessResponse|ErrorResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		try {
			$result = $this->client->index( $index->get_slug() )->deleteAllDocuments();
			$builder->response_code( 200 );
			$builder->response_body( [] );
			$builder->task_object( $result );
			if ( method_exists( $result, 'getTaskUid' ) ) {
				$builder->task_id( (string) $result->getTaskUid() );
			}
		} catch ( \Exception $e ) {
			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Delete the index
	 *
	 * @param BaseIndex $index
	 *
	 * @return SuccessResponse|ErrorResponse
	 */
	public function delete_index( BaseIndex $index ): SuccessResponse|ErrorResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		try {
			$result = $this->client->index( $index->get_slug() )->delete();
			$builder->response_code( 200 );
			$builder->response_body( [] );
			$builder->task_object( $result );
			if ( method_exists( $result, 'getTaskUid' ) ) {
				$builder->task_id( (string) $result->getTaskUid() );
			}
		} catch ( \Exception $e ) {
			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Push index settings to remote
	 *
	 * @param BaseIndex $index
	 *
	 * @return SuccessResponse|ErrorResponse
	 */
	public function push_settings( BaseIndex $index ): SuccessResponse|ErrorResponse {

		$builder = new IndexResponseBuilder( __FUNCTION__ );
		try {
			$settings = $this->build_settings( $index );
			$result   = $this->client->index( $index->get_slug() )->updateSettings( $settings );

			Logger::channel( Logger::CHANNEL_OBSERVERS )->info( 'MeiliSearch settings pushed', [
				'index_slug' => $index->get_slug(),
			] );

			$builder->response_code( 200 );
			$builder->response_body( [] );
			$builder->task_object( $result );
			if ( method_exists( $result, 'getTaskUid' ) ) {
				$builder->task_id( (string) $result->getTaskUid() );
			}
		} catch ( \Exception $e ) {
			Logger::channel( Logger::CHANNEL_ERRORS )->error( 'MeiliSearch push_settings failed', [
				'index_slug' => $index->get_slug(),
				'exception'  => $e->getMessage(),
				'error_code' => $e->getCode(),
			] );

			$builder->response_code( (int) $e->getCode() );
			$builder->response_body( [] );
			$builder->error_message( $e->getMessage() );
		}

		return $builder->build();
	}

	/**
	 * Build the settings
	 *
	 * @param BaseIndex $index
	 *
	 * @return array
	 */
	public function build_settings( BaseIndex $index ): array {

		$s = $index->get_settings();

		// Basic attributes
		$searchable_attributes = $s->get_searchable_attributes();
		$filterable_attributes = $s->get_filterable_attributes();
		$sortable_attributes   = $s->get_sortable_attributes();
		$distinct_attribute    = $s->get_distinct_attribute();
		$max_hits              = $s->get_max_hits();
		$synonyms              = $s->get_synonyms();

		// Format synonyms from IndexSynonym objects to array
		$conf_synonyms = [];
		foreach ( $synonyms as $synonym ) {
			$conf_synonyms[ $synonym->word ] = $synonym->corrections;
		}

		// Get stop words from stored settings, allow filter override
		$conf_stop_words     = apply_filters( 'celersearch_service_meilisearch_stop_words', $s->get_stop_words(), $this );
		$conf_non_sep_tokens = apply_filters( 'celersearch_service_meilisearch_non_separator_tokens', $s->get_non_token_separators(), $this );
		$conf_sep_tokens     = apply_filters( 'celersearch_service_meilisearch_separator_tokens', $s->get_token_separators(), $this );
		$conf_dictionary     = apply_filters( 'celersearch_service_meilisearch_dictionary', [], $this );
		$conf_synonyms       = apply_filters( 'celersearch_service_meilisearch_synonyms', $conf_synonyms, $this );

		// Proximity precision from stored settings
		$conf_pprecision = apply_filters( 'celersearch_service_meilisearch_proximity_precision', $s->get_proximity_precision(), $this );

		// Faceting with stored max values
		$conf_faceting = apply_filters( 'celersearch_service_meilisearch_faceting', [
			"maxValuesPerFacet" => $s->get_facet_max_values()
		], $this );

		// Pagination with stored max hits
		$conf_pagination = apply_filters( 'celersearch_service_meilisearch_pagination', [
			"maxTotalHits" => $max_hits
		] );

		// Ranking rules from stored settings, with default fallback
		$stored_ranking_rules = $s->get_ranking_rules();
		$conf_ranking_rules   = apply_filters( 'celersearch_service_meilisearch_ranking_rules',
			! empty( $stored_ranking_rules ) ? $stored_ranking_rules : $this->default_ranking_rules(),
			$this
		);

		// Typo tolerance from stored settings
		$conf_typo_tolerance = apply_filters( 'celersearch_service_meilisearch_typo_tolerance', [
			"enabled"             => $s->is_typo_enabled(),
			"minWordSizeForTypos" => [
				"oneTypo"  => $s->get_typo_min_one(),
				"twoTypos" => $s->get_typo_min_two()
			],
			"disableOnWords"      => $s->get_typo_disable_words(),
			"disableOnAttributes" => $s->get_typo_disable_fields()
		], $this );

		return [
			"displayedAttributes"  => [ "*" ],
			"synonyms"             => ! empty( $conf_synonyms ) ? $conf_synonyms : null,
			"searchableAttributes" => ! empty( $searchable_attributes ) ? ArrayUtilities::remove_secondary_value( $searchable_attributes ) : [ "*" ],
			"filterableAttributes" => ! empty( $filterable_attributes ) ? $filterable_attributes : [],
			"sortableAttributes"   => ! empty( $sortable_attributes ) ? ArrayUtilities::remove_secondary_value( $sortable_attributes ) : [],
			"distinctAttribute"    => ! empty( $distinct_attribute ) ? $distinct_attribute : null,
			// Note: primaryKey must be set during index creation (touch_index), not in updateSettings
			"rankingRules"         => $conf_ranking_rules,
			"typoTolerance"        => $conf_typo_tolerance,
			"faceting"             => $conf_faceting,
			"pagination"           => $conf_pagination,
			"proximityPrecision"   => $conf_pprecision,
			"stopWords"            => $conf_stop_words,
			"nonSeparatorTokens"   => $conf_non_sep_tokens,
			"separatorTokens"      => $conf_sep_tokens,
			"dictionary"           => $conf_dictionary,
		];
	}

	/**
	 * Get default ranking rules for Meilisearch
	 *
	 * @return array
	 */
	private function default_ranking_rules(): array {
		return [ "words", "typo", "proximity", "attribute", "sort", "exactness" ];
	}

	/**
	 * Wait for an async task to complete
	 *
	 * MeiliSearch operations are asynchronous - they return task IDs immediately
	 * but the actual operation completes later. This method blocks until the task
	 * finishes or times out.
	 *
	 * @param IndexResponse $response Response containing task info
	 * @param int           $timeout_ms Maximum wait time in milliseconds
	 *
	 * @return bool True if task completed successfully, false otherwise
	 */
	public function wait_for_completion( IndexResponse $response, int $timeout_ms = 5000 ): bool {
		$task = $response->get_task_object();

		if ( ! $task instanceof \CelerSearch\Vendor\Meilisearch\Contracts\Task ) {
			// No task to wait for, consider it complete
			return true;
		}

		try {
			// Use the Task's built-in wait() method
			$completed_task = $task->wait( $timeout_ms );
			return $completed_task->getStatus() === \CelerSearch\Vendor\Meilisearch\Contracts\TaskStatus::Succeeded;
		} catch ( \Exception $e ) {
			Logger::channel( Logger::CHANNEL_ERRORS )->error( 'Task wait failed', [
				'task_uid' => $task->getTaskUid(),
				'error'    => $e->getMessage(),
			] );
			return false;
		}
	}
}