<?php
/**
 * Copyright 2010-2013 Amazon.com, Inc. or its affiliates. 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.
 * A copy of the License is located at
 *
 * http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file 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 DuplicatorPro\Aws\S3;

defined("ABSPATH") or die("");

use DuplicatorPro\Aws\Common\Exception\RuntimeException;
use DuplicatorPro\Aws\Common\Exception\UnexpectedValueException;
use DuplicatorPro\Guzzle\Http\EntityBody;
use DuplicatorPro\Guzzle\Http\ReadLimitEntityBody;
use DuplicatorPro\Guzzle\Http\EntityBodyInterface;
use DuplicatorPro\Guzzle\Service\Resource\Model;

/**
 * Allows you to resume the download of a partially downloaded object.
 *
 * Downloads objects from Amazon S3 in using "Range" downloads. This allows a partially downloaded object to be resumed
 * so that only the remaining portion of the object is downloaded.
 */
class ResumableDownload
{
    /** @var S3Client The S3 client to use to download objects and issue HEAD requests */
    protected $client;

    /** @var Model Model object returned when the initial HeadObject operation was called */
    protected $meta;

    /** @var array Array of parameters to pass to a GetObject operation */
    protected $params;

    /** @var EntityBody Where the object will be downloaded */
    protected $target;

    /**
     * @param S3Client                            $client Client to use when executing requests
     * @param string                              $bucket Bucket that holds the object
     * @param string                              $key    Key of the object
     * @param string|resource|EntityBodyInterface $target Where the object should be downloaded to. Pass a string to
     *                                                    save the object to a file, pass a resource returned by
     *                                                    fopen() to save the object to a stream resource, or pass a
     *                                                    Guzzle EntityBody object to save the contents to an
     *                                                    EntityBody.
     * @param array                               $params Any additional GetObject or HeadObject parameters to use
     *                                                    with each command issued by the client. (e.g. pass "Version"
     *                                                    to download a specific version of an object)
     * @throws RuntimeException if the target variable points to a file that cannot be opened
     */
    public function __construct(S3Client $client, $bucket, $key, $target, array $params = array())
    {
        $this->params = $params;
        $this->client = $client;
        $this->params['Bucket'] = $bucket;
        $this->params['Key'] = $key;

        // If a string is passed, then assume that the download should stream to a file on disk
        if (is_string($target)) {
            if (!($target = fopen($target, 'a+'))) {
                throw new RuntimeException("Unable to open {$target} for writing");
            }
            // Always append to the file
            fseek($target, 0, SEEK_END);
        }

        // Get the metadata and Content-MD5 of the object
        $this->target = EntityBody::factory($target);
    }

    /**
     * Get the bucket of the download
     *
     * @return string
     */
    public function getBucket()
    {
        return $this->params['Bucket'];
    }

    /**
     * Get the key of the download
     *
     * @return string
     */
    public function getKey()
    {
        return $this->params['Key'];
    }

    /**
     * Get the file to which the contents are downloaded
     *
     * @return string
     */
    public function getFilename()
    {
        return $this->target->getUri();
    }

    /**
     * Download the remainder of the object from Amazon S3
     *
     * Performs a message integrity check if possible
     *
     * @return Model
     */
    public function __invoke()
    {
        $command = $this->client->getCommand('HeadObject', $this->params);
        $this->meta = $command->execute();

        if ($this->target->ftell() >= $this->meta['ContentLength']) {
            return false;
        }

        $this->meta['ContentMD5'] = (string) $command->getResponse()->getHeader('Content-MD5');

        // Use a ReadLimitEntityBody so that rewinding the stream after an error does not cause the file pointer
        // to enter an inconsistent state with the data being downloaded
        $this->params['SaveAs'] = new ReadLimitEntityBody(
            $this->target,
            $this->meta['ContentLength'],
            $this->target->ftell()
        );

        $result = $this->getRemaining();
        $this->checkIntegrity();

        return $result;
    }

    /**
     * Send the command to get the remainder of the object
     *
     * @return Model
     */
    protected function getRemaining()
    {
        $current = $this->target->ftell();
        $targetByte = $this->meta['ContentLength'] - 1;
        $this->params['Range'] = "bytes={$current}-{$targetByte}";

        // Set the starting offset so that the body is never seeked to before this point in the event of a retry
        $this->params['SaveAs']->setOffset($current);
        $command = $this->client->getCommand('GetObject', $this->params);

        return $command->execute();
    }

    /**
     * Performs an MD5 message integrity check if possible
     *
     * @throws UnexpectedValueException if the message does not validate
     */
    protected function checkIntegrity()
    {
        if ($this->target->isReadable() && $expected = $this->meta['ContentMD5']) {
            $actual = $this->target->getContentMd5();
            if ($actual != $expected) {
                throw new UnexpectedValueException(
                    "Message integrity check failed. Expected {$expected} but got {$actual}."
                );
            }
        }
    }
}
