<?php

namespace League\Flysystem;

use League\Flysystem\AdapterTestUtilities\ExceptionThrowingFilesystemAdapter;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
use PHPUnit\Framework\TestCase;

use function fclose;
use function is_resource;
use function stream_get_contents;
use function tmpfile;

/**
 * @group core
 */
class MountManagerTest extends TestCase
{
    /**
     * @var ExceptionThrowingFilesystemAdapter
     */
    private $firstStubAdapter;

    /**
     * @var ExceptionThrowingFilesystemAdapter
     */
    private $secondStubAdapter;

    /**
     * @var MountManager
     */
    private $mountManager;

    /**
     * @var Filesystem
     */
    private $firstFilesystem;

    /**
     * @var Filesystem
     */
    private $secondFilesystem;

    protected function setUp(): void
    {
        $firstFilesystemAdapter = new InMemoryFilesystemAdapter();
        $secondFilesystemAdapter = new InMemoryFilesystemAdapter();
        $this->firstStubAdapter = new ExceptionThrowingFilesystemAdapter($firstFilesystemAdapter);
        $this->secondStubAdapter = new ExceptionThrowingFilesystemAdapter($secondFilesystemAdapter);

        $this->mountManager = new MountManager([
            'first' => $this->firstFilesystem = new Filesystem($this->firstStubAdapter),
            'second' => $this->secondFilesystem = new Filesystem($this->secondStubAdapter),
       ]);
    }

    /**
     * @test
     */
    public function copying_without_retaining_visibility(): void
    {
        // arrange
        $firstFilesystemAdapter = new InMemoryFilesystemAdapter();
        $secondFilesystemAdapter = new InMemoryFilesystemAdapter();
        $mountManager = new MountManager([
            'first' => new Filesystem($firstFilesystemAdapter, ['visibility' => 'public']),
            'second' => new Filesystem($secondFilesystemAdapter, ['visibility' => 'private']),
        ], ['retain_visibility' => false]);

        // act
        $mountManager->write('first://file.txt', 'contents');
        $mountManager->copy('first://file.txt', 'second://file.txt');

        // assert
        $visibility = $mountManager->visibility('second://file.txt');
        self::assertEquals('private', $visibility);
    }

    /**
     * @test
     */
    public function extending_without_new_mounts_is_equal_but_not_the_same(): void
    {
        $mountManager = $this->mountManager->extend([]);

        $this->assertNotSame($this->mountManager, $mountManager);
        $this->assertEquals($this->mountManager, $mountManager);
    }

    /**
     * @test
     */
    public function extending_with_new_mounts_is_not_equal(): void
    {
        $mountManager = $this->mountManager->extend([
            'third' => new Filesystem(new InMemoryFilesystemAdapter()),
        ]);

        $this->assertNotEquals($this->mountManager, $mountManager);
    }

    /**
     * @test
     */
    public function extending_exposes_a_usable_mount_on_the_extension(): void
    {
        $mountManager = $this->mountManager->extend([
            'third' => new Filesystem(new InMemoryFilesystemAdapter()),
        ]);

        $mountManager->write('third://path.txt', 'this');
        $contents = $mountManager->read('third://path.txt');

        $this->assertEquals('this', $contents);
    }

    /**
     * @test
     */
    public function extending_does_not_mount_on_the_original_mount_manager(): void
    {
        $this->mountManager->extend([
            'third' => new Filesystem(new InMemoryFilesystemAdapter()),
        ]);

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

        $this->mountManager->write('third://path.txt', 'this');
    }

    /**
     * @test
     */
    public function copying_while_retaining_visibility(): void
    {
        // arrange
        $firstFilesystemAdapter = new InMemoryFilesystemAdapter();
        $secondFilesystemAdapter = new InMemoryFilesystemAdapter();
        $mountManager = new MountManager([
            'first' => new Filesystem($firstFilesystemAdapter, ['visibility' => 'public']),
            'second' => new Filesystem($secondFilesystemAdapter, ['visibility' => 'private']),
        ], ['retain_visibility' => true]);

        // act
        $mountManager->write('first://file.txt', 'contents');
        $mountManager->copy('first://file.txt', 'second://file.txt');

        // assert
        $visibility = $mountManager->visibility('second://file.txt');
        self::assertEquals('public', $visibility);
    }

