<?php
/**
 * Akeeba Engine
 *
 * @package   akeebaengine
 * @copyright Copyright (c)2006-2024 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   https://www.gnu.org/licenses/gpl-3.0.html GNU General Public License version 3, or later
 *
 * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with this program. If not, see
 * <https://www.gnu.org/licenses/>.
 */

namespace Akeeba\Engine\Postproc\Connector;

defined('AKEEBAENGINE') || die();

use Akeeba\Engine\Postproc\Connector\Backblaze\AccountInformation;
use Akeeba\Engine\Postproc\Connector\Backblaze\BucketInformation;
use Akeeba\Engine\Postproc\Connector\Backblaze\Exception\APIError;
use Akeeba\Engine\Postproc\Connector\Backblaze\Exception\cURLError;
use Akeeba\Engine\Postproc\Connector\Backblaze\Exception\InvalidJSON;
use Akeeba\Engine\Postproc\Connector\Backblaze\Exception\NotAllowed;
use Akeeba\Engine\Postproc\Connector\Backblaze\Exception\UnexpectedHTTPStatus;
use Akeeba\Engine\Postproc\Connector\Backblaze\FileInformation;
use Akeeba\Engine\Postproc\Connector\Backblaze\UploadURL;
use Akeeba\Engine\Postproc\ProxyAware;
use Akeeba\Engine\Util\FileCloseAware;
use DomainException;
use OutOfBoundsException;
use RuntimeException;

/**
 * Backblaze B2 API connector
 */
class Backblaze
{
	use FileCloseAware;
	use ProxyAware;

	/** The API entry point URL, only used to retrieve the authorization token */
	public const apiURL = "https://api.backblazeb2.com/b2api/v1/";

	/** @var  string  The Backblaze B2 Account ID */
	private $accountId;

	/** @var  AccountInformation  Account information returned from authorizeAccount */
	private $accountInformation;

	/** @var  string  The Backblaze B2 Application Key */
	private $applicationKey;

	/** @var  BucketInformation[]  A list of the buckets in this account, used by getBucketId */
	private $buckets;

	/**
	 * Default cURL options
	 *
	 * @var  array
	 */
	private $defaultOptions = [
		CURLOPT_SSL_VERIFYPEER => true,
		CURLOPT_SSL_VERIFYHOST => 2,
		CURLOPT_VERBOSE        => false,
		CURLOPT_HEADER         => false,
		CURLINFO_HEADER_OUT    => false,
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_CAINFO         => AKEEBA_CACERT_PEM,
	];

	/**
	 * Creates a new Backblaze B2 API Connector object.
	 *
	 * @param   string  $accountId       The Backblaze B2 Account ID
	 * @param   string  $applicationKey  The Backblaze B2 Application Key
	 */
	public function __construct($accountId, $applicationKey)
	{
		$this->accountId      = $accountId;
		$this->applicationKey = $applicationKey;
	}

	/**
	 * Cancel a multipart upload
	 *
	 * @param   string  $fileId  The fileId returned by startUpload
	 *
	 * @return  FileInformation  The information of the file being canceled
	 */
	public function cancelUpload($fileId)
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		$apiUrl       = $this->getApiUrl();
		$explicitPost = [
			'fileId' => $fileId,
		];
		$additional   = [
			'headers' => [
				'Accept: application/json',
			],
		];
		$apiReturn    = $this->fetch('POST', $apiUrl, 'b2api/v1/b2_cancel_large_file', $additional, $explicitPost);

