// *****************************************************************************
// * This file is part of the FreeFileSync project. It is distributed under    *
// * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0          *
// * Copyright (C) Zenju (zenju AT freefilesync DOT org) - All Rights Reserved *
// *****************************************************************************

#include "gdrive.h"
#include <variant>
#include <libcurl/curl_wrap.h> //DON'T include <curl/curl.h> directly!
#include <zen/base64.h>
#include <zen/file_access.h>
#include <zen/file_io.h>
#include <zen/file_traverser.h>
#include <zen/guid.h>
#include <zen/http.h>
#include <zen/json.h>
#include <zen/resolve_path.h>
#include <zen/process_exec.h>
#include <zen/socket.h>
#include <zen/shutdown.h>
#include <zen/time.h>
#include <zen/zlib_wrap.h>
#include "abstract_impl.h"
#include "init_curl_libssh2.h"
    #include <poll.h>

using namespace zen;
using namespace fff;
using AFS = AbstractFileSystem;


namespace fff
{
struct GdrivePath
{
    GdriveLogin gdriveLogin;
    AfsPath itemPath; //path relative to drive root
};

struct GdriveRawPath
{
    std::string parentId; //Google Drive item IDs are *globally* unique!
    Zstring itemName;
};
inline
std::weak_ordering operator<=>(const GdriveRawPath& lhs, const GdriveRawPath& rhs)
{
    if (const std::strong_ordering cmp = lhs.parentId <=> rhs.parentId;
        cmp != std::strong_ordering::equal)
        return cmp;

    return compareNativePath(lhs.itemName, rhs.itemName);
}

constinit Global<PathAccessLocker<GdriveRawPath>> globalGdrivePathAccessLocker;
GLOBAL_RUN_ONCE(globalGdrivePathAccessLocker.set(std::make_unique<PathAccessLocker<GdriveRawPath>>()));

template <> std::shared_ptr<PathAccessLocker<GdriveRawPath>> PathAccessLocker<GdriveRawPath>::getGlobalInstance() { return globalGdrivePathAccessLocker.get(); }
template <> Zstring PathAccessLocker<GdriveRawPath>::getItemName(const GdriveRawPath& nativePath) { return nativePath.itemName; }

using PathAccessLock = PathAccessLocker<GdriveRawPath>::Lock; //throw SysError
using PathBlockType  = PathAccessLocker<GdriveRawPath>::BlockType;
}


namespace
{
//Google Drive REST API Overview:  https://developers.google.com/drive/api/v3/about-sdk
//Google Drive REST API Reference: https://developers.google.com/drive/api/v3/reference
const Zchar* GOOGLE_REST_API_SERVER = Zstr("www.googleapis.com");

constexpr std::chrono::seconds HTTP_SESSION_MAX_IDLE_TIME  (20);
constexpr std::chrono::seconds HTTP_SESSION_CLEANUP_INTERVAL(4);
constexpr std::chrono::seconds GDRIVE_SYNC_INTERVAL         (5);

const size_t GDRIVE_BLOCK_SIZE_DOWNLOAD =  64 * 1024; //libcurl returns blocks of only 16 kB as returned by recv() even if we request larger blocks via CURLOPT_BUFFERSIZE
const size_t GDRIVE_BLOCK_SIZE_UPLOAD   =  64 * 1024; //libcurl requests blocks of 64 kB. larger blocksizes set via CURLOPT_UPLOAD_BUFFERSIZE do not seem to make a difference
const size_t GDRIVE_STREAM_BUFFER_SIZE = 1024 * 1024; //unit: [byte]
//stream buffer should be big enough to facilitate prefetching during alternating read/write operations => e.g. see serialize.h::unbufferedStreamCopy()

constexpr ZstringView gdrivePrefix = Zstr("gdrive:");
const char gdriveFolderMimeType  [] = "application/vnd.google-apps.folder";
const char gdriveShortcutMimeType[] = "application/vnd.google-apps.shortcut"; //= symbolic link!

const char DB_FILE_DESCR[] = "FreeFileSync";
const int  DB_FILE_VERSION = 5; //2021-05-15

std::string getGdriveClientId    () { return ""; } // => replace with live credentials
std::string getGdriveClientSecret() { return ""; } //




struct HttpSessionId
{
    explicit HttpSessionId(const Zstring& serverName) :
        server(serverName) {}

    Zstring server;
};

inline
bool operator==(const HttpSessionId& lhs, const HttpSessionId& rhs) { return equalAsciiNoCase(lhs.server, rhs.server); }
}

//exactly the type of case insensitive comparison we need for server names!
//https://docs.microsoft.com/en-us/windows/win32/api/ws2tcpip/nf-ws2tcpip-getaddrinfow#IDNs
template<> struct std::hash<HttpSessionId> { size_t operator()(const HttpSessionId& sessionId) const { return StringHashAsciiNoCase()(sessionId.server); } };


namespace
{
Zstring concatenateGdriveFolderPathPhrase(const GdrivePath& gdrivePath); //noexcept


//e.g.: gdrive:/john@gmail.com:SharedDrive/folder/file.txt
std::wstring getGdriveDisplayPath(const GdrivePath& gdrivePath)
{
    Zstring displayPath = Zstring(gdrivePrefix) + FILE_NAME_SEPARATOR;

    displayPath += utfTo<Zstring>(gdrivePath.gdriveLogin.email);

    if (!gdrivePath.gdriveLogin.locationName.empty())
        displayPath += Zstr(':') + gdrivePath.gdriveLogin.locationName;

    if (!gdrivePath.itemPath.value.empty())
        displayPath += FILE_NAME_SEPARATOR + gdrivePath.itemPath.value;

    return utfTo<std::wstring>(displayPath);
}


std::wstring formatGdriveErrorRaw(std::string serverResponse)
{
    /* e.g.: {  "error": {  "errors": [{ "domain": "global",
                                         "reason": "invalidSharingRequest",
                                         "message": "Bad Request. User message: \"ACL change not allowed.\"" }],
                            "code":    400,
                            "message": "Bad Request" }}

    or: {  "error":             "invalid_client",
           "error_description": "Unauthorized" }

    or merely: { "error": "invalid_token" }                                    */
    trim(serverResponse);

    assert(!serverResponse.empty());
    if (serverResponse.empty())
        return L"<" + _("empty") + L">"; //at least give some indication

    try
    {
        const JsonValue jresponse = parseJson(serverResponse); //throw JsonParsingError

        if (const JsonValue* error = getChildFromJsonObject(jresponse, "error"))
        {
            if (error->type == JsonValue::Type::string)
                return utfTo<std::wstring>(error->primVal);
            //the inner message is generally more descriptive!
            else if (const JsonValue* errors = getChildFromJsonObject(*error, "errors"))
                if (errors->type == JsonValue::Type::array && !errors->arrayVal.empty())
                    if (const JsonValue* message = getChildFromJsonObject(errors->arrayVal[0], "message"))
                        if (message->type == JsonValue::Type::string)
                            return utfTo<std::wstring>(message->primVal);
        }
    }
    catch (JsonParsingError&) {} //not JSON?

    return utfTo<std::wstring>(serverResponse);
}


AFS::FingerPrint getGdriveFilePrint(const std::string& itemId)
{
    assert(!itemId.empty());
    //Google Drive item ID is persistent and globally unique! :)
    return hashString<AFS::FingerPrint>(itemId);
}

//----------------------------------------------------------------------------------------------------------------

constinit Global<UniSessionCounter> httpSessionCount;
GLOBAL_RUN_ONCE(httpSessionCount.set(createUniSessionCounter()));
UniInitializer globalInitHttp(*httpSessionCount.get());

//----------------------------------------------------------------------------------------------------------------

class HttpSessionManager //reuse (healthy) HTTP sessions globally
{
public:
    explicit HttpSessionManager(const Zstring& caCertFilePath) :
        caCertFilePath_(caCertFilePath),
        sessionCleaner_([this]
    {
        setCurrentThreadName(Zstr("Session Cleaner[HTTP]"));
        runGlobalSessionCleanUp(); //throw ThreadStopRequest
    }) {}

    void access(const HttpSessionId& sessionId, const std::function<void(HttpSession& session)>& useHttpSession /*throw X*/) //throw SysError, X
    {
        Protected<HttpSessionManager::HttpSessionCache>& sessionCache = getSessionCache(sessionId);

        std::unique_ptr<HttpInitSession> httpSession;

        sessionCache.access([&](HttpSessionManager::HttpSessionCache& sessions)
        {
            //assume "isHealthy()" to avoid hitting server connection limits: (clean up of !isHealthy() after use, idle sessions via worker thread)
            if (!sessions.empty())
            {
                httpSession = std::move(sessions.back    ());
                /**/                    sessions.pop_back();
            }
        });

        //create new HTTP session outside the lock: 1. don't block other threads 2. non-atomic regarding "sessionCache"! => one session too many is not a problem!
        if (!httpSession)
            httpSession = std::make_unique<HttpInitSession>(sessionId.server, caCertFilePath_); //throw SysError

        ZEN_ON_SCOPE_EXIT(
            if (isHealthy(httpSession->session)) //thread that created the "!isHealthy()" session is responsible for clean up (avoid hitting server connection limits!)
        sessionCache.access([&](HttpSessionManager::HttpSessionCache& sessions) { sessions.push_back(std::move(httpSession)); }); );

        useHttpSession(httpSession->session); //throw X
    }

private:
    HttpSessionManager           (const HttpSessionManager&) = delete;
    HttpSessionManager& operator=(const HttpSessionManager&) = delete;

    //associate session counting (for initialization/teardown)
    struct HttpInitSession
    {
        HttpInitSession(const Zstring& server, const Zstring& caCertFilePath) :
            session(server, true /*useTls*/, caCertFilePath) {}

        const std::shared_ptr<UniCounterCookie> cookie{getLibsshCurlUnifiedInitCookie(httpSessionCount)}; //throw SysError
        HttpSession session; //life time must be subset of UniCounterCookie
    };
    static bool isHealthy(const HttpSession& s) { return std::chrono::steady_clock::now() - s.getLastUseTime() <= HTTP_SESSION_MAX_IDLE_TIME; }

    using HttpSessionCache = std::vector<std::unique_ptr<HttpInitSession>>;

    Protected<HttpSessionCache>& getSessionCache(const HttpSessionId& sessionId)
    {
        //single global session store per sessionId; life-time bound to globalInstance => never remove a sessionCache!!!
        Protected<HttpSessionCache>* sessionCache = nullptr;

        globalSessionCache_.access([&](GlobalHttpSessions& sessionsById)
        {
            sessionCache = &sessionsById[sessionId]; //get or create
        });
        static_assert(std::is_same_v<GlobalHttpSessions, std::unordered_map<HttpSessionId, Protected<HttpSessionCache>>>, "require std::unordered_map so that the pointers we return remain stable");

        return *sessionCache;
    }

    //run a dedicated clean-up thread => it's unclear when the server let's a connection time out, so we do it preemptively
    //context of worker thread:
    void runGlobalSessionCleanUp() //throw ThreadStopRequest
    {
        std::chrono::steady_clock::time_point lastCleanupTime;
        for (;;)
        {
            const auto now = std::chrono::steady_clock::now();

            if (now < lastCleanupTime + HTTP_SESSION_CLEANUP_INTERVAL)
                interruptibleSleep(lastCleanupTime + HTTP_SESSION_CLEANUP_INTERVAL - now); //throw ThreadStopRequest

            lastCleanupTime = std::chrono::steady_clock::now();

            std::vector<Protected<HttpSessionCache>*> sessionCaches; //pointers remain stable, thanks to std::unordered_map<>

            globalSessionCache_.access([&](GlobalHttpSessions& sessionsByCfg)
            {
                for (auto& [sessionCfg, idleSession] : sessionsByCfg)
                    sessionCaches.push_back(&idleSession);
            });

            for (Protected<HttpSessionCache>* sessionCache : sessionCaches)
                for (;;)
                {
                    bool done = false;
                    sessionCache->access([&](HttpSessionCache& sessions)
                    {
                        for (std::unique_ptr<HttpInitSession>& sshSession : sessions)
                            if (!isHealthy(sshSession->session)) //!isHealthy() sessions are destroyed after use => in this context this means they have been idle for too long
                            {
                                sshSession.swap(sessions.back());
                                /**/            sessions.pop_back(); //run ~HttpSession *inside* the lock! => avoid hitting server limits!
                                return; //don't hold lock for too long: delete only one session at a time, then yield...
                            }
                        done = true;
                    });
                    if (done)
                        break;
                    std::this_thread::yield();
                }
        }
    }

    using GlobalHttpSessions = std::unordered_map<HttpSessionId, Protected<HttpSessionCache>>;

    Protected<GlobalHttpSessions> globalSessionCache_;
    const Zstring caCertFilePath_;
    InterruptibleThread sessionCleaner_;
};

//--------------------------------------------------------------------------------------
constinit Global<HttpSessionManager> globalHttpSessionManager; //caveat: life time must be subset of static UniInitializer!
//--------------------------------------------------------------------------------------

struct GdriveAccess
{
    std::string token;
    int timeoutSec = 0;
};

//===========================================================================================================================

HttpSession::Result googleHttpsRequest(const Zstring& serverName, const std::string& serverRelPath, //throw SysError, X
                                       const std::vector<std::string>& extraHeaders,
                                       std::vector<CurlOption> extraOptions,
                                       const std::function<void  (std::span<const char> buf)>& writeResponse /*throw X*/, //optional
                                       const std::function<size_t(std::span<      char> buf)>& readRequest   /*throw X*/, //optional; return "bytesToRead" bytes unless end of stream!
                                       const std::function<void(const std::string_view& header)>& receiveHeader /*throw X*/, //optional
                                       int timeoutSec)
{
    //https://developers.google.com/drive/api/v3/performance
    //"In order to receive a gzip-encoded response you must do two things: Set an Accept-Encoding header, ["gzip" automatically set by HttpSession]
    extraOptions.emplace_back(CURLOPT_USERAGENT, "FreeFileSync (gzip)"); //and modify your user agent to contain the string gzip."

    const std::shared_ptr<HttpSessionManager> mgr = globalHttpSessionManager.get();
    if (!mgr)
        throw SysError(formatSystemError("googleHttpsRequest", L"", L"Function call not allowed during init/shutdown."));

    HttpSession::Result httpResult;

    mgr->access(HttpSessionId(serverName), [&](HttpSession& session) //throw SysError
    {
        httpResult = session.perform(serverRelPath, extraHeaders, extraOptions, writeResponse, readRequest, receiveHeader, timeoutSec); //throw SysError, X
    });
    return httpResult;
}


//try to get a grip on this crazy REST API: - parameters are passed via query string, header, or body, using GET, POST, PUT, PATCH, DELETE, ... it's a dice roll
HttpSession::Result gdriveHttpsRequest(const std::string& serverRelPath, //throw SysError, X
                                       std::vector<std::string> extraHeaders,
                                       const std::vector<CurlOption>& extraOptions,
                                       const std::function<void  (std::span<const char> buf)>& writeResponse /*throw X*/, //optional
                                       const std::function<size_t(std::span<      char> buf)>& readRequest   /*throw X*/, //optional; return "bytesToRead" bytes unless end of stream!
                                       const std::function<void(const std::string_view& header)>& receiveHeader /*throw X*/, //optional
                                       const GdriveAccess& access)
{
    extraHeaders.push_back("Authorization: Bearer " + access.token);

    return googleHttpsRequest(GOOGLE_REST_API_SERVER, serverRelPath,
                              extraHeaders,
                              extraOptions,
                              writeResponse /*throw X*/,
                              readRequest /*throw X*/,
                              receiveHeader /*throw X*/, access.timeoutSec); //throw SysError, X
}

//========================================================================================================

struct GdriveUser
{
    std::wstring displayName;
    std::string email;
};
GdriveUser getGdriveUser(const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/about
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"fields", "user/displayName,user/emailAddress"},
    });
    std::string response;
    gdriveHttpsRequest("/drive/v3/about?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    if (const JsonValue* user = getChildFromJsonObject(jresponse, "user"))
    {
        const std::optional<std::string> displayName = getPrimitiveFromJsonObject(*user, "displayName");
        const std::optional<std::string> email       = getPrimitiveFromJsonObject(*user, "emailAddress");
        if (displayName && email)
            return {utfTo<std::wstring>(*displayName), *email};
    }

    throw SysError(formatGdriveErrorRaw(response));
}


struct GdriveAuthCode
{
    std::string code;
    std::string redirectUrl;
    std::string codeChallenge;
};

struct GdriveAccessToken
{
    std::string value;
    time_t validUntil = 0; //remaining lifetime of the access token
};

struct GdriveAccessInfo
{
    GdriveAccessToken accessToken;
    std::string refreshToken;
    GdriveUser userInfo;
};