    /**
     * @test
     */
    public function writing_a_file(): void
    {
        $this->mountManager->write('first://file.txt', 'content');
        $this->mountManager->write('second://another-file.txt', 'content');

        $this->assertTrue($this->firstFilesystem->fileExists('file.txt'));
        $this->assertFalse($this->secondFilesystem->fileExists('file.txt'));

        $this->assertFalse($this->firstFilesystem->fileExists('another-file.txt'));
        $this->assertTrue($this->secondFilesystem->fileExists('another-file.txt'));
    }

    /**
     * @test
     */
    public function writing_a_file_with_a_stream(): void
    {
        $stream = stream_with_contents('contents');
        $this->mountManager->writeStream('first://location.txt', $stream);

        $this->assertTrue($this->firstFilesystem->fileExists('location.txt'));
        $this->assertEquals('contents', $this->firstFilesystem->read('location.txt'));
    }

    /**
     * @test
     */
    public function not_being_able_to_write_a_file(): void
    {
        $this->firstStubAdapter->stageException('write', 'file.txt', UnableToWriteFile::atLocation('file.txt'));

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

        $this->mountManager->write('first://file.txt', 'content');
    }

    /**
     * @test
     */
    public function not_being_able_to_stream_write_a_file(): void
    {
        $handle = tmpfile();
        $this->firstStubAdapter->stageException('writeStream', 'file.txt', UnableToWriteFile::atLocation('file.txt'));

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

        try {
            $this->mountManager->writeStream('first://file.txt', $handle);
        } finally {
            is_resource($handle) && fclose($handle);
        }
    }

    /**
     * @description This test method is so ugly, but I don't have the energy to create a nice test for every single one of these method.
     *
     * @test
     *
     * @dataProvider dpMetadataRetrieverMethods
     */
    public function failing_a_one_param_method(string $method, FilesystemOperationFailed $exception): void
    {
        $this->firstStubAdapter->stageException($method, 'location.txt', $exception);

        $this->expectException(get_class($exception));

        $this->mountManager->{$method}('first://location.txt');
    }

    public static function dpMetadataRetrieverMethods(): iterable
    {
        yield 'mimeType' => ['mimeType', UnableToRetrieveMetadata::mimeType('location.txt')];
        yield 'fileSize' => ['fileSize', UnableToRetrieveMetadata::fileSize('location.txt')];
        yield 'lastModified' => ['lastModified', UnableToRetrieveMetadata::lastModified('location.txt')];
        yield 'visibility' => ['visibility', UnableToRetrieveMetadata::visibility('location.txt')];
        yield 'delete' => ['delete', UnableToDeleteFile::atLocation('location.txt')];
        yield 'deleteDirectory' => ['deleteDirectory', UnableToDeleteDirectory::atLocation('location.txt')];
        yield 'createDirectory' => ['createDirectory', UnableToCreateDirectory::atLocation('location.txt')];
        yield 'read' => ['read', UnableToReadFile::fromLocation('location.txt')];
        yield 'readStream' => ['readStream', UnableToReadFile::fromLocation('location.txt')];
        yield 'fileExists' => ['fileExists', UnableToCheckFileExistence::forLocation('location.txt')];
    }

    /**
     * @test
     */
    public function reading_a_file(): void
    {
        $this->secondFilesystem->write('location.txt', 'contents');

        $contents = $this->mountManager->read('second://location.txt');

        $this->assertEquals('contents', $contents);
    }

