<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */

namespace MediaWiki\Storage;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use Wikimedia\Assert\Assert;

/**
 * Builder class for the EditResult object.
 *
 * @internal Only for use by PageUpdater
 * @since 1.35
 * @author Ostrzyciel
 */
class EditResultBuilder {

	public const CONSTRUCTOR_OPTIONS = [
		MainConfigNames::ManualRevertSearchRadius,
	];

	/**
	 * A mapping from EditResult's revert methods to relevant change tags.
	 * For use by getRevertTags()
	 */
	private const REVERT_METHOD_TO_CHANGE_TAG = [
		EditResult::REVERT_UNDO => 'mw-undo',
		EditResult::REVERT_ROLLBACK => 'mw-rollback',
		EditResult::REVERT_MANUAL => 'mw-manual-revert'
	];

	/** @var RevisionRecord|null */
	private $revisionRecord = null;

	/** @var bool */
	private $isNew = false;

	/** @var int|false */
	private $originalRevisionId = false;

	/** @var RevisionRecord|null */
	private $originalRevision = null;

	/** @var int|null */
	private $revertMethod = null;

	/** @var int|null */
	private $newestRevertedRevId = null;

	/** @var int|null */
	private $oldestRevertedRevId = null;

	/** @var int|null */
	private $revertAfterRevId = null;

	/** @var RevisionStore */
	private $revisionStore;

	/** @var string[] */
	private $softwareTags;

	/** @var ServiceOptions */
	private $options;

