<?php

namespace MediaWiki\Tests\Registration;

use Exception;
use InvalidArgumentException;
use LogicException;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Settings\SettingsBuilder;
use MediaWikiUnitTestCase;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Registration\ExtensionRegistry
 */
class ExtensionRegistryTest extends MediaWikiUnitTestCase {
	private const DATA_DIR = __DIR__ . '/../../../data/registration';

	/** @var array */
	private $restoreGlobals = [];

	/** @var array */
	private $unsetGlobals = [];

	protected function tearDown(): void {
		foreach ( $this->restoreGlobals as $key => $value ) {
			$GLOBALS[$key] = $value;
		}

		foreach ( $this->unsetGlobals as $var ) {
			unset( $GLOBALS[$var] );
		}

		parent::tearDown();
	}

	private function getRegistry(): ExtensionRegistry {
		$registry = new ExtensionRegistry();
		// Mock the global SettingsBuilder dependencies, as this is a unit test. And because SettingsBuilder
		// has a reverse dependency on the global ExtensionRegistry instance, it would throw an exception
		// because access to the global ExtensionRegistry instance is forbidden in unit tests.
		$registry->setSettingsBuilder( $this->createMock( SettingsBuilder::class ) );
		return $registry;
	}

	private function setGlobal( $key, $value ) {
		if ( isset( $GLOBALS[$key] ) ) {
			$this->restoreGlobals[$key] = $GLOBALS[$key];
		} else {
			$this->unsetGlobals[] = $key;
		}

		$GLOBALS[$key] = $value;
	}

	public function testQueue_invalid() {
		$this->setGlobal( 'wgExtensionInfoMTime', false );

		$registry = $this->getRegistry();
		$path = __DIR__ . '/doesnotexist.json';
		$this->expectException( Exception::class );
		$this->expectExceptionMessage( "file $path" );
		$registry->queue( $path );
	}

	public function testQueue() {
		$registry = $this->getRegistry();
		$path = self::DATA_DIR . "/good.json";
		$registry->queue( $path );
		$this->assertArrayHasKey(
			$path,
			$registry->getQueue()
		);
		$registry->clearQueue();
		$this->assertSame( [], $registry->getQueue() );
	}

	public function testLoadFromQueue_empty() {
		$registry = $this->getRegistry();
		$registry->loadFromQueue();
		$this->assertSame( [], $registry->getAllThings() );
	}

	public function testLoadFromQueue_late() {
		$registry = $this->getRegistry();
		$registry->finish();
		$registry->queue( self::DATA_DIR . "/good.json" );
		$this->expectException( LogicException::class );
		$this->expectExceptionMessage(
			"The following paths tried to load late: " . self::DATA_DIR . "/good.json" );
		$registry->loadFromQueue();
	}

	public function testLoadFromQueue() {
		$registry = $this->getRegistry();
		$registry->queue( self::DATA_DIR . "/good.json" );
		$registry->loadFromQueue();
		$this->assertArrayHasKey( 'FooBar', $registry->getAllThings() );
		$this->assertTrue( $registry->isLoaded( 'FooBar' ) );
		$this->assertTrue( $registry->isLoaded( 'FooBar', '*' ) );
		$this->assertSame( [ 'test' ], $registry->getAttribute( 'FooBarAttr' ) );
		$this->assertSame( [], $registry->getAttribute( 'NotLoadedAttr' ) );
	}

	public function testLoadFromQueueWithConstraintWithVersion() {
		$registry = $this->getRegistry();
		$registry->queue( self::DATA_DIR . "/good_with_version.json" );
		$registry->loadFromQueue();
		$this->assertTrue( $registry->isLoaded( 'FooBar', '>= 1.2.0' ) );
		$this->assertFalse( $registry->isLoaded( 'FooBar', '^1.3.0' ) );
	}

	public function testLoadFromQueueWithConstraintWithoutVersion() {
		$registry = $this->getRegistry();
		$registry->queue( self::DATA_DIR . "/good.json" );
		$registry->loadFromQueue();
		$this->expectException( LogicException::class );
		$registry->isLoaded( 'FooBar', '>= 1.2.0' );
	}

	public function testReadFromQueue_nonexistent() {
		$registry = $this->getRegistry();
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Unable to read' );
		$this->expectPHPError(
			E_WARNING,
			static function () use ( $registry ) {
				$registry->readFromQueue( [
					__DIR__ . '/doesnotexist.json' => 1
				] );
			}
		);
	}

	public function testExportExtractedDataNamespaceAlreadyDefined() {
		define( 'FOO_VALUE', 123 ); // Emulates overriding a namespace set in LocalSettings.php
		$registry = $this->getRegistry();
		$info = [ 'defines' => [ 'FOO_VALUE' => 456 ], 'globals' => [] ];
		$this->expectException( Exception::class );
		$this->expectExceptionMessage(
			"FOO_VALUE cannot be re-defined with 456 it has already been set with 123"
		);
		TestingAccessWrapper::newFromObject( $registry )->exportExtractedData( $info );
	}

