<?php
/**
 * Akeeba Engine
 * The modular PHP5 site backup engine
 *
 * @copyright Copyright (c)2006-2016 Nicholas K. Dionysopoulos
 * @license   GNU GPL version 3 or, at your option, any later version
 * @package   akeebaengine
 *
 *
 */

namespace Akeeba\Engine\Postproc\Connector;

// Protection against direct access
defined('AKEEBAENGINE') or die();

use Akeeba\Engine\Postproc\Connector\Sugarsync\Exception\Base as SugarsyncException;

/**
 * SugarSync PHP API class for Akeeba Engine
 *
 * @copyright Copyright (c)2006-2016 Nicholas K. Dionysopoulos / AkeebaBackup.com
 * @license   GNU General Public License version 3 or, at your option, any later version
 */
class Sugarsync
{
	/** @var string The URL to the SugarSync API endpoint */
	private $apiURL = 'https://api.sugarsync.com';

	private $userAgent = 'AkeebaEngine/';

	/** @var string The developer's access key */
	private $accessKey = '';

	/** @var string The developer's private key */
	private $privateKey = '';

	/** @var string The user's email address */
	private $userEmail = '';

	/** @var string The user's password */
	private $userPassword = '';

	/** @var string The API access token */
	private $accessToken = null;

	/** @var string The ID of the authenticated SugarSync user */
	private $userID = null;

	/**
	 * Public constructor. Remember to pass a configuration array with the keys
	 * access, private, email and password. Read the code for more info.
	 *
	 * @param array $config The configuration array
	 *
	 * @throws SugarsyncException
	 */
	public function __construct($config = array())
	{
		// Fetch the configuration parameters
		$this->accessKey = array_key_exists('access', $config) ? $config['access'] : '';
		$this->privateKey = array_key_exists('private', $config) ? $config['private'] : '';
		$this->userEmail = array_key_exists('email', $config) ? $config['email'] : '';
		$this->userPassword = array_key_exists('password', $config) ? $config['password'] : '';

		// Update the user agent with the version of the engine
		$this->userAgent .= defined('AKEEBABACKUP_VERSION') ? AKEEBABACKUP_VERSION : AKEEBA_VERSION;
	}

	/**
	 * Is this object connected (authenticated) to SugarSync yet?
	 *
	 * @return bool
	 */
	public function isConnected()
	{
		return !empty($this->accessToken) && !empty($this->userID);
	}

	/**
	 * (Re-)Connect to SugarSync
	 *
	 * @param array $config Optional override for configuration parameters
	 *
	 * @throws SugarsyncException
	 */
	public function connect($config = array())
	{
		// Apply configuration overrides
		if (array_key_exists('access', $config))
		{
			$this->accessKey = $config['access'];
		}
		if (array_key_exists('private', $config))
		{
			$this->privateKey = $config['private'];
		}
		if (array_key_exists('email', $config))
		{
			$this->userEmail = $config['email'];
		}
		if (array_key_exists('password', $config))
		{
			$this->userPassword = $config['password'];
		}

		// Check that all configuration parameters are in place
		if (empty($this->accessKey))
		{
			throw new SugarsyncException('You must set the developer access key');
		}
		if (empty($this->privateKey))
		{
			throw new SugarsyncException('You must set the developer private key');
		}
		if (empty($this->userEmail))
		{
			throw new SugarsyncException('You must set the user\'s email address');
		}
		if (empty($this->userPassword))
		{
			throw new SugarsyncException('You must set the user\'s password');
		}

		$xml = '<?xml version="1.0" encoding="UTF-8" ?>' . "\n";
		$xml .= '<authRequest>' . "\n";
		$xml .= '<username>' . utf8_encode($this->userEmail) . '</username>' . "\n";
		$xml .= '<password>' . utf8_encode($this->userPassword) . '</password>' . "\n";
		$xml .= '<accessKeyId>' . utf8_encode($this->accessKey) . '</accessKeyId>' . "\n";
		$xml .= '<privateAccessKey>' . utf8_encode($this->privateKey) . '</privateAccessKey>' . "\n";
		$xml .= '</authRequest>';

		$descriptor = array(
			'method'         => 'authorization',
			'verb'           => 'POST',
			'data'           => $xml,
			'auth'           => false,
			'return_headers' => true,
		);

		$this->accessToken = null;
		$this->userID = null;

		$ret = $this->apiCall($descriptor);

		$result = $ret['result'];

		// Extract the token
		if (preg_match('/Location:(.*?)\r/i', $result, $m))
		{
			$this->accessToken = $m[1];
		}

		// Extract the user ID
		$userStart = strpos($result, '<user>') + 6;
		$userEnd = strpos($result, '</user>');
		$userURL = substr($result, $userStart, $userEnd - $userStart);
		$userParts = explode('/', $userURL);
		$this->userID = array_pop($userParts);
	}

