15.1.36. tablet_qt/core/networkmanager.cpp

/*
    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
*/

// #define DEBUG_NETWORK_REQUESTS
// #define DEBUG_NETWORK_REPLIES_RAW
// #define DEBUG_NETWORK_REPLIES_DICT
// #define DEBUG_ACTIVITY
// #define DEBUG_JSON
#define USE_BACKGROUND_DATABASE

#include "networkmanager.h"
#include <functional>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QObject>
#include <QSqlQuery>
#include <QtGlobal>
#include <QtNetwork/QNetworkAccessManager>
#include <QtNetwork/QNetworkRequest>
#include <QtNetwork/QNetworkReply>
#include <QtNetwork/QSslConfiguration>
#include <QUrl>
#include <QUrlQuery>
#include "common/preprocessor_aid.h"  // IWYU pragma: keep
#include "common/varconst.h"
#include "core/camcopsapp.h"
#include "db/databasemanager.h"
#include "db/dbfunc.h"
#include "db/dbnestabletransaction.h"
#include "dbobjects/idnumdescription.h"
#include "dbobjects/patientidnum.h"
#include "dialogs/passwordentrydialog.h"
#include "dialogs/logbox.h"
#include "dbobjects/blob.h"
#include "lib/containers.h"
#include "lib/convert.h"
#include "lib/datetime.h"
#include "lib/idpolicy.h"
#include "lib/uifunc.h"
#include "tasklib/task.h"
#include "tasklib/taskfactory.h"
#include "tasklib/taskschedule.h"
#include "tasklib/taskscheduleitem.h"
#include "version/camcopsversion.h"

using dbfunc::delimit;

// Keys used by server or client (S server, C client, B bidirectional)
// SEE ALSO patient.cpp, for the JSON ones.
const QString KEY_CAMCOPS_VERSION("camcops_version");  // C->S
const QString KEY_DATABASE_TITLE("databaseTitle");  // S->C
const QString KEY_DATEVALUES("datevalues");  // C->S
const QString KEY_DBDATA("dbdata");  // C->S, new in v2.3.0
const QString KEY_DEVICE("device");  // C->S
const QString KEY_DEVICE_FRIENDLY_NAME("devicefriendlyname");  // C->S
const QString KEY_ERROR("error");  // S->C
const QString KEY_FIELDS("fields");    // B; fieldnames
const QString KEY_FINALIZING("finalizing");  // C->S, in JSON, v2.3.0
const QString KEY_ID_POLICY_UPLOAD("idPolicyUpload");  // S->C
const QString KEY_ID_POLICY_FINALIZE("idPolicyFinalize");  // S->C
const QString KEY_IP_USE_INFO("ip_use_info");  // S->C, new in v2.4.0
const QString KEY_IP_USE_COMMERCIAL("ip_use_commercial");  // S->C, new in v2.4.0
const QString KEY_IP_USE_CLINICAL("ip_use_clinical");  // S->C, new in v2.4.0
const QString KEY_IP_USE_EDUCATIONAL("ip_use_educational");  // S->C, new in v2.4.0
const QString KEY_IP_USE_RESEARCH("ip_use_research");  // S->C, new in v2.4.0
const QString KEY_MOVE_OFF_TABLET_VALUES("move_off_tablet_values");  // C->S, v2.3.0
const QString KEY_NFIELDS("nfields");  // B
const QString KEY_NRECORDS("nrecords");  // B
const QString KEY_OPERATION("operation");  // C->S
const QString KEY_PASSWORD("password");  // C->S
const QString KEY_PATIENT_INFO("patient_info");  // C->S, new in v2.3.0
const QString KEY_PATIENT_PROQUINT("patient_proquint"); // C->S, new in v2.4.0
const QString KEY_PKNAME("pkname");  // C->S
const QString KEY_PKNAMEINFO("pknameinfo");  // C->S
const QString KEY_PKVALUES("pkvalues");  // C->S
const QString KEY_RESULT("result");  // S->C
const QString KEY_SERVER_CAMCOPS_VERSION("serverCamcopsVersion");  // S->C
const QString KEY_SESSION_ID("session_id");  // B
const QString KEY_SESSION_TOKEN("session_token");  // B
const QString KEY_SUCCESS("success");  // S->C
const QString KEY_TABLE("table");  // C->S
const QString KEY_TABLES("tables");  // C->S
const QString KEY_TASK_SCHEDULES("task_schedules");  // S->C, new in v2.4.0
const QString KEY_TASK_SCHEDULE_ITEMS("task_schedule_items");
const QString KEY_USER("user");  // C->S
const QString KEY_VALUES("values");  // C->S
const QString KEYPREFIX_ID_DESCRIPTION("idDescription");  // S->C
const QString KEYSPEC_ID_DESCRIPTION(KEYPREFIX_ID_DESCRIPTION + "%1");  // S->C
const QString KEYPREFIX_ID_SHORT_DESCRIPTION("idShortDescription");  // S->C
const QString KEYSPEC_ID_SHORT_DESCRIPTION(KEYPREFIX_ID_SHORT_DESCRIPTION + "%1");  // S->C
const QString KEYPREFIX_ID_VALIDATION_METHOD("idValidationMethod");  // S->C, new in v2.2.8
const QString KEYSPEC_ID_VALIDATION_METHOD(KEYPREFIX_ID_VALIDATION_METHOD + "%1");  // S->C, new in v2.2.8
const QString KEYSPEC_RECORD("record%1");  // B

// Operations for server:
const QString OP_CHECK_DEVICE_REGISTERED("check_device_registered");
const QString OP_CHECK_UPLOAD_USER_DEVICE("check_upload_user_and_device");
const QString OP_DELETE_WHERE_KEY_NOT("delete_where_key_not");
const QString OP_END_UPLOAD("end_upload");
const QString OP_GET_EXTRA_STRINGS("get_extra_strings");
const QString OP_GET_ID_INFO("get_id_info");
const QString OP_GET_ALLOWED_TABLES("get_allowed_tables");  // v2.2.0
const QString OP_GET_TASK_SCHEDULES("get_task_schedules");  // v2.4.0
const QString OP_REGISTER("register");
const QString OP_REGISTER_PATIENT("register_patient");  // v2.4.0
const QString OP_START_PRESERVATION("start_preservation");
const QString OP_START_UPLOAD("start_upload");
const QString OP_UPLOAD_ENTIRE_DATABASE("upload_entire_database");  // v2.3.0
const QString OP_UPLOAD_TABLE("upload_table");
const QString OP_UPLOAD_RECORD("upload_record");
const QString OP_UPLOAD_EMPTY_TABLES("upload_empty_tables");
const QString OP_VALIDATE_PATIENTS("validate_patients");  // v2.3.0
const QString OP_WHICH_KEYS_TO_SEND("which_keys_to_send");

const Version MIN_SERVER_VERSION_FOR_VALIDATE_PATIENTS("2.3.0");
const Version MIN_SERVER_VERSION_FOR_ONE_STEP_UPLOAD("2.3.0");

const QString ENCODE_TRUE("1");
const QString ENCODE_FALSE("0");


// ============================================================================
// NetworkManager
// ============================================================================

// - MAIN COMMUNICATION METHOD:
//   serverPost(dict, &callbackfunction);

// CALLBACK LIFETIME SAFETY in this class:
// - There is only one NetworkManager in the whole app, owned by the
//   CamcopsApp.
// - The QNetworkAccessManager lives as long as the NetworkManager.
// - Therefore, any callbacks to this class are lifetime-safe and can use
//   std::bind.
// - HOWEVER, callbacks to something transient may not be (e.g. another object
//   sets up a callback to itself but with std::bind rather than to a QObject;
//   network function is called; object is deleted; network replies; boom).
//   So BEWARE there.
// - Since we have a single set of principal network access functions relating
//   to upload/server interaction, the simplest thing is to build them all into
//   this class, and then we don't have to worry about lifetime problems.


NetworkManager::NetworkManager(CamcopsApp& app,
                               DatabaseManager& db,
                               TaskFactoryPtr p_task_factory,
                               QWidget* parent) :
    m_app(app),
    m_db(db),
    m_p_task_factory(p_task_factory),
    m_parent(parent),
    m_offer_cancel(true),
    m_silent(parent == nullptr),
    m_logbox(nullptr),
    m_mgr(new QNetworkAccessManager(this)),  // will be autodeleted by QObject
    m_upload_method(UploadMethod::Copy),
    m_upload_next_stage(NextUploadStage::Invalid),
    m_upload_current_record_index(0),
    m_recordwise_prune_req_sent(false),
    m_recordwise_pks_pruned(false),
    m_upload_n_records(0),
    m_register_next_stage(NextRegisterStage::Invalid)
{
}


NetworkManager::~NetworkManager()
{
    deleteLogBox();
}


// ============================================================================
// User interface
// ============================================================================

void NetworkManager::ensureLogBox() const
{
    if (!m_logbox) {
#ifdef DEBUG_ACTIVITY
        qDebug() << Q_FUNC_INFO << "creating logbox";
#endif
        m_logbox = new LogBox(m_parent, m_title, m_offer_cancel);
        m_logbox->setStyleSheet(
                    m_app.getSubstitutedCss(uiconst::CSS_CAMCOPS_MAIN));
        connect(m_logbox.data(), &LogBox::accepted,
                this, &NetworkManager::logboxFinished,
                Qt::UniqueConnection);
        connect(m_logbox.data(), &LogBox::rejected,
                this, &NetworkManager::logboxCancelled,
                Qt::UniqueConnection);
        m_logbox->open();
    }
}


void NetworkManager::deleteLogBox()
{
    if (!m_logbox) {
        return;
    }
    m_logbox->deleteLater();
    m_logbox = nullptr;
}


void NetworkManager::enableLogging()
{
    m_silent = false;
}


void NetworkManager::disableLogging()
{
    m_silent = true;
}


bool NetworkManager::isLogging() const
{
    return !m_silent;
}


void NetworkManager::setTitle(const QString& title)
{
    m_title = title;
    if (m_logbox) {
        m_logbox->setWindowTitle(title);
    }
}


void NetworkManager::statusMessage(const QString& msg) const
{
    qInfo().noquote() << "Network:" << msg;
    if (m_silent) {
#ifdef DEBUG_ACTIVITY
        qDebug() << Q_FUNC_INFO << "silent";
#endif
        return;
    }
    ensureLogBox();
    m_logbox->statusMessage(
                QString("%1: %2").arg(datetime::nowTimestamp(), msg));
}


void NetworkManager::htmlStatusMessage(const QString& html) const
{
    if (m_silent) {
#ifdef DEBUG_ACTIVITY
        qDebug() << Q_FUNC_INFO << "silent";
#endif
        return;
    }
    ensureLogBox();
    m_logbox->statusMessage(html, true);
}


void NetworkManager::logboxCancelled()
{
    // User has hit cancel
#ifdef DEBUG_ACTIVITY
    qDebug() << Q_FUNC_INFO;
#endif
    cleanup();
    deleteLogBox();
    emit cancelled(ErrorCode::NoError, QString());
}