    /**
     * @test
     */
    public function reading_a_file_as_a_stream(): void
    {
        $this->secondFilesystem->write('location.txt', 'contents');

        $handle = $this->mountManager->readStream('second://location.txt');
        $contents = stream_get_contents($handle);
        fclose($handle);

        $this->assertEquals('contents', $contents);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_existing_file(): void
    {
        $this->secondFilesystem->write('location.txt', 'contents');

        $existence = $this->mountManager->fileExists('second://location.txt');

        $this->assertTrue($existence);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_non_existing_file(): void
    {
        $existence = $this->mountManager->fileExists('second://location.txt');

        $this->assertFalse($existence);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_non_existing_directory(): void
    {
        $existence = $this->mountManager->directoryExists('second://some-directory');

        $this->assertFalse($existence);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_existing_directory(): void
    {
        $this->secondFilesystem->write('nested/location.txt', 'contents');

        $existence = $this->mountManager->directoryExists('second://nested');

        $this->assertTrue($existence);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_existing_file_using_has(): void
    {
        $this->secondFilesystem->write('location.txt', 'contents');

        $existence = $this->mountManager->has('second://location.txt');

        $this->assertTrue($existence);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_non_existing_file_using_has(): void
    {
        $existence = $this->mountManager->has('second://location.txt');

        $this->assertFalse($existence);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_non_existing_directory_using_has(): void
    {
        $existence = $this->mountManager->has('second://some-directory');

        $this->assertFalse($existence);
    }

    /**
     * @test
     */
    public function checking_existence_for_an_existing_directory_using_has(): void
    {
        $this->secondFilesystem->write('nested/location.txt', 'contents');

        $existence = $this->mountManager->has('second://nested');

        $this->assertTrue($existence);
    }

    /**
     * @test
     */
    public function deleting_a_file(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');

        $this->mountManager->delete('first://location.txt');

        $this->assertFalse($this->firstFilesystem->fileExists('location.txt'));
    }

    /**
     * @test
     */
    public function deleting_a_directory(): void
    {
        $this->firstFilesystem->write('dirname/location.txt', 'contents');

        $this->mountManager->deleteDirectory('first://dirname');

        $this->assertFalse($this->firstFilesystem->fileExists('dirname/location.txt'));
    }

    /**
     * @test
     */
    public function setting_visibility(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');
        $this->firstFilesystem->setVisibility('location.txt', Visibility::PRIVATE);

        $this->mountManager->setVisibility('first://location.txt', Visibility::PUBLIC);

        $this->assertEquals(Visibility::PUBLIC, $this->firstFilesystem->visibility('location.txt'));
    }

    /**
     * @test
     */
    public function retrieving_metadata(): void
    {
        $now = time();
        $this->firstFilesystem->write('location.txt', 'contents');

        $lastModified = $this->mountManager->lastModified('first://location.txt');
        $fileSize = $this->mountManager->fileSize('first://location.txt');
        $mimeType = $this->mountManager->mimeType('first://location.txt');

        $this->assertGreaterThanOrEqual($now, $lastModified);
        $this->assertEquals(8, $fileSize);
        $this->assertEquals('text/plain', $mimeType);
    }

    /**
     * @test
     */
    public function creating_a_directory(): void
    {
        $this->mountManager->createDirectory('first://directory');

        $directoryListing = $this->firstFilesystem->listContents('/')
            ->toArray();

        $this->assertCount(1, $directoryListing);
        /** @var DirectoryAttributes $directory */
        $directory = $directoryListing[0];
        $this->assertInstanceOf(DirectoryAttributes::class, $directory);
        $this->assertEquals('directory', $directory->path());
    }

    /**
     * @test
     */
    public function list_directory(): void
    {
        $this->mountManager->createDirectory('first://directory');
        $this->mountManager->write('first://directory/file', 'foo');

        $directoryListing = $this->mountManager->listContents('first://', Filesystem::LIST_DEEP)->toArray();

        $this->assertCount(2, $directoryListing);

        /** @var DirectoryAttributes $directory */
        $directory = $directoryListing[0];
        $this->assertInstanceOf(DirectoryAttributes::class, $directory);
        $this->assertEquals('first://directory', $directory->path());

        /** @var FileAttributes $file */
        $file = $directoryListing[1];
        $this->assertInstanceOf(FileAttributes::class, $file);
        $this->assertEquals('first://directory/file', $file->path());
    }

    /**
     * @test
     */
    public function copying_in_the_same_filesystem(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');
        $this->assertTrue($this->firstFilesystem->fileExists('location.txt'));

        $this->mountManager->copy('first://location.txt', 'first://new-location.txt');

        $this->assertTrue($this->firstFilesystem->fileExists('location.txt'));
        $this->assertTrue($this->firstFilesystem->fileExists('new-location.txt'));
    }

    /**
     * @test
     */
    public function failing_to_copy_in_the_same_filesystem(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');
        $this->firstStubAdapter->stageException('copy', 'location.txt', UnableToCopyFile::fromLocationTo('a', 'b'));

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

        $this->mountManager->copy('first://location.txt', 'first://new-location.txt');
    }

    /**
     * @test
     */
    public function failing_to_move_in_the_same_filesystem(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');
        $this->firstStubAdapter->stageException('move', 'location.txt', UnableToMoveFile::fromLocationTo('a', 'b'));

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

        $this->mountManager->move('first://location.txt', 'first://new-location.txt');
    }

    /**
     * @test
     */
    public function moving_in_the_same_filesystem(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');
        $this->assertTrue($this->firstFilesystem->fileExists('location.txt'));

        $this->mountManager->move('first://location.txt', 'first://new-location.txt');

        $this->assertFalse($this->firstFilesystem->fileExists('location.txt'));
        $this->assertTrue($this->firstFilesystem->fileExists('new-location.txt'));
    }

    /**
     * @test
     */
    public function moving_across_filesystem(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');
        $this->assertTrue($this->firstFilesystem->fileExists('location.txt'));

        $this->mountManager->move('first://location.txt', 'second://new-location.txt');

        $this->assertFalse($this->firstFilesystem->fileExists('location.txt'));
        $this->assertTrue($this->secondFilesystem->fileExists('new-location.txt'));
    }

    /**
     * @test
     */
    public function failing_to_move_across_filesystem(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');

        $this->firstStubAdapter->stageException('visibility', 'location.txt', UnableToRetrieveMetadata::visibility('location.txt'));

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

        $this->mountManager->move('first://location.txt', 'second://new-location.txt');
    }

    /**
     * @test
     */
    public function failing_to_copy_across_filesystem(): void
    {
        $this->firstFilesystem->write('location.txt', 'contents');

        $this->firstStubAdapter->stageException('visibility', 'location.txt', UnableToRetrieveMetadata::visibility('location.txt'));

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

        $this->mountManager->copy('first://location.txt', 'second://new-location.txt');
    }

    /**
     * @test
     */
    public function listing_contents(): void
    {
        $this->firstFilesystem->write('contents.txt', 'file contents');
        $this->firstFilesystem->write('dirname/contents.txt', 'file contents');
        $this->secondFilesystem->write('dirname/contents.txt', 'file contents');

        $contents = $this->mountManager->listContents('first://', FilesystemReader::LIST_DEEP)->toArray();

        $this->assertCount(3, $contents);
    }

    /**
     * @test
     */
    public function dangerously_mounting_additional_filesystems(): void
    {
        $this->firstFilesystem->write('contents.txt', 'file contents');

        $this->mountManager->dangerouslyMountFilesystems('unknown', $this->firstFilesystem);

        $this->assertTrue($this->mountManager->fileExists('unknown://contents.txt'));
    }

    /**
     * @test
     */
    public function guarding_against_valid_mount_identifiers(): void
    {
        $this->expectException(UnableToMountFilesystem::class);

        /* @phpstan-ignore-next-line */
        new MountManager([1 => new Filesystem(new InMemoryFilesystemAdapter())]);
    }

    /**
     * @test
     */
    public function guarding_against_mounting_invalid_filesystems(): void
    {
        $this->expectException(UnableToMountFilesystem::class);

        /* @phpstan-ignore-next-line */
        new MountManager(['valid' => 'something else']);
    }

    /**
     * @test
     */
    public function guarding_against_using_paths_without_mount_prefix(): void
    {
        $this->expectException(UnableToResolveFilesystemMount::class);

        $this->mountManager->read('path-without-mount-prefix.txt');
    }

    /**
     * @test
     */
    public function guard_against_using_unknown_mount(): void
    {
        $this->expectException(UnableToResolveFilesystemMount::class);

        $this->mountManager->read('unknown://location.txt');
    }

    /**
     * @test
     */
    public function generate_public_url(): void
    {
        $mountManager = new MountManager([
            'first' => new Filesystem($this->firstStubAdapter, ['public_url' => 'first.example.com']),
            'second' => new Filesystem($this->secondStubAdapter, ['public_url' => 'second.example.com']),
        ]);

        $mountManager->write('first://file1.txt', 'content');
        $mountManager->write('second://file2.txt', 'content');

        $this->assertSame('first.example.com/file1.txt', $mountManager->publicUrl('first://file1.txt'));
        $this->assertSame('second.example.com/file2.txt', $mountManager->publicUrl('second://file2.txt'));
    }

    /**
     * @test
     */
    public function provide_checksum(): void
    {
        $this->mountManager->write('first://file.txt', 'content');

        $this->assertSame('9a0364b9e99bb480dd25e1f0284c8555', $this->mountManager->checksum('first://file.txt'));
    }
}