GdriveAccessInfo gdriveExchangeAuthCode(const GdriveAuthCode& authCode, int timeoutSec) //throw SysError
{
    //https://developers.google.com/identity/protocols/OAuth2InstalledApp#exchange-authorization-code
    const std::string postBuf = xWwwFormUrlEncode(
    {
        {"code",          authCode.code},
        {"client_id",     getGdriveClientId()},
        {"client_secret", getGdriveClientSecret()},
        {"redirect_uri",  authCode.redirectUrl},
        {"grant_type",    "authorization_code"},
        {"code_verifier", authCode.codeChallenge},
    });
    std::string response;
    googleHttpsRequest(Zstr("oauth2.googleapis.com"), "/token", {} /*extraHeaders*/, {{CURLOPT_POSTFIELDS, postBuf.c_str()}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, timeoutSec); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> accessToken  = getPrimitiveFromJsonObject(jresponse, "access_token");
    const std::optional<std::string> refreshToken = getPrimitiveFromJsonObject(jresponse, "refresh_token");
    const std::optional<std::string> expiresIn    = getPrimitiveFromJsonObject(jresponse, "expires_in"); //e.g. 3600 seconds
    if (!accessToken || !refreshToken || !expiresIn)
        throw SysError(formatGdriveErrorRaw(response));

    const GdriveUser userInfo = getGdriveUser({*accessToken, timeoutSec}); //throw SysError

    return {{*accessToken, std::time(nullptr) + stringTo<time_t>(*expiresIn)}, *refreshToken, userInfo};
}


GdriveAccessInfo gdriveAuthorizeAccess(const std::string& gdriveLoginHint, const std::function<void()>& updateGui /*throw X*/, int timeoutSec) //throw SysError, X
{
    //spin up a web server to wait for the HTTP GET after Google authentication
    const addrinfo hints
    {
        .ai_flags =
        AI_ADDRCONFIG | //no such issue on Linux: https://bugs.chromium.org/p/chromium/issues/detail?id=5234
        AI_PASSIVE, //the returned socket addresses will be suitable for bind(2)ing a socket that will accept(2) connections.
        .ai_family   = AF_UNSPEC, //don't care if AF_INET or AF_INET6
        .ai_socktype = SOCK_STREAM, //we *do* care about this one!
    };
    addrinfo* servinfo = nullptr;
    ZEN_ON_SCOPE_EXIT(if (servinfo) ::freeaddrinfo(servinfo));

    //ServiceName == "0": open the next best free port
    const int rcGai = ::getaddrinfo(nullptr,    //_In_opt_ PCSTR            pNodeName
                                    "0",        //_In_opt_ PCSTR            pServiceName
                                    &hints,     //_In_opt_ const ADDRINFOA* pHints
                                    &servinfo); //_Outptr_ PADDRINFOA*      ppResult
    if (rcGai != 0)
        THROW_LAST_SYS_ERROR_GAI(rcGai);
    if (!servinfo)
        throw SysError(formatSystemError("getaddrinfo", L"" /*errorCode*/, L"No local IP address available"));


    const auto getBoundSocket = [](const auto& /*::addrinfo*/ ai)
    {
        SocketType testSocket = ::socket(ai.ai_family,    //int socket_family
                                         SOCK_CLOEXEC |
                                         ai.ai_socktype,  //int socket_type
                                         ai.ai_protocol); //int protocol
        if (testSocket == invalidSocket)
            THROW_LAST_SYS_ERROR_WSA("socket");
        ZEN_ON_SCOPE_FAIL(closeSocket(testSocket));

        if (::bind(testSocket, ai.ai_addr, static_cast<int>(ai.ai_addrlen)) != 0)
            THROW_LAST_SYS_ERROR_WSA("bind");

        return testSocket;
    };


    SocketType socket = invalidSocket;
    std::optional<SysError> firstError;

    for (const auto* /*::addrinfo*/ si = servinfo; si; si = si->ai_next)
        if (si->ai_family == AF_INET ||
            si->ai_family == AF_INET6)
            try
            {
                socket = getBoundSocket(*si); //throw SysError; pass ownership
                break;
            }
            catch (const SysError& e) { if (!firstError) firstError = e; }

    if (socket == invalidSocket)
    {
        if (firstError)
            throw* firstError;
        throw SysError(formatSystemError("getaddrinfo", L"" /*errorCode*/, L"No local IPv4 or IPv6 address available"));
    }
    ZEN_ON_SCOPE_EXIT(closeSocket(socket));


    sockaddr_storage addr = {}; //"sufficiently large to store address information for IPv4 (AF_INET/sockaddr_in) or IPv6 (AF_INET6/sockaddr_in6)"
    socklen_t addrLen = sizeof(addr);
    if (::getsockname(socket, reinterpret_cast<sockaddr*>(&addr), &addrLen) != 0)
        THROW_LAST_SYS_ERROR_WSA("getsockname");

    std::string redirectUrl;
    if (addr.ss_family == AF_INET)
    {
        //the socket is not bound to a specific local IP:
        //  char buf[INET_ADDRSTRLEN] = {}; //inet_ntop -> "0.0.0.0"
        //  inet_ntop(AF_INET, &reinterpret_cast<const sockaddr_in&>(addr).sin_addr, buf, std::size(buf));
        const int port = ntohs(reinterpret_cast<const sockaddr_in&>(addr).sin_port);
        redirectUrl = "http://127.0.0.1:" + numberTo<std::string>(port);
    }
    else if (addr.ss_family == AF_INET6) //inet_ntop() == "::"
    {
        const int port = ntohs(reinterpret_cast<const sockaddr_in6&>(addr).sin6_port);
        redirectUrl = "http://[::1]:" + numberTo<std::string>(port);
    }
    else
        throw SysError(formatSystemError("getsockname", L"", L"Unexpected protocol family: " + numberTo<std::wstring>(addr.ss_family)));

    if (::listen(socket, SOMAXCONN) != 0)
        THROW_LAST_SYS_ERROR_WSA("listen");


    //"A code_verifier is a high-entropy cryptographic random string using the unreserved characters:"
    //[A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a minimum length of 43 characters and a maximum length of 128 characters.
    std::string codeChallenge = stringEncodeBase64(generateGUID() + generateGUID());
    replace(codeChallenge, '+', '-'); //
    replace(codeChallenge, '/', '.'); //base64 is almost a perfect fit for code_verifier!
    replace(codeChallenge, '=', '_'); //
    assert(codeChallenge.size() == 44);

    //authenticate Google Drive via browser: https://developers.google.com/identity/protocols/OAuth2InstalledApp#step-2-send-a-request-to-googles-oauth-20-server
    const std::string oauthUrl = "https://accounts.google.com/o/oauth2/v2/auth?" + xWwwFormUrlEncode(
    {
        {"client_id",      getGdriveClientId()},
        {"redirect_uri",   redirectUrl},
        {"response_type",  "code"},
        {"scope",          "https://www.googleapis.com/auth/drive"},
        {"code_challenge", codeChallenge},
        {"code_challenge_method", "plain"},
        {"login_hint",     gdriveLoginHint},
    });
    try
    {
        openWithDefaultApp(utfTo<Zstring>(oauthUrl)); //throw FileError
    }
    catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //errors should be further enriched by context info => SysError

    //process incoming HTTP requests
    for (;;)
    {
        for (;;) //::accept() blocks forever if no client connects (e.g. user just closes the browser window!) => wait for incoming traffic with a time-out via ::select()
        {
            if (updateGui) updateGui(); //throw X

            const int waitTimeMs = 100;
            pollfd fds[] = {{socket, POLLIN}};

            const char* functionName = "poll";
            const int rv = ::poll(fds, std::size(fds), waitTimeMs); //int timeout
            if (rv < 0)
                THROW_LAST_SYS_ERROR_WSA(functionName);
            else if (rv != 0)
                break;
            //else: time-out!
        }
        //potential race! if the connection is gone right after ::select() and before ::accept(), latter will hang
        const int clientSocket = ::accept4(socket,        //int sockfd
                                           nullptr,       //sockaddr* addr
                                           nullptr,       //socklen_t* addrlen
                                           SOCK_CLOEXEC); //int flags
        if (clientSocket == invalidSocket)
            THROW_LAST_SYS_ERROR_WSA("accept");


        //receive first line of HTTP request
        std::string reqLine;
        for (;;)
        {
            const size_t blockSize = 64 * 1024;
            reqLine.resize(reqLine.size() + blockSize);
            const size_t bytesReceived = tryReadSocket(clientSocket, &*(reqLine.end() - blockSize), blockSize); //throw SysError
            reqLine.resize(reqLine.size() - (blockSize - bytesReceived)); //caveat: unsigned arithmetics

            if (contains(reqLine, "\r\n"))
            {
                reqLine = beforeFirst(reqLine, "\r\n", IfNotFoundReturn::none);
                break;
            }
            if (bytesReceived == 0 || reqLine.size() >= 100'000 /*bogus line length*/)
                break;
        }

        //get OAuth2.0 authorization result from Google, either:
        std::string code;
        std::string error;

        //parse header; e.g.: GET http://127.0.0.1:62054/?code=4/ZgBRsB9k68sFzc1Pz1q0__Kh17QK1oOmetySrGiSliXt6hZtTLUlYzm70uElNTH9vt1OqUMzJVeFfplMsYsn4uI HTTP/1.1
        const std::vector<std::string_view> statusItems = splitCpy<std::string_view>(reqLine, ' ', SplitOnEmpty::allow); //Method SP Request-URI SP HTTP-Version CRLF

        if (statusItems.size() == 3 && statusItems[0] == "GET" && startsWith(statusItems[2], "HTTP/"))
        {
            for (const auto& [name, value] : xWwwFormUrlDecode(afterFirst(statusItems[1], "?", IfNotFoundReturn::none)))
                if (name == "code")
                    code = value;
                else if (name == "error")
                    error = value; //e.g. "access_denied" => no more detailed error info available :(
        } //"add explicit braces to avoid dangling else [-Wdangling-else]"

        std::variant<std::monostate, GdriveAccessInfo, SysError> authResult;

        //send HTTP response; https://www.w3.org/Protocols/HTTP/1.0/spec.html#Request-Line
        std::string httpResponse;
        if (code.empty() && error.empty()) //parsing error or unrelated HTTP request
            httpResponse = "HTTP/1.0 400 Bad Request" "\r\n" "\r\n" "400 Bad Request\n" + reqLine;
        else
        {
            std::string htmlMsg = R"(<!DOCTYPE html>
            <html lang="en">
                <head>
                    <meta charset="utf-8">
                    <meta name="viewport" content="width=device-width, initial-scale=1.0">
                    <title>TITLE_PLACEHOLDER</title>
                    <style>
                        * {
                            font-family: -apple-system, 'Segoe UI', arial, Tahoma, Helvetica, sans-serif;
                            text-align: center;
                            background-color: #eee; }
                        h1 {
                            font-size:   45px;
                            font-weight: 300;
                            margin: 80px 0 20px 0; }
                        .descr {
                            font-size:   21px;
                            font-weight: 200; }
                    </style>
                </head>
                <body>
                    <h1><img src="https://freefilesync.org/images/FreeFileSync.png" style="vertical-align:middle; height:50px;" alt=""> TITLE_PLACEHOLDER</h1>
                    <div class="descr">MESSAGE_PLACEHOLDER</div>
                </body>
            </html>
            )";
            try
            {
                if (!error.empty())
                    throw SysError(replaceCpy(_("Error code %x"), L"%x",  + L"\"" + utfTo<std::wstring>(error) + L"\""));

                //do as many login-related tasks as possible while we have the browser as an error output device!
                //see AFS::connectNetworkFolder() => errors will be lost after time out in dir_exist_async.h!
                authResult = gdriveExchangeAuthCode({code, redirectUrl, codeChallenge}, timeoutSec); //throw SysError
                replace(htmlMsg, "TITLE_PLACEHOLDER",   utfTo<std::string>(_("Authentication completed.")));
                replace(htmlMsg, "MESSAGE_PLACEHOLDER", utfTo<std::string>(_("You may close this page now and continue with FreeFileSync.")));
            }
            catch (const SysError& e)
            {
                authResult = e;
                replace(htmlMsg, "TITLE_PLACEHOLDER",   utfTo<std::string>(_("Authentication failed.")));
                replace(htmlMsg, "MESSAGE_PLACEHOLDER", utfTo<std::string>(replaceCpy(_("Unable to connect to %x."), L"%x", L"Google Drive") + L"\n\n" + e.toString()));
            }
            httpResponse = "HTTP/1.0 200 OK"         "\r\n"
                           "Content-Type: text/html" "\r\n"
                           "Content-Length: " + numberTo<std::string>(strLength(htmlMsg)) + "\r\n"
                           "\r\n" + htmlMsg;
        }

        for (size_t bytesToSend = httpResponse.size(); bytesToSend > 0;)
            bytesToSend -= tryWriteSocket(clientSocket, &*(httpResponse.end() - bytesToSend), bytesToSend); //throw SysError

        shutdownSocketSend(clientSocket); //throw SysError
        //---------------------------------------------------------------

        if (const SysError* e = std::get_if<SysError>(&authResult))
            throw *e;
        if (const GdriveAccessInfo* res = std::get_if<GdriveAccessInfo>(&authResult))
            return *res;
    }
}


GdriveAccessToken gdriveRefreshAccess(const std::string& refreshToken, int timeoutSec) //throw SysError
{
    //https://developers.google.com/identity/protocols/OAuth2InstalledApp#offline
    const std::string postBuf = xWwwFormUrlEncode(
    {
        {"refresh_token", refreshToken},
        {"client_id",     getGdriveClientId()},
        {"client_secret", getGdriveClientSecret()},
        {"grant_type",    "refresh_token"},
    });
    std::string response;
    googleHttpsRequest(Zstr("oauth2.googleapis.com"), "/token", {} /*extraHeaders*/, {{CURLOPT_POSTFIELDS, postBuf.c_str()}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, timeoutSec); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> accessToken = getPrimitiveFromJsonObject(jresponse, "access_token");
    const std::optional<std::string> expiresIn   = getPrimitiveFromJsonObject(jresponse, "expires_in"); //e.g. 3600 seconds
    if (!accessToken || !expiresIn)
        throw SysError(formatGdriveErrorRaw(response));

    return {*accessToken, std::time(nullptr) + stringTo<time_t>(*expiresIn)};
}


void gdriveRevokeAccess(const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/identity/protocols/OAuth2InstalledApp#tokenrevoke
    std::string response;
    const HttpSession::Result httpResult = googleHttpsRequest(Zstr("oauth2.googleapis.com"), "/revoke?token=" + access.token,
    {"Content-Type: application/x-www-form-urlencoded"}, {{ CURLOPT_POSTFIELDS, ""}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access.timeoutSec); //throw SysError

    if (httpResult.statusCode != 200)
        throw SysError(formatGdriveErrorRaw(response));
}


int64_t gdriveGetMyDriveFreeSpace(const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/about
    std::string response;
    gdriveHttpsRequest("/drive/v3/about?fields=storageQuota", {} /*extraHeaders*/, {} /*extraOptions*/,
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    if (const JsonValue* storageQuota = getChildFromJsonObject(jresponse, "storageQuota"))
    {
        const std::optional<std::string> usage = getPrimitiveFromJsonObject(*storageQuota, "usage");
        const std::optional<std::string> limit = getPrimitiveFromJsonObject(*storageQuota, "limit");
        if (usage)
        {
            if (!limit) //"will not be present if the user has unlimited storage."
                return std::numeric_limits<int64_t>::max();

            const auto bytesUsed  = stringTo<int64_t>(*usage);
            const auto bytesLimit = stringTo<int64_t>(*limit);

            if (0 <= bytesUsed && bytesUsed <= bytesLimit)
                return bytesLimit - bytesUsed;
        }
    }
    throw SysError(formatGdriveErrorRaw(response));
}


//instead of the "root" alias Google uses an actual ID in file metadata
std::string /*itemId*/ getMyDriveId(const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/get
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"fields", "id"},
    });
    std::string response;
    gdriveHttpsRequest("/drive/v3/files/root?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> itemId = getPrimitiveFromJsonObject(jresponse, "id");
    if (!itemId)
        throw SysError(formatGdriveErrorRaw(response));

    return *itemId;
}


struct DriveDetails
{
    std::string driveId;
    Zstring driveName;
};
std::vector<DriveDetails> getSharedDrives(const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/drives/list
    std::vector<DriveDetails> sharedDrives;
    {
        std::optional<std::string> nextPageToken;
        do
        {
            std::string queryParams = xWwwFormUrlEncode(
            {
                {"pageSize", "100"}, //"[1, 100] Default: 10"
                {"fields", "nextPageToken,drives(id,name)"},
            });
            if (nextPageToken)
                queryParams += '&' + xWwwFormUrlEncode({{"pageToken", *nextPageToken}});

            std::string response;
            gdriveHttpsRequest("/drive/v3/drives?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
            [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
            nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

            JsonValue jresponse;
            try { jresponse = parseJson(response); }
            catch (JsonParsingError&) {}

            /**/             nextPageToken = getPrimitiveFromJsonObject(jresponse, "nextPageToken");
            const JsonValue* drives        = getChildFromJsonObject    (jresponse, "drives");
            if (!drives || drives->type != JsonValue::Type::array)
                throw SysError(formatGdriveErrorRaw(response));

            for (const JsonValue& driveVal : drives->arrayVal)
            {
                std::optional<std::string> driveId   = getPrimitiveFromJsonObject(driveVal, "id");
                std::optional<std::string> driveName = getPrimitiveFromJsonObject(driveVal, "name");
                if (!driveId || !driveName || driveName->empty())
                    throw SysError(formatGdriveErrorRaw(serializeJson(driveVal)));

                sharedDrives.push_back({std::move(*driveId), utfTo<Zstring>(*driveName)});
            }
        }
        while (nextPageToken);
    }
    return sharedDrives;
}


struct StarredFolderDetails
{
    std::string folderId;
    Zstring folderName;
    std::string sharedDriveId; //empty if on "My Drive"
};
std::vector<StarredFolderDetails> getStarredFolders(const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/list
    std::vector<StarredFolderDetails> starredFolders;
    {
        std::optional<std::string> nextPageToken;
        do
        {
            std::string queryParams = xWwwFormUrlEncode(
            {
                {"corpora", "allDrives"}, //"The 'user' corpus includes all files in "My Drive" and "Shared with me" https://developers.google.com/drive/api/v3/reference/files/list
                {"includeItemsFromAllDrives", "true"},
                {"pageSize", "1000"}, //"[1, 1000] Default: 100"
                {"q", std::string("starred and mimeType = '") + gdriveFolderMimeType + "' and not trashed"},
                {"spaces", "drive"},
                {"supportsAllDrives", "true"},
                {"fields", "nextPageToken,incompleteSearch,files(id,name,driveId)"}, //https://developers.google.com/drive/api/v3/reference/files
            });
            if (nextPageToken)
                queryParams += '&' + xWwwFormUrlEncode({{"pageToken", *nextPageToken}});

            std::string response;
            gdriveHttpsRequest("/drive/v3/files?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
            [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
            nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

            JsonValue jresponse;
            try { jresponse = parseJson(response); }
            catch (JsonParsingError&) {}

            /**/                             nextPageToken    = getPrimitiveFromJsonObject(jresponse, "nextPageToken");
            const std::optional<std::string> incompleteSearch = getPrimitiveFromJsonObject(jresponse, "incompleteSearch");
            const JsonValue*                 files            = getChildFromJsonObject    (jresponse, "files");
            if (!incompleteSearch || *incompleteSearch != "false" || !files || files->type != JsonValue::Type::array)
                throw SysError(formatGdriveErrorRaw(response));

            for (const JsonValue& childVal : files->arrayVal)
            {
                assert(childVal.type == JsonValue::Type::object);
                const std::optional<std::string> itemId   = getPrimitiveFromJsonObject(childVal, "id");
                const std::optional<std::string> itemName = getPrimitiveFromJsonObject(childVal, "name");
                const std::optional<std::string> driveId  = getPrimitiveFromJsonObject(childVal, "driveId");

                if (!itemId || itemId->empty() || !itemName || itemName->empty())
                    throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                starredFolders.push_back({*itemId,
                                          utfTo<Zstring>(*itemName),
                                          driveId ? *driveId : ""});
            }
        }
        while (nextPageToken);
    }
    return starredFolders;
}


enum class GdriveItemType : unsigned char
{
    file,
    folder,
    shortcut,
};
enum class FileOwner : unsigned char
{
    none, //"ownedByMe" not populated for items in Shared Drives.
    me,
    other,
};
struct GdriveItemDetails
{
    Zstring itemName;
    uint64_t fileSize = 0;
    time_t modTime  = 0;
    //--- minimize padding ---
    GdriveItemType type = GdriveItemType::file;
    FileOwner owner = FileOwner::none;
    //------------------------
    std::string targetId; //for GdriveItemType::shortcut: https://developers.google.com/drive/api/v3/shortcuts
    std::vector<std::string> parentIds;

    bool operator==(const GdriveItemDetails&) const = default;
};


GdriveItemDetails extractItemDetails(const JsonValue& jvalue) //throw SysError
{
    assert(jvalue.type == JsonValue::Type::object);

    /**/  std::optional<std::string> itemName     = getPrimitiveFromJsonObject(jvalue, "name");
    const std::optional<std::string> mimeType     = getPrimitiveFromJsonObject(jvalue, "mimeType");
    const std::optional<std::string> ownedByMe    = getPrimitiveFromJsonObject(jvalue, "ownedByMe");
    const std::optional<std::string> size         = getPrimitiveFromJsonObject(jvalue, "size");
    const std::optional<std::string> modifiedTime = getPrimitiveFromJsonObject(jvalue, "modifiedTime");
    const JsonValue*                 parents      = getChildFromJsonObject    (jvalue, "parents");
    const JsonValue*                 shortcut     = getChildFromJsonObject    (jvalue, "shortcutDetails");

    if (!itemName || itemName->empty() || !mimeType || !modifiedTime)
        throw SysError(formatGdriveErrorRaw(serializeJson(jvalue)));

    const GdriveItemType type = *mimeType == gdriveFolderMimeType   ? GdriveItemType::folder :
                                *mimeType == gdriveShortcutMimeType ? GdriveItemType::shortcut :
                                GdriveItemType::file;

    const FileOwner owner = ownedByMe ? (*ownedByMe == "true" ? FileOwner::me : FileOwner::other) : FileOwner::none; //"Not populated for items in Shared Drives"
    const uint64_t fileSize = size ? stringTo<uint64_t>(*size) : 0; //not available for folders and shortcuts

    //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z"
    const TimeComp tc = parseTime("%Y-%m-%dT%H:%M:%S", beforeLast(*modifiedTime, '.', IfNotFoundReturn::all));
    if (tc == TimeComp() || !endsWith(*modifiedTime, 'Z')) //'Z' means "UTC" => it seems Google doesn't use the time-zone offset postfix
        throw SysError(L"Modification time is invalid. (" + utfTo<std::wstring>(*modifiedTime) + L')');

    const auto [modTime, timeValid] = utcToTimeT(tc);
    if (!timeValid)
        throw SysError(L"Modification time is invalid. (" + utfTo<std::wstring>(*modifiedTime) + L')');

    std::vector<std::string> parentIds;
    if (parents) //item without "parents" array is possible! e.g. 1. shared item located in "Shared with me", referenced via a Shortcut 2. root folder under "Computers"
        for (const JsonValue& parentVal : parents->arrayVal)
        {
            if (parentVal.type != JsonValue::Type::string)
                throw SysError(formatGdriveErrorRaw(serializeJson(jvalue)));
            parentIds.emplace_back(parentVal.primVal);
        }

    if (!!shortcut != (type == GdriveItemType::shortcut))
        throw SysError(formatGdriveErrorRaw(serializeJson(jvalue)));

    std::string targetId;
    if (shortcut)
    {
        std::optional<std::string> targetItemId = getPrimitiveFromJsonObject(*shortcut, "targetId");
        if (!targetItemId || targetItemId->empty())
            throw SysError(formatGdriveErrorRaw(serializeJson(jvalue)));

        targetId = std::move(*targetItemId);
        //evaluate "targetMimeType" ? don't bother: "The MIME type of a shortcut can become stale"!
    }

    return {utfTo<Zstring>(*itemName), fileSize, modTime, type, owner, std::move(targetId), std::move(parentIds)};
}


GdriveItemDetails getItemDetails(const std::string& itemId, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/get
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"fields", "trashed,name,mimeType,ownedByMe,size,modifiedTime,parents,shortcutDetails(targetId)"},
        {"supportsAllDrives", "true"},
    });
    std::string response;
    gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError
    try
    {
        const JsonValue jvalue = parseJson(response); //throw JsonParsingError

        //careful: do NOT return details about trashed items! they don't exist as far as FFS is concerned!!!
        const std::optional<std::string> trashed = getPrimitiveFromJsonObject(jvalue, "trashed");
        if (!trashed)
            throw SysError(formatGdriveErrorRaw(response));
        else if (*trashed == "true")
            throw SysError(L"Item has been trashed.");

        return extractItemDetails(jvalue); //throw SysError
    }
    catch (JsonParsingError&) { throw SysError(formatGdriveErrorRaw(response)); }
}


struct GdriveItem
{
    std::string itemId;
    GdriveItemDetails details;
};
std::vector<GdriveItem> readFolderContent(const std::string& folderId, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/list
    std::vector<GdriveItem> childItems;
    {
        std::optional<std::string> nextPageToken;
        do
        {
            std::string queryParams = xWwwFormUrlEncode(
            {
                {"corpora", "allDrives"}, //"The 'user' corpus includes all files in "My Drive" and "Shared with me" https://developers.google.com/drive/api/v3/reference/files/list
                {"includeItemsFromAllDrives", "true"},
                {"pageSize", "1000"}, //"[1, 1000] Default: 100"
                {"q", "'" + folderId + "' in parents and not trashed"},
                {"spaces", "drive"},
                {"supportsAllDrives", "true"},
                {"fields", "nextPageToken,incompleteSearch,files(id,name,mimeType,ownedByMe,size,modifiedTime,parents,shortcutDetails(targetId))"}, //https://developers.google.com/drive/api/v3/reference/files
            });
            if (nextPageToken)
                queryParams += '&' + xWwwFormUrlEncode({{"pageToken", *nextPageToken}});

            std::string response;
            gdriveHttpsRequest("/drive/v3/files?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
            [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
            nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

            JsonValue jresponse;
            try { jresponse = parseJson(response); }
            catch (JsonParsingError&) {}

            /**/                             nextPageToken    = getPrimitiveFromJsonObject(jresponse, "nextPageToken");
            const std::optional<std::string> incompleteSearch = getPrimitiveFromJsonObject(jresponse, "incompleteSearch");
            const JsonValue*                 files            = getChildFromJsonObject    (jresponse, "files");
            if (!incompleteSearch || *incompleteSearch != "false" || !files || files->type != JsonValue::Type::array)
                throw SysError(formatGdriveErrorRaw(response));

            for (const JsonValue& childVal : files->arrayVal)
            {
                std::optional<std::string> itemId = getPrimitiveFromJsonObject(childVal, "id");
                if (!itemId || itemId->empty())
                    throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                GdriveItemDetails itemDetails(extractItemDetails(childVal)); //throw SysError
                assert(std::find(itemDetails.parentIds.begin(), itemDetails.parentIds.end(), folderId) != itemDetails.parentIds.end());

                childItems.push_back({std::move(*itemId), std::move(itemDetails)});
            }
        }
        while (nextPageToken);
    }
    return childItems;
}


struct FileChange
{
    std::string itemId;
    std::optional<GdriveItemDetails> details; //empty if item was deleted/trashed
};
struct DriveChange
{
    std::string driveId;
    Zstring driveName; //empty if shared drive was deleted
};
struct ChangesDelta
{
    std::string newStartPageToken;
    std::vector<FileChange> fileChanges;
    std::vector<DriveChange> driveChanges;
};
ChangesDelta getChangesDelta(const std::string& sharedDriveId /*empty for "My Drive"*/, const std::string& startPageToken, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/changes/list
    ChangesDelta delta;
    std::optional<std::string> nextPageToken = startPageToken;
    for (;;)
    {
        std::string queryParams = xWwwFormUrlEncode(
        {
            {"pageToken", *nextPageToken},
            {"fields", "kind,nextPageToken,newStartPageToken,changes(kind,changeType,removed,fileId,file(trashed,name,mimeType,ownedByMe,size,modifiedTime,parents,shortcutDetails(targetId)),driveId,drive(name))"},
            {"includeItemsFromAllDrives", "true"}, //semantics are a mess https://developers.google.com/drive/api/v3/enable-shareddrives https://freefilesync.org/forum/viewtopic.php?t=7827&start=30#p29712
            //in short: if driveId is set: required, but blatant lie; only drive-specific file changes returned
            //          if no driveId set: optional, but blatant lie; only changes to drive objects are returned, but not contained files (with a few exceptions)
            {"pageSize", "1000"}, //"[1, 1000] Default: 100"
            {"spaces", "drive"},
            {"supportsAllDrives", "true"},
            //do NOT "restrictToMyDrive": we're also interested in "Shared with me" items, which might be referenced by a shortcut in "My Drive"
        });
        if (!sharedDriveId.empty())
            queryParams += '&' + xWwwFormUrlEncode({{"driveId", sharedDriveId}}); //only allowed for shared drives!

        std::string response;
        gdriveHttpsRequest("/drive/v3/changes?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
        [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
        nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

        JsonValue jresponse;
        try { jresponse = parseJson(response); }
        catch (JsonParsingError&) {}

        /**/                             nextPageToken     = getPrimitiveFromJsonObject(jresponse, "nextPageToken");
        const std::optional<std::string> newStartPageToken = getPrimitiveFromJsonObject(jresponse, "newStartPageToken");
        const std::optional<std::string> listKind          = getPrimitiveFromJsonObject(jresponse, "kind");
        const JsonValue*                 changes           = getChildFromJsonObject    (jresponse, "changes");

        if (!!nextPageToken == !!newStartPageToken || //there can be only one
            !listKind || *listKind != "drive#changeList" ||
            !changes || changes->type != JsonValue::Type::array)
            throw SysError(formatGdriveErrorRaw(response));

        for (const JsonValue& childVal : changes->arrayVal)
        {
            const std::optional<std::string> kind       = getPrimitiveFromJsonObject(childVal, "kind");
            const std::optional<std::string> changeType = getPrimitiveFromJsonObject(childVal, "changeType");
            const std::optional<std::string> removed    = getPrimitiveFromJsonObject(childVal, "removed");
            if (!kind || *kind != "drive#change" || !changeType || !removed)
                throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

            if (*changeType == "file")
            {
                std::optional<std::string> fileId = getPrimitiveFromJsonObject(childVal, "fileId");
                if (!fileId || fileId->empty())
                    throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                FileChange change;
                change.itemId = std::move(*fileId);
                if (*removed != "true")
                {
                    const JsonValue* file = getChildFromJsonObject(childVal, "file");
                    if (!file)
                        throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                    const std::optional<std::string> trashed = getPrimitiveFromJsonObject(*file, "trashed");
                    if (!trashed)
                        throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                    if (*trashed != "true")
                        change.details = extractItemDetails(*file); //throw SysError
                }
                delta.fileChanges.push_back(std::move(change));
            }
            else if (*changeType == "drive")
            {
                std::optional<std::string> driveId = getPrimitiveFromJsonObject(childVal, "driveId");
                if (!driveId || driveId->empty())
                    throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                DriveChange change;
                change.driveId = std::move(*driveId);
                if (*removed != "true")
                {
                    const JsonValue* drive = getChildFromJsonObject(childVal, "drive");
                    if (!drive)
                        throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                    const std::optional<std::string> name = getPrimitiveFromJsonObject(*drive, "name");
                    if (!name || name->empty())
                        throw SysError(formatGdriveErrorRaw(serializeJson(childVal)));

                    change.driveName = utfTo<Zstring>(*name);
                }
                delta.driveChanges.push_back(std::move(change));
            }
            else assert(false); //no other types (yet!)
        }

        if (!nextPageToken)
        {
            delta.newStartPageToken = *newStartPageToken;
            return delta;
        }
    }
}


std::string /*startPageToken*/ getChangesCurrentToken(const std::string& sharedDriveId /*empty for "My Drive"*/, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/changes/getStartPageToken
    std::string queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
    });
    if (!sharedDriveId.empty())
        queryParams += '&' + xWwwFormUrlEncode({{"driveId", sharedDriveId}}); //only allowed for shared drives!

    std::string response;
    gdriveHttpsRequest("/drive/v3/changes/startPageToken?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> startPageToken = getPrimitiveFromJsonObject(jresponse, "startPageToken");
    if (!startPageToken)
        throw SysError(formatGdriveErrorRaw(response));

    return *startPageToken;
}


//- if item is a folder: deletes recursively!!!
//- even deletes a hardlink with multiple parents => use gdriveUnlinkParent() first
void gdriveDeleteItem(const std::string& itemId, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/delete
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
    });
    std::string response;
    const HttpSession::Result httpResult = gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams,
    {} /*extraHeaders*/, {{CURLOPT_CUSTOMREQUEST, "DELETE"}}, [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    if (response.empty() && httpResult.statusCode == 204)
        return; //"If successful, this method returns an empty response body"

    throw SysError(formatGdriveErrorRaw(response));
}


//item is NOT deleted when last parent is removed: it is just not accessible via the "My Drive" hierarchy but still adds to quota! => use for hard links only!
void gdriveUnlinkParent(const std::string& itemId, const std::string& parentId, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/update
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"removeParents", parentId},
        {"supportsAllDrives", "true"},
        {"fields", "id,parents"}, //for test if operation was successful
    });
    std::string response;
    const HttpSession::Result httpResult = gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams,
    {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, { CURLOPT_POSTFIELDS, "{}"}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    if (response.empty() && httpResult.statusCode == 204)
        return; //removing last parent of item not owned by us returns "204 No Content" (instead of 200 + file body)

    JsonValue jresponse;
    try { jresponse = parseJson(response); /*throw JsonParsingError*/ }
    catch (const JsonParsingError&) {}

    const std::optional<std::string> id      = getPrimitiveFromJsonObject(jresponse, "id"); //id is returned on "success", unlike "parents", see below...
    const JsonValue*                 parents = getChildFromJsonObject(jresponse, "parents");
    if (!id || *id != itemId)
        throw SysError(formatGdriveErrorRaw(response));

    if (parents) //when last parent is removed, Google does NOT return the parents array (not even an empty one!)
        if (parents->type != JsonValue::Type::array ||
            std::any_of(parents->arrayVal.begin(), parents->arrayVal.end(),
        [&](const JsonValue& jval) { return jval.type == JsonValue::Type::string && jval.primVal == parentId; }))
    throw SysError(L"gdriveUnlinkParent: Google Drive internal failure"); //user should never see this...
}


//- if item is a folder: trashes recursively!!!
//- a hardlink with multiple parents will NOT be accessible anymore via any of its path aliases!
void gdriveMoveToTrash(const std::string& itemId, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/update
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"fields", "trashed"},
    });
    const std::string postBuf = R"({ "trashed": true })";

    std::string response;
    gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams,
    {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, {CURLOPT_POSTFIELDS, postBuf.c_str()}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); /*throw JsonParsingError*/ }
    catch (const JsonParsingError&) {}

    const std::optional<std::string> trashed = getPrimitiveFromJsonObject(jresponse, "trashed");
    if (!trashed || *trashed != "true")
        throw SysError(formatGdriveErrorRaw(response));
}