void NetworkManager::logboxFinished()
{
    // User has acknowledged finish
#ifdef DEBUG_ACTIVITY
    qDebug() << Q_FUNC_INFO;
#endif
    cleanup();
    deleteLogBox();
    emit finished();
}


// ============================================================================
// Basic connection management
// ============================================================================

void NetworkManager::disconnectManager()
{
    m_mgr->disconnect();
}


QNetworkRequest NetworkManager::createRequest(const QUrl& url,
                                              const bool offer_cancel,
                                              const bool ssl,
                                              const bool ignore_ssl_errors,
                                              QSsl::SslProtocol ssl_protocol)
{
    // Clear any previous callbacks
    disconnectManager();

    m_offer_cancel = offer_cancel;

    QNetworkRequest request;

#ifdef DEBUG_NETWORK_REQUESTS
    qDebug().nospace().noquote()
            << Q_FUNC_INFO
            << ": offer_cancel=" << offer_cancel
            << ", ssl=" << ssl
            << ", ignore_ssl_errors=" << ignore_ssl_errors
            << ", ssl_protocol=" << convert::describeSslProtocol(ssl_protocol);
#endif

    if (ssl) {
        QSslConfiguration config = QSslConfiguration::defaultConfiguration();
        config.setProtocol(ssl_protocol);
        // NB the OpenSSL version must also support the protocol (e.g. TLSv2);
        // ... see also https://bugreports.qt.io/browse/QTBUG-31230
        // ... but TLSv2 working fine with manually compiled OpenSSL
        request.setSslConfiguration(config);
        if (ignore_ssl_errors) {
            QObject::connect(
                        m_mgr, &QNetworkAccessManager::sslErrors,
                        std::bind(&NetworkManager::sslIgnoringErrorHandler,
                                  this, std::placeholders::_1,
                                  std::placeholders::_2));

        }
    }

    // URL
    request.setUrl(url);

    return request;
}


QUrl NetworkManager::serverUrl(bool& success) const
{
    QUrl url;
#ifdef DEBUG_OFFER_HTTP_TO_SERVER
    url.setScheme(m_app.varBool(varconst::DEBUG_USE_HTTPS_TO_SERVER) ? "https"
                                                                     : "http");
#else
    url.setScheme("https");
#endif
    url.setHost(m_app.varString(varconst::SERVER_ADDRESS));
    url.setPort(m_app.varInt(varconst::SERVER_PORT));
    QString path = m_app.varString(varconst::SERVER_PATH);
    if (!path.startsWith('/')) {
        path = "/" + path;
    }
    url.setPath(path);
    success = !url.host().isEmpty();
    return url;
}


QString NetworkManager::serverUrlDisplayString() const
{
    bool success = false;  // we don't care about the result
    QUrl url = serverUrl(success);
    const QString str = url.toDisplayString();
    return str;
}


QNetworkRequest NetworkManager::createServerRequest(bool& success)
{
    QSsl::SslProtocol ssl_protocol = convert::sslProtocolFromDescription(
                m_app.varString(varconst::SSL_PROTOCOL));
    return createRequest(
                serverUrl(success),
                true,  // always offer cancel
                true,  // always use SSL
                !m_app.validateSslCertificates(),  // ignore SSL errors?
                ssl_protocol);
}


void NetworkManager::serverPost(Dict dict, ReplyFuncPtr reply_func,
                                const bool include_user)
{
    // Request (URL, SSL, etc.).
    bool success = true;
    QNetworkRequest request = createServerRequest(success);
    if (!success) {
        statusMessage(tr("Server host details not specified; see Settings"));
        fail();
        return;
    }

    // Complete the dictionary
    // dict[KEY_CAMCOPS_VERSION] = camcopsversion::CAMCOPS_VERSION.toFloatString();  // outdated
    dict[KEY_CAMCOPS_VERSION] = camcopsversion::CAMCOPS_CLIENT_VERSION.toString();  // server copes as of v2.0.0
    dict[KEY_DEVICE] = m_app.deviceId();
    if (include_user) {
        QString user = m_app.varString(varconst::SERVER_USERNAME);
        if (user.isEmpty()) {
            statusMessage(tr("User information required but you have not yet "
                             "specified it; see Settings"));
            fail();
            return;
        }
        dict[KEY_USER] = user;

        if (!ensurePasswordKnown()) {
            statusMessage(tr("Password not specified"));
            fail();
            return;
        }
        dict[KEY_PASSWORD] = m_tmp_password;
    }
    if (!m_tmp_session_id.isEmpty() && !m_tmp_session_token.isEmpty()) {
        dict[KEY_SESSION_ID] = m_tmp_session_id;
        dict[KEY_SESSION_TOKEN] = m_tmp_session_token;
    }

    // Clean up the reply storage objects
    m_reply_data.clear();
    m_reply_dict.clear();

    // Connect up the reply signals
    QObject::connect(m_mgr, &QNetworkAccessManager::finished,
                     this, reply_func);

    // Send the request
    const QUrlQuery postdata = convert::getPostDataAsUrlQuery(dict);
    request.setHeader(QNetworkRequest::ContentTypeHeader,
                      "application/x-www-form-urlencoded");
    const QByteArray final_data = postdata.toString(QUrl::FullyEncoded).toUtf8();
    // See discussion of encoding in Convert::getPostDataAsUrlQuery
#ifdef DEBUG_NETWORK_REQUESTS
    qDebug() << "Request to server: " << final_data;
#endif
    statusMessage(tr("... sending ") + sizeBytes(final_data.length()));
    m_mgr->post(request, final_data);
}


bool NetworkManager::processServerReply(QNetworkReply* reply)
{
    if (!reply) {
        statusMessage("Bug: null pointer to processServerReply");
        fail();
        return false;
    }
    reply->deleteLater();
    if (reply->error() != QNetworkReply::NoError) {
        statusMessage(tr("Network failure: ") + reply->errorString());
        fail(convertQtNetworkCode(reply->error()), reply->errorString());
        return false;
    }
    m_reply_data = reply->readAll();  // can probably do this only once
    statusMessage(tr("... received ") + sizeBytes(m_reply_data.length()));
#ifdef DEBUG_NETWORK_REPLIES_RAW
    qDebug() << "Network reply (raw): " << m_reply_data;
#endif
    m_reply_dict = convert::getReplyDict(m_reply_data);
#ifdef DEBUG_NETWORK_REPLIES_DICT
    qInfo() << "Network reply (dictionary): " << m_reply_dict;
#endif
    if (!replyFormatCorrect()) {
        statusMessage(tr(
            "Reply is not from CamCOPS API. Are your server settings "
            "misconfigured? Reply is below."));
        htmlStatusMessage(convert::getReplyString(m_reply_data));
        fail(ErrorCode::IncorrectReplyFormat,
             tr("Reply is not from CamCOPS API. Are your server settings "
                "misconfigured?"));
        return false;
    }
    m_tmp_session_id = m_reply_dict[KEY_SESSION_ID];
    m_tmp_session_token = m_reply_dict[KEY_SESSION_TOKEN];
    if (replyReportsSuccess()) {
        return true;
    }
    // If the server's reporting success=0, it should provide an
    // error too:
    statusMessage(tr("Server reported an error: ") +
                  m_reply_dict[KEY_ERROR]);
    fail(ErrorCode::ServerError, QString(m_reply_dict[KEY_ERROR]));
    return false;
}


NetworkManager::ErrorCode NetworkManager::convertQtNetworkCode(
    const QNetworkReply::NetworkError error_code)
{
    Q_UNUSED(error_code)

    // There doesn't seem to be a way to correctly identify the
    // source of the problem. So for now just return the same error code and
    // in the app produce a list of things for the user to check.
    return NetworkManager::GenericNetworkError;
}


QString NetworkManager::sizeBytes(const qint64 size) const
{
    return convert::prettySize(size, true, false, true, "bytes");
}


bool NetworkManager::replyFormatCorrect() const
{
    // Characteristics of a reply that has come from the CamCOPS API, not
    // (for example) a "page not found" error from Apache:
    return m_reply_dict.contains(KEY_SUCCESS) &&
           m_reply_dict.contains(KEY_SESSION_ID) &&
           m_reply_dict.contains(KEY_SESSION_TOKEN);
}


bool NetworkManager::replyReportsSuccess() const
{
    return m_reply_dict[KEY_SUCCESS].toInt();
}


RecordList NetworkManager::getRecordList() const
{
    RecordList recordlist;

    if (!m_reply_dict.contains(KEY_NRECORDS) ||
            !m_reply_dict.contains(KEY_NFIELDS) ||
            !m_reply_dict.contains(KEY_FIELDS)) {
        statusMessage(tr("ERROR: missing field or record information"));
        return RecordList();
    }

    const int nrecords = m_reply_dict[KEY_NRECORDS].toInt();
    if (nrecords <= 0) {
        statusMessage(tr("ERROR: No records"));
        return RecordList();
    }

    int nfields = m_reply_dict[KEY_NFIELDS].toInt();
    const QString fields = m_reply_dict[KEY_FIELDS];
    const QStringList fieldnames = fields.split(',');
    if (nfields != fieldnames.length()) {
        statusMessage(
            tr("WARNING: nfields (%1) doesn't match number of actual "
               "fields (%2); field list is: %3")
                    .arg(nfields)
                    .arg(fieldnames.length())
                    .arg(fields));
        nfields = fieldnames.length();
    }
    if (nfields <= 0) {
        statusMessage(tr("ERROR: No fields"));
        return RecordList();
    }
    for (int r = 0; r < nrecords; ++r) {
        QMap<QString, QVariant> record;
        const QString recordname = KEYSPEC_RECORD.arg(r);
        if (!m_reply_dict.contains(recordname)) {
            statusMessage(tr("ERROR: missing record: ") + recordname);
            return RecordList();
        }
        const QString valuelist = m_reply_dict[recordname];
        const QVector<QVariant> values = convert::csvSqlLiteralsToValues(valuelist);
        if (values.length() != nfields) {
            statusMessage(tr("ERROR: #values not equal to #fields"));
            return RecordList();
        }
        for (int f = 0; f < nfields; ++f) {
            record[fieldnames[f]] = values[f];
        }
        recordlist.push_back(record);
    }
    return recordlist;
}


bool NetworkManager::ensurePasswordKnown()
{
    if (!m_tmp_password.isEmpty()) {
        // We already have it, from whatever source
        return true;
    }
    if (m_app.storingServerPassword()) {
        m_tmp_password = m_app.getPlaintextServerPassword();
        if (!m_tmp_password.isEmpty()) {
            return true;
        }
    }
    // If we get here, either we're not storing the password or it hasn't been
    // entered.
    const QString text = tr("Enter password for user <b>%1</b> on server %2")
            .arg(m_app.varString(varconst::SERVER_USERNAME),
                 serverUrlDisplayString());
    const QString title = tr("Enter server password");
    QWidget* parent = m_logbox ? m_logbox : m_parent;
    PasswordEntryDialog dlg(text, title, parent);
    const int reply = dlg.exec();
    if (reply != QDialog::Accepted) {
        return false;
    }
    // fetch/write back password
    m_tmp_password = dlg.password();
    return true;
}


