<?php

namespace MediaWiki\Parser\Parsoid;

use Language;
use LanguageCode;
use LanguageConverter;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\Languages\LanguageFactory;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\Parsoid\Config\PageConfig;
use MediaWiki\Parser\Parsoid\Config\PageConfigFactory;
use MediaWiki\Parser\Parsoid\Config\SiteConfig;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWikiUnitTestCase;
use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Bcp47Code\Bcp47Code;
use Wikimedia\Bcp47Code\Bcp47CodeValue;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\Parsoid;

/**
 * @covers MediaWiki\Parser\Parsoid\LanguageVariantConverter
 */
class LanguageVariantConverterUnitTest extends MediaWikiUnitTestCase {

	/** @dataProvider provideSetConfig */
	public function testSetConfig( bool $shouldPageConfigFactoryBeUsed ) {
		// Decide what should be called and what should not be
		$shouldParsoidBeUsed = true;
		$isLanguageConversionEnabled = true;

		// Set expected language codes
		// *These are all MediaWiki-internal codes*
		$pageBundleLanguageCode = 'zh';
		$titleLanguageCode = 'zh-hans';
		$targetLanguageCode = 'zh-hans';
		$sourceLanguageCode = null;

		// Create mocks
		$pageConfigMock = $this->getPageConfigMock();
		$pageBundleMock = $this->getPageBundleMock( $pageBundleLanguageCode );

		$languageVariantConverter = $this->getLanguageVariantConverter(
			$shouldParsoidBeUsed,
			$shouldPageConfigFactoryBeUsed,
			$isLanguageConversionEnabled,
			$pageBundleLanguageCode,
			null,
			$titleLanguageCode,
			$targetLanguageCode,
			$sourceLanguageCode
		);

		if ( !$shouldPageConfigFactoryBeUsed ) {
			$languageVariantConverter->setPageConfig( $pageConfigMock );
		}

		$languageFactoryMock = $this->getLanguageFactoryMock();
		$languageVariantConverter->convertPageBundleVariant(
			$pageBundleMock,
			new Bcp47CodeValue( $targetLanguageCode )
		);
	}

	public static function provideSetConfig() {
		yield 'PageConfigFactory should not be used if PageConfig is set' => [ false ];

		yield 'PageConfigFactory should be used if PageConfig is not set' => [ true ];
	}

	/** @dataProvider provideSourceLanguage */
	public function testSourceLanguage(
		?string $pageBundleLanguageCode,
		?string $titleLanguageCode,
		?string $contentLanguageOverride,
		?string $targetLanguageCode,
		?string $sourceLanguageCode,
		?string $expectedSourceCode
	) {
		// Decide what should be called and what should not be
		$shouldParsoidBeUsed = true;
		$shouldPageConfigFactoryBeUsed = true;
		$isLanguageConversionEnabled = true;

		// Set expected language codes
		$titleLanguageCode ??= 'en';
		$targetLanguageCode ??= $titleLanguageCode;

		// Create mocks
		if ( $pageBundleLanguageCode ) {
			$pageBundleMock = $this->getPageBundleMock( $pageBundleLanguageCode );
		} else {
			$pageBundleMock = $this->getPageBundleMockWithoutLanguage();
		}

		$languageVariantConverter = $this->getLanguageVariantConverter(
			$shouldParsoidBeUsed,
			$shouldPageConfigFactoryBeUsed,
			$isLanguageConversionEnabled,
			$pageBundleLanguageCode,
			$contentLanguageOverride,
			$titleLanguageCode,
			$targetLanguageCode, // expected target language
			$expectedSourceCode
		);

		$targetLanguage = new Bcp47CodeValue( $targetLanguageCode );
		$sourceLanguage = $sourceLanguageCode ? new Bcp47CodeValue( $sourceLanguageCode ) : null;

		$languageVariantConverter->convertPageBundleVariant( $pageBundleMock, $targetLanguage, $sourceLanguage );
	}

