<?php

/**
 * @covers FormatJson
 */
class FormatJsonTest extends MediaWikiTestCase {

	public static function provideEncoderPrettyPrinting() {
		return array(
			// Four spaces
			array( true, '    ' ),
			array( '    ', '    ' ),
			// Two spaces
			array( '  ', '  ' ),
			// One tab
			array( "\t", "\t" ),
		);
	}

	/**
	 * @dataProvider provideEncoderPrettyPrinting
	 */
	public function testEncoderPrettyPrinting( $pretty, $expectedIndent ) {
		$obj = array(
			'emptyObject' => new stdClass,
			'emptyArray' => array(),
			'string' => 'foobar\\',
			'filledArray' => array(
				array(
					123,
					456,
				),
				// Nested json works without problems
				'"7":["8",{"9":"10"}]',
				// Whitespace clean up doesn't touch strings that look alike
				"{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}",
			),
		);

		// No trailing whitespace, no trailing linefeed
		$json = '{
	"emptyObject": {},
	"emptyArray": [],
	"string": "foobar\\\\",
	"filledArray": [
		[
			123,
			456
		],
		"\"7\":[\"8\",{\"9\":\"10\"}]",
		"{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}"
	]
}';

		$json = str_replace( "\r", '', $json ); // Windows compat
		$json = str_replace( "\t", $expectedIndent, $json );
		$this->assertSame( $json, FormatJson::encode( $obj, $pretty ) );
	}

	public static function provideEncodeDefault() {
		return self::getEncodeTestCases( array() );
	}

	/**
	 * @dataProvider provideEncodeDefault
	 */
	public function testEncodeDefault( $from, $to ) {
		$this->assertSame( $to, FormatJson::encode( $from ) );
	}

	public static function provideEncodeUtf8() {
		return self::getEncodeTestCases( array( 'unicode' ) );
	}

	/**
	 * @dataProvider provideEncodeUtf8
	 */
	public function testEncodeUtf8( $from, $to ) {
		$this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) );
	}

	public static function provideEncodeXmlMeta() {
		return self::getEncodeTestCases( array( 'xmlmeta' ) );
	}

	/**
	 * @dataProvider provideEncodeXmlMeta
	 */
	public function testEncodeXmlMeta( $from, $to ) {
		$this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) );
	}

	public static function provideEncodeAllOk() {
		return self::getEncodeTestCases( array( 'unicode', 'xmlmeta' ) );
	}

	/**
	 * @dataProvider provideEncodeAllOk
	 */
	public function testEncodeAllOk( $from, $to ) {
		$this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) );
	}

	public function testEncodePhpBug46944() {
		$this->assertNotEquals(
			'\ud840\udc00',
			strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ),
			'Test encoding an broken json_encode character (U+20000)'
		);
	}

	public function testDecodeReturnType() {
		$this->assertInternalType(
			'object',
			FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ),
			'Default to object'
		);

		$this->assertInternalType(
			'array',
			FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ),
			'Optional array'
		);
	}

	public static function provideParse() {
		return array(
			array( null ),
			array( true ),
			array( false ),
			array( 0 ),
			array( 1 ),
			array( 1.2 ),
			array( '' ),
			array( 'str' ),
			array( array( 0, 1, 2 ) ),
			array( array( 'a' => 'b' ) ),
			array( array( 'a' => 'b' ) ),
			array( array( 'a' => 'b', 'x' => array( 'c' => 'd' ) ) ),
		);
	}

	/**
	 * Recursively convert arrays into stdClass
	 * @param array|string|bool|int|float|null $value
	 * @return stdClass|string|bool|int|float|null
	 */
	public static function toObject( $value ) {
		return !is_array( $value ) ? $value : (object) array_map( __METHOD__, $value );
	}

	/**
	 * @dataProvider provideParse
	 * @param mixed $value
	 */
	public function testParse( $value ) {
		$expected = self::toObject( $value );
		$json = FormatJson::encode( $expected, false, FormatJson::ALL_OK );
		$this->assertJson( $json );

		$st = FormatJson::parse( $json );
		$this->assertType( 'Status', $st );
		$this->assertTrue( $st->isGood() );
		$this->assertEquals( $expected, $st->getValue() );

		$st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC );
		$this->assertType( 'Status', $st );
		$this->assertTrue( $st->isGood() );
		$this->assertEquals( $value, $st->getValue() );
	}

	public static function provideParseTryFixing() {
		return array(
			array( "[,]", '[]' ),
			array( "[ , ]", '[]' ),
			array( "[ , }", false ),
			array( '[1],', false ),
			array( "[1,]", '[1]' ),
			array( "[1\n,]", '[1]' ),
			array( "[1,\n]", '[1]' ),
			array( "[1,]\n", '[1]' ),
			array( "[1\n,\n]\n", '[1]' ),
			array( '["a,",]', '["a,"]' ),
			array( "[[1,]\n,[2,\n],[3\n,]]", '[[1],[2],[3]]' ),
			array( '[[1,],[2,],[3,]]', false ), // I wish we could parse this, but would need quote parsing
			array( '[1,,]', false ),
		);
	}

	/**
	 * @dataProvider provideParseTryFixing
	 * @param string $value
	 * @param string|bool $expected
	 */
	public function testParseTryFixing( $value, $expected ) {
		$st = FormatJson::parse( $value, FormatJson::TRY_FIXING );
		$this->assertType( 'Status', $st );
		if ( $expected === false ) {
			$this->assertFalse( $st->isOK() );
		} else {
			$this->assertFalse( $st->isGood() );
			$this->assertTrue( $st->isOK() );
			$val = FormatJson::encode( $st->getValue(), false, FormatJson::ALL_OK );
			$this->assertEquals( $expected, $val );
		}
	}

	public static function provideParseErrors() {
		return array(
			array( 'aaa' ),
			array( '{"j": 1 ] }' ),
		);
	}

	/**
	 * @dataProvider provideParseErrors
	 * @param mixed $value
	 */
	public function testParseErrors( $value ) {
		$st = FormatJson::parse( $value );
		$this->assertType( 'Status', $st );
		$this->assertFalse( $st->isOK() );
	}

	/**
	 * Generate a set of test cases for a particular combination of encoder options.
	 *
	 * @param array $unescapedGroups List of character groups to leave unescaped
	 * @return array Arrays of unencoded strings and corresponding encoded strings
	 */
	private static function getEncodeTestCases( array $unescapedGroups ) {
		$groups = array(
			'always' => array(
				// Forward slash (always unescaped)
				'/' => '/',

				// Control characters
				"\0" => '\u0000',
				"\x08" => '\b',
				"\t" => '\t',
				"\n" => '\n',
				"\r" => '\r',
				"\f" => '\f',
				"\x1f" => '\u001f', // representative example

				// Double quotes
				'"' => '\"',

				// Backslashes
				'\\' => '\\\\',
				'\\\\' => '\\\\\\\\',
				'\\u00e9' => '\\\u00e9', // security check for Unicode unescaping

				// Line terminators
				"\xe2\x80\xa8" => '\u2028',
				"\xe2\x80\xa9" => '\u2029',
			),
			'unicode' => array(
				"\xc3\xa9" => '\u00e9',
				"\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP
			),
			'xmlmeta' => array(
				'<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits
				'>' => '\u003E',
				'&' => '\u0026',
			),
		);

		$cases = array();
		foreach ( $groups as $name => $rules ) {
			$leaveUnescaped = in_array( $name, $unescapedGroups );
			foreach ( $rules as $from => $to ) {
				$cases[] = array( $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' );
			}
		}

		return $cases;
	}
}