	/**
	 * @dataProvider provideExportExtractedDataGlobals
	 */
	public function testExportExtractedDataGlobals( $desc, $before, $globals, $expected ) {
		// Set globals for test
		if ( $before ) {
			foreach ( $before as $key => $value ) {
				// mw prefixed globals does not exist normally
				if ( str_starts_with( $key, 'mw' ) ) {
					$GLOBALS[$key] = $value;
				} else {
					$this->setGlobal( $key, $value );
				}
			}
		}

		$info = [
			'globals' => $globals,
			'callbacks' => [],
			'defines' => [],
			'credits' => [],
			'attributes' => [],
			'autoloaderPaths' => []
		];
		$registry = $this->getRegistry();
		TestingAccessWrapper::newFromObject( $registry )->exportExtractedData( $info );
		$result = array_intersect_key( $GLOBALS, $expected );
		$this->assertEquals( $expected, $result, $desc );

		// Remove mw prefixed globals
		if ( $before ) {
			foreach ( $before as $key => $value ) {
				if ( str_starts_with( $key, 'mw' ) ) {
					unset( $GLOBALS[$key] );
				}
			}
		}
	}

	public static function provideExportExtractedDataGlobals() {
		// "mwtest" prefix used instead of "$wg" to avoid potential conflicts
		return [
			[
				'Simple non-array values',
				[
					'mwtestFooBarConfig' => true,
					'mwtestFooBarConfig2' => 'string',
				],
				[
					'mwtestFooBarDefault' => 1234,
					'mwtestFooBarConfig' => false,
				],
				[
					'mwtestFooBarConfig' => true,
					'mwtestFooBarConfig2' => 'string',
					'mwtestFooBarDefault' => 1234,
				],
			],
			[
				'No global already set, simple array',
				null,
				[
					'mwtestDefaultOptions' => [
						'foobar' => true,
					]
				],
				[
					'mwtestDefaultOptions' => [
						'foobar' => true,
					]
				],
			],
			[
				'Global already set, simple array',
				[
					'mwtestDefaultOptions' => [
						'foobar' => true,
						'foo' => 'string'
					],
				],
				[
					'mwtestDefaultOptions' => [
						'barbaz' => 12345,
						'foobar' => false,
					],
				],
				[
					'mwtestDefaultOptions' => [
						'barbaz' => 12345,
						'foo' => 'string',
						'foobar' => true,
					],
				]
			],
			[
				'Global already set, 1d array that appends',
				[
					'mwAvailableRights' => [
						'foobar',
						'foo'
					],
				],
				[
					'mwAvailableRights' => [
						'barbaz',
					],
				],
				[
					'mwAvailableRights' => [
						'barbaz',
						'foobar',
						'foo',
					],
				]
			],
			[
				'Global already set, array with integer keys',
				[
					'mwNamespacesFoo' => [
						100 => true,
						102 => false
					],
				],
				[
					'mwNamespacesFoo' => [
						100 => false,
						500 => true,
						ExtensionRegistry::MERGE_STRATEGY => 'array_plus',
					],
				],
				[
					'mwNamespacesFoo' => [
						100 => true,
						102 => false,
						500 => true,
					],
				]
			],
			[
				'No global already set, $wgHooks',
				[
					'wgHooks' => [],
				],
				[
					'wgHooks' => [
						'FooBarEvent' => [
							'FooBarClass::onFooBarEvent'
						],
						ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive'
					],
				],
				[
					'wgHooks' => [
						'FooBarEvent' => [
							'FooBarClass::onFooBarEvent'
						],
					],
				],
			],
			[
				'Global already set, $wgHooks',
				[
					'wgHooks' => [
						'FooBarEvent' => [
							'FooBarClass::onFooBarEvent'
						],
						'BazBarEvent' => [
							'FooBarClass::onBazBarEvent',
						],
					],
				],
				[
					'wgHooks' => [
						'FooBarEvent' => [
							'BazBarClass::onFooBarEvent',
						],
						ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive',
					],
				],
				[
					'wgHooks' => [
						'FooBarEvent' => [
							'FooBarClass::onFooBarEvent',
							'BazBarClass::onFooBarEvent',
						],
						'BazBarEvent' => [
							'FooBarClass::onBazBarEvent',
						],
					],
				],
			],
			[
				'Global already set, $wgGroupPermissions',
				[
					'wgGroupPermissions' => [
						'sysop' => [
							'something' => true,
						],
						'user' => [
							'somethingtwo' => true,
						]
					],
				],
				[
					'wgGroupPermissions' => [
						'customgroup' => [
							'right' => true,
						],
						'user' => [
							'right' => true,
							'somethingtwo' => false,
							'nonduplicated' => true,
						],
						ExtensionRegistry::MERGE_STRATEGY => 'array_plus_2d',
					],
				],
				[
					'wgGroupPermissions' => [
						'customgroup' => [
							'right' => true,
						],
						'sysop' => [
							'something' => true,
						],
						'user' => [
							'somethingtwo' => true,
							'right' => true,
							'nonduplicated' => true,
						]
					],
				],
			],
			[
				'False local setting should not be overridden (T100767)',
				[
					'mwtestT100767' => false,
				],
				[
					'mwtestT100767' => true,
				],
				[
					'mwtestT100767' => false,
				],
			],
			[
				'test array_replace_recursive',
				[
					'mwtestJsonConfigs' => [
						'JsonZeroConfig' => [
							'namespace' => 480,
							'nsName' => 'Zero',
							'isLocal' => false,
							'remote' => [
								'username' => 'foo',
							],
						],
					],
				],
				[
					'mwtestJsonConfigs' => [
						'JsonZeroConfig' => [
							'isLocal' => true,
						],
						ExtensionRegistry::MERGE_STRATEGY => 'array_replace_recursive',
					],
				],
				[
					'mwtestJsonConfigs' => [
						'JsonZeroConfig' => [
							'namespace' => 480,
							'nsName' => 'Zero',
							'isLocal' => false,
							'remote' => [
								'username' => 'foo',
							],
						],
					],
				],
			],
			[
				'global is null before',
				[
					'NullGlobal' => null,
				],
				[
					'NullGlobal' => 'not-null'
				],
				[
					'NullGlobal' => null
				],
			],
			[
				'provide_default passive case',
				[
					'wgFlatArray' => [],
				],
				[
					'wgFlatArray' => [
						1,
						ExtensionRegistry::MERGE_STRATEGY => 'provide_default'
					],
				],
				[
					'wgFlatArray' => []
				],
			],
			[
				'provide_default active case',
				[
				],
				[
					'wgFlatArray' => [
						1,
						ExtensionRegistry::MERGE_STRATEGY => 'provide_default'
					],
				],
				[
					'wgFlatArray' => [ 1 ]
				],
			],
			[
				'a configured value should not turn into a default null value',
				[
					'ArrayValue' => [],
					'BooleanValue' => false,
					'IntegerValue' => 0,
					'StringValue' => '',
				],
				[
					'ArrayValue' => null,
					'BooleanValue' => null,
					'IntegerValue' => null,
					'StringValue' => null,
				],
				[
					'ArrayValue' => [],
					'BooleanValue' => false,
					'IntegerValue' => 0,
					'StringValue' => '',
				],
			],
			[
				'a configured value should not turn into a default empty array value',
				[
					'BooleanValue' => false,
					'IntegerValue' => 0,
					'NullValue' => null,
					'StringValue' => '',
				],
				[
					'BooleanValue' => [],
					'IntegerValue' => [],
					'NullValue' => [],
					'StringValue' => [],
				],
				[
					'BooleanValue' => false,
					'IntegerValue' => 0,
					'NullValue' => null,
					'StringValue' => '',
				],
			],
		];
	}