	public static function provideSourceLanguage() {
		yield 'content-language in PageBundle' => [
			'sr',    // PageBundle content-language
			null,    // Title PageLanguage
			null,    // PageLanguage override
			'sr-Cyrl', // target
			'sr-Cyrl', // explicit source
			'sr-Cyrl'  // expected source
		];
		yield 'content-language but no source language' => [
			'en',    // PageBundle content-language
			null,    // Title PageLanguage
			null,    // PageLanguage override
			'en',    // target
			null,    // explicit source
			null     // expected source
		];
		yield 'content-language is variant' => [
			'en-ca', // PageBundle content-language
			null,    // Title PageLanguage
			null,    // PageLanguage override
			'en',    // target
			null,    // explicit source
			'en-ca'  // expected source
		];
		yield 'Source variant is given' => [
			null,    // PageBundle content-language
			null,    // Title PageLanguage
			null,    // PageLanguage override
			'en',    // target
			'en-ca', // explicit source
			'en-ca'  // expected source
		];
		yield 'Source variant is a base language' => [
			null,    // PageBundle content-language
			null,    // Title PageLanguage
			null,    // PageLanguage override
			'en',    // target
			'en',    // explicit source
			null     // expected source
		];
		yield 'Page language override is variant' => [
			null,    // PageBundle content-language
			null,    // PageBundle content-language
			'en-ca', // PageLanguage override
			'en',    // target
			'en-ca', // explicit source
			'en-ca'  // expected source
		];
	}

	/** @dataProvider provideSiteConfiguration */
	public function testSiteConfiguration(
		bool $isLanguageConversionEnabled,
		bool $shouldParsoidBeUsed,
		bool $shouldPageConfigFactoryBeUsed
	) {
		// Set expected language codes
		$pageBundleLanguageCode = 'zh';
		$titleLanguageCode = 'zh-hans';
		$targetLanguageCode = 'zh-hans';
		$sourceLanguageCode = null;

		// Create mocks

		$pageBundleMock = $this->getPageBundleMock( $pageBundleLanguageCode );
		$languageVariantConverter = $this->getLanguageVariantConverter(
			$shouldParsoidBeUsed,
			$shouldPageConfigFactoryBeUsed,
			$isLanguageConversionEnabled,
			$pageBundleLanguageCode,
			null,
			$titleLanguageCode,
			$targetLanguageCode,
			$sourceLanguageCode
		);
		$languageFactoryMock = $this->getLanguageFactoryMock();
		$targetLanguage = $languageFactoryMock->getLanguage( $targetLanguageCode );
		$languageVariantConverter->convertPageBundleVariant( $pageBundleMock, $targetLanguage );
	}

	public static function provideSiteConfiguration() {
		$isLanguageConversionEnabled = false;
		$shouldParsoidBeUsed = false;
		$shouldPageConfigFactoryBeUsed = false;
		yield 'If language conversion is disabled, parsoid and page config factory should not be used' =>
			[ $isLanguageConversionEnabled, $shouldParsoidBeUsed, $shouldPageConfigFactoryBeUsed ];

		$isLanguageConversionEnabled = true;
		$shouldParsoidBeUsed = true;
		$shouldPageConfigFactoryBeUsed = true;
		yield 'If language conversion is enabled, parsoid and page config factory should be used' =>
			[ $isLanguageConversionEnabled, $shouldParsoidBeUsed, $shouldPageConfigFactoryBeUsed ];
	}

