<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit;

use AbuseFilterRowsAndFiltersTestTrait;
use Generator;
use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
use MediaWiki\Extension\AbuseFilter\Filter\ClosestFilterVersionNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\Filter\Filter;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Filter\HistoryFilter;
use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo;
use MediaWiki\Extension\AbuseFilter\Filter\Specs;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\User\UserIdentityValue;
use MediaWikiUnitTestCase;
use stdClass;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\SelectQueryBuilder;

/**
 * @group Test
 * @group AbuseFilter
 * @covers \MediaWiki\Extension\AbuseFilter\FilterLookup
 * @todo Some integration tests with a real DB might be helpful
 */
class FilterLookupTest extends MediaWikiUnitTestCase {
	use AbuseFilterRowsAndFiltersTestTrait;

	/**
	 * @param IDatabase|null $db
	 * @param string|false $centralDB
	 * @param WANObjectCache|null $cache
	 * @param bool $filterIsCentral
	 * @return FilterLookup
	 */
	private function getLookup(
		?IDatabase $db = null,
		$centralDB = false,
		?WANObjectCache $cache = null,
		bool $filterIsCentral = false
	): FilterLookup {
		$lb = $this->createMock( ILoadBalancer::class );
		$lb->method( 'getConnection' )
			->willReturn( $db ?? $this->createMock( IDatabase::class ) );

		$lbFactory = $this->createMock( LBFactory::class );
		$lbFactory->method( 'getMainLB' )->willReturnCallback(
			static function ( $domain ) use ( $lb, $centralDB ) {
				// Return null for sanity
				return $domain === $centralDB ? $lb : null;
			}
		);
		$centralDBManager = new CentralDBManager( $lbFactory, $centralDB, $filterIsCentral );
		return new FilterLookup(
			$lb,
			// Cannot use mocks because final methods aren't mocked and they would error out
			$cache ?? new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ),
			$centralDBManager
		);
	}

	/**
	 * Hacky helper to set up the database
	 * @param stdClass[] $filterRows abuse_filter or abuse_filter_history
	 * @param stdClass[] $actionRows
	 * @return IDatabase
	 */
	private function getDBWithMockRows( array $filterRows, array $actionRows = [] ): IDatabase {
		$db = $this->createMock( IDatabase::class );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$db->method( 'selectRow' )->willReturnCallback( static function ( $table ) use ( $filterRows ) {
			$tables = (array)$table;
			return array_intersect( $tables, [ 'abuse_filter', 'abuse_filter_history' ] ) ? $filterRows[0] : false;
		} );
		$db->method( 'select' )->willReturnCallback(
			static function ( $table, $_, $where ) use ( $filterRows, $actionRows ) {
				$tables = (array)$table;
				$ret = [];
				if ( in_array( 'abuse_filter_action', $tables ) ) {
					foreach ( $actionRows as $row ) {
						if ( $row->afa_filter === $where['afa_filter'] ) {
							$ret[] = $row;
						}
					}
				} elseif ( array_intersect( $tables, [ 'abuse_filter', 'abuse_filter_history' ] ) ) {
					$ret = $filterRows;
				}
				return new FakeResultWrapper( $ret );
			}
		);
		return $db;
	}

	/**
	 * @param int $version
	 * @param stdClass $dbRow
	 * @param HistoryFilter $expected
	 * @dataProvider provideFilterVersions
	 */
	public function testGetFilterVersion( int $version, stdClass $dbRow, HistoryFilter $expected ) {
		$db = $this->getDBWithMockRows( [ $dbRow ] );
		$filterLookup = $this->getLookup( $db );
		$this->assertEquals( $expected, $filterLookup->getFilterVersion( $version ) );
	}

	public static function provideFilterVersions(): Generator {
		$version = 163;
		$filters = [
			'no actions' => new HistoryFilter(
				new Specs(
					'false',
					'X',
					'Y',
					[],
					'default'
				),
				new Flags(
					true,
					true,
					true,
					true
				),
				[],
				new LastEditInfo(
					UserIdentityValue::newRegistered( 42, 'FilterManager' ),
					'20180706142932'
				),
				1,
				$version
			),
			'with actions' => new HistoryFilter(
				new Specs(
					'the_answer := 42; the_answer === 6*9',
					'Some comments',
					'My filter',
					[ 'degroup', 'disallow' ],
					'default'
				),
				new Flags(
					true,
					false,
					true,
					false
				),
				[ 'degroup' => [], 'disallow' => [] ],
				new LastEditInfo(
					UserIdentityValue::newRegistered( 42, 'FilterManager' ),
					'20180706142932'
				),
				1,
				$version
			),
		];

		foreach ( $filters as $filter ) {
			$flags = [];
			foreach ( [ 'enabled', 'deleted', 'hidden', 'protected', 'global' ] as $flag ) {
				$method = 'is' . ucfirst( $flag );
				if ( $filter->$method() ) {
					$flags[] = $flag;
				}
			}
			$historyRow = (object)[
				'afh_id' => $version,
				'afh_filter' => $filter->getID(),
				'afh_user' => $filter->getUserID(),
				'afh_user_text' => $filter->getUserName(),
				'afh_timestamp' => $filter->getTimestamp(),
				'afh_pattern' => $filter->getRules(),
				'afh_comments' => $filter->getComments(),
				'afh_flags' => implode( ',', $flags ),
				'afh_public_comments' => $filter->getName(),
				'afh_actions' => serialize( array_fill_keys( $filter->getActionsNames(), [] ) ),
				'afh_deleted' => 0,
				'afh_changed_fields' => 'actions',
				'afh_group' => $filter->getGroup()
			];
			yield [ $version, $historyRow, $filter ];
		}
	}

	public function testGetFilterVersion_notfound() {
		$db = $this->createMock( IDatabase::class );
		$db->method( 'selectRow' )->willReturn( false );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$filterLookup = $this->getLookup( $db );
		$this->expectException( FilterVersionNotFoundException::class );
		$filterLookup->getFilterVersion( 42 );
	}

	public function testGetLastHistoryVersion() {
		// Reuse this data provider for conveniency
		[ , $historyRow, $filter ] = $this->provideFilterVersions()->current();
		$db = $this->getDBWithMockRows( [ $historyRow ] );
		$filterLookup = $this->getLookup( $db );
		$this->assertEquals( $filter, $filterLookup->getLastHistoryVersion( $filter->getID() ) );
	}

	public function testGetLastHistoryVersion_notfound() {
		$db = $this->createMock( IDatabase::class );
		$db->method( 'selectRow' )->willReturn( false );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$filterLookup = $this->getLookup( $db );
		$this->expectException( FilterNotFoundException::class );
		$filterLookup->getLastHistoryVersion( 42 );
	}

	public function testGetClosestVersion() {
		// Reuse this data provider for conveniency
		[ , $historyRow, $filter ] = $this->provideFilterVersions()->current();
		$db = $this->getDBWithMockRows( [ $historyRow ] );
		$filterLookup = $this->getLookup( $db );
		$this->assertEquals( $filter, $filterLookup->getClosestVersion( 1, 42, FilterLookup::DIR_NEXT ) );
	}

	public function testGetClosestVersion_notfound() {
		$db = $this->createMock( IDatabase::class );
		$db->method( 'selectRow' )->willReturn( false );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$filterLookup = $this->getLookup( $db );
		$this->expectException( ClosestFilterVersionNotFoundException::class );
		$filterLookup->getClosestVersion( 42, 42, FilterLookup::DIR_PREV );
	}

	public function testGetFirstFilterVersionID() {
		$versionID = 1234;
		$db = $this->createMock( IDatabase::class );
		$db->method( 'selectField' )->willReturn( $versionID );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$filterLookup = $this->getLookup( $db );
		$this->assertSame( $versionID, $filterLookup->getFirstFilterVersionID( 42 ) );
	}

	public function testGetFirstFilterVersionID_notfound() {
		$db = $this->createMock( IDatabase::class );
		$db->method( 'selectField' )->willReturn( false );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$filterLookup = $this->getLookup( $db );
		$this->expectException( FilterNotFoundException::class );
		$filterLookup->getFirstFilterVersionID( 42 );
	}

	public function testLocalCache() {
		$row = self::provideRowsAndFilters()['no actions']['row'];
		$db = $this->createMock( IDatabase::class );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$db->expects( $this->once() )->method( 'selectRow' )->willReturn( $row );
		$filterLookup = $this->getLookup( $db );

		// Warm-up cache
		$filterLookup->getFilter( 42, false );
		// This should fail the soft assertion of once()
		$filterLookup->getFilter( 42, false );
	}

	public function testClearLocalCache() {
		$row = self::provideRowsAndFilters()['no actions']['row'];
		$db = $this->createMock( IDatabase::class );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$db->expects( $this->exactly( 2 ) )->method( 'selectRow' )->willReturn( $row );
		$filterLookup = $this->getLookup( $db );

		// Both calls should result in a query
		$filterLookup->getFilter( 42, false );
		$filterLookup->clearLocalCache();
		$filterLookup->getFilter( 42, false );
	}

	/**
	 * Provider to account for central vs non-central filter DB
	 * @return array
	 */
	public static function provideIsCentral() {
		return [
			'central' => [ true ],
			'not central' => [ false ]
		];
	}

	/**
	 * @param bool $isCentral
	 * @dataProvider provideIsCentral
	 */
	public function testGlobalCache( bool $isCentral ) {
		$row = self::provideRowsAndFilters()['no actions']['row'];
		$db = $this->createMock( IDatabase::class );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		// Should be called twice: once for the filter, once for the actions
		$db->expects( $this->exactly( 2 ) )->method( 'select' )
			->willReturnOnConsecutiveCalls(
				new FakeResultWrapper( [ $row ] ),
				new FakeResultWrapper( [] )
			);
		$filterLookup = $this->getLookup( $db, 'foobar', null, $isCentral );

		// WAN cache is only used for global filters
		$global = true;
		$group = 'foo';

		// Warm-up cache: the following calls must not fail the soft assertion of once()
		$filterLookup->getAllActiveFiltersInGroup( $group, $global );

		// This is covered by the internal cache
		$filterLookup->getAllActiveFiltersInGroup( $group, $global );
		$filterLookup->clearLocalCache();
		// This is covered by the network cache
		$filterLookup->getAllActiveFiltersInGroup( $group, $global );
	}

	public function testFilterLookupClearNetworkCache() {
		$row = self::provideRowsAndFilters()['no actions']['row'];
		$db = $this->createMock( IDatabase::class );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		// 4 calls: row, actions, row, actions
		$db->expects( $this->exactly( 4 ) )
			->method( 'select' )
			->willReturnOnConsecutiveCalls(
				new FakeResultWrapper( [ $row ] ),
				new FakeResultWrapper( [] ),
				new FakeResultWrapper( [ $row ] ),
				new FakeResultWrapper( [] )
			);
		$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$filterLookup = $this->getLookup( $db, 'foobar', $cache );

		// WAN cache is only used for global filters
		$global = true;
		$group = 'foo';

		// Both calls should result in a query
		$filterLookup->getAllActiveFiltersInGroup( $group, $global );
		$filterLookup->clearLocalCache();
		$filterLookup->purgeGroupWANCache( $group );
		// Avoid cache hits due to $TINY_POSTIVE (use +10 out of an abundance of caution)
		$fakeTime = microtime( true ) + 10;
		$cache->setMockTime( $fakeTime );
		$filterLookup->getAllActiveFiltersInGroup( $group, $global );
	}

	public function testValidConstructor() {
		$this->assertInstanceOf(
			FilterLookup::class,
			new FilterLookup(
				$this->createMock( ILoadBalancer::class ),
				$this->createMock( WANObjectCache::class ),
				$this->createMock( CentralDBManager::class )
			)
		);
	}

	/**
	 * @param stdClass $row
	 * @param stdClass[] $actionsRows
	 * @param ExistingFilter $expected
	 * @dataProvider provideRowsAndFilters
	 */
	public function testGetFilter( stdClass $row, array $actionsRows, ExistingFilter $expected ) {
		$db = $this->getDBWithMockRows( [ $row ], $actionsRows );
		$filterLookup = $this->getLookup( $db );

		$actual = $filterLookup->getFilter( $row->af_id, false );
		// Trigger the lazy-load mechanism
		$actual->getActions();
		$this->assertEquals( $expected, $actual );
	}

	/**
	 * @param stdClass $row
	 * @param stdClass[] $actionsRows
	 * @param ExistingFilter $expected
	 * @dataProvider provideRowsAndFilters
	 */
	public function testGetFilter_global( stdClass $row, array $actionsRows, ExistingFilter $expected ) {
		$db = $this->getDBWithMockRows( [ $row ], $actionsRows );
		$filterLookup = $this->getLookup( $db, 'central_wiki' );

		$actual = $filterLookup->getFilter( $row->af_id, true );
		// Trigger the lazy-load mechanism
		$actual->getActions();
		$this->assertEquals( $expected, $actual );
	}

	public function testGetFilter_notfound() {
		$db = $this->createMock( IDatabase::class );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );
		$db->method( 'selectRow' )->willReturn( false );
		$filterLookup = $this->getLookup( $db );

		$this->expectException( FilterNotFoundException::class );
		$filterLookup->getFilter( 42, false );
	}

	public function testGetFilter_globaldisabled() {
		$filterLookup = $this->getLookup();
		$this->expectException( CentralDBNotAvailableException::class );
		$filterLookup->getFilter( 42, true );
	}

	public function testGetAllActiveFiltersInGroup() {
		$data = self::provideRowsAndFilters();
		$db = $this->getDBWithMockRows(
			array_column( $data, 'row' ),
			array_merge( ...array_column( $data, 'actions' ) )
		);
		$filterLookup = $this->getLookup( $db, false, null );

		$expected = [];
		/** @var Filter $filter */
		foreach ( array_column( $data, 'filter' ) as $filter ) {
			$expected[$filter->getID()] = $filter;
		}

		$actual = $filterLookup->getAllActiveFiltersInGroup( '_', false );
		foreach ( $actual as $filter ) {
			// Trigger the lazy-load mechanism
			$filter->getActions();
		}

		$this->assertEquals( $expected, $actual );
	}

	/**
	 * @param bool $isCentral
	 * @dataProvider provideIsCentral
	 */
	public function testGetAllActiveFiltersInGroup_global( bool $isCentral ) {
		$data = self::provideRowsAndFilters();
		$db = $this->getDBWithMockRows(
			array_column( $data, 'row' ),
			array_merge( ...array_column( $data, 'actions' ) )
		);
		$filterLookup = $this->getLookup( $db, 'some_db', null, $isCentral );

		$expected = [];
		/** @var Filter $filter */
		foreach ( array_column( $data, 'filter' ) as $filter ) {
			$key = 'global-' . $filter->getID();
			$expected[$key] = $filter;
		}

		$actual = $filterLookup->getAllActiveFiltersInGroup( '_', true );
		foreach ( $actual as $filter ) {
			// Trigger the lazy-load mechanism
			$filter->getActions();
		}

		$this->assertEquals( $expected, $actual );
	}
}
