<?php

namespace MediaWiki\Tests\Unit\Permissions;

use DatabaseTestHelper;
use LinkCache;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Page\PageStore;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWikiUnitTestCase;
use ReflectionClass;
use ReflectionMethod;
use RuntimeException;
use Title;
use UnexpectedValueException;
use WANObjectCache;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;

/**
 * @coversDefaultClass \MediaWiki\Permissions\RestrictionStore
 */
class RestrictionStoreTest extends MediaWikiUnitTestCase {
	use DummyServicesTrait;

	private const DEFAULT_RESTRICTION_TYPES = [ 'create', 'edit', 'move', 'upload' ];

	/**
	 * @param array $expectedCalls E.g.:
	 *   [
	 *     DB_REPLICA => [
	 *       'update' => callback, # to be called exactly once
	 *       'delete' => [ callback, 2 ], # to be called exactly twice
	 *       'select' => [ callback, -1 ], # may be called 0 or more times
	 *     ],
	 *   ]
	 * @return ILoadBalancer
	 */
	private function newMockLoadBalancer( array $expectedCalls = [] ): ILoadBalancer {
		if ( !isset( $expectedCalls[DB_REPLICA] ) ) {
			$expectedCalls[DB_REPLICA] = [];
		}
		$expectedCalls[DB_REPLICA]['decodeExpiry'] = [
			static function ( string $expiry ): string {
				return $expiry;
			},
			-1
		];

		$dbs = [];
		foreach ( $expectedCalls as $index => $calls ) {
			$dbs[$index] = $this->createNoOpMock( IDatabase::class, array_keys( $calls ) );
			foreach ( $calls as $method => $callback ) {
				$count = 1;
				if ( is_array( $callback ) ) {
					[ $callback, $count ] = $callback;
				}
				$dbs[$index]->expects( $count < 0 ? $this->any() : $this->exactly( $count ) )
					->method( $method )->willReturnCallback( $callback );
			}
		}

		$lb = $this->createMock( ILoadBalancer::class, [ 'getConnectionRef' ] );
		$lb->method( 'getConnectionRef' )->willReturnCallback(
			function ( int $index ) use ( $dbs ): IDatabase {
				$this->assertArrayHasKey( $index, $dbs );
				return $dbs[$index];
			}
		);

		return $lb;
	}

	private function newRestrictionStore( array $options = [] ): RestrictionStore {
		return new RestrictionStore(
			new ServiceOptions( RestrictionStore::CONSTRUCTOR_OPTIONS, $options + [
				'NamespaceProtection' => [],
				'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ],
				'RestrictionTypes' => self::DEFAULT_RESTRICTION_TYPES,
				'SemiprotectedRestrictionLevels' => [ 'autoconfirmed' ],
			] ),
			$this->createNoOpMock( WANObjectCache::class ),
			$this->newMockLoadBalancer( $options['db'] ?? [] ),
			// @todo test that these calls work correctly
			$this->createNoOpMock( LinkCache::class, [ 'addLinkObj', 'getGoodLinkFieldObj' ] ),
			$this->getDummyCommentStore(),
			$this->createHookContainer( isset( $options['hookFn'] )
				? [ 'TitleGetRestrictionTypes' => $options['hookFn'] ]
				: [] ),
			$this->createNoOpMock( PageStore::class )
		);
	}

	private static function newImproperPageIdentity(
		int $ns, string $dbKey, $wikiId = PageIdentity::LOCAL
	): PageIdentity {
		// PageIdentityValue doesn't allow negative namespaces, and Title::exists accesses services
		// for hooks, so we need another solution to unit-test PageIdentity objects with negative
		// namespaces (until they cease to exist).
		return new class( $ns, $dbKey, $wikiId ) extends PageReferenceValue implements PageIdentity {
			public function getId( $wikiId = PageIdentity::LOCAL ): int {
				throw new RuntimeException;
			}

			public function canExist(): bool {
				return false;
			}

			public function exists(): bool {
				return false;
			}
		};
	}

