<?php

namespace CirrusSearch\Api;

use CirrusSearch\Connection;
use CirrusSearch\Sanity\BufferedRemediator;
use CirrusSearch\Sanity\Checker;
use CirrusSearch\Sanity\CheckerException;
use CirrusSearch\Sanity\Remediator;
use CirrusSearch\SearchConfig;
use CirrusSearch\Searcher;
use CirrusSearch\Util;
use MediaWiki\Api\ApiBase;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\ParamValidator\ParamValidator;
use WikiMedia\ParamValidator\TypeDef\IntegerDef;

/**
 * Validates the sanity of the search indexes for a range of page id's
 *
 * Invokes the cirrus sanity checker which compares a range of page ids
 * current state in the sql database against the elasticsearch indexes.
 * Reports on issues found such as missing pages, pages that should have
 * been deleted, and old versions in the search index.
 *
 * Also offers a constant rerender-over-time through the sequenceid and
 * rerenderfrequency options. The sequenceid should be incremented each
 * time the same set of page ids is sent to the checker. A subset of
 * the page ids will be emit as `oldDocument` in each batch, such that
 * after `rerenderfrequency` increments of `sequenceid` all pages will
 * have been rerendered. The purpose of the over-time rerender is to
 * ensure changes to how pages are rendered make it into the search indexes
 * within an expected timeframe.
 *
 * @license GPL-2.0-or-later
 */
class CheckSanity extends ApiBase {
	use ApiTrait;

	public function execute() {
		$cluster = $this->getParameter( 'cluster' );
		// Start and end values are inclusive
		$start = $this->getParameter( 'from' );
		$end = $start + $this->getParameter( 'limit' ) - 1;

		$remediator = new BufferedRemediator();
		$this->check( $this->makeChecker( $cluster, $remediator ), $start, $end );
		$problems = $remediator->getActions();

		$result = $this->getResult();
		$result->addValue( null, 'wikiId', WikiMap::getCurrentWikiId() );
		$result->addValue(
			null, 'clusterGroup',
			$this->getSearchConfig()->getClusterAssignment()->getCrossClusterName() );
		$result->addValue( null, 'problems', $this->reformat( $problems ) );
	}

	protected function makeChecker( string $cluster, Remediator $remediator ): Checker {
		$searchConfig = $this->getSearchConfig();
		$connection = Connection::getPool( $searchConfig, $cluster );
		$searcher = new Searcher( $connection, 0, 0, $searchConfig, [], null );

		return new Checker(
			$searchConfig,
			$connection,
			$remediator,
			$searcher,
			Util::getStatsFactory(),
			false, // logSane
			false, // fastRedirectCheck
			null, // pageCache
			$this->makeIsOldClosure()
		);
	}

	private function makeIsOldClosure(): ?\Closure {
		$sequenceId = $this->getParameter( 'sequenceid' );
		if ( $sequenceId === null ) {
			return null;
		}
		return Checker::makeIsOldClosure(
			$sequenceId,
			$this->getParameter( 'rerenderfrequency' )
		);
	}

	private function check( Checker $checker, int $start, int $end, int $batchSize = 10 ) {
		$ranges = array_chunk( range( $start, $end ), $batchSize );
		foreach ( $ranges as $pageIds ) {
			try {
				$checker->check( $pageIds );
			} catch ( CheckerException $e ) {
				// This mostly happens when there is a transient data loading problem.
				// The request should be retried.
				$this->dieWithException( $e );
			}
		}
	}

