<?php

namespace CirrusSearch\Fallbacks;

use CirrusSearch\CirrusSearchHookRunner;
use CirrusSearch\InterwikiResolver;
use CirrusSearch\Parser\NamespacePrefixParser;
use CirrusSearch\Profile\SearchProfileException;
use CirrusSearch\Profile\SearchProfileService;
use CirrusSearch\Search\CirrusSearchResultSet;
use CirrusSearch\Search\MSearchRequests;
use CirrusSearch\Search\MSearchResponses;
use CirrusSearch\Search\SearchMetricsProvider;
use CirrusSearch\Search\SearchQuery;
use Elastica\Client;
use Wikimedia\Assert\Assert;

class FallbackRunner implements SearchMetricsProvider {
	/** @var self */
	private static $NOOP_RUNNER = null;

	/**
	 * @var FallbackMethod[] List of fallbacks to apply in order, keyed by
	 *  name of the fallback configuration.
	 */
	private $fallbackMethods;

	/**
	 * @var array[] Execution trace of fallback methods run and their result.
	 */
	private $searchMetrics = [];

	/**
	 * @var int[] Map from fallback name to number of hits returned
	 */
	private $responseMetrics = [];

	/**
	 * @param FallbackMethod[] $fallbackMethods List of fallbacks to apply in order,
	 *  keyed by name of the fallback configuration.
	 */
	public function __construct( array $fallbackMethods ) {
		$this->fallbackMethods = $fallbackMethods;
	}

	/**
	 * Noop fallback runner
	 * @return self
	 */
	public static function noopRunner(): self {
		self::$NOOP_RUNNER ??= new self( [] );
		return self::$NOOP_RUNNER;
	}

	/**
	 * @param SearchQuery $query
	 * @param InterwikiResolver $interwikiResolver
	 * @param string $profileContext
	 * @param array $profileContextParam
	 * @return self
	 */
	public static function create(
		SearchQuery $query,
		InterwikiResolver $interwikiResolver,
		$profileContext = SearchProfileService::CONTEXT_DEFAULT,
		$profileContextParam = []
	): self {
		$profileService = $query->getSearchConfig()->getProfileService();
		if ( !$profileService->supportsContext( SearchProfileService::FALLBACKS, $profileContext ) ) {
			// This component is optional and we simply avoid building it if the $profileContext does
			// not define any defaults for it.
			return self::noopRunner();
		}
		return self::createFromProfile(
			$query,
			$profileService->loadProfile( SearchProfileService::FALLBACKS, $profileContext, null, $profileContextParam ),
			$interwikiResolver
		);
	}

	/**
	 * @param SearchQuery $query
	 * @param array $profile
	 * @param InterwikiResolver $interwikiResolver
	 * @return self
	 */
	private static function createFromProfile( SearchQuery $query, array $profile, InterwikiResolver $interwikiResolver ): self {
		$fallbackMethods = [];
		$methodDefs = $profile['methods'] ?? [];
		foreach ( $methodDefs as $name => $methodDef ) {
			if ( !isset( $methodDef['class'] ) ) {
				throw new SearchProfileException( "Invalid FallbackMethod: missing 'class' definition in profile" );
			}
			$clazz = $methodDef['class'];
			$params = $methodDef['params'] ?? [];
			if ( !class_exists( $clazz ) ) {
				throw new SearchProfileException( "Invalid FallbackMethod: unknown class $clazz" );
			}
			if ( !is_subclass_of( $clazz, FallbackMethod::class ) ) {
				throw new SearchProfileException( "Invalid FallbackMethod: $clazz must implement " . FallbackMethod::class );
			}
			if ( !self::acceptQuery( $methodDef, $query ) ) {
				continue;
			}
			$method = $clazz::build( $query, $params, $interwikiResolver );
			if ( $method !== null ) {
				$fallbackMethods[$name] = $method;
			}
		}
		return $fallbackMethods === [] ? self::noopRunner() : new self( $fallbackMethods );
	}

	private static function acceptQuery( array $methodDef, SearchQuery $query ): bool {
		if ( $methodDef['default_ns_only'] ?? false ) {
			return $query->isTargetingDefaultSearchedNamespaces();
		}
		return true;
	}

