<?php
/**
 * Copyright 2016 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 Google\Cloud\Core\Upload;

use Google\Cloud\Core\Exception\GoogleException;
use Google\Cloud\Core\Exception\ServiceException;
use Google\Cloud\Core\JsonTrait;
use Google\Cloud\Core\RequestWrapper;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\LimitStream;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;

/**
 * Resumable upload implementation.
 */
class ResumableUploader extends AbstractUploader
{
    use JsonTrait;

    /**
     * @var callable
     */
    private $uploadProgressCallback;

    /**
     * @var int
     */
    protected $rangeStart = 0;

    /**
     * @var string
     */
    protected $resumeUri;

    /**
     * Classes extending ResumableUploader may provide request headers to be
     * included in {@see \Google\Cloud\Core\Upload\ResumableUploader::upload()}
     * and {@see \Google\Cloud\Core\Upload\ResumableUploader::createResumeUri{}}.
     *
     * @var array
     */
    protected $headers = [];

    /**
     * @param RequestWrapper $requestWrapper
     * @param string|resource|StreamInterface $data
     * @param string $uri
     * @param array $options [optional] {
     *     Optional configuration.
     *
     *     @type array $metadata Metadata on the resource.
     *     @type callable $uploadProgressCallback The given callable
     *           function/method will be called after each successfully uploaded
     *           chunk. The callable function/method will receive the number of
     *           uploaded bytes after each uploaded chunk as a parameter to this
     *           callable. It's useful if you want to create a progress bar when
     *           using resumable upload type together with $chunkSize parameter.
     *           If $chunkSize is not set the callable function/method will be
     *           called only once after the successful file upload.
     *     @type int $chunkSize Size of the chunks to send incrementally during
     *           a resumable upload. Must be in multiples of 262144 bytes.
     *     @type array $restOptions HTTP client specific configuration options.
     *     @type int $retries Number of retries for a failed request.
     *           **Defaults to** `3`.
     *     @type string $contentType Content type of the resource.
     * }
     */
    public function __construct(
        RequestWrapper $requestWrapper,
        $data,
        $uri,
        array $options = []
    ) {
        parent::__construct($requestWrapper, $data, $uri, $options);

        // Set uploadProgressCallback if it's passed as an option.
        if (isset($options['uploadProgressCallback']) && is_callable($options['uploadProgressCallback'])) {
            $this->uploadProgressCallback = $options['uploadProgressCallback'];
        } elseif (isset($options['uploadProgressCallback'])) {
            throw new \InvalidArgumentException('$options.uploadProgressCallback must be a callable.');
        }
    }

    /**
     * Gets the resume URI.
     *
     * @return string
     */
    public function getResumeUri()
    {
        if (!$this->resumeUri) {
            return $this->createResumeUri();
        }

        return $this->resumeUri;
    }

    /**
     * Resumes a download using the provided URI.
     *
     * @param string $resumeUri
     * @return array
     * @throws GoogleException
     */
    public function resume($resumeUri)
    {
        if (!$this->data->isSeekable()) {
            throw new GoogleException('Cannot resume upload on a stream which cannot be seeked.');
        }

        $this->resumeUri = $resumeUri;
        $response = $this->getStatusResponse();

        if ($response->getBody()->getSize() > 0) {
            return $this->decodeResponse($response);
        }

        $this->rangeStart = $this->getRangeStart($response->getHeaderLine('Range'));

        return $this->upload();
    }

    /**
     * Triggers the upload process.
     *
     * Errors are of form [`google.rpc.Status`](https://cloud.google.com/apis/design/errors#error_model),
     * and may be obtained via {@see \Google\Cloud\Core\Exception\ServiceException::getMetadata()}.
     *
     * @return array
     * @throws ServiceException
     */
    public function upload()
    {
        $rangeStart = $this->rangeStart;
        $response = null;
        $resumeUri = $this->getResumeUri();
        $size = $this->data->getSize() ?: '*';

        do {
            $data = new LimitStream(
                $this->data,
                $this->chunkSize ?: - 1,
                $rangeStart
            );

            $currStreamLimitSize = $data->getSize();

            $rangeEnd = $rangeStart + ($currStreamLimitSize - 1);

            $headers = $this->headers + [
                'Content-Length' => $currStreamLimitSize,
                'Content-Type' => $this->contentType,
                'Content-Range' => "bytes $rangeStart-$rangeEnd/$size",
            ];

            $customHeaders = $this->requestOptions['restOptions']['headers'] ?? [];

            // Check if this chunk is the final one
            $isFinalChunk = ($size !== '*' && (int) ($rangeEnd + 1) === (int) $size);
            if (!$isFinalChunk) {
                unset($customHeaders['X-Goog-Hash']);
            }

            $headers = array_merge($headers, $customHeaders);

            $request = new Request(
                'PUT',
                $resumeUri,
                $headers,
                $data
            );

            try {
                $response = $this->requestWrapper->send($request, $this->requestOptions);
            } catch (GoogleException $ex) {
                throw new ServiceException(
                    "Upload failed. Please use this URI to resume your upload: $this->resumeUri",
                    $ex->getCode(),
                    null,
                    json_decode($ex->getMessage(), true) ?: []
                );
            }

            if (is_callable($this->uploadProgressCallback)) {
                call_user_func($this->uploadProgressCallback, $currStreamLimitSize);
            }

            $rangeStart = $this->getRangeStart($response->getHeaderLine('Range'));
        } while ($response->getStatusCode() === 308);

        return $this->decodeResponse($response);
    }

    /**
     * Currently only the MultiPartUploader supports async.
     *
     * Any calls to this will throw a generic Google Exception.
     *
     * @return PromiseInterface
     * @throws GoogleException
     * @experimental The experimental flag means that while we believe this method
     *      or class is ready for use, it may change before release in backwards-
     *      incompatible ways. Please use with caution, and test thoroughly when
     *      upgrading.
     */
    public function uploadAsync()
    {
        throw new GoogleException('Currently only the MultiPartUploader supports async.');
    }

    /**
     * Fetch and decode the response body
     *
     * @param ResponseInterface $response
     * @return array
     */
    protected function decodeResponse(ResponseInterface $response)
    {
        return $this->jsonDecode($response->getBody(), true);
    }

    /**
     * Creates the resume URI.
     *
     * @return string
     */
    protected function createResumeUri()
    {
        $headers = $this->headers + [
            'X-Upload-Content-Type' => $this->contentType,
            'X-Upload-Content-Length' => $this->data->getSize(),
            'Content-Type' => 'application/json'
        ];

        $body = $this->jsonEncode($this->metadata);

        $request = new Request(
            'POST',
            $this->uri,
            $headers,
            $body
        );

        $response = $this->requestWrapper->send($request, $this->requestOptions);

        return $this->resumeUri = $response->getHeaderLine('Location');
    }

    /**
     * Gets the status of the upload.
     *
     * @return ResponseInterface
     */
    protected function getStatusResponse()
    {
        $request = new Request(
            'PUT',
            $this->resumeUri,
            ['Content-Range' => 'bytes */' . $this->data->getSize()]
        );

        return $this->requestWrapper->send($request, $this->requestOptions);
    }

    /**
     * Gets the starting range for the upload.
     *
     * @param string $rangeHeader
     * @return int
     */
    protected function getRangeStart($rangeHeader)
    {
        if (!$rangeHeader) {
            // assume no bytes are uploaded if no range header is present
            return 0;
        }

        return (int) explode('-', $rangeHeader)[1] + 1;
    }
}
