<?php

declare(strict_types=1);

namespace League\Flysystem;

use DateTimeImmutable;
use DateTimeInterface;
use Generator;
use GuzzleHttp\Psr7\StreamWrapper;
use GuzzleHttp\Psr7\Utils;
use IteratorAggregate;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\UrlGeneration\PublicUrlGenerator;
use League\Flysystem\UrlGeneration\TemporaryUrlGenerator;
use LogicException;
use PHPUnit\Framework\TestCase;

use function iterator_to_array;

/**
 * @group core
 */
class FilesystemTest extends TestCase
{
    const ROOT = __DIR__ . '/../test_files/test-root';

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

    /**
     * @before
     */
    public function setupFilesystem(): void
    {
        $adapter = new LocalFilesystemAdapter(self::ROOT);
        $filesystem = new Filesystem($adapter);
        $this->filesystem = $filesystem;
    }

    /**
     * @after
     */
    public function removeFiles(): void
    {
        delete_directory(static::ROOT);
    }

    /**
     * @test
     */
    public function writing_and_reading_files(): void
    {
        $this->filesystem->write('path.txt', 'contents');
        $contents = $this->filesystem->read('path.txt');

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

    /**
     * @test
     *
     * @dataProvider invalidStreamInput
     *
     * @param mixed $input
     */
    public function trying_to_write_with_an_invalid_stream_arguments($input): void
    {
        $this->expectException(InvalidStreamProvided::class);

        $this->filesystem->writeStream('path.txt', $input);
    }

    public static function invalidStreamInput(): Generator
    {
        $handle = tmpfile();
        fclose($handle);
        yield "resource that is not open" => [$handle];
        yield "something that is not a resource" => [false];
    }

    /**
     * @test
     */
    public function writing_and_reading_a_stream(): void
    {
        $writeStream = stream_with_contents('contents');

        $this->filesystem->writeStream('path.txt', $writeStream);
        $readStream = $this->filesystem->readStream('path.txt');

        fclose($writeStream);

        $this->assertIsResource($readStream);
        $this->assertEquals('contents', stream_get_contents($readStream));

        fclose($readStream);
    }

    /**
     * @test
     */
    public function writing_using_a_stream_wrapper(): void
    {
        $contents = 'contents of the file';
        $stream = Utils::streamFor($contents);
        $resource = StreamWrapper::getResource($stream);

        $this->filesystem->writeStream('from-stream-wrapper.txt', $resource);
        fclose($resource);

        $this->assertEquals($contents, $this->filesystem->read('from-stream-wrapper.txt'));
    }

    /**
     * @test
     */
    public function checking_if_files_exist(): void
    {
        $this->filesystem->write('path.txt', 'contents');

        $pathDotTxtExists = $this->filesystem->fileExists('path.txt');
        $otherFileExists = $this->filesystem->fileExists('other.txt');

        $this->assertTrue($pathDotTxtExists);
        $this->assertFalse($otherFileExists);
    }

    /**
     * @test
     */
    public function checking_if_directories_exist(): void
    {
        $this->filesystem->createDirectory('existing-directory');

        $existingDirectory = $this->filesystem->directoryExists('existing-directory');
        $notExistingDirectory = $this->filesystem->directoryExists('not-existing-directory');

        $this->assertTrue($existingDirectory);
        $this->assertFalse($notExistingDirectory);
    }

    /**
     * @test
     */
    public function deleting_a_file(): void
    {
        $this->filesystem->write('path.txt', 'content');
        $this->filesystem->delete('path.txt');

        $this->assertFalse($this->filesystem->fileExists('path.txt'));
    }

    /**
     * @test
     */
    public function creating_a_directory(): void
    {
        $this->filesystem->createDirectory('here');

        $directoryAttrs = $this->filesystem->listContents('')->toArray()[0];
        $this->assertInstanceOf(DirectoryAttributes::class, $directoryAttrs);
        $this->assertEquals('here', $directoryAttrs->path());
    }

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

        $this->filesystem->deleteDirectory('dir');

        $this->assertTrue($this->filesystem->fileExists('dirname/a.txt'));

        $this->filesystem->deleteDirectory('dirname');

        $this->assertFalse($this->filesystem->fileExists('dirname/a.txt'));
        $this->assertFalse($this->filesystem->fileExists('dirname/b.txt'));
        $this->assertFalse($this->filesystem->fileExists('dirname/c.txt'));
    }

