<?php
/**
 * Akeeba Engine
 *
 * @package   akeebaengine
 * @copyright Copyright (c)2006-2026 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\Dropbox2\Exception\APIError;
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\cURLError;
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\InvalidJSON;
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\UnexpectedHTTPStatus;
use Akeeba\Engine\Postproc\ProxyAware;
use Akeeba\Engine\Util\FileCloseAware;
use Exception;
use RuntimeException;

/**
 * Dropbox (API v2) post-processing engine for Akeeba Engine
 *
 * @package Akeeba\Engine\Postproc\Connector
 */
class Dropbox2
{
	use FileCloseAware;
	use ProxyAware;

	/**
	 * The root URL for the Dropbox RPC API, ref https://www.dropbox.com/developers/documentation/http
	 */
	public const rootUrl = 'https://api.dropboxapi.com/2/';

	/**
	 * The root URL for the Dropbox Content API, ref https://www.dropbox.com/developers/documentation/http
	 */
	public const contentRootUrl = 'https://content.dropboxapi.com/2/';

	/**
	 * The URL of the helper script which is used to authenticate you with Dropbox
	 */
	public const helperUrl = 'https://www.akeeba.com/oauth2/dropbox2.php';

	/**
	 * Refresh the access token proactively when it is within this many seconds of expiring. Dropbox access tokens are
	 * short-lived (typically 4 hours); refreshing a little before expiry prevents the token from lapsing in the middle
	 * of a long-running backup, which is the cause of intermittent, hard-to-reproduce upload/download failures.
	 */
	private const tokenExpirationThreshold = 300;

	/**
	 * The refresh token used to get a new access token for OneDrive
	 *
	 * @var string
	 */
	protected $refreshToken = '';

	/**
	 * The access token for connecting to Dropbox
	 *
	 * @var   string
	 */
	private $accessToken = '';

	/**
	 * Download ID to use with the helper URL
	 *
	 * @var string
	 */
	private $dlid = '';

	/**
	 * 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,
	];

	private $namespaceId = '';

	private $refreshUrl = '';

	/**
	 * UNIX timestamp at which the current access token expires. 0 means "unknown" — e.g. a token supplied from saved
	 * configuration whose lifetime we have not learned yet. It is populated from the `expires_in` value Dropbox returns
	 * whenever the token is refreshed.
	 *
	 * @var int
	 */
	private $tokenExpiration = 0;

	/**
	 * Public constructor
	 *
	 * @param   string  $accessToken  The access token for accessing Dropbox
	 * @param   string  $dlid         The akeeba.com Download ID, used whenever you try to refresh the token
	 */
	public function __construct($accessToken, $refreshToken, $dlid, $refreshUrl = '')
	{
		$this->accessToken  = $accessToken;
		$this->refreshToken = $refreshToken;
		$this->dlid         = $dlid;
		$this->refreshUrl   = $refreshUrl ?: self::helperUrl;
	}