//folder name already existing? will (happily) create duplicate => caller must check!
std::string /*folderId*/ gdriveCreateFolderPlain(const Zstring& folderName, const std::string& parentId, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/folder#creating_a_folder
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"fields", "id"},
    });
    JsonValue postParams(JsonValue::Type::object);
    postParams.objectVal.set("mimeType", gdriveFolderMimeType);
    postParams.objectVal.set("name", utfTo<std::string>(folderName));
    postParams.objectVal.set("parents", std::vector<JsonValue> {JsonValue(parentId)});
    const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/);

    std::string response;
    gdriveHttpsRequest("/drive/v3/files?" + queryParams,
    {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_POSTFIELDS, postBuf.c_str()}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> itemId = getPrimitiveFromJsonObject(jresponse, "id");
    if (!itemId)
        throw SysError(formatGdriveErrorRaw(response));
    return *itemId;
}


//shortcut name already existing? will (happily) create duplicate => caller must check!
std::string /*shortcutId*/ gdriveCreateShortcutPlain(const Zstring& shortcutName, const std::string& parentId,  const std::string& targetId, const GdriveAccess& access) //throw SysError
{
    /* https://developers.google.com/drive/api/v3/shortcuts
       - targetMimeType is determined automatically (ignored if passed)
       - creating shortcuts to shortcuts fails with "Internal Error"              */
    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"fields", "id"},
    });
    JsonValue shortcutDetails(JsonValue::Type::object);
    shortcutDetails.objectVal.set("targetId", targetId);

    JsonValue postParams(JsonValue::Type::object);
    postParams.objectVal.set("mimeType", gdriveShortcutMimeType);
    postParams.objectVal.set("name", utfTo<std::string>(shortcutName));
    postParams.objectVal.set("parents", std::vector<JsonValue> {JsonValue(parentId)});
    postParams.objectVal.set("shortcutDetails", std::move(shortcutDetails));
    const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/);

    std::string response;
    gdriveHttpsRequest("/drive/v3/files?" + queryParams, {"Content-Type: application/json; charset=UTF-8"},
    {{CURLOPT_POSTFIELDS, postBuf.c_str()}}, [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> itemId = getPrimitiveFromJsonObject(jresponse, "id");
    if (!itemId)
        throw SysError(formatGdriveErrorRaw(response));
    return *itemId;
}


//target name already existing? will (happily) create duplicate items => caller must check!
//can copy files + shortcuts (but fails for folders) + Google-specific file types (.gdoc, .gsheet, .gslides)
std::string /*fileId*/ gdriveCopyFile(const std::string& fileId, const std::string& parentIdTo, const Zstring& newName, time_t newModTime, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/copy
    const std::string queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"fields", "id"},
    });

    //more Google Drive peculiarities: changing the file name changes modifiedTime!!! => workaround:

    //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z"
    const std::string modTimeRfc = utfTo<std::string>(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime(newModTime))); //returns empty string on error
    if (modTimeRfc.empty())
        throw SysError(L"Invalid modification time (time_t: " + numberTo<std::wstring>(newModTime) + L')');

    JsonValue postParams(JsonValue::Type::object);
    postParams.objectVal.set("name", utfTo<std::string>(newName));
    postParams.objectVal.set("parents", std::vector<JsonValue> {JsonValue(parentIdTo)});
    postParams.objectVal.set("modifiedTime", modTimeRfc);
    const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/);

    std::string response;
    gdriveHttpsRequest("/drive/v3/files/" + fileId + "/copy?" + queryParams,
    {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_POSTFIELDS, postBuf.c_str()}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); /*throw JsonParsingError*/ }
    catch (const JsonParsingError&) {}

    const std::optional<std::string> itemId = getPrimitiveFromJsonObject(jresponse, "id");
    if (!itemId)
        throw SysError(formatGdriveErrorRaw(response));

    return *itemId;

}


//target name already existing? will (happily) create duplicate items => caller must check!
void gdriveMoveAndRenameItem(const std::string& itemId, const std::string& parentIdFrom, const std::string& parentIdTo,
                             const Zstring& newName, time_t newModTime, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/folder#moving_files_between_folders
    std::string queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"fields", "name,parents"}, //for test if operation was successful
    });

    if (parentIdFrom != parentIdTo)
        queryParams += '&' + xWwwFormUrlEncode(
    {
        {"removeParents", parentIdFrom},
        {"addParents",    parentIdTo},
    });

    //more Google Drive peculiarities: changing the file name changes modifiedTime!!! => workaround:

    //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z"
    const std::string modTimeRfc = utfTo<std::string>(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime(newModTime))); //returns empty string on error
    if (modTimeRfc.empty())
        throw SysError(L"Invalid modification time (time_t: " + numberTo<std::wstring>(newModTime) + L')');

    JsonValue postParams(JsonValue::Type::object);
    postParams.objectVal.set("name", utfTo<std::string>(newName));
    postParams.objectVal.set("modifiedTime", modTimeRfc);
    const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/);

    std::string response;
    gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams,
    {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, {CURLOPT_POSTFIELDS, postBuf.c_str()}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); /*throw JsonParsingError*/ }
    catch (const JsonParsingError&) {}

    const std::optional<std::string> name    = getPrimitiveFromJsonObject(jresponse, "name");
    const JsonValue*                 parents = getChildFromJsonObject(jresponse, "parents");
    if (!name || *name != utfTo<std::string>(newName) ||
        !parents || parents->type != JsonValue::Type::array)
        throw SysError(formatGdriveErrorRaw(response));

    if (!std::any_of(parents->arrayVal.begin(), parents->arrayVal.end(),
    [&](const JsonValue& jval) { return jval.type == JsonValue::Type::string && jval.primVal == parentIdTo; }))
    throw SysError(formatSystemError("gdriveMoveAndRenameItem", L"", L"Google Drive internal failure.")); //user should never see this...
}


#if 0
void setModTime(const std::string& itemId, time_t modTime, const GdriveAccess& access) //throw SysError
{
    //https://developers.google.com/drive/api/v3/reference/files/update
    //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z"
    const std::string& modTimeRfc = formatTime<std::string>("%Y-%m-%dT%H:%M:%S.000Z", getUtcTime2(modTime)); //returns empty string on error
    if (modTimeRfc.empty())
        throw SysError(L"Invalid modification time (time_t: " + numberTo<std::wstring>(modTime) + L')');

    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"fields", "modifiedTime"},
    });
    const std::string postBuf = R"({ "modifiedTime": ")" + modTimeRfc + "\" }";

    std::string response;
    gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams,
    {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, {CURLOPT_POSTFIELDS, postBuf.c_str()}},
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError

    JsonValue jresponse;
    try { jresponse = parseJson(response); /*throw JsonParsingError*/ }
    catch (const JsonParsingError&) {}

    const std::optional<std::string> modifiedTime = getPrimitiveFromJsonObject(jresponse, "modifiedTime");
    if (!modifiedTime || *modifiedTime != modTimeRfc)
        throw SysError(formatGdriveErrorRaw(response));
}
#endif


DEFINE_NEW_SYS_ERROR(SysErrorAbusiveFile)
void gdriveDownloadFileImpl(const std::string& fileId, const std::function<void(const void* buffer, size_t bytesToWrite)>& writeBlock /*throw X*/, //throw SysError, SysErrorAbusiveFile, X
                            bool acknowledgeAbuse, const GdriveAccess& access)
{
    /*  https://developers.google.com/drive/api/v3/manage-downloads
        doesn't work for Google-specific file types, but Google Backup & Sync still "downloads" them:
            - in some JSON-like file format:
                {"url": "https://docs.google.com/open?id=FILE_ID", "doc_id": "FILE_ID", "email": "ACCOUNT_EMAIL"}

            - adds artificial file extensions: .gdoc, .gsheet, .gslides, ...

            - 2022-10-10: In "Google Drive for Desktop" the file content now looks like:
                {"":"WARNING! DO NOT EDIT THIS FILE! ANY CHANGES MADE WILL BE LOST!","doc_id":"FILE_ID","resource_key":"","email":"ACCOUNT_EMAIL"}     */

    std::string queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"alt", "media"},
    });
    if (acknowledgeAbuse) //apply on demand only! https://freefilesync.org/forum/viewtopic.php?t=7520")
        queryParams += '&' + xWwwFormUrlEncode({{"acknowledgeAbuse", "true"}});

    std::string headBytes;
    bool headBytesWritten = false;

    const HttpSession::Result httpResult = gdriveHttpsRequest("/drive/v3/files/" + fileId + '?' + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/,
                                                              [&](std::span<const char> buf)
                                                              /*  libcurl feeds us a shitload of tiny kB-sized zlib-decompressed pieces of data!
                                                                  libcurl's zlib buffer is sized at ridiculous 16 kB!
                                                                  => if this ever becomes a perf issue: roll our own zlib decompression!   */
    {
        if (headBytes.size() < 16 * 1024) //don't access writeBlock() yet in case of error! (=> support acknowledgeAbuse retry handling)
            headBytes.append(buf.data(), buf.size());
        else
        {
            if (!headBytesWritten)
            {
                headBytesWritten = true;
                writeBlock(headBytes.c_str(), headBytes.size()); //throw X
            }

            writeBlock(buf.data(), buf.size()); //throw X
        }
    }, nullptr /*tryReadRequest*/, nullptr /*receiveHeader*/, access); //throw SysError, X

    if (httpResult.statusCode / 100 != 2)
    {
        /* https://freefilesync.org/forum/viewtopic.php?t=7463 => HTTP status code 403 + body:
            { "error": { "errors": [{ "domain": "global",
                                      "reason": "cannotDownloadAbusiveFile",
                                      "message": "This file has been identified as malware or spam and cannot be downloaded." }],
                         "code": 403,
                         "message": "This file has been identified as malware or spam and cannot be downloaded." }}       */
        if (!headBytesWritten && httpResult.statusCode == 403 && contains(headBytes, "\"cannotDownloadAbusiveFile\""))
            throw SysErrorAbusiveFile(formatGdriveErrorRaw(headBytes));

        throw SysError(formatGdriveErrorRaw(headBytes));
    }

    if (!headBytesWritten && !headBytes.empty())
        writeBlock(headBytes.c_str(), headBytes.size()); //throw X
}