	public function testSetAttributeForTest() {
		$registry = $this->getRegistry();
		$registry->queue( self::DATA_DIR . "/good.json" );
		$registry->loadFromQueue();
		// Check that it worked
		$this->assertSame( [ 'test' ], $registry->getAttribute( 'FooBarAttr' ) );
		$reset = $registry->setAttributeForTest( 'FooBarAttr', [ 'override' ] );
		// overridden properly
		$this->assertSame( [ 'override' ], $registry->getAttribute( 'FooBarAttr' ) );
		ScopedCallback::consume( $reset );
		// reset properly
		$this->assertSame( [ 'test' ], $registry->getAttribute( 'FooBarAttr' ) );
	}

	public function testSetAttributeForTestDuplicate() {
		$registry = $this->getRegistry();
		$reset1 = $registry->setAttributeForTest( 'foo', [ 'val1' ] );
		$this->expectException( Exception::class );
		$this->expectExceptionMessage( "The attribute 'foo' has already been overridden" );
		$reset2 = $registry->setAttributeForTest( 'foo', [ 'val2' ] );
	}

	public function testGetLazyLoadedAttribute() {
		$registry = TestingAccessWrapper::newFromObject(
			$this->getRegistry()
		);
		// Verify the registry is absolutely empty
		$this->assertSame( [], $registry->getLazyLoadedAttribute( 'FooBarBaz' ) );
		// Indicate what paths should be checked for the lazy attributes
		$registry->queue( self::DATA_DIR . "/attribute.json" );
		$registry->loadFromQueue();
		// Set in attribute.json
		$this->assertEquals(
			[ 'buzz' => true ],
			$registry->getLazyLoadedAttribute( 'FooBarBaz' )
		);
		// Still return an array if nothing was set
		$this->assertSame(
			[],
			$registry->getLazyLoadedAttribute( 'NotSetAtAll' )
		);

		// Test test overrides
		$reset = $registry->setAttributeForTest( 'FooBarBaz',
			[ 'lightyear' => true ] );
		$this->assertEquals(
			[ 'lightyear' => true ],
			$registry->getLazyLoadedAttribute( 'FooBarBaz' )
		);
	}

}
