<?php
/**
 * @license GPL-2.0-or-later
 */

namespace CirrusSearch\Search\Rescore;

use CirrusSearch\CirrusSearchHookRunner;
use CirrusSearch\Profile\ArrayPathSetter;
use CirrusSearch\Profile\SearchProfileService;
use CirrusSearch\Search\SearchContext;
use Elastica\Query\FunctionScore;

class FunctionScoreChain {
	/**
	 * List of allowed function_score param
	 * we keep boost and boost_mode even if they do not make sense
	 * here since we do not allow to specify the query param.
	 * The query will be MatchAll with a score to 1.
	 *
	 * @var string[]
	 */
	private static $functionScoreParams = [
		'boost',
		'boost_mode',
		'max_boost',
		'score_mode',
		'min_score'
	];

	/**
	 * @var SearchContext
	 */
	private $context;

	/**
	 * @var FunctionScoreDecorator
	 */
	private $functionScore;

	/**
	 * @var array the function score chain
	 */
	private $chain;

	/**
	 * @var string the name of the chain
	 */
	private $chainName;
	/**
	 * @var CirrusSearchHookRunner
	 */
	private $cirrusSearchHookRunner;

	/**
	 * Builds a new function score chain.
	 *
	 * @param SearchContext $context
	 * @param string $chainName the name of the chain (must be a valid
	 *  chain in wgCirrusSearchRescoreFunctionScoreChains)
	 * @param array $overrides Parameter overrides
	 * @param CirrusSearchHookRunner $cirrusSearchHookRunner
	 */
	public function __construct( SearchContext $context, $chainName, $overrides, CirrusSearchHookRunner $cirrusSearchHookRunner ) {
		$this->chainName = $chainName;
		$this->context = $context;
		$this->functionScore = new FunctionScoreDecorator();
		$chain = $context->getConfig()
			->getProfileService()
			->loadProfileByName( SearchProfileService::RESCORE_FUNCTION_CHAINS, $chainName );
		$this->chain = $overrides ? $this->applyOverrides( $chain, $overrides ) : $chain;

		$params = array_intersect_key( $this->chain, array_flip( self::$functionScoreParams ) );
		foreach ( $params as $param => $value ) {
			$this->functionScore->setParam( $param, $value );
		}
		$this->cirrusSearchHookRunner = $cirrusSearchHookRunner;
	}

	private function applyOverrides( array $chain, array $overrides ): array {
		$transformer = new ArrayPathSetter( $overrides );
		return $transformer->transform( $chain );
	}

	/**
	 * @return FunctionScore|null the rescore query or null none of functions were
	 *  needed.
	 * @throws InvalidRescoreProfileException
	 */
	public function buildRescoreQuery() {
		if ( !isset( $this->chain['functions'] ) ) {
			throw new InvalidRescoreProfileException( "No functions defined in chain {$this->chainName}." );
		}
		foreach ( $this->chain['functions'] as $func ) {
			$impl = $this->getImplementation( $func );
			$impl->append( $this->functionScore );
		}
		// Add extensions
		if ( !empty( $this->chain['add_extensions'] ) ) {
			foreach ( $this->context->getExtraScoreBuilders() as $extBuilder ) {
				$extBuilder->append( $this->functionScore );
			}
		}
		if ( !$this->functionScore->isEmptyFunction() ) {
			return $this->functionScore;
		}
		return null;
	}

	/**
	 * @param array $func
	 * @return BoostFunctionBuilder
	 * @throws InvalidRescoreProfileException
	 */
	private function getImplementation( $func ) {
		$weight = $func['weight'] ?? 1;
		$config = $this->context->getConfig();
		switch ( $func['type'] ) {
			case 'boostlinks':
				return new IncomingLinksFunctionScoreBuilder();
			case 'recency':
				foreach ( $this->context->getExtraScoreBuilders() as $boostFunctionBuilder ) {
					if ( $boostFunctionBuilder instanceof PreferRecentFunctionScoreBuilder ) {
						// If prefer-recent was used as a keyword we don't send the one
						// from the profile
						return new BoostedQueriesFunction( [], [] );
					}
				}

				$preferRecentDecayPortion = $config->get( 'CirrusSearchPreferRecentDefaultDecayPortion' );
				$preferRecentHalfLife = 0;
				if ( $preferRecentDecayPortion > 0 ) {
					$preferRecentHalfLife = $config->get( 'CirrusSearchPreferRecentDefaultHalfLife' );
				}
				return new PreferRecentFunctionScoreBuilder( $config, $weight,
					$preferRecentHalfLife, $preferRecentDecayPortion );
			case 'templates':
				$withDefaultBoosts = true;
				foreach ( $this->context->getExtraScoreBuilders() as $boostFunctionBuilder ) {
					if ( $boostFunctionBuilder instanceof ByKeywordTemplateBoostFunction ) {
						$withDefaultBoosts = false;
						break;
					}
				}

				return new BoostTemplatesFunctionScoreBuilder( $config, $this->context->getNamespaces(),
					$this->context->getLimitSearchToLocalWiki(), $withDefaultBoosts, $weight );
			case 'namespaces':
				return new NamespacesFunctionScoreBuilder( $config, $this->context->getNamespaces(), $weight );
			case 'language':
				return new LangWeightFunctionScoreBuilder( $config, $weight );
			case 'custom_field':
				return new CustomFieldFunctionScoreBuilder( $config, $weight, $func['params'] );
			case 'script':
				return new ScriptScoreFunctionScoreBuilder( $config, $weight, $func['script'] );
			case 'logscale_boost':
				return new LogScaleBoostFunctionScoreBuilder( $config, $weight, $func['params'] );
			case 'satu':
				return new SatuFunctionScoreBuilder( $config, $weight, $func['params'] );
			case 'log_multi':
				return new LogMultFunctionScoreBuilder( $config, $weight, $func['params'] );
			case 'geomean':
				return new GeoMeanFunctionScoreBuilder( $config, $weight, $func['params'] );
			case 'term_boost':
				return new TermBoostScoreBuilder( $config, $weight, $func['params'] );
			default:
				$builder = null;
				$this->cirrusSearchHookRunner->onCirrusSearchScoreBuilder( $func, $this->context, $builder );
				if ( !$builder ) {
					throw new InvalidRescoreProfileException( "Unknown function score type {$func['type']}." );
				}
				if ( !( $builder instanceof BoostFunctionBuilder ) ) {
					throw new InvalidRescoreProfileException( "Invalid function score type {$func['type']}: expected " .
						BoostFunctionBuilder::class . " but was " . get_class( $builder ) );
				}
				return $builder;
		}
	}
}
