<?php

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

use MediaWiki\Context\RequestContext;
use MediaWiki\Extension\CheckUser\CheckUserQueryInterface;
use MediaWiki\Extension\CheckUser\Maintenance\PurgeOldData;
use MediaWiki\Extension\CheckUser\Services\CheckUserCentralIndexManager;
use MediaWiki\Extension\CheckUser\Services\CheckUserInsert;
use MediaWiki\Extension\CheckUser\Services\UserAgentClientHintsManager;
use MediaWiki\Extension\CheckUser\Tests\Integration\CheckUserCommonTestTrait;
use MediaWiki\Extension\CheckUser\Tests\Integration\Maintenance\Mocks\SemiMockedCheckUserDataPurger;
use MediaWiki\MainConfigNames;
use MediaWiki\Maintenance\Maintenance;
use MediaWiki\RecentChanges\RecentChange;
use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use PurgeRecentChanges;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group CheckUser
 * @group Database
 * @covers \MediaWiki\Extension\CheckUser\Maintenance\PurgeOldData
 */
class PurgeOldDataDatabaseTest extends MaintenanceBaseTestCase {
	use CheckUserCommonTestTrait;

	/** @var MockObject|Maintenance */
	protected $maintenance;

	/** @inheritDoc */
	protected function getMaintenanceClass() {
		return PurgeOldData::class;
	}

	protected function setUp(): void {
		parent::setUp();
		// Fix the current time and CUDMaxAge so that we can assert against pre-defined timestamp values
		ConvertibleTimestamp::setFakeTime( '20230405060708' );
		$this->overrideConfigValue( 'CUDMaxAge', 30 );
	}

	protected function createMaintenance() {
		$obj = $this->getMockBuilder( $this->getMaintenanceClass() )
			->onlyMethods( [ 'createChild', 'getPrimaryDB' ] )
			->getMock();
		$obj->method( 'getPrimaryDB' )
			->willReturn( $this->getDb() );

		return TestingAccessWrapper::newFromObject( $obj );
	}

	/**
	 * @param bool $shouldPurgeRecentChanges Whether the maintenance script should purge data from recentchanges
	 * @param bool $shouldPurgeCentralIndexRows
	 * @return string The expected output regex
	 */
	private function generateExpectedOutputRegex(
		bool $shouldPurgeRecentChanges,
		bool $shouldPurgeCentralIndexRows
	): string {
		$expectedOutputRegex = '/';
		foreach ( CheckUserQueryInterface::RESULT_TABLES as $table ) {
			$expectedOutputRegex .= "Purging data from $table.*Purged " .
				SemiMockedCheckUserDataPurger::MOCKED_PURGED_ROW_COUNTS_PER_TABLE[$table] .
				" rows and 2 client hint mapping rows.\n";
		}
		if ( $shouldPurgeCentralIndexRows ) {
			$expectedOutputRegex .= "Purged 12 central index rows.\n";
		}
		$expectedOutputRegex .= "Purged 123 orphaned client hint mapping rows.\n";
		if ( $shouldPurgeRecentChanges ) {
			$expectedOutputRegex .= "Purging data from recentchanges[\s\S]*";
		}
		$expectedOutputRegex .= "Pruning unused rows from cu_useragent[\s\S]*Done/";
		return $expectedOutputRegex;
	}

	/** @dataProvider provideExecute */
	public function testExecute( $config, $shouldPurgeRecentChanges ) {
		// Expect that the PurgeRecentChanges script is run if $shouldPurgeRecentChanges is true.
		$this->overrideConfigValues( $config );
		$this->maintenance->expects( $this->exactly( (int)$shouldPurgeRecentChanges ) )
			->method( 'createChild' )
			->with( PurgeRecentChanges::class )
			->willReturn( $this->createMock( PurgeRecentChanges::class ) );

		// Expect that UserAgentClientHintsManager::deleteOrphanedMapRows and ::deleteMappingRows are called,
		// and give them fake return values.
		$mockUserAgentClientHintsManager = $this->createMock( UserAgentClientHintsManager::class );
		$mockUserAgentClientHintsManager->method( 'deleteOrphanedMapRows' )
			->willReturn( 123 );
		$mockUserAgentClientHintsManager->expects( $this->exactly( 3 ) )
			->method( 'deleteMappingRows' )
			->willReturn( 2 );
		$this->setService( 'UserAgentClientHintsManager', $mockUserAgentClientHintsManager );

		// Install the mock CheckUserDataPurger service that will assert for us.
		$mockCheckUserDataPurger = new SemiMockedCheckUserDataPurger();
		$this->setService( 'CheckUserDataPurger', $mockCheckUserDataPurger );

		// Mock the CheckUserCentralIndexManager service to expect a call. The expected cutoff is
		// generated by the fixed timestamp in ::setUp.
		$mockCheckUserCentralIndexManager = $this->createMock( CheckUserCentralIndexManager::class );
		$mockCheckUserCentralIndexManager->expects( $this->exactly( 2 ) )
			->method( 'purgeExpiredRows' )
			->with( '20230405060638', $this->getDb()->getDomainID() )
			->willReturnOnConsecutiveCalls( 12, 0 );
		$this->setService( 'CheckUserCentralIndexManager', $mockCheckUserCentralIndexManager );

		// Run the maintenance script
		$this->maintenance->execute();

		// Verify the output of the maintenance script is as expected
		$this->expectOutputRegex( $this->generateExpectedOutputRegex(
			$shouldPurgeRecentChanges,
			true
		) );
		$mockCheckUserDataPurger->checkThatExpectedCallsHaveBeenMade();
	}

