<?php

/**
 * Dropbox API base class
 * @author Ben Tadiar <ben@handcraftedbyben.co.uk>
 * @link https://github.com/benthedesigner/dropbox
 * @link https://www.dropbox.com/developers
 * @link https://status.dropbox.com Dropbox status
 * @package Dropbox
 */
class UpdraftPlus_Dropbox_API
{
    // API Endpoints
    const API_URL     = 'https://api.dropbox.com/1/';
    const API_URL_V2  = 'https://api.dropboxapi.com/2/';
    const CONTENT_URL = 'https://api-content.dropbox.com/1/';
    
    /**
     * OAuth consumer object
     * @var null|OAuth\Consumer 
     */
    private $OAuth;
    
    /**
     * The root level for file paths
     * Either `dropbox` or `sandbox` (preferred)
     * @var null|string
     */
    private $root;
    
    /**
     * Format of the API response
     * @var string
     */
    private $responseFormat = 'php';
    
    /**
     * JSONP callback
     * @var string
     */
    private $callback = 'dropboxCallback';
    
    /**
     * Chunk size used for chunked uploads
     * @see \Dropbox\API::chunkedUpload()
     */
    private $chunkSize = 4194304;
    
    /**
     * Set the OAuth consumer object
     * See 'General Notes' at the link below for information on access type
     * @link https://www.dropbox.com/developers/reference/api
     * @param OAuth\Consumer\ConsumerAbstract $OAuth
     * @param string $root Dropbox app access type
     */
    public function __construct(Dropbox_ConsumerAbstract $OAuth, $root = 'sandbox')
    {
        $this->OAuth = $OAuth;
        $this->setRoot($root);
    }
    
    /**
    * Set the root level
    * @param mixed $root
    * @throws Exception
    * @return void
    */
    public function setRoot($root)
    {
        if ($root !== 'sandbox' && $root !== 'dropbox') {
            throw new Exception("Expected a root of either 'dropbox' or 'sandbox', got '$root'");
        } else {
            $this->root = $root;
        }
    }
    
    /**
     * Retrieves information about the user's account
     * @return object stdClass
     */
    public function accountInfo()
    {
        $response = $this->fetch('POST', self::API_URL, 'account/info');
        return $response;
    }
    
    /**
     * Uploads a physical file from disk
     * Dropbox impose a 150MB limit to files uploaded via the API. If the file
     * exceeds this limit or does not exist, an Exception will be thrown
     * @param string $file Absolute path to the file to be uploaded
     * @param string|bool $filename The destination filename of the uploaded file
     * @param string $path Path to upload the file to, relative to root
     * @param boolean $overwrite Should the file be overwritten? (Default: true)
     * @return object stdClass
     */
    public function putFile($file, $filename = false, $path = '', $overwrite = true)
    {
        if (file_exists($file)) {
            if (filesize($file) <= 157286400) {
                $call = 'files/' . $this->root . '/' . $this->encodePath($path);
                // If no filename is provided we'll use the original filename
                $filename = (is_string($filename)) ? $filename : basename($file);
                $params = array(
                    'filename' => $filename,
                    'file' => '@' . str_replace('\\', '/', $file) . ';filename=' . $filename,
                    'overwrite' => (int) $overwrite,
                );
                $response = $this->fetch('POST', self::CONTENT_URL, $call, $params);
                return $response;
            }
            throw new Exception('File exceeds 150MB upload limit');
        }
        
        // Throw an Exception if the file does not exist
        throw new Exception('Local file ' . $file . ' does not exist');
    }
    
    /**
     * Uploads file data from a stream
     * Note: This function is experimental and requires further testing
     * @todo Add filesize check
     * @param resource $stream A readable stream created using fopen()
     * @param string $filename The destination filename, including path
     * @param boolean $overwrite Should the file be overwritten? (Default: true)
     * @return array
     */
    public function putStream($stream, $filename, $overwrite = true)
    {
        $this->OAuth->setInFile($stream);
        $path = $this->encodePath($filename);
        $call = 'files_put/' . $this->root . '/' . $path;
        $params = array('overwrite' => (int) $overwrite);
        $response = $this->fetch('PUT', self::CONTENT_URL, $call, $params);
        return $response;
    }
    
