<?php

/**
 * These tests should work regardless of $wgCapitalLinks
 * @todo Split tests into providers and test methods
 */

use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\Authority;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\UserIdentity;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 */
class LocalFileTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	protected function setUp(): void {
		parent::setUp();
		$this->tablesUsed[] = 'image';
		$this->tablesUsed[] = 'oldimage';
		$this->tablesUsed[] = 'page';
		$this->tablesUsed[] = 'text';
	}

	private static function getDefaultInfo() {
		return [
			'name' => 'test',
			'directory' => '/testdir',
			'url' => '/testurl',
			'hashLevels' => 2,
			'transformVia404' => false,
			'backend' => new FSFileBackend( [
				'name' => 'local-backend',
				'wikiId' => wfWikiID(),
				'containerPaths' => [
					'cont1' => "/testdir/local-backend/tempimages/cont1",
					'cont2' => "/testdir/local-backend/tempimages/cont2"
				]
			] )
		];
	}

	/**
	 * @covers File::getHashPath
	 * @dataProvider provideGetHashPath
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetHashPath( $expected, $capitalLinks, array $info ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );
		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getHashPath() );
	}

	public static function provideGetHashPath() {
		return [
			[ '', true, [ 'hashLevels' => 0 ] ],
			[ 'a/a2/', true, [ 'hashLevels' => 2 ] ],
			[ 'c/c4/', false, [ 'initialCapital' => false ] ],
		];
	}

	/**
	 * @covers File::getRel
	 * @dataProvider provideGetRel
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetRel( $expected, $capitalLinks, array $info ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getRel() );
	}

	public static function provideGetRel() {
		return [
			[ 'Test!', true, [ 'hashLevels' => 0 ] ],
			[ 'a/a2/Test!', true, [ 'hashLevels' => 2 ] ],
			[ 'c/c4/test!', false, [ 'initialCapital' => false ] ],
		];
	}

	/**
	 * @covers File::getUrlRel
	 * @dataProvider provideGetUrlRel
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetUrlRel( $expected, $capitalLinks, array $info ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getUrlRel() );
	}

	public static function provideGetUrlRel() {
		return [
			[ 'Test%21', true, [ 'hashLevels' => 0 ] ],
			[ 'a/a2/Test%21', true, [ 'hashLevels' => 2 ] ],
			[ 'c/c4/test%21', false, [ 'initialCapital' => false ] ],
		];
	}

	/**
	 * @covers File::getArchivePath
	 * @dataProvider provideGetArchivePath
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetArchivePath( $expected, $capitalLinks, array $info, array $args ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getArchivePath( ...$args ) );
	}

	public static function provideGetArchivePath() {
		return [
			[ 'mwstore://local-backend/test-public/archive', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwstore://local-backend/test-public/archive/a/a2', true, [ 'hashLevels' => 2 ], [] ],
			[
				'mwstore://local-backend/test-public/archive/!',
				true, [ 'hashLevels' => 0 ], [ '!' ]
			], [
				'mwstore://local-backend/test-public/archive/a/a2/!',
				true, [ 'hashLevels' => 2 ], [ '!' ]
			],
		];
	}

	/**
	 * @covers File::getThumbPath
	 * @dataProvider provideGetThumbPath
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetThumbPath( $expected, $capitalLinks, array $info, array $args ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getThumbPath( ...$args ) );
	}

	public static function provideGetThumbPath() {
		return [
			[ 'mwstore://local-backend/test-thumb/Test!', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwstore://local-backend/test-thumb/a/a2/Test!', true, [ 'hashLevels' => 2 ], [] ],
			[
				'mwstore://local-backend/test-thumb/Test!/x',
				true, [ 'hashLevels' => 0 ], [ 'x' ]
			], [
				'mwstore://local-backend/test-thumb/a/a2/Test!/x',
				true, [ 'hashLevels' => 2 ], [ 'x' ]
			],
		];
	}

	/**
	 * @covers File::getArchiveUrl
	 * @dataProvider provideGetArchiveUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetArchiveUrl( $expected, $capitalLinks, array $info, array $args ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getArchiveUrl( ...$args ) );
	}

	public static function provideGetArchiveUrl() {
		return [
			[ '/testurl/archive', true, [ 'hashLevels' => 0 ], [] ],
			[ '/testurl/archive/a/a2', true, [ 'hashLevels' => 2 ], [] ],
			[ '/testurl/archive/%21', true, [ 'hashLevels' => 0 ], [ '!' ] ],
			[ '/testurl/archive/a/a2/%21', true, [ 'hashLevels' => 2 ], [ '!' ] ],
		];
	}

	/**
	 * @covers File::getThumbUrl
	 * @dataProvider provideGetThumbUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetThumbUrl( $expected, $capitalLinks, array $info, array $args ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getThumbUrl( ...$args ) );
	}

	public static function provideGetThumbUrl() {
		return [
			[ '/testurl/thumb/Test%21', true, [ 'hashLevels' => 0 ], [] ],
			[ '/testurl/thumb/a/a2/Test%21', true, [ 'hashLevels' => 2 ], [] ],
			[ '/testurl/thumb/Test%21/x', true, [ 'hashLevels' => 0 ], [ 'x' ] ],
			[ '/testurl/thumb/a/a2/Test%21/x', true, [ 'hashLevels' => 2 ], [ 'x' ] ],
		];
	}

	/**
	 * @covers File::getArchiveVirtualUrl
	 * @dataProvider provideGetArchiveVirtualUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetArchiveVirtualUrl(
		$expected, $capitalLinks, array $info, array $args
	) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getArchiveVirtualUrl( ...$args ) );
	}

	public static function provideGetArchiveVirtualUrl() {
		return [
			[ 'mwrepo://test/public/archive', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwrepo://test/public/archive/a/a2', true, [ 'hashLevels' => 2 ], [] ],
			[ 'mwrepo://test/public/archive/%21', true, [ 'hashLevels' => 0 ], [ '!' ] ],
			[ 'mwrepo://test/public/archive/a/a2/%21', true, [ 'hashLevels' => 2 ], [ '!' ] ],
		];
	}

	/**
	 * @covers File::getThumbVirtualUrl
	 * @dataProvider provideGetThumbVirtualUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetThumbVirtualUrl( $expected, $capitalLinks, array $info, array $args ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getThumbVirtualUrl( ...$args ) );
	}

	public static function provideGetThumbVirtualUrl() {
		return [
			[ 'mwrepo://test/thumb/Test%21', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwrepo://test/thumb/a/a2/Test%21', true, [ 'hashLevels' => 2 ], [] ],
			[ 'mwrepo://test/thumb/Test%21/%21', true, [ 'hashLevels' => 0 ], [ '!' ] ],
			[ 'mwrepo://test/thumb/a/a2/Test%21/%21', true, [ 'hashLevels' => 2 ], [ '!' ] ],
		];
	}

	/**
	 * @covers File::getUrl
	 * @dataProvider provideGetUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetUrl( $expected, $capitalLinks, array $info ) {
		$this->setMwGlobals( 'wgCapitalLinks', $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getUrl() );
	}

	public static function provideGetUrl() {
		return [
			[ '/testurl/Test%21', true, [ 'hashLevels' => 0 ] ],
			[ '/testurl/a/a2/Test%21', true, [ 'hashLevels' => 2 ] ],
		];
	}

	/**
	 * @covers ::wfLocalFile
	 */
	public function testWfLocalFile() {
		$this->hideDeprecated( 'wfLocalFile' );
		$file = wfLocalFile( "File:Some_file_that_probably_doesn't exist.png" );
		$this->assertInstanceOf(
			LocalFile::class,
			$file,
			'wfLocalFile() returns LocalFile for valid Titles'
		);
	}

	/**
	 * @covers File::getUser
	 */
	public function testGetUserForNonExistingFile() {
		$this->hideDeprecated( 'File::getUser' );
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( 'test!' );
		$this->assertSame( 'Unknown user', $file->getUser() );
	}

	/**
	 * @covers LocalFile::getUploader
	 */
	public function testGetUploaderForNonExistingFile() {
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( 'test!' );
		$this->assertNull( $file->getUploader() );
	}

	public function providePermissionChecks() {
		$capablePerformer = $this->mockAnonAuthorityWithPermissions( [ 'deletedhistory', 'deletedtext' ] );
		$incapablePerformer = $this->mockAnonAuthorityWithoutPermissions( [ 'deletedhistory', 'deletedtext' ] );
		yield 'Deleted, RAW' => [
			'performer' => $incapablePerformer,
			'audience' => File::RAW,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => true,
		];
		yield 'No permission, not deleted' => [
			'performer' => $incapablePerformer,
			'audience' => File::FOR_THIS_USER,
			'deleted' => 0,
			'expected' => true,
		];
		yield 'No permission, deleted' => [
			'performer' => $incapablePerformer,
			'audience' => File::FOR_THIS_USER,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => false,
		];
		yield 'Not deleted, public' => [
			'performer' => $capablePerformer,
			'audience' => File::FOR_PUBLIC,
			'deleted' => 0,
			'expected' => true,
		];
		yield 'Deleted, public' => [
			'performer' => $capablePerformer,
			'audience' => File::FOR_PUBLIC,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => false,
		];
		yield 'With permission, deleted' => [
			'performer' => $capablePerformer,
			'audience' => File::FOR_THIS_USER,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => true,
		];
	}

	private function getOldLocalFileWithDeletion(
		UserIdentity $uploader,
		int $deletedFlags
	): OldLocalFile {
		$this->db->insert(
			'oldimage',
			[
				'oi_name' => 'Random-11m.png',
				'oi_archive_name' => 'Random-11m.png',
				'oi_size' => 10816824,
				'oi_width' => 1000,
				'oi_height' => 1800,
				'oi_metadata' => '',
				'oi_bits' => 16,
				'oi_media_type' => 'BITMAP',
				'oi_major_mime' => 'image',
				'oi_minor_mime' => 'png',
				'oi_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->db, 'comment' )->id,
				'oi_actor' => $this->getServiceContainer()
					->getActorStore()
					->acquireActorId( $uploader, $this->db ),
				'oi_timestamp' => $this->db->timestamp( '20201105235242' ),
				'oi_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
				'oi_deleted' => $deletedFlags,
			]
		);
		$file = OldLocalFile::newFromTitle(
			Title::newFromText( 'File:Random-11m.png' ),
			$this->getServiceContainer()->getRepoGroup()->getLocalRepo(),
			'20201105235242'
		);
		$this->assertInstanceOf( File::class, $file, 'Sanity: created a test file' );
		return $file;
	}

	private function getArchivedFileWithDeletion(
		UserIdentity $uploader,
		int $deletedFlags
	): ArchivedFile {
		return ArchivedFile::newFromRow( (object)[
				'fa_id' => 1,
				'fa_storage_group' => 'test',
				'fa_storage_key' => 'bla',
				'fa_name' => 'Random-11m.png',
				'fa_archive_name' => 'Random-11m.png',
				'fa_size' => 10816824,
				'fa_width' => 1000,
				'fa_height' => 1800,
				'fa_metadata' => '',
				'fa_bits' => 16,
				'fa_media_type' => 'BITMAP',
				'fa_major_mime' => 'image',
				'fa_minor_mime' => 'png',
				'fa_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->db, 'comment' )->id,
				'fa_actor' => $this->getServiceContainer()
					->getActorStore()
					->acquireActorId( $uploader, $this->db ),
				'fa_user' => $uploader->getId(),
				'fa_user_text' => $uploader->getName(),
				'fa_timestamp' => $this->db->timestamp( '20201105235242' ),
				'fa_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
				'fa_deleted' => $deletedFlags,
			]
		);
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers LocalFile::getUploader
	 */
	public function testGetUploader(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getOldLocalFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertTrue( $performer->getUser()->equals( $file->getUploader( $audience, $performer ) ) );
		} else {
			$this->assertNull( $file->getUploader( $audience, $performer ) );
		}
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers ArchivedFile::getDescription
	 */
	public function testGetDescription(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getArchivedFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertSame( 'comment', $file->getDescription( $audience, $performer ) );
		} else {
			$this->assertSame( '', $file->getDescription( $audience, $performer ) );
		}
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers ArchivedFile::getUploader
	 */
	public function testArchivedGetUploader(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getArchivedFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertTrue( $performer->getUser()->equals( $file->getUploader( $audience, $performer ) ) );
		} else {
			$this->assertNull( $file->getUploader( $audience, $performer ) );
		}
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers LocalFile::getDescription
	 */
	public function testArchivedGetDescription(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getOldLocalFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertSame( 'comment', $file->getDescription( $audience, $performer ) );
		} else {
			$this->assertSame( '', $file->getDescription( $audience, $performer ) );
		}
	}

	/**
	 * @covers File::getDescriptionShortUrl
	 */
	public function testDescriptionShortUrlForNonExistingFile() {
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( 'test!' );
		$this->assertNull( $file->getDescriptionShortUrl() );
	}

	/**
	 * @covers File::getDescriptionText
	 */
	public function testDescriptionTextForNonExistingFile() {
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( 'test!' );
		$this->assertFalse( $file->getDescriptionText() );
	}

	public function provideLoadFromDBAndCache() {
		return [
			'legacy' => [
				// phpcs:ignore Generic.Files.LineLength
				'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:16;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:2:{s:8:"DateTime";s:19:"2019:07:30 13:52:32";s:15:"_MW_PNG_VERSION";i:1;}}',
				[],
				false,
			],
			'json' => [
				// phpcs:ignore Generic.Files.LineLength
				'{"data":{"frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"colorType":"truecolour","metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[],
				false,
			],
			'json with blobs' => [
				// phpcs:ignore Generic.Files.LineLength
				'{"blobs":{"colorType":"__BLOB0__"},"data":{"frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[ '"truecolour"' ],
				false,
			],
			'large (>100KB triggers uncached case)' => [
				// phpcs:ignore Generic.Files.LineLength
				'{"data":{"large":"' . str_repeat( 'x', 102401 ) . '","frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"colorType":"truecolour","metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[],
				102401,
			],
			'large json blob' => [
				// phpcs:ignore Generic.Files.LineLength
				'{"blobs":{"large":"__BLOB0__"},"data":{"frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"colorType":"truecolour","metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[ '"' . str_repeat( 'x', 102401 ) . '"' ],
				102401,
			],
		];
	}

	/**
	 * Test loadFromDB() and loadFromCache() and helpers
	 *
	 * @dataProvider provideLoadFromDBAndCache
	 * @covers File
	 * @covers LocalFile
	 * @param string $meta
	 * @param array $blobs Metadata blob values
	 * @param int|false $largeItemSize The size of the "large" metadata item,
	 *   or false if there will be no such item.
	 */
	public function testLoadFromDBAndCache( $meta, $blobs, $largeItemSize ) {
		$services = MediaWikiServices::getInstance();

		$cache = new HashBagOStuff;
		$this->setService(
			'MainWANObjectCache',
			new WANObjectCache( [
				'cache' => $cache
			] )
		);

		$dbw = wfGetDB( DB_PRIMARY );
		$norm = $services->getActorNormalization();
		$user = $this->getTestSysop()->getUserIdentity();
		$actorId = $norm->acquireActorId( $user, $dbw );
		$comment = $services->getCommentStore()->createComment( $dbw, 'comment' );
		$title = Title::newFromText( 'File:Random-11m.png' );

		if ( $blobs ) {
			$blobStore = $services->getBlobStore();
			foreach ( $blobs as $i => $value ) {
				$address = $blobStore->storeBlob( $value );
				$meta = str_replace( "__BLOB{$i}__", $address, $meta );
			}
		}

		// The provided metadata strings should all unserialize to this
		$expectedMetaArray = [
			'frameCount' => 0,
			'loopCount' => 1,
			'duration' => 0.0,
			'bitDepth' => 16,
			'colorType' => 'truecolour',
			'metadata' => [
				'DateTime' => '2019:07:30 13:52:32',
				'_MW_PNG_VERSION' => 1,
			],
		];
		if ( $largeItemSize ) {
			$expectedMetaArray['large'] = str_repeat( 'x', $largeItemSize );
		}
		$expectedProps = [
			'name' => 'Random-11m.png',
			'size' => 10816824,
			'width' => 1000,
			'height' => 1800,
			'metadata' => $expectedMetaArray,
			'bits' => 16,
			'media_type' => 'BITMAP',
			'mime' => 'image/png',
			'timestamp' => '20201105235242',
			'sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru'
		];

		$dbw->insert(
			'image',
			[
				'img_name' => 'Random-11m.png',
				'img_size' => 10816824,
				'img_width' => 1000,
				'img_height' => 1800,
				'img_metadata' => $meta,
				'img_bits' => 16,
				'img_media_type' => 'BITMAP',
				'img_major_mime' => 'image',
				'img_minor_mime' => 'png',
				'img_description_id' => $comment->id,
				'img_actor' => $actorId,
				'img_timestamp' => $dbw->timestamp( '20201105235242' ),
				'img_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
			]
		);
		$repo = $services->getRepoGroup()->getLocalRepo();
		$file = $repo->findFile( $title );

		$this->assertFileProperties( $expectedProps, $file );
		$this->assertSame( 'truecolour', $file->getMetadataItem( 'colorType' ) );
		$this->assertSame(
			[ 'loopCount' => 1, 'bitDepth' => 16 ],
			$file->getMetadataItems( [ 'loopCount', 'bitDepth', 'nonexistent' ] )
		);
		$this->assertSame( 'comment', $file->getDescription() );
		$this->assertTrue( $user->equals( $file->getUploader() ) );

		// Test cache by corrupting DB
		// Don't wipe img_metadata though since that will be loaded by loadExtraFromDB()
		$dbw->update( 'image', [ 'img_size' => 0 ],
			[ 'img_name' => 'Random-11m.png' ], __METHOD__ );
		$file = LocalFile::newFromTitle( $title, $repo );

		$this->assertFileProperties( $expectedProps, $file );
		$this->assertSame( 'truecolour', $file->getMetadataItem( 'colorType' ) );
		$this->assertSame(
			[ 'loopCount' => 1, 'bitDepth' => 16 ],
			$file->getMetadataItems( [ 'loopCount', 'bitDepth', 'nonexistent' ] )
		);
		$this->assertSame( 'comment', $file->getDescription() );
		$this->assertTrue( $user->equals( $file->getUploader() ) );

		// Make sure we were actually hitting the WAN cache
		$dbw->delete( 'image', [ 'img_name' => 'Random-11m.png' ], __METHOD__ );
		$file->invalidateCache();
		$file = LocalFile::newFromTitle( $title, $repo );
		$this->assertSame( false, $file->exists() );
	}

	private function assertFileProperties( $expectedProps, $file ) {
		// Compare metadata without ordering
		if ( isset( $expectedProps['metadata'] ) ) {
			$this->assertArrayEquals( $expectedProps['metadata'], $file->getMetadataArray() );
		}

		// Filter out unsupported expected properties
		$expectedProps = array_intersect_key(
			$expectedProps,
			array_fill_keys( [
				'name', 'size', 'width', 'height',
				'bits', 'media_type', 'mime', 'timestamp', 'sha1'
			], true )
		);

		// Compare the other properties
		$actualProps = [
			'name' => $file->getName(),
			'size' => $file->getSize(),
			'width' => $file->getWidth(),
			'height' => $file->getHeight(),
			'bits' => $file->getBitDepth(),
			'media_type' => $file->getMediaType(),
			'mime' => $file->getMimeType(),
			'timestamp' => $file->getTimestamp(),
			'sha1' => $file->getSha1()
		];
		$actualProps = array_intersect_key( $actualProps, $expectedProps );
		$this->assertArrayEquals( $expectedProps, $actualProps, false, true );
	}

	public function provideLegacyMetadataRoundTrip() {
		return [
			[ '0' ],
			[ '-1' ],
			[ '' ]
		];
	}

	/**
	 * Test the legacy function LocalFile::getMetadata()
	 * @dataProvider provideLegacyMetadataRoundTrip
	 * @covers LocalFile
	 */
	public function testLegacyMetadataRoundTrip( $meta ) {
		$file = new class( $meta ) extends LocalFile {
			public function __construct( $meta ) {
				$repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
				parent::__construct(
					Title::newFromText( 'File:TestLegacyMetadataRoundTrip' ),
					$repo );
				$this->loadMetadataFromString( $meta );
				$this->dataLoaded = true;
			}
		};
		$this->assertSame( $meta, $file->getMetadata() );
	}

	public function provideRecordUpload3() {
		$files = [
			'test.jpg' => [
				'width' => 20,
				'height' => 20,
				'bits' => 8,
				'metadata' => [
					'ImageDescription' => 'Test file',
					'XResolution' => '72/1',
					'YResolution' => '72/1',
					'ResolutionUnit' => 2,
					'YCbCrPositioning' => 1,
					'JPEGFileComment' => [
						'Created with GIMP',
					],
					'MEDIAWIKI_EXIF_VERSION' => 2,
				],
				'fileExists' => true,
				'size' => 437,
				'file-mime' => 'image/jpeg',
				'major_mime' => 'image',
				'minor_mime' => 'jpeg',
				'mime' => 'image/jpeg',
				'sha1' => '620ezvucfyia1mltnavzpqg9gmai2gf',
				'media_type' => 'BITMAP',
			],
			'large-text.pdf' => [
				'width' => 1275,
				'height' => 1650,
				'fileExists' => true,
				'size' => 10598657,
				'file-mime' => 'application/pdf',
				'major_mime' => 'application',
				'minor_mime' => 'pdf',
				'mime' => 'application/pdf',
				'sha1' => '1o3l1yqjue2diq07grnnyq9kyapfpor',
				'bits' => 0,
				'media_type' => 'OFFICE',
				'metadata' => [
					'Pages' => '6',
					'text' => [
						'Page 1 text .................................',
						'Page 2 text .................................',
						'Page 3 text .................................',
						'Page 4 text .................................',
						'Page 5 text .................................',
						'Page 6 text .................................',
					]
				]
			],
			'no-text.pdf' => [
				'width' => 1275,
				'height' => 1650,
				'fileExists' => true,
				'size' => 10598657,
				'file-mime' => 'application/pdf',
				'major_mime' => 'application',
				'minor_mime' => 'pdf',
				'mime' => 'application/pdf',
				'sha1' => '1o3l1yqjue2diq07grnnyq9kyapfpor',
				'bits' => 0,
				'media_type' => 'OFFICE',
				'metadata' => [
					'Pages' => '6',
				]
			]
		];
		$configurations = [
			[],
			[ 'useJsonMetadata' => true ],
			[
				'useJsonMetadata' => true,
				'useSplitMetadata' => true,
				'splitMetadataThreshold' => 50
			]
		];
		return ArrayUtils::cartesianProduct( $files, $configurations );
	}

	private function getMockPdfHandler() {
		return new class extends ImageHandler {
			public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
			}

			public function useSplitMetadata() {
				return true;
			}
		};
	}

	/**
	 * Test recordUpload3() and confirm that file properties are reflected back
	 * after loading the new file from the DB.
	 *
	 * @covers LocalFile
	 * @dataProvider provideRecordUpload3
	 * @param array $props File properties
	 * @param array $conf LocalRepo configuration overrides
	 */
	public function testRecordUpload3( $props, $conf ) {
		$repo = new LocalRepo(
			[
				'class' => LocalRepo::class,
				'name' => 'test',
				'backend' => new FSFileBackend( [
					'name' => 'test-backend',
					'wikiId' => WikiMap::getCurrentWikiId(),
					'basePath' => '/nonexistent'
				] )
			] + $conf
		);
		$title = Title::newFromText( 'File:Test.jpg' );
		$file = new LocalFile( $title, $repo );

		if ( $props['mime'] === 'application/pdf' ) {
			TestingAccessWrapper::newFromObject( $file )->handler = $this->getMockPdfHandler();
		}

		$status = $file->recordUpload3(
			'oldver',
			'comment',
			'page text',
			$this->getTestSysop()->getUser(),
			$props
		);
		$this->assertSame( [], $status->getErrors() );
		// Check properties of the same object immediately after upload
		$this->assertFileProperties( $props, $file );
		// Check round-trip through the DB
		$file = new LocalFile( $title, $repo );
		$this->assertFileProperties( $props, $file );
	}

	/**
	 * @covers LocalFile
	 */
	public function testUpload() {
		$repo = new LocalRepo(
			[
				'class' => LocalRepo::class,
				'name' => 'test',
				'backend' => new FSFileBackend( [
					'name' => 'test-backend',
					'wikiId' => WikiMap::getCurrentWikiId(),
					'basePath' => $this->getNewTempDirectory()
				] )
			]
		);
		$title = Title::newFromText( 'File:Test.jpg' );
		$file = new LocalFile( $title, $repo );
		$path = __DIR__ . '/../../../data/media/test.jpg';
		$status = $file->upload(
			$path,
			'comment',
			'page text',
			0
		);
		$this->assertSame( [], $status->getErrors() );

		// Test reupload
		$file = new LocalFile( $title, $repo );
		$path = __DIR__ . '/../../../data/media/jpeg-xmp-nullchar.jpg';
		$status = $file->upload(
			$path,
			'comment',
			'page text',
			0
		);
		$this->assertSame( [], $status->getErrors() );
	}

	public function provideReserializeMetadata() {
		return [
			[
				'',
				''
			],
			[
				'a:1:{s:4:"test";i:1;}',
				'{"data":{"test":1}}'
			],
			[
				serialize( [ 'test' => str_repeat( 'x', 100 ) ] ),
				'{"data":[],"blobs":{"test":"tt:%d"}}'
			]
		];
	}

	/**
	 * Test reserializeMetadata() via maybeUpgradeRow()
	 *
	 * @covers LocalFile::maybeUpgradeRow
	 * @covers LocalFile::reserializeMetadata
	 * @dataProvider provideReserializeMetadata
	 */
	public function testReserializeMetadata( $input, $expected ) {
		$dbw = wfGetDB( DB_PRIMARY );
		$services = MediaWikiServices::getInstance();
		$norm = $services->getActorNormalization();
		$user = $this->getTestSysop()->getUserIdentity();
		$actorId = $norm->acquireActorId( $user, $dbw );
		$comment = $services->getCommentStore()->createComment( $dbw, 'comment' );

		$dbw->insert(
			'image',
			[
				'img_name' => 'Test.pdf',
				'img_size' => 1,
				'img_width' => 1,
				'img_height' => 1,
				'img_metadata' => $input,
				'img_bits' => 0,
				'img_media_type' => 'OFFICE',
				'img_major_mime' => 'application',
				'img_minor_mime' => 'pdf',
				'img_description_id' => $comment->id,
				'img_actor' => $actorId,
				'img_timestamp' => $dbw->timestamp( '20201105235242' ),
				'img_sha1' => 'hhhh',
			]
		);

		$repo = new LocalRepo( [
			'class' => LocalRepo::class,
			'name' => 'test',
			'useJsonMetadata' => true,
			'useSplitMetadata' => true,
			'splitMetadataThreshold' => 50,
			'updateCompatibleMetadata' => true,
			'reserializeMetadata' => true,
			'backend' => new FSFileBackend( [
				'name' => 'test-backend',
				'wikiId' => WikiMap::getCurrentWikiId(),
				'basePath' => '/nonexistent'
			] )
		] );
		$title = Title::newFromText( 'File:Test.pdf' );
		$file = new LocalFile( $title, $repo );
		TestingAccessWrapper::newFromObject( $file )->handler = $this->getMockPdfHandler();
		$file->load();
		$file->maybeUpgradeRow();

		$metadata = $dbw->selectField( 'image', 'img_metadata',
			[ 'img_name' => 'Test.pdf' ], __METHOD__ );
		$this->assertStringMatchesFormat( $expected, $metadata );
	}

	/**
	 * Test upgradeRow() via maybeUpgradeRow()
	 *
	 * @covers LocalFile::maybeUpgradeRow
	 * @covers LocalFile::upgradeRow
	 */
	public function testUpgradeRow() {
		$repo = new LocalRepo( [
			'class' => LocalRepo::class,
			'name' => 'test',
			'updateCompatibleMetadata' => true,
			'useJsonMetadata' => true,
			'hashLevels' => 0,
			'backend' => new FSFileBackend( [
				'name' => 'test-backend',
				'wikiId' => WikiMap::getCurrentWikiId(),
				'containerPaths' => [ 'test-public' => __DIR__ . '/../../../data/media' ]
			] )
		] );
		$dbw = wfGetDB( DB_PRIMARY );
		$services = MediaWikiServices::getInstance();
		$norm = $services->getActorNormalization();
		$user = $this->getTestSysop()->getUserIdentity();
		$actorId = $norm->acquireActorId( $user, $dbw );
		$comment = $services->getCommentStore()->createComment( $dbw, 'comment' );

		$dbw->insert(
			'image',
			[
				'img_name' => 'Png-native-test.png',
				'img_size' => 1,
				'img_width' => 1,
				'img_height' => 1,
				'img_metadata' => 'a:1:{s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:0;}}',
				'img_bits' => 0,
				'img_media_type' => 'OFFICE',
				'img_major_mime' => 'image',
				'img_minor_mime' => 'png',
				'img_description_id' => $comment->id,
				'img_actor' => $actorId,
				'img_timestamp' => $dbw->timestamp( '20201105235242' ),
				'img_sha1' => 'hhhh',
			]
		);

		$title = Title::newFromText( 'File:Png-native-test.png' );
		$file = new LocalFile( $title, $repo );
		$file->load();
		$file->maybeUpgradeRow();
		$metadata = $dbw->selectField( 'image', 'img_metadata',
			[ 'img_name' => 'Png-native-test.png' ] );
		// Just confirm that it looks like JSON with real metadata
		$this->assertStringStartsWith( '{"data":{"frameCount":0,', $metadata );

		$file = new LocalFile( $title, $repo );
		$this->assertFileProperties(
			[
				'size' => 4665,
				'width' => 420,
				'height' => 300,
				'sha1' => '3n69qtiaif1swp3kyfueqjtmw2u4c2b',
				'bits' => 8,
				'media_type' => 'BITMAP',
			],
			$file );
	}
}