void gdriveDownloadFile(const std::string& fileId, const std::function<void(const void* buffer, size_t bytesToWrite)>& writeBlock /*throw X*/, //throw SysError, X
                        const GdriveAccess& access)
{
    try
    {
        gdriveDownloadFileImpl(fileId, writeBlock /*throw X*/, false /*acknowledgeAbuse*/, access); //throw SysError, SysErrorAbusiveFile, X
    }
    catch (SysErrorAbusiveFile&)
    {
        gdriveDownloadFileImpl(fileId, writeBlock /*throw X*/, true /*acknowledgeAbuse*/, access); //throw SysError, (SysErrorAbusiveFile), X
    }
}


#if 0
//file name already existing? => duplicate file created!
//note: Google Drive upload is already transactional!
//upload "small files" (5 MB or less; enforced by Google?) in a single round-trip
std::string /*itemId*/ gdriveUploadSmallFile(const Zstring& fileName, const std::string& parentId, uint64_t streamSize, std::optional<time_t> modTime, //throw SysError, X
                                             const std::function<size_t(void* buffer, size_t bytesToRead)>& readBlock /*throw X; return "bytesToRead" bytes unless end of stream*/,
                                             const GdriveAccess& access)
{
    //https://developers.google.com/drive/api/v3/folder#inserting_a_file_in_a_folder
    //https://developers.google.com/drive/api/v3/manage-uploads#http_1

    JsonValue postParams(JsonValue::Type::object);
    postParams.objectVal.emplace("name", utfTo<std::string>(fileName));
    postParams.objectVal.emplace("parents", std::vector<JsonValue> {JsonValue(parentId)});
    if (modTime) //convert to RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z"
    {
        const std::string& modTimeRfc = utfTo<std::string>(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime2(*modTime))); //returns empty string on error
        if (modTimeRfc.empty())
            throw SysError(L"Invalid modification time (time_t: " + numberTo<std::wstring>(*modTime) + L')');

        postParams.objectVal.emplace("modifiedTime", modTimeRfc);
    }
    const std::string& metaDataBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/);

    //allowed chars for border: DIGIT ALPHA ' ( ) + _ , - . / : = ?
    const std::string boundaryString = stringEncodeBase64(generateGUID() + generateGUID());

    const std::string postBufHead = "--" + boundaryString +                         "\r\n"
                                    "Content-Type: application/json; charset=UTF-8" "\r\n"
                                    /**/                                            "\r\n" +
                                    metaDataBuf +                                   "\r\n"
                                                    "--" + boundaryString +                         "\r\n"
                                                    "Content-Type: application/octet-stream"        "\r\n"
                                    /**/                                            "\r\n";

    const std::string postBufTail = "\r\n--" + boundaryString + "--";

    auto readMultipartBlock = [&, headPos = size_t(0), eof = false, tailPos = size_t(0)](void* buffer, size_t bytesToRead) mutable -> size_t
    {
        const auto bufStart = buffer;

        if (headPos < postBufHead.size())
        {
            const size_t junkSize = std::min<ptrdiff_t>(postBufHead.size() - headPos, bytesToRead);
            std::memcpy(buffer, postBufHead.c_str() + headPos, junkSize);
            headPos += junkSize;
            buffer = static_cast<std::byte*>(buffer) + junkSize;
            bytesToRead -= junkSize;
        }
        if (bytesToRead > 0)
        {
            if (!eof) //don't assume readBlock() will return streamSize bytes as promised => exhaust and let Google Drive fail if there is a mismatch in Content-Length!
            {
                const size_t bytesRead = readBlock(buffer, bytesToRead); //throw X; return "bytesToRead" bytes unless end of stream
                buffer = static_cast<std::byte*>(buffer) + bytesRead;
                bytesToRead -= bytesRead;

                if (bytesToRead > 0)
                    eof = true;
            }
            if (bytesToRead > 0)
                if (tailPos < postBufTail.size())
                {
                    const size_t junkSize = std::min<ptrdiff_t>(postBufTail.size() - tailPos, bytesToRead);
                    std::memcpy(buffer, postBufTail.c_str() + tailPos, junkSize);
                    tailPos += junkSize;
                    buffer = static_cast<std::byte*>(buffer) + junkSize;
                    bytesToRead -= junkSize;
                }
        }
        return static_cast<std::byte*>(buffer) -
               static_cast<std::byte*>(bufStart);
    };

TODO:
    gzip-compress HTTP request body!

    const std::string& queryParams = xWwwFormUrlEncode(
    {
        {"supportsAllDrives", "true"},
        {"uploadType", "multipart"},
    });
    std::string response;
    const HttpSession::Result httpResult = gdriveHttpsRequest("/upload/drive/v3/files?" + queryParams,
    {
        "Content-Type: multipart/related; boundary=" + boundaryString,
        "Content-Length: " + numberTo<std::string>(postBufHead.size() + streamSize + postBufTail.size())
    },
    {{CURLOPT_POST, 1}}, //otherwise HttpSession::perform() will PUT
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
    readMultipartBlock, nullptr /*receiveHeader*/, access); //throw SysError, X

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> itemId = getPrimitiveFromJsonObject(jresponse, "id");
    if (!itemId)
        throw SysError(formatGdriveErrorRaw(response));

    return *itemId;
}
#endif


//file name already existing? => duplicate file created!
//note: Google Drive upload is already transactional!
std::string /*itemId*/ gdriveUploadFile(const Zstring& fileName, const std::string& parentId, std::optional<time_t> modTime, //throw SysError, X
                                        const std::function<size_t(void* buffer, size_t bytesToRead)>& tryReadBlock /*throw X*/, //returning 0 signals EOF: Posix read() semantics
                                        const GdriveAccess& access)
{
    //https://developers.google.com/drive/api/v3/folder#inserting_a_file_in_a_folder
    //https://developers.google.com/drive/api/v3/manage-uploads#resumable

    //step 1: initiate resumable upload session
    std::string uploadUrlRelative;
    {
        const std::string& queryParams = xWwwFormUrlEncode(
        {
            {"supportsAllDrives", "true"},
            {"uploadType", "resumable"},
        });
        JsonValue postParams(JsonValue::Type::object);
        postParams.objectVal.set("name", utfTo<std::string>(fileName));
        postParams.objectVal.set("parents", std::vector<JsonValue> {JsonValue(parentId)});
        if (modTime) //convert to RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z"
        {
            const std::string& modTimeRfc = utfTo<std::string>(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime(*modTime))); //returns empty string on error
            if (modTimeRfc.empty())
                throw SysError(L"Invalid modification time (time_t: " + numberTo<std::wstring>(*modTime) + L')');

            postParams.objectVal.set("modifiedTime", modTimeRfc);
        }
        const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/);
        //---------------------------------------------------

        std::string uploadUrl;

        auto onHeaderData = [&](const std::string_view& header)
        {
            //"The callback will be called once for each header and only complete header lines are passed on to the callback" (including \r\n at the end)
            if (startsWithAsciiNoCase(header, "Location:"))
            {
                uploadUrl = header;
                uploadUrl = afterFirst(uploadUrl, ':', IfNotFoundReturn::none);
                trim(uploadUrl);
            }
        };

        std::string response;
        const HttpSession::Result httpResult = gdriveHttpsRequest("/upload/drive/v3/files?" + queryParams,
        {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_POSTFIELDS, postBuf.c_str()}},
        [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); },
        nullptr /*readRequest*/, onHeaderData, access); //throw SysError

        if (httpResult.statusCode != 200)
            throw SysError(formatGdriveErrorRaw(response));

        if (!startsWith(uploadUrl, "https://www.googleapis.com/"))
            throw SysError(L"Invalid upload URL: " + utfTo<std::wstring>(uploadUrl)); //user should never see this

        uploadUrlRelative = afterFirst(uploadUrl, "googleapis.com", IfNotFoundReturn::none);
    }
    //---------------------------------------------------
    //step 2: upload file content

    //not officially documented, but Google Drive supports compressed file upload when "Content-Encoding: gzip" is set! :)))
    InputStreamAsGzip gzipStream(tryReadBlock, GDRIVE_BLOCK_SIZE_UPLOAD); //throw SysError

    auto readRequest = [&](std::span<char> buf) { return gzipStream.read(buf.data(), buf.size()); }; //throw SysError, X

    std::string response; //don't need "Authorization: Bearer":
    googleHttpsRequest(GOOGLE_REST_API_SERVER, uploadUrlRelative, { "Content-Encoding: gzip" }, {} /*extraOptions*/,
    [&](std::span<const char> buf) { response.append(buf.data(), buf.size()); }, readRequest,
    nullptr /*receiveHeader*/, access.timeoutSec); //throw SysError, X

    JsonValue jresponse;
    try { jresponse = parseJson(response); }
    catch (JsonParsingError&) {}

    const std::optional<std::string> itemId = getPrimitiveFromJsonObject(jresponse, "id");
    if (!itemId)
        throw SysError(formatGdriveErrorRaw(response));

    return *itemId;
}


class GdriveAccessBuffer //per-user-session & drive! => serialize access (perf: amortized fully buffered!)
{
public:
    //GdriveDrivesBuffer constructor calls GdriveAccessBuffer::getAccessToken()
    explicit GdriveAccessBuffer(const GdriveAccessInfo& accessInfo) :
        accessInfo_(accessInfo) {}

    GdriveAccessBuffer(MemoryStreamIn& stream) //throw SysError
    {
        accessInfo_.accessToken.validUntil = readNumber<int64_t>(stream);                             //
        accessInfo_.accessToken.value      = readContainer<std::string>(stream);                      //
        accessInfo_.refreshToken           = readContainer<std::string>(stream);                      //SysErrorUnexpectedEos
        accessInfo_.userInfo.displayName   = utfTo<std::wstring>(readContainer<std::string>(stream)); //
        accessInfo_.userInfo.email         =                     readContainer<std::string>(stream);  //
    }

    void serialize(MemoryStreamOut& stream) const
    {
        writeNumber<int64_t>(stream, accessInfo_.accessToken.validUntil);
        static_assert(sizeof(accessInfo_.accessToken.validUntil) <= sizeof(int64_t)); //ensure cross-platform compatibility!
        writeContainer(stream, accessInfo_.accessToken.value);
        writeContainer(stream, accessInfo_.refreshToken);
        writeContainer(stream, utfTo<std::string>(accessInfo_.userInfo.displayName));
        writeContainer(stream,                    accessInfo_.userInfo.email);
    }

    //set *before* calling any of the subsequent functions; see GdrivePersistentSessions::accessUserSession()
    void setContextTimeout(const std::weak_ptr<int>& timeoutSec) { timeoutSec_ = timeoutSec; }

    GdriveAccess getAccessToken() //throw SysError
    {
        const int timeoutSec = getTimeoutSec();

        if (accessInfo_.accessToken.validUntil <= std::time(nullptr) + timeoutSec + 5 /*some leeway*/) //expired/will expire
        {
            GdriveAccessToken token = gdriveRefreshAccess(accessInfo_.refreshToken, timeoutSec); //throw SysError

            //"there are limits on the number of refresh tokens that will be issued"
            //Google Drive access token is usually valid for one hour => fail on pathologic user-defined time out:
            if (token.validUntil <= std::time(nullptr) + 2 * timeoutSec)
                throw SysError(_("Please set up a shorter time out for Google Drive.") + L" [" + _P("1 sec", "%x sec", timeoutSec) + L']');

            accessInfo_.accessToken = std::move(token);
        }

        return {accessInfo_.accessToken.value, timeoutSec};
    }

    const std::string& getUserEmail() const { return accessInfo_.userInfo.email; }

    void update(const GdriveAccessInfo& accessInfo)
    {
        if (!equalAsciiNoCase(accessInfo.userInfo.email, accessInfo_.userInfo.email))
            throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");
        accessInfo_ = accessInfo;
    }

private:
    GdriveAccessBuffer           (const GdriveAccessBuffer&) = delete;
    GdriveAccessBuffer& operator=(const GdriveAccessBuffer&) = delete;

    int getTimeoutSec() const
    {
        const std::shared_ptr<int> timeoutSec = timeoutSec_.lock();
        assert(timeoutSec);
        if (!timeoutSec)
            throw std::runtime_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] GdriveAccessBuffer: Timeout duration was not set.");

        return *timeoutSec;
    }

    GdriveAccessInfo accessInfo_;
    std::weak_ptr<int> timeoutSec_;
};


class GdriveDrivesBuffer;


class GdriveFileState //per-user-session! => serialize access (perf: amortized fully buffered!)
{
public:
    GdriveFileState(const std::string& driveId, //ID of shared drive or "My Drive": never empty!
                    const Zstring& sharedDriveName, //*empty* for "My Drive"
                    GdriveAccessBuffer& accessBuf) : //throw SysError
        /* issue getChangesCurrentToken() as the very first Google Drive query! */
        lastSyncToken_(getChangesCurrentToken(sharedDriveName.empty() ? std::string() : driveId, accessBuf.getAccessToken())), //throw SysError
        driveId_(driveId),
        sharedDriveName_(sharedDriveName),
        accessBuf_(accessBuf) { assert(!driveId.empty() && sharedDriveName != Zstr("My Drive")); }

    GdriveFileState(MemoryStreamIn& stream, GdriveAccessBuffer& accessBuf) : //throw SysError
        accessBuf_(accessBuf)
    {
        lastSyncToken_   = readContainer<std::string>(stream); //
        driveId_         = readContainer<std::string>(stream); //SysErrorUnexpectedEos
        sharedDriveName_ = utfTo<Zstring>(readContainer<std::string>(stream)); //

        for (;;)
        {
            const std::string folderId = readContainer<std::string>(stream); //SysErrorUnexpectedEos
            if (folderId.empty())
                break;
            folderContents_[folderId].isKnownFolder = true;
        }

        for (;;)
        {
            const std::string itemId = readContainer<std::string>(stream); //SysErrorUnexpectedEos
            if (itemId.empty())
                break;

            GdriveItemDetails details = {}; //read in correct sequence!
            details.itemName = utfTo<Zstring>(readContainer<std::string>(stream)); //
            details.type     = readNumber<GdriveItemType>(stream); //
            details.owner    = readNumber     <FileOwner>(stream); //
            details.fileSize = readNumber      <uint64_t>(stream); //SysErrorUnexpectedEos
            details.modTime  = static_cast<time_t>(readNumber<int64_t>(stream)); //
            details.targetId = readContainer<std::string>(stream); //

            size_t parentsCount = readNumber<uint32_t>(stream); //SysErrorUnexpectedEos
            while (parentsCount-- != 0)
                details.parentIds.push_back(readContainer<std::string>(stream)); //SysErrorUnexpectedEos

            updateItemState(itemId, &details);
        }
    }

    void serialize(MemoryStreamOut& stream) const
    {
        writeContainer(stream, lastSyncToken_);
        writeContainer(stream, driveId_);
        writeContainer(stream, utfTo<std::string>(sharedDriveName_));

        for (const auto& [folderId, content] : folderContents_)
            if (folderId.empty())
                throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");
            else if (content.isKnownFolder)
                writeContainer(stream, folderId);
        writeContainer(stream, std::string()); //sentinel

        auto serializeItem = [&](const std::string& itemId, const GdriveItemDetails& details)
        {
            writeContainer             (stream, itemId);
            writeContainer             (stream, utfTo<std::string>(details.itemName));
            writeNumber<GdriveItemType>(stream, details.type);
            writeNumber     <FileOwner>(stream, details.owner);
            writeNumber      <uint64_t>(stream, details.fileSize);
            writeNumber       <int64_t>(stream, details.modTime);
            static_assert(sizeof(details.modTime) <= sizeof(int64_t)); //ensure cross-platform compatibility!
            writeContainer(stream, details.targetId);

            writeNumber(stream, static_cast<uint32_t>(details.parentIds.size()));
            for (const std::string& parentId : details.parentIds)
                writeContainer(stream, parentId);
        };

        //serialize + clean up: only save items in "known folders" + items referenced by shortcuts
        for (const auto& [folderId, content] : folderContents_)
            if (content.isKnownFolder)
                for (const auto& itItem : content.childItems)
                {
                    const auto& [itemId, details] = *itItem;
                    if (itemId.empty())
                        throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");
                    serializeItem(itemId, details);

                    if (details.type == GdriveItemType::shortcut)
                    {
                        if (details.targetId.empty())
                            throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");

                        if (auto it = itemDetails_.find(details.targetId);
                            it != itemDetails_.end())
                            serializeItem(details.targetId, it->second);
                    }
                }
        writeContainer(stream, std::string()); //sentinel
    }

    std::string getDriveId() const { return driveId_; }

    Zstring getSharedDriveName() const { return sharedDriveName_; } //*empty* for "My Drive"

    void setSharedDriveName(const Zstring& sharedDriveName) { sharedDriveName_ = sharedDriveName; }

    struct PathStatus
    {
        std::string existingItemId;
        GdriveItemType existingType = GdriveItemType::file;
        AfsPath existingPath;         //input path =: existingPath + relPath
        std::vector<Zstring> relPath; //
    };
    PathStatus getPathStatus(const std::string& locationRootId, const AfsPath& itemPath, bool followLeafShortcut) //throw SysError
    {
        const std::vector<Zstring> relPath = splitCpy(itemPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip);
        if (relPath.empty())
            return {locationRootId, GdriveItemType::folder, AfsPath(), {}};
        else
            return getPathStatusSub(locationRootId, AfsPath(), relPath, followLeafShortcut); //throw SysError
    }

    std::string /*itemId*/ getItemId(const std::string& locationRootId, const AfsPath& itemPath, bool followLeafShortcut) //throw SysError
    {
        const GdriveFileState::PathStatus& ps = getPathStatus(locationRootId, itemPath, followLeafShortcut); //throw SysError
        if (ps.relPath.empty())
            return ps.existingItemId;

        throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front())));
    }

    std::pair<std::string /*itemId*/, GdriveItemDetails> getFileAttributes(const std::string& locationRootId, const AfsPath& itemPath, bool followLeafShortcut) //throw SysError
    {
        if (itemPath.value.empty()) //location root not covered by itemDetails_
        {
            GdriveItemDetails rootDetails
            {
                .type = GdriveItemType::folder,
                //.itemName =... => better leave empty for a root item!
                .owner = sharedDriveName_.empty() ? FileOwner::me : FileOwner::none,
            };
            return {locationRootId, std::move(rootDetails)};
        }

        const std::string itemId = getItemId(locationRootId, itemPath, followLeafShortcut); //throw SysError
        if (auto it = itemDetails_.find(itemId);
            it != itemDetails_.end())
            return *it;

        //itemId was already found! => (must either be a location root) or buffered in itemDetails_
        throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");
    }

    std::optional<GdriveItemDetails> tryGetBufferedItemDetails(const std::string& itemId) const
    {
        if (auto it = itemDetails_.find(itemId);
            it != itemDetails_.end())
            return it->second;
        return {};
    }

    std::optional<std::vector<GdriveItem>> tryGetBufferedFolderContent(const std::string& folderId) const
    {
        auto it = folderContents_.find(folderId);
        if (it == folderContents_.end() || !it->second.isKnownFolder)
            return std::nullopt;

        std::vector<GdriveItem> childItems;
        for (auto itChild : it->second.childItems)
        {
            const auto& [childId, childDetails] = *itChild;
            childItems.push_back({childId, childDetails});
        }
        return std::move(childItems); //[!] need std::move!
    }

    //-------------- notifications --------------
    using ItemIdDelta = std::unordered_set<std::string>;

    struct FileStateDelta //as long as instance exists, GdriveItem will log all changed items
    {
        FileStateDelta() {}
    private:
        explicit FileStateDelta(const std::shared_ptr<const ItemIdDelta>& cids) : changedIds(cids) {}
        friend class GdriveFileState;
        std::shared_ptr<const ItemIdDelta> changedIds; //lifetime is managed by caller; access *only* by GdriveFileState!
    };

    void notifyFolderContent(const FileStateDelta& stateDelta, const std::string& folderId, const std::vector<GdriveItem>& childItems)
    {
        folderContents_[folderId].isKnownFolder = true;

        for (const GdriveItem& item : childItems)
            notifyItemUpdated(stateDelta, item.itemId, &item.details);

        //- should we remove parent links for items that are not children of folderId anymore (as of this update)?? => fringe case during first update! (still: maybe trigger sync?)
        //- what if there are multiple folder state updates incoming in wrong order!? => notifyItemUpdated() will sort it out!
    }

    void notifyItemCreated(const FileStateDelta& stateDelta, const GdriveItem& item)
    {
        notifyItemUpdated(stateDelta, item.itemId, &item.details);
    }

    void notifyItemUpdated(const FileStateDelta& stateDelta, const GdriveItem& item)
    {
        notifyItemUpdated(stateDelta, item.itemId, &item.details);
    }

    void notifyFolderCreated(const FileStateDelta& stateDelta, const std::string& folderId, const Zstring& folderName, const std::string& parentId)
    {
        GdriveItemDetails details
        {
            .itemName = folderName,
            .modTime = std::time(nullptr),
            .type = GdriveItemType::folder,
            .owner = FileOwner::me,
            .parentIds{parentId},
        };

        //avoid needless conflicts due to different Google Drive folder modTime!
        if (auto it = itemDetails_.find(folderId); it != itemDetails_.end())
            details.modTime = it->second.modTime;

        notifyItemUpdated(stateDelta, folderId, &details);
    }

    void notifyShortcutCreated(const FileStateDelta& stateDelta, const std::string& shortcutId, const Zstring& shortcutName, const std::string& parentId, const std::string& targetId)
    {
        GdriveItemDetails details
        {
            .itemName = shortcutName,
            .modTime = std::time(nullptr),
            .type = GdriveItemType::shortcut,
            .owner = FileOwner::me,
            .targetId = targetId,
            .parentIds{parentId},
        };

        //avoid needless conflicts due to different Google Drive folder modTime!
        if (auto it = itemDetails_.find(shortcutId); it != itemDetails_.end())
            details.modTime = it->second.modTime;

        notifyItemUpdated(stateDelta, shortcutId, &details);
    }


    void notifyItemDeleted(const FileStateDelta& stateDelta, const std::string& itemId)
    {
        notifyItemUpdated(stateDelta, itemId, nullptr);
    }

    void notifyParentRemoved(const FileStateDelta& stateDelta, const std::string& itemId, const std::string& parentIdOld)
    {
        if (auto it = itemDetails_.find(itemId); it != itemDetails_.end())
        {
            GdriveItemDetails detailsNew = it->second;
            std::erase(detailsNew.parentIds, parentIdOld);
            notifyItemUpdated(stateDelta, itemId, &detailsNew);
        }
        else //conflict!!!
            markSyncDue();
    }

    void notifyMoveAndRename(const FileStateDelta& stateDelta, const std::string& itemId, const std::string& parentIdFrom, const std::string& parentIdTo, const Zstring& newName)
    {
        if (auto it = itemDetails_.find(itemId); it != itemDetails_.end())
        {
            GdriveItemDetails detailsNew = it->second;
            detailsNew.itemName = newName;

            std::erase_if(detailsNew.parentIds, [&](const std::string& id) { return id == parentIdFrom || id == parentIdTo; }); //
            detailsNew.parentIds.push_back(parentIdTo); //not a duplicate

            notifyItemUpdated(stateDelta, itemId, &detailsNew);
        }
        else //conflict!!!
            markSyncDue();
    }

