<?php

namespace MediaWiki\Extension\CheckUser\Tests\Integration\Services;

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\CheckUser\Services\CheckUserExpiredIdsLookupService;
use MediaWiki\Extension\CheckUser\Tests\Integration\AbuseFilter\FilterFactoryProxyTrait;
use MediaWiki\Extension\CheckUser\Tests\Integration\CheckUserCommonTestTrait;
use MediaWiki\Extension\CheckUser\Tests\Integration\CheckUserTempUserTestTrait;
use MediaWiki\Logging\ManualLogEntry;
use MediaWiki\Page\WikiPage;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStoreFactory;
use MediaWiki\Revision\SlotRecord;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group Database
 * @group CheckUser
 *
 * @covers \MediaWiki\Extension\CheckUser\Services\CheckUserExpiredIdsLookupService
 */
class CheckUserExpiredIdsLookupServiceTest extends MediaWikiIntegrationTestCase {

	use CheckUserCommonTestTrait;
	use CheckUserTempUserTestTrait;
	use FilterFactoryProxyTrait;

	private const CURRENT_TIMESTAMP = 1754031600;
	private const MAX_DATA_AGE_SECONDS = 60;

	private static array $revisions;
	private static array $revisionIDs;
	private static array $logs;
	private static array $logIDs;
	private static array $afLogIDs;

	/** @var (RevisionStoreFactory&MockObject) */
	private RevisionStoreFactory $revisionStoreFactory;

	private WikiPage $wikiPage;

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