    /**
     * @test
     */
    public function listing_directory_contents(): void
    {
        $this->filesystem->write('dirname/a.txt', 'contents');
        $this->filesystem->write('dirname/b.txt', 'contents');
        $this->filesystem->write('dirname/c.txt', 'contents');

        $listing = $this->filesystem->listContents('', false);

        $this->assertInstanceOf(DirectoryListing::class, $listing);
        $this->assertInstanceOf(IteratorAggregate::class, $listing);

        $attributeListing = iterator_to_array($listing);
        $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $attributeListing);
        $this->assertCount(1, $attributeListing);
    }

    /**
     * @test
     */
    public function listing_directory_contents_recursive(): void
    {
        $this->filesystem->write('dirname/a.txt', 'contents');
        $this->filesystem->write('dirname/b.txt', 'contents');
        $this->filesystem->write('dirname/c.txt', 'contents');

        $listing = $this->filesystem->listContents('', true);

        $attributeListing = $listing->toArray();
        $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $attributeListing);
        $this->assertCount(4, $attributeListing);
    }

    /**
     * @test
     */
    public function copying_files(): void
    {
        $this->filesystem->write('path.txt', 'contents');

        $this->filesystem->copy('path.txt', 'new-path.txt');

        $this->assertTrue($this->filesystem->fileExists('path.txt'));
        $this->assertTrue($this->filesystem->fileExists('new-path.txt'));
    }

    /**
     * @test
     */
    public function moving_files(): void
    {
        $this->filesystem->write('path.txt', 'contents');

        $this->filesystem->move('path.txt', 'new-path.txt');

        $this->assertFalse($this->filesystem->fileExists('path.txt'));
        $this->assertTrue($this->filesystem->fileExists('new-path.txt'));
    }

    /**
     * @test
     */
    public function fetching_last_modified(): void
    {
        $this->filesystem->write('path.txt', 'contents');

        $lastModified = $this->filesystem->lastModified('path.txt');

        $this->assertIsInt($lastModified);
        $this->assertTrue($lastModified > time() - 30);
        $this->assertTrue($lastModified < time() + 30);
    }

    /**
     * @test
     */
    public function fetching_mime_type(): void
    {
        $this->filesystem->write('path.txt', 'contents');

        $mimeType = $this->filesystem->mimeType('path.txt');

        $this->assertEquals('text/plain', $mimeType);
    }

    /**
     * @test
     */
    public function fetching_file_size(): void
    {
        $this->filesystem->write('path.txt', 'contents');

        $fileSize = $this->filesystem->fileSize('path.txt');

        $this->assertEquals(8, $fileSize);
    }

    /**
     * @test
     */
    public function ensuring_streams_are_rewound_when_writing(): void
    {
        $writeStream = stream_with_contents('contents');
        fseek($writeStream, 4);

        $this->filesystem->writeStream('path.txt', $writeStream);
        $contents = $this->filesystem->read('path.txt');

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

    /**
     * @test
     */
    public function setting_visibility(): void
    {
        $this->filesystem->write('path.txt', 'contents');

        $this->filesystem->setVisibility('path.txt', Visibility::PUBLIC);
        $publicVisibility = $this->filesystem->visibility('path.txt');

        $this->filesystem->setVisibility('path.txt', Visibility::PRIVATE);
        $privateVisibility = $this->filesystem->visibility('path.txt');

        $this->assertEquals(Visibility::PUBLIC, $publicVisibility);
        $this->assertEquals(Visibility::PRIVATE, $privateVisibility);
    }

    /**
     * @test
     *
     * @dataProvider scenariosCausingPathTraversal
     */
    public function protecting_against_path_traversals(callable $scenario): void
    {
        $this->expectException(PathTraversalDetected::class);
        $scenario($this->filesystem);
    }

    public static function scenariosCausingPathTraversal(): Generator
    {
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->delete('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->deleteDirectory('../path');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->createDirectory('../path');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->read('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->readStream('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->write('../path.txt', 'contents');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $stream = stream_with_contents('contents');
            try {
                $filesystem->writeStream('../path.txt', $stream);
            } finally {
                fclose($stream);
            }
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->listContents('../path');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->fileExists('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->mimeType('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->fileSize('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->lastModified('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->visibility('../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->setVisibility('../path.txt', Visibility::PUBLIC);
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->copy('../path.txt', 'path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->copy('path.txt', '../path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->move('../path.txt', 'path.txt');
        }];
        yield [function (FilesystemOperator $filesystem) {
            $filesystem->move('path.txt', '../path.txt');
        }];
    }

    /**
     * @test
     */
    public function listing_exceptions_are_uniformely_represented(): void
    {
        $filesystem = new Filesystem(
            new class() extends InMemoryFilesystemAdapter {
                public function listContents(string $path, bool $deep): iterable
                {
                    yield from parent::listContents($path, $deep);
                    throw new LogicException('Oh no.');
                }
            }
        );
        $items = $filesystem->listContents('', true);

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

        iterator_to_array($items); // force the yields
    }

    /**
     * @test
     */
    public function failing_to_create_a_public_url(): void
    {
        $filesystem = new Filesystem(
            new class() extends InMemoryFilesystemAdapter implements PublicUrlGenerator {
                public function publicUrl(string $path, Config $config): string
                {
                    throw new UnableToGeneratePublicUrl('No reason', $path);
                }
            }
        );

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

        $filesystem->publicUrl('path.txt');
    }

    /**
     * @test
     */
    public function not_configuring_a_public_url(): void
    {
        $filesystem = new Filesystem(new InMemoryFilesystemAdapter());

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

        $filesystem->publicUrl('path.txt');
    }

    /**
     * @test
     */
    public function creating_a_public_url(): void
    {
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            ['public_url' => 'https://example.org/public/'],
        );

        $url = $filesystem->publicUrl('path.txt');

        self::assertEquals('https://example.org/public/path.txt', $url);
    }

    /**
     * @test
     */
    public function public_url_array_uses_multi_prefixer(): void
    {
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            ['public_url' => ['https://cdn1', 'https://cdn2']],
        );

        $url1 = $filesystem->publicUrl('first-path1.txt');
        $url2 = $filesystem->publicUrl('path2.txt');
        $url3 = $filesystem->publicUrl('first-path1.txt'); // deterministic
        $url4 = $filesystem->publicUrl('/some/path-here.txt');
        $url5 = $filesystem->publicUrl('some/path-here.txt'); // deterministic even with leading "/"

        self::assertEquals('https://cdn1/first-path1.txt', $url1);
        self::assertEquals('https://cdn2/path2.txt', $url2);
        self::assertEquals('https://cdn1/first-path1.txt', $url3);
        self::assertEquals('https://cdn2/some/path-here.txt', $url4);
        self::assertEquals('https://cdn2/some/path-here.txt', $url5);
    }

    /**
     * @test
     */
    public function custom_public_url_generator(): void
    {
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            [],
            publicUrlGenerator: new class() implements PublicUrlGenerator {
                public function publicUrl(string $path, Config $config): string
                {
                    return 'custom/' . $path;
                }
            },
        );

        self::assertSame('custom/file.txt', $filesystem->publicUrl('file.txt'));
    }

    /**
     * @test
     */
    public function copying_from_and_to_the_same_location_fails(): void
    {
        $this->expectExceptionObject(UnableToCopyFile::sourceAndDestinationAreTheSame('from.txt', 'from.txt'));

        $config = [Config::OPTION_COPY_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL];
        $this->filesystem->copy('from.txt', 'from.txt', $config);
    }

    /**
     * @test
     */
    public function moving_from_and_to_the_same_location_fails(): void
    {
        $this->expectExceptionObject(UnableToMoveFile::fromLocationTo('from.txt', 'from.txt'));

        $config = [Config::OPTION_MOVE_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL];
        $this->filesystem->move('from.txt', 'from.txt', $config);
    }

    /**
     * @test
     */
    public function get_checksum_for_adapter_that_supports(): void
    {
        $this->filesystem->write('path.txt', 'foobar');

        $this->assertSame('3858f62230ac3c915f300c664312c63f', $this->filesystem->checksum('path.txt'));
    }

    /**
     * @test
     */
    public function get_checksum_for_adapter_that_does_not_support(): void
    {
        $filesystem = new Filesystem(new InMemoryFilesystemAdapter());

        $filesystem->write('path.txt', 'foobar');

        $this->assertSame('3858f62230ac3c915f300c664312c63f', $filesystem->checksum('path.txt'));
    }

    /**
     * @test
     */
    public function get_checksum_for_adapter_that_does_not_support_specific_algo(): void
    {
        $adapter = new class() extends InMemoryFilesystemAdapter implements ChecksumProvider {
            public function checksum(string $path, Config $config): string
            {
                throw new ChecksumAlgoIsNotSupported();
            }
        };
        $filesystem = new Filesystem($adapter);

        $filesystem->write('path.txt', 'foobar');

        $this->assertSame('3858f62230ac3c915f300c664312c63f', $filesystem->checksum('path.txt'));
    }

    /**
     * @test
     */
    public function get_sha256_checksum_for_adapter_that_does_not_support(): void
    {
        $filesystem = new Filesystem(new InMemoryFilesystemAdapter(), ['checksum_algo' => 'sha256']);

        $filesystem->write('path.txt', 'foobar');

        $this->assertSame('c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', $filesystem->checksum('path.txt'));
    }

    /**
     * @test
     */
    public function get_sha256_checksum_for_adapter_that_does_not_support_while_crc32c_is_the_default(): void
    {
        $filesystem = new Filesystem(new InMemoryFilesystemAdapter(), ['checksum_algo' => 'crc32c']);

        $filesystem->write('path.txt', 'foobar');
        $checksum = $filesystem->checksum('path.txt', ['checksum_algo' => 'sha256']);

        $this->assertSame('c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', $checksum);
    }

    /**
     * @test
     */
    public function unable_to_get_checksum_for_for_file_that_does_not_exist(): void
    {
        $filesystem = new Filesystem(new InMemoryFilesystemAdapter());

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

        $filesystem->checksum('path.txt');
    }

    /**
     * @test
     */
    public function generating_temporary_urls(): void
    {
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            temporaryUrlGenerator: new class() implements TemporaryUrlGenerator {
                public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string
                {
                    return 'https://flysystem.thephpleague.com/' . $path . '?exporesAt=' . $expiresAt->format('U');
                }
            }
        );

        $now = \time();
        $temporaryUrl = $filesystem->temporaryUrl('some/file.txt', new DateTimeImmutable('@' . $now));
        $expectedUrl = 'https://flysystem.thephpleague.com/some/file.txt?exporesAt=' . $now;

        self::assertEquals($expectedUrl, $temporaryUrl);
    }

    /**
     * @test
     */
    public function not_being_able_to_generate_temporary_urls(): void
    {
        $filesystem = new Filesystem(new InMemoryFilesystemAdapter());

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

        $filesystem->temporaryUrl('some/file.txt', new DateTimeImmutable());
    }

    /**
     * @test
     */
    public function ignoring_same_paths_for_move_and_copy(): void
    {
        $this->expectNotToPerformAssertions();

        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            [
                Config::OPTION_COPY_IDENTICAL_PATH => ResolveIdenticalPathConflict::IGNORE,
                Config::OPTION_MOVE_IDENTICAL_PATH => ResolveIdenticalPathConflict::IGNORE,
            ]
        );

        $filesystem->move('from.txt', 'from.txt');
        $filesystem->copy('from.txt', 'from.txt');
    }

    /**
     * @test
     */
    public function failing_same_paths_for_move(): void
    {
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            [
                Config::OPTION_MOVE_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL,
            ]
        );

        $this->expectExceptionObject(UnableToMoveFile::fromLocationTo('from.txt', 'from.txt'));
        $filesystem->move('from.txt', 'from.txt');
    }

    /**
     * @test
     */
    public function failing_same_paths_for_copy(): void
    {
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            [
                Config::OPTION_COPY_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL,
            ]
        );

        $this->expectExceptionObject(UnableToCopyFile::fromLocationTo('from.txt', 'from.txt'));
        $filesystem->copy('from.txt', 'from.txt');
    }

    /**
     * @test
     */
    public function unable_to_get_checksum_directory(): void
    {
        $filesystem = new Filesystem(new InMemoryFilesystemAdapter());
        $filesystem->createDirectory('foo');

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

        $filesystem->checksum('foo');
    }

    /**
     * @test
     *
     * @dataProvider fileMoveOrCopyScenarios
     */
    public function moving_a_file_with_visibility_scenario(
        array $mainConfig,
        array $moveConfig,
        ?string $writeVisibility,
        string $expectedVisibility
    ): void {
        // arrange
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            $mainConfig
        );
        $writeConfig = $writeVisibility ? ['visibility' => $writeVisibility] : [];
        $filesystem->write('from.txt', 'contents', $writeConfig);

        // act
        $filesystem->move('from.txt', 'to.txt', $moveConfig);

        // assert
        $this->assertEquals($expectedVisibility, $filesystem->visibility('to.txt'));
    }

    /**
     * @test
     *
     * @dataProvider fileMoveOrCopyScenarios
     */
    public function copying_a_file_with_visibility_scenario(
        array $mainConfig,
        array $copyConfig,
        ?string $writeVisibility,
        string $expectedVisibility
    ): void {
        // arrange
        $filesystem = new Filesystem(
            new InMemoryFilesystemAdapter(),
            $mainConfig
        );
        $writeConfig = $writeVisibility ? ['visibility' => $writeVisibility] : [];
        $filesystem->write('from.txt', 'contents', $writeConfig);

        // act
        $filesystem->copy('from.txt', 'to.txt', $copyConfig);

        // assert
        $this->assertEquals($expectedVisibility, $filesystem->visibility('to.txt'));
    }

    public static function fileMoveOrCopyScenarios(): iterable
    {
        yield 'retain visibility, write default, default private' => [
            ['retain_visibility' => true, 'visibility' => 'private'],
            [],
            null,
            'private'
        ];
        yield 'retain visibility, write default, default public' => [
            ['retain_visibility' => true, 'visibility' => 'public'],
            [],
            null,
            'public'
        ];
        yield 'retain visibility, write public, default private' => [
            ['retain_visibility' => true, 'visibility' => 'private'],
            [],
            'public',
            'public'
        ];
        yield 'retain visibility, write private, default public' => [
            ['retain_visibility' => true, 'visibility' => 'public'],
            [],
            'private',
            'private'
        ];

        yield 'retain visibility, write default, default private, execute public' => [
            ['retain_visibility' => true, 'visibility' => 'private'],
            ['visibility' => 'public'],
            null,
            'public'
        ];
        yield 'retain visibility, write default, default public, execute private' => [
            ['retain_visibility' => true, 'visibility' => 'public'],
            ['visibility' => 'private'],
            null,
            'private'
        ];
        yield 'retain visibility, write public, default private, execute private' => [
            ['retain_visibility' => true, 'visibility' => 'private'],
            ['visibility' => 'private'],
            'public',
            'private'
        ];
        yield 'retain visibility, write private, default public, execute public' => [
            ['retain_visibility' => true, 'visibility' => 'public'],
            ['visibility' => 'public'],
            'private',
            'public'
        ];

        yield 'do not retain visibility, write default, default private' => [
            ['retain_visibility' => false, 'visibility' => 'private'],
            [],
            null,
            'private'
        ];
        yield 'do not retain visibility, write default, default public' => [
            ['retain_visibility' => false, 'visibility' => 'public'],
            [],
            null,
            'public'
        ];
        yield 'do not retain visibility, write public, default private' => [
            ['retain_visibility' => false, 'visibility' => 'private'],
            [],
            'public',
            'private'
        ];
        yield 'do not retain visibility, write private, default public' => [
            ['retain_visibility' => false, 'visibility' => 'public'],
            [],
            'private',
            'public'
        ];

        yield 'do not retain visibility, write default, default private, execute public' => [
            ['retain_visibility' => false, 'visibility' => 'private'],
            ['visibility' => 'public'],
            null,
            'public'
        ];
        yield 'do not retain visibility, write default, default public, execute private' => [
            ['retain_visibility' => false, 'visibility' => 'public'],
            ['visibility' => 'private'],
            null,
            'private'
        ];
        yield 'do not retain visibility, write public, default private, execute public' => [
            ['retain_visibility' => false, 'visibility' => 'private'],
            ['visibility' => 'public'],
            'public',
            'public'
        ];
        yield 'do not retain visibility, write private, default public, execute private' => [
            ['retain_visibility' => false, 'visibility' => 'public'],
            ['visibility' => 'private'],
            'private',
            'private'
        ];
    }
}