		return new FileInformation($apiReturn);
	}

	/**
	 * Deletes a specific version of a file, given a file ID.
	 *
	 * @param   string  $fileName  The filename to delete all versions of. Can be a path.
	 * @param   string  $fileId    The file ID to delete.
	 *
	 * @return  void
	 */
	public function deleteByFileId($fileName, $fileId)
	{
		if (!$this->getAccountInformation()->allowed->canDeleteFiles())
		{
			throw new NotAllowed('Deleting files');
		}

		$apiUrl     = $this->getApiUrl();
		$body       = [
			'fileName' => $fileName,
			'fileId'   => $fileId,
		];
		$additional = [
			'headers' => [
				'Accept: application/json',
			],
		];

		$this->fetch('POST', $apiUrl, '/b2api/v1/b2_delete_file_version', $additional, json_encode($body));
	}

	/**
	 * Deletes all versions of a file, given a filename. Uses getFileVersions internally.
	 *
	 * @param   string  $bucketId  The bucket ID. Use getBucketId.
	 * @param   string  $fileName  The filename to delete all versions of. Can be a path.
	 *
	 * @return  void
	 */
	public function deleteByFileName($bucketId, $fileName)
	{
		if (!$this->getAccountInformation()->allowed->canDeleteFiles())
		{
			throw new NotAllowed('Deleting files');
		}

		$files = $this->getFileVersions($bucketId, $fileName);

		if (empty($files))
		{
			return;
		}

		foreach ($files as $file)
		{
			$this->deleteByFileId($file->fileName, $file->fileId);
		}
	}

	/**
	 * Download a file when you know its name
	 *
	 * @param   string  $bucketName  The name of the bucket you are downloading from
	 * @param   string  $fileName    The path of the file in the bucket you are downloading
	 * @param   string  $localFile   The path of the file in your local filesystem (download target)
	 * @param   array   $headers     HTTP headers to pass, see the link below
	 *
	 * @return  void
	 *
	 * @see https://www.backblaze.com/b2/docs/b2_download_file_by_name.html
	 */
	public function downloadFile($bucketName, $fileName, $localFile, $headers = [])
	{
		if (!$this->getAccountInformation()->allowed->canReadFiles())
		{
			throw new NotAllowed('Reading files');
		}

		if (!$this->getAccountInformation()->allowed->isBucketAllowed($bucketName))
		{
			throw new NotAllowed("Accessing the $bucketName bucket");
		}

		if (!$this->getAccountInformation()->allowed->isPrefixAllowed($fileName))
		{
			throw new NotAllowed("Accessing the $fileName file; the prefix (directory) is not allowed)");
		}

		$accountInfo = $this->getAccountInformation();
		$url         = rtrim($accountInfo->downloadUrl, '/') . '/';
		$relativeUrl = 'file/' . $bucketName . '/' . ltrim($fileName, '/');

		$this->fetch('GET', $url, $relativeUrl, [
			'headers'   => $headers,
			'file'      => $localFile,
			'file_mode' => 'wb',
		]);
	}

	/**
	 * Download a file when you know its ID
	 *
	 * @param   string  $fileId     The file ID returned when uploading or by getFileVersions
	 * @param   string  $localFile  The path of the file in your local filesystem (download target)
	 * @param   array   $headers    HTTP headers to pass, see the link below
	 *
	 * @return  void
	 *
	 * @see https://www.backblaze.com/b2/docs/b2_download_file_by_id.html
	 */
	public function downloadFileById($fileId, $localFile, $headers = [])
	{
		if (!$this->getAccountInformation()->allowed->canReadFiles())
		{
			throw new NotAllowed('Reading files');
		}

		$accountInfo = $this->getAccountInformation();
		$url         = rtrim($accountInfo->downloadUrl, '/') . '/';
		$relativeUrl = 'api/b2_download_file_by_id?fileId=' . $fileId;

		$this->fetch('GET', $url, $relativeUrl, [
			'headers'   => $headers,
			'file'      => $localFile,
			'file_mode' => 'wb',
		]);
	}

	/**
	 * Finishes a multipart file upload.
	 *
	 * @param   string    $fileId       The ID of the file being uploaded
	 * @param   string[]  $sha1PerPart  List of SHA-1 sums per uploaded part. First part at array index 0.
	 *
	 * @return  FileInformation  Note that you should not expect to get the SHA-1 of the entire file, per API docs.
	 */
	public function finishUpload($fileId, $sha1PerPart)
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		$apiUrl       = $this->getApiUrl();
		$explicitPost = json_encode([
			'fileId'        => $fileId,
			'partSha1Array' => $sha1PerPart,
		]);
		$additional   = [
			'headers' => [
				'Accept: application/json',
			],
		];
		$apiReturn    = $this->fetch('POST', $apiUrl, 'b2api/v1/b2_finish_large_file', $additional, $explicitPost);

		return new FileInformation($apiReturn);
	}

	/**
	 * Get the account information stored in this connector API object
	 *
	 * @return  AccountInformation
	 */
	public function getAccountInformation()
	{
		if (empty($this->accountInformation))
		{
			$this->authorizeAccount();
		}

		if (!$this->accountInformation->isValid())
		{
			$this->authorizeAccount();
		}

		return $this->accountInformation;
	}

	/**
	 * Reinitialize the connector API object with a stored account information object. If the object is out of date or
	 * you pass null we will retrieve a new AccountInformation object through the B2 API.
	 *
	 * @param   AccountInformation  $accountInformation
	 */
	public function setAccountInformation($accountInformation)
	{
		if (is_null($accountInformation) || (!$accountInformation->isValid()))
		{
			$this->authorizeAccount();

			return;
		}

		$this->accountInformation = $accountInformation;
	}

	/**
	 * Get the bucket ID, required for use in the API, from the unique bucket name which the end user knows and sees in
	 * the BackBlaze B2 interface.
	 *
	 * @param   string  $name          The name of the bucket you want the ID for
	 * @param   bool    $forceRefresh  This method caches the list of buckets. Pass true to force reloading this list
	 *                                 from BackBlaze B2 (useful if you just added a bucket)
	 *
	 * @return  string  The bucket ID
	 */
	public function getBucketId($name, $forceRefresh = false)
	{
		if ($this->getAccountInformation()->allowed->bucketName == $name)
		{
			return $this->getAccountInformation()->allowed->bucketId;
		}

		if (empty($this->buckets) || $forceRefresh)
		{
			$this->buckets = $this->listBuckets();
		}

		foreach ($this->buckets as $bucket)
		{
			if ($bucket->bucketName == $name)
			{
				return $bucket->bucketId;
			}
		}

		throw new DomainException(sprintf('Bucket %s not found under this BackBlaze B2 account', $name));
	}

	/**
	 * Returns the available file versions for a given file path.
	 *
	 * @param   string  $bucketId  The bucket ID. Use getBucketId.
	 * @param   string  $fileName  The filename to return versions for. Can be a path.
	 *
	 * @return  FileInformation[]  The FileInformation objects for each file version.
	 */
	public function getFileVersions($bucketId, $fileName)
	{
		if (!$this->getAccountInformation()->allowed->canReadFiles())
		{
			throw new NotAllowed('Reading files');
		}

		$apiUrl = $this->getApiUrl();
		$body   = [
			'bucketId' => $bucketId,
			'prefix'   => $fileName,
		];

		$additional = [
			'headers' => [
				'Accept: application/json',
			],
		];

		$apiReturn = $this->fetch('POST', $apiUrl, '/b2api/v1/b2_list_file_versions', $additional, json_encode($body));
		$return    = [];

		foreach ($apiReturn['files'] as $file)
		{
			// If no fileName is returned that's a folder
			if (empty($file['fileName']))
			{
				continue;
			}

			// The API returns versions for all files, starting with the one we requested. Hence the filtering here.
			if ($file['fileName'] != $fileName)
			{
				continue;
			}

			$return[] = new FileInformation($file);
		}

		return $return;
	}

	/**
	 * Get the upload URL, including the special upload authorization token, for multipart file uploads.
	 *
	 * This is the second step. The next steps are uploadPart and either finishUpload to finalize the upload, or
	 * cancelUpload to abort the multipart upload process.
	 *
	 * @param   string  $fileId
	 *
	 * @return  UploadURL
	 */
	public function getPartUploadUrl($fileId)
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		$apiUrl       = $this->getApiUrl();
		$explicitPost = json_encode([
			'fileId' => $fileId,
		]);
		$additional   = [
			'headers' => [
				'Accept: application/json',
			],
		];

		$apiReturn = $this->fetch('POST', $apiUrl, 'b2api/v1/b2_get_upload_part_url', $additional, $explicitPost);

		return new UploadURL($apiReturn);
	}

	/**
	 * Create a singed URL which allows anyone to download a file for a specific period of time.
	 *
	 * @param   string  $bucketName  The name of the bucket you are downloading from
	 * @param   string  $fileName    The path of the file in the bucket you are downloading
	 * @param   int     $expiresIn   How many seconds will the URL be valid for. Default is the maximum, one week.
	 *
	 * @return  string  The signed download URL which can be shared
	 */
	public function getSignedUrl($bucketName, $fileName, $expiresIn = 604800)
	{
		if (!$this->getAccountInformation()->allowed->canReadFiles())
		{
			throw new NotAllowed('Reading files');
		}

		// Let's get the bucket ID
		$bucketId = $this->getBucketId($bucketName);

		// $expiresIn must be between 1 and 604800 seconds
		$expiresIn = min($expiresIn, 604800);
		$expiresIn = max(1, $expiresIn);

		// Use b2_get_download_authorization to get a temporary authorization code
		$apiUrl     = $this->getApiUrl();
		$body       = [
			'bucketId'               => $bucketId,
			'fileNamePrefix'         => $fileName,
			'validDurationInSeconds' => $expiresIn,
		];
		$additional = [
			'headers' => [
				'Accept: application/json',
			],
		];

		$authResponse = $this->fetch('POST', $apiUrl, '/b2api/v1/b2_get_download_authorization', $additional, json_encode($body));

		// Construct the signed URL
		$accountInfo = $this->getAccountInformation();
		$url         = rtrim($accountInfo->downloadUrl, '/');
		$url         .= '/file/' . $bucketName . '/' . ltrim($fileName, '/');
		$url         .= '?Authorization=' . $authResponse['authorizationToken'];

		return $url;
	}

	/**
	 * Gets the URL for the upload API of the given bucket
	 *
	 * @param   string  $bucketId  The ID of the bucket (NOT the name!). Use getBucketId to retrieve it.
	 *
	 * @return  UploadURL  The Upload URL object
	 */
	public function getUploadUrl($bucketId)
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		$apiUrl       = $this->getApiUrl();
		$explicitPost = json_encode([
			'bucketId' => $bucketId,
		]);
		$additional   = [
			'headers' => [
				'Accept: application/json',
			],
		];

		$apiReturn = $this->fetch('POST', $apiUrl, 'b2api/v1/b2_get_upload_url', $additional, $explicitPost);

		return new UploadURL($apiReturn);
	}

	/**
	 * List all of the buckets in the account
	 *
	 * @return  BucketInformation[]
	 *
	 * @see  https://www.backblaze.com/b2/docs/b2_list_buckets.html
	 */
	public function listBuckets()
	{
		if (!$this->getAccountInformation()->allowed->canListBuckets())
		{
			throw new NotAllowed('Retrieving a list of your BackBlaze B2 buckets');
		}

		$apiUrl       = $this->getApiUrl();
		$explicitPost = json_encode([
			'accountId' => $this->getAccountInformation()->accountId,
		]);
		$additional   = [
			'headers' => [
				'Accept: application/json',
			],
		];

		$apiReturn = $this->fetch('POST', $apiUrl, 'b2api/v1/b2_list_buckets', $additional, $explicitPost);
		$return    = [];

		foreach ($apiReturn['buckets'] as $bucket)
		{
			$return[] = new BucketInformation($bucket);
		}

		return $return;
	}

	/**
	 * Start a multipart upload of a large file.
	 *
	 * This is the first step. The next step is getPartUploadUrl.
	 *
	 * @param   string  $bucketId     The ID of the bucket where the file is being uploaded to
	 * @param   string  $remoteFile   The path of the file in Backblaze
	 * @param   string  $contentType  MIME content type of the file
	 *
	 * @return  FileInformation
	 */
	public function startUpload($bucketId, $remoteFile, $contentType = 'application/octet-stream')
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		if (!$this->getAccountInformation()->allowed->isPrefixAllowed($remoteFile))
		{
			throw new NotAllowed("Accessing the $remoteFile file; the prefix (directory) is not allowed)");
		}

		$apiUrl       = $this->getApiUrl();
		$explicitPost = json_encode([
			'bucketId'    => $bucketId,
			'fileName'    => $remoteFile,
			'contentType' => $contentType,
		]);
		$additional   = [
			'headers' => [
				'Accept: application/json',
			],
		];

		$apiReturn = $this->fetch('POST', $apiUrl, 'b2api/v1/b2_start_large_file', $additional, $explicitPost);

		return new FileInformation($apiReturn);
	}

	/**
	 * Upload a file. Automatically determines single- or multi-part upload based on the size of the file, the part
	 * size and the absolute minimum part size that's possible.
	 *
	 * @param   string  $bucketId     The ID of the bucket. Use getBucketId.
	 * @param   string  $remoteFile   The path of the uploaded file in B2
	 * @param   string  $localFile    Path to the file to upload
	 * @param   int     $partSize     Part size for multipart uploads, default 5M
	 * @param   string  $contentType  MIME content type of the uploaded file
	 *
	 * @return  FileInformation
	 */
	public function uploadFile($bucketId, $remoteFile, $localFile, $partSize = 5242880, $contentType = 'application/octet-stream')
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		clearstatcache(false, $localFile);
		$fileSize = @filesize($localFile);

		$minPartSize = $this->getAccountInformation()->absoluteMinimumPartSize;

		if (($fileSize < $minPartSize) || ($partSize < $minPartSize))
		{
			return $this->uploadSingleFile($bucketId, $remoteFile, $localFile, $contentType);
		}

		/**
		 * Backblaze supports up to 10000 parts. We have to try increasing the part size until we're sure our  file
		 * will upload in a number of parts that's less than that.
		 */
		while ($fileSize / $partSize > 10000)
		{
			// Increase by 5M in each step
			$partSize += 5242880;
		}

		return $this->uploadLargeFile($bucketId, $remoteFile, $localFile, $partSize, $contentType);
	}

	/**
	 * Upload a file using a multipart upload.
	 *
	 * @param   string  $bucketId     The ID of the bucket. Use getBucketId.
	 * @param   string  $remoteFile   The path of the uploaded file in B2
	 * @param   string  $localFile    Path to the file to upload
	 * @param   int     $partSize     Part size for multipart uploads, default 5M
	 * @param   string  $contentType  MIME content type of the uploaded file
	 *
	 * @return  FileInformation
	 */
	public function uploadLargeFile($bucketId, $remoteFile, $localFile, $partSize = 5242880, $contentType = 'application/octet-stream')
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		if (!$this->getAccountInformation()->allowed->isPrefixAllowed($remoteFile))
		{
			throw new NotAllowed("Accessing the $remoteFile file; the prefix (directory) is not allowed)");
		}

		$fileStartInfo = $this->startUpload($bucketId, $remoteFile, $contentType);
		$uploadUrl     = $this->getPartUploadUrl($fileStartInfo->fileId);
		$partNumber    = 0;
		$sha1Array     = [];

		while (true)
		{
			try
			{
				$partInfo    = $this->uploadPart($uploadUrl, $localFile, ++$partNumber, $partSize);
				$sha1Array[] = $partInfo->contentSha1;
			}
			catch (OutOfBoundsException $e)
			{
				// I am done uploading parts
				break;
			}
		}

		return $this->finishUpload($fileStartInfo->fileId, $sha1Array);
	}

	/**
	 * Upload a part of a large file.
	 *
	 * @param   UploadURL  $uploadUrl   The upload URL information returned by getPartUploadUrl
	 * @param   string     $localFile   The local file to read from. Must be readable.
	 * @param   int        $partNumber  The part number, 1 to 10,000.
	 * @param   int        $partSize    The part size in bytes.
	 *
	 * @return  FileInformation  Keep that in an array. You'll need to pass that to finishUpload.
	 */
	public function uploadPart(UploadURL $uploadUrl, $localFile, $partNumber, $partSize = 5242880)
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		clearstatcache(false, $localFile);
		$filesize = filesize($localFile);


		// The offset to read from the file. Remember: part numbers start at 1, file offsets start at 0.
		$offset = ($partNumber - 1) * $partSize;

		// Sanity check: does this part even exist?
		if ($filesize <= $offset)
		{
			throw new OutOfBoundsException(sprintf("Cannot read part %d of file %s. The file only has %d bytes, requested offset is at %d bytes.", $partNumber, $localFile, $filesize, $offset));
		}

		// Read the part off the file and calculate the information required by Backblaze
		$fp = @fopen($localFile, 'r');

		if ($fp === false)
		{
			throw new RuntimeException(sprintf('Failed to multipart upload file %s to BackBlaze: cannot open file for reading', $localFile));
		}

		if (fseek($fp, $offset) == -1)
		{
			$this->conditionalFileClose($fp);

			throw new RuntimeException(sprintf('Failed to multipart upload file %s to BackBlaze: cannot seek to offset %d', $localFile, $offset));
		}

		$data = fread($fp, $partSize);

		if ($data === false)
		{
			$this->conditionalFileClose($fp);

			throw new RuntimeException(sprintf('Failed to multipart upload file %s to BackBlaze: cannot read from file at offset %d, data length %d', $localFile, $offset, $partSize));
		}

		$this->conditionalFileClose($fp);

		$sha1          = sha1($data);
		$contentLength = strlen($data);
		$additional    = [
			'headers' => [
				'Accept: application/json',
				sprintf('Content-Length: %s', $contentLength),
				sprintf('X-Bz-Part-Number: %d', $partNumber),
				sprintf('X-Bz-Content-Sha1: %s', $sha1),
				// WARNING: Uploads use a different authorization token than the API itself!
				sprintf('Authorization: %s', $uploadUrl->authorizationToken),
			],
		];

		$apiReturn = $this->fetch('POST', $uploadUrl->uploadUrl, '', $additional, $data);

		return new FileInformation($apiReturn);
	}

	/**
	 * Single part upload of a file to BackBlaze B2
	 *
	 * @param   string  $bucketId     The ID of the bucket to upload to. Use getBucketId to fetch it.
	 * @param   string  $remoteFile   The path of the file in the bucket.
	 * @param   string  $localFile    The path to the local file to upload.
	 * @param   string  $contentType  The MIME content type of the uploaded file.
	 *
	 * @return  FileInformation
	 */
	public function uploadSingleFile($bucketId, $remoteFile, $localFile, $contentType = 'application/octet-stream')
	{
		if (!$this->getAccountInformation()->allowed->canWriteFiles())
		{
			throw new NotAllowed('Writing to files');
		}

		if (!$this->getAccountInformation()->allowed->isPrefixAllowed($remoteFile))
		{
			throw new NotAllowed("Accessing the $remoteFile file; the prefix (directory) is not allowed)");
		}

		$uploadUrl = $this->getUploadUrl($bucketId);

		clearstatcache(false, $localFile);
		$sha1          = sha1_file($localFile);
		$contentLength = filesize($localFile);

		$additional = [
			'headers' => [
				'Accept: application/json',
				sprintf('X-Bz-File-Name: %s', $remoteFile),
				sprintf('Content-Type: %s', $contentType),
				sprintf('Content-Length: %s', $contentLength),
				sprintf('X-Bz-Content-Sha1: %s', $sha1),
				// WARNING: Uploads use a different authorization token than the API itself!
				sprintf('Authorization: %s', $uploadUrl->authorizationToken),
			],
			'file'    => $localFile,
		];

		$apiReturn = $this->fetch('POST', $uploadUrl->uploadUrl, '', $additional);

		return new FileInformation($apiReturn);
	}

	/**
	 * Uses the account ID and application key to retrieve a (temporary) authorization token which will be used in all
	 * subsequent operations. Furthermore, it will retrieve information regarding the account-specific API URLs, the
	 * account-specific download URLs and the supported part sizes. The information is retrieved as an immutable
	 * AccountInformation object which you can retrieve through getAccountInformation.
	 *
	 * If you have already run this and have an AccountInformation object you can use the applyAccountInformation
	 * method to re-initialize this object
	 *
	 * @return void
	 */
	protected function authorizeAccount()
	{
		$additional = [
			'headers' => [
				sprintf('Authorization: Basic %s', base64_encode($this->accountId . ':' . $this->applicationKey)),
				'Accept: application/json',
			],
		];

		$apiReturn = $this->fetch('GET', static::apiURL, 'b2_authorize_account', $additional);

		$this->accountInformation = new AccountInformation($apiReturn);
	}

	/**
	 * Execute an API call
	 *
	 * @param   string  $method        The HTTP method
	 * @param   string  $baseUrl       The base URL. Use one of self::rootUrl or self::contentRootUrl
	 * @param   string  $relativeUrl   The relative URL to ping
	 * @param   array   $additional    Additional parameters
	 * @param   mixed   $explicitPost  Passed explicitly to POST requests if set, otherwise $additional is passed.
	 *
	 * @return  array
	 * @throws  RuntimeException
	 *
	 */
	protected function fetch($method, $baseUrl, $relativeUrl, array $additional = [], $explicitPost = null)
	{
		// Get full URL, if required
		$url = $relativeUrl;

		if (substr($relativeUrl, 0, 6) != 'https:')
		{
			$url = $baseUrl . ltrim($relativeUrl, '/');
		}

		// Should I expect a specific header?
		$expectHttpStatus = [];

		if (isset($additional['expect-status']))
		{
			$expectHttpStatus = $additional['expect-status'];

			if (!is_array($expectHttpStatus))
			{
				$expectHttpStatus = [$expectHttpStatus];
			}

			unset($additional['expect-status']);
		}

		// Am I told to not parse the result?
		$noParse = false;

		if (isset($additional['no-parse']))
		{
			$noParse = $additional['no-parse'];
			unset ($additional['no-parse']);
		}

		// Am I told not to follow redirections?
		$followRedirect = true;

		if (isset($additional['follow-redirect']))
		{
			$followRedirect = $additional['follow-redirect'];
			unset ($additional['follow-redirect']);
		}

		// Initialise and execute a cURL request
		$ch = curl_init($url);

		$this->applyProxySettingsToCurl($ch);

		// Get the default options array
		$options = $this->defaultOptions;

		// Do I have explicit cURL options to add?
		if (isset($additional['curl-options']) && is_array($additional['curl-options']))
		{
			// We can't use array_merge since we have integer keys and array_merge reassigns them :(
			foreach ($additional['curl-options'] as $k => $v)
			{
				$options[$k] = $v;
			}
		}

		// Set up custom headers
		$headers                = [];
		$hasAuthorizationHeader = false;

		if (isset($additional['headers']))
		{
			$headers = $additional['headers'];
			unset ($additional['headers']);
		}

		// Add the authorization header, if it doesn't exist
		array_walk($headers, function ($header) use (&$hasAuthorizationHeader) {
			if (substr($header, 0, 15) == 'Authorization: ')
			{
				$hasAuthorizationHeader = true;
			}
		});

		if (!$hasAuthorizationHeader)
		{
			$headers[] = 'Authorization: ' . $this->getAccountInformation()->authorizationToken;
		}

		$options[CURLOPT_HTTPHEADER] = $headers;

		// Handle files
		$file     = null;
		$fp       = null;
		$fileMode = null;

		if (isset($additional['file']))
		{
			$file = $additional['file'];
			unset ($additional['file']);
		}

		if (isset($additional['file_mode']))
		{
			$fileMode = $additional['file_mode'];
			unset ($additional['file_mode']);
		}

		if (!isset($additional['fp']) && !empty($file))
		{
			if (is_null($fileMode))
			{
				$fileMode = ($method == 'GET') ? 'w' : 'r';
			}

			$fp = @fopen($file, $fileMode);
		}
		elseif (isset($additional['fp']))
		{
			$fp = $additional['fp'];
			unset($additional['fp']);
		}

		// Set up additional options
		if ($method == 'GET' && $fp)
		{
			$options[CURLOPT_RETURNTRANSFER] = false;
			$options[CURLOPT_HEADER]         = false;
			$options[CURLOPT_FILE]           = $fp;
			$options[CURLOPT_BINARYTRANSFER] = true;

			if (empty($expectHttpStatus))
			{
				$expectHttpStatus = [200, 206];
			}
		}
		elseif (in_array($method, ['POST', 'PUT']) && $fp)
		{
			$options[CURLOPT_PUT]   = true;
			$options[CURLOPT_CUSTOMREQUEST] = $method;
			$options[CURLOPT_INFILE]        = $fp;

			// Some broken cURL versions cause an error. Forcing HTTP/1.1 seems to be fixing it.
			if (defined('CURLOPT_HTTP_VERSION') && defined('CURL_HTTP_VERSION_1_1'))
			{
				$options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
			}

			if ($file)
			{
				clearstatcache();
				$options[CURLOPT_INFILESIZE] = @filesize($file);
			}
			else
			{
				$options[CURLOPT_INFILESIZE] = strlen(stream_get_contents($fp));
			}

			fseek($fp, 0);
		}
		elseif ($method == 'POST')
		{
			$options[CURLOPT_POST] = true;

			if ($explicitPost)
			{
				$options[CURLOPT_POSTFIELDS] = $explicitPost;
			}
			elseif (!empty($additional))
			{
				$options[CURLOPT_POSTFIELDS] = $additional;
			}
			// This is required for some broken servers, e.g. SiteGround
			else
			{
				$options[CURLOPT_POSTFIELDS] = '';
			}
		}
		else // Any other HTTP method, e.g. DELETE
		{
			$options[CURLOPT_CUSTOMREQUEST] = $method;

			if ($explicitPost)
			{
				$options[CURLOPT_POSTFIELDS] = $explicitPost;
			}
			elseif (!empty($additional))
			{
				$options[CURLOPT_POSTFIELDS] = $additional;
			}
		}

		// Set the cURL options at once
		@curl_setopt_array($ch, $options);

		// Set the follow location flag
		if ($followRedirect)
		{
			@curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
		}

		// Execute and parse the response
		$response     = curl_exec($ch);
		$errNo        = curl_errno($ch);
		$error        = curl_error($ch);
		$lastHttpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

		curl_close($ch);

		// Close open file pointers
		$hadFile = false;

		if ($fp)
		{
			$hadFile = true;

			$this->conditionalFileClose($fp);
		}

		// Did we have a cURL error?
		if ($errNo)
		{
			throw new cURLError($errNo, $error);
		}

		// Close open file pointers
		if ($hadFile && !empty($expectHttpStatus) && !in_array($lastHttpCode, $expectHttpStatus))
		{
			if ($file && ($method == 'GET'))
			{
				@unlink($file);
			}

			throw new UnexpectedHTTPStatus($lastHttpCode);
		}

		if (!empty($expectHttpStatus) && in_array($lastHttpCode, $expectHttpStatus))
		{
			return [];
		}

		if ($noParse)
		{
			return $response;
		}

		// Parse the response
		$originalResponse = $response;
		$response         = json_decode($response, true);

		// Did we get invalid JSON data?
		if (!$response)
		{
			throw new InvalidJSON("Invalid JSON Data: $originalResponse");
		}

		unset($originalResponse);

		if (!empty($response) && ($lastHttpCode != 200))
		{
			$response = ['error' => $response];
		}

		// Did we get an error response?
		if (isset($response['error']) && is_array($response['error']))
		{
			$decodedError    = $response['error'];
			$actualException = new APIError($decodedError['code'], $decodedError['message'], 500);

			/**
			 * BackBlaze's blog post (see below) states that we need to retry getting an upload URL upon HTTP error 500
			 * or 503. However, the reality is different. We get an HTTP 200 with an error document that contains the
			 * error code literal 'service_unavailable'. In this case we'd simply throw an APIError exception and stop
			 * the upload attempt. In an effort to keep the error handling code simple (since PHP doesn't allow catching
			 * two disparate exception types in a single catch) we will simulate an HTTP exception here, attaching the
			 * actual APIException as its previous exception to facilitate troubleshooting. The UnexpectedHTTPStatus
			 * exception bubbles up to Akeeba\Engine\Postproc\Backblaze where it's intercepted, allowing us to ask for a
			 * new upload URL and continue the upload.
			 *
			 * @see https://www.backblaze.com/blog/b2-503-500-server-error/
			 * @see https://www.akeeba.com/support/32973
			 */
			if ($decodedError['code'] == 'service_unavailable')
			{
				throw new UnexpectedHTTPStatus('503 (Simulated – received API error code ‘service_unavailable’ per parent exception)', 503, $actualException);
			}

			throw $actualException;
		}

		return $response;
	}

	/**
	 * Return the API URL for all operations except file downloads. It includes the all important trailing slash which
	 * fetch() expects to be present.
	 *
	 * @return  string
	 */
	protected function getApiUrl()
	{
		$apiUrl = $this->getAccountInformation()->apiUrl;
		$apiUrl = rtrim($apiUrl, '/');

		return $apiUrl . '/';
	}

	/**
	 * Return the API URL for file download operations only. It includes the all important trailing slash which fetch()
	 * expects to be present.
	 *
	 * @return  string
	 */
	protected function getDownloadUrl()
	{
		$downloadUrl = $this->getAccountInformation()->downloadUrl;
		$downloadUrl = rtrim($downloadUrl, '/');

		return $downloadUrl . '/';
	}
}
