<?php

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

use MediaWiki\Extension\CheckUser\CheckUserQueryInterface;
use MediaWiki\Extension\CheckUser\Services\CheckUserLookupUtils;
use MediaWiki\Logging\LogEntryBase;
use MediaWiki\Logging\LogPage;
use MediaWiki\Revision\RevisionArchiveRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Extension\CheckUser\Services\CheckUserLookupUtils
 * @group Database
 */
class CheckUserLookupUtilsTest extends MediaWikiIntegrationTestCase {
	/** @dataProvider provideGetIpConds */
	public function testGetIpConds( $target, $table, $xfor, $expectedSql ) {
		$this->overrideConfigValue( 'CheckUserCIDRLimit', [ 'IPv4' => 16, 'IPv6' => 19 ] );
		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$actualExpr = $checkUserLookupUtils->getIPTargetExpr( $target, $xfor, $table );
		if ( $expectedSql === null ) {
			$this->assertNull(
				$actualExpr,
				'Conditions were generated when they should not have been, as the target was invalid.'
			);
		} else {
			$this->assertEquals(
				$expectedSql,
				$actualExpr->toSql( $this->getDb() ),
				'The SQL representation of the conditions for an IP or IP range target is not as expected.'
			);
		}
	}

	public static function provideGetIpConds() {
		return [
			'Single IPv4 address for cu_changes and non-XFF' => [
				// The target IP address or range
				'212.35.31.121',
				// The table which these conditions will be used on
				CheckUserQueryInterface::CHANGES_TABLE,
				// The value of the xfor parameter (true indicates the IP is an XFF IP).
				false,
				// The expected SQL representation of the IExpression that is returned.
				'cuc_ip_hex = \'D4231F79\'',
			],
			'Single IPv4 address for cu_private_event and XFF' => [
				'212.35.31.121', CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE, true, 'cupe_xff_hex = \'D4231F79\'',
			],
			'Single IPv4 address for cu_log_event and non-XFF' => [
				'212.35.31.121', CheckUserQueryInterface::LOG_EVENT_TABLE, false, 'cule_ip_hex = \'D4231F79\'',
			],
			'Single IPv4 address notated as a /32' => [
				'212.35.31.121/32', CheckUserQueryInterface::CHANGES_TABLE, false,
				'(cuc_ip_hex >= \'D4231F79\' AND cuc_ip_hex <= \'D4231F79\')',
			],
			'Single IPv6 address' => [
				'::e:f:2001', CheckUserQueryInterface::CHANGES_TABLE, false,
				'cuc_ip_hex = \'v6-00000000000000000000000E000F2001\'',
			],
			'IPv6 /96 range for cu_changes and non-XFF' => [
				'::e:f:2001/96', CheckUserQueryInterface::CHANGES_TABLE, false,
				'(cuc_ip_hex >= \'v6-00000000000000000000000E00000000\' AND ' .
					'cuc_ip_hex <= \'v6-00000000000000000000000EFFFFFFFF\')',
			],
			'IPv6 /96 range for cu_private_event and XFF' => [
				'::e:f:2001/96', CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE, true,
				'(cupe_xff_hex >= \'v6-00000000000000000000000E00000000\' AND ' .
					'cupe_xff_hex <= \'v6-00000000000000000000000EFFFFFFFF\')',
			],
			'Invalid IP address' => [ 'abcedf', CheckUserQueryInterface::CHANGES_TABLE, false, null ],
			'IPv4 address with CIDR lower than the limit' => [
				'0.17.184.5/15', CheckUserQueryInterface::CHANGES_TABLE, false, null,
			],
			'IPv6 address with CIDR lower than the limit' => [
				'2000::/18', CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE, true, null,
			],
		];
	}

	/** @dataProvider provideIsValidRange */
	public function testIsValidRange( $target, $expected ) {
		$this->overrideConfigValue( 'CheckUserCIDRLimit', [ 'IPv4' => 16, 'IPv6' => 19 ] );
		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$this->assertSame(
			$expected,
			$checkUserLookupUtils->isValidIPOrRange( $target ),
			'The return value of ::isValidIPOrRange is not as expected.'
		);
	}