    /**
     * Uploads large files to Dropbox in mulitple chunks
     * @param string $file Absolute path to the file to be uploaded
     * @param string|bool $filename The destination filename of the uploaded file
     * @param string $path Path to upload the file to, relative to root
     * @param boolean $overwrite Should the file be overwritten? (Default: true)
     * @param integer $offset position to seek to when opening the file
     * @param string $uploadID existing upload_id to resume an upload
     * @param string|array function to call back to upon each chunk
     * @return stdClass
     */
    public function chunkedUpload($file, $filename = false, $path = '', $overwrite = true, $offset = 0, $uploadID = null, $callback = null)
    {
        if (file_exists($file)) {
            if ($handle = @fopen($file, 'r')) {
                // Set initial upload ID and offset
                if ($offset > 0) {
                    fseek($handle, $offset);
                }
                
                // Read from the file handle until EOF, uploading each chunk
                while ($data = fread($handle, $this->chunkSize)) {
                    // Open a temporary file handle and write a chunk of data to it
                    $chunkHandle = fopen('php://temp', 'rw');
                    fwrite($chunkHandle, $data);
                    
                    // Set the file, request parameters and send the request
                    $this->OAuth->setInFile($chunkHandle);
                    $params = array('upload_id' => $uploadID, 'offset' => $offset);
                    $response = $this->fetch('PUT', self::CONTENT_URL, 'chunked_upload', $params);
                    
                    // On subsequent chunks, use the upload ID returned by the previous request
                    if (isset($response['body']->upload_id)) {
                        $uploadID = $response['body']->upload_id;
                    }
                    
                    if (isset($response['body']->offset)) {
                        $offset = $response['body']->offset;
                        if ($callback) {
                            call_user_func($callback, $offset, $uploadID, $file);
                        }
                    }                    
                    
                    // Close the file handle for this chunk
                    fclose($chunkHandle);
                }
                
                // Complete the chunked upload
                $filename = (is_string($filename)) ? $filename : basename($file);
                $call = 'commit_chunked_upload/' . $this->root . '/' . $this->encodePath($path . $filename);
                $params = array('overwrite' => (int) $overwrite, 'upload_id' => $uploadID);
                $response = $this->fetch('POST', self::CONTENT_URL, $call, $params);
                return $response;
            } else {
                throw new Exception('Could not open ' . $file . ' for reading');
            }
        }
        
        // Throw an Exception if the file does not exist
        throw new Exception('Local file ' . $file . ' does not exist');
    }
    
    /**
     * Downloads a file
     * Returns the base filename, raw file data and mime type returned by Fileinfo
     * @param string $file Path to file, relative to root, including path
     * @param string $outFile Filename to write the downloaded file to
     * @param string $revision The revision of the file to retrieve
     * @param boolean $allow_resume - append to the file if it already exists
     * @return array
     */
    public function getFile($file, $outFile = false, $revision = null, $allow_resume = false)
    {
        // Only allow php response format for this call
        if ($this->responseFormat !== 'php') {
            throw new Exception('This method only supports the `php` response format');
        }

        $params = array('rev' => $revision);
        
        $handle = null;
        if ($outFile !== false) {
            // Create a file handle if $outFile is specified
            if ($allow_resume && file_exists($outFile)) {
               if (!$handle = fopen($outFile, 'a')) {
                   throw new Exception("Unable to open file handle for $outFile");
               } else {
                   $this->OAuth->setOutFile($handle);
                   $params['headers'] = array('Range: bytes='.filesize($outFile).'-');
               }
            }
            elseif (!$handle = fopen($outFile, 'w')) {
                throw new Exception("Unable to open file handle for $outFile");
            } else {
                $this->OAuth->setOutFile($handle);
            }
        }
        
        $file = $this->encodePath($file);        
        $call = 'files/' . $this->root . '/' . $file;
        $response = $this->fetch('GET', self::CONTENT_URL, $call, $params);
        
        // Close the file handle if one was opened
        if ($handle) fclose($handle);

        return array(
            'name' => ($outFile) ? $outFile : basename($file),
            'mime' => $this->getMimeType(($outFile) ? $outFile : $response['body'], $outFile),
            'meta' => json_decode($response['headers']['x-dropbox-metadata']),
            'data' => $response['body'],
        );
    }
    