	/**
	 * Get a list of the top-level sync folders of the user's account
	 *
	 * @staticvar array|null $folders Caches the sync folders list
	 * @return array Sync folders as a display_name => internal_ID hash array
	 */
	public function getSyncFolders()
	{
		static $folders = null;

		if (is_null($folders))
		{
			if (!$this->isConnected())
			{
				$this->connect();
			}

			$descriptor = array(
				'method' => 'user/' . $this->userID . '/folders/contents',
				'verb'   => 'GET',
			);

			$ret = $this->apiCall($descriptor);

			$xml = $ret['result'];
			$dom = new \DOMDocument('1.0', 'UTF-8');
			$dom->loadXML($xml);
			$collections = $dom->getElementsByTagName('collection');
			$folders = array();
			for ($i = 0; $i < $collections->length; $i++)
			{
				/** @var \DOMElement $item */
				$item = $collections->item($i);
				$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
				$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
				$refParts = explode('/', $ref);
				$id = array_pop($refParts);
				$folders[$name] = $id;
			}
			unset($dom);
		}

		return $folders;
	}

	/**
	 * Resolves a folder path to a folder ID
	 *
	 * @staticvar array $mappedFolders Cache of folder names to folder IDs
	 *
	 * @param string $folder        The path to the folder
	 * @param bool   $createMissing Should I create any folders which do not exist along the way?
	 *
	 * @return string The folder ID
	 */
	protected function resolveFolder($folder, $createMissing = false)
	{
		static $mappedFolders = array();

		if (!array_key_exists($folder, $mappedFolders))
		{
			// Break the folder into bits and pieces
			$folderParts = explode('/', $folder);

			// First, let's fetch a list of top-level sync folders
			$syncFolders = $this->getSyncFolders();

			// Is our top-level folder really a top-level folder?
			if (!array_key_exists($folderParts[0], $syncFolders))
			{
				// Treason! The user did not use a top-level folder!
				if (array_key_exists('Magic Briefcase', $syncFolders))
				{
					// OK, let's use the user's "Magic Briefcase"
					array_unshift($folderParts, 'Magic Briefcase');
				}
				else
				{
					// This should normally never, ever be executed
					$randomFolder = array_shift($syncFolders);
					array_unshift($syncFolders, $randomFolder);
					array_unshift($folderParts, 'Magic Briefcase');
				}
			}

			// Get the ID of the top-level folder
			$toplevelFolder = array_shift($folderParts);
			$toplevelID = $syncFolders[$toplevelFolder];

			$folderID = $this->folderReduce($folderParts, $toplevelID, $createMissing);

			$mappedFolders[$folder] = $folderID;
		}

		return $mappedFolders[$folder];
	}

	/**
	 * Recursive internal function to reduce a stack of path parts to an ID.
	 * Used by resolveFolder().
	 *
	 * @param array  $stack         Stack of path parts to resolve
	 * @param string $id            Folder ID relative to which I should resolve the stack
	 * @param bool   $createMissing Should I create missing folders along the way
	 *
	 * @return string The folder ID to which the stack resolves
	 *
	 * @throws SugarsyncException
	 */
	protected function folderReduce($stack, $id, $createMissing = false)
	{
		// Is the path fully reduced?
		if (empty($stack))
		{
			return $id;
		}

		// No? Get the next path fragment
		$search = array_shift($stack);

		// If the fragment is empty the path is, in fact, fully reduced.
		if (empty($search))
		{
			return $id;
		}

		$descriptor = array(
			'method' => 'folder/' . $id . '/contents?type=folder',
			'verb'   => 'GET',
		);
		$ret = $this->apiCall($descriptor);

		$xml = $ret['result'];
		$dom = new \DOMDocument('1.0', 'UTF-8');
		$dom->loadXML($xml);
		$collections = $dom->getElementsByTagName('collection');
		$folders = array();
		for ($i = 0; $i < $collections->length; $i++)
		{
			/** @var \DOMElement $item */
			$item = $collections->item($i);
			$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
			$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
			$refParts = explode('/', $ref);
			$xid = array_pop($refParts);
			$folders[$name] = $xid;
		}
		unset($dom);

		if (array_key_exists($search, $folders))
		{
			// Folder found; recurse
			return $this->folderReduce($stack, $folders[$search], $createMissing);
		}
		else
		{
			// The folder was not found
			if ($createMissing)
			{
				$newId = $this->createFolder($id, $search);

				return $this->folderReduce($stack, $newId, $createMissing);
			}
			else
			{
				throw new SugarsyncException("The requested folder could not be located in your SugarSync account");
			}
		}
	}