private:
    GdriveFileState           (const GdriveFileState&) = delete;
    GdriveFileState& operator=(const GdriveFileState&) = delete;

    friend class GdriveDrivesBuffer;

    void notifyItemUpdated(const FileStateDelta& stateDelta, const std::string& itemId, const GdriveItemDetails* details)
    {
        if (!stateDelta.changedIds->contains(itemId)) //no conflicting changes in the meantime?
            updateItemState(itemId, details);         //=> accept new state data
        else //conflict?
        {
            auto it = itemDetails_.find(itemId);
            if (!details == (it == itemDetails_.end()))
                if (!details || *details == it->second)
                    return; //notified changes match our current file state
            //else: conflict!!! unclear which has the more recent data!
            markSyncDue();
        }
    }

    FileStateDelta registerFileStateDelta()
    {
        auto deltaPtr = std::make_shared<ItemIdDelta>();
        changeLog_.push_back(deltaPtr);
        return FileStateDelta(deltaPtr);
    }

    bool syncIsDue() const { return std::chrono::steady_clock::now() >= lastSyncTime_ + GDRIVE_SYNC_INTERVAL; }

    void markSyncDue() { lastSyncTime_ = std::chrono::steady_clock::now() - GDRIVE_SYNC_INTERVAL; }

    void syncWithGoogle() //throw SysError
    {
        const ChangesDelta delta = getChangesDelta(sharedDriveName_.empty() ? std::string() : driveId_, lastSyncToken_, accessBuf_.getAccessToken()); //throw SysError

        for (const FileChange& change : delta.fileChanges)
            updateItemState(change.itemId, get(change.details));

        lastSyncToken_ = delta.newStartPageToken;
        lastSyncTime_ = std::chrono::steady_clock::now();

        //good to know: if item is created and deleted between polling for changes it is still reported as deleted by Google!
        //Same goes for any other change that is undone in between change notification syncs.
    }

    PathStatus getPathStatusSub(const std::string& folderId, const AfsPath& folderPath, const std::vector<Zstring>& relPath, bool followLeafShortcut) //throw SysError
    {
        assert(!relPath.empty());

        auto itKnown = folderContents_.find(folderId);
        if (itKnown == folderContents_.end() || !itKnown->second.isKnownFolder)
        {
            notifyFolderContent(registerFileStateDelta(), folderId, readFolderContent(folderId, accessBuf_.getAccessToken())); //throw SysError
            //perf: always buffered, except for direct, first-time folder access!
            itKnown = folderContents_.find(folderId);
            assert(itKnown != folderContents_.end());
            if (!itKnown->second.isKnownFolder)
                throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");
        }

        auto itFound = itemDetails_.cend();
        for (const DetailsIterator& itChild : itKnown->second.childItems)
            //Since Google Drive has no concept of a file path, we have to roll our own "path to ID" mapping => let's use the platform-native style
            if (equalNativePath(itChild->second.itemName, relPath.front()))
            {
                if (itFound != itemDetails_.end())
                    throw SysError(replaceCpy(_("The name %x is used by more than one item in the folder."), L"%x", fmtPath(relPath.front())));

                itFound = itChild;
            }

        if (itFound == itemDetails_.end())
            return {folderId, GdriveItemType::folder, folderPath, relPath}; //always a folder, see check before recursion above
        else
        {
            auto getItemDetailsBuffered = [&](const std::string& itemId) -> const GdriveItemDetails&
            {
                auto it = itemDetails_.find(itemId);
                if (it == itemDetails_.end())
                {
                    notifyItemUpdated(registerFileStateDelta(), {itemId, getItemDetails(itemId, accessBuf_.getAccessToken())}); //throw SysError
                    //perf: always buffered, except for direct, first-time folder access!
                    it = itemDetails_.find(itemId);
                    assert(it != itemDetails_.end());
                }
                return it->second;
            };

            const auto& [childId, childDetails] = *itFound;
            const AfsPath              childItemPath(appendPath(folderPath.value, relPath.front()));
            const std::vector<Zstring> childRelPath(relPath.begin() + 1, relPath.end());

            if (childRelPath.empty())
            {
                if (childDetails.type == GdriveItemType::shortcut && followLeafShortcut)
                    return {childDetails.targetId, getItemDetailsBuffered(childDetails.targetId).type, childItemPath, childRelPath};
                else
                    return {childId, childDetails.type, childItemPath, childRelPath};
            }

            switch (childDetails.type)
            {
                case GdriveItemType::file: //parent/file/child-rel-path... => obscure, but possible
                    throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(AFS::getItemName(childItemPath))));

                case GdriveItemType::folder:
                    return getPathStatusSub(childId, childItemPath, childRelPath, followLeafShortcut); //throw SysError

                case GdriveItemType::shortcut:
                    switch (getItemDetailsBuffered(childDetails.targetId).type)
                    {
                        case GdriveItemType::file: //parent/file-symlink/child-rel-path... => obscure, but possible
                            throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(AFS::getItemName(childItemPath))));

                        case GdriveItemType::folder: //parent/folder-symlink/child-rel-path... => always follow
                            return getPathStatusSub(childDetails.targetId, childItemPath, childRelPath, followLeafShortcut); //throw SysError

                        case GdriveItemType::shortcut: //should never happen: creating shortcuts to shortcuts fails with "Internal Error"
                            throw SysError(replaceCpy<std::wstring>(L"Google Drive Shortcut %x is pointing to another Shortcut.", L"%x", fmtPath(AFS::getItemName(childItemPath))));
                    }
                    break;
            }
            throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");
        }
    }

    void updateItemState(const std::string& itemId, const GdriveItemDetails* details)
    {
        auto it = itemDetails_.find(itemId);
        if (!details == (it == itemDetails_.end()))
            if (!details || *details == it->second) //notified changes match our current file state
                return; //=> avoid misleading changeLog_ entries after Google Drive sync!!!

        //update change logs (and clean up obsolete entries)
        std::erase_if(changeLog_, [&](std::weak_ptr<ItemIdDelta>& weakPtr)
        {
            if (std::shared_ptr<ItemIdDelta> iid = weakPtr.lock())
            {
                (*iid).insert(itemId);
                return false;
            }
            else
                return true;
        });

        //update file state
        if (details)
        {
            if (it != itemDetails_.end()) //update
            {
                if (it->second.type != details->type)
                    throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!"); //WTF!?

                std::vector<std::string> parentIdsNew     = details->parentIds;
                std::vector<std::string> parentIdsRemoved = it->second.parentIds;
                std::erase_if(parentIdsNew,     [&](const std::string& id) { return std::find(it->second.parentIds.begin(), it->second.parentIds.end(), id) != it->second.parentIds.end(); });
                std::erase_if(parentIdsRemoved, [&](const std::string& id) { return std::find(details->parentIds.begin(), details->parentIds.end(), id) != details->parentIds.end(); });

                for (const std::string& parentId : parentIdsNew)
                    folderContents_[parentId].childItems.push_back(it); //new insert => no need for duplicate check

                for (const std::string& parentId : parentIdsRemoved)
                    if (auto itP = folderContents_.find(parentId); itP != folderContents_.end())
                        std::erase(itP->second.childItems, it);
                //if all parents are removed, Google Drive will (recursively) delete the item => don't prematurely do this now: wait for change notifications!
                //OR: item without parents located in "Shared with me", but referenced via Shortcut => don't remove!!!

                it->second = *details;
            }
            else //create
            {
                auto itNew = itemDetails_.emplace(itemId, *details).first;

                for (const std::string& parentId : details->parentIds)
                    folderContents_[parentId].childItems.push_back(itNew); //new insert => no need for duplicate check
            }
        }
        else //delete
        {
            if (it != itemDetails_.end())
            {
                for (const std::string& parentId : it->second.parentIds) //1. delete from parent folders
                    if (auto itP = folderContents_.find(parentId); itP != folderContents_.end())
                        std::erase(itP->second.childItems, it);

                itemDetails_.erase(it);
            }

            if (auto itP = folderContents_.find(itemId); itP != folderContents_.end())
            {
                //2. delete as parent from child items (don't wait for change notifications of children)
                //  what if e.g. single change notification "folder removed", then folder reapears,
                //  and no notifications for child items: possible with Google drive!?
                //  => no problem: FolderContent::isKnownFolder will be false for this restored folder => only a rescan needed
                for (auto itChild : itP->second.childItems)
                    std::erase(itChild->second.parentIds, itemId);
                folderContents_.erase(itP);
            }
        }
    }

    using DetailsIterator = std::unordered_map<std::string, GdriveItemDetails>::iterator;

    struct FolderContent
    {
        bool isKnownFolder = false; //:= we've seen its full content at least once; further changes are calculated via change notifications
        std::vector<DetailsIterator> childItems;
    };
    std::unordered_map<std::string /*folderId*/, FolderContent> folderContents_;
    std::unordered_map<std::string /*itemId*/, GdriveItemDetails> itemDetails_; //contains ALL known, existing items!

    std::string lastSyncToken_; //drive-specific(!) marker corresponding to last sync with Google's change notifications
    std::chrono::steady_clock::time_point lastSyncTime_ = std::chrono::steady_clock::now() - GDRIVE_SYNC_INTERVAL; //... with Google Drive (default: sync is due)

    std::vector<std::weak_ptr<ItemIdDelta>> changeLog_; //track changed items since FileStateDelta was created (includes sync with Google + our own intermediate change notifications)

    std::string driveId_; //ID of shared drive or "My Drive": never empty!
    Zstring sharedDriveName_; //name of shared drive: empty for "My Drive"!

    GdriveAccessBuffer& accessBuf_;
};


class GdriveFileStateAtLocation
{
public:
    GdriveFileStateAtLocation(GdriveFileState& fileState, const std::string& locationRootId) : fileState_(fileState), locationRootId_(locationRootId) {}

    GdriveFileState::PathStatus getPathStatus(const AfsPath& itemPath, bool followLeafShortcut) //throw SysError
    {
        return fileState_.getPathStatus(locationRootId_, itemPath, followLeafShortcut); //throw SysError
    }

    std::string /*itemId*/ getItemId(const AfsPath& itemPath, bool followLeafShortcut) //throw SysError
    {
        return fileState_.getItemId(locationRootId_, itemPath, followLeafShortcut); //throw SysError
    }

    std::pair<std::string /*itemId*/, GdriveItemDetails> getFileAttributes(const AfsPath& itemPath, bool followLeafShortcut) //throw SysError
    {
        return fileState_.getFileAttributes(locationRootId_, itemPath, followLeafShortcut); //throw SysError
    }

    GdriveFileState& all() { return fileState_; }

private:
    GdriveFileState& fileState_;
    const std::string locationRootId_;
};


class GdriveDrivesBuffer
{
public:
    explicit GdriveDrivesBuffer(GdriveAccessBuffer& accessBuf) :
        accessBuf_(accessBuf),
        myDrive_(getMyDriveId(accessBuf.getAccessToken()), Zstring() /*sharedDriveName*/, accessBuf) {} //throw SysError

    GdriveDrivesBuffer(MemoryStreamIn& stream, GdriveAccessBuffer& accessBuf) : //throw SysError
        accessBuf_(accessBuf),
        myDrive_(stream, accessBuf) //throw SysError
    {
        size_t sharedDrivesCount = readNumber<uint32_t>(stream); //SysErrorUnexpectedEos
        while (sharedDrivesCount-- != 0)
        {
            auto fileState = makeSharedRef<GdriveFileState>(stream, accessBuf); //throw SysError
            sharedDrives_.emplace(fileState.ref().getDriveId(), fileState);
        }
    }

    void serialize(MemoryStreamOut& stream) const
    {
        myDrive_.serialize(stream);

        writeNumber(stream, static_cast<uint32_t>(sharedDrives_.size()));
        for (const auto& [driveId, fileState] : sharedDrives_)
            fileState.ref().serialize(stream);

        //starredFolders_? no, will be fully restored by syncWithGoogle()
    }

    std::vector<Zstring /*locationName*/> listLocations() //throw SysError
    {
        if (syncIsDue())
            syncWithGoogle(); //throw SysError

        std::vector<Zstring> locationNames;

        for (const auto& [driveId, fileState] : sharedDrives_)
            locationNames.push_back(fileState.ref().getSharedDriveName());

        for (const StarredFolderDetails& sfd : starredFolders_)
            locationNames.push_back(sfd.folderName);

        return locationNames;
    }

    std::pair<GdriveFileStateAtLocation, GdriveFileState::FileStateDelta> prepareAccess(const Zstring& locationName) //throw SysError
    {
        //checking for added/renamed/deleted shared drives *every* GDRIVE_SYNC_INTERVAL is needlessly excessive!
        //  => check 1. once per FFS run
        //           2. on drive access error
        if (lastSyncTime_ == std::chrono::steady_clock::time_point())
            syncWithGoogle(); //throw SysError

        GdriveFileStateAtLocation fileState = [&]
        {
            try
            {
                return getFileState(locationName); //throw SysError
            }
            catch (SysError&)
            {
                if (syncIsDue())
                    syncWithGoogle(); //throw SysError

                return getFileState(locationName); //throw SysError
            }
        }();

        //manage last sync time here so that "lastSyncToken" remains stable while accessing GdriveFileState in the callback
        if (fileState.all().syncIsDue())
            fileState.all().syncWithGoogle(); //throw SysError

        return {fileState, fileState.all().registerFileStateDelta()};
    }

private:
    bool syncIsDue() const { return std::chrono::steady_clock::now() >= lastSyncTime_ + GDRIVE_SYNC_INTERVAL; }

    void syncWithGoogle() //throw SysError
    {
        //run in parallel with getSharedDrives()
        auto ftStarredFolders = runAsync([access = accessBuf_.getAccessToken() /*throw SysError*/] { return getStarredFolders(access); /*throw SysError*/ });

        decltype(sharedDrives_) currentDrives;

        //getSharedDrives() should be fast enough to avoid the unjustified complexity of change notifications: https://freefilesync.org/forum/viewtopic.php?t=7827&start=30#p29712
        for (const auto& [driveId, driveName] : getSharedDrives(accessBuf_.getAccessToken())) //throw SysError
        {
            auto fileState = [&]
            {
                if (auto it = sharedDrives_.find(driveId);
                    it != sharedDrives_.end())
                {
                    it->second.ref().setSharedDriveName(driveName);
                    return it->second;
                }
                else
                    return makeSharedRef<GdriveFileState>(driveId, driveName, accessBuf_); //throw SysError
            }();
            currentDrives.emplace(driveId, fileState);
        }

        starredFolders_ = ftStarredFolders.get(); //throw SysError //
        sharedDrives_.swap(currentDrives);                         //transaction!
        lastSyncTime_ = std::chrono::steady_clock::now(); //...(uhm, mostly, except for setSharedDriveName())
    }

    GdriveFileStateAtLocation getFileState(const Zstring& locationName) //throw SysError
    {
        if (locationName.empty())
            return {myDrive_, myDrive_.getDriveId()};

        GdriveFileState* fileState = nullptr;
        std::string locationRootId;

        for (auto& [driveId, fileStateRef] : sharedDrives_)
            if (equalNativePath(fileStateRef.ref().getSharedDriveName(), locationName))
            {
                if (fileState)
                    throw SysError(replaceCpy(_("The name %x is used by more than one item in the folder."), L"%x", fmtPath(locationName)));

                fileState = &fileStateRef.ref();
                locationRootId = driveId;
            }

        for (const StarredFolderDetails& sfd : starredFolders_)
            if (equalNativePath(sfd.folderName, locationName))
            {
                if (fileState)
                    throw SysError(replaceCpy(_("The name %x is used by more than one item in the folder."), L"%x", fmtPath(locationName)));

                if (sfd.sharedDriveId.empty()) //=> My Drive
                    fileState = &myDrive_;
                else
                {
                    auto it = sharedDrives_.find(sfd.sharedDriveId);
                    if (it == sharedDrives_.end())
                        break;

                    fileState = &it->second.ref();
                }
                locationRootId = sfd.folderId;
            }

        if (!fileState)
            throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(locationName)));

        return {*fileState, locationRootId};
    }

    GdriveAccessBuffer& accessBuf_;
    std::chrono::steady_clock::time_point lastSyncTime_; //... with Google Drive (default: sync is due)

    GdriveFileState myDrive_;
    std::unordered_map<std::string /*drive ID*/, SharedRef<GdriveFileState>> sharedDrives_;

    std::vector<StarredFolderDetails> starredFolders_;
};

//==========================================================================================
//==========================================================================================

class GdrivePersistentSessions
{
public:
    explicit GdrivePersistentSessions(const Zstring& configDirPath) : configDirPath_(configDirPath)
    {
        onSystemShutdownRegister(onBeforeSystemShutdownCookie_);
    }

    void saveActiveSessions() //throw FileError
    {
        std::vector<Protected<SessionHolder>*> protectedSessions; //pointers remain stable, thanks to std::unordered_map<>
        globalSessions_.access([&](GlobalSessions& sessions)
        {
            for (auto& [accountEmail, protectedSession] : sessions)
                protectedSessions.push_back(&protectedSession);
        });

        if (!protectedSessions.empty())
        {
            createDirectoryIfMissingRecursion(configDirPath_); //throw FileError

            std::exception_ptr firstError;

            //access each session outside the globalSessions_ lock!
            for (Protected<SessionHolder>* protectedSession : protectedSessions)
                protectedSession->access([&](SessionHolder& holder)
            {
                if (holder.session)
                    try
                    {
                        const Zstring dbFilePath = getDbFilePath(holder.session->accessBuf.ref().getUserEmail());
                        saveSession(dbFilePath, *holder.session); //throw FileError
                    }
                    catch (FileError&) { if (!firstError) firstError = std::current_exception(); }
            });

            if (firstError)
                std::rethrow_exception(firstError); //throw FileError
        }
    }