	/**
	 * @param SearcherFactory $factory
	 * @param CirrusSearchResultSet $initialResult
	 * @param MSearchResponses $responses
	 * @param NamespacePrefixParser $namespacePrefixParser
	 * @param CirrusSearchHookRunner $cirrusSearchHookRunner
	 * @return CirrusSearchResultSet
	 */
	public function run(
		SearcherFactory $factory,
		CirrusSearchResultSet $initialResult,
		MSearchResponses $responses,
		NamespacePrefixParser $namespacePrefixParser,
		CirrusSearchHookRunner $cirrusSearchHookRunner
	) {
		$this->recordResponseMetrics( $responses );
		$methods = [];
		$position = 0;
		$context = new FallbackRunnerContextImpl( $initialResult, $factory, $namespacePrefixParser, $cirrusSearchHookRunner );
		foreach ( $this->fallbackMethods as $name => $fallback ) {
			$position++;
			$context->resetSuggestResponse();
			if ( $fallback instanceof ElasticSearchRequestFallbackMethod ) {
				$k = $this->msearchKey( $position );
				if ( $responses->hasResultsFor( $k ) ) {
					$context->setSuggestResponse( $responses->getResultSet( $this->msearchKey( $position ) ) );
				}
			}
			$score = $fallback->successApproximation( $context );
			if ( $score >= 1.0 ) {
				$status = $this->execute( $name, $fallback, $context );
				return $status->apply( $context->getPreviousResultSet() );
			}
			if ( $score <= 0 ) {
				continue;
			}
			$methods[] = [
				'name' => $name,
				'method' => $fallback,
				'score' => $score,
				'position' => $position
			];
		}

		usort( $methods, static function ( $a, $b ) {
			return $b['score'] <=> $a['score'] ?: $a['position'] <=> $b['position'];
		} );
		foreach ( $methods as $fallbackArray ) {
			$name = $fallbackArray['name'];
			$fallback = $fallbackArray['method'];
			$context->resetSuggestResponse();
			if ( $fallback instanceof ElasticSearchRequestFallbackMethod ) {
				$context->setSuggestResponse( $responses->getResultSet( $this->msearchKey( $fallbackArray['position'] ) ) );
			}
			$status = $this->execute( $name, $fallback, $context );
			$context->setPreviousResultSet( $status->apply( $context->getPreviousResultSet() ) );
		}
		return $context->getPreviousResultSet();
	}

	public function getElasticSuggesters(): array {
		$suggesters = [];
		foreach ( $this->fallbackMethods as $method ) {
			if ( $method instanceof ElasticSearchSuggestFallbackMethod ) {
				$suggestQueries = $method->getSuggestQueries();
				if ( $suggestQueries !== null ) {
					foreach ( $suggestQueries as $name => $suggestQ ) {
						Assert::precondition( !array_key_exists( $name, $suggesters ),
							get_class( $method ) . " is trying to add a suggester [$name] (duplicate)" );
						$suggesters[$name] = $suggestQ;
					}
				}
			}
		}
		return $suggesters;
	}

	public function attachSearchRequests( MSearchRequests $requests, Client $client ) {
		$position = 0;
		foreach ( $this->fallbackMethods as $method ) {
			$position++;
			if ( $method instanceof ElasticSearchRequestFallbackMethod ) {
				$search = $method->getSearchRequest( $client );
				if ( $search !== null ) {
					$requests->addRequest(
						$this->msearchKey( $position ),
						$search
					);
				}
			}
		}
	}

	/**
	 * @param int $position
	 * @return string
	 */
	private function msearchKey( $position ) {
		return "fallback-$position";
	}

	/**
	 * @param string $name
	 * @param FallbackMethod $fallbackMethod
	 * @param FallbackRunnerContext $context
	 * @return FallbackStatus
	 */
	private function execute( string $name, FallbackMethod $fallbackMethod, FallbackRunnerContext $context ): FallbackStatus {
		$status = $fallbackMethod->rewrite( $context );
		// Collecting metrics here isn't particularly simple, as methods have
		// the ability to not only change the suggestion, but to replace the
		// entire result set. Instead of figuring out what happened we only
		// record that a method was run, depending on implementations to
		// report what happened themselves.
		$metrics = [ 'name' => $name, 'action' => $status->getAction() ];
		if ( $fallbackMethod instanceof SearchMetricsProvider ) {
			$metrics += $fallbackMethod->getMetrics() ?? [];
		}
		$this->searchMetrics[] = $metrics;
		return $status;
	}

	private function recordResponseMetrics( MSearchResponses $responses ) {
		// Report on which fallback methods returned results, even if they didn't get used.
		$position = 0;
		foreach ( $this->fallbackMethods as $name => $fallback ) {
			$position++;
			if ( !( $fallback instanceof ElasticSearchRequestFallbackMethod ) ) {
				continue;
			}
			$k = $this->msearchKey( $position );
			if ( !$responses->hasResultsFor( $k ) ) {
				continue;
			}
			$resultSet = $responses->getResultSet( $k );
			$this->responseMetrics[$name] = count( $resultSet );
		}
	}

	/**
	 * @return array
	 */
	public function getMetrics() {
		// The metrics we have currently are useful for debugging or
		// tracing, but are too detailed for passing on to the frontend
		// to use as part of event reporting.  Generate a simplified
		// version for that purpose.
		$mainResults = [ 'name' => '__main__', 'action' => null ];
		$querySuggestion = null;
		foreach ( $this->searchMetrics as $metrics ) {
			if ( $metrics['action'] == FallbackStatus::ACTION_SUGGEST_QUERY ) {
				$querySuggestion = $metrics;
			} elseif ( $metrics['action'] == FallbackStatus::ACTION_REPLACE_LOCAL_RESULTS ) {
				$mainResults = $metrics;
				$querySuggestion = $metrics;
			}
		}

		return [
			'wgCirrusSearchFallback' => [
				// action that provided main search results
				'mainResults' => $mainResults,
				// action that made final query suggestion
				'querySuggestion' => $querySuggestion,
				// metrics about requests that were made
				'responseMetrics' => $this->responseMetrics,
			],
		];
	}
}