	/**
	 * @param RevisionStore $revisionStore
	 * @param string[] $softwareTags Array of currently enabled software change tags. Can be
	 *        obtained from ChangeTagsStore->getSoftwareTags()
	 * @param ServiceOptions $options Options for this instance.
	 */
	public function __construct(
		RevisionStore $revisionStore,
		array $softwareTags,
		ServiceOptions $options
	) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );

		$this->revisionStore = $revisionStore;
		$this->softwareTags = $softwareTags;
		$this->options = $options;
	}

	/**
	 * @return EditResult
	 */
	public function buildEditResult(): EditResult {
		if ( $this->revisionRecord === null ) {
			throw new PageUpdateException(
				'Revision was not set prior to building an EditResult'
			);
		}

		// If we don't know the original revision ID, but know which one was undone, try to find out
		$this->guessOriginalRevisionId();

		// do a last-minute check if this was a manual revert
		$this->detectManualRevert();

		return new EditResult(
			$this->isNew,
			$this->originalRevisionId,
			$this->revertMethod,
			$this->oldestRevertedRevId,
			$this->newestRevertedRevId,
			$this->isExactRevert(),
			$this->isNullEdit(),
			$this->getRevertTags()
		);
	}

	/**
	 * Set the revision associated with this edit.
	 * Should only be called by PageUpdater when saving an edit.
	 *
	 * @param RevisionRecord $revisionRecord
	 */
	public function setRevisionRecord( RevisionRecord $revisionRecord ) {
		$this->revisionRecord = $revisionRecord;
	}

	/**
	 * Set whether the edit created a new page.
	 * Should only be called by PageUpdater when saving an edit.
	 *
	 * @param bool $isNew
	 */
	public function setIsNew( bool $isNew ) {
		$this->isNew = $isNew;
	}

	/**
	 * Marks this edit as a revert and applies relevant information.
	 *
	 * @param int $revertMethod The method used to make the revert:
	 *   REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL
	 * @param int $newestRevertedRevId the revision ID of the latest reverted revision.
	 * @param int|null $revertAfterRevId the revision ID after which revisions
	 *   are being reverted. Defaults to the revision before the $newestRevertedRevId.
	 */
	public function markAsRevert(
		int $revertMethod,
		int $newestRevertedRevId,
		?int $revertAfterRevId = null
	) {
		Assert::parameter(
			in_array(
				$revertMethod,
				[ EditResult::REVERT_UNDO, EditResult::REVERT_ROLLBACK, EditResult::REVERT_MANUAL ]
			),
			'$revertMethod',
			'must be one of REVERT_UNDO, REVERT_ROLLBACK, REVERT_MANUAL'
		);
		$this->revertAfterRevId = $revertAfterRevId;

		if ( $newestRevertedRevId ) {
			$this->revertMethod = $revertMethod;
			$this->newestRevertedRevId = $newestRevertedRevId;
			$revertAfterRevision = $revertAfterRevId ?
				$this->revisionStore->getRevisionById( $revertAfterRevId ) :
				null;
			$oldestRevertedRev = $revertAfterRevision ?
				$this->revisionStore->getNextRevision( $revertAfterRevision ) : null;
			if ( $oldestRevertedRev ) {
				$this->oldestRevertedRevId = $oldestRevertedRev->getId();
			} else {
				// Can't find the oldest reverted revision.
				// Oh well, just mark the one we know was undone.
				$this->oldestRevertedRevId = $this->newestRevertedRevId;
			}
		}
	}

	/**
	 * @param RevisionRecord|int|false|null $originalRevision
	 *   RevisionRecord or revision ID for the original revision.
	 *   False or null to unset.
	 */
	public function setOriginalRevision( $originalRevision ) {
		if ( $originalRevision instanceof RevisionRecord ) {
			$this->originalRevision = $originalRevision;
			$this->originalRevisionId = $originalRevision->getId();
		} else {
			$this->originalRevisionId = $originalRevision ?? false;
			$this->originalRevision = null; // Will be lazy-loaded.
		}
	}

	/**
	 * If this edit was not already marked as a revert using EditResultBuilder::markAsRevert(),
	 * tries to establish whether this was a manual revert, i.e. someone restored the page to
	 * an exact previous state manually.
	 *
	 * If successful, mutates the builder accordingly.
	 */
	private function detectManualRevert() {
		$searchRadius = $this->options->get( MainConfigNames::ManualRevertSearchRadius );
		if ( !$searchRadius ||
			// we already marked this as a revert
			$this->revertMethod !== null ||
			// it's a null edit, nothing was reverted
			$this->isNullEdit() ||
			// we wouldn't be able to figure out what was the newest reverted edit
			// this also discards new pages
			!$this->revisionRecord->getParentId()
		) {
			return;
		}

		$revertedToRev = $this->revisionStore->findIdenticalRevision( $this->revisionRecord, $searchRadius );
		if ( !$revertedToRev ) {
			return;
		}
		$oldestReverted = $this->revisionStore->getNextRevision( $revertedToRev );
		if ( !$oldestReverted ) {
			return;
		}

		$this->setOriginalRevision( $revertedToRev );
		$this->revertMethod = EditResult::REVERT_MANUAL;
		$this->oldestRevertedRevId = $oldestReverted->getId();
		$this->newestRevertedRevId = $this->revisionRecord->getParentId();
		$this->revertAfterRevId = $revertedToRev->getId();
	}

	/**
	 * In case we have not got the original revision ID, try to guess.
	 */
	private function guessOriginalRevisionId() {
		if ( !$this->originalRevisionId ) {
			if ( $this->revertAfterRevId ) {
				$this->setOriginalRevision( $this->revertAfterRevId );
			} elseif ( $this->newestRevertedRevId ) {
				// Try finding the original revision ID by assuming it's the one before the edit
				// that is being reverted.
				$undidRevision = $this->revisionStore->getRevisionById( $this->newestRevertedRevId );
				if ( $undidRevision ) {
					$originalRevision = $this->revisionStore->getPreviousRevision( $undidRevision );
					if ( $originalRevision ) {
						$this->setOriginalRevision( $originalRevision );
					}
				}
			}
		}

		// Make sure original revision's content is the same as
		// the new content and save the original revision ID.
		if ( $this->getOriginalRevision() &&
			!$this->getOriginalRevision()->hasSameContent( $this->revisionRecord )
		) {
			$this->setOriginalRevision( false );
		}
	}

	/**
	 * Returns the revision that is being repeated or restored.
	 * Returns null if not set for this edit.
	 *
	 * @return RevisionRecord|null
	 */
	private function getOriginalRevision(): ?RevisionRecord {
		if ( $this->originalRevision ) {
			return $this->originalRevision;
		}
		if ( !$this->originalRevisionId ) {
			return null;
		}

		$this->originalRevision = $this->revisionStore->getRevisionById( $this->originalRevisionId );
		return $this->originalRevision;
	}

	/**
	 * Whether the edit was an exact revert, i.e. the contents of the revert
	 * revision and restored revision match
	 *
	 * @return bool
	 */
	private function isExactRevert(): bool {
		if ( $this->isNew || $this->oldestRevertedRevId === null ) {
			return false;
		}

		$originalRevision = $this->getOriginalRevision();
		if ( !$originalRevision ) {
			// we can't find the original revision for some reason, better return false
			return false;
		}

		return $this->revisionRecord->hasSameContent( $originalRevision );
	}

	/**
	 * An edit is a null edit if the original revision is equal to the parent revision.
	 *
	 * @return bool
	 */
	private function isNullEdit(): bool {
		if ( $this->isNew ) {
			return false;
		}

		return $this->getOriginalRevision() &&
			$this->originalRevisionId === $this->revisionRecord->getParentId();
	}

	/**
	 * Returns an array of revert-related tags that will be applied automatically to this edit.
	 *
	 * @return string[]
	 */
	private function getRevertTags(): array {
		if ( isset( self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod] ) ) {
			$revertTag = self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod];
			if ( in_array( $revertTag, $this->softwareTags ) ) {
				return [ $revertTag ];
			}
		}
		return [];
	}
}