	/**
	 * Return information about the current user's account
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-users-get_current_account
	 */
	public function getCurrentAccount()
	{
		$relativeUrl = 'users/get_current_account';

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/json; charset=utf-8',
			],
		], 'null');

		return $result;
	}

	/**
	 * Get the raw listing of a folder
	 *
	 * @param   string  $path              The relative path of the folder to list its contents
	 * @param   bool    $recursive         Produce a recursive listing? [false]
	 * @param   bool    $includeMediaInfo  Include media information in the metadata? [false]
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-list_folder
	 */
	public function getRawContents($path = '/', $recursive = false, $includeMediaInfo = false)
	{
		$relativeUrl = 'files/list_folder';

		$path = $this->normalizePath($path);

		$params = [
			'path'               => $path,
			'recursive'          => $recursive ? true : false,
			'include_media_info' => $includeMediaInfo ? true : false,
		];

		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/json; charset=utf-8',
			],
		], $paramsForPost);

		return $result;
	}

	/**
	 * Get the raw listing of a folder
	 *
	 * @param   string  $cursor  The cursor recieved from getRawContents
	 *
	 * @return  array  See
	 *                 https://www.dropbox.com/developers/documentation/http#documentation-files-list_folder-continue
	 */
	public function getRawContentsContinue($cursor)
	{
		$relativeUrl = 'files/list_folder/continue';

		$params = [
			'cursor' => $cursor,
		];

		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/json; charset=utf-8',
			],
		], $paramsForPost);

		return $result;
	}

	/**
	 * Get the processed listing of a folder
	 *
	 * @param   string  $path  The relative path of the folder to list its contents
	 *
	 * @return  array  Two arrays under keys folders and files. Each array's key is the file/folder name, the value is
	 *                 the Dropbox Folder ID (folder) or size in bytes (file)
	 */
	public function listContents($path = '/')
	{
		$result = [];

		$rawContents = $this->getRawContents($path);
		$result      = $rawContents['entries'];

		while ($rawContents['has_more'])
		{
			$cursor      = $rawContents['cursor'];
			$rawContents = $this->getRawContentsContinue($cursor);
			$result      = array_merge($result, $rawContents['entries']);
		}

		$return = [
			'files'   => [],
			'folders' => [],
		];

		foreach ($result as $item)
		{
			if ($item['.tag'] == 'folder')
			{
				$return['folders'][$item['name']] = $item['id'];

				continue;
			}

			$return['files'][$item['name']] = $item['size'];
		}

		return $return;
	}

	/**
	 * Return the metadata for a file or folder
	 *
	 * @param   string  $path              The relative path of the file/folder to fetch the metadata for
	 * @param   bool    $includeMediaInfo  Include media information in the metadata? [false]
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-get_metadata
	 */
	public function getMetadata($path, $includeMediaInfo = false)
	{
		$relativeUrl = 'files/get_metadata';

		$path = $this->normalizePath($path);

		$params = [
			'path'               => $path,
			'include_media_info' => $includeMediaInfo ? true : false,
		];

		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/json; charset=utf-8',
			],
		], $paramsForPost);

		return $result;
	}

	/**
	 * Delete a file. See https://www.dropbox.com/developers/documentation/http#documentation-files-delete
	 *
	 * @param   string  $path         The relative path to the file to delete
	 * @param   bool    $failOnError  Throw exception if the deletion fails? Default true.
	 *
	 * @return  bool  True on success
	 *
	 * @throws  Exception
	 */
	public function delete($path, $failOnError = true)
	{
		$relativeUrl = 'files/delete_v2';
		$path        = $this->normalizePath($path);

		$params        = [
			'path' => $path,
		];
		$paramsForPost = json_encode($params);

		try
		{
			$result = $this->fetch('POST', self::rootUrl, $relativeUrl, [
				'headers' => [
					'Content-Type: application/json; charset=utf-8',
				],
			], $paramsForPost);
		}
		catch (Exception $e)
		{
			if (!$failOnError)
			{
				return false;
			}

			throw $e;
		}

		return true;
	}

	/**
	 * Download a remote file
	 *
	 * @param   string  $path       The path of the file in Dropbox
	 * @param   string  $localFile  The absolute filesystem path where the file will be downloaded to
	 */
	public function download($path, $localFile)
	{
		$relativeUrl = 'files/download';
		$path        = $this->normalizePath($path);

		$params        = [
			'path' => $path,
		];
		$paramsForPost = json_encode($params);

		$this->fetch('GET', self::contentRootUrl, $relativeUrl, [
			'headers'   => [
				'Content-Type:', // WARNING: Content-Type MUST be empty!
				'Dropbox-API-Arg: ' . $paramsForPost,
			],
			'file'      => $localFile,
			'file_mode' => 'wb',
		]);
	}

	/**
	 * Get a shared download URL for the remote file with the specified path to Dropbox root. This kind of URL is
	 * suitable for sharing with third parties. It doesn't the (secret) authentication token.
	 *
	 * @param   string  $path  Relative path to Dropbox root
	 *
	 * @return  string  Shared URL to download the file's contents
	 *
	 * @see     https://www.dropbox.com/developers/documentation/http#documentation-sharing-create_shared_link
	 */
	public function getSharedUrl($path, $expires = null)
	{
		$relativeUrl = 'sharing/create_shared_link_with_settings';
		$path        = $this->normalizePath($path);

		$settings = [
			'requested_visibility' => 'public',
		];

		$params        = [
			'path'     => $path,
			'settings' => $settings,
		];
		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/json; charset=utf-8',
			],
		], $paramsForPost);

		return $result['url'];
	}

	/**
	 * Returns an fully qualified, authenticated URL from a relative URL. This URL is NOT meant for sharing! It contains
	 * the (secret) authentication token!
	 *
	 * @param   string  $path  The URL to apply
	 *
	 * @return  string
	 */
	public function getAuthenticatedUrl($path, $retry = true)
	{
		$path = $this->normalizePath($path);

		// Use Dropbox's documented files/get_temporary_link endpoint. It returns a short-lived (~4 hour) direct-download
		// URL that needs NO access token. This is robust and secure: unlike embedding the bearer token as a query
		// parameter on the content endpoint (which Dropbox rejects intermittently once the token is near expiry, and
		// which would leak the token into browser history, server logs and referrers), the temporary link keeps working
		// for its whole lifetime regardless of what happens to the access token afterwards.
		try
		{
			$result = $this->fetch('POST', self::rootUrl, 'files/get_temporary_link', [
				'headers' => [
					'Content-Type: application/json; charset=utf-8',
				],
			], json_encode(['path' => $path]));
		}
		catch (RuntimeException $e)
		{
			// The access token may have expired between minting and use. Refresh it and retry once.
			if ($retry)
			{
				$this->refreshToken();

				return $this->getAuthenticatedUrl($path, false);
			}

			throw $e;
		}

		if (empty($result['link']))
		{
			throw new RuntimeException('Could not obtain a temporary download link from Dropbox', 500);
		}

		return $result['link'];
	}

	/**
	 * Creates a new multipart upload session and returns its upload URL
	 *
	 * @return  string  The upload session ID
	 *
	 * @see     https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-start
	 */
	public function createUploadSession()
	{
		$relativeUrl = 'files/upload_session/start';

		$info = $this->fetch('POST', self::contentRootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/octet-stream',
			],
		]);

		return $info['session_id'];
	}

	/**
	 * Finish an already started upload session and commits the file to a specific location in Dropbox
	 *
	 * @param   string  $sessionId  The upload session ID
	 * @param   string  $path       Relative path of the file in Dropbox
	 * @param   int     $offset     The file size that's been already uploaded
	 * @param   bool    $mute       If true, the Dropbox desktop/mobile app will NOT notify users of the uploaded file
	 *
	 * @return  array  See
	 *                 https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-finish
	 */
	public function finishUploadSession($sessionId, $path, $offset, $mute = false)
	{
		$relativeUrl = 'files/upload_session/finish';
		$path        = $this->normalizePath($path);

		$params        = [
			'cursor' => [
				'session_id' => $sessionId,
				'offset'     => $offset,
			],
			'commit' => [
				'path'       => $path,
				'mode'       => 'overwrite',
				'autorename' => false,
				'mute'       => $mute ? true : false,
			],
		];
		$paramsForPost = json_encode($params);

		return $this->fetch('POST', self::contentRootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/octet-stream',
				'Dropbox-API-Arg: ' . $paramsForPost,
			],
		]);
	}

	/**
	 * Upload a part
	 *
	 * @param   string  $sessionId  The upload session URL, see createUploadSession
	 * @param   string  $localFile  Absolute filesystem path of the source file
	 * @param   int     $from       Starting byte to begin uploading, default is 0 (start of file)
	 * @param   int     $length     Chunk size in bytes, default 10Mb, must NOT be over 60Mb!  MUST be a multiple of
	 *                              320Kb.
	 *
	 * @return  void
	 */
	public function uploadPart($sessionId, $localFile, $from = 0, $length = 10485760)
	{
		$relativeUrl = 'files/upload_session/append_v2';

		clearstatcache();
		$totalSize = filesize($localFile);
		$to        = $from + $length - 1;

		if ($to > ($totalSize - 1))
		{
			$to = $totalSize - 1;
		}

		$contentLength = $to - $from + 1;

		if ($contentLength <= 0)
		{
			return;
		}

		$params        = [
			'cursor' => [
				'session_id' => $sessionId,
				'offset'     => $from,
			],
			'close'  => false,
		];
		$paramsForPost = json_encode($params);

		$additional = [
			'headers'  => [
				'Content-Type: application/octet-stream',
				'Dropbox-API-Arg: ' . $paramsForPost,
			],
			'no-parse' => true,
		];

		$fp = @fopen($localFile, 'r');

		if ($fp === false)
		{
			throw new RuntimeException("Could not open $localFile for reading", 500);
		}

		fseek($fp, $from);
		$data = fread($fp, $contentLength);
		$this->conditionalFileClose($fp);

		$this->fetch('POST', self::contentRootUrl, $relativeUrl, $additional, $data);
	}

	/**
	 * Upload a file using multipart uploads. Useful for files over 100Mb and up to 2Gb.
	 *
	 * @param   string  $path       Relative path in Dropbox
	 * @param   string  $localFile  Absolute filesystem path of the source file
	 * @param   int     $partSize   Part size in bytes, default 10Mb.
	 * @param   bool    $mute       If true, the Dropbox desktop/mobile app will NOT notify users of the uploaded file
	 *
	 * @return  array  See
	 *                 https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-finish
	 */
	public function resumableUpload($path, $localFile, $partSize = 10485760, $mute = false)
	{
		clearstatcache();
		$totalSize = filesize($localFile);

		$sessionId = $this->createUploadSession();
		$from      = 0;

		while (true)
		{
			$this->uploadPart($sessionId, $localFile, $from, $partSize);

			$from += $partSize;

			if ($from >= $totalSize)
			{
				break;
			}
		}

		return $this->finishUploadSession($sessionId, $path, $totalSize, $mute);
	}

	/**
	 * Automatically decides which upload method to use to upload a file to Dropbox. This method will return when the
	 * entire file has been uploaded. If you want to implement staggered uploads use the createUploadSession and
	 * uploadPart methods.
	 *
	 * @param   string  $path       The remote path relative to Dropbox root
	 * @param   string  $localFile  The absolute local filesystem path
	 *
	 * @return  array  See
	 *                 https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-finish
	 */
	public function upload($path, $localFile)
	{
		clearstatcache();
		$filesize = @filesize($localFile);

		// Use resumable uploads with up to 1Mb parts
		return $this->resumableUpload($path, $localFile, 1048576);
	}

	/**
	 * Make a directory (including all of its parent directories) if the directory doesn't exist. If it already exists
	 * nothing happens. If it doesn't exist and cannot be created an exception is raised.
	 *
	 * @param   string  $path  The path to create
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-create_folder
	 */
	public function makeDirectory($path)
	{
		$path = $this->normalizePath($path);

		try
		{
			$ownMetaData = $this->getMetadata($path);
		}
		catch (Exception $e)
		{
			$ownMetaData = null;
		}

		// Empty path means that it already exists (it's the root)
		if (empty($path))
		{
			return [$ownMetaData];
		}

		// Get the parent path and the directory components of the path
		$parentPath = '/';
		$folder     = $path;

		if (strpos($path, '/') !== false)
		{
			$pathParts  = explode('/', $path);
			$folder     = array_pop($pathParts);
			$parentPath = implode('/', $pathParts);
		}

		// Does this path exist in the parent path?
		$mustCreate = false;

		try
		{
			if (is_null($ownMetaData))
			{
				$parentMetaData = $this->getMetadata($parentPath);
			}
		}
		catch (Exception $e)
		{
			// The parent folder doesn't exist. Create it!
			$this->makeDirectory($parentPath);
		}

		if (!is_null($ownMetaData))
		{
			return $ownMetaData;
		}

		// We have to create a new folder $folder in parent folder $parentPath.
		$relativeUrl   = 'files/create_folder_v2';
		$params        = [
			'path'       => $path,
			'autorename' => false,
		];
		$paramsForPost = json_encode($params);

		return $this->fetch('POST', self::rootUrl, $relativeUrl, [
			'headers' => [
				'Content-Type: application/json',
			],
		], $paramsForPost);
	}

	/**
	 * Get the namespace ID.
	 *
	 * This is used for Dropbox for Business only.
	 *
	 * @see     https://www.dropbox.com/developers/reference/namespace-guide
	 *
	 * @return  string
	 */
	public function getNamespaceId()
	{
		return $this->namespaceId;
	}

	/**
	 * Set the namespace ID. Set to empty to use the user's personal space (default behavior).
	 *
	 * This is used for Dropbox for Business only.
	 *
	 * @param   string  $namespaceId  The namespace ID. Get it with $this->getCurrentAccount
	 *
	 * @return  void
	 * @see     https://www.dropbox.com/developers/reference/namespace-guide
	 *
	 */
	public function setNamespaceId($namespaceId)
	{
		$this->namespaceId = $namespaceId;
	}

	/**
	 * Try to ping Dropbox, refresh the token if it's expired and return the refresh results.
	 *
	 * If no refresh was required 'needs_refresh' will be false.
	 *
	 * If refresh was required 'needs_refresh' will be true and the rest of the keys will be as returned by Dropbox.
	 *
	 * If the refresh failed you'll get a RuntimeException.
	 *
	 * @param   bool  $forceRefresh  Set to true to forcibly refresh the tokens
	 *
	 * @return  array
	 *
	 * @throws  RuntimeException
	 */
	public function ping($forceRefresh = false)
	{
		// Initialization
		$response = [
			'needs_refresh' => false,
		];

		if (!$forceRefresh)
		{
			if (!empty($this->tokenExpiration))
			{
				// We know when the token expires: refresh PROACTIVELY once it is at — or within a safety margin of —
				// expiry, so it cannot lapse mid-operation. This avoids the race where a "test" call succeeds but the
				// token then expires moments later during the real request (the cause of intermittent failures).
				$response['needs_refresh'] = (time() + self::tokenExpirationThreshold) >= $this->tokenExpiration;
			}
			else
			{
				// We do NOT know this token's expiry (e.g. it was supplied from saved configuration). Fall back to
				// probing the API with it and refreshing only if that probe fails.
				try
				{
					$this->getCurrentAccount();
				}
				catch (RuntimeException $e)
				{
					$response['needs_refresh'] = true;
				}
			}
		}

		// If there is no need to refresh the tokens, return
		if (!$response['needs_refresh'] && !$forceRefresh)
		{
			return $response;
		}

		$refreshResponse = $this->refreshToken();

		return array_merge($response, $refreshResponse);
	}

	/**
	 * Refresh the access token.
	 *
	 * @return array|string  The result coming from OneDrive
	 */
	public function refreshToken()
	{
		$refreshUrl = $this->getRefreshUrl();

		$refreshResponse = $this->fetch('GET', '', $refreshUrl);

		$this->refreshToken = $refreshResponse['refresh_token'] ?? $this->refreshToken;
		$this->accessToken  = $refreshResponse['access_token'] ?? $this->accessToken;

		// Record when the freshly minted access token will expire so ping() can refresh it proactively next time.
		if (isset($refreshResponse['expires_in']))
		{
			$this->tokenExpiration = time() + (int) $refreshResponse['expires_in'];
		}

		$refreshResponse['refresh_token']    = $this->refreshToken;
		$refreshResponse['access_token']     = $this->accessToken;
		$refreshResponse['token_expiration'] = $this->tokenExpiration;

		return $refreshResponse;
	}

	/**
	 * Get the UNIX timestamp at which the current access token expires (0 if unknown).
	 *
	 * @return  int
	 */
	public function getTokenExpiration()
	{
		return $this->tokenExpiration;
	}

	/**
	 * Restore a previously persisted access-token expiry timestamp, so ping() can refresh proactively without first
	 * having to probe the API. Pass 0 to mark the expiry as unknown.
	 *
	 * @param   int  $tokenExpiration  UNIX timestamp at which the access token expires.
	 *
	 * @return  void
	 */
	public function setTokenExpiration($tokenExpiration)
	{
		$this->tokenExpiration = (int) $tokenExpiration;
	}

	/**
	 * 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 = false;

		if (isset($additional['expect-status']))
		{
			$expectHttpStatus = $additional['expect-status'];
			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;

		// Try to use at least TLS 1.2. Requires cURL 7.34.0 or later.
		if (defined('CURLOPT_SSLVERSION') && defined('CURL_SSLVERSION_TLSv1_2'))
		{
			$options[CURLOPT_SSLVERSION] = CURL_SSLVERSION_TLSv1_2;
		}

		// 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 = [];

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

		// Add the authorization header
		$headers[] = 'Authorization: Bearer ' . $this->accessToken;

		// Add the Dropbox-API-Path-Root header
		$apiRootHeader = '{".tag": "home"}';

		if (!empty($this->namespaceId))
		{
			$apiRootHeader = sprintf('{".tag": "namespace_id", "namespace_id": "%s"}', $this->namespaceId);
		}

		$headers[] = 'Dropbox-API-Path-Root: ' . $apiRootHeader;

		// Apply the headers
		$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;

			if (!$expectHttpStatus)
			{
				$expectHttpStatus = 200;
			}
		}
		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] = '';
			}
		}
		elseif ($method == 'PUT' && $fp)
		{
			$options[CURLOPT_PUT]    = true;
			$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);
		}
		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);

		if (version_compare(PHP_VERSION, '8.5.0', 'lt'))
		{
			curl_close($ch);
		}

		// Close open file pointers
		if ($fp)
		{
			$this->conditionalFileClose($fp);

			if ($expectHttpStatus && ($expectHttpStatus != $lastHttpCode))
			{
				if ($file && ($method == 'GET'))
				{
					@unlink($file);
				}

				throw new UnexpectedHTTPStatus($lastHttpCode);
			}
		}

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

		if ($expectHttpStatus)
		{
			if ($expectHttpStatus == $lastHttpCode)
			{
				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);

		// Did we get an error response?
		if (isset($response['error']) && is_array($response['error']))
		{
			$decodedError = $this->decodeError($response['error']);

			throw new APIError($decodedError['code'], $decodedError['description'], 500);
		}

		// Did we get an error response (from the helper script)?
		if (isset($response['error']))
		{
			$error            = $response['error'];
			$errorDescription = $response['error_description'] ?? 'No error description provided';

			throw new APIError($error, $errorDescription, 500);
		}

		return $response;
	}

	/**
	 * Normalize the path of a resource inside the Dropbox account
	 *
	 * @param   string  $relativePath  The relative path to the Dropbox root
	 *
	 * @return  string
	 */
	protected function normalizePath($relativePath)
	{
		/**
		 * Some users enter the base path as /foo/bar/ instead of /foo/bar. This results in relative paths in the form
		 * of /foo/bar//baz.bat instead of /foo/bar/baz.bat. While the former doesn't cause a problem uploading(!) it
		 * causes the download to fail with a 400 error and the signed URL to fail entirely with a Dropbox-side error
		 * message. Therefore we need to replace // with / in the $relativePath.
		 */
		$relativePath = str_replace('//', '/', $relativePath);

		// Remove trailing slashes from the relative path
		$relativePath = trim($relativePath, '/');

		// An empty path is normalized to an empty string.
		if (empty($relativePath))
		{
			$path = '';

			return $path;
		}

		// The path MUST start with a forward slash
		$path = '/' . $relativePath;

		/**
		 * If the path is just a forward slash OR a double forward slash then it's the root which MUST be normalized to
		 * an empty string. Normally the check for the double forward slash should always be false (unless someone
		 * screwed up the code above).
		 */
		if (($path == '/') || $path == '//')
		{
			$path = '';
		}

		return $path;
	}

	/**
	 * Decodes the error messages returned by Dropbox
	 *
	 * @param   array  $error  The error structure returned by Dropbox
	 *
	 * @return  array  Error code and description
	 */
	protected function decodeError($error)
	{
		// Initialise
		$ret = [
			'code'        => 'unknown',
			'description' => 'No error description provided. Raw error: ' . print_r($error, true),
		];

		// Make sure there's an error tag
		if (!isset($error['.tag']))
		{
			$error['.tag'] = 'other';
		}

		$ret['code'] = $error['.tag'];

		switch ($error['.tag'])
		{
			case 'path':
			case 'path_lookup':
				$tag = $error['.tag'];

				if (!isset($error[$tag]['.tag']))
				{
					$error[$tag]['.tag'] = 'other';
				}

				$ret['code'] = $error[$tag]['.tag'];

				switch ($ret['code'])
				{
					case 'malformed_path':
						$ret['description'] = 'This field is optional.';
						break;

					case 'not_found':
						$ret['description'] = 'There is nothing at the given path.';
						break;

					case 'not_file':
						$ret['description'] = 'Dropbox was expecting a file, but the given path refers to something that isn\'t a file.';
						break;

					case 'not_folder':
						$ret['description'] = 'Dropbox was expecting a folder, but the given path refers to something that isn\'t a folder.';
						break;

					case 'restricted_content':
						$ret['description'] = 'The file cannot be transferred because the content is restricted. For example, sometimes there are legal restrictions due to copyright claims.';
						break;
				}
				break;

			case 'path_write':
				if (!isset($error['path_write']['.tag']))
				{
					$error['path_write']['.tag'] = 'other';
				}

				$ret['code'] = $error['path_write']['.tag'];

				switch ($ret['code'])
				{
					case 'malformed_path':
						$ret['description'] = 'This field is optional.';
						break;

					case 'conflict':
						$ret['description'] = 'Couldn\'t write to the target path because of a conflict.';
						break;

					case 'no_write_permission':
						$ret['description'] = 'You do not have permissions to write to the target location.';
						break;

					case 'insufficient_space':
						$ret['description'] = 'You do not have enough available space (bytes) to write more data.';
						break;

					case 'disallowed_name':
						$ret['description'] = 'Dropbox will not save the file or folder because its name contains characters that are not allowed.';
						break;
				}
				break;

			case 'reset':
				$ret['description'] = 'The folder listing cursor has been invalidated. Try getting a new folder list.';
				break;
		}

		return $ret;
	}

	protected function getRefreshUrl()
	{
		if (empty($this->refreshUrl))
		{
			throw new \RuntimeException('The OAuth2 Refresh URL is empty. Please check your backup profile\'s configuration.');
		}

		$refreshUrl = $this->refreshUrl .
		              (strpos($this->refreshUrl, '?') === false ? '?' : '&')
		              . 'refresh_token=' . urlencode($this->refreshToken);

		if (strpos($refreshUrl, self::helperUrl) === 0)
		{
			$refreshUrl .= '&dlid=' . urlencode($this->dlid);
		}

		return $refreshUrl;
	}
}