	public static function provideExecute() {
		return [
			'wgPutIPinRC is false' => [ [ MainConfigNames::PutIPinRC => false ], false ],
			'wgPutIPinRC is true' => [ [ MainConfigNames::PutIPinRC => true ], true ],
		];
	}

	public function testExecuteForUserAgentTablePurging() {
		$this->overrideConfigValues( [
			'CheckUserWriteToCentralIndex' => false,
			MainConfigNames::PutIPinRC => false,
		] );

		// Expect purgeRecentChanges.php to not be run as $wgPutIPinRC is false
		$this->maintenance->expects( $this->never() )
			->method( 'createChild' );

		// Mock the UserAgentClientHintsManager to return fake return values
		$mockUserAgentClientHintsManager = $this->createMock( UserAgentClientHintsManager::class );
		$mockUserAgentClientHintsManager->method( 'deleteOrphanedMapRows' )
			->willReturn( 123 );
		$mockUserAgentClientHintsManager->method( 'deleteMappingRows' )
			->willReturn( 2 );
		$this->setService( 'UserAgentClientHintsManager', $mockUserAgentClientHintsManager );

		// Mock all local CheckUser data purging but the cu_useragent table purge
		$mockCheckUserDataPurger = new SemiMockedCheckUserDataPurger();
		$this->setService( 'CheckUserDataPurger', $mockCheckUserDataPurger );

		$this->addTestingRowsForPurgeTest();

		$this->maintenance->execute();

		// Verify the output of the maintenance script is as expected
		$this->expectOutputRegex( $this->generateExpectedOutputRegex(
			false,
			false
		) );

		// Check that all but the second, third, fifth, sixth and eighth user agent are deleted:
		// * The second, third, fifth and sixth are in use so should be kept
		// * The eighth is within the 1% (rounded down) of newly created rows so should not be touched
		$this->newSelectQueryBuilder()
			->select( 'cuua_text' )
			->from( 'cu_useragent' )
			->caller( __METHOD__ )
			->assertFieldValues( [ 'test2', 'test3', 'test5', 'test6', 'test8' ] );
	}

	private function addTestingRowsForPurgeTest(): void {
		// Add some testing rows to cu_useragent
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'cu_useragent' )
			->rows( [
				[ 'cuua_text' => 'test1' ],
				[ 'cuua_text' => 'test2' ],
				[ 'cuua_text' => 'test3' ],
				[ 'cuua_text' => 'test4' ],
				[ 'cuua_text' => 'test5' ],
				[ 'cuua_text' => 'test6' ],
				[ 'cuua_text' => 'test7' ],
				[ 'cuua_text' => 'test8' ],
			] )
			->caller( __METHOD__ )
			->execute();

		/** @var CheckUserInsert $checkUserInsert */
		$checkUserInsert = $this->getServiceContainer()->get( 'CheckUserInsert' );
		$target = $this->getMutableTestUser()->getUserIdentity();

		// Make use of the second test user agent for a row in cu_changes
		RequestContext::getMain()->getRequest()->setHeader( 'User-Agent', 'test2' );
		$rc = new RecentChange;
		$rc->setAttribs( array_merge(
			self::getDefaultRecentChangeAttribs(),
			[ 'rc_user' => $target->getId(), 'rc_user_text' => $target->getName() ],
		) );
		$checkUserInsert->updateCheckUserData( $rc );

		// Make use of the third and fifth test user agent for rows in cu_log_event
		RequestContext::getMain()->getRequest()->setHeader( 'User-Agent', 'test3' );
		$logId = $this->newLogEntry();
		$rc = new RecentChange;
		$rc->setAttribs( array_merge(
			self::getDefaultRecentChangeAttribs(),
			[
				'rc_source' => RecentChange::SRC_LOG, 'rc_logid' => $logId,
				'rc_user' => $target->getId(), 'rc_user_text' => $target->getName(),
			]
		) );
		$checkUserInsert->updateCheckUserData( $rc );

		RequestContext::getMain()->getRequest()->setHeader( 'User-Agent', 'test5' );
		$logId = $this->newLogEntry();
		$rc = new RecentChange;
		$rc->setAttribs( array_merge(
			self::getDefaultRecentChangeAttribs(),
			[
				'rc_source' => RecentChange::SRC_LOG, 'rc_logid' => $logId,
				'rc_user' => $target->getId(), 'rc_user_text' => $target->getName(),
			]
		) );
		$checkUserInsert->updateCheckUserData( $rc );

		// Make use of the third and sixth test user agent for rows in cu_private_event
		RequestContext::getMain()->getRequest()->setHeader( 'User-Agent', 'test3' );
		$rc = new RecentChange;
		$rc->setAttribs( array_merge(
			self::getDefaultRecentChangeAttribs(),
			[
				'rc_source' => RecentChange::SRC_LOG, 'rc_logid' => 0,
				'rc_log_type' => 'test', 'rc_log_action' => 'test2',
				'rc_user' => $target->getId(), 'rc_user_text' => $target->getName(),
			]
		) );
		$checkUserInsert->updateCheckUserData( $rc );

		RequestContext::getMain()->getRequest()->setHeader( 'User-Agent', 'test6' );
		$rc = new RecentChange;
		$rc->setAttribs( array_merge(
			self::getDefaultRecentChangeAttribs(),
			[
				'rc_source' => RecentChange::SRC_LOG, 'rc_logid' => 0,
				'rc_log_type' => 'test', 'rc_log_action' => 'test2',
				'rc_user' => $target->getId(), 'rc_user_text' => $target->getName(),
			]
		) );
		$checkUserInsert->updateCheckUserData( $rc );
	}
}