void NetworkManager::cleanup()
{
    disconnectManager();
    m_tmp_password = "";
    m_tmp_session_id = "";
    m_tmp_session_token = "";
    m_register_next_stage = NextRegisterStage::Invalid;
    m_reply_data.clear();
    m_reply_dict.clear();

    m_upload_next_stage = NextUploadStage::Invalid;
    m_upload_patient_ids_to_move_off.clear();
    m_upload_empty_tables.clear();
    m_upload_tables_to_send_whole.clear();
    m_upload_tables_to_send_recordwise.clear();
    m_upload_recordwise_table_in_progress = "";
    m_upload_recordwise_fieldnames.clear();
    m_upload_current_record_index = -1;
    m_upload_recordwise_pks_to_send.clear();
    m_upload_n_records = 0;
    m_upload_tables_to_wipe.clear();
    m_upload_patient_info_json = "";
}


void NetworkManager::sslIgnoringErrorHandler(QNetworkReply* reply,
                                             const QList<QSslError> & errlist)
{
    // Error handle that ignores SSL certificate errors and continues
    statusMessage(tr("+++ Ignoring %1 SSL error(s):").arg(errlist.length()));
    for (const QSslError& err : errlist) {
        statusMessage("    " + err.errorString());
    }
    reply->ignoreSslErrors();
}


void NetworkManager::cancel()
{
#ifdef DEBUG_ACTIVITY
    qDebug() << Q_FUNC_INFO;
#endif
    cleanup();
    if (m_logbox) {
        return m_logbox->reject();  // its rejected() signal calls our logboxCancelled()
    }

    emit cancelled(ErrorCode::NoError, QString());
}


void NetworkManager::fail(const ErrorCode error_code,
                          const QString& error_string)
{
#ifdef DEBUG_ACTIVITY
    qDebug() << Q_FUNC_INFO;
#endif
    cleanup();
    if (m_logbox) {
        return m_logbox->finish(false);  // its signals call our logboxCancelled() or logboxFinished()
    }

    emit cancelled(error_code, error_string);
}


void NetworkManager::succeed()
{
#ifdef DEBUG_ACTIVITY
    qDebug() << Q_FUNC_INFO;
#endif
    cleanup();
    if (m_logbox) {
        return m_logbox->finish(true);  // its signals call our logboxCancelled() or logboxFinished()
    }

    emit finished();
}


// ============================================================================
// Testing
// ============================================================================

void NetworkManager::testHttpGet(const QString& url, const bool offer_cancel)
{
    QNetworkRequest request = createRequest(QUrl(url), offer_cancel,
                                            false, false);
    statusMessage(tr("Testing HTTP GET connection to:") + " " + url);
    // Safe object lifespan signal: can use std::bind
    QObject::connect(m_mgr, &QNetworkAccessManager::finished,
                     std::bind(&NetworkManager::testReplyFinished,
                               this, std::placeholders::_1));
    // GET
    m_mgr->get(request);
    statusMessage(tr("... sent request to:") + " " + url);
}


void NetworkManager::testHttpsGet(const QString& url, const bool offer_cancel,
                                  const bool ignore_ssl_errors)
{
    QNetworkRequest request = createRequest(QUrl(url), offer_cancel,
                                            true, ignore_ssl_errors,
                                            QSsl::AnyProtocol);
    statusMessage(tr("Testing HTTPS GET connection to:") + " " + url);
    // Safe object lifespan signal: can use std::bind
    QObject::connect(m_mgr, &QNetworkAccessManager::finished,
                     std::bind(&NetworkManager::testReplyFinished, this,
                               std::placeholders::_1));
    // Note: the reply callback arrives on the main (GUI) thread.
    // GET
    m_mgr->get(request);
    statusMessage(tr("... sent request to:") + " " + url);
}


void NetworkManager::testReplyFinished(QNetworkReply* reply)
{
    if (reply->error() == QNetworkReply::NoError) {
        statusMessage(tr("Result:"));
        statusMessage(reply->readAll());
    } else {
        statusMessage(tr("Network error:") + " " + reply->errorString());
    }
    reply->deleteLater();  // https://doc.qt.io/qt-6.5/qnetworkaccessmanager.html#details
    succeed();
}


// ============================================================================
// Server registration
// ============================================================================

void NetworkManager::registerWithServer()
{
    registerNext();
}

void NetworkManager::registerNext(QNetworkReply* reply)
{
    if (reply) {
        if (!processServerReply(reply)) {
            return;
        }

        statusMessage(tr("... OK"));
    }

    Dict dict;

    switch (m_register_next_stage) {

    case NextRegisterStage::Invalid:
        m_register_next_stage = NextRegisterStage::Register;
        registerNext();
        break;

    case NextRegisterStage::Register:
        statusMessage(
            //: Server URL
            tr("Registering with %1 and receiving identification information")
            .arg(serverUrlDisplayString())
        );
        dict[KEY_OPERATION] = OP_REGISTER;
        dict[KEY_DEVICE_FRIENDLY_NAME] = m_app.varString(
            varconst::DEVICE_FRIENDLY_NAME
        );
        m_register_next_stage = NextRegisterStage::StoreServerIdentification;

        serverPost(dict, &NetworkManager::registerNext);
        break;

    case NextRegisterStage::StoreServerIdentification:
        storeServerIdentificationInfo();
        m_register_next_stage = NextRegisterStage::GetAllowedTables;

        registerNext();
        break;

    case NextRegisterStage::GetAllowedTables:
        statusMessage(tr("Requesting allowed tables"));
        dict[KEY_OPERATION] = OP_GET_ALLOWED_TABLES;
        m_register_next_stage = NextRegisterStage::StoreAllowedTables;

        serverPost(dict, &NetworkManager::registerNext);
        break;

    case NextRegisterStage::StoreAllowedTables:
        storeAllowedTables();
        m_register_next_stage = NextRegisterStage::GetExtraStrings;

        registerNext();
        break;

    case NextRegisterStage::GetExtraStrings:
        statusMessage(tr("Requesting extra strings"));
        dict[KEY_OPERATION] = OP_GET_EXTRA_STRINGS;

        m_register_next_stage = NextRegisterStage::StoreExtraStrings;

        serverPost(dict, &NetworkManager::registerNext);
        break;

    case NextRegisterStage::StoreExtraStrings:
        storeExtraStrings();
        m_register_next_stage = NextRegisterStage::Finished;

        if (m_app.isSingleUserMode()) {
            m_register_next_stage = NextRegisterStage::GetTaskSchedules;
        }
        registerNext();
        break;

    case NextRegisterStage::GetTaskSchedules:
        dict[KEY_OPERATION] = OP_GET_TASK_SCHEDULES;
        dict[KEY_PATIENT_PROQUINT] = m_app.varString(
            varconst::SINGLE_PATIENT_PROQUINT
        );

        m_register_next_stage = NextRegisterStage::StoreTaskSchedules;

        serverPost(dict, &NetworkManager::registerNext);
        break;

    case NextRegisterStage::StoreTaskSchedules:
        storeTaskSchedulesAndPatientDetails();

        m_register_next_stage = NextRegisterStage::Finished;
        registerNext();
        break;

    case NextRegisterStage::Finished:
        statusMessage(tr("Completed successfully."));

        succeed();
        break;

    default:
        uifunc::stopApp("Bug: unknown m_register_next_stage");
    }
}

void NetworkManager::updateTaskSchedulesAndPatientDetails()
{
    Dict dict;

    dict[KEY_OPERATION] = OP_GET_TASK_SCHEDULES;
    dict[KEY_PATIENT_PROQUINT] = m_app.varString(
        varconst::SINGLE_PATIENT_PROQUINT
    );

    statusMessage(tr("Getting task schedules from") + " " +
                  serverUrlDisplayString());

    serverPost(dict, &NetworkManager::receivedTaskSchedulesAndPatientDetails);
}

void NetworkManager::receivedTaskSchedulesAndPatientDetails(QNetworkReply* reply)
{
    if (!processServerReply(reply)) {
        return;
    }

    storeTaskSchedulesAndPatientDetails();
    succeed();
}


void NetworkManager::storeTaskSchedulesAndPatientDetails()
{
    statusMessage(tr("... received task schedules"));

    QJsonParseError error;

    // ------------------------------------------------------------------------
    // Patient
    // ------------------------------------------------------------------------
    // Note: Unlike in createSinglePatient(), our patient object already
    // exists. We're just checking that the details match (in case there's been
    // a change on the server).
    const QJsonDocument patient_doc = QJsonDocument::fromJson(
        m_reply_dict[KEY_PATIENT_INFO].toUtf8(), &error
    );
    if (patient_doc.isNull()) {
        const QString message = tr("Failed to parse patient info: %1").arg(
            error.errorString()
        );
        statusMessage(message);
        fail(ErrorCode::JsonParseError, message);
        return;
    }
    const QJsonArray patients_json_array = patient_doc.array();
    const QJsonObject patient_json = patients_json_array.first().toObject();
    Patient* patient = m_app.selectedPatient();
    if (patient) {
        patient->setPatientDetailsFromJson(patient_json);
        patient->setIdNums(patient_json);
        patient->save();
    } else {
        const QString message = tr(
                "No patient selected! Unexpected in single-patient mode.");
        statusMessage(message);
        // ... but continue.
    }

    // ------------------------------------------------------------------------
    // Schedules
    // ------------------------------------------------------------------------

    const QJsonDocument schedule_doc = QJsonDocument::fromJson(
        m_reply_dict[KEY_TASK_SCHEDULES].toUtf8(), &error
    );
    if (schedule_doc.isNull()) {
        const QString message = tr("Failed to parse task schedules: %1").arg(
            error.errorString()
        );
        statusMessage(message);
        fail(ErrorCode::JsonParseError, message);

        return;
    }

    const TaskSchedulePtrList old_schedules = m_app.getTaskSchedules();
    const QJsonArray schedules_array = schedule_doc.array();
    TaskSchedulePtrList new_schedules;
    for (QJsonArray::const_iterator it = schedules_array.constBegin();
            it != schedules_array.constEnd();
            it++) {
        QJsonObject schedule_json = it->toObject();

        TaskSchedulePtr schedule = TaskSchedulePtr(
            new TaskSchedule(m_app, m_app.sysdb(), schedule_json)
        );

        schedule->save();

        schedule->addItems(
            schedule_json.value(KEY_TASK_SCHEDULE_ITEMS).toArray()
        );

        new_schedules.append(schedule);
    }

    if (old_schedules.size() > 0) {
        updateCompleteStatusForAnonymousTasks(old_schedules, new_schedules);
    }

    for (const TaskSchedulePtr& old_schedule : old_schedules) {
        old_schedule->deleteFromDatabase();
    }
}