	public static function provideIsValidRange() {
		return [
			'Single IPv4 address' => [ '212.35.31.121', true ],
			'Single IPv4 address notated as a /32' => [ '212.35.31.121/32', true ],
			'Single IPv6 address' => [ '::e:f:2001', true ],
			'IPv6 /96 range' => [ '::e:f:2001/96', true ],
			'Invalid IP address' => [ 'abcedf', false ],
			'IPv4 address with CIDR lower than the limit' => [ '0.17.184.5/15', false ],
			'IPv6 address with CIDR lower than the limit' => [ '2000::/18', false ],
		];
	}

	public function testGetRangeLimit() {
		$limit = [ 'IPv4' => 16, 'IPv6' => 19 ];
		$this->overrideConfigValue( 'CheckUserCIDRLimit', $limit );
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$this->assertArrayEquals( $limit, $checkUserLookupUtils->getRangeLimit() );
	}

	/** @dataProvider provideGetIndexName */
	public function testGetIndexName( $table, $xfor, $expectedIndexValue ) {
		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$this->assertSame(
			$expectedIndexValue,
			$checkUserLookupUtils->getIndexName( $xfor, $table ),
			'Index name is not as expected.'
		);
	}

	public static function provideGetIndexName() {
		return [
			'cu_changes with null xfor' => [ CheckUserQueryInterface::CHANGES_TABLE, null, 'cuc_actor_ip_hex_time' ],
			'cu_private_event with null xfor' => [
				CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE, null, 'cupe_actor_ip_hex_time',
			],
			'cu_log_event with false xfor' => [ CheckUserQueryInterface::LOG_EVENT_TABLE, false, 'cule_ip_hex_time' ],
			'cu_private_event with true xfor' => [
				CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE, true, 'cupe_xff_hex_time',
			],
		];
	}