    /**
     * Retrieves file and folder metadata
     * @param string $path The path to the file/folder, relative to root
     * @param string $rev Return metadata for a specific revision (Default: latest rev)
     * @param int $limit Maximum number of listings to return
     * @param string $hash Metadata hash to compare against
     * @param bool $list Return contents field with response
     * @param bool $deleted Include files/folders that have been deleted
     * @return object stdClass 
     */
    public function metaData($path = null, $rev = null, $limit = 10000, $hash = false, $list = true, $deleted = false)
    {
        $call = 'metadata/' . $this->root . '/' . $this->encodePath($path);
        $params = array(
            'file_limit' => ($limit < 1) ? 1 : (($limit > 10000) ? 10000 : (int) $limit),
            'hash' => (is_string($hash)) ? $hash : 0,
            'list' => (int) $list,
            'include_deleted' => (int) $deleted,
            'rev' => (is_string($rev)) ? $rev : null,
        );
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Return "delta entries", intructing you how to update
     * your application state to match the server's state
     * Important: This method does not make changes to the application state
     * @param null|string $cursor Used to keep track of your current state
     * @return array Array of delta entries
     */
    public function delta($cursor = null)
    {
        $call = 'delta';
        $params = array('cursor' => $cursor);
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Obtains metadata for the previous revisions of a file
     * @param string Path to the file, relative to root
     * @param integer Number of revisions to return (1-1000)
     * @return array
     */
    public function revisions($file, $limit = 10)
    {
        $call = 'revisions/' . $this->root . '/' . $this->encodePath($file);
        $params = array(
            'rev_limit' => ($limit < 1) ? 1 : (($limit > 1000) ? 1000 : (int) $limit),
        );
        $response = $this->fetch('GET', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Restores a file path to a previous revision
     * @param string $file Path to the file, relative to root
     * @param string $revision The revision of the file to restore
     * @return object stdClass
     */
    public function restore($file, $revision)
    {
        $call = 'restore/' . $this->root . '/' . $this->encodePath($file);
        $params = array('rev' => $revision);
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Returns metadata for all files and folders that match the search query
     * @param mixed $query The search string. Must be at least 3 characters long
     * @param string $path The path to the folder you want to search in
     * @param integer $limit Maximum number of results to return (1-1000)
     * @param boolean $deleted Include deleted files/folders in the search
     * @return array
     */
    public function search($query, $path = '', $limit = 1000, $deleted = false)
    {
        $call = 'search/' . $this->root . '/' . $this->encodePath($path);
        $params = array(
            'query' => $query,
            'file_limit' => ($limit < 1) ? 1 : (($limit > 1000) ? 1000 : (int) $limit),
            'include_deleted' => (int) $deleted,
        );
        $response = $this->fetch('GET', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Creates and returns a shareable link to files or folders
     * The link returned is for a preview page from which the user an choose to
     * download the file if they wish. For direct download links, see media().
     * @param string $path The path to the file/folder you want a sharable link to
     * @return object stdClass
     */
    public function shares($path, $shortUrl = true)
    {
        $call = 'shares/' . $this->root . '/' .$this->encodePath($path);
        $params = array('short_url' => ($shortUrl) ? 1 : 0);
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Returns a link directly to a file
     * @param string $path The path to the media file you want a direct link to
     * @return object stdClass
     */
    public function media($path)
    {
        $call = 'media/' . $this->root . '/' . $this->encodePath($path);
        $response = $this->fetch('POST', self::API_URL, $call);
        return $response;
    }
    
    /**
     * Gets a thumbnail for an image
     * @param string $file The path to the image you wish to thumbnail
     * @param string $format The thumbnail format, either JPEG or PNG
     * @param string $size The size of the thumbnail
     * @return array
     */
    public function thumbnails($file, $format = 'JPEG', $size = 'small')
    {
        // Only allow php response format for this call
        if ($this->responseFormat !== 'php') {
            throw new Exception('This method only supports the `php` response format');
        }
        
        $format = strtoupper($format);
        // If $format is not 'PNG', default to 'JPEG'
        if ($format != 'PNG') $format = 'JPEG';
        
        $size = strtolower($size);
        $sizes = array('s', 'm', 'l', 'xl', 'small', 'medium', 'large');
        // If $size is not valid, default to 'small'
        if (!in_array($size, $sizes)) $size = 'small';
        
        $call = 'thumbnails/' . $this->root . '/' . $this->encodePath($file);
        $params = array('format' => $format, 'size' => $size);
        $response = $this->fetch('GET', self::CONTENT_URL, $call, $params);
        
        return array(
            'name' => basename($file),
            'mime' => $this->getMimeType($response['body']),
            'meta' => json_decode($response['headers']['x-dropbox-metadata']),
            'data' => $response['body'],
        );
    }
    
    /**
     * Creates and returns a copy_ref to a file
     * This reference string can be used to copy that file to another user's
     * Dropbox by passing it in as the from_copy_ref parameter on /fileops/copy
     * @param $path File for which ref should be created, relative to root
     * @return array
     */
    public function copyRef($path)
    {
        $call = 'copy_ref/' . $this->root . '/' . $this->encodePath($path);
        $response = $this->fetch('GET', self::API_URL, $call);
        return $response;
    }
    
    /**
     * Copies a file or folder to a new location
     * @param string $from File or folder to be copied, relative to root
     * @param string $to Destination path, relative to root
     * @param null|string $fromCopyRef Must be used instead of the from_path
     * @return object stdClass
     */
    public function copy($from, $to, $fromCopyRef = null)
    {
        $call = 'fileops/copy';
        $params = array(
            'root' => $this->root,
            'from_path' => $this->normalisePath($from),
            'to_path' => $this->normalisePath($to),
        );
        
        if ($fromCopyRef) {
            $params['from_path'] = null;
            $params['from_copy_ref'] = $fromCopyRef;
        }
        
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Creates a folder
     * @param string New folder to create relative to root
     * @return object stdClass
     */
    public function create($path)
    {
        $call = 'fileops/create_folder';
        $params = array('root' => $this->root, 'path' => $this->normalisePath($path));
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Deletes a file or folder
     * @param string $path The path to the file or folder to be deleted
     * @return object stdClass
     */
    public function delete($path)
    {
        $call = 'fileops/delete';
        $params = array('root' => $this->root, 'path' => $this->normalisePath($path));
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Moves a file or folder to a new location
     * @param string $from File or folder to be moved, relative to root
     * @param string $to Destination path, relative to root
     * @return object stdClass
     */
    public function move($from, $to)
    {
        $call = 'fileops/move';
        $params = array(
                'root' => $this->root,
                'from_path' => $this->normalisePath($from),
                'to_path' => $this->normalisePath($to),
        );
        $response = $this->fetch('POST', self::API_URL, $call, $params);
        return $response;
    }
    
    /**
     * Intermediate fetch function
     * @param string $method The HTTP method
     * @param string $url The API endpoint
     * @param string $call The API method to call
     * @param array $params Additional parameters
     * @return mixed
     */
    private function fetch($method, $url, $call, array $params = array())
    {
        // Make the API call via the consumer
        $response = $this->OAuth->fetch($method, $url, $call, $params);
        
        // Format the response and return
        switch ($this->responseFormat) {
            case 'json':
                return json_encode($response);
            case 'jsonp':
                $response = json_encode($response);
                return $this->callback . '(' . $response . ')';
            default:
                return $response;
        }
    }
    
    /**
     * Set the API response format
     * @param string $format One of php, json or jsonp
     * @return void
     */
    public function setResponseFormat($format)
    {
        $format = strtolower($format);
        if (!in_array($format, array('php', 'json', 'jsonp'))) {
            throw new Exception("Expected a format of php, json or jsonp, got '$format'");
        } else {
            $this->responseFormat = $format;
        }
    }
    
    /**
     * Set the chunk size for chunked uploads
     * If $chunkSize is empty, set to 4194304 bytes (4 MB)
     * @see \Dropbox\API\chunkedUpload()
     */
    public function setChunkSize($chunkSize = 4194304)
    {
        if (!is_int($chunkSize)) {
            throw new Exception('Expecting chunk size to be an integer, got ' . gettype($chunkSize));
        } elseif ($chunkSize > 157286400) {
            throw new Exception('Chunk size must not exceed 157286400 bytes, got ' . $chunkSize);
        } else {
            $this->chunkSize = $chunkSize;
        }
    }
    
    /**
    * Set the JSONP callback function
    * @param string $function
    * @return void
    */
    public function setCallback($function)
    {
        $this->callback = $function;
    }
    
    /**
     * Get the mime type of downloaded file
     * If the Fileinfo extension is not loaded, return false
     * @param string $data File contents as a string or filename
     * @param string $isFilename Is $data a filename?
     * @return boolean|string Mime type and encoding of the file
     */
    private function getMimeType($data, $isFilename = false)
    {
        if (extension_loaded('fileinfo')) {
            $finfo = new finfo(FILEINFO_MIME);
            if ($isFilename !== false) {
                return $finfo->file($data);
            }
            return $finfo->buffer($data);
        }
        return false;
    }
    
    /**
     * Trim the path of forward slashes and replace
     * consecutive forward slashes with a single slash
     * @param string $path The path to normalise
     * @return string
     */
    private function normalisePath($path)
    {
        $path = preg_replace('#/+#', '/', trim($path, '/'));
        return $path;
    }
    
    /**
     * Encode the path, then replace encoded slashes
     * with literal forward slash characters
     * @param string $path The path to encode
     * @return string
     */
    private function encodePath($path)
    {
        $path = $this->normalisePath($path);
        $path = str_replace('%2F', '/', rawurlencode($path));
        return $path;
    }
}