void NetworkManager::updateCompleteStatusForAnonymousTasks(
        TaskSchedulePtrList old_schedules,
        TaskSchedulePtrList new_schedules)
{
    // When updating the schedule, the server does not know which anonymous
    // tasks have been completed so we use any existing data on the tablet.
    // The new task schedule item has to match the old one exactly in terms
    // of table name, date etc

    QMap<QString, TaskSchedulePtr> old_schedule_map;
    for (const TaskSchedulePtr& old_schedule : old_schedules) {
        old_schedule_map[old_schedule->name()] = old_schedule;
    }

    for (const TaskSchedulePtr& new_schedule : new_schedules) {
        const QString schedule_name = new_schedule->name();
        if (old_schedule_map.contains(schedule_name)) {
            TaskSchedulePtr old_schedule = old_schedule_map[schedule_name];

            for (const TaskScheduleItemPtr& old_item: old_schedule->items()) {

                if (old_item->isAnonymous()) {
                    TaskScheduleItemPtr new_item = new_schedule->findItem(
                        old_item
                    );

                    if (new_item != nullptr) {
                        new_item->setComplete(old_item->isComplete(),
                                              old_item->whenCompleted());
                        new_item->save();
                    }
                }
            }
        }
    }
}


void NetworkManager::fetchIdDescriptions()
{
    statusMessage(tr("Getting ID info from") + " " + serverUrlDisplayString());
    Dict dict;
    dict[KEY_OPERATION] = OP_GET_ID_INFO;
    serverPost(dict, &NetworkManager::fetchIdDescriptionsSub1);
}


void NetworkManager::fetchIdDescriptionsSub1(QNetworkReply* reply)
{
    if (!processServerReply(reply)) {
        return;
    }
    statusMessage(tr("... registered and received identification information"));
    storeServerIdentificationInfo();
    succeed();
}


void NetworkManager::fetchExtraStrings()
{
    statusMessage(tr("Getting extra strings from") + " " +
                  serverUrlDisplayString());
    Dict dict;
    dict[KEY_OPERATION] = OP_GET_EXTRA_STRINGS;
    serverPost(dict, &NetworkManager::fetchExtraStringsSub1);
}


void NetworkManager::fetchExtraStringsSub1(QNetworkReply* reply)
{
    if (!processServerReply(reply)) {
        return;
    }
    statusMessage(tr("... received extra strings"));
    storeExtraStrings();
    succeed();
}


void NetworkManager::fetchAllServerInfo()
{
    statusMessage(tr("Fetching server info from ") + serverUrlDisplayString());
    statusMessage(tr("Requesting ID info"));
    Dict dict;
    dict[KEY_OPERATION] = OP_GET_ID_INFO;
    serverPost(dict, &NetworkManager::fetchAllServerInfoSub1);
}


void NetworkManager::fetchAllServerInfoSub1(QNetworkReply* reply)
{
    if (!processServerReply(reply)) {
        return;
    }
    statusMessage(tr("... received identification information"));
    storeServerIdentificationInfo();

    // Now we move across to the "registration" chain of functions:
    m_register_next_stage = NextRegisterStage::GetAllowedTables;
    registerNext();
}


void NetworkManager::storeServerIdentificationInfo()
{
    m_app.setVar(varconst::SERVER_DATABASE_TITLE, m_reply_dict[KEY_DATABASE_TITLE]);
    m_app.setVar(varconst::SERVER_CAMCOPS_VERSION, m_reply_dict[KEY_SERVER_CAMCOPS_VERSION]);
    m_app.setVar(varconst::ID_POLICY_UPLOAD, m_reply_dict[KEY_ID_POLICY_UPLOAD]);
    m_app.setVar(varconst::ID_POLICY_FINALIZE, m_reply_dict[KEY_ID_POLICY_FINALIZE]);

    m_app.deleteAllIdDescriptions();
    for (const QString& keydesc : m_reply_dict.keys()) {
        if (keydesc.startsWith(KEYPREFIX_ID_DESCRIPTION)) {
            const QString number = keydesc.right(keydesc.length() -
                                                 KEYPREFIX_ID_DESCRIPTION.length());
            bool ok = false;
            const int which_idnum = number.toInt(&ok);
            if (ok) {
                const QString desc = m_reply_dict[keydesc];
                const QString key_shortdesc = KEYSPEC_ID_SHORT_DESCRIPTION.arg(which_idnum);
                const QString shortdesc = m_reply_dict[key_shortdesc];
                const QString key_validation = KEYSPEC_ID_VALIDATION_METHOD.arg(which_idnum);
                const QString validation_method = m_reply_dict[key_validation];
                m_app.setIdDescription(which_idnum, desc, shortdesc, validation_method);
            } else {
                qWarning() << "Bad ID description key:" << keydesc;
            }
        }
    }

    m_app.setVar(varconst::LAST_SERVER_REGISTRATION, datetime::now());
    m_app.setVar(varconst::LAST_SUCCESSFUL_UPLOAD, QVariant());
    // ... because we might have registered with a different server, we set
    // this to NULL, so it doesn't give the impression that we have uploaded
    // our data to the new server.

    // Deselect patient or reload single user mode patient as its description
    // text may be out of date
    m_app.setDefaultPatient(true);
}


void NetworkManager::storeAllowedTables()
{
    RecordList recordlist = getRecordList();
    m_app.setAllowedServerTables(recordlist);
    statusMessage(tr("Saved %1 allowed tables").arg(recordlist.length()));
}


void NetworkManager::storeExtraStrings()
{
    RecordList recordlist = getRecordList();
    if (!recordlist.isEmpty()) {
        m_app.setAllExtraStrings(recordlist);
        statusMessage(tr("Saved %1 extra strings").arg(recordlist.length()));
    }
}


// ============================================================================
// Upload
// ============================================================================

// ----------------------------------------------------------------------------
// Upload: CORE
// ----------------------------------------------------------------------------

void NetworkManager::upload(const UploadMethod method)
{
    statusMessage(tr("Preparing to upload to:") + " " +
                  serverUrlDisplayString());
    // ... in part so uploadNext() status message looks OK

    // The GUI doesn't get a chance to respond until after this function
    // has completed.
    // SlowGuiGuard guard();  // not helpful

    m_app.processEvents();  // these, scattered around, are very helpful.

    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    // 1. Internal database checks/flag-setting
    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    cleanup();
    m_upload_method = method;

    // Offline things first:
    if (!isPatientInfoComplete()) {  // also sets m_patient_info_json
        fail();
        return;
    }
    m_app.processEvents();

    statusMessage(tr("Removing any defunct binary large objects"));
    if (!pruneDeadBlobs()) {
        fail();
        return;
    }
    statusMessage(tr("... done"));
    m_app.processEvents();

    statusMessage("Setting move-off flags for tasks, where applicable");
    if (!applyPatientMoveOffTabletFlagsToTasks()) {
        fail();
        return;
    }
    statusMessage(tr("... done"));
    m_app.processEvents();

    m_app.processEvents();

    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    // 2. Begin comms with the server by checking device is registered.
    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    checkDeviceRegistered();
    m_upload_next_stage = NextUploadStage::CheckUser;
    // ... will end up at uploadNext().
}


void NetworkManager::uploadNext(QNetworkReply* reply)
{
    // This function imposes an order on the upload sequence, which makes
    // everything else work.

    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    // Whatever happens next, check the server was happy with our last request.
    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    // The option for reply to be nullptr is so we can do a no-op.
    if (reply && !processServerReply(reply)) {
        return;
    }
    if (m_upload_next_stage == NextUploadStage::Invalid) {
        // stage might be Invalid if user hit cancel while messages still
        // inbound
        return;
    }
    statusMessage(tr("... OK"));

    switch (m_upload_next_stage) {

    case NextUploadStage::CheckUser:
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // FROM: check device registration. (Checked implicitly.)
        // TO: check user OK.
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        checkUploadUser();
        m_upload_next_stage = NextUploadStage::FetchServerIdInfo;
        break;

    case NextUploadStage::FetchServerIdInfo:
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // FROM: check user OK. (Checked implicitly.)
        // TO: fetch server ID info (server version, database title,
        //      which ID numbers, ID policies)
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        uploadFetchServerIdInfo();
        m_upload_next_stage = NextUploadStage::ValidatePatients;
        break;

    case NextUploadStage::StoreExtraStrings:
        // The server version changed so we fetch any new extra strings
        storeExtraStrings();
        // now we go back to trying to fetch the server info
        m_upload_next_stage = NextUploadStage::FetchServerIdInfo;
        uploadNext(nullptr);
        break;

    case NextUploadStage::ValidatePatients:
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // FROM: fetch server ID info
        // TO: ask server to validate patients
        //     ... or if the server doesn't support that, move on another step
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        if (m_app.isSingleUserMode()) {
            // In single user mode, if the server has been updated, we overwrite
            // the stored server version and refetch all server info without
            // warning or prompting the user to refetch.
            if (!serverVersionMatchesStored()) {
                storeServerIdentificationInfo();

                statusMessage(tr("Requesting extra strings"));
                Dict dict;
                dict[KEY_OPERATION] = OP_GET_EXTRA_STRINGS;

                m_upload_next_stage = NextUploadStage::StoreExtraStrings;
                serverPost(dict, &NetworkManager::uploadNext);
                return;
            }
        }

        if (!isServerVersionOK() || !arePoliciesOK() || !areDescriptionsOK()) {
            fail();
            return;
        }
        if (serverSupportsValidatePatients()) {
            uploadValidatePatients();  // v2.3.0
            m_upload_next_stage = NextUploadStage::FetchAllowedTables;
            break;
        } else {
            // Otherwise:

            // [[fallthrough]];
            // ... C++17 syntax!
            // - https://en.cppreference.com/w/cpp/language/attributes
            // - https://en.cppreference.com/w/cpp/language/attributes/fallthrough

            // [[clang::fallthrough]];
            // ... compiler-specific

            Q_FALLTHROUGH();
            // ... well done, Qt
            // - https://doc.qt.io/qt-6.5/qtglobal.html#Q_FALLTHROUGH
        }

    case NextUploadStage::FetchAllowedTables:
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // FROM: ask server to validate patients
        // TO: fetch allowed tables/minimum client versions
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        uploadFetchAllowedTables();
        m_upload_next_stage = NextUploadStage::CheckPoliciesThenStartUpload;
        break;

    case NextUploadStage::CheckPoliciesThenStartUpload:
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // FROM: fetch allowed tables/minimum client versions
        // TO: start upload or preservation
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        statusMessage("... received allowed tables");
        storeAllowedTables();
        if (!catalogueTablesForUpload()) {  // checks per-table version requirements
            fail();
            return;
        }
        if (shouldUseOneStepUpload()) {
            uploadOneStep();
            m_upload_next_stage = NextUploadStage::Finished;
        } else {
            startUpload();
            if (m_upload_method == UploadMethod::Copy) {
                // If we copy, we proceed to uploading
                m_upload_next_stage = NextUploadStage::Uploading;
            } else {
                // If we're moving, we preserve records.
                m_upload_next_stage = NextUploadStage::StartPreservation;
            }
        }
        break;

    case NextUploadStage::StartPreservation:
        startPreservation();
        m_upload_next_stage = NextUploadStage::Uploading;
        break;

    case NextUploadStage::Uploading:
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // FROM: start upload or preservation
        // TO: upload, tablewise then recordwise (CYCLES ROUND here until done)
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        if (!m_upload_empty_tables.isEmpty()) {

            sendEmptyTables(m_upload_empty_tables);
            m_upload_empty_tables.clear();

        } else if (!m_upload_tables_to_send_whole.isEmpty()) {

            QString table = m_upload_tables_to_send_whole.front();
            m_upload_tables_to_send_whole.pop_front();
            sendTableWhole(table);

        } else if (!m_upload_recordwise_pks_to_send.isEmpty()) {

            if (!m_recordwise_prune_req_sent) {
                requestRecordwisePkPrune();
            } else {
                if (!m_recordwise_pks_pruned) {
                    if (!pruneRecordwisePks()) {
                        fail();
                        return;
                    }
                    if (m_upload_recordwise_pks_to_send.isEmpty()) {
                        // Quasi-recursive way of saying "do whatever you would
                        // have done otherwise", since the server had said "I'm
                        // not interested in any records from that table".
                        statusMessage(tr("... server doesn't want anything "
                                         "from this table"));
                        uploadNext(nullptr);
                        return;
                    }
                }
                sendNextRecord();
            }

        } else if (!m_upload_tables_to_send_recordwise.isEmpty()) {

            QString table = m_upload_tables_to_send_recordwise.front();
            m_upload_tables_to_send_recordwise.pop_front();
            sendTableRecordwise(table);

        } else {

            endUpload();
            m_upload_next_stage = NextUploadStage::Finished;

        }
        break;

    case NextUploadStage::Finished:
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // FROM: upload, or uploadOneStep()
        // All done successfully!
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        wipeTables();
        statusMessage(tr("Finished"));
        m_app.setVar(varconst::LAST_SUCCESSFUL_UPLOAD, datetime::now());
        m_app.setNeedsUpload(false);
        m_app.setDefaultPatient(true);  // even for "copy" method; see changelog
        m_app.forceRefreshPatientList();
        succeed();
        break;

    default:
        uifunc::stopApp("Bug: unknown m_upload_next_stage");
    }
}


