<?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Api\ApiMessage;
use MediaWiki\Content\Content;
use MediaWiki\Context\IContextSource;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\BlockedDomains\IBlockedDomainFilter;
use MediaWiki\Extension\AbuseFilter\EditRevUpdater;
use MediaWiki\Extension\AbuseFilter\FilterRunnerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Hook\EditFilterMergedContentHook;
use MediaWiki\Hook\TitleMoveHook;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Page\Hook\PageDeleteHook;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Storage\Hook\ParserOutputStashForEditHook;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWiki\Upload\Hook\UploadStashFileHook;
use MediaWiki\Upload\Hook\UploadVerifyUploadHook;
use MediaWiki\Upload\UploadBase;
use MediaWiki\User\TempUser\TempUserConfig;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use StatusValue;
use Wikimedia\Stats\IBufferingStatsdDataFactory;

/**
 * Handler for actions that can be filtered
 */
class FilteredActionsHandler implements
	EditFilterMergedContentHook,
	TitleMoveHook,
	PageDeleteHook,
	UploadVerifyUploadHook,
	UploadStashFileHook,
	ParserOutputStashForEditHook
{

	public function __construct(
		private readonly IBufferingStatsdDataFactory $statsDataFactory,
		private readonly FilterRunnerFactory $filterRunnerFactory,
		private readonly VariableGeneratorFactory $variableGeneratorFactory,
		private readonly EditRevUpdater $editRevUpdater,
		private readonly IBlockedDomainFilter $blockedDomainFilter,
		private readonly PermissionManager $permissionManager,
		private readonly TitleFactory $titleFactory,
		private readonly UserFactory $userFactory,
		private readonly TempUserConfig $tempUserConfig,
	) {
	}

	/**
	 * @inheritDoc
	 * @param string $slot Slot role for the content, added by Wikibase (T288885)
	 */
	public function onEditFilterMergedContent(
		IContextSource $context,
		Content $content,
		Status $status,
		$summary,
		User $user,
		$minoredit,
		string $slot = SlotRecord::MAIN
	) {
		$startTime = microtime( true );
		if ( !$status->isOK() ) {
			// Investigate what happens if we skip filtering here (T211680)
			LoggerFactory::getInstance( 'AbuseFilter' )->info(
				'Status is already not OK',
				[ 'status' => (string)$status ]
			);
		}

		$this->filterEdit( $context, $user, $content, $summary, $slot, $status );

		$this->statsDataFactory->timing( 'timing.editAbuseFilter', microtime( true ) - $startTime );

		return $status->isOK();
	}

	/**
	 * Implementation for EditFilterMergedContent hook.
	 *
	 * @param IContextSource $context the context of the edit
	 * @param User $user
	 * @param Content $content the new Content generated by the edit
	 * @param string $summary Edit summary for page
	 * @param string $slot slot role for the content
	 * @param Status $status
	 */
	private function filterEdit(
		IContextSource $context,
		User $user,
		Content $content,
		string $summary,
		string $slot,
		Status $status
	): void {
		$this->editRevUpdater->clearLastEditPage();

		$title = $context->getTitle();
		$logger = LoggerFactory::getInstance( 'AbuseFilter' );
		if ( $title === null ) {
			// T144265: This *should* never happen.
			$logger->warning( __METHOD__ . ' received a null title.' );
			return;
		}
		if ( !$title->canExist() ) {
			// This also should be handled in EditPage or whoever is calling the hook.
			$logger->warning( __METHOD__ . ' received a Title that cannot exist.' );
			// Note that if the title cannot exist, there's no much point in filtering the edit anyway
			return;
		}

		$page = $context->getWikiPage();

		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title );
		$vars = $builder->getEditVars( $content, $summary, $slot, $page );
		if ( $vars === null ) {
			// We don't have to filter the edit
			return;
		}
		$runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' );
		$filterResult = $runner->run();
		if ( !$filterResult->isOK() ) {
			// Produce a useful error message for API edits
			$filterResultApi = self::getApiStatus( $filterResult );
			$status->merge( $filterResultApi );
			return;
		}

		$this->editRevUpdater->setLastEditPage( $page );

		if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) {
			return;
		}
		$blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title );
		if ( !$blockedDomainFilterResult->isOK() ) {
			$status->merge( $blockedDomainFilterResult );
		}
	}

	/**
	 * @param Status $status Error message details
	 * @return Status Status containing the same error messages with extra data for the API
	 */
	private static function getApiStatus( Status $status ): Status {
		$allActionsTaken = $status->getValue();
		$statusForApi = Status::newGood();

		foreach ( $status->getMessages() as $msg ) {
			[ $filterDescription, $filter ] = $msg->getParams();
			$actionsTaken = $allActionsTaken[ $filter ];

			$code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed';
			$data = [
				'abusefilter' => [
					'id' => $filter,
					'description' => $filterDescription,
					'actions' => $actionsTaken,
				],
			];

			$message = ApiMessage::create( $msg, $code, $data );
			$statusForApi->fatal( $message );
		}

		return $statusForApi;
	}

	/**
	 * @inheritDoc
	 */
	public function onTitleMove( Title $old, Title $nt, User $user, $reason, Status &$status ) {
		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $old );
		$vars = $builder->getMoveVars( $nt, $reason );
		$runner = $this->filterRunnerFactory->newRunner( $user, $old, $vars, 'default' );
		$result = $runner->run();
		$status->merge( $result );
	}

	/**
	 * @inheritDoc
	 */
	public function onPageDelete(
		ProperPageIdentity $page, Authority $deleter, string $reason, StatusValue $status, bool $suppress
	) {
		if ( $suppress ) {
			// Don't filter suppressions, T71617
			return true;
		}
		$user = $this->userFactory->newFromAuthority( $deleter );
		$title = $this->titleFactory->newFromPageIdentity( $page );
		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title );
		$vars = $builder->getDeleteVars( $reason );
		$runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' );
		$filterResult = $runner->run();

		$status->merge( $filterResult );

		return $filterResult->isOK();
	}

	/**
	 * @inheritDoc
	 */
	public function onUploadVerifyUpload(
		UploadBase $upload,
		User $user,
		?array $props,
		$comment,
		$pageText,
		&$error
	) {
		return $this->filterUpload( 'upload', $upload, $user, $props, $comment, $pageText, $error );
	}

	/**
	 * Filter an upload to stash. If a filter doesn't need to check the page contents or
	 * upload comment, it can use `action='stashupload'` to provide better experience to e.g.
	 * UploadWizard (rejecting files immediately, rather than after the user adds the details).
	 *
	 * @inheritDoc
	 */
	public function onUploadStashFile( UploadBase $upload, User $user, ?array $props, &$error ) {
		return $this->filterUpload( 'stashupload', $upload, $user, $props, null, null, $error );
	}

	/**
	 * Implementation for UploadStashFile and UploadVerifyUpload hooks.
	 *
	 * @param string $action 'upload' or 'stashupload'
	 * @param UploadBase $upload
	 * @param User $user User performing the action
	 * @param array|null $props File properties, as returned by MWFileProps::getPropsFromPath().
	 * @param string|null $summary Upload log comment (also used as edit summary)
	 * @param string|null $text File description page text (only used for new uploads)
	 * @param array|ApiMessage &$error
	 * @return bool
	 */
	private function filterUpload(
		string $action,
		UploadBase $upload,
		User $user,
		?array $props,
		?string $summary,
		?string $text,
		&$error
	): bool {
		$title = $upload->getTitle();
		if ( $title === null ) {
			// T144265: This could happen for 'stashupload' if the specified title is invalid.
			// Let UploadBase warn the user about that, and we'll filter later.
			$logger = LoggerFactory::getInstance( 'AbuseFilter' );
			$logger->warning( __METHOD__ . " received a null title. Action: $action." );
			return true;
		}

		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title );
		$vars = $builder->getUploadVars( $action, $upload, $summary, $text, $props );
		if ( $vars === null ) {
			return true;
		}
		$runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' );
		$filterResult = $runner->run();

		if ( !$filterResult->isOK() ) {
			// Produce a useful error message for API edits
			$filterResultApi = self::getApiStatus( $filterResult );
			// @todo Return all errors instead of only the first one
			$error = $filterResultApi->getMessages()[0];
		} else {
			if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) {
				return true;
			}
			$blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title );
			if ( !$blockedDomainFilterResult->isOK() ) {
				$error = $blockedDomainFilterResult->getMessages()[0];
				return $blockedDomainFilterResult->isOK();
			}
		}

		return $filterResult->isOK();
	}

	/**
	 * @inheritDoc
	 */
	public function onParserOutputStashForEdit( $page, $content, $output, $summary, $user ) {
		// If temporary accounts are enabled, we can't reuse stashed filter matches for unregistered
		// performers, as the actual save attempt would result in a new temporary account being created,
		// thereby changing some of the input variables and potentially leading to a different outcome.
		// (T402298)
		if ( !$user->isRegistered() && $this->tempUserConfig->isEnabled() ) {
			return;
		}

		// XXX: This makes the assumption that this method is only ever called for the main slot.
		// Which right now holds true, but any more fancy MCR stuff will likely break here...
		$slot = SlotRecord::MAIN;

		// Cache any resulting filter matches.
		// Do this outside the synchronous stash lock to avoid any chance of slowdown.
		DeferredUpdates::addCallableUpdate(
			function () use (
				$user,
				$page,
				$summary,
				$content,
				$slot
			) {
				$startTime = microtime( true );
				$generator = $this->variableGeneratorFactory->newRunGenerator( $user, $page->getTitle() );
				$vars = $generator->getStashEditVars( $content, $summary, $slot, $page );
				if ( !$vars ) {
					return;
				}
				$runner = $this->filterRunnerFactory->newRunner( $user, $page->getTitle(), $vars, 'default' );
				$runner->runForStash();
				$totalTime = microtime( true ) - $startTime;
				$this->statsDataFactory->timing( 'timing.stashAbuseFilter', $totalTime );
			},
			DeferredUpdates::PRESEND
		);
	}
}