	/**
	 * Creates a new folder and returns its ID
	 *
	 * @param string $container     Container folder's ID or path
	 * @param string $newFoldername The display name of the new folder
	 *
	 * @return string The ID of the created folder
	 *
	 * @throws SugarsyncException
	 */
	public function createFolder($container, $newFoldername)
	{
		if (substr($container, 0, 4) != ':sc:')
		{
			$container = $this->resolveFolder($container);
		}

		$xml = '<?xml version="1.0" encoding="UTF-8" ?>';
		$xml .= '<folder><displayName>' . utf8_encode($newFoldername) . '</displayName></folder>';

		$descriptor = array(
			'method'         => 'folder/' . $container,
			'verb'           => 'POST',
			'data'           => $xml,
			'return_headers' => true
		);

		$ret = $this->apiCall($descriptor);
		$result = $ret['result'];

		// Extract the URL
		if (preg_match('/Location:(.*?)\r/i', $result, $m))
		{
			$url = $m[1];
		}
		else
		{
			$url = '/';
		}

		$urlParts = explode('/', $url);

		return array_pop($urlParts);
	}

	/**
	 * Lists all subfolders of a folder
	 *
	 * @param string $container Folder ID or path to list
	 *
	 * @return array Hashed array, folder name => folder ID
	 *
	 * @throws SugarsyncException
	 */
	public function getFolders($container)
	{
		if (substr($container, 0, 4) != ':sc:')
		{
			$container = $this->resolveFolder($container);
		}

		$descriptor = array(
			'method' => 'folder/' . $container . '/contents?type=folder',
			'verb'   => 'GET',
		);
		$ret = $this->apiCall($descriptor);

		$xml = $ret['result'];
		$dom = new \DOMDocument('1.0', 'UTF-8');
		$dom->loadXML($xml);
		$collections = $dom->getElementsByTagName('collection');
		$folders = array();
		for ($i = 0; $i < $collections->length; $i++)
		{
			/** @var \DOMElement $item */
			$item = $collections->item($i);
			$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
			$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
			$refParts = explode('/', $ref);
			$xid = array_pop($refParts);
			$folders[$name] = $xid;
		}
		unset($dom);

		return $folders;
	}

	/**
	 * Lists all files of a folder
	 *
	 * @param string $container Folder ID or path to list
	 *
	 * @return array Hashed array, file name => file ID
	 *
	 * @throws SugarsyncException
	 */
	public function getFiles($container)
	{
		if (substr($container, 0, 4) != ':sc:')
		{
			$container = $this->resolveFolder($container);
		}

		$descriptor = array(
			'method' => 'folder/' . $container . '/contents?type=file',
			'verb'   => 'GET',
		);
		$ret = $this->apiCall($descriptor);

		$xml = $ret['result'];

		$dom = new \DOMDocument('1.0', 'UTF-8');
		$dom->loadXML($xml);
		$collections = $dom->getElementsByTagName('file');
		$files = array();
		for ($i = 0; $i < $collections->length; $i++)
		{
			/** @var \DOMElement $item */
			$item = $collections->item($i);
			$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
			$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
			$refParts = explode('/', $ref);
			$xid = array_pop($refParts);
			$files[$name] = $xid;
		}
		unset($dom);

		return $files;
	}

