<?php

declare(strict_types=1);

namespace League\Flysystem\AzureBlobStorage;

use DateTime;
use DateTimeInterface;
use League\Flysystem\ChecksumAlgoIsNotSupported;
use League\Flysystem\ChecksumProvider;
use League\Flysystem\Config;
use League\Flysystem\DirectoryAttributes;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\PathPrefixer;
use League\Flysystem\UnableToCheckDirectoryExistence;
use League\Flysystem\UnableToCheckFileExistence;
use League\Flysystem\UnableToCopyFile;
use League\Flysystem\UnableToDeleteDirectory;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToGenerateTemporaryUrl;
use League\Flysystem\UnableToMoveFile;
use League\Flysystem\UnableToProvideChecksum;
use League\Flysystem\UnableToReadFile;
use League\Flysystem\UnableToRetrieveMetadata;
use League\Flysystem\UnableToSetVisibility;
use League\Flysystem\UnableToWriteFile;
use League\Flysystem\UrlGeneration\PublicUrlGenerator;
use League\Flysystem\UrlGeneration\TemporaryUrlGenerator;
use League\MimeTypeDetection\FinfoMimeTypeDetector;
use League\MimeTypeDetection\MimeTypeDetector;
use MicrosoftAzure\Storage\Blob\BlobRestProxy;
use MicrosoftAzure\Storage\Blob\BlobSharedAccessSignatureHelper;
use MicrosoftAzure\Storage\Blob\Models\BlobProperties;
use MicrosoftAzure\Storage\Blob\Models\CreateBlockBlobOptions;
use MicrosoftAzure\Storage\Blob\Models\ListBlobsOptions;
use MicrosoftAzure\Storage\Common\Exceptions\ServiceException;
use MicrosoftAzure\Storage\Common\Internal\Resources;
use MicrosoftAzure\Storage\Common\Internal\StorageServiceSettings;
use MicrosoftAzure\Storage\Common\Models\ContinuationToken;
use Throwable;
use function base64_decode;
use function bin2hex;
use function stream_get_contents;

class AzureBlobStorageAdapter implements FilesystemAdapter, PublicUrlGenerator, ChecksumProvider, TemporaryUrlGenerator
{
    /** @var string[] */
    private const META_OPTIONS = [
        'CacheControl',
        'ContentType',
        'Metadata',
        'ContentLanguage',
        'ContentEncoding',
    ];
    const ON_VISIBILITY_THROW_ERROR = 'throw';
    const ON_VISIBILITY_IGNORE = 'ignore';

    private MimeTypeDetector $mimeTypeDetector;
    private PathPrefixer $prefixer;

    public function __construct(
        private BlobRestProxy $client,
        private string $container,
        string $prefix = '',
        MimeTypeDetector $mimeTypeDetector = null,
        private int $maxResultsForContentsListing = 5000,
        private string $visibilityHandling = self::ON_VISIBILITY_THROW_ERROR,
        private ?StorageServiceSettings $serviceSettings = null,
    ) {
        $this->prefixer = new PathPrefixer($prefix);
        $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector();
    }

    public function copy(string $source, string $destination, Config $config): void
    {
        $resolvedDestination = $this->prefixer->prefixPath($destination);
        $resolvedSource = $this->prefixer->prefixPath($source);

        try {
            $this->client->copyBlob(
                $this->container,
                $resolvedDestination,
                $this->container,
                $resolvedSource
            );
        } catch (Throwable $throwable) {
            throw UnableToCopyFile::fromLocationTo($source, $destination, $throwable);
        }
    }

