<?php

namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;

use LogicException;
use MediaWiki\Content\Content;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\TextExtractor;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Page\WikiPage;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\Upload\UploadBase;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\Utils\MWFileProps;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Mime\MimeAnalyzer;

/**
 * This class contains the logic used to create variable holders before filtering
 * an action.
 */
class RunVariableGenerator extends VariableGenerator {

	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		UserFactory $userFactory,
		private readonly TextExtractor $textExtractor,
		private readonly MimeAnalyzer $mimeAnalyzer,
		private readonly WikiPageFactory $wikiPageFactory,
		private readonly User $user,
		private readonly Title $title,
		?VariableHolder $vars = null
	) {
		parent::__construct( $hookRunner, $userFactory, $vars );
	}

	/**
	 * Get variables for pre-filtering an edit during stash
	 *
	 * @param Content $content
	 * @param string $summary
	 * @param string $slot
	 * @param WikiPage $page
	 * @return VariableHolder|null
	 */
	public function getStashEditVars(
		Content $content,
		string $summary,
		$slot,
		WikiPage $page
	): ?VariableHolder {
		$filterText = $this->getEditTextForFiltering( $page, $content, $slot );
		if ( $filterText === null ) {
			return null;
		}
		[ $oldContent, $oldAfText, $text ] = $filterText;
		return $this->newVariableHolderForEdit(
			$page, $summary, $content, $text, $oldAfText, $oldContent
		);
	}

	/**
	 * Get the text of an edit to be used for filtering
	 * @todo Full support for multi-slots
	 *
	 * @param WikiPage $page
	 * @param Content $content
	 * @param string $slot
	 * @return array|null
	 */
	private function getEditTextForFiltering( WikiPage $page, Content $content, $slot ): ?array {
		$oldRevRecord = $page->getRevisionRecord();
		if ( !$oldRevRecord ) {
			return null;
		}

		$oldContent = $oldRevRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
		if ( !$oldContent ) {
			// @codeCoverageIgnoreStart
			throw new LogicException( 'Content cannot be null' );
			// @codeCoverageIgnoreEnd
		}
		$oldAfText = $this->textExtractor->revisionToString( $oldRevRecord, $this->user );

		// XXX: Recreate what the new revision will probably be so we can get the full AF
		// text for all slots
		$newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevRecord );
		$newRevision->setContent( $slot, $content );
		$text = $this->textExtractor->revisionToString( $newRevision, $this->user );

		// Don't trigger for null edits. Compare Content objects if available, but check the
		// stringified contents as well, e.g. for line endings normalization (T240115).
		// Don't treat content model change as null edit though.
		if (
			$content->equals( $oldContent ) ||
			( $oldContent->getModel() === $content->getModel() && $oldAfText === $text )
		) {
			return null;
		}

		return [ $oldContent, $oldAfText, $text ];
	}

	/**
	 * @param WikiPage $page
	 * @param string $summary
	 * @param Content $newcontent
	 * @param string $text
	 * @param string $oldtext
	 * @param Content|null $oldcontent
	 * @return VariableHolder
	 */
	private function newVariableHolderForEdit(
		WikiPage $page,
		string $summary,
		Content $newcontent,
		string $text,
		string $oldtext,
		?Content $oldcontent = null
	): VariableHolder {
		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'page' );
		$this->vars->setVar( 'action', 'edit' );
		$this->vars->setVar( 'summary', $summary );
		$this->setLastEditAge( $page->getRevisionRecord(), 'page' );

		if ( $oldcontent instanceof Content ) {
			$oldmodel = $oldcontent->getModel();
		} else {
			$oldmodel = '';
			$oldtext = '';
		}
		$this->vars->setVar( 'old_content_model', $oldmodel );
		$this->vars->setVar( 'new_content_model', $newcontent->getModel() );
		$this->vars->setVar( 'old_wikitext', $oldtext );
		$this->vars->setVar( 'new_wikitext', $text );

		try {
			$update = $page->getCurrentUpdate();
			$update->getParserOutputForMetaData();
		} catch ( PreconditionException | LogicException ) {
			// Temporary workaround until this becomes
			// a hook parameter
			$update = null;
		}

		if ( $update ) {
			$this->addEditVarsFromUpdate( $update, $this->user );
		} else {
			$this->addEditVars( $page, $this->user );
		}

		$this->addGenericVars();
		return $this->vars;
	}

	/**
	 * Get variables for filtering an edit.
	 *
	 * @param Content $content
	 * @param string $summary
	 * @param string $slot
	 * @param WikiPage $page
	 * @return VariableHolder|null
	 */
	public function getEditVars(
		Content $content,
		string $summary,
		$slot,
		WikiPage $page
	): ?VariableHolder {
		if ( $this->title->exists() ) {
			$filterText = $this->getEditTextForFiltering( $page, $content, $slot );
			if ( $filterText === null ) {
				return null;
			}
			[ $oldContent, $oldAfText, $text ] = $filterText;
		} else {
			// Optimization
			$oldContent = null;
			$oldAfText = '';
			$text = $this->textExtractor->contentToString( $content );
		}

		return $this->newVariableHolderForEdit(
			$page, $summary, $content, $text, $oldAfText, $oldContent
		);
	}

	/**
	 * @param RevisionRecord|Title|null $from
	 * @param string $prefix
	 */
	private function setLastEditAge( $from, string $prefix ): void {
		$varName = "{$prefix}_last_edit_age";
		if ( $from instanceof RevisionRecord ) {
			$this->vars->setVar(
				$varName,
				(int)wfTimestamp( TS_UNIX ) - (int)wfTimestamp( TS_UNIX, $from->getTimestamp() )
			);
		} elseif ( $from instanceof Title ) {
			$this->vars->setLazyLoadVar(
				$varName,
				'revision-age',
				[ 'title' => $from, 'asof' => wfTimestampNow() ]
			);
		} else {
			$this->vars->setVar( $varName, null );
		}
	}

	/**
	 * Get variables used to filter a move.
	 *
	 * @param Title $newTitle
	 * @param string $reason
	 * @return VariableHolder
	 */
	public function getMoveVars(
		Title $newTitle,
		string $reason
	): VariableHolder {
		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'moved_from' )
			->addTitleVars( $newTitle, 'moved_to' );

		$this->vars->setVar( 'summary', $reason );
		$this->vars->setVar( 'action', 'move' );
		$this->setLastEditAge( $this->title, 'moved_from' );
		$this->setLastEditAge( $newTitle, 'moved_to' );
		// TODO: add old_wikitext etc. (T320347)
		$this->addGenericVars();
		return $this->vars;
	}

	/**
	 * Get variables for filtering a deletion.
	 *
	 * @param string $reason
	 * @return VariableHolder
	 */
	public function getDeleteVars(
		string $reason
	): VariableHolder {
		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'page' );

		$this->vars->setVar( 'summary', $reason );
		$this->vars->setVar( 'action', 'delete' );
		$this->setLastEditAge( $this->title, 'page' );
		// TODO: add old_wikitext etc. (T173663)
		$this->addGenericVars();
		return $this->vars;
	}

	/**
	 * Get variables for filtering an upload.
	 *
	 * @param string $action
	 * @param UploadBase $upload
	 * @param string|null $summary
	 * @param string|null $text
	 * @param array|null $props
	 * @return VariableHolder|null
	 */
	public function getUploadVars(
		string $action,
		UploadBase $upload,
		?string $summary,
		?string $text,
		?array $props
	): ?VariableHolder {
		if ( !$props ) {
			$props = ( new MWFileProps( $this->mimeAnalyzer ) )->getPropsFromPath(
				$upload->getTempPath(),
				true
			);
		}

		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'page' );
		$this->vars->setVar( 'action', $action );

		// We use the hexadecimal version of the file sha1.
		// Use UploadBase::getTempFileSha1Base36 so that we don't have to calculate the sha1 sum again
		$sha1 = \Wikimedia\base_convert( $upload->getTempFileSha1Base36(), 36, 16, 40 );

		// This is the same as AbuseFilterRowVariableGenerator::addUploadVars, but from a different source
		$this->vars->setVar( 'file_sha1', $sha1 );
		$this->vars->setVar( 'file_size', $upload->getFileSize() );

		$this->vars->setVar( 'file_mime', $props['mime'] );
		$this->vars->setVar( 'file_mediatype', $this->mimeAnalyzer->getMediaType( null, $props['mime'] ) );
		$this->vars->setVar( 'file_width', $props['width'] );
		$this->vars->setVar( 'file_height', $props['height'] );
		$this->vars->setVar( 'file_bits_per_channel', $props['bits'] );

		// We only have the upload comment and page text when using the UploadVerifyUpload hook
		if ( $summary !== null && $text !== null ) {
			// This block is adapted from self::getEditTextForFiltering()
			$page = $this->wikiPageFactory->newFromTitle( $this->title );
			if ( $this->title->exists() ) {
				$revRec = $page->getRevisionRecord();
				if ( !$revRec ) {
					return null;
				}

				$this->setLastEditAge( $revRec, 'page' );
				$oldcontent = $revRec->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
				if ( $oldcontent === null ) {
					throw new \UnexpectedValueException( 'Failed to retrieve the old page content' );
				}
				$oldtext = $this->textExtractor->contentToString( $oldcontent );

				// Page text is ignored for uploads when the page already exists
				$text = $oldtext;
			} else {
				$oldtext = '';
				$this->setLastEditAge( null, 'page' );
			}

			// Load vars for filters to check
			$this->vars->setVar( 'summary', $summary );
			$this->vars->setVar( 'old_wikitext', $oldtext );
			$this->vars->setVar( 'new_wikitext', $text );
			// TODO: set old_content_model and new_content_model vars, use them
			$this->addEditVars( $page, $this->user );
		}
		$this->addGenericVars();
		return $this->vars;
	}

	/**
	 * Get variables for filtering an account creation
	 *
	 * @param User $createdUser This is the user being created, not the creator (which is $this->user)
	 * @param bool $autocreate
	 * @return VariableHolder
	 */
	public function getAccountCreationVars(
		User $createdUser,
		bool $autocreate
	): VariableHolder {
		// generateUserVars records $this->user->getName() which would be the IP for unregistered users
		if ( $this->user->isRegistered() ) {
			$this->addUserVars( $this->user );
		} else {
			// Set the user_type for IP users, so that filters can distinguish between account
			// creations from temporary accounts and those from IP addresses.
			$this->vars->setLazyLoadVar(
				'user_type',
				'user-type',
				[ 'user-identity' => $this->user ]
			);

			// Extra security layer to make user_unnamed_ip accessible only for temp account creation
			// See also LazyVariableComputer::compute(), which conditionally exposes the source IP
			if ( $autocreate && $createdUser->isTemp() ) {
				$this->vars->setLazyLoadVar(
					'user_unnamed_ip',
					'user-unnamed-ip',
					[ 'user' => $this->user, 'rc' => null ]
				);
			}
		}

		$this->vars->setVar( 'action', $autocreate ? 'autocreateaccount' : 'createaccount' );
		$this->vars->setVar( 'account_name', $createdUser->getName() );
		$this->vars->setLazyLoadVar(
			'account_type',
			'account-type',
			[ 'autocreate' => $autocreate, 'createdUser' => $createdUser ]
		);
		$this->addGenericVars();

		$this->hookRunner->onAbuseFilterGenerateAccountCreationVars(
			$this->vars, $this->user, $createdUser, $autocreate, null
		);
		return $this->vars;
	}
}
