<?php

/**
 * Copyright 2017 Google Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
namespace DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Storage;

use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Core\Exception\NotFoundException;
use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Core\Exception\ServiceException;
use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Storage\Bucket;
use DeliciousBrains\WP_Offload_Media\Gcp\GuzzleHttp\Psr7\CachingStream;
/**
 * A streamWrapper implementation for handling `gs://bucket/path/to/file.jpg`.
 * Note that you can only open a file with mode 'r', 'rb', 'rt', 'w', 'wb', 'wt', 'a', 'ab', or 'at'.
 *
 * See: http://php.net/manual/en/class.streamwrapper.php
 */
class StreamWrapper
{
    const DEFAULT_PROTOCOL = 'gs';
    const FILE_WRITABLE_MODE = 33206;
    // 100666 in octal
    const FILE_READABLE_MODE = 33060;
    // 100444 in octal
    const DIRECTORY_WRITABLE_MODE = 16895;
    // 40777 in octal
    const DIRECTORY_READABLE_MODE = 16676;
    // 40444 in octal
    const TAIL_NAME_SUFFIX = '~';
    /**
     * @var resource|null Must be public according to the PHP documentation.
     *
     * Contains array of context options in form ['protocol' => ['option' => value]].
     * Options used by StreamWrapper:
     *
     * flush (bool) `true`: fflush() will flush output buffer; `false`: fflush() will do nothing
     */
    public $context;
    /**
     * @var \Psr\Http\Message\StreamInterface
     */
    private $stream;
    /**
     * @var string Protocol used to open this stream
     */
    private $protocol;
    /**
     * @var Bucket Reference to the bucket the opened file
     *      lives in or will live in.
     */
    private $bucket;
    /**
     * @var string Name of the file opened by this stream.
     */
    private $file;
    /**
     * @var StorageClient[] $clients The default clients to use if using
     *      global methods such as fopen on a stream wrapper. Keyed by protocol.
     */
    private static $clients = [];
    /**
     * @var ObjectIterator Used for iterating through a directory
     */
    private $directoryIterator;
    /**
     * @var StorageObject
     */
    private $object;
    /**
     * @var array Context options passed to stream_open(), used for append mode and flushing.
     */
    private $options = [];
    /**
     * @var bool `true`: fflush() will flush output buffer and redirect output to the "tail" object.
     */
    private $flushing = \false;
    /**
     * @var string|null Content type for composed object. Will be filled on first composing.
     */
    private $contentType = null;
    /**
     * @var bool `true`: writing the "tail" object, next fflush() or fclose() will compose.
     */
    private $composing = \false;
    /**
     * @var bool `true`: data has been written to the stream.
     */
    private $dirty = \false;
    /**
     * Ensure we close the stream when this StreamWrapper is destroyed.
     */
    public function __destruct()
    {
        $this->stream_close();
    }
    /**
     * Starting PHP 7.4, this is called when include/require is used on a stream.
     * Absence of this method presents a warning.
     * https://www.php.net/manual/en/migration74.incompatible.php
     */
    public function stream_set_option()
    {
        return \false;
    }
    /**
     * Register a StreamWrapper for reading and writing to Google Storage
     *
     * @param StorageClient $client The StorageClient configuration to use.
     * @param string $protocol The name of the protocol to use. **Defaults to**
     *        `gs`.
     * @throws \RuntimeException
     */
    public static function register(StorageClient $client, $protocol = null)
    {
        $protocol = $protocol ?: self::DEFAULT_PROTOCOL;
        if (!\in_array($protocol, \stream_get_wrappers())) {
            if (!\stream_wrapper_register($protocol, StreamWrapper::class, \STREAM_IS_URL)) {
                throw new \RuntimeException("Failed to register '{$protocol}://' protocol");
            }
            self::$clients[$protocol] = $client;
            return \true;
        }
        return \false;
    }
    /**
     * Unregisters the SteamWrapper
     *
     * @param string $protocol The name of the protocol to unregister. **Defaults
     *        to** `gs`.
     */
    public static function unregister($protocol = null)
    {
        $protocol = $protocol ?: self::DEFAULT_PROTOCOL;
        \stream_wrapper_unregister($protocol);
        unset(self::$clients[$protocol]);
    }
    /**
     * Get the default client to use for streams.
     *
     * @param string $protocol The name of the protocol to get the client for.
     *        **Defaults to** `gs`.
     * @return StorageClient
     */
    public static function getClient($protocol = null)
    {
        $protocol = $protocol ?: self::DEFAULT_PROTOCOL;
        return self::$clients[$protocol];
    }
    /**
     * Callback handler for when a stream is opened. For reads, we need to
     * download the file to see if it can be opened.
     *
     * @param string $path The path of the resource to open
     * @param string $mode The fopen mode. Currently supports ('r', 'rb', 'rt', 'w', 'wb', 'wt', 'a', 'ab', 'at')
     * @param int $flags Bitwise options STREAM_USE_PATH|STREAM_REPORT_ERRORS|STREAM_MUST_SEEK
     * @param string $openedPath Will be set to the path on success if STREAM_USE_PATH option is set
     * @return bool
     */
    public function stream_open($path, $mode, $flags, &$openedPath)
    {
        $client = $this->openPath($path);
        // strip off 'b' or 't' from the mode
        $mode = \rtrim($mode, 'bt');
        $options = [];
        if ($this->context) {
            $contextOptions = \stream_context_get_options($this->context);
            if (\array_key_exists($this->protocol, $contextOptions)) {
                $options = $contextOptions[$this->protocol] ?: [];
            }
            if (isset($options['flush'])) {
                $this->flushing = (bool) $options['flush'];
                unset($options['flush']);
            }
            $this->options = $options;
        }
        if ($mode == 'w') {
            $this->stream = new WriteStream(null, $options);
            $this->stream->setUploader($this->bucket->getStreamableUploader($this->stream, $options + ['name' => $this->file]));
        } elseif ($mode == 'a') {
            try {
                $info = $this->bucket->object($this->file)->info();
                $this->composing = $info['size'] > 0;
            } catch (NotFoundException $e) {
            }
            $this->stream = new WriteStream(null, $options);
            $name = $this->file;
            if ($this->composing) {
                $name .= self::TAIL_NAME_SUFFIX;
            }
            $this->stream->setUploader($this->bucket->getStreamableUploader($this->stream, $options + ['name' => $name]));
        } elseif ($mode == 'r') {
            try {
                // Lazy read from the source
                $options['restOptions']['stream'] = \true;
                $this->stream = new ReadStream($this->bucket->object($this->file)->downloadAsStream($options));
                // Wrap the response in a caching stream to make it seekable
                if (!$this->stream->isSeekable() && $flags & \STREAM_MUST_SEEK) {
                    $this->stream = new CachingStream($this->stream);
                }
            } catch (ServiceException $ex) {
                return $this->returnError($ex->getMessage(), $flags);
            }
        } else {
            return $this->returnError('Unknown stream_open mode.', $flags);
        }
        if ($flags & \STREAM_USE_PATH) {
            $openedPath = $path;
        }
        return \true;
    }
    /**
     * Callback handler for when we try to read a certain number of bytes.
     *
     * @param int $count The number of bytes to read.
     *
     * @return string
     */
    public function stream_read($count)
    {
        return $this->stream->read($count);
    }
    /**
     * Callback handler for when we try to write data to the stream.
     *
     * @param string $data The data to write
     *
     * @return int The number of bytes written.
     */
    public function stream_write($data)
    {
        $result = $this->stream->write($data);
        $this->dirty = $this->dirty || (bool) $result;
        return $result;
    }
    /**
     * Callback handler for getting data about the stream.
     *
     * @return array
     */
    public function stream_stat()
    {
        $mode = $this->stream->isWritable() ? self::FILE_WRITABLE_MODE : self::FILE_READABLE_MODE;
        return $this->makeStatArray(['mode' => $mode, 'size' => $this->stream->getSize()]);
    }
    /**
     * Callback handler for checking to see if the stream is at the end of file.
     *
     * @return bool
     */
    public function stream_eof()
    {
        return $this->stream->eof();
    }
    /**
     * Callback handler for trying to close the stream.
     */
    public function stream_close()
    {
        if (isset($this->stream)) {
            $this->stream->close();
        }
        if ($this->composing) {
            if ($this->dirty) {
                $this->compose();
                $this->dirty = \false;
            }
            try {
                $this->bucket->object($this->file . self::TAIL_NAME_SUFFIX)->delete();
            } catch (NotFoundException $e) {
            }
            $this->composing = \false;
        }
    }
    /**
     * Callback handler for trying to seek to a certain location in the stream.
     *
     * @param int $offset The stream offset to seek to
     * @param int $whence Flag for what the offset is relative to. See:
     *        http://php.net/manual/en/streamwrapper.stream-seek.php
     * @return bool
     */
    public function stream_seek($offset, $whence = \SEEK_SET)
    {
        if ($this->stream->isSeekable()) {
            $this->stream->seek($offset, $whence);
            return \true;
        }
        return \false;
    }
    /**
     * Callhack handler for inspecting our current position in the stream
     *
     * @return int
     */
    public function stream_tell()
    {
        return $this->stream->tell();
    }
    /**
     * Callback handler for trying to close an opened directory.
     *
     * @return bool
     */
    public function dir_closedir()
    {
        return \false;
    }
    /**
     * Callback handler for trying to open a directory.
     *
     * @param string $path The url directory to open
     * @param int $options Whether or not to enforce safe_mode
     * @return bool
     */
    public function dir_opendir($path, $options)
    {
        $this->openPath($path);
        return $this->dir_rewinddir();
    }
    /**
     * Callback handler for reading an entry from a directory handle.
     *
     * @return string|bool
     */
    public function dir_readdir()
    {
        $name = $this->directoryIterator->current();
        if ($name) {
            $this->directoryIterator->next();
            return $name;
        }
        return \false;
    }
    /**
     * Callback handler for rewind the directory handle.
     *
     * @return bool
     */
    public function dir_rewinddir()
    {
        try {
            $iterator = $this->bucket->objects(['prefix' => $this->file, 'fields' => 'items/name,nextPageToken']);
            // The delimiter options do not give us what we need, so instead we
            // list all results matching the given prefix, enumerate the
            // iterator, filter and transform results, and yield a fresh
            // generator containing only the directory listing.
            $this->directoryIterator = \call_user_func(function () use($iterator) {
                $yielded = [];
                $pathLen = \strlen($this->makeDirectory($this->file));
                foreach ($iterator as $object) {
                    $name = \substr($object->name(), $pathLen);
                    $parts = \explode('/', $name);
                    // since the service call returns nested results and we only
                    // want to yield results directly within the requested directory,
                    // check if we've already yielded this value.
                    if ($parts[0] === "" || \in_array($parts[0], $yielded)) {
                        continue;
                    }
                    $yielded[] = $parts[0];
                    (yield $name => $parts[0]);
                }
            });
        } catch (ServiceException $e) {
            return \false;
        }
        return \true;
    }
    /**
     * Callback handler for trying to create a directory. If no file path is specified,
     * or STREAM_MKDIR_RECURSIVE option is set, then create the bucket if it does not exist.
     *
     * @param string $path The url directory to create
     * @param int $mode The permissions on the directory
     * @param int $options Bitwise mask of options. STREAM_MKDIR_RECURSIVE
     * @return bool
     */
    public function mkdir($path, $mode, $options)
    {
        $path = $this->makeDirectory($path);
        $client = $this->openPath($path);
        $predefinedAcl = $this->determineAclFromMode($mode);
        try {
            if ($options & \STREAM_MKDIR_RECURSIVE || $this->file == '') {
                if (!$this->bucket->exists()) {
                    $client->createBucket($this->bucket->name(), ['predefinedAcl' => $predefinedAcl, 'predefinedDefaultObjectAcl' => $predefinedAcl]);
                }
            }
            // If the file name is empty, we were trying to create a bucket. In this case,
            // don't create the placeholder file.
            if ($this->file != '') {
                $bucketInfo = $this->bucket->info();
                $ublEnabled = isset($bucketInfo['iamConfiguration']['uniformBucketLevelAccess']) && $bucketInfo['iamConfiguration']['uniformBucketLevelAccess']['enabled'] === \true;
                // if bucket has uniform bucket level access enabled, don't set ACLs.
                $acl = [];
                if (!$ublEnabled) {
                    $acl = ['predefinedAcl' => $predefinedAcl];
                }
                // Fake a directory by creating an empty placeholder file whose name ends in '/'
                $this->bucket->upload('', ['name' => $this->file] + $acl);
            }
        } catch (ServiceException $e) {
            return \false;
        }
        return \true;
    }
    /**
     * Callback handler for trying to move a file or directory.
     *
     * @param string $from The URL to the current file
     * @param string $to The URL of the new file location
     * @return bool
     */
    public function rename($from, $to)
    {
        $this->openPath($from);
        $destination = (array) \parse_url($to) + ['path' => '', 'host' => ''];
        $destinationBucket = $destination['host'];
        $destinationPath = \substr($destination['path'], 1);
        // loop through to rename file and children, if given path is a directory.
        $objects = $this->bucket->objects(['prefix' => $this->file]);
        foreach ($objects as $obj) {
            $oldName = $obj->name();
            $newPath = \str_replace($this->file, $destinationPath, $oldName);
            try {
                $obj->rename($newPath, ['destinationBucket' => $destinationBucket]);
            } catch (ServiceException $e) {
                return \false;
            }
        }
        return \true;
    }
    /**
     * Callback handler for trying to remove a directory or a bucket. If the path is empty
     * or '/', the bucket will be deleted.
     *
     * Note that the STREAM_MKDIR_RECURSIVE flag is ignored because the option cannot
     * be set via the `rmdir()` function.
     *
     * @param string $path The URL directory to remove. If the path is empty or is '/',
     *        This will attempt to destroy the bucket.
     * @param int $options Bitwise mask of options.
     * @return bool
     */
    public function rmdir($path, $options)
    {
        $path = $this->makeDirectory($path);
        $this->openPath($path);
        try {
            if ($this->file == '') {
                $this->bucket->delete();
                return \true;
            } else {
                return $this->unlink($path);
            }
        } catch (ServiceException $e) {
            return \false;
        }
    }
    /**
     * Callback handler for retrieving the underlaying resource
     *
     * @param int $castAs STREAM_CAST_FOR_SELECT|STREAM_CAST_AS_STREAM
     * @return resource|bool
     */
    public function stream_cast($castAs)
    {
        return \false;
    }
    /**
     * Callback handler for deleting a file
     *
     * @param string $path The URL of the file to delete
     * @return bool
     */
    public function unlink($path)
    {
        $client = $this->openPath($path);
        $object = $this->bucket->object($this->file);
        try {
            $object->delete();
            return \true;
        } catch (ServiceException $e) {
            return \false;
        }
    }
    /**
     * Callback handler for retrieving information about a file
     *
     * @param string $path The URI to the file
     * @param int $flags Bitwise mask of options
     * @return array|bool
     */
    public function url_stat($path, $flags)
    {
        $client = $this->openPath($path);
        // if directory
        $dir = $this->getDirectoryInfo($this->file);
        if ($dir) {
            return $this->urlStatDirectory($dir);
        }
        return $this->urlStatFile();
    }
    /**
     * Callback handler for fflush() function.
     *
     * @return bool
     */
    public function stream_flush()
    {
        if (!$this->flushing) {
            return \false;
        }
        if (!$this->dirty) {
            return \true;
        }
        if (isset($this->stream)) {
            $this->stream->close();
        }
        if ($this->composing) {
            $this->compose();
        }
        $options = $this->options;
        $this->stream = new WriteStream(null, $options);
        $this->stream->setUploader($this->bucket->getStreamableUploader($this->stream, $options + ['name' => $this->file . self::TAIL_NAME_SUFFIX]));
        $this->composing = \true;
        $this->dirty = \false;
        return \true;
    }
    /**
     * Parse the URL and set protocol, filename and bucket.
     *
     * @param  string $path URL to open
     * @return StorageClient
     */
    private function openPath($path)
    {
        $url = (array) \parse_url($path) + ['scheme' => '', 'path' => '', 'host' => ''];
        $this->protocol = $url['scheme'];
        $this->file = \ltrim($url['path'], '/');
        $client = self::getClient($this->protocol);
        $this->bucket = $client->bucket($url['host']);
        return $client;
    }
    /**
     * Given a path, ensure that we return a path that looks like a directory
     *
     * @param  string $path
     * @return string
     */
    private function makeDirectory($path)
    {
        if ($path == '' or $path == '/') {
            return '';
        }
        if (\substr($path, -1) == '/') {
            return $path;
        }
        return $path . '/';
    }
    /**
     * Calculate the `url_stat` response for a directory
     *
     * @return array|bool
     */
    private function urlStatDirectory(StorageObject $object)
    {
        $stats = [];
        $info = $object->info();
        // equivalent to 40777 and 40444 in octal
        $stats['mode'] = $this->bucket->isWritable() ? self::DIRECTORY_WRITABLE_MODE : self::DIRECTORY_READABLE_MODE;
        $this->statsFromFileInfo($info, $stats);
        return $this->makeStatArray($stats);
    }
    /**
     * Calculate the `url_stat` response for a file
     *
     * @return array|bool
     */
    private function urlStatFile()
    {
        try {
            $this->object = $this->bucket->object($this->file);
            $info = $this->object->info();
        } catch (ServiceException $e) {
            // couldn't stat file
            return \false;
        }
        // equivalent to 100666 and 100444 in octal
        $stats = array('mode' => $this->bucket->isWritable() ? self::FILE_WRITABLE_MODE : self::FILE_READABLE_MODE);
        $this->statsFromFileInfo($info, $stats);
        return $this->makeStatArray($stats);
    }
    /**
     * Given a `StorageObject` info array, extract the available fields into the
     * provided `$stats` array.
     *
     * @param array $info Array provided from a `StorageObject`.
     * @param array $stats Array to put the calculated stats into.
     */
    private function statsFromFileInfo(array &$info, array &$stats)
    {
        $stats['size'] = isset($info['size']) ? (int) $info['size'] : null;
        $stats['mtime'] = isset($info['updated']) ? \strtotime($info['updated']) : null;
        $stats['ctime'] = isset($info['timeCreated']) ? \strtotime($info['timeCreated']) : null;
    }
    /**
     * Get the given path as a directory.
     *
     * In list objects calls, directories are returned with a trailing slash. By
     * providing the given path with a trailing slash as a list prefix, we can
     * check whether the given path exists as a directory.
     *
     * If the path does not exist or is not a directory, return null.
     *
     * @param  string $path
     * @return StorageObject|null
     */
    private function getDirectoryInfo($path)
    {
        $scan = $this->bucket->objects(['prefix' => $this->makeDirectory($path), 'resultLimit' => 1, 'fields' => 'items/name,items/size,items/updated,items/timeCreated,nextPageToken']);
        return $scan->current();
    }
    /**
     * Returns the associative array that a `stat()` response expects using the
     * provided stats. Defaults the remaining fields to 0.
     *
     * @param  array $stats Sparse stats entries to set.
     * @return array
     */
    private function makeStatArray($stats)
    {
        return \array_merge(\array_fill_keys(['dev', 'ino', 'mode', 'nlink', 'uid', 'gid', 'rdev', 'size', 'atime', 'mtime', 'ctime', 'blksize', 'blocks'], 0), $stats);
    }
    /**
     * Helper for whether or not to trigger an error or just return false on an error.
     *
     * @param  string $message The PHP error message to emit.
     * @param  int $flags Bitwise mask of options (STREAM_REPORT_ERRORS)
     * @return bool Returns false
     */
    private function returnError($message, $flags)
    {
        if ($flags & \STREAM_REPORT_ERRORS) {
            \trigger_error($message, \E_USER_WARNING);
        }
        return \false;
    }
    /**
     * Helper for determining which predefinedAcl to use given a mode.
     *
     * @param  int $mode Decimal representation of the file system permissions
     * @return string
     */
    private function determineAclFromMode($mode)
    {
        if ($mode & 04) {
            // If any user can read, assume it should be publicRead.
            return 'publicRead';
        } elseif ($mode & 040) {
            // If any group user can read, assume it should be projectPrivate.
            return 'projectPrivate';
        }
        // Otherwise, assume only the project/bucket owner can use the bucket.
        return 'private';
    }
    private function compose()
    {
        if (!isset($this->contentType)) {
            $info = $this->bucket->object($this->file)->info();
            $this->contentType = $info['contentType'] ?: 'application/octet-stream';
        }
        $options = ['destination' => ['contentType' => $this->contentType]];
        $this->bucket->compose([$this->file, $this->file . self::TAIL_NAME_SUFFIX], $this->file, $options);
    }
}