    std::string addUserSession(const std::string& gdriveLoginHint, const std::function<void()>& updateGui /*throw X*/, int timeoutSec) //throw SysError, X
    {
        const GdriveAccessInfo accessInfo = gdriveAuthorizeAccess(gdriveLoginHint, updateGui, timeoutSec); //throw SysError, X

        accessUserSession(accessInfo.userInfo.email, timeoutSec, [&](std::optional<UserSession>& userSession) //throw SysError
        {
            if (userSession)
                userSession->accessBuf.ref().update(accessInfo); //redundant?
            else
            {
                const std::shared_ptr<int> timeoutSec2 = std::make_shared<int>(timeoutSec); //context option: valid only for duration of this call!
                auto accessBuf = makeSharedRef<GdriveAccessBuffer>(accessInfo);
                accessBuf.ref().setContextTimeout(timeoutSec2); //[!] used by GdriveDrivesBuffer()!
                auto drivesBuf = makeSharedRef<GdriveDrivesBuffer>(accessBuf.ref()); //throw SysError
                userSession = {accessBuf, drivesBuf};
            }
        });

        return accessInfo.userInfo.email;
    }

    void removeUserSession(const std::string& accountEmail, int timeoutSec) //throw SysError
    {
        try
        {
            accessUserSession(accountEmail, timeoutSec, [&](std::optional<UserSession>& userSession) //throw SysError
            {
                if (userSession)
                    gdriveRevokeAccess(userSession->accessBuf.ref().getAccessToken()); //throw SysError
            });
        }
        catch ([[maybe_unused]] const SysError& e) { assert(false); } //best effort: try to invalidate the access token
        //=> expected to fail 1. if offline => not worse than removing FFS via "Uninstall Programs" 2. already revoked 3. if DB is corrupted

        try
        {
            //start with deleting the DB file (1. maybe it's corrupted? 2. skip unnecessary lazy-load)
            const Zstring dbFilePath = getDbFilePath(accountEmail);
            try
            {
                removeFilePlain(dbFilePath); //throw FileError
            }
            catch (FileError&)
            {
                if (itemExists(dbFilePath)) //throw FileError
                    throw;
            }
        }
        catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //file access errors should be further enriched by context info => SysError


        accessUserSession(accountEmail, timeoutSec, [&](std::optional<UserSession>& userSession) //throw SysError
        {
            userSession.reset();
        });
    }

    std::vector<std::string /*account email*/> listAccounts() //throw SysError
    {
        std::vector<std::string> emails;

        std::vector<Protected<SessionHolder>*> protectedSessions; //pointers remain stable, thanks to std::unordered_map<>
        globalSessions_.access([&](GlobalSessions& sessions)
        {
            for (auto& [accountEmail, protectedSession] : sessions)
                protectedSessions.push_back(&protectedSession);
        });

        //access each session outside the globalSessions_ lock!
        for (Protected<SessionHolder>* protectedSession : protectedSessions)
            protectedSession->access([&](SessionHolder& holder)
        {
            if (holder.session)
                emails.push_back(holder.session->accessBuf.ref().getUserEmail());
        });

        //also include available, but not-yet-loaded sessions
        try
        {
            traverseFolder(configDirPath_,
            [&](const    FileInfo& fi) { if (endsWith(fi.itemName, Zstr(".db"))) emails.push_back(utfTo<std::string>(beforeLast(fi.itemName, Zstr('.'), IfNotFoundReturn::none))); },
            [&](const  FolderInfo& fi) {},
            [&](const SymlinkInfo& si) {}); //throw FileError
        }
        catch (FileError&)
        {
            try
            {
                if (itemExists(configDirPath_)) //throw FileError
                    throw;
            }
            catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //file access errors should be further enriched by context info => SysError
        }

        removeDuplicates(emails, LessAsciiNoCase());
        return emails;
    }

    std::vector<Zstring /*locationName*/> listLocations(const std::string& accountEmail, int timeoutSec) //throw SysError
    {
        std::vector<Zstring> locationNames;

        accessUserSession(accountEmail, timeoutSec, [&](std::optional<UserSession>& userSession) //throw SysError
        {
            if (!userSession)
                throw SysError(replaceCpy(_("Please add a connection to user account %x first."), L"%x", utfTo<std::wstring>(accountEmail)));

            locationNames = userSession->drivesBuf.ref().listLocations(); //throw SysError
        });
        return locationNames;
    }

    struct AsyncAccessInfo
    {
        GdriveAccess access; //don't allow (long-running) web requests while holding the global session lock!
        GdriveFileState::FileStateDelta stateDelta;
    };
    //perf: amortized fully buffered!
    AsyncAccessInfo accessGlobalFileState(const GdriveLogin& login, const std::function<void(GdriveFileStateAtLocation& fileState)>& useFileState /*throw X*/) //throw SysError, X
    {
        GdriveAccess access;
        GdriveFileState::FileStateDelta stateDelta;

        accessUserSession(login.email, login.timeoutSec, [&](std::optional<UserSession>& userSession) //throw SysError
        {
            if (!userSession)
                throw SysError(replaceCpy(_("Please add a connection to user account %x first."), L"%x", utfTo<std::wstring>(login.email)));

            access                        = userSession->accessBuf.ref().getAccessToken(); //throw SysError
            auto [fileState, stateDelta2] = userSession->drivesBuf.ref().prepareAccess(login.locationName); //throw SysError
            stateDelta = std::move(stateDelta2);

            useFileState(fileState); //throw X
        });
        return {access, stateDelta};
    }

private:
    GdrivePersistentSessions           (const GdrivePersistentSessions&) = delete;
    GdrivePersistentSessions& operator=(const GdrivePersistentSessions&) = delete;

    struct UserSession;

    Zstring getDbFilePath(const std::string& accountEmail) const
    {
        //return appendPath(configDirPath_, utfTo<Zstring>(formatAsHexString(getMd5(utfTo<std::string>(accountEmail)))) + Zstr(".db"));
        return appendPath(configDirPath_, utfTo<Zstring>(getAsciiLowerCase(accountEmail)) + Zstr(".db"));
    }

    void accessUserSession(const std::string& accountEmail, int timeoutSec, const std::function<void(std::optional<UserSession>& userSession)>& useSession /*throw X*/) //throw SysError, X
    {
        Protected<SessionHolder>* protectedSession = nullptr; //pointers remain stable, thanks to std::unordered_map<>
        globalSessions_.access([&](GlobalSessions& sessions) { protectedSession = &sessions[accountEmail]; });

        protectedSession->access([&](SessionHolder& holder)
        {
            if (!holder.dbWasLoaded) //let's NOT load the DB files under the globalSessions_ lock, but the session-specific one!
                try
                {
                    holder.session = loadSession(getDbFilePath(accountEmail), timeoutSec); //throw SysError
                }
                catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //GdrivePersistentSessions errors should be further enriched with context info => SysError
            holder.dbWasLoaded = true;

            const std::shared_ptr<int> timeoutSec2 = std::make_shared<int>(timeoutSec); //context option: valid only for duration of this call!
            if (holder.session)
                holder.session->accessBuf.ref().setContextTimeout(timeoutSec2);

            useSession(holder.session); //throw X
        });
    }

    static void saveSession(const Zstring& dbFilePath, const UserSession& userSession) //throw FileError
    {
        MemoryStreamOut streamOut;
        writeArray(streamOut, DB_FILE_DESCR, sizeof(DB_FILE_DESCR));
        writeNumber<int32_t>(streamOut, DB_FILE_VERSION);

        MemoryStreamOut streamOutBody;
        userSession.accessBuf.ref().serialize(streamOutBody);
        userSession.drivesBuf.ref().serialize(streamOutBody);

        try
        {
            streamOut.ref() += compress(streamOutBody.ref(), 3 /*best compression level: see db_file.cpp*/); //throw SysError
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(dbFilePath)), e.toString()); }

        setFileContent(dbFilePath, streamOut.ref(), nullptr /*notifyUnbufferedIO*/); //throw FileError
    }

    static std::optional<UserSession> loadSession(const Zstring& dbFilePath, int timeoutSec) //throw FileError
    {
        std::string byteStream;
        try
        {
            byteStream = getFileContent(dbFilePath, nullptr /*notifyUnbufferedIO*/); //throw FileError
        }
        catch (FileError&)
        {
            if (itemExists(dbFilePath)) //throw FileError
                throw;

            return std::nullopt;
        }

        try
        {
            MemoryStreamIn streamIn(byteStream);
            //-------- file format header --------
            char tmp[sizeof(DB_FILE_DESCR)] = {};
            readArray(streamIn, &tmp, sizeof(tmp)); //throw SysErrorUnexpectedEos

            const std::shared_ptr<int> timeoutSec2 = std::make_shared<int>(timeoutSec); //context option: valid only for duration of this call!

            //TODO: remove migration code at some time! 2020-07-03
            if (!std::equal(std::begin(tmp), std::end(tmp), std::begin(DB_FILE_DESCR)))
            {
                const std::string& uncompressedStream = decompress(byteStream); //throw SysError
                MemoryStreamIn streamIn2(uncompressedStream);
                //-------- file format header --------
                const char DB_FILE_DESCR_OLD[] = "FreeFileSync: Google Drive Database";
                char tmp2[sizeof(DB_FILE_DESCR_OLD)] = {};
                readArray(streamIn2, &tmp2, sizeof(tmp2)); //throw SysErrorUnexpectedEos

                if (!std::equal(std::begin(tmp2), std::end(tmp2), std::begin(DB_FILE_DESCR_OLD)))
                    throw SysError(_("File content is corrupted.") + L" (invalid header)");

                const int version = readNumber<int32_t>(streamIn2); //throw SysErrorUnexpectedEos
                if (version != 1 && //TODO: remove migration code at some time! 2019-12-05
                    version != 2 && //TODO: remove migration code at some time! 2020-06-11
                    version != 3)   //TODO: remove migration code at some time! 2020-07-03
                    throw SysError(_("Unsupported data format.") + L' ' + replaceCpy(_("Version: %x"), L"%x", numberTo<std::wstring>(version)));

                //version 1 + 2: fully discard old state due to missing "ownedByMe" attribute + shortcut support
                //version 3:     fully discard old state due to revamped shared drive handling
                auto accessBuf = makeSharedRef<GdriveAccessBuffer>(streamIn2); //throw SysError
                accessBuf.ref().setContextTimeout(timeoutSec2); //not used by GdriveDrivesBuffer(), but let's be consistent
                auto drivesBuf = makeSharedRef<GdriveDrivesBuffer>(accessBuf.ref()); //throw SysError
                return UserSession{accessBuf, drivesBuf};
            }
            else
            {
                if (!std::equal(std::begin(tmp), std::end(tmp), std::begin(DB_FILE_DESCR)))
                    throw SysError(_("File content is corrupted.") + L" (invalid header)");

                const int version = readNumber<int32_t>(streamIn); //throw SysErrorUnexpectedEos
                if (version != 4 &&
                    version != DB_FILE_VERSION)
                    throw SysError(_("Unsupported data format.") + L' ' + replaceCpy(_("Version: %x"), L"%x", numberTo<std::wstring>(version)));

                const std::string& uncompressedStream = decompress({byteStream.begin() + streamIn.pos(), byteStream.end()}); //throw SysError
                MemoryStreamIn streamInBody(uncompressedStream);

                auto accessBuf = makeSharedRef<GdriveAccessBuffer>(streamInBody); //throw SysError
                accessBuf.ref().setContextTimeout(timeoutSec2); //not used by GdriveDrivesBuffer(), but let's be consistent
                auto drivesBuf = [&]
                {
                    //TODO: remove migration code at some time! 2021-05-15
                    if (version <= 4) //fully discard old state due to revamped shared drive handling
                        return makeSharedRef<GdriveDrivesBuffer>(accessBuf.ref()); //throw SysError
                    else
                        return makeSharedRef<GdriveDrivesBuffer>(streamInBody, accessBuf.ref()); //throw SysError
                }();

                return UserSession{accessBuf, drivesBuf};
            }
        }
        catch (const SysError& e)
        {
            throw FileError(replaceCpy(_("Cannot read database file %x."), L"%x", fmtPath(dbFilePath)), e.toString());
        }
    }

    struct UserSession
    {
        SharedRef<GdriveAccessBuffer> accessBuf;
        SharedRef<GdriveDrivesBuffer> drivesBuf;
    };

    struct SessionHolder
    {
        bool dbWasLoaded = false;
        std::optional<UserSession> session;
    };
    using GlobalSessions = std::unordered_map<std::string /*Google account email*/, Protected<SessionHolder>, StringHashAsciiNoCase, StringEqualAsciiNoCase>;

    Protected<GlobalSessions> globalSessions_;
    const Zstring configDirPath_;

    const SharedRef<std::function<void()>> onBeforeSystemShutdownCookie_ = makeSharedRef<std::function<void()>>([this]
    {
        try //let's not lose Google Drive data due to unexpected system shutdown:
        { saveActiveSessions(); } //throw FileError
        catch (const FileError& e) { logExtraError(e.toString()); }
    });
};
//==========================================================================================
constinit Global<GdrivePersistentSessions> globalGdriveSessions;
//==========================================================================================

GdrivePersistentSessions::AsyncAccessInfo accessGlobalFileState(const GdriveLogin& login, const std::function<void(GdriveFileStateAtLocation& fileState)>& useFileState /*throw X*/) //throw SysError, X
{
    if (const std::shared_ptr<GdrivePersistentSessions> gps = globalGdriveSessions.get())
        return gps->accessGlobalFileState(login, useFileState); //throw SysError, X

    throw SysError(formatSystemError("accessGlobalFileState", L"", L"Function call not allowed during init/shutdown."));
}

//==========================================================================================
//==========================================================================================

struct GetDirDetails
{
    GetDirDetails(const GdrivePath& folderPath) : folderPath_(folderPath) {}

    struct Result
    {
        std::vector<GdriveItem> childItems;
        GdrivePath folderPath;
    };
    Result operator()() const
    {
        try
        {
            std::string folderId;
            std::optional<std::vector<GdriveItem>> childItemsBuf;
            const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(folderPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                const auto& [itemId, itemDetails] = fileState.getFileAttributes(folderPath_.itemPath, true /*followLeafShortcut*/); //throw SysError

                if (itemDetails.type != GdriveItemType::folder) //check(!) or readFolderContent() will return empty (without failing!)
                    throw SysError(replaceCpy<std::wstring>(L"%x is not a directory.", L"%x", fmtPath(utfTo<Zstring>(itemDetails.itemName))));

                folderId      = itemId;
                childItemsBuf = fileState.all().tryGetBufferedFolderContent(folderId);
            });

            if (!childItemsBuf)
            {
                childItemsBuf = readFolderContent(folderId, aai.access); //throw SysError

                //buffer new file state ASAP => make sure accessGlobalFileState() has amortized constant access (despite the occasional internal readFolderContent() on non-leaf folders)
                accessGlobalFileState(folderPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
                {
                    fileState.all().notifyFolderContent(aai.stateDelta, folderId, *childItemsBuf);
                });
            }

            for (const GdriveItem& item : *childItemsBuf)
                if (item.details.itemName.empty())
                    throw SysError(L"Folder contains an item without name."); //mostly an issue for FFS's folder traversal, but NOT for globalGdriveSessions!

            return {std::move(*childItemsBuf), folderPath_};
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(getGdriveDisplayPath(folderPath_))), e.toString()); }
    }

private:
    GdrivePath folderPath_;
};


struct GetShortcutTargetDetails
{
    GetShortcutTargetDetails(const GdrivePath& shortcutPath, const GdriveItemDetails& shortcutDetails) : shortcutPath_(shortcutPath), shortcutDetails_(shortcutDetails) {}

    struct Result
    {
        GdriveItemDetails target;
        GdriveItemDetails shortcut;
        GdrivePath shortcutPath;
    };
    Result operator()() const
    {
        try
        {
            std::optional<GdriveItemDetails> targetDetailsBuf;
            const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(shortcutPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                targetDetailsBuf = fileState.all().tryGetBufferedItemDetails(shortcutDetails_.targetId);
            });
            if (!targetDetailsBuf)
            {
                targetDetailsBuf = getItemDetails(shortcutDetails_.targetId, aai.access); //throw SysError

                //buffer new file state ASAP
                accessGlobalFileState(shortcutPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
                {
                    fileState.all().notifyItemUpdated(aai.stateDelta, {shortcutDetails_.targetId, *targetDetailsBuf});
                });
            }

            assert(targetDetailsBuf->targetId.empty());
            if (targetDetailsBuf->type == GdriveItemType::shortcut) //should never happen: creating shortcuts to shortcuts fails with "Internal Error"
                throw SysError(L"Google Drive Shortcut points to another Shortcut.");

            return {std::move(*targetDetailsBuf), shortcutDetails_, shortcutPath_};
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(getGdriveDisplayPath(shortcutPath_))), e.toString()); }
    }

private:
    GdrivePath shortcutPath_;
    GdriveItemDetails shortcutDetails_;
};


class SingleFolderTraverser
{
public:
    SingleFolderTraverser(const GdriveLogin& gdriveLogin, const std::vector<std::pair<AfsPath, std::shared_ptr<AFS::TraverserCallback>>>& workload /*throw X*/) :
        gdriveLogin_(gdriveLogin), workload_(workload)
    {
        while (!workload_.empty())
        {
            auto wi = std::move(workload_.    back()); //yes, no strong exception guarantee (std::bad_alloc)
            /**/                workload_.pop_back();  //
            const auto& [folderPath, cb] = wi;

            tryReportingDirError([&] //throw X
            {
                traverseWithException(folderPath, *cb); //throw FileError, X
            }, *cb);
        }
    }

private:
    SingleFolderTraverser           (const SingleFolderTraverser&) = delete;
    SingleFolderTraverser& operator=(const SingleFolderTraverser&) = delete;

    void traverseWithException(const AfsPath& folderPath, AFS::TraverserCallback& cb) //throw FileError, X
    {
        const std::vector<GdriveItem>& childItems = GetDirDetails({gdriveLogin_, folderPath})().childItems; //throw FileError

        for (const GdriveItem& item : childItems)
        {
            const Zstring itemName = utfTo<Zstring>(item.details.itemName);

            switch (item.details.type)
            {
                case GdriveItemType::file:
                    cb.onFile({itemName, item.details.fileSize, item.details.modTime, getGdriveFilePrint(item.itemId), false /*isFollowedSymlink*/}); //throw X
                    break;

                case GdriveItemType::folder:
                    if (std::shared_ptr<AFS::TraverserCallback> cbSub = cb.onFolder({itemName, false /*isFollowedSymlink*/})) //throw X
                    {
                        const AfsPath afsItemPath(appendPath(folderPath.value, itemName));
                        workload_.emplace_back(afsItemPath, std::move(cbSub));
                    }
                    break;

                case GdriveItemType::shortcut:
                    switch (cb.onSymlink({itemName, item.details.modTime})) //throw X
                    {
                        case AFS::TraverserCallback::HandleLink::follow:
                        {
                            const AfsPath afsItemPath(appendPath(folderPath.value, itemName));

                            GdriveItemDetails targetDetails = {};
                            if (!tryReportingItemError([&] //throw X
                        {
                            targetDetails = GetShortcutTargetDetails({gdriveLogin_, afsItemPath}, item.details)().target; //throw FileError
                            }, cb, itemName))
                            continue;

                            if (targetDetails.type == GdriveItemType::folder)
                            {
                                if (std::shared_ptr<AFS::TraverserCallback> cbSub = cb.onFolder({itemName, true /*isFollowedSymlink*/})) //throw X
                                    workload_.emplace_back(afsItemPath, std::move(cbSub));
                            }
                            else //a file or named pipe, etc.
                                cb.onFile({itemName, targetDetails.fileSize, targetDetails.modTime, getGdriveFilePrint(item.details.targetId), true /*isFollowedSymlink*/}); //throw X
                        }
                        break;

                        case AFS::TraverserCallback::HandleLink::skip:
                            break;
                    }
                    break;
            }
        }
    }

    const GdriveLogin gdriveLogin_;
    std::vector<std::pair<AfsPath, std::shared_ptr<AFS::TraverserCallback>>> workload_;
};


void gdriveTraverseFolderRecursive(const GdriveLogin& gdriveLogin, const std::vector<std::pair<AfsPath, std::shared_ptr<AFS::TraverserCallback>>>& workload /*throw X*/, size_t) //throw X
{
    SingleFolderTraverser dummy(gdriveLogin, workload); //throw X
}
//==========================================================================================
//==========================================================================================

