<?php

namespace MediaWiki\Tests\HookContainer {

	use Error;
	use InvalidArgumentException;
	use LogicException;
	use MediaWiki\HookContainer\HookContainer;
	use MediaWiki\HookContainer\StaticHookRegistry;
	use MediaWiki\Tests\Unit\DummyServicesTrait;
	use MediaWikiUnitTestCase;
	use stdClass;
	use UnexpectedValueException;
	use Wikimedia\ScopedCallback;
	use Wikimedia\TestingAccessWrapper;

	class HookContainerTest extends MediaWikiUnitTestCase {
		use DummyServicesTrait;

		private const HANDLER_FUNCTION = FooClass::class . '::fooStaticMethod';

		private const HANDLER_REGISTRATION = [
			'extensionPath' => __DIR__,
			'handler' => [
				'name' => 'TestHookHandler',
				'class' => 'FooExtension\Hooks'
			]
		];

		/**
		 * Creates a new hook container with StaticHookRegistry and empty ObjectFactory
		 *
		 * @param null|array $oldHooks
		 * @param null|array $newHooks
		 * @param array $deprecatedHooksArray
		 *
		 * @return HookContainer
		 */
		private function newHookContainer(
			$oldHooks = null, $newHooks = null, $deprecatedHooksArray = []
		) {
			if ( $oldHooks === null ) {
				$oldHooks[ 'FoobarActionComplete' ][] = static function ( &$called ) {
					$called[] = 11;
				};
			}
			if ( $newHooks === null ) {
				$handler = [ 'handler' => [
					'name' => 'FooExtension-FooActionHandler',
					'class' => 'FooExtension\\Hooks',
					'services' => [] ]
				];
				$newHooks = [ 'FooActionComplete' => [ $handler ] ];
			}

			// fake object factory
			$objectFactory = $this->getDummyObjectFactory(
				[
					'SomeService' => static function () {
						return new stdClass();
					}
				]
			);

			$registry = new StaticHookRegistry( $oldHooks, $newHooks, $deprecatedHooksArray );
			$hookContainer = new HookContainer( $registry, $objectFactory );
			return $hookContainer;
		}