	/**
	 * Reformat Saneitizer problems for output
	 *
	 * Intentionally only emits numeric ids to avoid responding with
	 * any user generated data. As a list of page ids and index states
	 * this shouldn't be capable of leaking information thats not already
	 * known.
	 */
	private function reformat( array $problems ): array {
		$clean = [];
		$indexBaseName = $this->getSearchConfig()->get( SearchConfig::INDEX_BASE_NAME );
		// Generic connection for resolving index names, its always the same everywhere
		$connection = Connection::getPool( $this->getSearchConfig() );
		foreach ( $problems as [ $problem, $args ] ) {
			switch ( $problem ) {
				case 'redirectInIndex':
					[ $docId, $page, $indexSuffix ] = $args;
					$target = $page->getRedirectTarget();
					$problem = [
						'indexName' => $connection->getIndexName( $indexBaseName, $indexSuffix ),
						'errorType' => $problem,
						'pageId' => $page->getId(),
						'namespaceId' => $page->getNamespace(),
					];
					// Page could redirect to a Special page or even another wiki,
					// target information is only useful on pages that exist locally.
					if ( $target != null && $target->canExist() ) {
						$targetIndexSuffix = $connection->getIndexSuffixForNamespace( $target->getNamespace() );
						$problem['target'] = [
							'pageId' => $target->getId(),
							'namespaceId' => $target->getNamespace(),
							'indexName' => $connection->getIndexName( $indexBaseName, $targetIndexSuffix ),
						];
					}
					$clean[] = $problem;
					break;

				case 'pageNotInIndex':
				case 'oldDocument':
					[ $page ] = $args;
					$indexSuffix = $connection->getIndexSuffixForNamespace( $page->getNamespace() );
					$clean[] = [
						'indexName' => $connection->getIndexName( $indexBaseName, $indexSuffix ),
						'errorType' => $problem,
						'pageId' => $page->getId(),
						'namespaceId' => $page->getNamespace(),
					];
					break;

				case 'ghostPageInIndex':
					[ $docId, $title ] = $args;
					$indexSuffix = $connection->getIndexSuffixForNamespace( $title->getNamespace() );
					$clean[] = [
						'indexName' => $connection->getIndexName( $indexBaseName, $indexSuffix ),
						'errorType' => $problem,
						'pageId' => (int)$docId,
						'namespaceId' => $title->getNamespace(),
					];
					break;

				case 'pageInWrongIndex':
					[ $docId, $page, $wrongIndexSuffix ] = $args;
					$indexSuffix = $connection->getIndexSuffixForNamespace( $page->getNamespace() );
					$clean[] = [
						'wrongIndexName' => $connection->getIndexName( $indexBaseName, $wrongIndexSuffix ),
						'indexName' => $connection->getIndexName( $indexBaseName, $indexSuffix ),
						'errorType' => $problem,
						'pageId' => $page->getId(),
						'namespaceId' => $page->getNamespace(),
					];
					break;

				case 'oldVersionInIndex':
					// kinda random this one provides the suffix directly
					[ $docId, $page, $indexSuffix ] = $args;
					$clean[] = [
						'indexName' => $connection->getIndexName( $indexBaseName, $indexSuffix ),
						'errorType' => $problem,
						'pageId' => $page->getId(),
						'namespaceId' => $page->getNamespace(),
					];
					break;

				default:
					$this->dieDebug( __METHOD__, "Unknown remediation: $problem" );
			}
		}

		return $clean;
	}

	/** @inheritDoc */
	public function getAllowedParams() {
		$assignment = $this->getSearchConfig()->getClusterAssignment();
		return [
			'cluster' => [
				ParamValidator::PARAM_REQUIRED => true,
				ParamValidator::PARAM_TYPE => $assignment->getManagedClusters(),
			],
			'from' => [
				ParamValidator::PARAM_TYPE => 'integer',
				ParamValidator::PARAM_REQUIRED => true,
				IntegerDef::PARAM_MIN => 0,
			],
			'limit' => [
				ParamValidator::PARAM_DEFAULT => 100,
				ParamValidator::PARAM_TYPE => 'limit',
				IntegerDef::PARAM_MIN => 1,
				IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
				IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
			],
			// The caller must increment the sequenceid each successive
			// time it invokes the sanity check for the same set of pages.
			// Pages within the batch will emit an `oldDocument` problem
			// spread over `rerenderfrequency` invocations of the api.
			// This supports a slow and constant rerender of all content,
			// ensuring the search indices stay aligned with changes to
			// indexing and rendering code.
			'sequenceid' => [
				// Providing this enables the "old document" checks
				// which provide constant re-rendering over time.
				ParamValidator::PARAM_TYPE => 'integer',
			],
			// Controls how often a page is flagged with the `oldDocument`
			// problem. If the caller scans all page ids every week, then
			// the default value of 16 would emit an `oldDocument` problem
			// for all existing pages spread over 16 weeks.
			'rerenderfrequency' => [
				ParamValidator::PARAM_DEFAULT => 16,
				ParamValidator::PARAM_TYPE => 'integer',
				IntegerDef::PARAM_MIN => 2,
			]
		];
	}

	/**
	 * Mark as internal. This isn't meant to be used by normal api users
	 * @return bool
	 */
	public function isInternal() {
		return true;
	}

}