struct InputStreamGdrive : public AFS::InputStream
{
    explicit InputStreamGdrive(const GdrivePath& gdrivePath) :
        gdrivePath_(gdrivePath)
    {
        worker_ = InterruptibleThread([asyncStreamOut = asyncStreamIn_, gdrivePath]
        {
            setCurrentThreadName(Zstr("Istream ") + utfTo<Zstring>(getGdriveDisplayPath(gdrivePath)));
            try
            {
                GdriveAccess access;
                std::string fileId;
                try
                {
                    access = accessGlobalFileState(gdrivePath.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
                    {
                        fileId = fileState.getItemId(gdrivePath.itemPath, true /*followLeafShortcut*/); //throw SysError
                    }).access;
                }
                catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath))), e.toString()); }

                try
                {
                    auto writeBlock = [&](const void* buffer, size_t bytesToWrite)
                    {
                        asyncStreamOut->write(buffer, bytesToWrite); //throw ThreadStopRequest
                    };
                    gdriveDownloadFile(fileId, writeBlock, access); //throw SysError, ThreadStopRequest
                }
                catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath))), e.toString()); }

                asyncStreamOut->closeStream();
            }
            catch (FileError&) { asyncStreamOut->setWriteError(std::current_exception()); } //let ThreadStopRequest pass through!
        });
    }

    ~InputStreamGdrive()
    {
        asyncStreamIn_->setReadError(std::make_exception_ptr(ThreadStopRequest()));
    }

    size_t getBlockSize() override { return GDRIVE_BLOCK_SIZE_DOWNLOAD; } //throw (FileError)

    //may return short; only 0 means EOF! CONTRACT: bytesToRead > 0!
    size_t tryRead(void* buffer, size_t bytesToRead, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, (ErrorFileLocked), X
    {
        const size_t bytesRead = asyncStreamIn_->tryRead(buffer, bytesToRead); //throw FileError
        reportBytesProcessed(notifyUnbufferedIO); //throw X
        return bytesRead;
        //no need for asyncStreamIn_->checkWriteErrors(): once end of stream is reached, asyncStreamOut->closeStream() was called => no errors occured
    }

    std::optional<AFS::StreamAttributes> tryGetAttributesFast() override //throw FileError
    {
        AFS::StreamAttributes attr = {};
        try
        {
            accessGlobalFileState(gdrivePath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                const auto& [itemId, itemDetails] = fileState.getFileAttributes(gdrivePath_.itemPath, true /*followLeafShortcut*/); //throw SysError
                attr.modTime  = itemDetails.modTime;
                attr.fileSize = itemDetails.fileSize;
                attr.filePrint = getGdriveFilePrint(itemId);
            });
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath_))), e.toString()); }
        return std::move(attr); //[!]
    }

private:
    void reportBytesProcessed(const IoCallback& notifyUnbufferedIO /*throw X*/) //throw X
    {
        const int64_t bytesDelta = makeSigned(asyncStreamIn_->getTotalBytesWritten()) - totalBytesReported_;
        totalBytesReported_ += bytesDelta;
        if (notifyUnbufferedIO) notifyUnbufferedIO(bytesDelta); //throw X
    }

    const GdrivePath gdrivePath_;
    int64_t totalBytesReported_ = 0;
    std::shared_ptr<AsyncStreamBuffer> asyncStreamIn_ = std::make_shared<AsyncStreamBuffer>(GDRIVE_STREAM_BUFFER_SIZE);
    InterruptibleThread worker_;
};

//==========================================================================================

//already existing: 1. fails or 2. creates duplicate
struct OutputStreamGdrive : public AFS::OutputStreamImpl
{
    OutputStreamGdrive(const GdrivePath& gdrivePath,
                       std::optional<uint64_t> /*streamSize*/,
                       std::optional<time_t> modTime,
                       std::unique_ptr<PathAccessLock>&& pal) : //throw SysError
        gdrivePath_(gdrivePath)
    {
        std::promise<AFS::FingerPrint> promFilePrint;
        futFilePrint_ = promFilePrint.get_future();

        //CAVEAT: if file is already existing, OutputStreamGdrive *constructor* must fail, not OutputStreamGdrive::write(),
        //        otherwise ~OutputStreamImpl() will delete the already existing file! => don't check asynchronously!
        const Zstring fileName = AFS::getItemName(gdrivePath.itemPath);
        std::string parentId;
        /*const*/ GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdrivePath.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
        {
            const GdriveFileState::PathStatus& ps = fileState.getPathStatus(gdrivePath.itemPath, false /*followLeafShortcut*/); //throw SysError
            if (ps.relPath.empty())
                throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(fileName)));

            if (ps.relPath.size() > 1) //parent folder missing
                throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front())));

            parentId = ps.existingItemId;
        });

        worker_ = InterruptibleThread([gdrivePath, modTime, fileName, asyncStreamIn = asyncStreamOut_,
                                       pFilePrint = std::move(promFilePrint),
                                       parentId   = std::move(parentId),
                                       aai        = std::move(aai),
                                       pal        = std::move(pal)] mutable
        {
            assert(pal); //bind life time to worker thread!
            setCurrentThreadName(Zstr("Ostream ") + utfTo<Zstring>(getGdriveDisplayPath(gdrivePath)));
            try
            {
                auto tryReadBlock = [&](void* buffer, size_t bytesToRead) //may return short, only 0 means EOF!
                {
                    return asyncStreamIn->tryRead(buffer, bytesToRead); //throw ThreadStopRequest
                };
                //for whatever reason, gdriveUploadFile() is slightly faster than gdriveUploadSmallFile()! despite its two roundtrips! even when file sizes are 0!
                //=> 1. issue likely on Google's side => 2. persists even after having fixed "Expect: 100-continue"
                const std::string fileIdNew = //streamSize && *streamSize < 5 * 1024 * 1024 ?
                    //gdriveUploadSmallFile(fileName, parentId, *streamSize, modTime,    readBlock, aai.access) : //throw SysError, ThreadStopRequest
                    gdriveUploadFile       (fileName, parentId,              modTime, tryReadBlock, aai.access);  //throw SysError, ThreadStopRequest
                assert(asyncStreamIn->getTotalBytesRead() == asyncStreamIn->getTotalBytesWritten());
                //already existing: creates duplicate

                //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
                GdriveItem newFileItem
                {
                    .itemId = fileIdNew,
                    .details{
                        .itemName = fileName,
                        .fileSize = asyncStreamIn->getTotalBytesRead(),
                        .type = GdriveItemType::file,
                        .owner = FileOwner::me,
                    }
                };
                if (modTime) //else: whatever modTime Google Drive selects will be notified after GDRIVE_SYNC_INTERVAL
                    newFileItem.details.modTime = *modTime;
                newFileItem.details.parentIds.push_back(parentId);

                accessGlobalFileState(gdrivePath.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
                {
                    fileState.all().notifyItemCreated(aai.stateDelta, newFileItem);
                });

                pFilePrint.set_value(getGdriveFilePrint(fileIdNew));
            }
            catch (const SysError& e)
            {
                FileError fe(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath))), e.toString());
                const std::exception_ptr exptr = std::make_exception_ptr(std::move(fe));
                asyncStreamIn->setReadError(exptr); //set both!
                pFilePrint.set_exception(exptr);    //
            }
            //let ThreadStopRequest pass through!
        });
    }

    ~OutputStreamGdrive()
    {
        if (asyncStreamOut_) //=> cleanup non-finalized output file
        {
            asyncStreamOut_->setWriteError(std::make_exception_ptr(ThreadStopRequest()));
            worker_.join();

            try //see removeFilePlain()
            {
                std::optional<std::string> itemId;
                const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdrivePath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
                {
                    const GdriveFileState::PathStatus ps = fileState.getPathStatus(gdrivePath_.itemPath, false /*followLeafShortcut*/); //throw SysError
                    if (ps.relPath.empty())
                        itemId = ps.existingItemId;
                });
                if (itemId)
                {
                    gdriveDeleteItem(*itemId, aai.access); //throw SysError

                    //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
                    accessGlobalFileState(gdrivePath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError
                    {
                        fileState.all().notifyItemDeleted(aai.stateDelta, *itemId);
                    });
                }
            }
            catch (const SysError& e)
            {
                logExtraError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath_))) + L"\n\n" + e.toString());
            }
        }
    }

    size_t getBlockSize() override { return GDRIVE_BLOCK_SIZE_UPLOAD; } //throw (FileError)

    size_t tryWrite(const void* buffer, size_t bytesToWrite, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X; may return short! CONTRACT: bytesToWrite > 0
    {
        const size_t bytesWritten = asyncStreamOut_->tryWrite(buffer, bytesToWrite); //throw FileError
        reportBytesProcessed(notifyUnbufferedIO); //throw X
        return bytesWritten;
    }

    AFS::FinalizeResult finalize(const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X
    {
        if (!asyncStreamOut_)
            throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");

        asyncStreamOut_->closeStream();

        while (futFilePrint_.wait_for(std::chrono::milliseconds(25)) == std::future_status::timeout)
            reportBytesProcessed(notifyUnbufferedIO); //throw X
        reportBytesProcessed(notifyUnbufferedIO); //[!] once more, now that *all* bytes were written

        AFS::FinalizeResult result;
        assert(isReady(futFilePrint_));
        result.filePrint = futFilePrint_.get(); //throw FileError

        //asyncStreamOut_->checkReadErrors(); //throw FileError -> not needed after *successful* upload
        asyncStreamOut_.reset(); //output finalized => no more exceptions from here on!
        //--------------------------------------------------------------------

        //result.errorModTime -> already (successfully) set during file creation
        return result;
    }

private:
    void reportBytesProcessed(const IoCallback& notifyUnbufferedIO /*throw X*/) //throw X
    {
        const int64_t bytesDelta = makeSigned(asyncStreamOut_->getTotalBytesRead()) - totalBytesReported_;
        totalBytesReported_ += bytesDelta;
        if (notifyUnbufferedIO) notifyUnbufferedIO(bytesDelta); //throw X
    }

    const GdrivePath gdrivePath_;
    int64_t totalBytesReported_ = 0;
    std::shared_ptr<AsyncStreamBuffer> asyncStreamOut_ = std::make_shared<AsyncStreamBuffer>(GDRIVE_STREAM_BUFFER_SIZE);
    InterruptibleThread worker_;
    std::future<AFS::FingerPrint> futFilePrint_;
};

//==========================================================================================

class GdriveFileSystem : public AbstractFileSystem
{
public:
    explicit GdriveFileSystem(const GdriveLogin& gdriveLogin) : gdriveLogin_(gdriveLogin) {}

    const GdriveLogin& getGdriveLogin() const { return gdriveLogin_; }

    Zstring getFolderUrl(const AfsPath& folderPath) const //throw FileError
    {
        try
        {
            GdriveFileState::PathStatus ps;
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                ps = fileState.getPathStatus(folderPath, true /*followLeafShortcut*/); //throw SysError
            });

            if (!ps.relPath.empty())
                throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front())));

            if (ps.existingType != GdriveItemType::folder)
                throw SysError(replaceCpy<std::wstring>(L"%x is not a folder.", L"%x", fmtPath(getItemName(folderPath))));

            return Zstr("https://drive.google.com/drive/folders/") + utfTo<Zstring>(ps.existingItemId);
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); }
    }

