<?php

namespace MediaWiki\Tests\Storage;

use Article;
use EditPage;
use FauxRequest;
use McrUndoAction;
use MediaWiki\Revision\RevisionStoreRecord;
use MediaWiki\Storage\EditResult;
use MediaWiki\Storage\SlotRecord;
use MediaWikiIntegrationTestCase;
use OutputPage;
use RequestContext;
use Title;
use User;
use WikiPage;
use WikitextContent;

/**
 * Integration tests for undos.
 * TODO: This should also test edits with multiple slots.
 *
 * @covers McrUndoAction
 * @covers WikiPage
 * @covers EditPage
 *
 * @group Database
 * @group medium
 */
class UndoIntegrationTest extends MediaWikiIntegrationTestCase {

	private const PAGE_NAME = 'McrUndoTestPage';

	protected function setUp(): void {
		parent::setUp();

		// Clean up these tables after each test
		$this->tablesUsed = [
			'page',
			'revision',
			'comment',
			'text',
			'content'
		];
	}

	/**
	 * Creates a new McrUndoAction object for testing.
	 *
	 * @param RequestContext $context
	 * @param Article $article
	 * @param array $params POST/GET parameters passed to the action on submit
	 *
	 * @return McrUndoAction
	 */
	private function makeNewMcrUndoAction(
		RequestContext $context,
		Article $article,
		array $params
	): McrUndoAction {
		$request = new FauxRequest( $params );
		$request->setVal( 'wpSave', '' );
		$context->setRequest( $request );

		$outputPage = $this->createMock( OutputPage::class );
		$context->setOutput( $outputPage );
		$context->setUser( $this->getTestSysop()->getUser() );

		$services = $this->getServiceContainer();
		$revisionRenderer = $services->getRevisionRenderer();
		$revisionLookup = $services->getRevisionLookup();
		$readOnlyMode = $services->getReadOnlyMode();
		$config = $services->getMainConfig();
		return new class(
			$article,
			$context,
			$readOnlyMode,
			$revisionLookup,
			$revisionRenderer,
			$config
		) extends McrUndoAction {
			public function show() {
				// Instead of trying to actually display anything, just initialize the class.
				$this->checkCanExecute( $this->getUser() );
			}
		};
	}

	/**
	 * Convenience function for setting up a test page and filling it with edits.
	 * @param string[] $revisions
	 *
	 * @return array
	 */
	private function setUpPageForTesting( array $revisions ): array {
		$this->getExistingTestPage( self::PAGE_NAME );
		$revisionIds = [];
		foreach ( $revisions as $revisionContent ) {
			$revisionIds[] = $this->editPage( self::PAGE_NAME, $revisionContent )
				->value['revision-record']->getId();
		}
		$revisionIds['false'] = false;
		return $revisionIds;
	}