	/** @dataProvider provideGetIpHexColumn */
	public function testGetIpHexColumn( $xfor, $table, $expectedReturnValue ) {
		$objectUnderTest = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->get( 'CheckUserLookupUtils' )
		);
		$this->assertSame(
			$expectedReturnValue,
			$objectUnderTest->getIpHexColumn( $xfor, $table ),
			'Call to ::getIpHexColumn did not return the correct value.'
		);
	}

	public static function provideGetIpHexColumn() {
		return [
			'Table as cu_changes with xfor as false' => [ false, CheckUserQueryInterface::CHANGES_TABLE, 'cuc_ip_hex' ],
			'Table as cu_changes with xfor as true' => [ true, CheckUserQueryInterface::CHANGES_TABLE, 'cuc_xff_hex' ],
			'Table as cu_log_event with xfor as true' => [
				true, CheckUserQueryInterface::LOG_EVENT_TABLE, 'cule_xff_hex',
			],
			'Table as cu_private_event with xfor as false' => [
				false, CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE, 'cupe_ip_hex',
			],
		];
	}

	/** @dataProvider provideGetManualLogEntryFromRow */
	public function testGetManualLogEntryFromRow( \stdClass $row, $user, $expectedParameters ) {
		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$actualLogEntry = $checkUserLookupUtils->getManualLogEntryFromRow( $row, $user );
		$this->assertSame(
			$row->log_deleted,
			$actualLogEntry->getDeleted(),
			'ManualLogEntry::getDeleted did not return the expected value.'
		);
		$this->assertSame(
			$row->log_type . '/' . $row->log_action,
			$actualLogEntry->getFullType(),
			'ManualLogEntry::getFullType did not return the expected value.'
		);
		$this->assertSame(
			$row->timestamp,
			$actualLogEntry->getTimestamp(),
			'ManualLogEntry::getTimestamp did not return the expected timestamp.'
		);
		$this->assertSame(
			$user,
			$actualLogEntry->getPerformerIdentity(),
			'ManualLogEntry::getPerformerIdentity did not return the expected UserIdentity.'
		);
		$actualTarget = $actualLogEntry->getTarget();
		$this->assertSame(
			$row->namespace,
			$actualTarget->getNamespace(),
			'ManualLogEntry::getTarget did not return the expected Title.'
		);
		$this->assertSame(
			$row->title,
			$actualTarget->getDBkey(),
			'ManualLogEntry::getTarget did not return the expected Title.'
		);
		$this->assertSame(
			$expectedParameters,
			$actualLogEntry->getParameters(),
			'ManualLogEntry::getParameters did not return the expected parameters.'
		);
	}

	public static function provideGetManualLogEntryFromRow() {
		return [
			'Row with legacy parameters' => [
				(object)[
					'log_type' => 'type',
					'log_action' => 'action',
					'log_params' => LogPage::makeParamBlob( [ '4::target' => 'Testing', '5::noredir' => '0' ] ),
					'log_deleted' => 0,
					'title' => 'title',
					'namespace' => 0,
					'timestamp' => '20210101000000',
				],
				UserIdentityValue::newRegistered( 1, 'User' ),
				[ 'Testing', '0' ],
			],
			'Row with non-legacy parameters' => [
				(object)[
					'log_type' => 'type',
					'log_action' => 'action',
					'log_params' => LogEntryBase::makeParamBlob( [ '4::target' => 'Testing', '5::noredir' => '0' ] ),
					'log_deleted' => 0,
					'timestamp' => '20220101000000',
					'title' => 'title',
					'namespace' => 0,
				],
				UserIdentityValue::newRegistered( 1, 'User' ),
				[ '4::target' => 'Testing', '5::noredir' => '0' ],
			],
		];
	}

	/**
	 * @dataProvider providePageIdField
	 */
	public function testGetManualLogEntryFromRowWithPage( $pageIdField ) {
		// Create a testing page so that we can get a valid page ID
		$insertPageResult = $this->insertPage( 'Testing', 'Testing', 0 );
		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$actualLogEntry = $checkUserLookupUtils->getManualLogEntryFromRow(
			(object)[
				'log_type' => 'type',
				'log_action' => 'action',
				'log_params' => LogEntryBase::makeParamBlob( [ '4::target' => 'Testing', '5::noredir' => '0' ] ),
				'log_deleted' => 0,
				'timestamp' => '20220101000000',
				$pageIdField => $insertPageResult['id'],
			],
			UserIdentityValue::newRegistered( 1, 'User' )
		);
		$this->assertSame(
			$insertPageResult['id'],
			$actualLogEntry->getTarget()->getArticleID(),
			'ManualLogEntry::getTarget did not return the expected Title.'
		);
	}

	public static function providePageIdField() {
		return [
			[ 'page' ],
			[ 'page_id' ],
		];
	}

	/**
	 * @dataProvider provideGetManualLogEntryFromRowWithMissingTarget
	 */
	public function testGetManualLogEntryFromRowWithMissingTarget( \stdClass $row ) {
		$mockLogger = $this->createMock( LoggerInterface::class );
		$mockLogger->method( 'error' )
			->with(
				'Missing target for log entry being displayed in CheckUser result interface',
				$this->anything()
			)
			->willReturn( function ( $message, array $context ) use ( $row )  {
				$this->assertSame( $row, $context['row'] );
				$this->assertInstanceOf( RuntimeException::class, $context['exception'] );
			} );
		$this->setLogger( 'CheckUser', $mockLogger );

		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$actualLogEntry = $checkUserLookupUtils->getManualLogEntryFromRow(
			$row,
			UserIdentityValue::newRegistered( 1, 'User' )
		);
		// T333573: When no valid title or page ID is available, the target should
		// fall back to Special:Badtitle instead of leaving $targetPage uninitialized.
		$actualTarget = $actualLogEntry->getTarget();
		$this->assertSame( NS_SPECIAL, $actualTarget->getNamespace() );
		$this->assertSame( 'Badtitle', $actualTarget->getDBkey() );
	}

	public static function provideGetManualLogEntryFromRowWithMissingTarget() {
		return [
			'Row with empty title and no page ID' => [
				(object)[
					'log_type' => 'checkuser-private-event',
					'log_action' => 'login-success',
					'log_params' => LogEntryBase::makeParamBlob( [ '4::target' => 'User' ] ),
					'log_deleted' => 0,
					'timestamp' => '20220101000000',
					'title' => '',
					'namespace' => 0,
				],
			],
			'Row with empty title and page ID of 0' => [
				(object)[
					'log_type' => 'checkuser-private-event',
					'log_action' => 'login-success',
					'log_params' => LogEntryBase::makeParamBlob( [ '4::target' => 'User' ] ),
					'log_deleted' => 0,
					'timestamp' => '20220101000000',
					'title' => '',
					'namespace' => 0,
					'page_id' => 0,
				],
			],
			'Row with no title or page fields' => [
				(object)[
					'log_type' => 'checkuser-private-event',
					'log_action' => 'login-success',
					'log_params' => LogEntryBase::makeParamBlob( [ '4::target' => 'User' ] ),
					'log_deleted' => 0,
					'timestamp' => '20220101000000',
				],
			],
		];
	}

	public function testGetRevisionRecordFromRowOnMissingRevisionId() {
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		// Install a mock LoggerInterface that expects a call to ::warning
		$mockLogger = $this->createMock( LoggerInterface::class );
		$mockLogger->expects( $this->atLeastOnce() )
			->method( 'warning' );
		$checkUserLookupUtils = TestingAccessWrapper::newFromObject( $checkUserLookupUtils );
		$checkUserLookupUtils->logger = $mockLogger;
		// Call the method under test
		$this->assertNull(
			$checkUserLookupUtils->getRevisionRecordFromRow( (object)[ 'this_oldid' => 12345 ] ),
			'The value returned by ::getRevisionRecordFromRow was not as expected.'
		);
	}

	public function testGetRevisionRecordForDeletedRevision() {
		$title = Title::newFromText( 'Testing' );
		// Create a page and get the revision ID associated with the edit that created the page.
		$editStatus = $this->editPage( $title, 'Testing', 0 );
		$this->assertStatusGood( $editStatus );
		$revId = $editStatus->getNewRevision()->getId();
		// Delete the page we just created.
		$this->deletePage( $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title ) );

		// Attempt to get the RevisionRecord for the deleted revision.
		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$actualRevisionRecord = $checkUserLookupUtils->getRevisionRecordFromRow( (object)[ 'this_oldid' => $revId ] );
		$this->assertSame(
			$revId,
			$actualRevisionRecord->getId(),
			'The RevisionRecord returned by ::getRevisionRecordFromRow does not have the expected revision ID.'
		);
		$this->assertInstanceOf( RevisionArchiveRecord::class, $actualRevisionRecord );
	}

	public function testGetRevisionRecordForRevision() {
		$title = Title::newFromText( 'Testing' );
		// Create a page and get the revision ID associated with the edit that created the page.
		$editStatus = $this->editPage( $title, 'Testing', 0 );
		$this->assertStatusGood( $editStatus );
		$revId = $editStatus->getNewRevision()->getId();

		/** @var CheckUserLookupUtils $checkUserLookupUtils */
		$checkUserLookupUtils = $this->getServiceContainer()->get( 'CheckUserLookupUtils' );
		$actualRevisionRecord = $checkUserLookupUtils->getRevisionRecordFromRow( (object)[ 'this_oldid' => $revId ] );
		$this->assertSame(
			$revId,
			$actualRevisionRecord->getId(),
			'The RevisionRecord returned by ::getRevisionRecordFromRow does not have the expected revision ID.'
		);
	}
}