	/**
	 * @covers ::<public>
	 * @dataProvider provideNonLocalPage
	 */
	public function testNonLocalPage( string $method, ...$extraArgs ) {
		$this->expectException( PreconditionException::class );
		$this->expectExceptionMessage( 'otherwiki' );

		$obj = $this->newRestrictionStore();
		$page = new PageIdentityValue( 1, NS_MAIN, 'X', 'otherwiki' );
		$obj->$method( $page, ...$extraArgs );
	}

	public function provideNonLocalPage() {
		// We programmatically get all public methods whose first parameter is a PageIdentity. This
		// way we'll make sure to include any new methods that are added in the future.
		$ret = [];
		$methods = ( new ReflectionClass( RestrictionStore::class ) )
			->getMethods( ReflectionMethod::IS_PUBLIC );
		foreach ( $methods as $method ) {
			$params = $method->getParameters();
			if ( !$params[0]->hasType() || $params[0]->getType()->getName() !== PageIdentity::class ) {
				continue;
			}
			$ret[$method->getName()] = [ $method->getName() ];

			foreach ( array_slice( $params, 1 ) as $param ) {
				// Extra required arguments
				if ( $param->isOptional() ) {
					break;
				}
				switch ( $param->getType()->getName() ) {
					case 'string':
						$ret[$method->getName()][] = 'x';
						break;
					case 'array':
						$ret[$method->getName()][] = [];
						break;
					default:
						throw new UnexpectedValueException(
							"{$param->getType()->getName} type not supported" );
				}
			}
		}

		return $ret;
	}

	/**
	 * @covers ::getRestrictions
	 * @dataProvider provideGetRestrictions
	 */
	public function testGetRestrictions(
		array $expected,
		PageIdentity $page,
		string $action,
		?array $rowsToLoad,
		array $options = []
	): void {
		$obj = $this->newRestrictionStore( $options );
		if ( is_array( $rowsToLoad ) ) {
			$obj->loadRestrictionsFromRows( $page, $rowsToLoad );
		}
		$this->assertSame( $expected, $obj->getRestrictions( $page, $action ) );
	}