    public function delete(string $path): void
    {
        $location = $this->prefixer->prefixPath($path);

        try {
            $this->client->deleteBlob($this->container, $location);
        } catch (Throwable $exception) {
            if ($exception instanceof ServiceException && $exception->getCode() === 404) {
                return;
            }

            throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception);
        }
    }

    public function read(string $path): string
    {
        $response = $this->readStream($path);

        return stream_get_contents($response);
    }

    public function readStream(string $path)
    {
        $location = $this->prefixer->prefixPath($path);

        try {
            $response = $this->client->getBlob($this->container, $location);

            return $response->getContentStream();
        } catch (Throwable $exception) {
            throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception);
        }
    }

    public function listContents(string $path, bool $deep = false): iterable
    {
        $resolved = $this->prefixer->prefixDirectoryPath($path);

        $options = new ListBlobsOptions();
        $options->setPrefix($resolved);
        $options->setMaxResults($this->maxResultsForContentsListing);

        if ($deep === false) {
            $options->setDelimiter('/');
        }

        do {
            $response = $this->client->listBlobs($this->container, $options);

            foreach ($response->getBlobPrefixes() as $blobPrefix) {
                yield new DirectoryAttributes($this->prefixer->stripDirectoryPrefix($blobPrefix->getName()));
            }

            foreach ($response->getBlobs() as $blob) {
                yield $this->normalizeBlobProperties(
                    $this->prefixer->stripPrefix($blob->getName()),
                    $blob->getProperties()
                );
            }

            $continuationToken = $response->getContinuationToken();
            $options->setContinuationToken($continuationToken);
        } while ($continuationToken instanceof ContinuationToken);
    }

    public function fileExists(string $path): bool
    {
        $resolved = $this->prefixer->prefixPath($path);
        try {
            return $this->fetchMetadata($resolved) !== null;
        } catch (Throwable $exception) {
            if ($exception instanceof ServiceException && $exception->getCode() === 404) {
                return false;
            }
            throw UnableToCheckFileExistence::forLocation($path, $exception);
        }
    }

    public function directoryExists(string $path): bool
    {
        $resolved = $this->prefixer->prefixDirectoryPath($path);
        $options = new ListBlobsOptions();
        $options->setPrefix($resolved);
        $options->setMaxResults(1);

        try {
            $listResults = $this->client->listBlobs($this->container, $options);

            return count($listResults->getBlobs()) > 0;
        } catch (Throwable $exception) {
            throw UnableToCheckDirectoryExistence::forLocation($path, $exception);
        }
    }

    public function deleteDirectory(string $path): void
    {
        $resolved = $this->prefixer->prefixDirectoryPath($path);
        $options = new ListBlobsOptions();
        $options->setPrefix($resolved);

        try {
            start:
            $listResults = $this->client->listBlobs($this->container, $options);

            foreach ($listResults->getBlobs() as $blob) {
                $this->client->deleteBlob($this->container, $blob->getName());
            }

            $continuationToken = $listResults->getContinuationToken();

            if ($continuationToken instanceof ContinuationToken) {
                $options->setContinuationToken($continuationToken);
                goto start;
            }
        } catch (Throwable $exception) {
            throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception);
        }
    }

    public function createDirectory(string $path, Config $config): void
    {
        // this is not supported by Azure
    }

    public function setVisibility(string $path, string $visibility): void
    {
        if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) {
            throw UnableToSetVisibility::atLocation($path, 'Azure does not support this operation.');
        }
    }

    public function visibility(string $path): FileAttributes
    {
        throw UnableToRetrieveMetadata::visibility($path, 'Azure does not support visibility');
    }

    public function mimeType(string $path): FileAttributes
    {
        try {
            return $this->fetchMetadata($this->prefixer->prefixPath($path));
        } catch (Throwable $exception) {
            throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception);
        }
    }

    public function lastModified(string $path): FileAttributes
    {
        try {
            return $this->fetchMetadata($this->prefixer->prefixPath($path));
        } catch (Throwable $exception) {
            throw UnableToRetrieveMetadata::lastModified($path, $exception->getMessage(), $exception);
        }
    }

    public function fileSize(string $path): FileAttributes
    {
        try {
            return $this->fetchMetadata($this->prefixer->prefixPath($path));
        } catch (Throwable $exception) {
            throw UnableToRetrieveMetadata::fileSize($path, $exception->getMessage(), $exception);
        }
    }

    public function move(string $source, string $destination, Config $config): void
    {
        try {
            $this->copy($source, $destination, $config);
            $this->delete($source);
        } catch (Throwable $exception) {
            throw UnableToMoveFile::fromLocationTo($source, $destination, $exception);
        }
    }

    public function write(string $path, string $contents, Config $config): void
    {
        $this->upload($path, $contents, $config);
    }

    public function writeStream(string $path, $contents, Config $config): void
    {
        $this->upload($path, $contents, $config);
    }

    /**
     * @param string|resource $contents
     */
    private function upload(string $destination, $contents, Config $config): void
    {
        $resolved = $this->prefixer->prefixPath($destination);
        try {
            $options = $this->getOptionsFromConfig($config);

            if (empty($options->getContentType())) {
                $options->setContentType($this->mimeTypeDetector->detectMimeType($resolved, $contents));
            }

            $this->client->createBlockBlob(
                $this->container,
                $resolved,
                $contents,
                $options
            );
        } catch (Throwable $exception) {
            throw UnableToWriteFile::atLocation($destination, $exception->getMessage(), $exception);
        }
    }

    private function fetchMetadata(string $path): FileAttributes
    {
        return $this->normalizeBlobProperties(
            $path,
            $this->client->getBlobProperties($this->container, $path)->getProperties()
        );
    }

    private function getOptionsFromConfig(Config $config): CreateBlockBlobOptions
    {
        $options = new CreateBlockBlobOptions();

        foreach (self::META_OPTIONS as $option) {
            $setting = $config->get($option, '___NOT__SET___');

            if ($setting === '___NOT__SET___') {
                continue;
            }

            call_user_func([$options, "set$option"], $setting);
        }

        $mimeType = $config->get('mimetype');

        if ($mimeType !== null) {
            $options->setContentType($mimeType);
        }

        return $options;
    }

    private function normalizeBlobProperties(string $path, BlobProperties $properties): FileAttributes
    {
        return new FileAttributes(
            $path,
            $properties->getContentLength(),
            null,
            $properties->getLastModified()->getTimestamp(),
            $properties->getContentType(),
            ['md5_checksum' => $properties->getContentMD5()]
        );
    }

    public function publicUrl(string $path, Config $config): string
    {
        $location = $this->prefixer->prefixPath($path);

        return $this->client->getBlobUrl($this->container, $location);
    }

    public function checksum(string $path, Config $config): string
    {
        $algo = $config->get('checksum_algo', 'md5');

        if ($algo !== 'md5') {
            throw new ChecksumAlgoIsNotSupported();
        }

        try {
            $metadata = $this->fetchMetadata($this->prefixer->prefixPath($path));
            $checksum = $metadata->extraMetadata()['md5_checksum'] ?? '__not_specified';
        } catch (Throwable $exception) {
            throw new UnableToProvideChecksum($exception->getMessage(), $path, $exception);
        }

        if ($checksum === '__not_specified') {
            throw new UnableToProvideChecksum('No checksum provided in metadata', $path);
        }

        return bin2hex(base64_decode($checksum));
    }

    public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string
    {
        if ( ! $this->serviceSettings instanceof StorageServiceSettings) {
            throw UnableToGenerateTemporaryUrl::noGeneratorConfigured(
                $path,
                'The $serviceSettings constructor parameter must be set to generate temporary URLs.',
            );
        }

        try {
            $sas = new BlobSharedAccessSignatureHelper($this->serviceSettings->getName(), $this->serviceSettings->getKey());
            $baseUrl = $this->publicUrl($path, $config);
            $resourceName = $this->container . '/' . ltrim($this->prefixer->prefixPath($path), '/');
            $token = $sas->generateBlobServiceSharedAccessSignatureToken(
                Resources::RESOURCE_TYPE_BLOB,
                $resourceName,
                'r', // read
                DateTime::createFromInterface($expiresAt),
                $config->get('signed_start', ''),
                $config->get('signed_ip', ''),
                $config->get('signed_protocol', 'https'),
                $config->get('signed_identifier', ''),
                $config->get('cache_control', ''),
                $config->get('content_disposition', ''),
                $config->get('content_encoding', ''),
                $config->get('content_language', ''),
                $config->get('content_type', ''),
            );

            return "$baseUrl?$token";
        } catch (Throwable $exception) {
            throw UnableToGenerateTemporaryUrl::dueToError($path, $exception);
        }
    }
}