	/**
	 * Uploads a file, overwriting a file by the same name if one exists.
	 *
	 * @param string      $container Folder ID, or path to the folder, or full path to the file
	 * @param string|null $fileName  Name of the remote file, or null if a full path is provided in $container
	 * @param string      $localFile Full path to the local file to upload
	 *
	 * @return boolean True on success
	 *
	 * @throws SugarsyncException
	 */
	public function uploadFile($container, $fileName = null, $localFile = null)
	{
		if (substr($container, 0, 4) != ':sc:')
		{
			if (empty($fileName))
			{
				$pathParts = explode('/', $container);
				$fileName = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}

			$container = $this->resolveFolder($container, true);
		}

		// First check if the file already exists
		$files = $this->getFiles($container);

		if (!array_key_exists($fileName, $files))
		{
			$fileID = $this->createFile($container, $fileName);
		}
		else
		{
			$fileID = $files[$fileName];
		}

		$descriptor = array(
			'method' => 'file/' . $fileID . '/data',
			'verb'   => 'PUT',
			'data'   => $localFile
		);
		$ret = $this->apiCall($descriptor);

		return true;
	}

	/**
	 * Creates an (empty) file
	 *
	 * @param string      $container Folder ID, or path to the folder, or full path to the file
	 * @param string|null $fileName  Name of the remote file, or null if a full path is provided in $container
	 *
	 * @return string The file ID
	 *
	 * @throws SugarsyncException
	 */
	public function createFile($container, $fileName = null, $mimeType = 'application/octet-stream')
	{
		if (substr($container, 0, 4) != ':sc:')
		{
			if (empty($fileName))
			{
				$pathParts = explode('/', $container);
				$fileName = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}
			$container = $this->resolveFolder($container, true);
		}

		// First check if the file already exists
		$files = $this->getFiles($container);

		if (array_key_exists($fileName, $files))
		{
			return $files[$fileName];
		}

		$xml = '<?xml version="1.0" encoding="UTF-8" ?>';
		$xml .= '<file>';
		$xml .= '<displayName>' . utf8_encode($fileName) . '</displayName>';
		$xml .= '<mediaType>' . $mimeType . '</mediaType>';
		$xml .= '</file>';

		$descriptor = array(
			'method'         => 'folder/' . $container,
			'verb'           => 'POST',
			'data'           => $xml,
			'return_headers' => true,
		);
		$ret = $this->apiCall($descriptor);

		$result = $ret['result'];

		// Extract the URL
		if (preg_match('/Location:(.*?)\r/i', $result, $m))
		{
			$url = $m[1];
		}
		else
		{
			$url = '/';
		}

		$urlParts = explode('/', $url);

		return array_pop($urlParts);
	}

	/**
	 * Downloads a file
	 *
	 * @param string      $container A folder ID, or a folder path or a full path to the file to download
	 * @param string|null $file      Remote filename or null if $container is a full path
	 * @param string|null $localFile Full path to the local file to write the data. If null, the raw file data will be
	 *                               returned by this method.
	 */
	public function downloadFile($container, $file, $localFile = null)
	{
		if (substr($container, 0, 4) != ':sc:')
		{
			if (empty($file))
			{
				$pathParts = explode('/', $container);
				$file = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}
			$container = $this->resolveFolder($container);
		}

		// First check if the file already exists
		$files = $this->getFiles($container);

		if (array_key_exists($file, $files))
		{
			$fileID = $files[$file];
		}
		else
		{
			throw new SugarsyncException("File not found");
		}

		$descriptor = array(
			'method' => 'file/' . $fileID . '/data',
			'verb'   => 'GET',
			'data'   => $localFile
		);
		$ret = $this->apiCall($descriptor);

		if (empty($localFile))
		{
			return $ret['result'];
		}
	}

	public function deleteFile($container, $file = null)
	{
		if (substr($container, 0, 4) != ':sc:')
		{
			if (empty($file))
			{
				$pathParts = explode('/', $container);
				$file = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}
			$container = $this->resolveFolder($container);
		}

		// First check if the file already exists
		$files = $this->getFiles($container);

		if (array_key_exists($file, $files))
		{
			$fileID = $files[$file];
		}
		else
		{
			throw new SugarsyncException("File not found");
		}

		$descriptor = array(
			'method' => 'file/' . $fileID,
			'verb'   => 'DELETE',
		);
		$ret = $this->apiCall($descriptor);

		return true;
	}