	public function provideGetRestrictions(): array {
		$all = $this->provideGetAllRestrictions();
		$ret = [];

		foreach ( $all as $name => $arr ) {
			[ $expected, $page ] = $arr;

			$actions = array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'vaporize' ],
				array_keys( $expected ) );
			foreach ( $actions as $action ) {
				$ret["$name ($action)"] =
					array_merge(
						[ $expected[$action] ?? [], $page, $action ],
						array_slice( $arr, 2 )
					);
			}
		}

		return $ret;
	}

	/**
	 * @covers ::__construct
	 * @covers ::getAllRestrictions
	 * @covers ::loadRestrictions
	 * @covers ::loadRestrictionsFromRows
	 * @dataProvider provideGetAllRestrictions
	 */
	public function testGetAllRestrictions(
		array $expected, PageIdentity $page, ?array $rowsToLoad, array $options = []
	): void {
		$obj = $this->newRestrictionStore( $options );
		if ( is_array( $rowsToLoad ) ) {
			$obj->loadRestrictionsFromRows( $page, $rowsToLoad );
		}
		$this->assertSame( $expected, $obj->getAllRestrictions( $page ) );
	}

	public function provideGetAllRestrictions(): array {
		return [
			'Special page' => [
				[],
				self::newImproperPageIdentity( NS_SPECIAL, 'X' ),
				null,
			],
			'Media page' => [
				[],
				self::newImproperPageIdentity( NS_MEDIA, 'X' ),
				null,
			],
			'Simple existing unprotected page' => [
				[ 'edit' => [], 'move' => [] ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[],
			],
			'Simple existing protected page' => [
				[ 'edit' => [ 'sysop', 'bureaucrat' ], 'move' => [ 'sysop' ] ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop,bureaucrat',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
				],
			],
			'Protection type not allowed' => [
				[ 'edit' => [ 'sysop' ] ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
				],
				[ 'RestrictionTypes' => [ 'create', 'edit', 'upload' ] ],
			],
			'Expired protection' => [
				[ 'edit' => [], 'move' => [ 'sysop' ] ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => '20200101000000', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
				],
			],
		];
	}

	/**
	 * @covers ::getRestrictionExpiry
	 * @dataProvider provideGetRestrictionExpiry
	 */
	public function testGetRestrictionExpiry(
		?string $expected,
		PageIdentity $page,
		string $action,
		?array $rowsToLoad,
		array $options = []
	): void {
		$obj = $this->newRestrictionStore( $options );
		if ( is_array( $rowsToLoad ) ) {
			$obj->loadRestrictionsFromRows( $page, $rowsToLoad );
		}
		$this->assertSame( $expected, $obj->getRestrictionExpiry( $page, $action ) );
	}

	public function provideGetRestrictionExpiry(): array {
		return [
			'Special page' => [
				null,
				self::newImproperPageIdentity( NS_SPECIAL, 'X' ),
				'edit',
				null,
			],
			'Media page' => [
				null,
				self::newImproperPageIdentity( NS_MEDIA, 'X' ),
				'edit',
				null,
			],
			'Simple existing unprotected page' => [
				'infinity',
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				'edit',
				[],
			],
			'Simple existing protected page (edit)' => [
				'20760101000000',
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				'edit',
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => '20760101000000', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
				],
			],
			'Simple existing protected page (move)' => [
				'20670101000000',
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				'move',
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => '20670101000000', 'pr_cascade' => 0 ],
				],
			],
			'Simple existing protected page (unrecognized)' => [
				null,
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				'unrecognized',
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
				],
			],
			'Simple existing expired protected page (edit)' => [
				'infinity',
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				'edit',
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => '20160101000000', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
				],
			],
			'Simple existing expired protected page (move)' => [
				'infinity',
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				'move',
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => '20170101000000', 'pr_cascade' => 0 ],
				],
			],
			'Simple existing expired protected page (unrecognized)' => [
				null,
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				'unrecognized',
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => '20170101000000', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'unrecognized', 'pr_level' => 'sysop',
						'pr_expiry' => '20170101000000', 'pr_cascade' => 0 ],
				],
			],
		];
	}

	/**
	 * @covers ::getCreateProtection
	 * @covers ::getCreateProtectionInternal
	 * @dataProvider provideGetCreateProtection
	 */
	public function testGetCreateProtection(
		?array $expected, PageIdentity $page, $return, array $options = []
	): void {
		if ( $page->canExist() && !$page->exists() ) {
			$options['db'] = [ DB_REPLICA => [ 'selectRow' =>
				function (
					$table, $vars, $conds, string $fname, $options = [], $join_conds = []
				) use ( $page, $return ) {
					$options = (array)$options;
					$options['LIMIT'] = 1;
					$db = new DatabaseTestHelper( __CLASS__ );
					$sql = trim( preg_replace( '/\s+/', ' ', $db->selectSQLText(
						$table, $vars, $conds, $fname, $options, $join_conds ) ) );
					$this->assertSame(
						'SELECT pt_user,pt_expiry,pt_create_perm,comment_pt_reason.comment_text ' .
						'AS pt_reason_text,comment_pt_reason.comment_data AS pt_reason_data,' .
						'comment_pt_reason.comment_id AS pt_reason_cid ' .
						'FROM protected_titles JOIN comment comment_pt_reason ON ' .
						'((comment_pt_reason.comment_id = pt_reason_id)) ' .
						"WHERE pt_namespace = {$page->getNamespace()} AND " .
						"pt_title = '{$page->getDBkey()}' LIMIT 1",
						$sql );

					return is_array( $return ) ? (object)$return : $return;
				}
			] ];
		}
		$obj = $this->newRestrictionStore( $options );
		$this->assertSame( $expected, $obj->getCreateProtection( $page ) );
	}

	public function provideGetCreateProtection(): array {
		$ret = [
			'Special page' => [ null, self::newImproperPageIdentity( NS_SPECIAL, 'X' ), null ],
			'Media page' => [ null, self::newImproperPageIdentity( NS_MEDIA, 'X' ), null ],
			'Existing page' => [ null, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), null ],
			'Unprotected' => [ null, PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), false ],
		];
		$protectedTests = [
			'sysop' => 'editprotected',
			'autoconfirmed' => 'editsemiprotected',
			'editprotected' => 'editprotected',
			'editsemiprotected' => 'editsemiprotected',
			'custom' => 'custom',
		];
		foreach ( $protectedTests as $db => $returned ) {
			$ret["Protected ($db)"] = [
				[
					'user' => 123,
					'expiry' => 'infinity',
					'permission' => $returned,
					'reason' => 'reason',
				],
				PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ),
				[
					'pt_user' => 123,
					'pt_expiry' => 'infinity',
					'pt_create_perm' => $db,
					'pt_reason_id' => 456,
					'pt_reason_data' => '{}',
					'pt_reason_text' => 'reason',
				],
			];
		}

		return $ret;
	}

	/**
	 * @covers ::deleteCreateProtection
	 * @dataProvider provideDeleteCreateProtection
	 */
	public function testDeleteCreateProtection( PageIdentity $page ): void {
		$obj = $this->newRestrictionStore( [ 'db' => [ DB_PRIMARY => [ 'delete' =>
			function ( string $table, array $where, string $method ) use ( $page ): bool {
				$this->assertSame( 'protected_titles', $table );
				$this->assertSame(
					[ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
					$where
				);
				return true;
			}
		] ] ] );

		$obj->deleteCreateProtection( $page );
	}

	public function provideDeleteCreateProtection(): array {
		return [
			// Most of these don't actually make sense, but test current behavior regardless.
			'Special page' => [ self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ],
			'Media page' => [ self::newImproperPageIdentity( NS_MEDIA, 'X' ) ],
			'Nonexistent page' => [ PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ) ],
			'Existing page' => [ PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ) ],
		];
	}

	/**
	 * @covers ::isSemiProtected
	 * @dataProvider provideIsSemiProtected
	 */
	public function testIsSemiProtected(
		bool $expected, ?array $rowsToLoad, array $options = []
	): void {
		$page = $options['page'] ?? PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' );
		$obj = $this->newRestrictionStore( $options );
		if ( is_array( $rowsToLoad ) ) {
			$obj->loadRestrictionsFromRows( $page, $rowsToLoad );
		}
		$this->assertSame( $expected, $obj->isSemiProtected( $page, 'edit' ) );
	}

	public function provideIsSemiProtected(): array {
		return [
			'Special page' => [ false, null,
				[ 'page' => self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ] ],
			'Media page' => [ false, null,
				[ 'page' => self::newImproperPageIdentity( NS_MEDIA, 'X' ) ] ],
			'Unprotected page' => [
				false,
				[ (object)[ 'pr_type' => 'move', 'pr_level' => 'autoconfirmed',
					'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
			],
			'Semiprotected page' => [
				true,
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
				],
			],
			'Fully protected page' => [
				false,
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'autoconfirmed',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
				],
			],
			'No semiprotection configured' => [
				false,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'SemiprotectedRestrictionLevels' => [] ],
			],
			'Config with editsemiprotected' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'SemiprotectedRestrictionLevels' => [ 'editsemiprotected' ] ],
			],
			'Data with editsemiprotected' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'editsemiprotected',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
			],
			'Config and data with editsemiprotected' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'editsemiprotected',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'SemiprotectedRestrictionLevels' => [ 'editsemiprotected' ] ],
			],
			'Semiprotection plus other protection level' => [
				false,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed,superman',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop', 'superman' ] ],
			],
			'Two semiprotections' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed,superman',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop', 'superman' ],
					'SemiprotectedRestrictionLevels' => [ 'autoconfirmed', 'superman' ] ],
			],
		];
	}

	/**
	 * @covers ::isProtected
	 * @dataProvider provideIsProtected
	 */
	public function testIsProtected(
		bool $expected, ?array $rowsToLoad, array $options = []
	): void {
		$page = $options['page'] ?? PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' );
		$action = $options['action'] ?? '';
		$obj = $this->newRestrictionStore( $options );
		if ( is_array( $rowsToLoad ) ) {
			$obj->loadRestrictionsFromRows( $page, $rowsToLoad );
		}
		$this->assertSame( $expected, $obj->isProtected( $page, $action ) );
	}

	public function provideIsProtected(): array {
		return [
			'Special page' => [ true, null,
				[ 'page' => self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ] ],
			'Media page' => [ false, null,
				[ 'page' => self::newImproperPageIdentity( NS_MEDIA, 'X' ) ] ],
			'Unprotected page' => [ false, [] ],
			'Semiprotected page' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
			],
			'Fully protected page' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
			],
			'Protected against empty string' => [
				false,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => '',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
			],
			'Unrecognized protection' => [
				false,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'unrecognized',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
			],
			'Unrecognized plus recognized protection' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop,unrecognized',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
			],

			'Check unrecognized protection type' => [
				false,
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
					(object)[ 'pr_type' => 'unrecognized', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
				],
				[ 'action' => 'unrecognized' ],
			],
			'Check custom protection type' => [
				true,
				[ (object)[ 'pr_type' => 'custom', 'pr_level' => 'sysop',
					'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], ],
				[ 'action' => 'custom', 'RestrictionTypes' =>
					array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'custom' ] ) ],
			],
			'Check custom protection level' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'custom',
					'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], ],
				[ 'action' => 'edit', 'RestrictionLevels' =>
					[ '', 'autoconfirmed', 'sysop', 'custom' ] ],
			],

			'Check edit protection of edit-protected page' => [
				true,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'action' => 'edit' ],
			],
			'Check move protection of edit-protected page' => [
				false,
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'action' => 'move' ],
			],
			'Check move protection of move-protected page' => [
				true,
				[ (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'action' => 'move' ],
			],
			'Check edit protection of move-protected page' => [
				false,
				[ (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ],
				[ 'action' => 'edit' ],
			],
			'Check edit protection of edit- and move-protected page' => [
				true,
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
				],
				[ 'action' => 'edit' ],
			],
			'Check move protection of edit- and move-protected page' => [
				true,
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => '0' ],
				],
				[ 'action' => 'move' ],
			],
		];
	}

	/**
	 * @covers ::listApplicableRestrictionTypes
	 * @dataProvider provideListApplicableRestrictionTypes
	 */
	public function testListApplicableRestrictionTypes(
		array $expected, PageIdentity $page, array $options = []
	): void {
		$obj = $this->newRestrictionStore( $options );

		$this->assertSame( $expected, $obj->listApplicableRestrictionTypes( $page ) );
	}

	public function provideListApplicableRestrictionTypes(): array {
		$expandedRestrictions = array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'liquify' ] );
		return [
			'Special page' => [
				[],
				self::newImproperPageIdentity( NS_SPECIAL, 'X' ),
			],
			'Media page' => [
				[],
				self::newImproperPageIdentity( NS_MEDIA, 'X' ),
			],
			'Nonexistent page' => [
				[ 'create' ],
				PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ),
			],
			'Existing page' => [
				[ 'edit', 'move' ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
			],
			'Nonexistent file' => [
				[ 'create', 'upload' ],
				PageIdentityValue::localIdentity( 0, NS_FILE, 'X' ),
			],
			'Existing file' => [
				[ 'edit', 'move', 'upload' ],
				PageIdentityValue::localIdentity( 1, NS_FILE, 'X' ),
			],

			'Nonexistent page with no create' => [
				[],
				PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ),
				[ 'RestrictionTypes' => [ 'edit', 'move', 'upload' ] ],
			],
			'Existing page with no move' => [
				[ 'edit' ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[ 'RestrictionTypes' => [ 'create', 'edit', 'upload' ] ],
			],
			'Nonexistent file with no upload' => [
				[ 'create' ],
				PageIdentityValue::localIdentity( 0, NS_FILE, 'X' ),
				[ 'RestrictionTypes' => [ 'create', 'edit', 'move' ] ],
			],

			'Special page with extra type' => [
				[],
				self::newImproperPageIdentity( NS_SPECIAL, 'X' ),
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],
			'Media page with extra type' => [
				[],
				self::newImproperPageIdentity( NS_MEDIA, 'X' ),
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],
			'Nonexistent page with extra type' => [
				[ 'create' ],
				PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ),
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],
			'Existing page with extra type' => [
				[ 'edit', 'move', 'liquify' ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],
			'Nonexistent file with extra type' => [
				[ 'create', 'upload' ],
				PageIdentityValue::localIdentity( 0, NS_FILE, 'X' ),
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],
			'Existing file with extra type' => [
				[ 'edit', 'move', 'upload', 'liquify' ],
				PageIdentityValue::localIdentity( 1, NS_FILE, 'X' ),
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],

			'Hook' => [
				[ 'move', 'liquify' ],
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[ 'hookFn' => function ( Title $title, array &$types ): bool {
					self::assertEquals( Title::castFromPageIdentity(
						PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ) ), $title );
					self::assertSame( [ 'edit', 'move' ], $types );

					$types = [ 'move', 'liquify' ];

					return false;
				} ],
			],
			'Hook not run for special page' => [
				[],
				self::newImproperPageIdentity( NS_SPECIAL, 'X' ),
				[ 'hookFn' => function () {
					$this->fail( 'Should be unreached' );
				} ],
			],
			'Hook not run for media page' => [
				[],
				self::newImproperPageIdentity( NS_MEDIA, 'X' ),
				[ 'hookFn' => function () {
					$this->fail( 'Should be unreached' );
				} ],
			],
		];
	}

	/**
	 * @covers ::listAllRestrictionTypes
	 * @dataProvider provideListAllRestrictionTypes
	 */
	public function testListAllRestrictionTypes(
		array $expected, array $args, array $options = []
	) {
		$obj = $this->newRestrictionStore( $options );
		$this->assertSame( $expected, $obj->listAllRestrictionTypes( ...$args ) );
	}

	public function provideListAllRestrictionTypes() {
		$expandedRestrictions = array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'solidify' ] );
		return [
			'Exists' => [ [ 'edit', 'move', 'upload' ], [ true ] ],
			'Default is exists' => [ [ 'edit', 'move', 'upload' ], [] ],
			'Nonexistent' => [ [ 'create', 'upload' ], [ false ] ],

			'Exists with extra restriction type' => [
				[ 'edit', 'move', 'upload', 'solidify' ],
				[ true ],
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],
			'Default is exists with extra restriction type' => [
				[ 'edit', 'move', 'upload', 'solidify' ],
				[],
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],
			'Nonexistent with extra restriction type' => [
				[ 'create', 'upload' ],
				[ false ],
				[ 'RestrictionTypes' => $expandedRestrictions ],
			],

			'Exists with no edit' => [
				[ 'move', 'upload' ],
				[ true ],
				[ 'RestrictionTypes' => [ 'create', 'move', 'upload' ] ],
			],
			'Exists with only create' => [
				[],
				[ true ],
				[ 'RestrictionTypes' => [ 'create' ] ],
			],
			'Nonexistent with no create' => [
				[ 'upload' ],
				[ false ],
				[ 'RestrictionTypes' => [ 'edit', 'move', 'upload', 'solidify' ] ],
			],
			'Nonexistent with no upload' => [
				[ 'create' ],
				[ false ],
				[ 'RestrictionTypes' => [ 'create', 'edit', 'move', 'solidify' ] ],
			],
			'Nonexistent with no create or upload' => [
				[],
				[ false ],
				[ 'RestrictionTypes' => [ 'edit', 'move', 'solidify' ] ],
			],
		];
	}

	/**
	 * @covers ::areRestrictionsLoaded
	 * @covers ::loadRestrictionsFromRows
	 * @dataProvider provideAreRestrictionsLoaded
	 */
	public function testAreRestrictionsLoaded(
		bool $expected, PageIdentity $page, ?array $rowsToLoad = null, array $options = []
	): void {
		$obj = $this->newRestrictionStore( $options );
		if ( is_array( $rowsToLoad ) ) {
			$obj->loadRestrictionsFromRows( $page, $rowsToLoad );
		}
		$this->assertSame( $expected, $obj->areRestrictionsLoaded( $page ) );
	}

	public function provideAreRestrictionsLoaded(): array {
		return [
			'Special page' => [ false, self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ],
			'Media page' => [ false, self::newImproperPageIdentity( NS_MEDIA, 'X' ) ],
			'Regular page' => [ false, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ) ],
			'Regular page with no restrictions' =>
				[ true, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), [] ],
			'Regular page with restrictions' => [
				true,
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
					'pr_expiry' => 'infinity', 'pr_cascade' => 0 ] ],
			],
		];
	}

	/**
	 * @covers ::areRestrictionsCascading
	 * @covers ::loadRestrictionsFromRows
	 * @dataProvider provideAreRestrictionsCascading
	 */
	public function testAreRestrictionsCascading(
		bool $expected, PageIdentity $page, ?array $rowsToLoad, array $options = []
	): void {
		$obj = $this->newRestrictionStore( $options );
		if ( is_array( $rowsToLoad ) ) {
			$obj->loadRestrictionsFromRows( $page, $rowsToLoad );
		}
		$this->assertSame( $expected, $obj->areRestrictionsCascading( $page ) );
	}

	public function provideAreRestrictionsCascading(): array {
		return [
			'Special page' => [ false, self::newImproperPageIdentity( NS_SPECIAL, 'X' ), null ],
			'Media page' => [ false, self::newImproperPageIdentity( NS_MEDIA, 'X' ), null ],
			'Regular page with no restrictions' =>
				[ false, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), [] ],
			'Regular page with restrictions' => [
				false,
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
					'pr_expiry' => 'infinity', 'pr_cascade' => 0 ] ],
			],
			'Regular page with cascading restrictions' => [
				true,
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
					'pr_expiry' => 'infinity', 'pr_cascade' => 1 ] ],
			],
			'Regular page with some cascading restrictions and some not' => [
				true,
				PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ),
				[
					(object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 0 ],
					(object)[ 'pr_type' => 'move', 'pr_level' => 'sysop',
						'pr_expiry' => 'infinity', 'pr_cascade' => 1 ],
				],
			],
		];
	}

	/**
	 * @covers ::flushRestrictions
	 */
	public function testFlushRestrictions(): void {
		$obj = $this->newRestrictionStore();
		$page = PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' );
		$this->assertFalse( $obj->areRestrictionsLoaded( $page ) );
		$obj->loadRestrictionsFromRows( $page, [] );
		$this->assertTrue( $obj->areRestrictionsLoaded( $page ) );
		$obj->flushRestrictions( $page );
		$this->assertFalse( $obj->areRestrictionsLoaded( $page ) );
	}

	/**
	 * @covers ::getCascadeProtectionSources
	 * @covers ::getCascadeProtectionSourcesInternal
	 */
	public function testGetCascadeProtectionSources() {
		$obj = $this->newRestrictionStore( [ 'db' => [ DB_REPLICA => [ 'select' =>
			static function () {
				return [
					(object)[ 'pr_page' => 1, 'page_namespace' => NS_MAIN, 'page_title' => 'test',
						'pr_expiry' => 'infinity', 'pr_type' => 'edit', 'pr_level' => 'Sysop' ]
				];
			}
		] ] ] );

		$page = PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' );
		[ $sources, $restrictions ] = $obj->getCascadeProtectionSources( $page );
		$this->assertCount( 1, $sources );
		$this->assertArrayHasKey( 'edit', $restrictions );
	}

}