		public static function provideRegister() {
			return [
				'function' => [ 'strtoupper', 'strtoupper' ],
				'object' => [ new \FooExtension\Hooks(), 'FooExtension\Hooks::onFooActionComplete' ],
				'object and method' => [ [ new FooClass(), 'fooMethod' ], 'MediaWiki\Tests\HookContainer\FooClass::fooMethod' ],
				'extension' => [
					self::HANDLER_REGISTRATION,
					'FooExtension\Hooks::onFooActionComplete'
				],
				'callable referencing a class that extends an unknown class' => [
					[ 'MediaWiki\Tests\BrokenClass', 'aMethod' ],
					'MediaWiki\Tests\BrokenClass::aMethod'
				],
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::register
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @dataProvider provideRegister
		 */
		public function testRegister( $handler, $expected ) {
			$hookContainer = $this->newHookContainer( [], [
				'FooActionComplete' => [ $handler ]
			], [] );

			$handlers = $hookContainer->getHandlerDescriptions( 'FooActionComplete' );

			$this->assertSame( $expected, $handlers[0] );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::getHandlerDescriptions
		 */
		public function testGetHandlerDescriptions() {
			$handler = 'MediaWiki\Tests\HookContainer\FooClass::fooStaticMethod';
			$expected = [ $handler ];

			$hookContainer = $this->newHookContainer(
				[
					'BarActionComplete' => [ $handler ]
				],
				[
					'FooActionComplete' => [ $handler ]
				], [] );

			$this->assertSame( $expected, $hookContainer->getHandlerDescriptions( 'FooActionComplete' ) );
			$this->assertSame( $expected, $hookContainer->getHandlerDescriptions( 'BarActionComplete' ) );

			// Fire the hooks, then check again
			$hookContainer->run( 'FooActionComplete', [ 1 ] );
			$hookContainer->run( 'BarActionComplete', [ 1 ] );

			$this->assertSame( $expected, $hookContainer->getHandlerDescriptions( 'FooActionComplete' ) );
			$this->assertSame( $expected, $hookContainer->getHandlerDescriptions( 'BarActionComplete' ) );
		}

		/**
		 * Values returned: hook, handlersToRegister, expectedReturn
		 */
		public static function provideGetHandlerDescriptions() {
			return [
				'NoHandlersExist' => [
					'MWTestHook',
					null,
					0,
					0
				],
				'SuccessfulHandlerReturn' => [
					'FooActionComplete',
					[
						'handler' => [
							'name' => 'FooExtension-FooActionHandler',
							'class' => 'FooExtension\\Hooks',
							'services' => [],
						],
					],
					1,
					1
				],
				'SkipDeprecated' => [
					'FooActionCompleteDeprecated',
					[
						'handler' => [
							'name' => 'FooExtension-FooActionHandler',
							'class' => 'FooExtension\\Hooks',
							'services' => [],
						],
						'deprecated' => true,
					],
					1,
					0
				],
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::salvage
		 */
		public function testSalvage() {
			$firstHookContainer = $this->newHookContainer( [], [] );
			$secondHookContainer = $this->newHookContainer();

			$firstHookContainer->register( 'TestHook', self::HANDLER_FUNCTION );

			$secondHookContainer->salvage( $firstHookContainer );

			$this->assertTrue( $secondHookContainer->isRegistered( 'TestHook' ) );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::salvage
		 */
		public function testSalvageThrows() {
			$firstHookContainer = $this->newHookContainer( [], [] );
			$secondHookContainer = $this->newHookContainer();

			$secondHookContainer->register( 'TestHook', self::HANDLER_FUNCTION );

			$this->expectException( LogicException::class );
			$secondHookContainer->salvage( $firstHookContainer );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::isRegistered
		 * @covers \MediaWiki\HookContainer\HookContainer::register
		 * @covers \MediaWiki\HookContainer\HookContainer::clear
		 */
		public function testIsRegistered() {
			$hookContainer = $this->newHookContainer(
				[ 'XyzHook' => [ self::HANDLER_FUNCTION ] ],
				[ 'MWTestHook' => [ self::HANDLER_REGISTRATION ] ],
			);

			$hookContainer->register( 'AbcHook', self::HANDLER_FUNCTION );

			$this->assertFalse( $hookContainer->isRegistered( 'XyzzyHook' ) );

			$this->assertTrue( $hookContainer->isRegistered( 'XyzHook' ) );
			$this->assertTrue( $hookContainer->isRegistered( 'MWTestHook' ) );
			$this->assertTrue( $hookContainer->isRegistered( 'AbcHook' ) );

			$hookContainer->clear( 'AbcHook' );
			$hookContainer->clear( 'XyzHook' );
			$hookContainer->clear( 'MWTestHook' );

			$this->assertFalse( $hookContainer->isRegistered( 'XyzHook' ) );
			$this->assertFalse( $hookContainer->isRegistered( 'MWTestHook' ) );
			$this->assertFalse( $hookContainer->isRegistered( 'AbcHook' ) );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::scopedRegister
		 */
		public function testScopedRegister() {
			$hookContainer = $this->newHookContainer();
			$reset = $hookContainer->scopedRegister( 'MWTestHook', [ new FooClass(),
				'fooMethod'
			] );
			$this->assertTrue( $hookContainer->isRegistered( 'MWTestHook' ) );
			ScopedCallback::consume( $reset );
			$this->assertFalse( $hookContainer->isRegistered( 'MWTestHook' ) );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::scopedRegister
		 */
		public function testScopedRegisterTwoHandlers() {
			$hookContainer = $this->newHookContainer();
			$called1 = $called2 = false;
			$reset1 = $hookContainer->scopedRegister( 'MWTestHook',
				static function () use ( &$called1 ) {
					$called1 = true;
				}
			);
			$reset2 = $hookContainer->scopedRegister( 'MWTestHook',
				static function () use ( &$called2 ) {
					$called2 = true;
				}
			);
			$hookContainer->run( 'MWTestHook' );
			$this->assertTrue( $called1 );
			$this->assertTrue( $called2 );

			$called1 = $called2 = false;
			ScopedCallback::consume( $reset1 );
			$hookContainer->run( 'MWTestHook' );
			$this->assertFalse( $called1 );
			$this->assertTrue( $called2 );

			$called1 = $called2 = false;
			ScopedCallback::consume( $reset2 );
			$hookContainer->run( 'MWTestHook' );
			$this->assertFalse( $called1 );
			$this->assertFalse( $called2 );
		}

		/**
		 * Register handlers with scopedRegister() and register()
		 * @covers \MediaWiki\HookContainer\HookContainer::scopedRegister
		 */
		public function testHandlersRegisteredWithScopedRegisterAndRegister() {
			$hookContainer = $this->newHookContainer();
			$numCalls = 0;
			$hookContainer->register( 'MWTestHook', static function () use ( &$numCalls ) {
				$numCalls++;
			} );
			$reset = $hookContainer->scopedRegister( 'MWTestHook', static function () use ( &$numCalls ) {
				$numCalls++;
			} );

			// handlers registered in 2 different ways
			$this->assertCount( 2, $hookContainer->getHandlerDescriptions( 'MWTestHook' ) );
			$hookContainer->run( 'MWTestHook' );
			$this->assertEquals( 2, $numCalls );

			// Remove one of the handlers that increments $called
			ScopedCallback::consume( $reset );
			$this->assertCount( 1, $hookContainer->getHandlerDescriptions( 'MWTestHook' ) );

			$numCalls = 0;
			$hookContainer->run( 'MWTestHook' );
			$this->assertSame( 1, $numCalls );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::getHandlerDescriptions
		 * @covers \MediaWiki\HookContainer\HookContainer::getHandlerCallbacks
		 * @dataProvider provideGetHandlerDescriptions
		 */
		public function testGetHandlers(
			string $hook,
			?array $handlerToRegister,
			int $expectedDescriptions,
			int $expectedCallbacks
		) {
			if ( $handlerToRegister ) {
				$hooks = [ $hook => [ $handlerToRegister ] ];
			} else {
				$hooks = [];
			}
			$fakeDeprecatedHooks = [
				'FooActionCompleteDeprecated' => [ 'deprecatedVersion' => '1.35' ]
			];
			$hookContainer = $this->newHookContainer( [], $hooks, $fakeDeprecatedHooks );

			$descriptions = $hookContainer->getHandlerDescriptions( $hook );
			$this->assertCount(
				$expectedDescriptions,
				$descriptions,
				'getHandlerDescriptions()'
			);

			$this->expectDeprecationAndContinue( '/getHandlerCallbacks/' );
			$callbacks = $hookContainer->getHandlerCallbacks( $hook );
			$this->assertCount(
				$expectedCallbacks,
				$callbacks,
				'getHandlerCallbacks()'
			);

			foreach ( $callbacks as $clbk ) {
				$this->assertIsCallable( $clbk );
			}
		}

		public static function provideRunConfigured() {
			$fooObj = new FooClass();
			$closure = static function ( &$count ) {
				$count++;
			};
			$extra	= 10;
			return [
				// Callables
				'Function' => [ 'fooGlobalFunction' ],
				'Object and method' => [ [ $fooObj, 'fooMethod' ] ],
				'Class name and static method' => [ [ 'MediaWiki\Tests\HookContainer\FooClass', 'fooStaticMethod' ] ],
				'static method' => [ 'MediaWiki\Tests\HookContainer\FooClass::fooStaticMethod' ],
				'Closure' => [ $closure ],

				// Shorthand
				'Object' => [ $fooObj ],

				// No-ops
				'NOOP' => [ HookContainer::NOOP, 1 ],
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @dataProvider provideRunConfigured
		 */
		public function testRunConfigured( $handler, $expectedCount = 2 ) {
			$hookContainer = $this->newHookContainer( [ 'Increment' => [ $handler ] ] );

			$count = 1;
			$hookValue = $hookContainer->run( 'Increment', [ &$count ] );
			$this->assertTrue( $hookValue );
			$this->assertSame( $expectedCount, $count );
		}

		public static function provideRunDeprecatedStyle() {
			$fooObj = new FooClass();
			$closure = static function ( &$count ) {
				$count++;
			};
			$extra	= 10;
			return [
				// Handlers with extra data attached
				'static method with extra data' => [
					[ 'MediaWiki\Tests\HookContainer\FooClass::fooStaticMethodWithExtra', $extra ],
					11
				],
				'Object and method with extra data' => [ [ [ $fooObj, 'fooMethodWithExtra' ], $extra ], 11 ],
				'Function extra data' => [ [ 'fooGlobalFunctionWithExtra', $extra ], 11 ],
				'Closure with extra data' => [
					[
						static function ( int $inc, &$count ) {
							$count += $inc;
						},
						10
					],
					11
				],

				// No-ops
				'empty array' => [ [], 1 ],
				'null' => [ null, 1 ],
				'false' => [ false, 1 ],

				// Strange edge cases
				'Object in array without method' => [ [ $fooObj ] ],
				'Callable in array' => [ [ [ $fooObj, 'fooMethod' ] ] ],
				'Closure in array with no extra data' => [ [ $closure ] ],
				'Function in array' => [ [ 'fooGlobalFunction' ] ],
				'Function in array in array' => [ [ [ 'fooGlobalFunction' ] ] ],
				'static method as array in array' => [
					[ [ 'MediaWiki\Tests\HookContainer\FooClass', 'fooStaticMethod' ] ]
				],
				'Object and fully-qualified non-static method' => [
					[ $fooObj, 'MediaWiki\Tests\HookContainer\FooClass::fooMethod' ]
				]
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @dataProvider provideRunDeprecatedStyle
		 */
		public function testRunDeprecatedStyle( $handler, $expectedCount = 2 ) {
			$hookContainer = $this->newHookContainer( [ 'Increment' => [ $handler ] ] );

			$this->expectDeprecationAndContinue( '/Deprecated handler style/' );

			$count = 1;
			$hookValue = $hookContainer->run( 'Increment', [ &$count ] );
			$this->assertTrue( $hookValue );
			$this->assertSame( $expectedCount, $count );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @dataProvider provideRunConfigured
		 * @dataProvider provideRunExtensionHook
		 */
		public function testRegisterAndRun( $handler, $expectedCount = 2 ) {
			$hookContainer = $this->newHookContainer( [], [] );
			$hookContainer->register( 'Increment', $handler );

			$count = 1;
			$hookValue = $hookContainer->run( 'Increment', [ &$count ] );
			$this->assertTrue( $hookValue );
			$this->assertSame( $expectedCount, $count );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @dataProvider provideRunDeprecatedStyle
		 */
		public function testRegisterDeprecatedStyle( $handler ) {
			$hookContainer = $this->newHookContainer( [], [] );

			// Force the handler list to be initialized, so register() will normalize the handler immediately.
			$hookContainer->run( 'Increment' );

			$this->expectDeprecationAndContinue( '/Deprecated handler style for hook/' );
			$hookContainer->register( 'Increment', $handler );
		}

		/**
		 * Values returned: hook, handler, handler arguments, options
		 */
		public static function provideRegisterAndRunCallback() {
			$fooObj = new FooClass();
			return [
				// Callables
				'Function' => [ 'fooGlobalFunction' ],
				'Object and method' => [ [ $fooObj, 'fooMethod' ] ],
				'Class name and static method' => [ [ 'MediaWiki\Tests\HookContainer\FooClass', 'fooStaticMethod' ] ],
				'static method' => [ 'MediaWiki\Tests\HookContainer\FooClass::fooStaticMethod' ],
				'Closure' => [
					static function ( &$count ) {
						$count++;
					}
				],

				// Extension-style handler
				'Extension handler' => [ self::HANDLER_REGISTRATION ],

				// NOTE: hook handlers with extra data are not supported for callbacks!
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::getHandlerCallbacks
		 * @dataProvider provideRegisterAndRunCallback
		 */
		public function testRegisterAndRunCallback( $handler, $expectedCount = 2 ) {
			$hookContainer = $this->newHookContainer( [], [] );
			$hookContainer->register( 'Increment', $handler );

			$this->expectDeprecationAndContinue( '/getHandlerCallbacks/' );

			$count = 1;
			foreach ( $hookContainer->getHandlerCallbacks( 'Increment' ) as $callback ) {
				$callback( $count );
			}
			$this->assertSame( $expectedCount, $count );
		}

		/**
		 * Values returned: hook, handler, handler arguments, options
		 */
		public static function provideRunExtensionHook() {
			return [
				[ self::HANDLER_REGISTRATION ],
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @dataProvider provideRunExtensionHook
		 */
		public function testRunExtensionHook( array $handler, $expectedCount = 1 ) {
			$hookContainer = $this->newHookContainer( [], [ 'X\\Y::Increment' => [ $handler ] ] );

			$count = 0;
			$hookValue = $hookContainer->run( 'X\\Y::Increment', [ &$count ] );
			$this->assertTrue( $hookValue );
			$this->assertSame( $expectedCount, $count );
		}

		public static function provideRunFailsWithNoService() {
			$handler = self::HANDLER_REGISTRATION;
			$handler['handler']['services'] = [ 'SomeService' ];

			yield [ $handler ];

			$handler = self::HANDLER_REGISTRATION;
			$handler['handler']['optional_services'] = [ 'SomeService' ];

			yield [ $handler ];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @dataProvider provideRunFailsWithNoService
		 */
		public function testRunFailsWithNoService( array $handler ) {
			$hookContainer = $this->newHookContainer( [], [ 'Increment' => [ $handler ] ] );

			$this->expectException( UnexpectedValueException::class );

			$count = 0;
			$options = [ 'noServices' => true ];
			$hookContainer->run( 'Increment', [ &$count ], $options );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 */
		public function testRunOrder() {
			$configured1 = static function ( &$seq ) {
				$seq[] = 'configured1';
			};

			$configured2 = static function ( &$seq ) {
				$seq[] = 'configured2';
			};

			$registered = static function ( &$seq ) {
				$seq[] = 'registered';
			};

			$hookContainer = $this->newHookContainer(
				[ 'Append' => [ $configured1, $configured2 ] ],
				[ 'Append' => [ self::HANDLER_REGISTRATION ] ]
			);

			$hookContainer->register( 'Append', $registered );

			$seq = [ 'start' ];
			$hookContainer->run( 'Append', [ &$seq ] );

			$expected = [ 'start', 'configured1', 'configured2', 'FooExtension', 'registered' ];
			$this->assertSame( $expected, $seq );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * Test HookContainer::run() when the handler returns false
		 */
		public function testRunAbort() {
			$handler1 = [ 'handler' => [
				'name' => 'FooExtension-Abort1',
				'class' => 'FooExtension\\AbortHooks1'
			] ];
			$handler2 = [ 'handler' => [
				'name' => 'FooExtension-Abort2',
				'class' => 'FooExtension\\AbortHooks2'
			] ];
			$handler3 = [ 'handler' => [
				'name' => 'FooExtension-Abort3',
				'class' => 'FooExtension\\AbortHooks3'
			] ];
			$hooks = [
				'Abort' => [
					$handler1,
					$handler2,
					$handler3
				]
			];
			$hookContainer = $this->newHookContainer( [], $hooks );
			$called = [];
			$ret = $hookContainer->run( 'Abort', [ &$called ] );
			$this->assertFalse( $ret );
			$this->assertArrayEquals( [ 1, 2 ], $called );
		}

		public static function provideRegisterDeprecated() {
			// registering a deprecated hook should trigger a warning
			yield [ [ 'deprecatedVersion' => '1.0' ], true ];

			// the silent flag should suppress the warning
			yield [ [ 'deprecatedVersion' => '1.0', 'silent' => true ], false ];
		}

		/**
		 * Test HookContainer::register() successfully registers even when hook is deprecated.
		 * @covers \MediaWiki\HookContainer\HookContainer::register
		 * @dataProvider provideRegisterDeprecated
		 */
		public function testRegisterDeprecated( array $deprecationInfo, bool $expectWarning ) {
			$deprecations = [ 'FooActionComplete' => $deprecationInfo ];

			// Assert we don't get any deprecation warnings during initialization!
			$this->newHookContainer(
				[ 'FooActionComplete' => [ self::HANDLER_FUNCTION ] ],
				[ 'FooActionComplete' => [ self::HANDLER_REGISTRATION ] ],
				$deprecations
			);

			// Make a hook container with no hooks registered yet
			$hookContainer = $this->newHookContainer( [], [], $deprecations );

			// Expected deprecation?
			if ( $expectWarning ) {
				$this->expectDeprecationAndContinue( '/FooActionComplete hook/' );
			}

			$hookContainer->register( 'FooActionComplete', self::HANDLER_FUNCTION );

			// Deprecated hooks should still be functional!
			$this->assertTrue( $hookContainer->isRegistered( 'FooActionComplete' ) );
		}

		/**
		 * Test running deprecated hooks from $wgHooks with the deprecation declared in HookContainer.
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @dataProvider provideRegisterDeprecated
		 */
		public function testRunConfiguredDeprecated( array $deprecationInfo ) {
			$hookContainer = $this->newHookContainer(
				[ 'Increment' => [ self::HANDLER_FUNCTION ] ],
				[],
				[ 'Increment' => $deprecationInfo ]
			);

			// No warning expected when running the hook!
			// Deprecated hooks should still be functional!
			$count = 0;
			$hookContainer->run( 'Increment', [ &$count ] );
			$this->assertSame( 1, $count );
		}

		/**
		 * Test running deprecated hooks from $wgHooks with the deprecation passed in the options parameter.
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @dataProvider provideRegisterDeprecated
		 */
		public function testRunConfiguredDeprecatedWithOption( array $deprecationInfo, bool $expectWarning ) {
			// Assert we don't get any deprecation warnings during initialization!
			$hookContainer = $this->newHookContainer(
				[ 'Increment' => [ self::HANDLER_FUNCTION ] ],
			);

			// Expected deprecation?
			if ( $expectWarning ) {
				$this->expectDeprecationAndContinue( '/Use of Increment hook/' );
			}

			// Deprecated hooks should still be functional!
			$count = 0;
			$hookContainer->run( 'Increment', [ &$count ], $deprecationInfo );
			$this->assertSame( 1, $count );
		}

		/**
		 * Test running deprecated hooks from extensions.
		 *
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 */
		public function testRunHandlerObjectDeprecated() {
			$deprecationInfo = [ 'deprecatedVersion' => '1.0' ];

			// If the handler acknowledges deprecation, it should be skipped
			$knownDeprecated = self::HANDLER_REGISTRATION + [ 'deprecated' => true ];

			$hookContainer = $this->newHookContainer(
				[],
				[ 'Increment' => [ self::HANDLER_REGISTRATION, $knownDeprecated ] ],
				[ 'Increment' => $deprecationInfo ]
			);

			// Deprecated hooks should be functional, the handle that acknowledges deprecation should be skipped.
			// We do not expect deprecation warnings here. They are covered by emitDeprecationWarnings()
			$count = 0;
			$hookContainer->run( 'Increment', [ &$count ] );
			$this->assertSame( 1, $count );
		}

		/**
		 * Test running deprecated hooks from extensions.
		 *
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 */
		public function testRunHandlerObjectDeprecatedWithOption() {
			$deprecationInfo = [ 'deprecatedVersion' => '1.0' ];

			// If the handler acknowledges deprecation, it should be skipped
			$knownDeprecated = self::HANDLER_REGISTRATION + [ 'deprecated' => true ];

			$hookContainer = $this->newHookContainer(
				[],
				[ 'Increment' => [ self::HANDLER_REGISTRATION, $knownDeprecated ] ],
				[ 'Increment' => $deprecationInfo ]
			);

			// We do expect deprecation warnings when the 'deprecationVersion' key is provided in the $options parameter.
			$this->expectDeprecationAndContinue( '/Use of Increment hook/' );

			// Deprecated hooks should be functional, the handle that acknowledges deprecation should be skipped.
			$count = 0;
			$hookContainer->run( 'Increment', [ &$count ], $deprecationInfo );
			$this->assertSame( 1, $count );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::getHookNames
		 */
		public function testGetHookNames() {
			$fooHandler = [ 'handler' => [
				'name' => 'FooHookHandler',
				'class' => 'FooExtension\\Hooks'
			] ];

			$noop = static function () {
				// noop
			};

			$container = $this->newHookContainer(
				[
					'A' => [ $noop ]
				],
				[
					'B' => [ $fooHandler ]
				]
			);

			$container->register( 'C', 'strtoupper' );

			// Ask for a few hooks that have no handlers.
			// Negative caching inside HookHandler should not cause them to be returned from getHookNames
			$container->isRegistered( 'X' );

			$this->expectDeprecationAndContinue( '/getHandlerCallbacks/' );
			$container->getHandlerCallbacks( 'Y' );

			$this->assertArrayEquals( [ 'A', 'B', 'C' ], $container->getHookNames() );

			// make sure we are getting each hook name only once
			$container->register( 'B', 'strtoupper' );
			$container->register( 'A', 'strtoupper' );

			$this->assertArrayEquals( [ 'A', 'B', 'C' ], $container->getHookNames() );
		}

		/**
		 * Values returned: hook, handlersToRegister, options
		 */
		public static function provideRunErrors() {
			// XXX: should also fail: non-function string, empty array
			return [
				'return a string' => [
					static function () {
						return 'string';
					},
					[]
				],
				'abort even though not abortable' => [
					static function () {
						return false;
					},
					[ 'abortable' => false ]
				],
				'callable referencing a class that extends an unknown class' => [
					[ 'MediaWiki\\Tests\\BrokenClass2', 'aMethod' ],
					[],
					Error::class
				],
			];
		}

		/**
		 * @dataProvider provideRunErrors
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * Test errors thrown with invalid handlers
		 */
		public function testRunErrors( $handler, $options, $expected = UnexpectedValueException::class ) {
			$hookContainer = $this->newHookContainer();
			$hookContainer->register( 'MWTestHook', $handler );

			$this->filterDeprecated( '/^Returning a string from a hook handler/' );
			$this->expectException( $expected );
			$hookContainer->run( 'MWTestHook', [], $options );
		}

		/**
		 * Values returned: hook, handlersToRegister, options
		 */
		public static function provideRegisterErrors() {
			// XXX: should also fail: non-function string, empty array
			return [
				'a number' => [ 123 ],
				'non-callable string' => [ 'a, b, c' ],
				'array referencing an unknown method' => [ [ self::class, 'thisMethodDoesNotExist' ] ],
				'empty string' => [ '' ],
				'zero' => [ 0 ],
				'true' => [ true ],
				'callable referencing an unknown class' => [
					[ 'FooExtension\DoesNotExist', 'onFoo' ],
					'FooExtension\DoesNotExist::onFoo'
				],
			];
		}

		/**
		 * @dataProvider provideRegisterErrors
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @covers \MediaWiki\HookContainer\HookContainer::register
		 */
		public function testRegisterErrors( $badHandler ) {
			$hookContainer = $this->newHookContainer();

			// Force the handler list to be initialized, so register() will normalize the handler immediately.
			$hookContainer->run( 'MWTestHook' );

			$this->expectException( InvalidArgumentException::class );
			$hookContainer->register( 'MWTestHook', $badHandler );
		}

		/**
		 * @dataProvider provideRegisterErrors
		 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 */
		public function testRunWithBadHandlers( $badHandler ) {
			$goodHandler = self::HANDLER_FUNCTION;
			$hookContainer = $this->newHookContainer( [ 'MWTestHook' => [ $badHandler, $goodHandler ] ] );

			// Bad handlers from the constructor should fail silently
			$count = 0;
			$hookContainer->run( 'MWTestHook', [ &$count ] );

			$this->assertSame( 1, $count );
		}

		public static function provideEmitDeprecationWarnings() {
			yield 'Deprecated extension hook' => [
				'$oldHooks' => [],
				'$newHooks' => [ self::HANDLER_REGISTRATION ],
				'$deprecationInfo' => [ 'deprecatedVersion' => '1.35' ],
				'$expectWarning' => true,
			];

			yield 'Deprecated extension hook, silent' => [
				'$oldHooks' => [],
				'$newHooks' => [ self::HANDLER_REGISTRATION ],
				'$deprecationInfo' => [ 'deprecatedVersion' => '1.35', 'silent' => true ],
				'$expectWarning' => false,
			];

			yield 'Deprecated extension hook, acknowledged' => [
				'$oldHooks' => [],
				'$newHooks' => [ self::HANDLER_REGISTRATION + [ 'deprecated' => true ] ],
				'$deprecationInfo' => [ 'deprecatedVersion' => '1.35' ],
				'$expectWarning' => false,
			];

			yield 'Deprecated configured hook' => [
				'$oldHooks' => [ self::HANDLER_FUNCTION ],
				'$newHooks' => [],
				'$deprecationInfo' => [ 'deprecatedVersion' => '1.35' ],
				'$expectWarning' => false, // NOTE: Currently expected to be ignored. This may change.
			];

			yield 'Deprecated configured hook, silent' => [
				'$oldHooks' => [ self::HANDLER_FUNCTION ],
				'$newHooks' => [],
				'$deprecationInfo' => [ 'deprecatedVersion' => '1.35', 'silent' => true ],
				'$expectWarning' => false,
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::emitDeprecationWarnings
		 * @dataProvider provideEmitDeprecationWarnings
		 */
		public function testEmitDeprecationWarnings( $oldHandlers, $newHandlers, $deprecationInfo, $expectWarning ) {
			$hookContainer = $this->newHookContainer(
				[ 'FooActionComplete' => $oldHandlers ],
				[ 'FooActionComplete' => $newHandlers ],
				[ 'FooActionComplete' => $deprecationInfo ]
			);

			if ( $expectWarning ) {
				$this->expectDeprecationAndContinue( '/Hook FooActionComplete was deprecated/' );
			}

			$hookContainer->emitDeprecationWarnings();
			$this->addToAssertionCount( 1 );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::emitDeprecationWarnings
		 */
		public function testEmitDeprecationWarningsSilent() {
			$hooks = [
				'FooActionComplete' => [
					[
						'handler' => 'fooGlobalFunction',
						'extensionPath' => 'fake-extension.json'
					]
				]
			];
			$deprecatedHooksArray = [
				'FooActionComplete' => [
					'deprecatedVersion' => '1.35',
					'silent' => true
				]
			];
			$hookContainer = $this->newHookContainer( [], $hooks, $deprecatedHooksArray );
			$hookContainer->emitDeprecationWarnings();
			$this->assertTrue( true );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::isRegistered
		 * @covers \MediaWiki\HookContainer\HookContainer::getHandlerCallbacks
		 * @covers \MediaWiki\HookContainer\HookContainer::register
		 * @covers \MediaWiki\HookContainer\HookContainer::clear
		 */
		public function testClear() {
			$increment = [ new \FooExtension\Hooks(), 'onIncrement' ];

			$hookContainer = $this->newHookContainer(
				[ 'Increment' => [ $increment ], 'XyzHook' => [ self::HANDLER_FUNCTION ], ],
				[ 'Increment' => [ self::HANDLER_REGISTRATION ], 'FooActionComplete' => [ self::HANDLER_REGISTRATION ] ],
			);

			$hookContainer->register( 'AbcHook', self::HANDLER_FUNCTION );
			$hookContainer->register( 'Increment', static function ( &$count ) {
				$count++;
			} );

			// Check: all three handlers should be called initially.
			$count = 0;
			$hookContainer->run( 'Increment', [ &$count ] );
			$this->assertSame( 3, $count );

			$hookContainer->clear( 'Increment' );

			$this->assertFalse( $hookContainer->isRegistered( 'Increment' ) );
			$this->assertTrue( $hookContainer->isRegistered( 'AbcHook' ) );
			$this->assertTrue( $hookContainer->isRegistered( 'XyzHook' ) );
			$this->assertTrue( $hookContainer->isRegistered( 'FooActionComplete' ) );

			$this->assertCount( 0, $hookContainer->getHandlerDescriptions( 'Increment' ) );
			$this->assertNotEmpty( $hookContainer->getHandlerDescriptions( 'AbcHook' ) );
			$this->assertNotEmpty( $hookContainer->getHandlerDescriptions( 'FooActionComplete' ) );
			$this->assertNotEmpty( $hookContainer->getHandlerDescriptions( 'XyzHook' ) );

			// No more increment!
			$hookContainer->run( 'Increment', [ &$count ] );
			$this->assertSame( 3, $count );

			// When adding a handler again...
			$hookContainer->register( 'Increment', static function ( &$count ) {
				$count = 11;
			} );

			// ...the new handler should be called, but not the old ones.
			$count = 0;
			$hookContainer->run( 'Increment', [ &$count ] );
			$this->assertSame( 11, $count );
			$this->assertTrue( $hookContainer->isRegistered( 'Increment' ) );
		}

		public static function provideMayBeCallable() {
			yield 'function' => [
				'strtoupper',
			];
			yield 'closure' => [
				static function () {
					// noop
				},
			];
			yield 'object and method' => [
				[ new FooClass(), 'fooMethod' ],
			];
			yield 'static method as array' => [
				[ FooClass::class, 'fooStaticMethod', ],
			];
			yield 'static method as string' => [
				'MediaWiki\Tests\HookContainer\FooClass::fooStaticMethod',
			];
			yield 'callable referencing a class that extends an unknown class' => [
				[ 'MediaWiki\Tests\BrokenClass3', 'aMethod' ],
			];
		}

		public static function provideNotCallable() {
			yield 'object' => [
				new \FooExtension\Hooks(),
			];
			yield 'object and non-existing method' => [
				[ new FooClass(), 'noSuchMethod' ],
			];
			yield 'object and method and extra stuff' => [
				[ new FooClass(), 'fooMethod', 'extra', 'stuff' ],
			];
			yield 'object and method assoc' => [
				[ 'a' => new FooClass(), 'b' => 'fooMethod' ],
			];
			yield 'object and method nested in array' => [
				[ [ new FooClass(), 'fooMethod' ], 'whatever' ],
			];
			yield 'non-existing static method on existing class' => [
				'MediaWiki\Tests\HookContainer\FooClass::noSuchMethod',
			];
			yield 'global function with extra data in array' => [
				[ 'strtoupper', 'extra' ],
			];
			yield 'non-existing static method on existing class as array' => [
				[ FooClass::class, 'noSuchMethod' ],
			];
			yield 'non-function text' => [
				'just some text',
			];
			yield 'object in array with no method' => [
				[ new \FooExtension\Hooks() ],
			];
			yield 'callable referencing an unknown class' => [
				[ 'FooExtension\DoesNotExist', 'onFoo' ],
			];
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::mayBeCallable
		 * @dataProvider provideMayBeCallable
		 */
		public function testMayBeCallable_true( $v ) {
			$access = TestingAccessWrapper::newFromClass( HookContainer::class );
			$this->assertTrue( $access->mayBeCallable( $v ) );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::mayBeCallable
		 * @dataProvider provideNotCallable
		 */
		public function testMayBeCallable_false( $v ) {
			$access = TestingAccessWrapper::newFromClass( HookContainer::class );
			$this->assertFalse( $access->mayBeCallable( $v ) );
		}
	}

	// Mock class for different types of handler functions
	class FooClass {

		public function fooMethod( &$count ) {
			$count++;
			return true;
		}

		public function onIncrement( &$count ) {
			$count++;
		}

		public static function fooStaticMethod( &$count ) {
			$count++;
			return null;
		}

		public function fooMethodWithExtra( int $inc, &$count ) {
			$count += $inc;
			return true;
		}

		public static function fooStaticMethodWithExtra( int $inc, &$count ) {
			$count += $inc;
			return null;
		}

		public static function fooMethodReturnValueError() {
			return 'a string';
		}

		public static function onMWTestHook() {
			// noop
		}
	}

}

// Function in global namespace
namespace {

	function fooGlobalFunction( &$count ) {
		$count++;
		return true;
	}

	function fooGlobalFunctionWithExtra( $inc, &$count ) {
		$count += $inc;
	}

}

// Mock Extension
namespace FooExtension {

	class Hooks {

		public function onFooActionComplete() {
			return true;
		}

		public function onMWTest() {
			// noop
		}

		public function onIncrement( &$count ) {
			$count++;
		}

		public function onX_Y__Increment( &$count ) {
			$count++;
		}

		public function onAppend( &$list ) {
			$list[] = 'FooExtension';
		}
	}

	class AbortHooks1 {
		public function onAbort( &$called ) {
			$called[] = 1;
			return true;
		}
	}

	class AbortHooks2 {
		public function onAbort( &$called ) {
			$called[] = 2;
			return false;
		}
	}

	class AbortHooks3 {
		public function onAbort( &$called ) {
			$called[] = 3;
			return true;
		}
	}

}