// ----------------------------------------------------------------------------
// Upload: COMMS
// ----------------------------------------------------------------------------

void NetworkManager::checkDeviceRegistered()
{
    statusMessage(tr("Checking device is registered with server"));
    Dict dict;
    dict[KEY_OPERATION] = OP_CHECK_DEVICE_REGISTERED;
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::checkUploadUser()
{
    statusMessage(tr("Checking user/device permitted to upload"));
    Dict dict;
    dict[KEY_OPERATION] = OP_CHECK_UPLOAD_USER_DEVICE;
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::uploadFetchServerIdInfo()
{
    statusMessage(tr("Fetching server's version/ID policies/ID descriptions"));
    Dict dict;
    dict[KEY_OPERATION] = OP_GET_ID_INFO;
    serverPost(dict, &NetworkManager::uploadNext);
}


bool NetworkManager::serverSupportsValidatePatients() const
{
    return m_app.serverVersion() >= MIN_SERVER_VERSION_FOR_VALIDATE_PATIENTS;
}


void NetworkManager::uploadValidatePatients()
{
    // Added in v2.3.0
    statusMessage(tr("Validating patients for upload"));
    Dict dict;
    dict[KEY_OPERATION] = OP_VALIDATE_PATIENTS;
    dict[KEY_PATIENT_INFO] = m_upload_patient_info_json;
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::uploadFetchAllowedTables()
{
    statusMessage(tr("Fetching server's allowed tables/client versions"));
    Dict dict;
    dict[KEY_OPERATION] = OP_GET_ALLOWED_TABLES;
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::startUpload()
{
    statusMessage(tr("Starting upload"));
    Dict dict;
    dict[KEY_OPERATION] = OP_START_UPLOAD;
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::startPreservation()
{
    statusMessage(tr("Starting preservation"));
    Dict dict;
    dict[KEY_OPERATION] = OP_START_PRESERVATION;
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::sendEmptyTables(const QStringList& tablenames)
{
    statusMessage(tr("Uploading empty tables: ") + tablenames.join(", "));
    Dict dict;
    dict[KEY_OPERATION] = OP_UPLOAD_EMPTY_TABLES;
    dict[KEY_TABLES] = tablenames.join(",");
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::sendTableWhole(const QString& tablename)
{
    statusMessage(tr("Uploading table: ") + tablename);
    Dict dict;
    dict[KEY_OPERATION] = OP_UPLOAD_TABLE;
    dict[KEY_TABLE] = tablename;
    const QStringList fieldnames = m_db.getFieldNames(tablename);
    dict[KEY_PKNAME] = dbconst::PK_FIELDNAME;  // version 2.0.4
    // There was a BUG here before v2.0.4:
    // - the old Titanium code gave fieldnames starting with the PK
    // - the SQLite reporting order isn't necessarily like that
    // - for the upload_table command, the receiving code relied on the PK
    //   being first
    // - So as of tablet v2.0.4, the client explicitly reports PK name (and
    //   makes no guarantee about field order) and as of server v2.1.0, the
    //   server takes the PK name if the tablet is >=2.0.4, or "id" otherwise
    //   (because the client PK name always was "id"!). This allows old tablets
    //   to work (for which: could use fieldnames[0] or "id") and early buggy
    //   C++ clients to work (for which: "id" is the only valid option).
    dict[KEY_FIELDS] = fieldnames.join(",");
    const QString sql = dbfunc::selectColumns(fieldnames, tablename);
    const QueryResult result = m_db.query(sql);
    if (!result.succeeded()) {
        queryFail(sql);
        return;
    }
    const int nrows = result.nRows();
    for (int record = 0; record < nrows; ++record) {
        dict[KEYSPEC_RECORD.arg(record)] = result.csvRow(record);
    }
    dict[KEY_NRECORDS] = QString::number(nrows);
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::sendTableRecordwise(const QString& tablename)
{
    statusMessage(tr("Preparing to send table (recordwise): ") + tablename);

    m_upload_recordwise_table_in_progress = tablename;
    m_upload_recordwise_fieldnames = m_db.getFieldNames(tablename);
    m_recordwise_prune_req_sent = false;
    m_recordwise_pks_pruned = false;
    m_upload_recordwise_pks_to_send = m_db.getPKs(tablename,
                                                  dbconst::PK_FIELDNAME);
    m_upload_n_records = m_upload_recordwise_pks_to_send.size();
    m_upload_current_record_index = 0;

    // First, DELETE WHERE pk NOT...
    const QString pkvalues = convert::numericVectorToCsvString(m_upload_recordwise_pks_to_send);
    Dict dict;
    dict[KEY_OPERATION] = OP_DELETE_WHERE_KEY_NOT;
    dict[KEY_TABLE] = tablename;
    dict[KEY_PKNAME] = dbconst::PK_FIELDNAME;
    dict[KEY_PKVALUES] = pkvalues;
    statusMessage(tr("Sending message: ") + OP_DELETE_WHERE_KEY_NOT);
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::requestRecordwisePkPrune()
{
    const QString sql = QString("SELECT %1, %2, %3 FROM %4")
            .arg(delimit(dbconst::PK_FIELDNAME),  // result column 0
                 delimit(dbconst::MODIFICATION_TIMESTAMP_FIELDNAME),  // result column 1
                 delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),  // result column 2
                 delimit(m_upload_recordwise_table_in_progress));
    const QueryResult result = m_db.query(sql);
    const QStringList pkvalues = result.columnAsStringList(0);
    const QStringList datevalues = result.columnAsStringList(1);
    const QStringList move_off_tablet_values = result.columnAsStringList(2);
    Dict dict;
    dict[KEY_OPERATION] = OP_WHICH_KEYS_TO_SEND;
    dict[KEY_TABLE] = m_upload_recordwise_table_in_progress;
    dict[KEY_PKNAME] = dbconst::PK_FIELDNAME;
    dict[KEY_PKVALUES] = pkvalues.join(",");
    dict[KEY_DATEVALUES] = datevalues.join(",");
    dict[KEY_MOVE_OFF_TABLET_VALUES] = move_off_tablet_values.join(",");  // v2.3.0
    m_recordwise_prune_req_sent = true;
    statusMessage(tr("Sending message: ") + OP_WHICH_KEYS_TO_SEND);
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::sendNextRecord()
{
    ++m_upload_current_record_index;
    statusMessage(tr("Uploading table %1, record %2/%3")
                  .arg(m_upload_recordwise_table_in_progress)
                  .arg(m_upload_current_record_index)
                  .arg(m_upload_n_records));
    // Don't use m_upload_recordwise_pks_to_send.size() as the count, as that
    // changes during upload.
    const int pk = m_upload_recordwise_pks_to_send.front();
    m_upload_recordwise_pks_to_send.pop_front();

    SqlArgs sqlargs(dbfunc::selectColumns(
                        m_upload_recordwise_fieldnames,
                        m_upload_recordwise_table_in_progress));
    WhereConditions where;
    where.add(dbconst::PK_FIELDNAME, pk);
    where.appendWhereClauseTo(sqlargs);
    const QueryResult result = m_db.query(sqlargs, QueryResult::FetchMode::FetchFirst);
    if (!result.succeeded() || result.nRows() < 1) {
        queryFail(sqlargs.sql);
        return;
    }
    const QString values = result.csvRow(0);

    Dict dict;
    dict[KEY_OPERATION] = OP_UPLOAD_RECORD;
    dict[KEY_TABLE] = m_upload_recordwise_table_in_progress;
    dict[KEY_FIELDS] = m_upload_recordwise_fieldnames.join(",");
    dict[KEY_PKNAME] = dbconst::PK_FIELDNAME;
    dict[KEY_VALUES] = values;
    serverPost(dict, &NetworkManager::uploadNext);
}


void NetworkManager::endUpload()
{
    statusMessage(tr("Finishing upload"));
    Dict dict;
    dict[KEY_OPERATION] = OP_END_UPLOAD;
    serverPost(dict, &NetworkManager::uploadNext);
}


// ----------------------------------------------------------------------------
// Upload: INTERNAL FUNCTIONS
// ----------------------------------------------------------------------------

bool NetworkManager::isPatientInfoComplete()
{
    statusMessage(tr("Checking patient information sufficiently complete"));

    Patient specimen_patient(m_app, m_db);
    const SqlArgs sqlargs = specimen_patient.fetchQuerySql();
    const QueryResult result = m_db.query(sqlargs);
    if (!result.succeeded()) {
        queryFail(sqlargs.sql);
        return false;
    }

    const bool finalizing = m_upload_method != UploadMethod::Copy;
    int nfailures_upload = 0;
    int nfailures_finalize = 0;
    int nfailures_clash = 0;
    int nfailures_move_off = 0;
    QJsonArray patients_json_array;
    const int nrows = result.nRows();
    for (int row = 0; row < nrows; ++row) {
        Patient patient(m_app, m_db);
        patient.setFromQuery(result, row, true);
        const bool finalizing_this_pt = patient.shouldMoveOffTablet();
        if (!patient.compliesWithUpload()) {
            ++nfailures_upload;
        }
        const bool complies_with_finalize = patient.compliesWithFinalize();
        if (!complies_with_finalize) {
            ++nfailures_finalize;
        }
        if (patient.anyIdClash()) {
            // not the most efficient; COUNT DISTINCT...
            // However, this gives us the number of patients clashing.
            ++nfailures_clash;
        }
        if (m_upload_method != UploadMethod::Move && finalizing_this_pt) {
            // To move a patient off, it must comply with the finalize policy.
            if (!complies_with_finalize) {
                ++nfailures_move_off;
            } else {
                m_upload_patient_ids_to_move_off.append(patient.pkvalueInt());
            }
        }

        // Set JSON too. See below.
        QJsonObject ptjson = patient.jsonDescription();
        ptjson[KEY_FINALIZING] = finalizing || finalizing_this_pt;
        patients_json_array.append(ptjson);
    }

    if (nfailures_clash > 0) {
        statusMessage(tr("Failure: %1 patient(s) having clashing ID numbers")
                      .arg(nfailures_clash));
        return false;
    }
    if (nfailures_move_off > 0) {
        statusMessage(tr(
                "You are trying to move off %1 patient(s) using the "
                "explicit per-patient move-off flag, but they do not "
                "comply with the server's finalize ID policy [%2]")
                      .arg(nfailures_move_off)
                      .arg(m_app.finalizePolicy().pretty()));
        return false;
    }
    if (m_upload_method == UploadMethod::Copy && nfailures_upload > 0) {
        // Copying; we're allowed not to meet the finalizing requirements,
        // but we must meet the uploading requirements
        statusMessage(tr("Failure: %1 patient(s) do not meet the "
                         "server's upload ID policy of: %2")
                      .arg(nfailures_upload)
                      .arg(m_app.uploadPolicy().pretty()));
        return false;
    }
    if (finalizing && nfailures_upload + nfailures_finalize > 0) {
        // Finalizing; must meet all requirements
        statusMessage(tr(
            "Failure: %1 patient(s) do not meet the server's upload ID policy "
            "[%2]; %3 patient(s) do not meet its finalize ID policy [%4]")
                      .arg(nfailures_upload)
                      .arg(m_app.uploadPolicy().pretty())
                      .arg(nfailures_finalize)
                      .arg(m_app.finalizePolicy().pretty()));
        return false;
    }

    // We also set the patient info JSON here, so we only iterate through
    // patients once.
    //
    // Compare camcops_server.cc_modules.client_api.validate_patients() on the
    // server.
    //
    // Top-level JSON can be an object or an array.
    // - https://stackoverflow.com/questions/3833299/can-an-array-be-top-level-json-text
    // - http://www.ietf.org/rfc/rfc4627.txt?number=4627
    const QJsonDocument jsondoc(patients_json_array);
    m_upload_patient_info_json  = jsondoc.toJson(QJsonDocument::Compact);
    //                                    ^^^^^^ ... a QByteArray in UTF-8
    // - https://stackoverflow.com/questions/28181627/how-to-convert-a-qjsonobject-to-qstring
#ifdef DEBUG_JSON
    qDebug().noquote() << "Patient info JSON:" << m_upload_patient_info_json;
#endif

    return true;
}


bool NetworkManager::applyPatientMoveOffTabletFlagsToTasks()
{
    // If we were uploading, we need to undo our move-off flags (in case the
    // user changes their mind about a patient)
    // We could use a system of "set before upload, clear afterwards".
    // However, failing to clear (for some reason) is a risk.
    // Therefore, we set and clear flags here, for all tables.
    // That is, we make sure these flags are all correct immediately before
    // an upload (which is when we care).

    if (m_upload_method != UploadMethod::Copy) {
        // if we're not using UploadMethod::Copy, everything is going to be
        // moved anyway, by virtue of startPreservation()
        statusMessage(tr("... not applicable; all tasks will be moved"));
        return true;
    }

    DbNestableTransaction trans(m_db);

    // ========================================================================
    // Step 1: clear all move-off flags, except in the source tables (being:
    // patient tables and anonymous task primary tables).
    // ========================================================================
    for (const TaskPtr& specimen : m_p_task_factory->allSpecimens()) {
        if (specimen->isAnonymous()) {
            // anonymous task: clear the ancillary tables
            for (const QString& tablename : specimen->ancillaryTables()) {
                if (!clearMoveOffTabletFlag(tablename)) {
                    queryFailClearingMoveOffFlag(tablename);
                    return false;
                }
            }
        } else {
            // task with patient: clear all tables
            for (const QString& tablename : specimen->allTables()) {
                if (!clearMoveOffTabletFlag(tablename)) {
                    queryFailClearingMoveOffFlag(tablename);
                    return false;
                }
            }
        }
    }
    // Clear all flags for BLOBs
    if (!clearMoveOffTabletFlag(Blob::TABLENAME)) {
        queryFailClearingMoveOffFlag(Blob::TABLENAME);
        return false;
    }

    // ========================================================================
    // Step 2: Apply flags from patients to their idnums/tasks/ancillary tables.
    // ========================================================================
    // m_upload_patient_ids_to_move_off has been precalculated for efficiency

    const int n_patients = m_upload_patient_ids_to_move_off.length();
    if (n_patients > 0) {
        QString pt_paramholders = dbfunc::sqlParamHolders(n_patients);
        ArgList pt_args = dbfunc::argListFromIntList(m_upload_patient_ids_to_move_off);
        // Maximum length of an SQL statement: lots
        // https://www.sqlite.org/limits.html
        QString sql;

        // Patient ID number table
        sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
                      .arg(delimit(PatientIdNum::PATIENT_IDNUM_TABLENAME),
                           delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
                           delimit(PatientIdNum::FK_PATIENT),
                           pt_paramholders);
#ifdef USE_BACKGROUND_DATABASE
            m_db.execNoAnswer(sql, pt_args);
#else
            if (!m_db.exec(sql, pt_args)) {
                queryFail(sql);
                return false;
            }
#endif

        // Task tables
        for (const TaskPtr& specimen : m_p_task_factory->allSpecimens()) {
            if (specimen->isAnonymous()) {
                continue;
            }
            const QString main_tablename = specimen->tablename();
            // (a) main table, with FK to patient
            sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
                    .arg(delimit(main_tablename),
                         delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
                         delimit(Task::PATIENT_FK_FIELDNAME),
                         pt_paramholders);
#ifdef USE_BACKGROUND_DATABASE
            m_db.execNoAnswer(sql, pt_args);
#else
            if (!m_db.exec(sql, pt_args)) {
                queryFail(sql);
                return false;
            }
#endif
            // (b) ancillary tables
            const QStringList ancillary_tables = specimen->ancillaryTables();
            if (ancillary_tables.isEmpty()) {
                // no ancillary tables
                continue;
            }
            WhereConditions where;
            where.add(dbconst::MOVE_OFF_TABLET_FIELDNAME, 1);
            const QVector<int> task_pks = m_db.getSingleFieldAsIntList(
                        main_tablename, dbconst::PK_FIELDNAME, where);
            if (task_pks.isEmpty()) {
                // no tasks to be moved off
                continue;
            }
            const QString fk_task_fieldname = specimen->ancillaryTableFKToTaskFieldname();
            if (fk_task_fieldname.isEmpty()) {
                uifunc::stopApp(QString(
                    "Task %1 has ancillary tables but "
                    "ancillaryTableFKToTaskFieldname() returns empty")
                                .arg(main_tablename));
            }
            const QString task_paramholders = dbfunc::sqlParamHolders(task_pks.length());
            const ArgList task_args = dbfunc::argListFromIntList(task_pks);
            for (const QString& ancillary_table : ancillary_tables) {
                sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
                        .arg(delimit(ancillary_table),
                             delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
                             delimit(fk_task_fieldname),
                             task_paramholders);
#ifdef USE_BACKGROUND_DATABASE
                m_db.execNoAnswer(sql, task_args);
#else
                if (!m_db.exec(sql, task_args)) {
                    queryFail(sql);
                    return false;
                }
#endif
            }
        }
    }

    // ========================================================================
    // Step 3: Apply flags from anonymous tasks to their ancillary tables.
    // ========================================================================

    for (const TaskPtr& specimen : m_p_task_factory->allSpecimens()) {
        if (!specimen->isAnonymous()) {
            continue;
        }
        const QString main_tablename = specimen->tablename();
        const QStringList ancillary_tables = specimen->ancillaryTables();
        if (ancillary_tables.isEmpty()) {
            continue;
        }
        // Get PKs of all anonymous tasks being moved off
        WhereConditions where;
        where.add(dbconst::MOVE_OFF_TABLET_FIELDNAME, 1);
        const QVector<int> task_pks = m_db.getSingleFieldAsIntList(
                    main_tablename, dbconst::PK_FIELDNAME, where);
        if (task_pks.isEmpty()) {
            // no tasks to be moved off
            continue;
        }
        const QString fk_task_fieldname = specimen->ancillaryTableFKToTaskFieldname();
        if (fk_task_fieldname.isEmpty()) {
            uifunc::stopApp(QString(
                "Task %1 has ancillary tables but "
                "ancillaryTableFKToTaskFieldname() returns empty")
                            .arg(main_tablename));
        }
        const QString task_paramholders = dbfunc::sqlParamHolders(task_pks.length());
        const ArgList task_args = dbfunc::argListFromIntList(task_pks);
        for (const QString& ancillary_table : ancillary_tables) {
            QString sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
                    .arg(delimit(ancillary_table),
                         delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
                         delimit(fk_task_fieldname),
                         task_paramholders);
#ifdef USE_BACKGROUND_DATABASE
            m_db.execNoAnswer(sql, task_args);
#else
            if (!m_db.exec(sql, task_args)) {
                queryFail(sql);
                return false;
            }
#endif
        }
    }

    // ========================================================================
    // Step 4. BLOB table.
    // ========================================================================
    // Options here are:
    // - iterate through every task (and ancillary table), loading them from
    //   SQL to C++, and asking each what BLOB IDs they possess;
    // - store patient_id (or NULL) with each BLOB;
    // - iterate through each BLOB, looking for the move-off flag on the
    //   associated task/ancillary record.
    // The most efficient and simple is likely to be (3).

    // (a) For every BLOB...
    const QString sql = dbfunc::selectColumns(
                QStringList{dbconst::PK_FIELDNAME,
                            Blob::SRC_TABLE_FIELDNAME,
                            Blob::SRC_PK_FIELDNAME},
                Blob::TABLENAME);
    const QueryResult result = m_db.query(sql);
    if (!result.succeeded()) {
        queryFail(sql);
        return false;
    }
    const int nrows = result.nRows();
    for (int row = 0; row < nrows; ++row) {
        // (b) find the table/PK of the linked task (or other table)
        const int blob_pk = result.at(row, 0).toInt();
        const QString src_table = result.at(row, 1).toString();
        const int src_pk = result.at(row, 2).toInt();

        // (c) find the move-off flag for that linked task
        SqlArgs sub1_sqlargs(
                    dbfunc::selectColumns(
                        QStringList{dbconst::MOVE_OFF_TABLET_FIELDNAME},
                        src_table));
        WhereConditions sub1_where;
        sub1_where.add(dbconst::PK_FIELDNAME, src_pk);
        sub1_where.appendWhereClauseTo(sub1_sqlargs);
        const int move_off_int = m_db.fetchInt(sub1_sqlargs, -1);
        if (move_off_int == -1) {
            // No records matching
            qWarning().nospace()
                    << "BLOB refers to "
                    << src_table
                    << "."
                    << dbconst::PK_FIELDNAME
                    << " = "
                    << src_pk
                    << " but record doesn't exist!";
            continue;
        }
        if (move_off_int == 0) {
            // Record exists; task not marked for move-off
            continue;
        }

        // (d) set the BLOB's move-off flag
        const UpdateValues update_values{{dbconst::MOVE_OFF_TABLET_FIELDNAME, true}};
        SqlArgs sub2_sqlargs = dbfunc::updateColumns(update_values, Blob::TABLENAME);
        WhereConditions sub2_where;
        sub2_where.add(dbconst::PK_FIELDNAME, blob_pk);
        sub2_where.appendWhereClauseTo(sub2_sqlargs);
#ifdef USE_BACKGROUND_DATABASE
        m_db.execNoAnswer(sub2_sqlargs);
#else
        if (!m_db.exec(sub2_sqlargs)) {
            queryFail(sub2_sqlargs.sql);
            return false;
        }
#endif
    }
    return true;
}


bool NetworkManager::catalogueTablesForUpload()
{
    statusMessage(tr("Cataloguing tables for upload"));
    const QStringList recordwise_tables{Blob::TABLENAME};
    const QStringList patient_tables{Patient::TABLENAME,
                                     PatientIdNum::PATIENT_IDNUM_TABLENAME};
    const QStringList all_tables = m_db.getAllTables();
    const Version server_version = m_app.serverVersion();
    bool may_upload;
    bool server_has_table;  // table present on server
    Version min_client_version;  // server's requirement for min client version
    Version min_server_version;  // client's requirement for min server version
    for (const QString& table : all_tables) {
        const int n_records = m_db.count(table);
        may_upload = m_app.mayUploadTable(
                    table, server_version,
                    server_has_table, min_client_version, min_server_version);
        if (!may_upload) {
            if (server_has_table) {
                // This table requires a newer client than we are, OR we
                // require a newer server than it is.
                // If the table is empty, proceed. Otherwise, fail.
                if (server_version < min_server_version) {
                    if (n_records != 0) {
                        statusMessage(tr(
                            "ERROR: Table '%1' contains data; it is present "
                            "on the server but the client requires server "
                            "version >=%2; the server is version %3"
                        ).arg(table, min_server_version.toString(),
                              server_version.toString()));
                        return false;
                    }
                    statusMessage(tr(
                        "WARNING: Table '%1' is present on the server but "
                        "the client requires server version >=%2; the "
                        "server is version %3; proceeding ONLY BECAUSE "
                        "THIS TABLE IS EMPTY."
                    ).arg(table, min_server_version.toString(),
                          server_version.toString()));
                } else {
                    if (n_records != 0) {
                        statusMessage(tr(
                            "ERROR: Table '%1' contains data; it is present "
                            "on the server but the server requires client "
                            "version >=%2; you are using version %3"
                        ).arg(table, min_client_version.toString(),
                              camcopsversion::CAMCOPS_CLIENT_VERSION.toString()));
                        return false;
                    }
                    statusMessage(tr(
                        "WARNING: Table '%1' is present on the server but "
                        "the server requires client version >=%2; you are "
                        "using version %3; proceeding ONLY BECAUSE THIS "
                        "TABLE IS EMPTY."
                    ).arg(table, min_client_version.toString(),
                          camcopsversion::CAMCOPS_CLIENT_VERSION.toString()));
                }
            } else {
                // The table isn't on the server.
                if (n_records != 0) {
                    statusMessage(tr(
                        "ERROR: Table '%1' contains data but is absent on the "
                        "server. You probably need a newer server version. "
                        "(Once you have upgraded the server, re-register with "
                        "it.)").arg(table));
                    return false;
                }
                statusMessage(tr(
                    "WARNING: Table '%1' is absent on the server. You "
                    "probably need a newer server version. (Once you have "
                    "upgraded the server, re-register with it.) "
                    "Proceeding ONLY BECAUSE THIS TABLE IS EMPTY."
                ).arg(table));
            }
        }
        // How to upload?
        if (n_records == 0) {
            if (may_upload) {
                m_upload_empty_tables.append(table);
            }
        } else if (recordwise_tables.contains(table)) {
            m_upload_tables_to_send_recordwise.append(table);
        } else {
            m_upload_tables_to_send_whole.append(table);
        }

        // Whether to clear afterwards?
        // (Note that if we get here and may_upload is false, it must be the
        // case that the table is empty, in which case it doesn't matter
        // whether we clear it or not.)
        switch (m_upload_method) {
        case UploadMethod::Copy:
        case UploadMethod::Invalid:
#ifdef COMPILER_WANTS_DEFAULT_IN_EXHAUSTIVE_SWITCH
        default:
#endif
            break;
        case UploadMethod::MoveKeepingPatients:
            if (!patient_tables.contains(table)) {
                m_upload_tables_to_wipe.append(table);
            }
            break;
        case UploadMethod::Move:
            m_upload_tables_to_wipe.append(table);
            break;
        }
    }
    return true;
}


bool NetworkManager::isServerVersionOK() const
{
    statusMessage(tr("Checking server CamCOPS version"));

    if (!serverVersionNewEnough()) {
        return false;
    }
    if (!serverVersionMatchesStored()) {
        return false;
    }
    statusMessage(tr("... OK"));
    return true;
}


bool NetworkManager::serverVersionNewEnough() const
{
    const Version server_version = serverVersionFromReply();
    bool new_enough = server_version >= camcopsversion::MINIMUM_SERVER_VERSION;

    if (!new_enough) {
        statusMessage(tr("Server CamCOPS version (%1) is too old; must be >= %2")
                      .arg(server_version.toString(),
                           camcopsversion::MINIMUM_SERVER_VERSION.toString()));
    }

    return new_enough;
}


bool NetworkManager::serverVersionMatchesStored() const
{
    const Version server_version = serverVersionFromReply();
    const Version stored_server_version = m_app.serverVersion();

    bool matches = server_version == stored_server_version;

    if (!matches) {
        statusMessage(tr("Server version (%1) doesn't match stored version (%2).")
                      .arg(server_version.toString(),
                           stored_server_version.toString()) +
                      txtPleaseRefetchServerInfo());
    }

    return matches;
}


Version NetworkManager::serverVersionFromReply() const
{
    return Version(m_reply_dict[KEY_SERVER_CAMCOPS_VERSION]);
}


bool NetworkManager::arePoliciesOK() const
{
    statusMessage(tr("Checking ID policies match server"));
    const QString local_upload = m_app.uploadPolicy().pretty();
    const QString local_finalize = m_app.finalizePolicy().pretty();
    const QString server_upload = IdPolicy(m_reply_dict[KEY_ID_POLICY_UPLOAD]).pretty();
    const QString server_finalize = IdPolicy(m_reply_dict[KEY_ID_POLICY_FINALIZE]).pretty();
    bool ok = true;
    if (local_upload != server_upload) {
        statusMessage(tr("Local upload policy [%1] doesn't match server's [%2].")
                      .arg(local_upload, server_upload) +
                      txtPleaseRefetchServerInfo());
        ok = false;
    }
    if (local_finalize != server_finalize) {
        statusMessage(tr("Local finalize policy [%1] doesn't match server's [%2].")
                      .arg(local_finalize, server_finalize) +
                      txtPleaseRefetchServerInfo());
        ok = false;
    }
    if (ok) {
        statusMessage(tr("... OK"));
    }
    return ok;
}


bool NetworkManager::areDescriptionsOK() const
{
    statusMessage(tr("Checking ID descriptions match server"));
    bool idnums_all_on_server = true;
    bool descriptions_match = true;
    QVector<int> which_idnums_on_server;
    QVector<IdNumDescriptionPtr> iddescriptions = m_app.getAllIdDescriptions();
    for (const IdNumDescriptionPtr& iddesc : iddescriptions) {
        const int n = iddesc->whichIdNum();
        const QString key_desc = KEYSPEC_ID_DESCRIPTION.arg(n);
        const QString key_shortdesc = KEYSPEC_ID_SHORT_DESCRIPTION.arg(n);
        const QString key_validation = KEYSPEC_ID_VALIDATION_METHOD.arg(n);
        if (m_reply_dict.contains(key_desc) &&
                m_reply_dict.contains(key_shortdesc)) {
            const QString local_desc = iddesc->description();
            const QString local_shortdesc = iddesc->shortDescription();
            const QString server_desc = m_reply_dict[key_desc];
            const QString server_shortdesc = m_reply_dict[key_shortdesc];
            descriptions_match = descriptions_match &&
                    local_desc == server_desc &&
                    local_shortdesc == server_shortdesc;
            which_idnums_on_server.append(n);
            // Old servers may not provide the ID number validator info.
            // But new ones will (v2.2.8+), in which case we'll check.
            if (m_reply_dict.contains(key_validation)) {
                const QString local_validation = iddesc->validationMethod();
                const QString server_validation = m_reply_dict[key_validation];
                descriptions_match = descriptions_match &&
                        local_validation == server_validation;
            }
        } else {
            idnums_all_on_server = false;
        }
    }
    QVector<int> which_idnums_on_tablet = whichIdnumsUsedOnTablet();
    QVector<int> extra_idnums_on_tablet = containers::setSubtract(
                which_idnums_on_tablet, which_idnums_on_server);
    const bool extra_idnums = !extra_idnums_on_tablet.isEmpty();

    const bool ok = descriptions_match && idnums_all_on_server && !extra_idnums;
    if (ok) {
        statusMessage(tr("... OK"));
    } else if (!idnums_all_on_server) {
        statusMessage(tr("Some ID numbers defined on the tablet are absent on "
                         "the server!") + txtPleaseRefetchServerInfo());
    } else if (!descriptions_match) {
        statusMessage(tr("Descriptions do not match!") + txtPleaseRefetchServerInfo());
    } else if (extra_idnums) {
        statusMessage(tr(
                "ID numbers %1 are used on the tablet but not defined "
                "on the server! Please edit patient records to remove "
                "them.").arg(convert::numericVectorToCsvString(
                                 extra_idnums_on_tablet)));
    } else {
        statusMessage("Logic bug: something not OK but don't know why");
    }
    return ok;
}


QVector<int> NetworkManager::whichIdnumsUsedOnTablet() const
{
    const QString sql = QString("SELECT DISTINCT %1 FROM %2 ORDER BY %1")
            .arg(delimit(PatientIdNum::FN_WHICH_IDNUM),
                 delimit(PatientIdNum::PATIENT_IDNUM_TABLENAME));
    const QueryResult result = m_db.query(sql);
    return result.firstColumnAsIntList();
}


bool NetworkManager::pruneRecordwisePks()
{
    if (!m_reply_dict.contains(KEY_RESULT)) {
        statusMessage(tr("Server's reply was missing the key: ") + KEY_RESULT);
        return false;
    }
    const QString reply = m_reply_dict[KEY_RESULT];
    statusMessage(tr("Server requests only PKs: ") + reply);
    m_upload_recordwise_pks_to_send = convert::csvStringToIntVector(reply);
    m_upload_n_records = m_upload_recordwise_pks_to_send.size();
    m_recordwise_pks_pruned = true;
    return true;
}


void NetworkManager::wipeTables()
{
    DbNestableTransaction trans(m_db);

    // Plain wipes, of entire tables
    for (const QString& wipe_table : m_upload_tables_to_wipe) {
        // Note: m_upload_tables_to_wipe will contain the patient table if
        // we're moving everything; see catalogueTablesForUpload()
        statusMessage(tr("Wiping table: ") + wipe_table);
        if (!m_db.deleteFrom(wipe_table)) {
            statusMessage(tr("... failed to delete!"));
            trans.fail();
            fail();
        }
    }

    // Selective wipes: tasks, patients, ancillary tables...
    // - We wipe: (a) records in tasks whose patient record was marked for
    //   moving (and whose _move_off_tablet field was propagated through to the
    //   task, as above); (b) any anonymous tasks specifically marked for
    //   moving; (c) any ancillary tasks of the above.
    // - The simplest way is to go through ALL tables (task + ancillary +
    //   patient + patient ID...) and delete records for which
    //   "_move_off_tablet" is set (skipping any tables we've already wiped
    //   completely, for speed).
    if (m_upload_method != UploadMethod::Move) {
        // ... if we were doing a Move, *everything* has gone
        statusMessage(tr("Wiping any specifically requested patients and/or anonymous tasks"));
        WhereConditions where_move_off;
        where_move_off.add(dbconst::MOVE_OFF_TABLET_FIELDNAME, 1);

        const QStringList all_tables = m_db.getAllTables();
        for (const QString& tablename : all_tables) {
            if (m_upload_tables_to_wipe.contains(tablename)) {
                continue;  // Already totally wiped
            }
            m_db.deleteFrom(tablename, where_move_off);
        }
    }
}


void NetworkManager::queryFail(const QString& sql)
{
    statusMessage(tr("Query failed: ") + sql);
    fail();
}


void NetworkManager::queryFailClearingMoveOffFlag(const QString& tablename)
{
    queryFail(tr("... trying to clear move-off-tablet flag for table:") +
              " " + tablename);
}


bool NetworkManager::clearMoveOffTabletFlag(const QString& tablename)
{
    // 1. Clear all
    const QString sql = QString("UPDATE %1 SET %2 = 0")
            .arg(delimit(tablename),
                 delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME));
#ifdef USE_BACKGROUND_DATABASE
    m_db.execNoAnswer(sql);
    return true;
#else
    return m_db.exec(sql);
#endif
}


bool NetworkManager::pruneDeadBlobs()
{
    using dbfunc::delimit;

    const QStringList all_tables = m_db.getAllTables();
    QVector<int> bad_blob_pks;

    // For all BLOBs...
    QString sql = dbfunc::selectColumns(
                QStringList{dbconst::PK_FIELDNAME,
                            Blob::SRC_TABLE_FIELDNAME,
                            Blob::SRC_PK_FIELDNAME},
                Blob::TABLENAME);
    const QueryResult result = m_db.query(sql);
    if (!result.succeeded()) {
        queryFail(sql);
        return false;
    }
    const int nrows = result.nRows();
    for (int row = 0; row < nrows; ++row) {
        const int blob_pk = result.at(row, 0).toInt();
        const QString src_table = result.at(row, 1).toString();
        const int src_pk = result.at(row, 2).toInt();
        if (src_pk == dbconst::NONEXISTENT_PK) {
            continue;
        }
        // Does our BLOB refer to something non-existent?
        if (!all_tables.contains(src_table) ||
                !m_db.existsByPk(src_table, dbconst::PK_FIELDNAME, src_pk)) {
            bad_blob_pks.append(blob_pk);
        }
    }

    const int n_bad_blobs = bad_blob_pks.length();
    statusMessage(tr("... %1 defunct BLOBs").arg(n_bad_blobs));
    if (n_bad_blobs == 0) {
        return true;
    }

    qWarning() << "Deleting defunct BLOBs with PKs:" << bad_blob_pks;
    const QString paramholders = dbfunc::sqlParamHolders(n_bad_blobs);
    sql = QString("DELETE FROM %1 WHERE %2 IN (%3)")
            .arg(delimit(Blob::TABLENAME),
                 delimit(dbconst::PK_FIELDNAME),
                 paramholders);
    ArgList args = dbfunc::argListFromIntList(bad_blob_pks);
#ifdef USE_BACKGROUND_DATABASE
    m_db.execNoAnswer(sql, args);
#else
    if (!m_db.exec(sql, args)) {
        queryFail(sql);
        return false;
    }
#endif
    return true;
}


// ============================================================================
// One-step upload
// ============================================================================

bool NetworkManager::serverSupportsOneStepUpload() const
{
    return m_app.serverVersion() >= MIN_SERVER_VERSION_FOR_ONE_STEP_UPLOAD;
}


bool NetworkManager::shouldUseOneStepUpload() const
{
    if (m_app.isSingleUserMode()) {
        return false;
    }

    if (!serverSupportsOneStepUpload()) {
        return false;
    }
    const int method = m_app.varInt(varconst::UPLOAD_METHOD);
    // Can't use switch; const int is not const enough.
    // Can't use enums; have to store in an int field.
    if (method == varconst::UPLOAD_METHOD_ONESTEP) {
        return true;
    } else if (method == varconst::UPLOAD_METHOD_BYSIZE) {
        return m_db.approximateDatabaseSize() <= m_app.varLongLong(
                    varconst::MAX_DBSIZE_FOR_ONESTEP_UPLOAD);
    } else {
        // e.g. varconst::UPLOAD_METHOD_MULTISTEP or bad value
        return false;
    }
}


void NetworkManager::uploadOneStep()
{
    statusMessage(tr("Starting one-step upload"));
    const bool preserving = m_upload_method != UploadMethod::Copy;
    Dict dict;
    dict[KEY_OPERATION] = OP_UPLOAD_ENTIRE_DATABASE;
    dict[KEY_FINALIZING] = preserving ? ENCODE_TRUE : ENCODE_FALSE;
    dict[KEY_PKNAMEINFO] = getPkInfoAsJson();
    dict[KEY_DBDATA] = m_db.getDatabaseAsJson();
#ifdef DEBUG_JSON
    qDebug().noquote() << Q_FUNC_INFO << dict[KEY_DBDATA];
#endif
    serverPost(dict, &NetworkManager::uploadNext);
}


QString NetworkManager::getPkInfoAsJson()
{
    QJsonObject root;
    for (const QString& tablename : m_db.getAllTables()) {
        root[tablename] = dbconst::PK_FIELDNAME;  // they're all the same...
    }
    const QJsonDocument jsondoc(root);
    return jsondoc.toJson(QJsonDocument::Compact);
}


// ============================================================================
// Translatable text
// ============================================================================

QString NetworkManager::txtPleaseRefetchServerInfo()
{
    // return " " + tr("Please re-register with the server.");
    return " " + tr("Please re-fetch server information.");
}

// ============================================================================
// Patient registration
// ============================================================================

void NetworkManager::registerPatient()
{
    Dict dict;
    dict[KEY_OPERATION] = OP_REGISTER_PATIENT;
    dict[KEY_PATIENT_PROQUINT] = m_app.varString(
        varconst::SINGLE_PATIENT_PROQUINT
    );

    const bool include_user = false;
    serverPost(dict, &NetworkManager::registerPatientSub1, include_user);
}

void NetworkManager::registerPatientSub1(QNetworkReply* reply)
{
    if (!processServerReply(reply)) {
        return;
    }

    setUserDetails();
    if (!createSinglePatient()) {
        return;
    }

    if (!setIpUseInfo()) {
        return;
    }

    registerWithServer();
}


void NetworkManager::setUserDetails()
{
    if (m_reply_dict.contains(KEY_USER)) {
        m_app.setEncryptedServerPassword(m_reply_dict[KEY_PASSWORD]);
        m_app.setVar(varconst::SERVER_USERNAME, m_reply_dict[KEY_USER]);
    }
}


bool NetworkManager::createSinglePatient()
{
    QJsonParseError error;

    const QJsonDocument doc = QJsonDocument::fromJson(
        m_reply_dict[KEY_PATIENT_INFO].toUtf8(), &error
    );
    if (doc.isNull()) {
        const QString message = tr("Failed to parse patient info: %1").arg(
            error.errorString()
        );
        statusMessage(message);
        fail(ErrorCode::JsonParseError, message);
        return false;
    }

    // Consistent with uploading patients but only one element
    // in the array
    const QJsonArray patients_json_array = doc.array();
    const QJsonObject patient_json = patients_json_array.first().toObject();

    PatientPtr patient = PatientPtr(
        new Patient(m_app, m_app.db(), patient_json)
    );
    patient->save();
    m_app.setSinglePatientId(patient->id());

    patient->addIdNums(patient_json);

    return true;
}


bool NetworkManager::setIpUseInfo()
{
    QJsonParseError error;

    QJsonDocument doc = QJsonDocument::fromJson(
        m_reply_dict[KEY_IP_USE_INFO].toUtf8(), &error
    );

    if (doc.isNull()) {
        const QString message = tr(
            "Failed to parse intellectual property use info: %1"
        ).arg(error.errorString());
        statusMessage(message);
        fail(ErrorCode::JsonParseError, message);

        return false;
    }

    const QJsonObject ip_use_info = doc.object();

    m_app.setVar(varconst::IP_USE_CLINICAL,
                 ip_use_info.value(KEY_IP_USE_CLINICAL));
    m_app.setVar(varconst::IP_USE_COMMERCIAL,
                 ip_use_info.value(KEY_IP_USE_COMMERCIAL));
    m_app.setVar(varconst::IP_USE_EDUCATIONAL,
                 ip_use_info.value(KEY_IP_USE_EDUCATIONAL));
    m_app.setVar(varconst::IP_USE_RESEARCH,
                 ip_use_info.value(KEY_IP_USE_RESEARCH));

    return true;
}