<?php

namespace CirrusSearch\Maintenance;

use CirrusSearch\Connection;
use CirrusSearch\MetaStore\MetaStoreIndex;
use CirrusSearch\SearchConfig;
use CirrusSearch\UserTestingEngine;
use Elastica\Index;
use MediaWiki\Maintenance\Maintenance as MWMaintenance;
use MediaWiki\MediaWikiServices;
use MediaWiki\Settings\SettingsBuilder;
use MediaWiki\Status\Status;
use RuntimeException;
use StatusValue;

// Maintenance class is loaded before autoload, so we need to pull the interface
require_once __DIR__ . '/Printer.php';

/**
 * Cirrus helpful extensions to Maintenance.
 *
 * @license GPL-2.0-or-later
 */
abstract class Maintenance extends MWMaintenance implements Printer {
	/**
	 * @var string The string to indent output with
	 */
	protected static $indent = null;

	/**
	 * @var Connection|null
	 */
	private $connection;

	/**
	 * @var SearchConfig
	 */
	protected $searchConfig;

	public function __construct( ?SearchConfig $searchConfig = null ) {
		parent::__construct();
		if ( $searchConfig !== null ) {
			$this->searchConfig = $searchConfig;
		}
		$this->addOption( 'cluster', 'Perform all actions on the specified elasticsearch cluster',
			false, true );
		$this->addOption( 'userTestTrigger', 'Use config var and profiles set in the user testing ' .
			'framework, e.g. --userTestTrigger=trigger', false, true );
		$this->requireExtension( 'CirrusSearch' );
	}

	public function finalSetup( SettingsBuilder $settingsBuilder ) {
		parent::finalSetup( $settingsBuilder );

		if ( $this->hasOption( 'userTestTrigger' ) ) {
			$this->setupUserTest();
		}
	}

	/**
	 * Setup config vars with the UserTest framework
	 */
	private function setupUserTest() {
		// Configure the UserTesting framework
		// Useful in case an index needs to be built with a
		// test config that is not meant to be the default.
		// This is realistically only usefull to test across
		// multiple clusters.
		// Perhaps setting $wgCirrusSearchIndexBaseName to an
		// alternate value would testing on the same cluster
		// but this index would not receive updates.
		$trigger = $this->getOption( 'userTestTrigger' );
		$engine = UserTestingEngine::fromConfig( $this->getConfig() );
		$status = $engine->decideTestByTrigger( $trigger );
		if ( !$status->isActive() ) {
			$this->fatalError( "Unknown user test trigger: $trigger" );
		}
		$engine->activateTest( $status );
	}

	/** @inheritDoc */
	public function createChild( string $maintClass, ?string $classFile = null ): MWMaintenance {
		$child = parent::createChild( $maintClass, $classFile );
		if ( $child instanceof self ) {
			$child->searchConfig = $this->searchConfig;
		}

		return $child;
	}

	/**
	 * @param string|null $cluster
	 * @return Connection
	 */
	public function getConnection( $cluster = null ) {
		if ( $cluster ) {
			$connection = Connection::getPool( $this->getSearchConfig(), $cluster );
		} else {
			if ( $this->connection === null ) {
				$cluster = $this->decideCluster();
				$this->connection = Connection::getPool( $this->getSearchConfig(), $cluster );
			}
			$connection = $this->connection;
		}

		$connection->setTimeout( $this->getSearchConfig()->get( 'CirrusSearchMaintenanceTimeout' ) );

		return $connection;
	}

	public function getSearchConfig(): SearchConfig {
		if ( $this->searchConfig == null ) {
			// @phan-suppress-next-line PhanTypeMismatchProperty
			$this->searchConfig = MediaWikiServices::getInstance()
				->getConfigFactory()
				->makeConfig( 'CirrusSearch' );
			if ( !$this->searchConfig instanceof SearchConfig ) {
				// We shouldn't ever get here ... but the makeConfig type signature returns the parent
				// class of SearchConfig so just being extra careful...
				throw new \RuntimeException( 'Expected instanceof CirrusSearch\SearchConfig, but received ' .
					get_class( $this->searchConfig ) );
			}
		}
		return $this->searchConfig;
	}

	public function getMetaStore( ?Connection $conn = null ): MetaStoreIndex {
		return new MetaStoreIndex( $conn ?? $this->getConnection(), $this, $this->getSearchConfig() );
	}

	/**
	 * @return string|null
	 */
	protected function decideCluster() {
		$config = $this->getSearchConfig();
		$assignment = $config->getClusterAssignment();

		$cluster = $this->getOption( 'cluster', null );
		if ( $cluster !== null && $config->has( 'CirrusSearchServers' ) ) {
			$this->fatalError( 'Not configured for cluster operations.' );
		}
		if ( $cluster === null ) {
			$cluster = $assignment->getSearchCluster();
		}
		if ( $this->requireManagedCluster() && !$assignment->canManageCluster( $cluster ) ) {
			$this->fatalError(
				"Named cluster ($cluster) is not configured for maintenance operations. " .
				"Allowed clusters: " . implode( ", ", $assignment->getManagedClusters() )
			);
		}
		return $cluster;
	}