		ConvertibleTimestamp::setFakeTime( self::CURRENT_TIMESTAMP );
	}

	/**
	 * Note "unique identifiers" in the method signature refers to the position
	 * in the $revisionIDs array of the page revisions inserted by
	 * addDBDataOnce(), and are used as a kind of stable identifier to make it
	 * easier to reason about the test.
	 *
	 * The data provider uses those positions instead of directly using the IDs
	 * because we can't guarantee the exact IDs that will be generated by
	 * insertions (i.e. IDs are automatically generated), and PHPUnit will run
	 * the data provider before addDBDataOnce() anyway.
	 *
	 * @dataProvider listsExpiredRevisionIdsInSetDataProvider
	 */
	public function testListsExpiredRevisionIdsInSet(
		array $expectedRevisionIDsByUniqueIdentifier,
		int $CUDMaxAge,
		array $inputRevisionsByUniqueIdentifier
	): void {
		$sut = $this->getSubjectUnderTest( [
			'ServiceOptions' => new ServiceOptions(
				CheckUserExpiredIdsLookupService::CONSTRUCTOR_OPTIONS,
				[ 'CUDMaxAge' => $CUDMaxAge ]
			),
		] );

		// [ A => 2, B => 3, ...]
		$contentToRevIDs = array_intersect_key(
			self::$revisionIDs,
			array_flip( $inputRevisionsByUniqueIdentifier )
		);
		$expectedIDs = array_intersect_key(
			$contentToRevIDs,
			array_flip( $expectedRevisionIDsByUniqueIdentifier )
		);

		// array_map is needed since IDs are integers but the DB returns strings
		$this->assertSame(
			array_map( static fn ( $v ) => (string)$v, array_values( $expectedIDs ) ),
			$sut->listExpiredRevisionIdsInSet( array_values( $contentToRevIDs ) )
		);
	}

	public static function listsExpiredRevisionIdsInSetDataProvider(): array {
		return [
			'Returns an empty array if the MaxAge is zero' => [
				'expectedRevisionIDsByUniqueIdentifier' => [],
				'CUDMaxAge' => 0,
				'inputRevisionsByUniqueIdentifier' => [ 'A', 'B', 'C' ],
			],
			'Returns an empty array if the input array is empty' => [
				'expectedRevisionIDsByUniqueIdentifier' => [],
				'CUDMaxAge' => 1,
				'inputRevisionsByUniqueIdentifier' => [],
			],
			'Returns the expected results if the MaxAge is non-zero' => [
				'expectedRevisionIDsByUniqueIdentifier' => [ 'E' ],
				'CUDMaxAge' => self::MAX_DATA_AGE_SECONDS,
				'inputRevisionsByUniqueIdentifier' => [ 'A', 'B', 'C', 'D', 'E' ],
			],
		];
	}

	/**
	 * Note "unique identifiers" in the method signature refers to the position
	 * in the $revisionIDs array of the page revisions inserted by
	 * addDBDataOnce(), and are used as a kind of stable identifier to make it
	 * easier to reason about the test.
	 *
	 * The data provider uses those positions instead of directly using the IDs
	 * because we can't guarantee the exact IDs that will be generated by
	 * insertions (i.e. IDs are automatically generated), and PHPUnit will run
	 * the data provider before addDBDataOnce() anyway.
	 *
	 * @dataProvider listsExpiredLogIdsInSetDataProvider
	 */
	public function testListExpiredLogIdsInSet(
		array $expectedLogIdsByUniqueIdentifier,
		int $CUDMaxAge,
		array $inputLogIdsByUniqueIdentifier
	): void {
		$sut = $this->getSubjectUnderTest( [
			'ServiceOptions' => new ServiceOptions(
				CheckUserExpiredIdsLookupService::CONSTRUCTOR_OPTIONS,
				[ 'CUDMaxAge' => $CUDMaxAge ]
			),
		] );

		// [ A => 2, B => 3, ...]
		$contentToLogIDs = array_intersect_key(
			self::$logIDs,
			array_flip( $inputLogIdsByUniqueIdentifier )
		);
		$expectedIDs = array_intersect_key(
			$contentToLogIDs,
			array_flip( $expectedLogIdsByUniqueIdentifier )
		);

		$this->assertSame(
			$expectedIDs,
			$sut->listExpiredLogIdsInSet(
				array_values( $inputLogIdsByUniqueIdentifier )
			)
		);
	}

	public static function listsExpiredLogIdsInSetDataProvider(): array {
		return [
			'Returns an empty array if the MaxAge is zero' => [
				'expectedLogIdsByUniqueIdentifier' => [],
				'CUDMaxAge' => 0,
				'inputLogIdsByUniqueIdentifier' => [ 'A', 'B', 'C' ],
			],
			'Returns an empty array if the input array is empty ' => [
				'expectedLogIdsByUniqueIdentifier' => [],
				'CUDMaxAge' => 1,
				'inputLogIdsByUniqueIdentifier' => [],
			],
			'Returns the expected results if the MaxAge is non-zero' => [
				'expectedLogIdsByUniqueIdentifier' => [ 'E' ],
				'CUDMaxAge' => self::MAX_DATA_AGE_SECONDS,
				'inputLogIdsByUniqueIdentifier' => [ 'A', 'B', 'C' ],
			],
		];
	}

	/**
	 * Note in this test "unique identifiers" means the actual log IDs as they
	 * are persisted in the database.
	 *
	 * @dataProvider listsExpiredAbuseLogIdsDataProvider
	 */
	public function testListExpiredAbuseLogIdsInSet(
		array $expectedAbuseLogIDsByUniqueIdentifier,
		bool $isAbuseFilterLoaded,
		int $CUDMaxAge,
		array $inputAbuseLogEntryIndices
	): void {
		$this->markTestSkippedIfExtensionNotLoaded( 'Abuse Filter' );

		$sut = $this->getSubjectUnderTest( [
			'ServiceOptions' => new ServiceOptions(
				CheckUserExpiredIdsLookupService::CONSTRUCTOR_OPTIONS,
				[ 'CUDMaxAge' => $CUDMaxAge ]
			),
			'isAbuseFilterLoaded' => $isAbuseFilterLoaded,
		] );

		$mapper = static fn ( int $index ) => self::$afLogIDs[ $index ];
		$this->assertSame(
			$expectedAbuseLogIDsByUniqueIdentifier,
			$sut->listExpiredAbuseLogIdsInSet(
				array_map( $mapper, $inputAbuseLogEntryIndices )
			)
		);
	}

	public static function listsExpiredAbuseLogIdsDataProvider(): array {
		return [
			'Returns an empty array if the MaxAge is zero' => [
				'expectedAbuseLogIDsByUniqueIdentifier' => [],
				'isAbuseFilterLoaded' => true,
				'CUDMaxAge' => 0,
				'inputAbuseLogEntryIndices' => [ 0, 1 ],
			],
			'Returns an empty array if the input array is empty' => [
				'expectedAbuseLogIDsByUniqueIdentifier' => [],
				'isAbuseFilterLoaded' => true,
				'CUDMaxAge' => 1,
				'inputAbuseLogEntryIndices' => [],
			],
			'Does nothing if AbuseFilter is not loaded' => [
				'expectedAbuseLogIDsByUniqueIdentifier' => [],
				'isAbuseFilterLoaded' => false,
				'CUDMaxAge' => 1,
				'inputAbuseLogEntryIndices' => [ 0, 1 ],
			],
			'Returns the expected results if testing both an expired and a non-expired ID' => [
				'expectedAbuseLogIDsByUniqueIdentifier' => [ '2' ],
				'isAbuseFilterLoaded' => true,
				'CUDMaxAge' => 1,
				'inputAbuseLogEntryIndices' => [ 0, 1 ],
			],
			'Returns the expected results if testing only a non-expired ID' => [
				'expectedAbuseLogIDsByUniqueIdentifier' => [],
				'isAbuseFilterLoaded' => true,
				'CUDMaxAge' => 1,
				'inputAbuseLogEntryIndices' => [ 0 ],
			],
			'Returns the expected results if testing only an expired ID' => [
				'expectedAbuseLogIDsByUniqueIdentifier' => [ '2' ],
				'isAbuseFilterLoaded' => true,
				'CUDMaxAge' => 1,
				'inputAbuseLogEntryIndices' => [ 1 ],
			],
		];
	}

	public function addDBDataOnce(): void {
		$this->wikiPage = $this->getExistingTestPage( 'CheckUserExpiredIdsLookupServiceTest' );

		$data = [
			'A' => self::CURRENT_TIMESTAMP,
			'B' => self::CURRENT_TIMESTAMP - ( self::MAX_DATA_AGE_SECONDS / 2 ),
			'C' => self::CURRENT_TIMESTAMP - ( self::MAX_DATA_AGE_SECONDS - 1 ),
			'D' => self::CURRENT_TIMESTAMP - self::MAX_DATA_AGE_SECONDS,
			'E' => self::CURRENT_TIMESTAMP - ( self::MAX_DATA_AGE_SECONDS + 1 ),
		];

		// Revision table
		self::$revisions = [];
		self::$revisionIDs = [];

		foreach ( $data as $content => $timestamp ) {
			$record = $this->insertRevisionToTestPage( $content, $timestamp );

			self::$revisions[ $content ] = $record;
			self::$revisionIDs[] = $record->getId();
		}

		// Log table
		self::$logs = [];
		self::$logIDs = [];

		foreach ( $data as $content => $timestamp ) {
			$result = $this->insertLogEntryForTestPage( $timestamp );

			self::$logs[ $content ] = $result[ 'entry' ];
			self::$logIDs[ $content ] = $result[ 'id' ];
		}

		// AbuseFilter table
		if ( ExtensionRegistry::getInstance()->isLoaded( 'Abuse Filter' ) ) {
			$status = AbuseFilterServices::getFilterStore()->saveFilter(
				$this->getTestSysop()->getUser(),
				null,
				$this->getFilterFactoryProxy()->getFilter( [
					'id' => '1',
					'name' => 'Test filter',
					'privacy' => Flags::FILTER_HIDDEN,
					'rules' => 'old_wikitext = "abc"',
				] ),
				MutableFilter::newDefault()
			);

			$this->assertStatusGood( $status );

			$pageTitle = $this->wikiPage->getTitle();
			$testUser = $this->getTestSysop()->getUser();
			$vars = VariableHolder::newFromArray( [
				'action' => 'edit',
				'user_name' => $testUser->getName(),
				'old_wikitext' => 'abc',
			] );

			$afLoggerFactory = AbuseFilterServices::getAbuseLoggerFactory();
			$logger = $afLoggerFactory->newLogger( $pageTitle, $testUser, $vars );

			// Insert a non-expired entry
			ConvertibleTimestamp::setFakeTime( self::CURRENT_TIMESTAMP );
			$afLog1 = $logger->addLogEntries( [ 1 => [ 'warn' ] ] );
			$this->assertCount( 1, $afLog1[ 'local' ] );
			$this->assertSame( 1, $afLog1['local'][0] );

			// Insert an expired entry
			ConvertibleTimestamp::setFakeTime( self::CURRENT_TIMESTAMP - 2 );
			$afLog2 = $logger->addLogEntries( [ 1 => [ 'warn' ] ] );
			$this->assertCount( 1, $afLog2[ 'local' ] );
			$this->assertSame( 1, $afLog2['local'][0] );

			// Obtain the IDs for the newly-inserted log entries
			self::$afLogIDs = $this->newSelectQueryBuilder()
				->select( [ 'afl_id' ] )
				->from( 'abuse_filter_log' )
				->orderBy( 'afl_id' )
				->caller( __METHOD__ )
				->fetchFieldValues();
		}

		ConvertibleTimestamp::setFakeTime( false );
	}

	/**
	 * Inserts a new revision of the test page to the DB.
	 */
	private function insertRevisionToTestPage(
		string $content,
		string $timestamp
	): RevisionRecord {
		$revisionRecord = $this->getNewRevisionForTestPage( $content );
		$revisionRecord->setUser( $this->getTestUser()->getUser() );
		$revisionRecord->setTimestamp( $timestamp );
		$revisionRecord->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		return $this->getServiceContainer()->getRevisionStore()->insertRevisionOn(
			$revisionRecord,
			$this->getDb()
		);
	}

	/**
	 * Inserts a new log entry of the test page to the DB.
	 *
	 * @return array{id: int, entry: ManualLogEntry}
	 */
	private function insertLogEntryForTestPage( int $unixTimestamp ): array {
		$logEntry = new ManualLogEntry( 'phpunit', 'test' );
		$logEntry->setPerformer( $this->getTestUser()->getUser() );
		$logEntry->setTarget( $this->wikiPage );
		$logEntry->setComment( 'A very good reason' );
		$logEntry->setTimestamp( $unixTimestamp );

		$logId = $logEntry->insert();
		$this->assertGreaterThan( 0, $logId > 0 );

		return [
			'id' => $logId,
			'entry' => $logEntry,
		];
	}

	/**
	 * Returns a next in sequence revision of the test page with specified content.
	 */
	private function getNewRevisionForTestPage(
		string $content
	): MutableRevisionRecord {
		$revision = new MutableRevisionRecord( $this->wikiPage->getTitle() );
		$revision->setPageId( $this->wikiPage->getId() );
		$revision->setContent(
			SlotRecord::MAIN,
			new WikitextContent( $content )
		);

		return $revision;
	}

	/**
	 * Returns a new instance of the subject under test.
	 *
	 * @param array<string, object> $overrides Map of service overrides.
	 */
	private function getSubjectUnderTest(
		array $overrides
	): CheckUserExpiredIdsLookupService {
		$services = $this->getServiceContainer();

		$extensionRegistry = $this->createMock( ExtensionRegistry::class );
		$extensionRegistry
			->method( 'isLoaded' )
			->with( 'Abuse Filter' )
			->willReturn( $overrides[ 'isAbuseFilterLoaded' ] ?? true );

		return new CheckUserExpiredIdsLookupService(
			$overrides[ 'ServiceOptions' ] ?? new ServiceOptions(
				CheckUserExpiredIdsLookupService::CONSTRUCTOR_OPTIONS,
				[ 'CUDMaxAge' => self::MAX_DATA_AGE_SECONDS ]
			),
			$overrides[ 'ConnectionProvider' ] ??
				$services->getConnectionProvider(),
			$extensionRegistry
		);
	}
}