	/**
	 * @param bool $shouldParsoidBeUsed
	 * @param bool $shouldPageConfigFactoryBeUsed
	 * @param bool $isLanguageConversionEnabled
	 * @param string|null $pageBundleLanguageCode
	 * @param string|null $contentLanguageOverride
	 * @param string $titleLanguageCode
	 * @param string $targetLanguageCode
	 * @param string|null $sourceLanguageCode
	 *
	 * @return LanguageVariantConverter
	 */
	private function getLanguageVariantConverter(
		bool $shouldParsoidBeUsed,
		bool $shouldPageConfigFactoryBeUsed,
		bool $isLanguageConversionEnabled,
		?string $pageBundleLanguageCode,
		?string $contentLanguageOverride,
		string $titleLanguageCode,
		string $targetLanguageCode,
		?string $sourceLanguageCode
	): LanguageVariantConverter {
		// If Content language is set, use language from there,
		// If PageBundle language code is set, use that
		// Else, fallback to title page language
		$pageLanguageCode = $contentLanguageOverride ?? $pageBundleLanguageCode ?? $titleLanguageCode;
		// The page language code should not be a variant
		$pageLanguageCode = preg_replace( '/-.*$/', '', $pageLanguageCode );

		$shouldSiteConfigBeUsed = true;
		$pageIdentityValue = new PageIdentityValue( 1, NS_MAIN, 'hello_world', PageIdentity::LOCAL );

		// Create the necessary mocks
		$languageFactoryMock = $this->getLanguageFactoryMock();
		$pageLanguage = new Bcp47CodeValue( $pageLanguageCode );
		$sourceLanguage = $sourceLanguageCode ? new Bcp47CodeValue( $sourceLanguageCode ) : null;
		$targetLanguage = new Bcp47CodeValue( $targetLanguageCode );

		$pageConfigMock = $this->getPageConfigMock();
		$pageConfigFactoryMock = $this->getPageConfigFactoryMock(
			$shouldPageConfigFactoryBeUsed,
			// Expected arguments to PageConfigFactory mock
			[ $pageIdentityValue, null, null, null, $this->constraintEquals( $pageLanguage ) ],
			$pageConfigMock
		);
		$pageBundleMock = $this->getPageBundleMock( $pageBundleLanguageCode );
		$siteConfigMock = $this->getSiteConfigMock(
			$shouldSiteConfigBeUsed, $pageLanguage, $isLanguageConversionEnabled
		);
		$titleFactoryMock = $this->getTitleFactoryMock( $pageIdentityValue, $titleLanguageCode );

		$parsoidMock = $this->getParsoidMock(
			$shouldParsoidBeUsed,
			[
				$pageConfigMock,
				'variant',
				$pageBundleMock,
				$this->constraintEquals( [
					'variant' => [
						'source' => $sourceLanguage,
						'target' => $targetLanguage,
					]
				] )
			]
		);

		$languageVariantConverter = new LanguageVariantConverter(
			$pageIdentityValue,
			$pageConfigFactoryMock,
			$parsoidMock,
			$siteConfigMock,
			$titleFactoryMock,
			$this->getLanguageConverterFactoryMock(),
			$languageFactoryMock
		);

		if ( $contentLanguageOverride ) {
			$languageVariantConverter->setPageLanguageOverride(
				$languageFactoryMock->getLanguage( $contentLanguageOverride )
			);
		}

		return $languageVariantConverter;
	}

	// Mock methods follow

	/**
	 * @param bool $shouldBeCalled
	 * @param array $arguments
	 * @param PageConfig $pageConfig
	 *
	 * @return MockObject|PageConfigFactory
	 */
	private function getPageConfigFactoryMock( bool $shouldBeCalled, array $arguments, PageConfig $pageConfig ) {
		$mock = $this->createMock( PageConfigFactory::class );

		if ( $shouldBeCalled ) {
			$mock->expects( $this->once() )
				->method( 'create' )
				->with( ...$arguments )
				->willReturn( $pageConfig );
		} else {
			$mock->expects( $this->never() )
				->method( 'create' );
		}

		return $mock;
	}

	/**
	 * @param bool $shouldBeCalled
	 * @param array $arguments
	 *
	 * @return MockObject|Parsoid
	 */
	private function getParsoidMock( bool $shouldBeCalled, array $arguments ) {
		$mock = $this->createMock( Parsoid::class );
		if ( $shouldBeCalled ) {
			$mock->expects( $this->once() )
				->method( 'pb2pb' )
				->with( ...$arguments );
		} else {
			$mock->expects( $this->never() )
				->method( 'pb2pb' );
		}

		$mock->method( 'implementsLanguageConversionBcp47' )
			->willReturn( true );

		return $mock;
	}

	/**
	 * Mock constraint helper to compare equality when there are
	 * Bcp47Code instances involved.
	 * @param mixed $expected The expected value, with embedded Bcp47Codes
	 * @return Constraint a PHPUnit equality constrait
	 */
	private function constraintEquals( $expected ): Constraint {
		return $this->callback( static function ( $actual ) use ( $expected ) {
			return self::arrayWithCodeEquals( $expected, $actual );
		} );
	}

	/**
	 * Compare two values for equality, using case-insensitive BCP-47 code
	 * comparisons for Bcp47Code instances.
	 * @param mixed $expected
	 * @param mixed $actual
	 * @return bool True if the objects should be considered equal, false otherwise.
	 */
	private static function arrayWithCodeEquals( $expected, $actual ) {
		if ( $actual === $expected ) {
			return true;
		}
		if ( is_array( $actual ) && is_array( $expected ) ) {
			if ( count( $actual ) !== count( $expected ) ) {
				return false;
			}
			foreach ( $expected as $key => $value ) {
				if ( !array_key_exists( $key, $actual ) ) {
					return false;
				}
				if ( !self::arrayWithCodeEquals( $value, $actual[$key] ) ) {
					return false;
				}
			}
			return true;
		}
		if ( $actual instanceof Bcp47Code && $expected instanceof Bcp47Code ) {
			# BCP-47 codes are case insensitive.
			return strcasecmp( $actual->toBcp47Code(), $expected->toBcp47Code() ) == 0;
		}
		return false;
	}