	/**
	 * Execute a callback function at the end of initialisation
	 */
	public function loadSpecialVars() {
		parent::loadSpecialVars();
		if ( self::$indent === null ) {
			// First script gets no indentation
			self::$indent = '';
		} else {
			// Others get one tab beyond the last
			self::$indent .= "\t";
		}
	}

	/**
	 * Call to signal that execution of this maintenance script is complete so
	 * the next one gets the right indentation.
	 */
	public function done() {
		self::$indent = substr( self::$indent, 1 );
	}

	/**
	 * @param string $message
	 * @param string|null $channel
	 */
	public function output( $message, $channel = null ) {
		parent::output( $message );
	}

	/** @inheritDoc */
	public function outputIndented( $message ) {
		$this->output( self::$indent . $message );
	}

	/**
	 * @param string $err
	 * @param int $die deprecated, do not use
	 */
	public function error( $err, $die = 0 ) {
		parent::error( $err, $die );
	}

	/**
	 * Disable all pool counters and cirrus query logs.
	 * Only useful for maint scripts
	 *
	 * Ideally this method could be run in the constructor
	 * but apparently globals are reset just before the
	 * call to execute()
	 */
	protected function disablePoolCountersAndLogging() {
		global $wgPoolCounterConf, $wgCirrusSearchLogElasticRequests;

		// Make sure we don't flood the pool counter
		unset( $wgPoolCounterConf['CirrusSearch-Search'] );

		// Don't skew the dashboards by logging these requests to
		// the global request log.
		$wgCirrusSearchLogElasticRequests = false;
	}

	/**
	 * Create metastore only if the alias does not already exist
	 * @return MetaStoreIndex
	 */
	protected function maybeCreateMetastore() {
		$metastore = new MetaStoreIndex(
			$this->getConnection(),
			$this,
			$this->getSearchConfig() );
		$status = $metastore->createIfNecessary();
		$this->unwrap( $status );
		return $metastore;
	}

	protected function requireCirrusReady() {
		// If the version does not exist it's certainly because nothing has been indexed.
		if ( !$this->getMetaStore()->cirrusReady() ) {
			throw new RuntimeException(
				"Cirrus meta store does not exist, you must index your data first"
			);
		}
	}

	/**
	 * @return bool True if this script only operates on clusters specified
	 *  in CirrusSearchManagedClusters. Can be set to false for read-only
	 *  scripts that don't care where they read from.
	 */
	protected function requireManagedCluster() {
		return true;
	}

	/**
	 * Provides support for backward compatible CLI options
	 *
	 * Requires either one or neither of the two options to be provided.
	 *
	 * @param string $current The current option to request
	 * @param string $bc The old option to provide BC support for
	 * @param bool $required True if the option must be provided. When false and no option
	 *  is provided null is returned.
	 * @return mixed
	 */
	protected function getBackCompatOption( string $current, string $bc, bool $required = true ) {
		if ( $this->hasOption( $current ) && $this->hasOption( $bc ) ) {
			$this->error( "\nERROR: --$current cannot be provided with --$bc" );
			$this->maybeHelp( true );
		} elseif ( $this->hasOption( $current ) ) {
			return $this->getOption( $current );
		} elseif ( $this->hasOption( $bc ) ) {
			return $this->getOption( $bc );
		} elseif ( $required ) {
			$this->error( "\nERROR: Param $current is required" );
			$this->maybeHelp( true );
		} else {
			return null;
		}
	}

	/**
	 * Helper method for Status returning methods, such as via ConfigUtils
	 *
	 * @template T
	 * @param Status<T> $status
	 * @return T
	 */
	protected function unwrap( Status $status ) {
		if ( !$status->isGood() ) {
			$this->fatalError( (string)$status );
		}
		return $status->getValue();
	}

	/**
	 * Count the number of doc in this index.
	 * @param Index $index
	 * @return int
	 */
	protected function safeCount( Index $index, int $attempts = 3 ): int {
		return ConfigUtils::safeCountOrFail(
			$index,
			function ( StatusValue $error ): never {
				$this->fatalError( $error );
			},
			$attempts
		);
	}

	/**
	 * Refresh the index.
	 * @param Index $index
	 * @param int $attempts
	 * @return void
	 */
	protected function safeRefresh( Index $index, int $attempts = 3 ): void {
		ConfigUtils::safeRefreshOrFail(
			$index,
			function ( StatusValue $error ): never {
				$this->fatalError( $error );
			},
			$attempts
		);
	}

}