	/**
	 * Calls SugarSync's API and returns the results
	 *
	 * @param array $descriptor An array describing the API call you want to make
	 *
	 * @return array
	 * @throws SugarsyncException
	 */
	protected function  apiCall($descriptor = array())
	{
		// Get data from descriptor
		$suffix = array_key_exists('method', $descriptor) ? $descriptor['method'] : '';
		$data = array_key_exists('data', $descriptor) ? $descriptor['data'] : '';
		$verb = array_key_exists('verb', $descriptor) ? $descriptor['verb'] : 'GET';
		$auth = array_key_exists('auth', $descriptor) ? $descriptor['auth'] : true;
		$heads = array_key_exists('headers', $descriptor) ? $descriptor['headers'] : array();
		$returnHeaders = array_key_exists('return_headers', $descriptor) ? $descriptor['return_headers'] : false;
		$silenceErrors = array_key_exists('shutup', $descriptor) ? $descriptor['shutup'] : false;

		// Make sure the HTTP verb is a supported one
		if (!in_array($verb, array('GET', 'POST', 'PUT', 'DELETE')))
		{
			$verb = 'GET';
		}

		// Calculate the URL
		$url = $this->apiURL . '/' . $suffix;

		// Create the HTTP headers array
		$headers = array(
			'Expect:'
		);
		$headers = array_merge($headers, $heads);

		// Handle extra headers for authorised API calls
		if ($auth && !$this->isConnected())
		{
			$this->connect();
		}
		if ($auth)
		{
			$headers[] = 'Authorization: ' . $this->accessToken;
		}

		$ch = curl_init($url);
		curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent);
		@curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

		@curl_setopt($ch, CURLOPT_CAINFO, AKEEBA_CACERT_PEM);

		curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
		curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);

		$fp = null;
		switch ($verb)
		{
			case 'POST':
				curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
				curl_setopt($ch, CURLOPT_POST, true);
				$headers[] = 'Content-Type: application/xml; charset=UTF-8';
				$headers[] = 'Content-Length: ' . strlen($data);
				break;

			case 'PUT':
				if (is_file($data) && is_readable($data))
				{
					$headers[] = 'Content-Length: ' . filesize($data);
					$fp = fopen($data, 'rb');
					curl_setopt($ch, CURLOPT_PUT, true);
					curl_setopt($ch, CURLOPT_INFILE, $fp);
					curl_setopt($ch, CURLOPT_INFILESIZE, filesize($data));
				}
				else
				{
					throw new SugarsyncException("$data is not readable; can not upload to SugarSync");
				}
				break;

			case 'DELETE':
				curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
				break;

			case 'GET':
				if (!empty($data))
				{
					$fp = fopen($data, 'wb');
					curl_setopt($ch, CURLOPT_FILE, $fp);
				}
				curl_setopt($ch, CURLOPT_POST, false);
				break;
		}

		curl_setopt($ch, CURLINFO_HEADER_OUT, true);
		curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
		if ($returnHeaders)
		{
			curl_setopt($ch, CURLOPT_HEADER, true);
		}

		$result = curl_exec($ch);
		$info = curl_getinfo($ch);
		$errno = curl_errno($ch);
		$error = curl_error($ch);

		@curl_close($ch);

		if (!is_null($fp))
		{
			fclose($fp);
		}

		if (!$silenceErrors && ($errno != 0))
		{
			throw new SugarsyncException("Network error [$errno]: $error");
		}

		$ret = array(
			'result' => $result,
			'info'   => $info,
			'errno'  => $errno,
			'error'  => $error,
		);

		$http_code = $info['http_code'];

		if ($silenceErrors || (($http_code >= 200) && ($http_code <= 299)))
		{
			return $ret;
		}
		elseif ($http_code == 400)
		{
			throw new SugarsyncException("HTTP Error [$http_code]: Required information was not provided to SugarSync");
		}
		elseif ($http_code == 401)
		{
			throw new SugarsyncException("HTTP Error [$http_code]: The credentials were rejected by SugarSync. Check your email and password.");
		}
		elseif ($http_code == 403)
		{
			throw new SugarsyncException("HTTP Error [$http_code]: Failed authentication.");
		}
		elseif ($http_code == 404)
		{
			throw new SugarsyncException("HTTP Error [$http_code]: Not found.");
		}
		else
		{
			throw new SugarsyncException("HTTP Error [$http_code]: Server Error; SugarSync's API service may be down or experiencing a technical problem");
		}
	}
}