	/**
	 * @param bool $shouldBeCalled
	 * @param Bcp47Code $baseLanguage
	 * @param bool $isLanguageConversionEnabled
	 *
	 * @return MockObject|SiteConfig
	 */
	private function getSiteConfigMock(
		bool $shouldBeCalled,
		Bcp47Code $baseLanguage,
		bool $isLanguageConversionEnabled
	) {
		$mock = $this->createMock( SiteConfig::class );
		if ( $shouldBeCalled ) {
			$mock->expects( $this->once() )
				->method( 'langConverterEnabledBcp47' )
				->with( $this->constraintEquals( $baseLanguage ) )
				->willReturn( $isLanguageConversionEnabled );
		} else {
			$mock->expects( $this->never() )
				->method( 'langConverterEnabledBcp47' );
		}

		return $mock;
	}

	/**
	 * @param PageIdentity $pageIdentity
	 * @param string $languageCode
	 *
	 * @return MockObject|TitleFactory
	 */
	private function getTitleFactoryMock( PageIdentity $pageIdentity, string $languageCode ) {
		$languageMock = $this->getLanguageMock( $languageCode );

		$titleMock = $this->createMock( Title::class );
		$titleMock->method( 'getPageLanguage' )
			->willReturn( $languageMock );

		$mock = $this->createMock( TitleFactory::class );
		$mock->expects( $this->once() )
			->method( 'newFromPageIdentity' )
			->willReturn( $titleMock )
			->with( $pageIdentity );

		return $mock;
	}

	/**
	 * @return MockObject|LanguageFactory
	 */
	private function getLanguageFactoryMock() {
		$mock = $this->createMock( LanguageFactory::class );
		$mock->method( 'getLanguage' )
			->willReturnCallback( function ( $code ) {
				static $seen = [];
				if ( $code instanceof Bcp47Code ) {
					$code = LanguageCode::bcp47ToInternal( $code );
				}
				if ( !isset( $seen[$code] ) ) {
					$seen[$code] = $this->getLanguageMock( $code );
				}
				return $seen[$code];
			} );
		$mock->method( 'getParentLanguage' )
			 ->willReturnCallback( static function ( $code ) use ( $mock ) {
				if ( $code instanceof Bcp47Code ) {
					$code = LanguageCode::bcp47ToInternal( $code );
				}
				$code = preg_replace( '/-.*$/', '', $code );
				return $mock->getLanguage( $code );
			 } );

		return $mock;
	}

	/**
	 * @return MockObject|PageBundle
	 */
	private function getPageBundleMockWithoutLanguage() {
		return $this->getPageBundleMock( null );
	}

	/**
	 * @param string|null $languageCode
	 *
	 * @return MockObject|PageBundle
	 */
	private function getPageBundleMock( ?string $languageCode ) {
		$mock = $this->createMock( PageBundle::class );
		$mock->headers = [
			# T320662: this should probably be a BCP-47 code, not internal
			'content-language' => $languageCode
		];
		$mock->html = 'test message';
		return $mock;
	}

	/**
	 * @return MockObject|PageConfig
	 */
	private function getPageConfigMock() {
		$mock = $this->createNoOpMock( PageConfig::class, [ 'setVariantBcp47' ] );
		return $mock;
	}

	/**
	 * @param string $languageCode
	 *
	 * @return MockObject|Language
	 */
	private function getLanguageMock( $languageCode ): Language {
		$languageMock = $this->createMock( Language::class );
		$languageMock->method( 'getCode' )
			->willReturn( $languageCode );
		$languageMock->method( 'toBcp47Code' )
			->willReturn( LanguageCode::bcp47( $languageCode ) );

		return $languageMock;
	}

	private function getLanguageConverterFactoryMock() {
		$languageConverterFactoryMock = $this->createMock( LanguageConverterFactory::class );
		$languageConverter = $this->createMock( LanguageConverter::class );
		$languageConverter->method( 'convertTo' )
			->willReturnCallback( static function ( $text, $code ) {
				return $text;
			} );
		$languageConverter->method( 'hasVariant' )
			->willReturnCallback( static function ( $code ) {
				return true;
			} );
		$languageConverterFactoryMock->method( 'getLanguageConverter' )
			->willReturn( $languageConverter );

		return $languageConverterFactoryMock;
	}
}
