<?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Psr\Log\LoggerInterface;

/**
 * This class contains the logic for executing abuse filters and their actions. The entry points are
 * run() and runForStash(). Note that run() can only be executed once on a given instance.
 * @internal Not stable yet
 */
class FilterRunner {
	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterValidGroups',
		'AbuseFilterCentralDB',
		'AbuseFilterIsCentral',
		'AbuseFilterConditionLimit',
	];

	/**
	 * @var FilterEvaluator
	 */
	private $ruleChecker;

	/**
	 * @var string The action we're filtering
	 */
	private $action;

	/**
	 * @throws InvalidArgumentException If $group is invalid or the 'action' variable is unset
	 */
	public function __construct(
		private readonly AbuseFilterHookRunner $hookRunner,
		private readonly FilterProfiler $filterProfiler,
		private readonly ChangeTagger $changeTagger,
		private readonly FilterLookup $filterLookup,
		private readonly RuleCheckerFactory $ruleCheckerFactory,
		private readonly ConsequencesExecutorFactory $consExecutorFactory,
		private readonly AbuseLoggerFactory $abuseLoggerFactory,
		private readonly VariablesManager $varManager,
		private readonly EmergencyCache $emergencyCache,
		private readonly array $watchers,
		private readonly EditStashCache $stashCache,
		private readonly LoggerInterface $logger,
		private readonly ServiceOptions $options,
		private readonly User $user,
		private readonly Title $title,
		private VariableHolder $vars,
		private readonly string $group
	) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		if ( !in_array( $group, $options->get( 'AbuseFilterValidGroups' ), true ) ) {
			throw new InvalidArgumentException( "Group $group is not a valid group" );
		}

		if ( !$vars->varIsSet( 'action' ) ) {
			throw new InvalidArgumentException( "The 'action' variable is not set." );
		}

		$this->action = $vars->getComputedVariable( 'action' )->toString();
	}

	/**
	 * Inits variables and parser right before running
	 */
	private function init() {
		// Add vars from extensions
		$this->hookRunner->onAbuseFilterAlterVariables(
			$this->vars,
			$this->title,
			$this->user
		);
		$this->ruleChecker = $this->ruleCheckerFactory->newRuleChecker( $this->vars );
	}

	/**
	 * The main entry point of this class. This method runs all filters and takes their consequences.
	 *
	 * @param bool $allowStash Whether we are allowed to check the cache to see if there's a cached
	 *  result of a previous execution for the same edit.
	 * @return Status Good if no action has been taken, a fatal otherwise.
	 */
	public function run( $allowStash = true ): Status {
		$this->init();

		$skipReasons = [];
		$shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
			$this->vars, $this->title, $this->user, $skipReasons
		);
		if ( !$shouldFilter ) {
			$this->logger->info(
				'Skipping action {action}. Reasons provided: {reasons}',
				[ 'action' => $this->action, 'reasons' => implode( ', ', $skipReasons ) ]
			);
			return Status::newGood();
		}

		$useStash = $allowStash && $this->action === 'edit';

		$runnerData = null;
		if ( $useStash ) {
			$cacheData = $this->stashCache->seek( $this->vars );
			if ( $cacheData !== false ) {
				// Use cached vars (T176291) and profiling data (T191430)
				$this->vars = VariableHolder::newFromArray( $cacheData['vars'] );
				$runnerData = RunnerData::fromArray( $cacheData['data'] );
			}
		}

		$runnerData ??= $this->checkAllFiltersInternal();

		DeferredUpdates::addCallableUpdate( function () use ( $runnerData ) {
			$this->profileExecution( $runnerData );
			$this->updateEmergencyCache( $runnerData->getMatchesMap() );
		} );

		// TODO: inject the action specifier to avoid this
		$accountName = $this->varManager->getVar(
			$this->vars,
			'account_name',
			VariablesManager::GET_BC
		)->toNative();
		$spec = new ActionSpecifier(
			$this->action,
			$this->title,
			$this->user,
			$this->user->getRequest()->getIP(),
			$accountName
		);

		// Tag the action if the condition limit was hit
		if ( $runnerData->getTotalConditions() > $this->options->get( 'AbuseFilterConditionLimit' ) ) {
			$this->changeTagger->addConditionsLimitTag( $spec );
		}

		$matchedFilters = $runnerData->getMatchedFilters();

		if ( count( $matchedFilters ) === 0 ) {
			return Status::newGood();
		}

		$executor = $this->consExecutorFactory->newExecutor( $spec, $this->vars );
		$status = $executor->executeFilterActions( $matchedFilters );
		$actionsTaken = $status->getValue();

		// Note, it's important that we create an AbuseLogger now, after all lazy-loaded variables
		// requested by active filters have been computed
		$abuseLogger = $this->abuseLoggerFactory->newLogger( $this->title, $this->user, $this->vars );
		[
			'local' => $loggedLocalFilters,
			'global' => $loggedGlobalFilters
		] = $abuseLogger->addLogEntries( $actionsTaken );

		foreach ( $this->watchers as $watcher ) {
			$watcher->run( $loggedLocalFilters, $loggedGlobalFilters, $this->group );
		}

		return $status;
	}

	/**
	 * Similar to run(), but runs in "stash" mode, which means filters are executed, no actions are
	 *  taken, and the result is saved in cache to be later reused. This can only be used for edits,
	 *  and not doing so will throw.
	 *
	 * @throws InvalidArgumentException
	 * @return Status Always a good status, since we're only saving data.
	 */
	public function runForStash(): Status {
		if ( $this->action !== 'edit' ) {
			throw new InvalidArgumentException(
				__METHOD__ . " can only be called for edits, called for action {$this->action}."
			);
		}

		$this->init();

		$skipReasons = [];
		$shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
			$this->vars, $this->title, $this->user, $skipReasons
		);
		if ( !$shouldFilter ) {
			// Don't log it yet
			return Status::newGood();
		}

		// XXX: We need a copy here because the cache key is computed
		// from the variables, but some variables can be loaded lazily
		// which would store the data with a key distinct from that
		// computed by seek() in ::run().
		// TODO: Find better way to generate the cache key.
		$origVars = clone $this->vars;

		$runnerData = $this->checkAllFiltersInternal();
		// Save the filter stash result and do nothing further
		$cacheData = [
			'vars' => $this->varManager->dumpAllVars( $this->vars ),
			'data' => $runnerData->toArray(),
		];

		$this->stashCache->store( $origVars, $cacheData );

		return Status::newGood();
	}

	/**
	 * Run all filters and return information about matches and profiling
	 */
	private function checkAllFiltersInternal(): RunnerData {
		// Ensure there's no extra time leftover
		LazyVariableComputer::$profilingExtraTime = 0;

		$data = new RunnerData();

		foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, false ) as $filter ) {
			[ $status, $timeTaken ] = $this->checkFilter( $filter );
			$data->record( $filter->getID(), false, $status, $timeTaken );
		}

		if ( $this->options->get( 'AbuseFilterCentralDB' ) && !$this->options->get( 'AbuseFilterIsCentral' ) ) {
			foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, true ) as $filter ) {
				[ $status, $timeTaken ] = $this->checkFilter( $filter, true );
				$data->record( $filter->getID(), true, $status, $timeTaken );
			}
		}

		return $data;
	}

	/**
	 * Returns an associative array of filters which were tripped
	 *
	 * @internal BC method
	 * @return bool[] Map of (filter ID => bool)
	 */
	public function checkAllFilters(): array {
		$this->init();
		return $this->checkAllFiltersInternal()->getMatchesMap();
	}

	/**
	 * Check the conditions of a single filter, and profile it
	 *
	 * @param ExistingFilter $filter
	 * @param bool $global
	 * @return array{0:\MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerStatus,1:float}
	 */
	private function checkFilter( ExistingFilter $filter, bool $global = false ): array {
		$filterName = GlobalNameUtils::buildGlobalName( $filter->getID(), $global );

		$startTime = microtime( true );
		$origExtraTime = LazyVariableComputer::$profilingExtraTime;

		$status = $this->ruleChecker->checkConditions( $filter->getRules(), $filterName );

		$actualExtra = LazyVariableComputer::$profilingExtraTime - $origExtraTime;
		$timeTaken = 1000 * ( microtime( true ) - $startTime - $actualExtra );

		return [ $status, $timeTaken ];
	}

	private function profileExecution( RunnerData $data ) {
		$allFilters = $data->getAllFilters();
		$matchedFilters = $data->getMatchedFilters();
		$this->filterProfiler->recordRuntimeProfilingResult(
			count( $allFilters ),
			$data->getTotalConditions(),
			$data->getTotalRuntime()
		);
		$this->filterProfiler->recordPerFilterProfiling( $this->title, $data->getProfilingData() );
		$this->filterProfiler->recordStats(
			$this->group,
			$data->getTotalConditions(),
			$data->getTotalRuntime(),
			(bool)$matchedFilters
		);
	}

	/**
	 * @param bool[] $matches
	 */
	private function updateEmergencyCache( array $matches ): void {
		$filters = $this->emergencyCache->getFiltersToCheckInGroup( $this->group );
		foreach ( $filters as $filter ) {
			if ( array_key_exists( "$filter", $matches ) ) {
				$this->emergencyCache->incrementForFilter( $filter, $matches["$filter"] );
			}
		}
	}
}