	/**
	 * @param string $newContent
	 * @param array $revisionIds
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	private function setPageSaveCompleteHook(
		string $newContent,
		array $revisionIds,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		// set up a temporary hook with asserts
		$this->setTemporaryHook(
			'PageSaveComplete',
			function (
				WikiPage $wikiPage,
				User $user,
				string $summary,
				int $flags,
				RevisionStoreRecord $revisionRecord,
				EditResult $editResult
			) use (
				$newContent,
				$revisionIds,
				$isExactRevert,
				$oldestRevertedRevIndex,
				$newestRevertedRevIndex,
				$originalRevIndex
			) {
				$this->assertTrue(
					$editResult->isRevert(),
					'EditResult::isRevert()'
				);
				$this->assertSame(
					EditResult::REVERT_UNDO,
					$editResult->getRevertMethod(),
					'EditResult::getRevertMethod()'
				);
				$this->assertArrayEquals( [ 'mw-undo' ],
					$editResult->getRevertTags(),
					false,
					false,
					'EditResult::getRevertTags()'
				);
				$this->assertSame(
					$isExactRevert,
					$editResult->isExactRevert(),
					'EditResult::isExactRevert()'
				);
				$this->assertSame(
					$revisionIds[$oldestRevertedRevIndex],
					$editResult->getOldestRevertedRevisionId(),
					'EditResult::getOldestRevertedRevisionId()'
				);
				$this->assertSame(
					$revisionIds[$newestRevertedRevIndex],
					$editResult->getNewestRevertedRevisionId(),
					'EditResult::getNewestRevertedRevisionId()'
				);
				$this->assertSame(
					$revisionIds[$originalRevIndex],
					$editResult->getOriginalRevisionId(),
					'EditResult::getOriginalRevisionId()'
				);
				$mainContent = $revisionRecord->getContent( SlotRecord::MAIN );
				/** @var WikitextContent $mainContent */
				$this->assertSame(
					$newContent,
					$mainContent->getText(),
					'RevisionRecord::getContent()'
				);
			}
		);
	}

	/**
	 * Provides test cases for well-formed undos.
	 *
	 * @return array[]
	 */
	public function provideUndos() {
		return [
			'undoing a single revision' => [
				[ '1', '2' ],
				'1',
				0,
				1,
				true,
				1,
				1,
				0
			],
			'undoing multiple revisions' => [
				[ '1', '2', '3', '4' ],
				'1',
				0,
				3,
				true,
				1,
				3,
				0
			],
			'undoing an intermittent revision' => [
				[
					"line 1\n\nline 2\n\nline3",
					"line 1\n\nvandalism\n\nline3",
					"line 1\n\nvandalism\n\nline3 more content"
				],
				"line 1\n\nline 2\n\nline3 more content",
				0,
				1,
				false,
				1,
				1,
				'false'
			],
			'undoing multiple intermittent revisions' => [
				[
					"line 1\n\nline 2\n\nline3",
					"line 1\n\nvandalism\n\nline3",
					"line 1\n\nmore vandalism\n\nline3",
					"line 1\n\nmore vandalism\n\nline3 content"
				],
				"line 1\n\nline 2\n\nline3 content",
				0,
				2,
				false,
				1,
				2,
				'false'
			]
		];
	}

	/**
	 * Provides test cases of undos with incomplete parameters.
	 * This should be handled well by EditPage and WikiPage.
	 * McrUndoAction just refuses to do anything.
	 *
	 * @return array[]
	 */
	public function provideIncompleteUndos() {
		return [
			'undoing a revision without undoafter param' => [
				[ '1', '2' ],
				'1',
				'false',
				1,
				true,
				1,
				1,
				0
			],
			'undoing an intermittent revision without undoafter param' => [
				[
					"line 1\n\nline 2\n\nline3",
					"line 1\n\nvandalism\n\nline3",
					"line 1\n\nvandalism\n\nline3 more content"
				],
				"line 1\n\nline 2\n\nline3 more content",
				'false',
				1,
				false,
				1,
				1,
				'false'
			]
		];
	}

	/**
	 * Test how McrUndoAction cooperates with the PageUpdater by looking at values provided
	 * by the PageSaveComplete hook.
	 *
	 * @dataProvider provideUndos
	 *
	 * @param string[] $revisions
	 * @param string $newContent
	 * @param int|string $undoafterIndex
	 * @param int|string $undoIndex
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	public function testMcrUndoAction(
		array $revisions,
		string $newContent,
		$undoafterIndex,
		$undoIndex,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		$this->markTestSkippedIfNoDiff3();

		$context = RequestContext::getMain();
		$revisionIds = $this->setUpPageForTesting( $revisions );
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		$mcrUndoAction = $this->makeNewMcrUndoAction(
			$context,
			$article,
			[
				'undoafter' => $revisionIds[$undoafterIndex],
				'undo' => $revisionIds[$undoIndex]
			]
		);
		// This should initialize the action properly.
		$mcrUndoAction->show();

		// Set the hook and submit the request
		$this->setPageSaveCompleteHook(
			$newContent,
			$revisionIds,
			$isExactRevert,
			$oldestRevertedRevIndex,
			$newestRevertedRevIndex,
			$originalRevIndex
		);
		$mcrUndoAction->onSubmit( [] );
	}

	/**
	 * Test how WikiPage cooperates with the PageUpdater by looking at values
	 * provided by the PageSaveComplete hook.
	 *
	 * @dataProvider provideUndos
	 * @dataProvider provideIncompleteUndos
	 *
	 * @param string[] $revisions
	 * @param string $newContent
	 * @param int|string $undoafterIndex
	 * @param int|string $undoIndex
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	public function testWikiPage(
		array $revisions,
		string $newContent,
		$undoafterIndex,
		$undoIndex,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		$revisionIds = $this->setUpPageForTesting( $revisions );

		// Set the hook with asserts
		$this->setPageSaveCompleteHook(
			$newContent,
			$revisionIds,
			$isExactRevert,
			$oldestRevertedRevIndex,
			$newestRevertedRevIndex,
			$originalRevIndex
		);

		$wikiPage = new WikiPage( Title::newFromText( self::PAGE_NAME ) );
		$wikiPage->doUserEditContent(
			new WikitextContent( $newContent ),
			$this->getTestSysop()->getUser(),
			'',
			0,
			$revisionIds[$undoafterIndex],
			[],
			$revisionIds[$undoIndex]
		);
	}

	/**
	 * Test how EditPage and WikiPage work together and with the PageUpdater by looking
	 * at values provided by the PageSaveComplete hook.
	 *
	 * @dataProvider provideUndos
	 * @dataProvider provideIncompleteUndos
	 *
	 * @param string[] $revisions
	 * @param string $newContent
	 * @param int|string $undoafterIndex
	 * @param int|string $undoIndex
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	public function testEditPage(
		array $revisions,
		string $newContent,
		$undoafterIndex,
		$undoIndex,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		$this->markTestSkippedIfNoDiff3();

		$revisionIds = $this->setUpPageForTesting( $revisions );
		$context = RequestContext::getMain();
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		// Set the hook with asserts
		$this->setPageSaveCompleteHook(
			$newContent,
			$revisionIds,
			$isExactRevert,
			$oldestRevertedRevIndex,
			$newestRevertedRevIndex,
			$originalRevIndex
		);

		$request = new FauxRequest(
			[
				// We kind of let EditPage cheat here by providing the content of the page
				// after the undo, but automatic conflict resolution is not the point of
				// this test anyway.
				'wpTextbox1' => $newContent,
				'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
				// These two parameters are the important ones here
				'wpUndidRevision' => $revisionIds[$undoIndex],
				'wpUndoAfter' => $revisionIds[$undoafterIndex],
				'wpStarttime' => wfTimestampNow(),
				'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
				'model' => CONTENT_MODEL_WIKITEXT,
				'format' => CONTENT_FORMAT_WIKITEXT,
			],
			true
		);

		$editPage = new EditPage( $article );
		$editPage->importFormData( $request );
		$editPage->internalAttemptSave( $result, false );
	}

	/**
	 * Test the case where the user undoes some edits, but applies additional changes before
	 * saving. EditPage should detect that and not mark such an edit as a revert.
	 */
	public function testDirtyUndo() {
		$revisionIds = $this->setUpPageForTesting( [
			"line 1\n\nline 2\n\nline3",
			"line 1\n\nvandalism\n\nline3",
			"line 1\n\nvandalism\n\nline3 more content"
		] );
		$context = RequestContext::getMain();
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		// set up a temporary hook with asserts
		$this->setTemporaryHook(
			'PageSaveComplete',
			function (
				WikiPage $wikiPage,
				User $user,
				string $summary,
				int $flags,
				RevisionStoreRecord $revisionRecord,
				EditResult $editResult
			) {
				// Just ensuring that the edit was not marked as a revert should be enough
				$this->assertFalse(
					$editResult->isRevert(),
					'EditResult::isRevert()'
				);
			}
		);

		$request = new FauxRequest(
			[
				// We emulate the user applying additional changes on top of the undo.
				'wpTextbox1' => "line 1\n\nline 2\n\nline3 more content\n\neven more",
				'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
				'wpUndidRevision' => $revisionIds[1],
				'wpUndoAfter' => $revisionIds[0],
				'wpStarttime' => wfTimestampNow(),
				'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
				'model' => CONTENT_MODEL_WIKITEXT,
				'format' => CONTENT_FORMAT_WIKITEXT,
			],
			true
		);

		$editPage = new EditPage( $article );
		$editPage->importFormData( $request );
		$editPage->internalAttemptSave( $result, false );
	}

	/**
	 * Test whether EditPage correctly handles situations where an undo is impossible.
	 * Ensures T262463 is fixed.
	 */
	public function testImpossibleUndo() {
		$revisionIds = $this->setUpPageForTesting( [
			"line 1\n\nline 2\n\nline3",
			"line 1\n\nvandalism\n\nline3",
			"line 1\n\nvandalism good content\n\nline3 more content"
		] );

		$context = RequestContext::getMain();
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		// set up a temporary hook with asserts
		$this->setTemporaryHook(
			'PageSaveComplete',
			function (
				WikiPage $wikiPage,
				User $user,
				string $summary,
				int $flags,
				RevisionStoreRecord $revisionRecord,
				EditResult $editResult
			) {
				$this->assertFalse(
					$editResult->isRevert(),
					'EditResult::isRevert()'
				);
				$this->assertTrue(
					$editResult->isNullEdit(),
					'EditResult::isNullEdit()'
				);
			}
		);

		$request = new FauxRequest(
			[
				// We leave the "top" content in the textbox, as the undo should have failed
				'wpTextbox1' => "line 1\n\nvandalism good content\n\nline3 more content",
				'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
				'wpUndidRevision' => $revisionIds[1],
				'wpUndoAfter' => $revisionIds[0],
				'wpStarttime' => wfTimestampNow(),
				'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
				'model' => CONTENT_MODEL_WIKITEXT,
				'format' => CONTENT_FORMAT_WIKITEXT,
			],
			true
		);

		$editPage = new EditPage( $article );
		$editPage->importFormData( $request );
		$editPage->internalAttemptSave( $result, false );
	}
}