private:
    GdrivePath getGdrivePath(const AfsPath& itemPath) const { return {gdriveLogin_, itemPath}; }

    GdriveRawPath getGdriveRawPath(const AfsPath& itemPath) const //throw SysError
    {
        const std::optional<AfsPath> parentPath = getParentPath(itemPath);
        if (!parentPath)
            throw SysError(L"Item is device root");

        std::string parentId;
        accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
        {
            parentId = fileState.getItemId(*parentPath, true /*followLeafShortcut*/); //throw SysError
        });
        return { std::move(parentId), getItemName(itemPath)};
    }

    Zstring getInitPathPhrase(const AfsPath& itemPath) const override { return concatenateGdriveFolderPathPhrase(getGdrivePath(itemPath)); }

    std::vector<Zstring> getPathPhraseAliases(const AfsPath& itemPath) const override { return {getInitPathPhrase(itemPath)}; }

    std::wstring getDisplayPath(const AfsPath& itemPath) const override { return getGdriveDisplayPath(getGdrivePath(itemPath)); }

    bool isNullFileSystem() const override { return gdriveLogin_.email.empty(); }

    std::weak_ordering compareDeviceSameAfsType(const AbstractFileSystem& afsRhs) const override
    {
        const GdriveLogin& lhs = gdriveLogin_;
        const GdriveLogin& rhs = static_cast<const GdriveFileSystem&>(afsRhs).gdriveLogin_;

        if (const std::weak_ordering cmp = compareAsciiNoCase(lhs.email, rhs.email);
            cmp != std::weak_ordering::equivalent)
            return cmp;

        return compareNativePath(lhs.locationName, rhs.locationName);
    }

    //----------------------------------------------------------------------------------------------------------------
    ItemType getItemType(const AfsPath& itemPath) const override //throw FileError
    {
        try
        {
            GdriveFileState::PathStatus ps;
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                ps = fileState.getPathStatus(itemPath, false /*followLeafShortcut*/); //throw SysError
            });
            if (ps.relPath.empty())
                switch (ps.existingType)
                {
                    case GdriveItemType::file:     return ItemType::file;
                    case GdriveItemType::folder:   return ItemType::folder;
                    case GdriveItemType::shortcut: return ItemType::symlink;
                }

            throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(Zstring(ps.relPath.front()))));
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); }
    }

    std::optional<ItemType> getItemTypeIfExists(const AfsPath& itemPath) const override //throw FileError
    {
        try
        {
            GdriveFileState::PathStatus ps;
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                ps = fileState.getPathStatus(itemPath, false /*followLeafShortcut*/); //throw SysError
            });
            if (ps.relPath.empty())
                switch (ps.existingType)
                {
                    case GdriveItemType::file:     return ItemType::file;
                    case GdriveItemType::folder:   return ItemType::folder;
                    case GdriveItemType::shortcut: return ItemType::symlink;
                }
            return std::nullopt;
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); }
    }

    //----------------------------------------------------------------------------------------------------------------
    //already existing: 1. fails or 2. creates duplicate (unlikely)
    void createFolderPlain(const AfsPath& folderPath) const override //throw FileError
    {
        try
        {
            //avoid duplicate Google Drive item creation by multiple threads
            PathAccessLock pal(getGdriveRawPath(folderPath), PathBlockType::otherWait); //throw SysError

            const Zstring folderName = getItemName(folderPath);
            std::string parentId;
            const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                const GdriveFileState::PathStatus& ps = fileState.getPathStatus(folderPath, false /*followLeafShortcut*/); //throw SysError
                if (ps.relPath.empty())
                    throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(folderName)));

                if (ps.relPath.size() > 1) //parent folder missing
                    throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front())));

                parentId = ps.existingItemId;
            });

            //already existing: creates duplicate
            const std::string folderIdNew = gdriveCreateFolderPlain(folderName, parentId, aai.access); //throw SysError

            //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                fileState.all().notifyFolderCreated(aai.stateDelta, folderIdNew, folderName, parentId);
            });
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); }
    }

    void removeItemPlainImpl(const AfsPath& itemPath, std::optional<GdriveItemType> expectedType, bool permanent /*...or move to trash*/, bool failIfNotExist) const //throw SysError
    {
        const std::optional<AfsPath> parentPath = getParentPath(itemPath);
        if (!parentPath) throw SysError(L"Item is device root");

        std::string itemId;
        std::optional<std::string> parentIdToUnlink;
        const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
        {
            const GdriveFileState::PathStatus ps = fileState.getPathStatus(itemPath, false /*followLeafShortcut*/); //throw SysError
            if (!ps.relPath.empty())
            {
                if (failIfNotExist)
                    throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front())));
                else
                    return;
            }

            GdriveItemDetails itemDetails;
            std::tie(itemId, itemDetails) = fileState.getFileAttributes(itemPath, false /*followLeafShortcut*/); //throw SysError
            assert(std::find(itemDetails.parentIds.begin(), itemDetails.parentIds.end(), fileState.getItemId(*parentPath, true /*followLeafShortcut*/)) != itemDetails.parentIds.end());

            if (expectedType && itemDetails.type != *expectedType)
                switch (*expectedType)
                {
                    case GdriveItemType::file:     throw SysError(L"Item is not a file");
                    case GdriveItemType::folder:   throw SysError(L"Item is not a folder");
                    case GdriveItemType::shortcut: throw SysError(L"Item is not a shortcut");
                }

            //hard-link handling applies to shared files as well: 1. it's the right thing (TM) 2. if we're not the owner: deleting would fail
            if (itemDetails.parentIds.size() > 1 || itemDetails.owner == FileOwner::other) //FileOwner::other behaves like a followed symlink! i.e. vanishes if owner deletes it!
                parentIdToUnlink = fileState.getItemId(*parentPath, true /*followLeafShortcut*/); //throw SysError
        });
        if (itemId.empty())
            return;

        if (parentIdToUnlink)
        {
            gdriveUnlinkParent(itemId, *parentIdToUnlink, aai.access); //throw SysError

            //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                fileState.all().notifyParentRemoved(aai.stateDelta, itemId, *parentIdToUnlink);
            });
        }
        else
        {
            if (permanent)
                gdriveDeleteItem(itemId, aai.access); //throw SysError
            else
                gdriveMoveToTrash(itemId, aai.access); //throw SysError

            //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                fileState.all().notifyItemDeleted(aai.stateDelta, itemId);
            });
        }
    }

    void removeFilePlain(const AfsPath& filePath) const override //throw FileError
    {
        try { removeItemPlainImpl(filePath, GdriveItemType::file, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString()); }
    }

    void removeSymlinkPlain(const AfsPath& linkPath) const override //throw FileError
    {
        try { removeItemPlainImpl(linkPath, GdriveItemType::shortcut, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete symbolic link %x."), L"%x", fmtPath(getDisplayPath(linkPath))), e.toString()); }
    }

    void removeFolderPlain(const AfsPath& folderPath) const override //throw FileError
    {
        try { removeItemPlainImpl(folderPath, GdriveItemType::folder, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); }
    }

    void removeFolderIfExistsRecursion(const AfsPath& folderPath, //throw FileError
                                       const std::function<void(const std::wstring& displayPath)>& onBeforeFileDeletion   /*throw X*/,
                                       const std::function<void(const std::wstring& displayPath)>& onBeforeSymlinkDeletion/*throw X*/,
                                       const std::function<void(const std::wstring& displayPath)>& onBeforeFolderDeletion /*throw X*/) const override
    {
        if (onBeforeFolderDeletion) onBeforeFolderDeletion(getDisplayPath(folderPath)); //throw X

        try { removeItemPlainImpl(folderPath, GdriveItemType::folder, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); }
    }

    //----------------------------------------------------------------------------------------------------------------
    AbstractPath getSymlinkResolvedPath(const AfsPath& linkPath) const override //throw FileError
    {
        //this function doesn't make sense for Google Drive: Shortcuts do not refer by path, but ID!
        //even if it were possible to determine a path, doing anything with the target file (e.g. delete + recreate) would break other Shortcuts!
        throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(getDisplayPath(linkPath))), _("Operation not supported by device."));
    }

    bool equalSymlinkContentForSameAfsType(const AfsPath& linkPathL, const AbstractPath& linkPathR) const override //throw FileError
    {
        auto getTargetId = [](const GdriveFileSystem& gdriveFs, const AfsPath& linkPath)
        {
            try
            {
                std::string targetId;
                const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveFs.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
                {
                    const GdriveItemDetails& itemDetails = fileState.getFileAttributes(linkPath, false /*followLeafShortcut*/).second; //throw SysError
                    if (itemDetails.type != GdriveItemType::shortcut)
                        throw SysError(L"Not a Google Drive Shortcut.");

                    targetId = itemDetails.targetId;
                });
                return targetId;
            }
            catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(gdriveFs.getDisplayPath(linkPath))), e.toString()); }
        };

        return getTargetId(*this, linkPathL) == getTargetId(static_cast<const GdriveFileSystem&>(linkPathR.afsDevice.ref()), linkPathR.afsPath);
    }

    //----------------------------------------------------------------------------------------------------------------

    //return value always bound:
    std::unique_ptr<InputStream> getInputStream(const AfsPath& filePath) const override //throw FileError, (ErrorFileLocked)
    {
        return std::make_unique<InputStreamGdrive>(getGdrivePath(filePath));
    }

    //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename)
    //=> actual behavior: 1. fails or 2. creates duplicate (unlikely)
    std::unique_ptr<OutputStreamImpl> getOutputStream(const AfsPath& filePath, //throw FileError
                                                      std::optional<uint64_t> streamSize,
                                                      std::optional<time_t> modTime) const override
    {
        try
        {
            //avoid duplicate item creation by multiple threads
            auto pal = std::make_unique<PathAccessLock>(getGdriveRawPath(filePath), PathBlockType::otherFail); //throw SysError
            //don't block during a potentially long-running file upload!

            //already existing: 1. fails or 2. creates duplicate
            return std::make_unique<OutputStreamGdrive>(getGdrivePath(filePath), streamSize, modTime, std::move(pal)); //throw SysError
        }
        catch (const SysError& e)
        {
            throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString());
        }
    }

    //----------------------------------------------------------------------------------------------------------------
    void traverseFolderRecursive(const TraverserWorkload& workload /*throw X*/, size_t parallelOps) const override
    {
        gdriveTraverseFolderRecursive(gdriveLogin_, workload, parallelOps); //throw X
    }
    //----------------------------------------------------------------------------------------------------------------

    //symlink handling: follow
    //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename)
    //=> actual behavior: 1. fails or 2. creates duplicate (unlikely)
    FileCopyResult copyFileForSameAfsType(const AfsPath& sourcePath, const StreamAttributes& sourceAttr, //throw FileError, (ErrorFileLocked), (X)
                                          const AbstractPath& targetPath, bool copyFilePermissions, const IoCallback& notifyUnbufferedIO /*throw X*/) const override
    {
        //no native Google Drive file copy => use stream-based file copy:
        if (copyFilePermissions)
            throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device."));

        const GdriveFileSystem& fsTarget = static_cast<const GdriveFileSystem&>(targetPath.afsDevice.ref());

        if (!equalAsciiNoCase(gdriveLogin_.email, fsTarget.gdriveLogin_.email))
            //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename)
            //=> actual behavior: 1. fails or 2. creates duplicate (unlikely)
            return copyFileAsStream(sourcePath, sourceAttr, targetPath, notifyUnbufferedIO); //throw FileError, (ErrorFileLocked), X
        //else: copying files within account works, e.g. between My Drive <-> shared drives

        try
        {
            //avoid duplicate Google Drive item creation by multiple threads (blocking is okay: gdriveCopyFile() should complete instantly!)
            PathAccessLock pal(fsTarget.getGdriveRawPath(targetPath.afsPath), PathBlockType::otherWait); //throw SysError

            const Zstring itemNameNew = getItemName(targetPath);
            std::string itemIdSrc;
            GdriveItemDetails itemDetailsSrc;
            /*const GdrivePersistentSessions::AsyncAccessInfo aaiSrc =*/ accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                std::tie(itemIdSrc, itemDetailsSrc) = fileState.getFileAttributes(sourcePath, true /*followLeafShortcut*/); //throw SysError

                assert(itemDetailsSrc.type == GdriveItemType::file); //Google Drive *should* fail trying to copy folder: "This file cannot be copied by the user."
                if (itemDetailsSrc.type != GdriveItemType::file)     //=> don't trust + improve error message
                    throw SysError(replaceCpy<std::wstring>(L"%x is not a file.", L"%x", fmtPath(getItemName(sourcePath))));
            });

            std::string parentIdTrg;
            const GdrivePersistentSessions::AsyncAccessInfo aaiTrg = accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                const GdriveFileState::PathStatus psTo = fileState.getPathStatus(targetPath.afsPath, false /*followLeafShortcut*/); //throw SysError
                if (psTo.relPath.empty())
                    throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(itemNameNew)));

                if (psTo.relPath.size() > 1) //parent folder missing
                    throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(psTo.relPath.front())));

                parentIdTrg = psTo.existingItemId;
            });

            //already existing: creates duplicate
            const std::string fileIdTrg = gdriveCopyFile(itemIdSrc, parentIdTrg, itemNameNew, itemDetailsSrc.modTime, aaiTrg.access); //throw SysError

            //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
            accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                const GdriveItem newFileItem
                {
                    .itemId = fileIdTrg,
                    .details{
                        .itemName = itemNameNew,
                        .fileSize = itemDetailsSrc.fileSize,
                        .modTime = itemDetailsSrc.modTime,
                        .type = GdriveItemType::file,
                        .owner = fileState.all().getSharedDriveName().empty() ? FileOwner::me : FileOwner::none,
                        .parentIds{parentIdTrg},
                    }
                };
                fileState.all().notifyItemCreated(aaiTrg.stateDelta, newFileItem);
            });

            return
            {
                .fileSize = itemDetailsSrc.fileSize,
                .modTime  = itemDetailsSrc.modTime,
                .sourceFilePrint = getGdriveFilePrint(itemIdSrc),
                .targetFilePrint = getGdriveFilePrint(fileIdTrg),
                /*.errorModTime = */
            };
        }
        catch (const SysError& e)
        {
            throw FileError(replaceCpy(replaceCpy(_("Cannot copy file %x to %y."),
                                                  L"%x", L'\n' + fmtPath(getDisplayPath(sourcePath))),
                                       L"%y",  L'\n' + fmtPath(AFS::getDisplayPath(targetPath))), e.toString());
        }
    }

    //symlink handling: follow
    //already existing: fail
    void copyNewFolderForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError
    {
        //already existing: 1. fails or 2. creates duplicate (unlikely)
        AFS::createFolderPlain(targetPath); //throw FileError

        if (copyFilePermissions)
            throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device."));
    }

    //already existing: fail
    void copySymlinkForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError
    {
        try
        {
            std::string targetId;
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                const GdriveItemDetails& itemDetails = fileState.getFileAttributes(sourcePath, false /*followLeafShortcut*/).second; //throw SysError
                if (itemDetails.type != GdriveItemType::shortcut)
                    throw SysError(L"Not a Google Drive Shortcut.");

                targetId = itemDetails.targetId;
            });

            const GdriveFileSystem& fsTarget = static_cast<const GdriveFileSystem&>(targetPath.afsDevice.ref());

            //avoid duplicate Google Drive item creation by multiple threads
            PathAccessLock pal(fsTarget.getGdriveRawPath(targetPath.afsPath), PathBlockType::otherWait); //throw SysError

            const Zstring shortcutName = getItemName(targetPath.afsPath);
            std::string parentId;
            const GdrivePersistentSessions::AsyncAccessInfo aaiTrg = accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                const GdriveFileState::PathStatus& ps = fileState.getPathStatus(targetPath.afsPath, false /*followLeafShortcut*/); //throw SysError
                if (ps.relPath.empty())
                    throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(shortcutName)));

                if (ps.relPath.size() > 1) //parent folder missing
                    throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front())));

                parentId = ps.existingItemId;
            });

            //already existing: creates duplicate
            const std::string shortcutIdNew = gdriveCreateShortcutPlain(shortcutName, parentId, targetId, aaiTrg.access); //throw SysError

            //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
            accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                fileState.all().notifyShortcutCreated(aaiTrg.stateDelta, shortcutIdNew, shortcutName, parentId, targetId);
            });
        }
        catch (const SysError& e)
        {
            throw FileError(replaceCpy(replaceCpy(_("Cannot copy symbolic link %x to %y."),
                                                  L"%x", L'\n' + fmtPath(getDisplayPath(sourcePath))),
                                       L"%y",  L'\n' + fmtPath(AFS::getDisplayPath(targetPath))), e.toString());
        }
    }

    //already existing: undefined behavior! (e.g. fail/overwrite)
    //=> actual behavior: 1. fails or 2. creates duplicate (unlikely)
    void moveAndRenameItemForSameAfsType(const AfsPath& pathFrom, const AbstractPath& pathTo) const override //throw FileError, ErrorMoveUnsupported
    {
        if (compareDeviceSameAfsType(pathTo.afsDevice.ref()) != std::weak_ordering::equivalent)
            throw ErrorMoveUnsupported(generateMoveErrorMsg(pathFrom, pathTo), _("Operation not supported between different devices."));
        //note: moving files within account works, e.g. between My Drive <-> shared drives
        //      BUT: not supported by our model with separate GdriveFileStates; e.g. how to handle complexity of a moved folder (tree)?
        try
        {
            const GdriveFileSystem& fsTarget = static_cast<const GdriveFileSystem&>(pathTo.afsDevice.ref());

            //avoid duplicate Google Drive item creation by multiple threads
            PathAccessLock pal(fsTarget.getGdriveRawPath(pathTo.afsPath), PathBlockType::otherWait); //throw SysError

            const Zstring itemNameOld = getItemName(pathFrom);
            const Zstring itemNameNew = getItemName(pathTo);
            const std::optional<AfsPath> parentPathFrom = getParentPath(pathFrom);
            const std::optional<AfsPath> parentPathTo   = getParentPath(pathTo.afsPath);
            if (!parentPathFrom) throw SysError(L"Source is device root");
            if (!parentPathTo  ) throw SysError(L"Target is device root");

            std::string itemId;
            GdriveItemDetails itemDetails;
            std::string parentIdFrom;
            std::string parentIdTo;
            const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                std::tie(itemId, itemDetails) = fileState.getFileAttributes(pathFrom, false /*followLeafShortcut*/); //throw SysError

                parentIdFrom = fileState.getItemId(*parentPathFrom, true /*followLeafShortcut*/); //throw SysError

                const GdriveFileState::PathStatus psTo = fileState.getPathStatus(pathTo.afsPath, false /*followLeafShortcut*/); //throw SysError

                //e.g. changing file name case only => this is not an "already exists" situation!
                //also: hardlink referenced by two different paths, the source one will be unlinked
                if (psTo.relPath.empty() && psTo.existingItemId == itemId)
                    parentIdTo = fileState.getItemId(*parentPathTo, true /*followLeafShortcut*/); //throw SysError
                else
                {
                    if (psTo.relPath.empty())
                        throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(itemNameNew)));

                    if (psTo.relPath.size() > 1) //parent folder missing
                        throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(psTo.relPath.front())));

                    parentIdTo = psTo.existingItemId;
                }
            });

            if (parentIdFrom == parentIdTo && itemNameOld == itemNameNew)
                return; //nothing to do

            //already existing: creates duplicate
            gdriveMoveAndRenameItem(itemId, parentIdFrom, parentIdTo, itemNameNew, itemDetails.modTime, aai.access); //throw SysError

            //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL)
            accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError
            {
                fileState.all().notifyMoveAndRename(aai.stateDelta, itemId, parentIdFrom, parentIdTo, itemNameNew);
            });
        }
        catch (const SysError& e) { throw FileError(generateMoveErrorMsg(pathFrom, pathTo), e.toString()); }
    }

    bool supportsPermissions(const AfsPath& folderPath) const override { return false; } //throw FileError

    //----------------------------------------------------------------------------------------------------------------
    FileIconHolder getFileIcon      (const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value
    ImageHolder    getThumbnailImage(const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value

    void authenticateAccess(const RequestPasswordFun& requestPassword /*throw X*/) const override //throw FileError, (X)
    {
        try
        {
            const std::shared_ptr<GdrivePersistentSessions> gps = globalGdriveSessions.get();
            if (!gps)
                throw SysError(formatSystemError("GdriveFileSystem::authenticateAccess", L"", L"Function call not allowed during init/shutdown."));

            for (const std::string& accountEmail : gps->listAccounts()) //throw SysError
                if (equalAsciiNoCase(accountEmail, gdriveLogin_.email))
                    return;

            const bool allowUserInteraction = static_cast<bool>(requestPassword);
            if (allowUserInteraction)
                gps->addUserSession(gdriveLogin_.email /*gdriveLoginHint*/, nullptr /*updateGui*/, gdriveLogin_.timeoutSec); //throw SysError
            //error messages will be lost if user cancels in dir_exist_async.h! However:
            //The most-likely-to-fail parts (web access) are reported by gdriveAuthorizeAccess() via the browser!
            else
                throw SysError(replaceCpy(_("Please add a connection to user account %x first."), L"%x", utfTo<std::wstring>(gdriveLogin_.email)));
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(getDisplayPath(AfsPath()))), e.toString()); }
    }

    bool hasNativeTransactionalCopy() const override { return true; }
    //----------------------------------------------------------------------------------------------------------------

    int64_t getFreeDiskSpace(const AfsPath& folderPath) const override //throw FileError, returns < 0 if not available
    {
        bool onMyDrive = false;
        try
        {
            const GdriveAccess& access = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState)
            { onMyDrive = fileState.all().getSharedDriveName().empty(); }).access; //throw SysError

            if (onMyDrive)
                return gdriveGetMyDriveFreeSpace(access); //throw SysError
            else
                return -1;
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine free disk space for %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); }
    }

    std::unique_ptr<RecycleSession> createRecyclerSession(const AfsPath& folderPath) const override //throw FileError, (RecycleBinUnavailable)
    {
        struct RecycleSessionGdrive : public RecycleSession
        {
            //fails if item is not existing
            void moveToRecycleBin(const AbstractPath& itemPath, const Zstring& logicalRelPath) override { AFS::moveToRecycleBin(itemPath); } //throw FileError, (RecycleBinUnavailable)
            void tryCleanup(const std::function<void(const std::wstring& displayPath)>& notifyDeletionStatus) override {}; //throw FileError
        };

        return std::make_unique<RecycleSessionGdrive>();
    }

    //fails if item is not existing
    void moveToRecycleBin(const AfsPath& itemPath) const override //throw FileError, (RecycleBinUnavailable)
    {
        try
        {
            removeItemPlainImpl(itemPath, std::nullopt /*expectedType*/, false /*permanent*/, true /*failIfNotExist*/); //throw SysError
        }
        catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to move %x to the recycle bin."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); }
    }

    const GdriveLogin gdriveLogin_;
};
//===========================================================================================================================

//expects "clean" input data
Zstring concatenateGdriveFolderPathPhrase(const GdrivePath& gdrivePath) //noexcept
{
    Zstring emailAndDrive = utfTo<Zstring>(gdrivePath.gdriveLogin.email);
    if (!gdrivePath.gdriveLogin.locationName.empty())
        emailAndDrive += Zstr(':') + gdrivePath.gdriveLogin.locationName;

    Zstring options;
    if (gdrivePath.gdriveLogin.timeoutSec != GdriveLogin().timeoutSec)
        options += Zstr("|timeout=") + numberTo<Zstring>(gdrivePath.gdriveLogin.timeoutSec);

    Zstring itemPath;
    if (!gdrivePath.itemPath.value.empty())
        itemPath += FILE_NAME_SEPARATOR + gdrivePath.itemPath.value;

    if (endsWith(itemPath, Zstr(' ')) && options.empty()) //path phrase concept must survive trimming!
        itemPath += FILE_NAME_SEPARATOR;

    return Zstring(gdrivePrefix) + FILE_NAME_SEPARATOR + emailAndDrive + itemPath + options;
}
}


void fff::gdriveInit(const Zstring& configDirPath, const Zstring& caCertFilePath)
{
    assert(!globalHttpSessionManager.get());
    globalHttpSessionManager.set(std::make_unique<HttpSessionManager>(caCertFilePath));

    assert(!globalGdriveSessions.get());
    globalGdriveSessions.set(std::make_unique<GdrivePersistentSessions>(configDirPath));
}


void fff::gdriveTeardown()
{
    try //don't use ~GdrivePersistentSessions() to save! Might never happen, e.g. detached thread waiting for Google Drive authentication; terminated on exit!
    {
        if (const std::shared_ptr<GdrivePersistentSessions> gps = globalGdriveSessions.get())
            gps->saveActiveSessions(); //throw FileError
    }
    catch (const FileError& e) { logExtraError(e.toString()); }

    assert(globalGdriveSessions.get());
    globalGdriveSessions.set(nullptr);

    assert(globalHttpSessionManager.get());
    globalHttpSessionManager.set(nullptr);
}


std::string fff::gdriveAddUser(const std::function<void()>& updateGui /*throw X*/, int timeoutSec) //throw FileError, X
{
    try
    {
        if (const std::shared_ptr<GdrivePersistentSessions> gps = globalGdriveSessions.get())
            return gps->addUserSession("" /*gdriveLoginHint*/, updateGui, timeoutSec); //throw SysError, X

        throw SysError(formatSystemError("gdriveAddUser", L"", L"Function call not allowed during init/shutdown."));
    }
    catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", L"Google Drive"), e.toString()); }
}


void fff::gdriveRemoveUser(const std::string& accountEmail, int timeoutSec) //throw FileError
{
    try
    {
        if (const std::shared_ptr<GdrivePersistentSessions> gps = globalGdriveSessions.get())
            return gps->removeUserSession(accountEmail, timeoutSec); //throw SysError

        throw SysError(formatSystemError("gdriveRemoveUser", L"", L"Function call not allowed during init/shutdown."));
    }
    catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to disconnect from %x."), L"%x", fmtPath(getGdriveDisplayPath({{accountEmail, Zstr("")}, AfsPath()}))), e.toString()); }
}


std::vector<std::string /*account email*/> fff::gdriveListAccounts() //throw FileError
{
    try
    {
        if (const std::shared_ptr<GdrivePersistentSessions> gps = globalGdriveSessions.get())
            return gps->listAccounts(); //throw SysError

        throw SysError(formatSystemError("gdriveListAccounts", L"", L"Function call not allowed during init/shutdown."));
    }
    catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", L"Google Drive"), e.toString()); }
}


std::vector<Zstring /*locationName*/> fff::gdriveListLocations(const std::string& accountEmail, int timeoutSec) //throw FileError
{
    try
    {
        if (const std::shared_ptr<GdrivePersistentSessions> gps = globalGdriveSessions.get())
            return gps->listLocations(accountEmail, timeoutSec); //throw SysError

        throw SysError(formatSystemError("gdriveListLocations", L"", L"Function call not allowed during init/shutdown."));
    }
    catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(getGdriveDisplayPath({{accountEmail, Zstr("")}, AfsPath()}))), e.toString()); }
}


AfsDevice fff::condenseToGdriveDevice(const GdriveLogin& login) //noexcept
{
    //clean up input:
    GdriveLogin loginTmp = login;
    trim(loginTmp.email);

    loginTmp.timeoutSec = std::max(1, loginTmp.timeoutSec);

    return makeSharedRef<GdriveFileSystem>(loginTmp);
}


GdriveLogin fff::extractGdriveLogin(const AfsDevice& afsDevice) //noexcept
{
    if (const auto gdriveDevice = dynamic_cast<const GdriveFileSystem*>(&afsDevice.ref()))
        return gdriveDevice ->getGdriveLogin();

    assert(false);
    return {};
}


Zstring fff::getGoogleDriveFolderUrl(const AbstractPath& folderPath) //throw FileError
{
    if (const auto gdriveDevice = dynamic_cast<const GdriveFileSystem*>(&folderPath.afsDevice.ref()))
        return gdriveDevice->getFolderUrl(folderPath.afsPath); //throw FileError
    //assert(false);
    return {};
}


bool fff::acceptsItemPathPhraseGdrive(const Zstring& itemPathPhrase) //noexcept
{
    Zstring path = expandMacros(itemPathPhrase); //expand before trimming!
    trim(path);
    return startsWithAsciiNoCase(path, gdrivePrefix);
}


/* syntax: gdrive:\<email>[:<shared drive>]\<relative-path>[|option_name=value]

    e.g.: gdrive:\john@gmail.com\folder\file.txt
          gdrive:\john@gmail.com:location\folder\file.txt|option_name=value        */
AbstractPath fff::createItemPathGdrive(const Zstring& itemPathPhrase) //noexcept
{
    Zstring pathPhrase = expandMacros(itemPathPhrase); //expand before trimming!
    trim(pathPhrase);

    if (startsWithAsciiNoCase(pathPhrase, gdrivePrefix))
        pathPhrase = pathPhrase.c_str() + strLength(gdrivePrefix);
    trim(pathPhrase, TrimSide::left, [](Zchar c) { return c == Zstr('/') || c == Zstr('\\'); });

    const ZstringView fullPath = beforeFirst<ZstringView>(pathPhrase, Zstr('|'), IfNotFoundReturn::all);
    const ZstringView options  =  afterFirst<ZstringView>(pathPhrase, Zstr('|'), IfNotFoundReturn::none);

    auto it = std::find_if(fullPath.begin(), fullPath.end(), [](Zchar c) { return c == '/' || c == '\\'; });
    const ZstringView emailAndDrive(fullPath.begin(), it);
    const AfsPath itemPath = sanitizeDeviceRelativePath({it, fullPath.end()});

    GdriveLogin login
    {
        .email        = utfTo<std::string>(beforeFirst(emailAndDrive, Zstr(':'), IfNotFoundReturn::all)),
        .locationName =            Zstring(afterFirst (emailAndDrive, Zstr(':'), IfNotFoundReturn::none)),
    };

    split(options, Zstr('|'), [&](ZstringView optPhrase)
    {
        optPhrase = trimCpy(optPhrase);
        if (!optPhrase.empty())
        {
            if (startsWith(optPhrase, Zstr("timeout=")))
                login.timeoutSec = stringTo<int>(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none));
            else
                assert(false);
        }
    });
    return AbstractPath(makeSharedRef<GdriveFileSystem>(login), itemPath);
}
